YanAdjeNole commited on
Commit
c5ed2c0
·
verified ·
1 Parent(s): 4ea39a1

Update src/App_New.tsx

Browse files
Files changed (1) hide show
  1. src/App_New.tsx +253 -226
src/App_New.tsx CHANGED
@@ -3,39 +3,55 @@ import {
3
  LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, Legend, CartesianGrid,
4
  AreaChart, Area, BarChart, Bar
5
  } from "recharts";
 
 
 
 
 
 
 
6
 
7
  type SeriesPoint = { date: string; close: number[] };
8
- type DataDict = Record<string, SeriesPoint[]>;
9
 
10
  const MAX_DAYS = 67;
11
  const maxSteps = 60;
12
  const START_DAY = 7;
13
 
14
- /** 取得当日收盘价:数组最后一个元素 */
15
  function latestClose(p: SeriesPoint): number {
16
  const arr = p.close;
17
- if (!Array.isArray(arr) || arr.length === 0) {
18
- throw new Error(`Empty close array at ${p.date}`);
19
- }
20
  const last = arr[arr.length - 1];
21
- if (!Number.isFinite(last)) {
22
- throw new Error(`Invalid close value at ${p.date}`);
23
- }
24
  return last;
25
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
 
27
- /** 生成示例数据:close 为滚动窗口数组,窗口最大 500 */
28
  function generateFakeJSON(maxLen = 500): DataDict {
29
  const randTicker = () => Math.random().toString(36).substring(2, 6).toUpperCase();
30
  const tickers = Array.from({ length: 5 }, () => randTicker());
31
-
32
  const start = new Date("2024-01-02T00:00:00Z");
33
  const dates = Array.from({ length: 67 }, (_, i) => {
34
- const d = new Date(start);
35
- d.setDate(start.getDate() + i);
36
  return d.toISOString().slice(0, 10);
37
  });
38
-
39
  const out: DataDict = {};
40
  for (let a = 0; a < 5; a++) {
41
  const ticker = tickers[a];
@@ -44,7 +60,6 @@ function generateFakeJSON(maxLen = 500): DataDict {
44
  const sigma = 0.15 + Math.random() * 0.35;
45
  const window: number[] = [];
46
  const series: SeriesPoint[] = [];
47
-
48
  for (let i = 0; i < dates.length; i++) {
49
  if (i > 0) {
50
  const z = (Math.random() - 0.5) * 1.6 + (Math.random() - 0.5) * 1.6;
@@ -60,7 +75,13 @@ function generateFakeJSON(maxLen = 500): DataDict {
60
  return out;
61
  }
62
 
 
63
  export default function App() {
 
 
 
 
 
64
  const [rawData, setRawData] = useState<DataDict>({});
65
  const [assets, setAssets] = useState<string[]>([]);
66
  const [dates, setDates] = useState<string[]>([]);
@@ -74,39 +95,179 @@ export default function App() {
74
  const [confirming, setConfirming] = useState(false);
75
  const [finalSaved, setFinalSaved] = useState(false);
76
 
77
- // 分时视图开关 & 所选日期索引
78
  const [intradayMode, setIntradayMode] = useState(false);
79
- // intradayDayIdx 为“在 dates 数组里的下标”;默认跟随窗口最后一天
80
  const [intradayDayIdx, setIntradayDayIdx] = useState<number | null>(null);
81
 
82
- // 当窗口推进或数据变更时,若未选择指定日期,则默认使用窗口最后一天
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83
  useEffect(() => {
84
  if (!dates.length) return;
85
  const lastIdx = Math.min(windowLen - 1, dates.length - 1);
86
- if (intradayDayIdx === null || intradayDayIdx > lastIdx) {
87
- setIntradayDayIdx(lastIdx);
88
- }
89
  }, [dates, windowLen, intradayDayIdx]);
90
 
91
- useEffect(() => {
92
- if (Object.keys(rawData).length === 0) {
93
- const example = generateFakeJSON();
94
- const keys = Object.keys(example);
95
- const first = example[keys[0]];
96
- setRawData(example);
97
- setAssets(keys);
98
- setDates(first.map((d) => d.date));
99
- setStep(1);
100
- setWindowLen(START_DAY);
101
- setSelections([]);
102
- setSelectedAsset(null);
103
- setMessage(`Loaded example: ${keys.length} assets, ${first.length} days.`);
104
- }
105
- }, []);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
106
 
 
107
  const isFinal = windowLen >= MAX_DAYS || selections.length >= maxSteps;
108
 
109
- // 日线数据(归一到第0天)
110
  const windowData = useMemo(() => {
111
  if (!dates.length || windowLen < 2) return [] as any[];
112
  const sliceDates = dates.slice(0, windowLen);
@@ -121,40 +282,25 @@ export default function App() {
121
  });
122
  }, [assets, dates, windowLen, rawData]);
123
 
124
- // 可见日期列表 & 当前分时查看日期
125
  const visibleDates = useMemo(() => dates.slice(0, Math.max(0, windowLen)), [dates, windowLen]);
126
  const activeIntradayIdx = intradayDayIdx ?? Math.min(windowLen - 1, dates.length - 1);
127
  const activeIntradayDate = visibleDates[activeIntradayIdx] ?? "";
128
 
129
- // 分时数据:展示“所选日期”的分钟级走势(当日内归一)
130
  const intradayData = useMemo(() => {
131
  if (!dates.length || activeIntradayIdx == null || activeIntradayIdx < 0) return [] as any[];
132
-
133
- // 当日各资产的分钟序列长度最大值
134
- const maxBars = assets.reduce((m, a) => {
135
- const arr = rawData[a]?.[activeIntradayIdx]?.close ?? [];
136
- return Math.max(m, arr.length);
137
- }, 0);
138
-
139
- // 当日内归一:每个资产以当日首笔为1
140
  const dayFirstPrice: Record<string, number> = {};
141
  assets.forEach(a => {
142
  const arr = rawData[a]?.[activeIntradayIdx]?.close ?? [];
143
  dayFirstPrice[a] = arr.length ? arr[0] : 1;
144
  });
145
-
146
  const rows: any[] = [];
147
  for (let i = 0; i < maxBars; i++) {
148
  const row: Record<string, any> = { idx: i + 1 };
149
  assets.forEach(a => {
150
  const arr = rawData[a]?.[activeIntradayIdx]?.close ?? [];
151
  const val = arr[i];
152
- if (typeof val === "number") {
153
- const base = dayFirstPrice[a] || 1;
154
- row[a] = base ? val / base : null;
155
- } else {
156
- row[a] = null; // 该分钟无数据则断线
157
- }
158
  });
159
  rows.push(row);
160
  }
@@ -173,17 +319,11 @@ export default function App() {
173
  const example = generateFakeJSON();
174
  const keys = Object.keys(example);
175
  const first = example[keys[0]];
176
- setRawData(example);
177
- setAssets(keys);
178
- setDates(first.map((d) => d.date));
179
- setStep(1);
180
- setWindowLen(START_DAY);
181
- setSelections([]);
182
- setSelectedAsset(null);
183
- setIntradayMode(false);
184
- setIntradayDayIdx(null);
185
- setMessage(`Loaded example: ${keys.length} assets, ${first.length} days.`);
186
- try { localStorage.removeItem("asset_experiment_selections"); } catch {}
187
  }
188
 
189
  function resetSession() {
@@ -194,71 +334,7 @@ export default function App() {
194
  setMessage("Session reset.");
195
  setIntradayMode(false);
196
  setIntradayDayIdx(null);
197
- try { localStorage.removeItem("asset_experiment_selections"); } catch {}
198
- }
199
-
200
- /** 仅接受新格式:close 必须是数组,长度最多 500,元素为数值 */
201
- function onFile(e: any) {
202
- const f = e.target.files?.[0];
203
- if (!f) return;
204
- const reader = new FileReader();
205
- reader.onload = () => {
206
- try {
207
- const json: DataDict = JSON.parse(String(reader.result));
208
- const keys = Object.keys(json);
209
- if (keys.length === 0) throw new Error("Empty dataset");
210
-
211
- const firstArr = json[keys[0]];
212
- if (!Array.isArray(firstArr) || !firstArr[0]?.date || !Array.isArray(firstArr[0]?.close)) {
213
- throw new Error("Invalid series format. Need [{date, close:number[]}]");
214
- }
215
-
216
- const refDates = firstArr.map(p => p.date);
217
- const checkPoint = (p: SeriesPoint) => {
218
- if (!p?.date) throw new Error("Missing date");
219
- if (!Array.isArray(p.close) || p.close.length === 0) {
220
- throw new Error(`Empty close array at ${p.date}`);
221
- }
222
- if (p.close.length > 500) {
223
- throw new Error(`close array exceeds 500 at ${p.date}`);
224
- }
225
- for (const v of p.close) {
226
- if (typeof v !== "number" || !Number.isFinite(v)) {
227
- throw new Error(`Non numeric close in array at ${p.date}`);
228
- }
229
- }
230
- };
231
-
232
- for (const k of keys) {
233
- const arr = json[k];
234
- if (!Array.isArray(arr) || arr.length !== firstArr.length) {
235
- throw new Error("All series must have the same length");
236
- }
237
- for (let i = 0; i < arr.length; i++) {
238
- const p = arr[i];
239
- checkPoint(p);
240
- if (p.date !== refDates[i]) {
241
- throw new Error("Date misalignment across assets");
242
- }
243
- }
244
- }
245
-
246
- setRawData(json);
247
- setAssets(keys);
248
- setDates(firstArr.map((d) => d.date));
249
- setStep(1);
250
- setWindowLen(START_DAY);
251
- setSelections([]);
252
- setSelectedAsset(null);
253
- setIntradayMode(false);
254
- setIntradayDayIdx(null);
255
- setMessage(`Loaded file: ${keys.length} assets, ${firstArr.length} days.`);
256
- try { localStorage.removeItem("asset_experiment_selections"); } catch {}
257
- } catch (err: any) {
258
- setMessage("Failed to parse JSON: " + err.message);
259
- }
260
- };
261
- reader.readAsText(f);
262
  }
263
 
264
  function exportLog() {
@@ -266,28 +342,19 @@ export default function App() {
266
  const url = URL.createObjectURL(blob);
267
  const a = document.createElement("a");
268
  a.href = url;
269
- a.download = `selections_${new Date().toISOString().slice(0,10)}.json`;
270
  a.click();
271
  URL.revokeObjectURL(url);
272
  }
273
 
274
  function confirmSelection() {
275
  if (confirming) return;
276
- if (!selectedAsset) {
277
- setMessage("Select a line first.");
278
- return;
279
- }
280
  setConfirming(true);
281
-
282
  const res = realizedNextDayReturn(selectedAsset);
283
- if (!res) {
284
- setMessage("No more data available.");
285
- setConfirming(false);
286
- return;
287
- }
288
  const entry = { step, date: res.date, asset: selectedAsset, ret: res.ret };
289
  setSelections((prev) => [...prev, entry]);
290
-
291
  setWindowLen((w) => Math.min(w + 1, MAX_DAYS));
292
  setStep((s) => s + 1);
293
  setSelectedAsset(null);
@@ -297,10 +364,7 @@ export default function App() {
297
 
298
  const portfolioSeries = useMemo(() => {
299
  let value = 1;
300
- const pts = selections.map((s) => {
301
- value *= 1 + s.ret;
302
- return { step: s.step, date: s.date, value };
303
- });
304
  return [{ step: 0, date: "start", value: 1 }, ...pts];
305
  }, [selections]);
306
 
@@ -322,13 +386,12 @@ export default function App() {
322
  const countsAll = assets.reduce((acc: Record<string, number>, a: string) => { acc[a] = 0; return acc; }, {} as Record<string, number>);
323
  selections.forEach((s) => { countsAll[s.asset] = (countsAll[s.asset] || 0) + 1; });
324
  const rankAll = assets.map((a) => ({ asset: a, votes: countsAll[a] || 0 })).sort((x, y) => y.votes - x.votes);
325
-
326
  let value = 1;
327
  const portfolio = selections.map((s) => { value *= 1 + s.ret; return { step: s.step, date: s.date, value }; });
328
-
329
- const lastCols = Array.from({ length: Math.min(30, lastStep ? lastStep - start30 + 1 : 0) }, (_, i) => start30 + i);
 
330
  const heatGrid = assets.map((a) => ({ asset: a, cells: lastCols.map((c) => (selections.some((s) => s.asset === a && s.step === c) ? 1 : 0)) }));
331
-
332
  const rets = selections.map((s) => s.ret);
333
  const N = rets.length;
334
  const cum = portfolio.at(-1)?.value ?? 1;
@@ -339,7 +402,7 @@ export default function App() {
339
  const wins = rets.filter((r) => r > 0).length;
340
 
341
  return {
342
- meta: { saved_at: new Date().toISOString(), start_day: START_DAY, max_days: MAX_DAYS, max_steps: maxSteps },
343
  assets,
344
  dates,
345
  selections,
@@ -351,6 +414,7 @@ export default function App() {
351
  }
352
 
353
  useEffect(() => {
 
354
  if (isFinal && !finalSaved) {
355
  try {
356
  const payload = buildFinalPayload();
@@ -359,67 +423,50 @@ export default function App() {
359
  const url = URL.createObjectURL(blob);
360
  const a = document.createElement("a");
361
  a.href = url;
362
- a.download = `run_summary_${new Date().toISOString().slice(0, 10)}.json`;
363
  a.click();
364
  URL.revokeObjectURL(url);
365
  setFinalSaved(true);
366
- } catch (e) {
367
- console.warn("Failed to save final JSON:", e);
368
- }
369
  }
370
- }, [isFinal, finalSaved, assets, selections, dates]);
 
371
 
372
  useEffect(() => {
373
  function onKey(e: KeyboardEvent) {
374
  const tag = (e.target && (e.target as HTMLElement).tagName) || "";
375
  if (tag === "INPUT" || tag === "TEXTAREA") return;
376
  const idx = parseInt((e as any).key, 10) - 1;
377
- if (!Number.isNaN(idx) && idx >= 0 && idx < assets.length) {
378
- setSelectedAsset(assets[idx]);
379
- }
380
- if ((e as any).key === "Enter" && Boolean(selectedAsset) && windowLen < MAX_DAYS) {
381
- confirmSelection();
382
- }
383
  }
384
  window.addEventListener("keydown", onKey);
385
  return () => window.removeEventListener("keydown", onKey);
386
  }, [assets, selectedAsset, windowLen]);
387
 
388
- if (isFinal) {
389
- return <FinalSummary assets={assets} selections={selections} />;
 
 
 
 
 
 
 
 
 
390
  }
391
 
392
- // 分时控制条(只在 intradayMode 下显示)
393
  const IntradayControls = () => (
394
  <div className="flex items-center gap-2">
395
  <span className="text-xs px-2 py-1 rounded bg-indigo-50 text-indigo-700">
396
- Intraday • {activeIntradayDate || "-"}
397
  </span>
398
- <button
399
- onClick={() => setIntradayDayIdx(i => (i == null ? 0 : Math.max(0, i - 1)))}
400
- className="text-xs px-2 py-1 rounded-xl border border-gray-200 hover:bg-gray-50"
401
- disabled={activeIntradayIdx <= 0}
402
- title="Prev day"
403
- >
404
-
405
- </button>
406
- <select
407
- className="text-xs px-2 py-1 rounded-xl border border-gray-200"
408
- value={activeIntradayIdx}
409
- onChange={(e) => setIntradayDayIdx(Number(e.target.value))}
410
- >
411
- {visibleDates.map((d, idx) => (
412
- <option key={d} value={idx}>{d}</option>
413
- ))}
414
  </select>
415
- <button
416
- onClick={() => setIntradayDayIdx(i => (i == null ? 0 : Math.min(visibleDates.length - 1, i + 1)))}
417
- className="text-xs px-2 py-1 rounded-xl border border-gray-200 hover:bg-gray-50"
418
- disabled={activeIntradayIdx >= visibleDates.length - 1}
419
- title="Next day"
420
- >
421
-
422
- </button>
423
  </div>
424
  );
425
 
@@ -428,21 +475,19 @@ export default function App() {
428
  <div className="flex flex-wrap justify-between items-center gap-3">
429
  <div className="flex items-center gap-2">
430
  <h1 className="text-xl font-semibold">Asset Choice Simulation</h1>
 
 
431
  <span className="text-xs text-gray-500">Day {windowLen} / {MAX_DAYS}</span>
432
  {intradayMode && <IntradayControls />}
433
  </div>
434
  <div className="flex items-center gap-2">
435
  <input type="file" accept="application/json" onChange={onFile} className="text-sm" />
436
  <button onClick={loadExample} className="text-sm px-3 py-1.5 rounded-xl bg-blue-100 text-blue-800 hover:bg-blue-200">Load Example</button>
437
- <button onClick={resetSession} className="text-sm px-3 py-1.5 rounded-xl bg-gray-200 hover:bg-gray-300">Reset</button>
438
- <button
439
- onClick={() => setIntradayMode(m => !m)}
440
- className="text-sm px-3 py-1.5 rounded-xl bg-indigo-100 text-indigo-800 hover:bg-indigo-200"
441
- title="Toggle intraday view"
442
- >
443
  {intradayMode ? "View Daily" : "View Intraday"}
444
  </button>
445
- <button onClick={exportLog} className="text-sm px-3 py-1.5 rounded-xl bg-gray-900 text-white hover:bg-black">Export Log</button>
446
  </div>
447
  </div>
448
 
@@ -460,12 +505,10 @@ export default function App() {
460
  {i+1}. {a}
461
  </button>
462
  ))}
463
- {assets.length>0 && (
464
- <span className="text-xs text-gray-500 ml-1">Hotkeys: 1 to {assets.length}, Enter to confirm</span>
465
- )}
466
  </div>
467
 
468
- {/* 图表:日线 / 分时切换 */}
469
  <div className="h-80">
470
  <ResponsiveContainer width="100%" height="100%">
471
  {intradayMode ? (
@@ -478,7 +521,7 @@ export default function App() {
478
  {assets.map((a, i) => (
479
  <Line
480
  key={a}
481
- type="monotone"
482
  dataKey={a}
483
  strokeWidth={selectedAsset === a ? 5 : hoverAsset === a ? 4 : 2.5}
484
  strokeOpacity={selectedAsset && selectedAsset !== a ? 0.3 : 1}
@@ -502,7 +545,7 @@ export default function App() {
502
  {assets.map((a, i) => (
503
  <Line
504
  key={a}
505
- type="monotone"
506
  dataKey={a}
507
  strokeWidth={selectedAsset === a ? 5 : hoverAsset === a ? 4 : 2.5}
508
  strokeOpacity={selectedAsset && selectedAsset !== a ? 0.3 : 1}
@@ -523,11 +566,7 @@ export default function App() {
523
  <div className="text-sm text-gray-600">
524
  Selected: {selectedAsset ?? "(none)"} {message && <span className="ml-2 text-gray-500">{message}</span>}
525
  </div>
526
- <button
527
- onClick={confirmSelection}
528
- disabled={!selectedAsset || windowLen >= MAX_DAYS}
529
- className={`px-4 py-2 rounded-xl ${selectedAsset && windowLen < MAX_DAYS ? "bg-blue-600 text-white" : "bg-gray-200 text-gray-500"}`}
530
- >
531
  Confirm & Next Day →
532
  </button>
533
  </div>
@@ -583,7 +622,7 @@ export default function App() {
583
  );
584
  }
585
 
586
- /** 最终总结组件 */
587
  function FinalSummary({ assets, selections }: { assets: string[]; selections: { step: number; date: string; asset: string; ret: number }[] }) {
588
  const rets = selections.map(s=>s.ret);
589
  const N = rets.length;
@@ -596,22 +635,16 @@ function FinalSummary({ assets, selections }: { assets: string[]; selections: {
596
 
597
  const countsAll: Record<string, number> = assets.reduce((acc: any,a: string)=>{acc[a]=0;return acc;},{} as Record<string, number>);
598
  selections.forEach(s=>{ countsAll[s.asset] = (countsAll[s.asset]||0)+1; });
599
- const rankAll = assets
600
- .map(a=>({ asset:a, votes: countsAll[a]||0 }))
601
- .sort((x,y)=> y.votes - x.votes);
602
 
603
  const lastStep = selections.reduce((m,s)=>Math.max(m,s.step),0);
604
  const start30 = Math.max(1, lastStep - 30 + 1);
605
  const cols = Array.from({length: Math.min(30,lastStep ? lastStep - start30 + 1 : 0)}, (_,i)=> start30 + i);
606
- const grid = assets.map(a=>({
607
- asset:a,
608
- cells: cols.map(c => selections.some(s=> s.asset===a && s.step===c) ? 1 : 0)
609
- }));
610
 
611
  return (
612
  <div className="p-6 bg-gray-50 min-h-screen flex flex-col gap-6">
613
  <h1 className="text-xl font-semibold">Final Summary</h1>
614
-
615
  <div className="bg-white p-4 rounded-2xl shadow">
616
  <h2 className="font-medium mb-2">Overall Metrics</h2>
617
  <ul className="text-sm text-gray-700 space-y-1">
@@ -619,7 +652,7 @@ function FinalSummary({ assets, selections }: { assets: string[]; selections: {
619
  <li>Win Rate: {(N? (wins/N*100):0).toFixed(1)}%</li>
620
  <li>Cumulative Return: {(cum*100).toFixed(2)}%</li>
621
  <li>Volatility: {(stdev*100).toFixed(2)}%</li>
622
- <li>Sharpe: {sharpe.toFixed(2)}</li>
623
  <li>Top Preference: {rankAll[0]?.asset ?? "-"} ({rankAll[0]?.votes ?? 0})</li>
624
  </ul>
625
  </div>
@@ -640,15 +673,13 @@ function FinalSummary({ assets, selections }: { assets: string[]; selections: {
640
  </div>
641
 
642
  <div className="bg-white p-4 rounded-2xl shadow">
643
- <h2 className="font-medium mb-2">Selection Heatmap Last 30 Steps</h2>
644
  <div className="overflow-auto">
645
  <table className="text-xs border-collapse">
646
  <thead>
647
  <tr>
648
  <th className="p-1 pr-2 text-left sticky left-0 bg-white">Asset</th>
649
- {cols.map(c=> (
650
- <th key={c} className="px-1 py-1 text-center">{c}</th>
651
- ))}
652
  </tr>
653
  </thead>
654
  <tbody>
@@ -656,11 +687,7 @@ function FinalSummary({ assets, selections }: { assets: string[]; selections: {
656
  <tr key={row.asset}>
657
  <td className="p-1 pr-2 font-medium sticky left-0 bg-white">{row.asset}</td>
658
  {row.cells.map((v,j)=> (
659
- <td key={j} className="w-6 h-6" style={{
660
- background: v? "#2563eb" : "#e5e7eb",
661
- opacity: v? 0.9 : 1,
662
- border: "1px solid #ffffff"
663
- }} />
664
  ))}
665
  </tr>
666
  ))}
 
3
  LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, Legend, CartesianGrid,
4
  AreaChart, Area, BarChart, Bar
5
  } from "recharts";
6
+ import {
7
+ apiGetLatestDataset,
8
+ apiUpsertDataset,
9
+ apiGetAnnotation,
10
+ apiUpsertAnnotation,
11
+ DataDict,
12
+ } from "./api";
13
 
14
  type SeriesPoint = { date: string; close: number[] };
 
15
 
16
  const MAX_DAYS = 67;
17
  const maxSteps = 60;
18
  const START_DAY = 7;
19
 
20
+ /* --------------------- utils --------------------- */
21
  function latestClose(p: SeriesPoint): number {
22
  const arr = p.close;
23
+ if (!Array.isArray(arr) || arr.length === 0) throw new Error(`Empty close array at ${p.date}`);
 
 
24
  const last = arr[arr.length - 1];
25
+ if (!Number.isFinite(last)) throw new Error(`Invalid close value at ${p.date}`);
 
 
26
  return last;
27
  }
28
+ function uuidv4() {
29
+ return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, c => {
30
+ const r = (Math.random() * 16) | 0, v = c === "x" ? r : (r & 0x3) | 0x8;
31
+ return v.toString(16);
32
+ });
33
+ }
34
+ function getOrCreateUserId() {
35
+ const k = "annot_user_id";
36
+ let uid = localStorage.getItem(k);
37
+ if (!uid) { uid = uuidv4(); localStorage.setItem(k, uid); }
38
+ return uid;
39
+ }
40
+ function stableHash(str: string): string {
41
+ let h = 5381;
42
+ for (let i = 0; i < str.length; i++) h = (h * 33) ^ str.charCodeAt(i);
43
+ return (h >>> 0).toString(16);
44
+ }
45
 
46
+ /** 示例数据:新格式(close 为滚动数组,最多500 */
47
  function generateFakeJSON(maxLen = 500): DataDict {
48
  const randTicker = () => Math.random().toString(36).substring(2, 6).toUpperCase();
49
  const tickers = Array.from({ length: 5 }, () => randTicker());
 
50
  const start = new Date("2024-01-02T00:00:00Z");
51
  const dates = Array.from({ length: 67 }, (_, i) => {
52
+ const d = new Date(start); d.setDate(start.getDate() + i);
 
53
  return d.toISOString().slice(0, 10);
54
  });
 
55
  const out: DataDict = {};
56
  for (let a = 0; a < 5; a++) {
57
  const ticker = tickers[a];
 
60
  const sigma = 0.15 + Math.random() * 0.35;
61
  const window: number[] = [];
62
  const series: SeriesPoint[] = [];
 
63
  for (let i = 0; i < dates.length; i++) {
64
  if (i > 0) {
65
  const z = (Math.random() - 0.5) * 1.6 + (Math.random() - 0.5) * 1.6;
 
75
  return out;
76
  }
77
 
78
+ /* --------------------- component --------------------- */
79
  export default function App() {
80
+ const userId = getOrCreateUserId();
81
+
82
+ const [datasetId, setDatasetId] = useState<string | null>(null);
83
+ const [datasetName, setDatasetName] = useState<string>("");
84
+
85
  const [rawData, setRawData] = useState<DataDict>({});
86
  const [assets, setAssets] = useState<string[]>([]);
87
  const [dates, setDates] = useState<string[]>([]);
 
95
  const [confirming, setConfirming] = useState(false);
96
  const [finalSaved, setFinalSaved] = useState(false);
97
 
 
98
  const [intradayMode, setIntradayMode] = useState(false);
 
99
  const [intradayDayIdx, setIntradayDayIdx] = useState<number | null>(null);
100
 
101
+ /* ---------- localStorage keys ---------- */
102
+ const LS_DATASET_KEY = "annot_dataset_meta"; // {datasetId, name}
103
+ const LS_DATA_PREFIX = (id: string) => `annot_dataset_${id}`; // {data, assets, dates, name}
104
+ const LS_ANN_PREFIX = (id: string, uid: string) => `annot_user_${uid}_ds_${id}`; // {selections, step, window_len}
105
+
106
+ /* ---------- boot: local -> cloud latest -> example ---------- */
107
+ useEffect(() => {
108
+ const boot = async () => {
109
+ try {
110
+ // 1) local dataset meta
111
+ const metaRaw = localStorage.getItem(LS_DATASET_KEY);
112
+ if (metaRaw) {
113
+ const meta = JSON.parse(metaRaw);
114
+ if (meta?.datasetId) {
115
+ const dJson = localStorage.getItem(LS_DATA_PREFIX(meta.datasetId));
116
+ if (dJson) {
117
+ const payload = JSON.parse(dJson) as { data: DataDict; assets: string[]; dates: string[]; name?: string };
118
+ loadDatasetIntoState(meta.datasetId, payload.data, payload.assets, payload.dates, payload.name || "");
119
+ await loadUserAnnotation(meta.datasetId);
120
+ return;
121
+ }
122
+ }
123
+ }
124
+ // 2) cloud latest
125
+ try {
126
+ const ds = await apiGetLatestDataset();
127
+ const id = (ds.id ?? ds.dataset_id) as string; // 兼容字���名
128
+ const name = ds.name || "";
129
+ const data = ds.data as DataDict;
130
+ const dsAssets = ds.assets as string[];
131
+ const dsDates = ds.dates as string[];
132
+ persistDatasetLocal(id, data, dsAssets, dsDates, name);
133
+ loadDatasetIntoState(id, data, dsAssets, dsDates, name);
134
+ await loadUserAnnotation(id);
135
+ return;
136
+ } catch (e) {
137
+ // 无云端数据,走示例
138
+ }
139
+ // 3) example
140
+ const example = generateFakeJSON();
141
+ const keys = Object.keys(example);
142
+ const dsDates = example[keys[0]].map(d => d.date);
143
+ const id = stableHash(JSON.stringify({ assets: keys, dates: dsDates }));
144
+ persistDatasetLocal(id, example, keys, dsDates, "Example");
145
+ loadDatasetIntoState(id, example, keys, dsDates, "Example");
146
+ } catch (e) {
147
+ console.warn("Boot failed:", e);
148
+ }
149
+ };
150
+ boot();
151
+ // eslint-disable-next-line react-hooks/exhaustive-deps
152
+ }, []);
153
+
154
+ function persistDatasetLocal(id: string, data: DataDict, a: string[], d: string[], name: string) {
155
+ localStorage.setItem(LS_DATASET_KEY, JSON.stringify({ datasetId: id, name }));
156
+ localStorage.setItem(LS_DATA_PREFIX(id), JSON.stringify({ data, assets: a, dates: d, name }));
157
+ }
158
+ function loadDatasetIntoState(id: string, data: DataDict, a: string[], d: string[], name: string) {
159
+ setDatasetId(id);
160
+ setDatasetName(name || "");
161
+ setRawData(data);
162
+ setAssets(a);
163
+ setDates(d);
164
+ setStep(1);
165
+ setWindowLen(START_DAY);
166
+ setSelections([]);
167
+ setSelectedAsset(null);
168
+ setIntradayMode(false);
169
+ setIntradayDayIdx(null);
170
+ setMessage(`Loaded dataset ${name || id}: ${a.length} assets, ${d.length} days.`);
171
+ }
172
+ async function loadUserAnnotation(id: string) {
173
+ // local
174
+ const localRaw = localStorage.getItem(LS_ANN_PREFIX(id, userId));
175
+ if (localRaw) {
176
+ try {
177
+ const ann = JSON.parse(localRaw);
178
+ setSelections(ann.selections || []);
179
+ setStep(ann.step || 1);
180
+ setWindowLen(ann.window_len || START_DAY);
181
+ setIntradayDayIdx(null);
182
+ } catch {}
183
+ }
184
+ // cloud
185
+ try {
186
+ const ann = await apiGetAnnotation(id, userId);
187
+ setSelections(ann.selections || []);
188
+ setStep(ann.step || 1);
189
+ setWindowLen(ann.window_len || START_DAY);
190
+ setIntradayDayIdx(null);
191
+ localStorage.setItem(LS_ANN_PREFIX(id, userId), JSON.stringify(ann));
192
+ } catch {}
193
+ }
194
+ function persistAnnotationLocal() {
195
+ if (!datasetId) return;
196
+ const ann = { user_id: userId, dataset_id: datasetId, selections, step, window_len: windowLen };
197
+ localStorage.setItem(LS_ANN_PREFIX(datasetId, userId), JSON.stringify(ann));
198
+ }
199
+ async function upsertAnnotationCloud() {
200
+ if (!datasetId) return;
201
+ try {
202
+ await apiUpsertAnnotation({ dataset_id: datasetId, user_id: userId, selections, step, window_len: windowLen });
203
+ } catch (e) { console.warn("Upsert annotation failed:", e); }
204
+ }
205
+ useEffect(() => {
206
+ persistAnnotationLocal();
207
+ upsertAnnotationCloud();
208
+ // eslint-disable-next-line react-hooks/exhaustive-deps
209
+ }, [selections, step, windowLen]);
210
+
211
+ /* ---------- tie intraday selected day to window ---------- */
212
  useEffect(() => {
213
  if (!dates.length) return;
214
  const lastIdx = Math.min(windowLen - 1, dates.length - 1);
215
+ if (intradayDayIdx === null || intradayDayIdx > lastIdx) setIntradayDayIdx(lastIdx);
 
 
216
  }, [dates, windowLen, intradayDayIdx]);
217
 
218
+ /* ---------- upload handler (new format only) ---------- */
219
+ async function onFile(e: any) {
220
+ const f = e.target.files?.[0];
221
+ if (!f) return;
222
+ const reader = new FileReader();
223
+ reader.onload = async () => {
224
+ try {
225
+ const json: DataDict = JSON.parse(String(reader.result));
226
+ const keys = Object.keys(json);
227
+ if (keys.length === 0) throw new Error("Empty dataset");
228
+ const firstArr = json[keys[0]];
229
+ if (!Array.isArray(firstArr) || !firstArr[0]?.date || !Array.isArray(firstArr[0]?.close)) {
230
+ throw new Error("Invalid series format. Need [{date, close:number[]}]");
231
+ }
232
+ const refDates = firstArr.map(p => p.date);
233
+ const checkPoint = (p: SeriesPoint) => {
234
+ if (!p?.date) throw new Error("Missing date");
235
+ if (!Array.isArray(p.close) || p.close.length === 0) throw new Error(`Empty close array at ${p.date}`);
236
+ if (p.close.length > 500) throw new Error(`close array exceeds 500 at ${p.date}`);
237
+ for (const v of p.close) if (typeof v !== "number" || !Number.isFinite(v)) throw new Error(`Non numeric close in array at ${p.date}`);
238
+ };
239
+ for (const k of keys) {
240
+ const arr = json[k];
241
+ if (!Array.isArray(arr) || arr.length !== firstArr.length) throw new Error("All series must have the same length");
242
+ for (let i = 0; i < arr.length; i++) {
243
+ const p = arr[i]; checkPoint(p);
244
+ if (p.date !== refDates[i]) throw new Error("Date misalignment across assets");
245
+ }
246
+ }
247
+ // persist + cloud
248
+ const dsDates = firstArr.map(d => d.date);
249
+ const id = stableHash(JSON.stringify({ assets: keys, dates: dsDates }));
250
+ const name = f.name.replace(/\.[^.]+$/, "");
251
+ persistDatasetLocal(id, json, keys, dsDates, name);
252
+ loadDatasetIntoState(id, json, keys, dsDates, name);
253
+
254
+ try {
255
+ await apiUpsertDataset({ dataset_id: id, name, data: json, assets: keys, dates: dsDates });
256
+ // 初始化我的标注
257
+ await apiUpsertAnnotation({ dataset_id: id, user_id: userId, selections: [], step: 1, window_len: START_DAY });
258
+ } catch (err: any) {
259
+ console.warn("Upload to backend failed:", err?.message || err);
260
+ }
261
+ } catch (err: any) {
262
+ setMessage("Failed to parse JSON: " + err.message);
263
+ }
264
+ };
265
+ reader.readAsText(f);
266
+ }
267
 
268
+ /* ---------- computed ---------- */
269
  const isFinal = windowLen >= MAX_DAYS || selections.length >= maxSteps;
270
 
 
271
  const windowData = useMemo(() => {
272
  if (!dates.length || windowLen < 2) return [] as any[];
273
  const sliceDates = dates.slice(0, windowLen);
 
282
  });
283
  }, [assets, dates, windowLen, rawData]);
284
 
 
285
  const visibleDates = useMemo(() => dates.slice(0, Math.max(0, windowLen)), [dates, windowLen]);
286
  const activeIntradayIdx = intradayDayIdx ?? Math.min(windowLen - 1, dates.length - 1);
287
  const activeIntradayDate = visibleDates[activeIntradayIdx] ?? "";
288
 
 
289
  const intradayData = useMemo(() => {
290
  if (!dates.length || activeIntradayIdx == null || activeIntradayIdx < 0) return [] as any[];
291
+ const maxBars = assets.reduce((m, a) => Math.max(m, rawData[a]?.[activeIntradayIdx]?.close?.length ?? 0), 0);
 
 
 
 
 
 
 
292
  const dayFirstPrice: Record<string, number> = {};
293
  assets.forEach(a => {
294
  const arr = rawData[a]?.[activeIntradayIdx]?.close ?? [];
295
  dayFirstPrice[a] = arr.length ? arr[0] : 1;
296
  });
 
297
  const rows: any[] = [];
298
  for (let i = 0; i < maxBars; i++) {
299
  const row: Record<string, any> = { idx: i + 1 };
300
  assets.forEach(a => {
301
  const arr = rawData[a]?.[activeIntradayIdx]?.close ?? [];
302
  const val = arr[i];
303
+ row[a] = typeof val === "number" ? (dayFirstPrice[a] ? val / dayFirstPrice[a] : null) : null;
 
 
 
 
 
304
  });
305
  rows.push(row);
306
  }
 
319
  const example = generateFakeJSON();
320
  const keys = Object.keys(example);
321
  const first = example[keys[0]];
322
+ const dsDates = first.map((d) => d.date);
323
+ const id = stableHash(JSON.stringify({ assets: keys, dates: dsDates }));
324
+ persistDatasetLocal(id, example, keys, dsDates, "Example");
325
+ loadDatasetIntoState(id, example, keys, dsDates, "Example");
326
+ // 不强求上传到云端
 
 
 
 
 
 
327
  }
328
 
329
  function resetSession() {
 
334
  setMessage("Session reset.");
335
  setIntradayMode(false);
336
  setIntradayDayIdx(null);
337
+ if (datasetId) localStorage.removeItem(LS_ANN_PREFIX(datasetId, userId));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
338
  }
339
 
340
  function exportLog() {
 
342
  const url = URL.createObjectURL(blob);
343
  const a = document.createElement("a");
344
  a.href = url;
345
+ a.download = `selections_${new Date().toISOString().slice(0,10)}_${userId}.json`;
346
  a.click();
347
  URL.revokeObjectURL(url);
348
  }
349
 
350
  function confirmSelection() {
351
  if (confirming) return;
352
+ if (!selectedAsset) { setMessage("Select a line first."); return; }
 
 
 
353
  setConfirming(true);
 
354
  const res = realizedNextDayReturn(selectedAsset);
355
+ if (!res) { setMessage("No more data available."); setConfirming(false); return; }
 
 
 
 
356
  const entry = { step, date: res.date, asset: selectedAsset, ret: res.ret };
357
  setSelections((prev) => [...prev, entry]);
 
358
  setWindowLen((w) => Math.min(w + 1, MAX_DAYS));
359
  setStep((s) => s + 1);
360
  setSelectedAsset(null);
 
364
 
365
  const portfolioSeries = useMemo(() => {
366
  let value = 1;
367
+ const pts = selections.map((s) => { value *= 1 + s.ret; return { step: s.step, date: s.date, value }; });
 
 
 
368
  return [{ step: 0, date: "start", value: 1 }, ...pts];
369
  }, [selections]);
370
 
 
386
  const countsAll = assets.reduce((acc: Record<string, number>, a: string) => { acc[a] = 0; return acc; }, {} as Record<string, number>);
387
  selections.forEach((s) => { countsAll[s.asset] = (countsAll[s.asset] || 0) + 1; });
388
  const rankAll = assets.map((a) => ({ asset: a, votes: countsAll[a] || 0 })).sort((x, y) => y.votes - x.votes);
 
389
  let value = 1;
390
  const portfolio = selections.map((s) => { value *= 1 + s.ret; return { step: s.step, date: s.date, value }; });
391
+ const lastStep2 = selections.reduce((m, s) => Math.max(m, s.step), 0);
392
+ const start30_ = Math.max(1, lastStep2 - 30 + 1);
393
+ const lastCols = Array.from({ length: Math.min(30, lastStep2 ? lastStep2 - start30_ + 1 : 0) }, (_, i) => start30_ + i);
394
  const heatGrid = assets.map((a) => ({ asset: a, cells: lastCols.map((c) => (selections.some((s) => s.asset === a && s.step === c) ? 1 : 0)) }));
 
395
  const rets = selections.map((s) => s.ret);
396
  const N = rets.length;
397
  const cum = portfolio.at(-1)?.value ?? 1;
 
402
  const wins = rets.filter((r) => r > 0).length;
403
 
404
  return {
405
+ meta: { saved_at: new Date().toISOString(), start_day: START_DAY, max_days: MAX_DAYS, max_steps: maxSteps, dataset_id: datasetId, dataset_name: datasetName },
406
  assets,
407
  dates,
408
  selections,
 
414
  }
415
 
416
  useEffect(() => {
417
+ const isFinal = windowLen >= MAX_DAYS || selections.length >= maxSteps;
418
  if (isFinal && !finalSaved) {
419
  try {
420
  const payload = buildFinalPayload();
 
423
  const url = URL.createObjectURL(blob);
424
  const a = document.createElement("a");
425
  a.href = url;
426
+ a.download = `run_summary_${new Date().toISOString().slice(0, 10)}_${userId}.json`;
427
  a.click();
428
  URL.revokeObjectURL(url);
429
  setFinalSaved(true);
430
+ } catch (e) { console.warn("Failed to save final JSON:", e); }
 
 
431
  }
432
+ // eslint-disable-next-line react-hooks/exhaustive-deps
433
+ }, [windowLen, selections.length, finalSaved]);
434
 
435
  useEffect(() => {
436
  function onKey(e: KeyboardEvent) {
437
  const tag = (e.target && (e.target as HTMLElement).tagName) || "";
438
  if (tag === "INPUT" || tag === "TEXTAREA") return;
439
  const idx = parseInt((e as any).key, 10) - 1;
440
+ if (!Number.isNaN(idx) && idx >= 0 && idx < assets.length) setSelectedAsset(assets[idx]);
441
+ if ((e as any).key === "Enter" && Boolean(selectedAsset) && windowLen < MAX_DAYS) confirmSelection();
 
 
 
 
442
  }
443
  window.addEventListener("keydown", onKey);
444
  return () => window.removeEventListener("keydown", onKey);
445
  }, [assets, selectedAsset, windowLen]);
446
 
447
+ if (!datasetId) {
448
+ return (
449
+ <div className="p-6">
450
+ <h1 className="text-xl font-semibold mb-3">Asset Choice Simulation</h1>
451
+ <p className="text-sm text-gray-600 mb-2">Upload a dataset (new format) or load example. Data persists locally & on the Space backend.</p>
452
+ <input type="file" accept="application/json" onChange={onFile} className="text-sm" />
453
+ <div className="mt-4">
454
+ <button onClick={loadExample} className="text-sm px-3 py-1.5 rounded-xl bg-blue-100 text-blue-800 hover:bg-blue-200">Load Example</button>
455
+ </div>
456
+ </div>
457
+ );
458
  }
459
 
 
460
  const IntradayControls = () => (
461
  <div className="flex items-center gap-2">
462
  <span className="text-xs px-2 py-1 rounded bg-indigo-50 text-indigo-700">
463
+ Intraday • {visibleDates[intradayDayIdx ?? 0] || "-"}
464
  </span>
465
+ <button onClick={() => setIntradayDayIdx(i => (i == null ? 0 : Math.max(0, i - 1)))} className="text-xs px-2 py-1 rounded-xl border border-gray-200 hover:bg-gray-50" disabled={(intradayDayIdx ?? 0) <= 0} title="Prev day">‹</button>
466
+ <select className="text-xs px-2 py-1 rounded-xl border border-gray-200" value={intradayDayIdx ?? 0} onChange={(e) => setIntradayDayIdx(Number(e.target.value))}>
467
+ {visibleDates.map((d, idx) => <option key={d} value={idx}>{d}</option>)}
 
 
 
 
 
 
 
 
 
 
 
 
 
468
  </select>
469
+ <button onClick={() => setIntradayDayIdx(i => (i == null ? 0 : Math.min(visibleDates.length - 1, i + 1)))} className="text-xs px-2 py-1 rounded-xl border border-gray-200 hover:bg-gray-50" disabled={(intradayDayIdx ?? 0) >= visibleDates.length - 1} title="Next day">›</button>
 
 
 
 
 
 
 
470
  </div>
471
  );
472
 
 
475
  <div className="flex flex-wrap justify-between items-center gap-3">
476
  <div className="flex items-center gap-2">
477
  <h1 className="text-xl font-semibold">Asset Choice Simulation</h1>
478
+ <span className="text-xs text-gray-500">Dataset: {datasetName || datasetId}</span>
479
+ <span className="text-xs text-gray-500">User: {userId.slice(0,8)}…</span>
480
  <span className="text-xs text-gray-500">Day {windowLen} / {MAX_DAYS}</span>
481
  {intradayMode && <IntradayControls />}
482
  </div>
483
  <div className="flex items-center gap-2">
484
  <input type="file" accept="application/json" onChange={onFile} className="text-sm" />
485
  <button onClick={loadExample} className="text-sm px-3 py-1.5 rounded-xl bg-blue-100 text-blue-800 hover:bg-blue-200">Load Example</button>
486
+ <button onClick={resetSession} className="text-sm px-3 py-1.5 rounded-xl bg-gray-200 hover:bg-gray-300">Reset (Keep data)</button>
487
+ <button onClick={() => setIntradayMode(m => !m)} className="text-sm px-3 py-1.5 rounded-xl bg-indigo-100 text-indigo-800 hover:bg-indigo-200" title="Toggle intraday view">
 
 
 
 
488
  {intradayMode ? "View Daily" : "View Intraday"}
489
  </button>
490
+ <button onClick={exportLog} className="text-sm px-3 py-1.5 rounded-xl bg-gray-900 text-white hover:bg-black">Export My Log</button>
491
  </div>
492
  </div>
493
 
 
505
  {i+1}. {a}
506
  </button>
507
  ))}
508
+ {assets.length>0 && <span className="text-xs text-gray-500 ml-1">Hotkeys: 1..{assets.length}, Enter confirm</span>}
 
 
509
  </div>
510
 
511
+ {/* 图表:日线 / 分时 */}
512
  <div className="h-80">
513
  <ResponsiveContainer width="100%" height="100%">
514
  {intradayMode ? (
 
521
  {assets.map((a, i) => (
522
  <Line
523
  key={a}
524
+ type="linear"
525
  dataKey={a}
526
  strokeWidth={selectedAsset === a ? 5 : hoverAsset === a ? 4 : 2.5}
527
  strokeOpacity={selectedAsset && selectedAsset !== a ? 0.3 : 1}
 
545
  {assets.map((a, i) => (
546
  <Line
547
  key={a}
548
+ type="linear"
549
  dataKey={a}
550
  strokeWidth={selectedAsset === a ? 5 : hoverAsset === a ? 4 : 2.5}
551
  strokeOpacity={selectedAsset && selectedAsset !== a ? 0.3 : 1}
 
566
  <div className="text-sm text-gray-600">
567
  Selected: {selectedAsset ?? "(none)"} {message && <span className="ml-2 text-gray-500">{message}</span>}
568
  </div>
569
+ <button onClick={confirmSelection} disabled={!selectedAsset || windowLen >= MAX_DAYS} className={`px-4 py-2 rounded-xl ${selectedAsset && windowLen < MAX_DAYS ? "bg-blue-600 text-white" : "bg-gray-200 text-gray-500"}`}>
 
 
 
 
570
  Confirm & Next Day →
571
  </button>
572
  </div>
 
622
  );
623
  }
624
 
625
+ /** 最终总结组件(保持你原样式) */
626
  function FinalSummary({ assets, selections }: { assets: string[]; selections: { step: number; date: string; asset: string; ret: number }[] }) {
627
  const rets = selections.map(s=>s.ret);
628
  const N = rets.length;
 
635
 
636
  const countsAll: Record<string, number> = assets.reduce((acc: any,a: string)=>{acc[a]=0;return acc;},{} as Record<string, number>);
637
  selections.forEach(s=>{ countsAll[s.asset] = (countsAll[s.asset]||0)+1; });
638
+ const rankAll = assets.map(a=>({ asset:a, votes: countsAll[a]||0 })).sort((x,y)=> y.votes - x.votes);
 
 
639
 
640
  const lastStep = selections.reduce((m,s)=>Math.max(m,s.step),0);
641
  const start30 = Math.max(1, lastStep - 30 + 1);
642
  const cols = Array.from({length: Math.min(30,lastStep ? lastStep - start30 + 1 : 0)}, (_,i)=> start30 + i);
643
+ const grid = assets.map(a=>({ asset:a, cells: cols.map(c => selections.some(s=> s.asset===a && s.step===c) ? 1 : 0) }));
 
 
 
644
 
645
  return (
646
  <div className="p-6 bg-gray-50 min-h-screen flex flex-col gap-6">
647
  <h1 className="text-xl font-semibold">Final Summary</h1>
 
648
  <div className="bg-white p-4 rounded-2xl shadow">
649
  <h2 className="font-medium mb-2">Overall Metrics</h2>
650
  <ul className="text-sm text-gray-700 space-y-1">
 
652
  <li>Win Rate: {(N? (wins/N*100):0).toFixed(1)}%</li>
653
  <li>Cumulative Return: {(cum*100).toFixed(2)}%</li>
654
  <li>Volatility: {(stdev*100).toFixed(2)}%</li>
655
+ <li>Sharpe (rough): {sharpe.toFixed(2)}</li>
656
  <li>Top Preference: {rankAll[0]?.asset ?? "-"} ({rankAll[0]?.votes ?? 0})</li>
657
  </ul>
658
  </div>
 
673
  </div>
674
 
675
  <div className="bg-white p-4 rounded-2xl shadow">
676
+ <h2 className="font-medium mb-2">Selection Heatmap (Last 30 Steps)</h2>
677
  <div className="overflow-auto">
678
  <table className="text-xs border-collapse">
679
  <thead>
680
  <tr>
681
  <th className="p-1 pr-2 text-left sticky left-0 bg-white">Asset</th>
682
+ {cols.map(c=> (<th key={c} className="px-1 py-1 text-center">{c}</th>))}
 
 
683
  </tr>
684
  </thead>
685
  <tbody>
 
687
  <tr key={row.asset}>
688
  <td className="p-1 pr-2 font-medium sticky left-0 bg-white">{row.asset}</td>
689
  {row.cells.map((v,j)=> (
690
+ <td key={j} className="w-6 h-6" style={{ background: v? "#2563eb" : "#e5e7eb", opacity: v? 0.9 : 1, border: "1px solid #ffffff" }} />
 
 
 
 
691
  ))}
692
  </tr>
693
  ))}