YanAdjeNole commited on
Commit
2ec3cf4
·
verified ·
1 Parent(s): 4115887

Update src/App_Old.tsx

Browse files
Files changed (1) hide show
  1. src/App_Old.tsx +701 -212
src/App_Old.tsx CHANGED
@@ -1,157 +1,412 @@
1
  import { useEffect, useMemo, useState } from "react";
2
  import {
3
- LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, Legend, CartesianGrid,
4
- AreaChart, Area, BarChart, Bar
 
 
 
 
 
 
 
 
 
 
5
  } from "recharts";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
 
7
- // -----------------------------
8
- // Fake JSON example (5 assets, 67 trading days, randomized tickers)
9
- // -----------------------------
10
- function generateFakeJSON() {
11
  const randTicker = () => Math.random().toString(36).substring(2, 6).toUpperCase();
12
  const tickers = Array.from({ length: 5 }, () => randTicker());
13
-
14
  const start = new Date("2024-01-02T00:00:00Z");
15
  const dates = Array.from({ length: 67 }, (_, i) => {
16
  const d = new Date(start);
17
  d.setDate(start.getDate() + i);
18
  return d.toISOString().slice(0, 10);
19
  });
20
-
21
- const fake: Record<string, { date: string; close: number }[]> = {};
22
  for (let a = 0; a < 5; a++) {
23
  const ticker = tickers[a];
24
  let price = 80 + Math.random() * 40;
25
  const mu = (Math.random() * 0.1 - 0.05) / 252;
26
  const sigma = 0.15 + Math.random() * 0.35;
27
- const series: { date: string; close: number }[] = [];
28
  for (let i = 0; i < dates.length; i++) {
29
- if (i > 0) {
30
- const z = (Math.random() - 0.5) * 1.6 + (Math.random() - 0.5) * 1.6;
31
- const daily = mu + (sigma / Math.sqrt(252)) * z;
32
- price *= 1 + daily;
 
 
 
 
33
  }
34
- series.push({ date: dates[i], close: Number(price.toFixed(2)) });
 
 
35
  }
36
- fake[ticker] = series;
37
  }
38
- return fake;
39
  }
40
 
41
- const MAX_DAYS = 67;
42
- const maxSteps = 60; // number of picks to reach MAX_DAYS from START_DAY
43
- const START_DAY = 7; // first visible day, first pick occurs at this day
 
 
 
 
 
44
 
 
45
  export default function App() {
46
- const [rawData, setRawData] = useState<Record<string, { date: string; close: number }[]>>({});
 
 
 
 
 
47
  const [assets, setAssets] = useState<string[]>([]);
48
  const [dates, setDates] = useState<string[]>([]);
49
 
50
- const [step, setStep] = useState(1); // number of picks made
51
- const [windowLen, setWindowLen] = useState(START_DAY); // initial visible window length
 
 
 
 
52
  const [selectedAsset, setSelectedAsset] = useState<string | null>(null);
53
  const [hoverAsset, setHoverAsset] = useState<string | null>(null);
54
  const [selections, setSelections] = useState<{ step: number; date: string; asset: string; ret: number }[]>([]);
 
55
  const [message, setMessage] = useState("");
56
  const [confirming, setConfirming] = useState(false);
57
  const [finalSaved, setFinalSaved] = useState(false);
58
 
59
- // boot with example data
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
  useEffect(() => {
61
- if (Object.keys(rawData).length === 0) {
62
- const example = generateFakeJSON();
63
- const keys = Object.keys(example);
64
- const first = example[keys[0]];
65
- setRawData(example);
66
- setAssets(keys);
67
- setDates(first.map((d) => d.date));
68
- setStep(1);
69
- setWindowLen(START_DAY);
70
- setSelections([]);
71
- setSelectedAsset(null);
72
- setMessage(`Loaded example: ${keys.length} assets, ${first.length} days.`);
73
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74
  }, []);
75
 
76
- const isFinal = windowLen >= MAX_DAYS || selections.length >= maxSteps;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
77
 
 
 
 
 
78
  const windowData = useMemo(() => {
79
  if (!dates.length || windowLen < 2) return [] as any[];
80
  const sliceDates = dates.slice(0, windowLen);
81
  return sliceDates.map((date, idx) => {
82
  const row: Record<string, any> = { date };
83
  assets.forEach((a) => {
84
- const base = rawData[a]?.[0]?.close ?? 1;
85
- const val = rawData[a]?.[idx]?.close ?? base;
86
  row[a] = base ? val / base : 1;
87
  });
88
  return row;
89
  });
90
  }, [assets, dates, windowLen, rawData]);
91
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
92
  function realizedNextDayReturn(asset: string) {
93
  const t = windowLen - 1;
94
  if (t + 1 >= dates.length) return null as any;
95
- const series = rawData[asset];
96
- const ret = series[t + 1].close / series[t].close - 1;
97
  return { date: dates[t + 1], ret };
98
  }
99
 
100
  function loadExample() {
101
  const example = generateFakeJSON();
102
  const keys = Object.keys(example);
103
- const first = example[keys[0]];
104
- setRawData(example);
105
- setAssets(keys);
106
- setDates(first.map((d) => d.date));
107
- setStep(1);
108
- setWindowLen(START_DAY);
109
- setSelections([]);
110
- setSelectedAsset(null);
111
- setMessage(`Loaded example: ${keys.length} assets, ${first.length} days.`);
112
- try { localStorage.removeItem("asset_experiment_selections"); } catch {}
113
  }
114
 
115
  function resetSession() {
116
  setSelections([]);
 
117
  setSelectedAsset(null);
118
  setStep(1);
119
- setWindowLen(START_DAY);
120
  setMessage("Session reset.");
121
- try { localStorage.removeItem("asset_experiment_selections"); } catch {}
122
- }
123
-
124
- function onFile(e: any) {
125
- const f = e.target.files?.[0];
126
- if (!f) return;
127
- const reader = new FileReader();
128
- reader.onload = () => {
129
- try {
130
- const json = JSON.parse(String(reader.result));
131
- const keys = Object.keys(json);
132
- if (keys.length === 0) throw new Error("Empty dataset");
133
- const first = json[keys[0]];
134
- if (!Array.isArray(first) || !first[0]?.date || typeof first[0]?.close !== "number") {
135
- throw new Error("Invalid series format (need [{date, close}])");
136
- }
137
- const ref = new Set(first.map((d: any) => d.date));
138
- for (const k of keys.slice(1)) {
139
- for (const p of json[k]) { if (!ref.has(p.date)) throw new Error("Date misalignment across assets"); }
140
- }
141
- setRawData(json);
142
- setAssets(keys);
143
- setDates(first.map((d: any) => d.date));
144
- setStep(1);
145
- setWindowLen(START_DAY);
146
- setSelections([]);
147
- setSelectedAsset(null);
148
- setMessage(`Loaded file: ${keys.length} assets, ${first.length} days.`);
149
- try { localStorage.removeItem("asset_experiment_selections"); } catch {}
150
- } catch (err: any) {
151
- setMessage("Failed to parse JSON: " + err.message);
152
- }
153
- };
154
- reader.readAsText(f);
155
  }
156
 
157
  function exportLog() {
@@ -159,14 +414,17 @@ export default function App() {
159
  const url = URL.createObjectURL(blob);
160
  const a = document.createElement("a");
161
  a.href = url;
162
- a.download = `selections_${new Date().toISOString().slice(0,10)}.json`;
163
  a.click();
164
  URL.revokeObjectURL(url);
165
  }
166
 
167
  function confirmSelection() {
168
  if (confirming) return;
169
- if (!selectedAsset) return setMessage("Select a line first.");
 
 
 
170
  setConfirming(true);
171
 
172
  const res = realizedNextDayReturn(selectedAsset);
@@ -175,14 +433,36 @@ export default function App() {
175
  setConfirming(false);
176
  return;
177
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
178
  const entry = { step, date: res.date, asset: selectedAsset, ret: res.ret };
179
  setSelections((prev) => [...prev, entry]);
180
-
181
- setWindowLen((w) => Math.min(w + 1, MAX_DAYS));
182
  setStep((s) => s + 1);
183
  setSelectedAsset(null);
184
  setConfirming(false);
185
- setMessage(`Pick ${step}: ${selectedAsset} → next-day return ${(res.ret * 100).toFixed(2)}%`);
186
  }
187
 
188
  const portfolioSeries = useMemo(() => {
@@ -205,19 +485,35 @@ export default function App() {
205
  const wins = rets.filter((r) => r > 0).length;
206
  return { cumRet: cum - 1, stdev, sharpe, wins, N };
207
  }, [portfolioSeries, selections]);
208
- // ---- Build and auto-save final JSON when finished ----
209
  function buildFinalPayload() {
210
- const lastStep = selections.reduce((m, s) => Math.max(m, s.step), 0);
211
- const start30 = Math.max(1, lastStep - 30 + 1);
212
- const countsAll = assets.reduce((acc: Record<string, number>, a: string) => { acc[a] = 0; return acc; }, {} as Record<string, number>);
213
- selections.forEach((s) => { countsAll[s.asset] = (countsAll[s.asset] || 0) + 1; });
214
- const rankAll = assets.map((a) => ({ asset: a, votes: countsAll[a] || 0 })).sort((x, y) => y.votes - x.votes);
 
 
 
 
 
215
 
216
  let value = 1;
217
- const portfolio = selections.map((s) => { value *= 1 + s.ret; return { step: s.step, date: s.date, value }; });
 
 
 
218
 
219
- const lastCols = Array.from({ length: Math.min(30, lastStep ? lastStep - start30 + 1 : 0) }, (_, i) => start30 + i);
220
- const heatGrid = assets.map((a) => ({ asset: a, cells: lastCols.map((c) => (selections.some((s) => s.asset === a && s.step === c) ? 1 : 0)) }));
 
 
 
 
 
 
 
 
221
 
222
  const rets = selections.map((s) => s.ret);
223
  const N = rets.length;
@@ -229,19 +525,28 @@ export default function App() {
229
  const wins = rets.filter((r) => r > 0).length;
230
 
231
  return {
232
- meta: { saved_at: new Date().toISOString(), start_day: START_DAY, max_days: MAX_DAYS, max_steps: maxSteps },
 
 
 
 
 
 
 
233
  assets,
234
  dates,
235
  selections,
 
236
  portfolio,
237
- stats: { cumRet: (cum - 1), stdev, sharpe, wins, N },
238
  preference_all: rankAll,
239
  heatmap_last30: { cols: lastCols, grid: heatGrid },
240
  };
241
  }
242
 
243
  useEffect(() => {
244
- if (isFinal && !finalSaved) {
 
245
  try {
246
  const payload = buildFinalPayload();
247
  localStorage.setItem("asset_experiment_final", JSON.stringify(payload));
@@ -249,7 +554,7 @@ export default function App() {
249
  const url = URL.createObjectURL(blob);
250
  const a = document.createElement("a");
251
  a.href = url;
252
- a.download = `run_summary_${new Date().toISOString().slice(0, 10)}.json`;
253
  a.click();
254
  URL.revokeObjectURL(url);
255
  setFinalSaved(true);
@@ -257,101 +562,256 @@ export default function App() {
257
  console.warn("Failed to save final JSON:", e);
258
  }
259
  }
260
- }, [isFinal, finalSaved, assets, selections, dates]);
 
261
 
262
- // Hotkeys: 1..N selects asset, Enter confirms
263
  useEffect(() => {
264
  function onKey(e: KeyboardEvent) {
265
  const tag = (e.target && (e.target as HTMLElement).tagName) || "";
266
  if (tag === "INPUT" || tag === "TEXTAREA") return;
267
  const idx = parseInt((e as any).key, 10) - 1;
268
- if (!Number.isNaN(idx) && idx >= 0 && idx < assets.length) {
269
- setSelectedAsset(assets[idx]);
270
- }
271
- if ((e as any).key === "Enter" && Boolean(selectedAsset) && windowLen < MAX_DAYS) {
272
- confirmSelection();
273
- }
274
  }
275
  window.addEventListener("keydown", onKey);
276
  return () => window.removeEventListener("keydown", onKey);
277
- }, [assets, selectedAsset, windowLen]);
278
 
279
- if (isFinal) {
280
  return (
281
- <FinalSummary assets={assets} selections={selections} />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
282
  );
283
  }
284
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
285
  return (
286
  <div className="p-6 bg-gray-50 min-h-screen flex flex-col gap-6">
287
  <div className="flex flex-wrap justify-between items-center gap-3">
288
  <div className="flex items-center gap-2">
289
  <h1 className="text-xl font-semibold">Asset Choice Simulation</h1>
290
- <span className="text-xs text-gray-500">Day {windowLen} / {MAX_DAYS}</span>
 
 
 
 
 
 
 
291
  </div>
292
- <div className="flex items-center gap-2">
293
  <input type="file" accept="application/json" onChange={onFile} className="text-sm" />
294
- <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>
295
- <button onClick={resetSession} className="text-sm px-3 py-1.5 rounded-xl bg-gray-200 hover:bg-gray-300">Reset</button>
296
- <button onClick={exportLog} className="text-sm px-3 py-1.5 rounded-xl bg-gray-900 text-white hover:bg-black">Export Log</button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
297
  </div>
298
  </div>
299
 
300
  <div className="bg-white p-4 rounded-2xl shadow">
301
- {/* Quick picker (keeps legend & line clicks) */}
302
  <div className="flex flex-wrap items-center gap-2 mb-3">
303
  {assets.map((a, i) => (
304
  <button
305
  key={`pick-${a}`}
306
  onClick={() => setSelectedAsset(a)}
307
- className={`text-xs px-2 py-1 rounded-xl border transition ${selectedAsset===a?"bg-blue-600 text-white border-blue-600":"bg-white text-gray-700 border-gray-200 hover:bg-gray-50"}`}
308
- title={`Hotkey ${i+1}`}
 
 
 
 
309
  >
310
- <span className="inline-block w-2.5 h-2.5 rounded-full mr-2" style={{backgroundColor: `hsl(${(360/assets.length)*i},70%,50%)`}} />
311
- {i+1}. {a}
 
 
 
312
  </button>
313
  ))}
314
- {assets.length>0 && (
315
- <span className="text-xs text-gray-500 ml-1">Hotkeys: 1{assets.length}, Enter to confirm</span>
316
  )}
317
  </div>
318
- <div className="h-80">
 
319
  <ResponsiveContainer width="100%" height="100%">
320
- <LineChart data={windowData}>
321
- <CartesianGrid strokeDasharray="3 3" />
322
- <XAxis dataKey="date" tick={{ fontSize: 10 }} />
323
- <YAxis domain={["auto", "auto"]} tick={{ fontSize: 10 }} />
324
- <Tooltip contentStyle={{ fontSize: 12 }} />
325
- <Legend onClick={(o: any) => setSelectedAsset(o.value)} wrapperStyle={{ cursor: "pointer" }} />
326
- {assets.map((a, i) => (
327
- <Line
328
- key={a}
329
- type="monotone"
330
- dataKey={a}
331
- strokeWidth={selectedAsset === a ? 5 : hoverAsset === a ? 4 : 2.5}
332
- strokeOpacity={selectedAsset && selectedAsset !== a ? 0.3 : 1}
333
- dot={false}
334
- isAnimationActive={false}
335
- stroke={`hsl(${(360 / assets.length) * i},70%,50%)`}
336
- onMouseEnter={() => setHoverAsset(a)}
337
- onMouseLeave={() => setHoverAsset(null)}
338
- onClick={() => setSelectedAsset((p) => (p === a ? null : a))}
339
  />
340
- ))}
341
- </LineChart>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
342
  </ResponsiveContainer>
343
  </div>
344
 
345
- <div className="flex justify-between items-center mt-3">
346
- <div className="text-sm text-gray-600">Selected: {selectedAsset ?? "(none)"} {message && <span className="ml-2 text-gray-500">{message}</span>}</div>
347
- <button
348
- onClick={confirmSelection}
349
- disabled={!selectedAsset || windowLen >= MAX_DAYS}
350
- className={`px-4 py-2 rounded-xl ${selectedAsset && windowLen < MAX_DAYS ? "bg-blue-600 text-white" : "bg-gray-200 text-gray-500"}`}
351
- >
352
- Confirm & Next Day
353
- </button>
354
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
355
  </div>
356
 
357
  <div className="bg-white p-4 rounded-2xl shadow">
@@ -371,11 +831,12 @@ export default function App() {
371
  <li>Cumulative Return: {(stats.cumRet * 100).toFixed(2)}%</li>
372
  <li>Volatility: {(stats.stdev * 100).toFixed(2)}%</li>
373
  <li>Sharpe: {stats.sharpe.toFixed(2)}</li>
374
- <li>Winning Days: {stats.wins}/{stats.N}</li>
 
 
375
  </ul>
376
  </div>
377
 
378
- {/* Daily selections table */}
379
  <div className="bg-white p-4 rounded-2xl shadow">
380
  <h2 className="font-medium mb-2">Daily Selections</h2>
381
  <div className="overflow-auto rounded-xl border border-gray-100">
@@ -389,14 +850,19 @@ export default function App() {
389
  </tr>
390
  </thead>
391
  <tbody>
392
- {selections.slice().reverse().map((s) => (
393
- <tr key={`${s.step}-${s.asset}-${s.date}`} className="odd:bg-white even:bg-gray-50">
394
- <td className="px-2 py-1">{s.step}</td>
395
- <td className="px-2 py-1">{s.date}</td>
396
- <td className="px-2 py-1">{s.asset}</td>
397
- <td className={`px-2 py-1 text-right ${s.ret>=0?"text-green-600":"text-red-600"}`}>{(s.ret*100).toFixed(2)}%</td>
398
- </tr>
399
- ))}
 
 
 
 
 
400
  </tbody>
401
  </table>
402
  </div>
@@ -405,55 +871,73 @@ export default function App() {
405
  );
406
  }
407
 
408
- // ---------- Final Summary Component ----------
409
- function FinalSummary({ assets, selections }: { assets: string[]; selections: { step: number; date: string; asset: string; ret: number }[] }) {
410
- // Overall metrics for full run
411
- const rets = selections.map(s=>s.ret);
 
 
 
 
 
412
  const N = rets.length;
413
- const cum = rets.reduce((v,r)=> v*(1+r), 1) - 1;
414
- const mean = N ? rets.reduce((a,b)=>a+b,0)/N : 0;
415
- const variance = N ? rets.reduce((a,b)=> a + (b-mean)**2, 0)/N : 0;
416
  const stdev = Math.sqrt(variance);
417
- const sharpe = stdev ? (mean*252)/(stdev*Math.sqrt(252)) : 0;
418
- const wins = rets.filter(r=>r>0).length;
419
-
420
- // Preference ranking (ALL picks)
421
- const countsAll: Record<string, number> = assets.reduce((acc: any,a: string)=>{acc[a]=0;return acc;},{} as Record<string, number>);
422
- selections.forEach(s=>{ countsAll[s.asset] = (countsAll[s.asset]||0)+1; });
423
- const rankAll = assets
424
- .map(a=>({ asset:a, votes: countsAll[a]||0 }))
425
- .sort((x,y)=> y.votes - x.votes);
426
-
427
- // Heatmap for last 30 steps
428
- const lastStep = selections.reduce((m,s)=>Math.max(m,s.step),0);
 
 
 
 
 
 
 
 
 
 
 
 
 
429
  const start30 = Math.max(1, lastStep - 30 + 1);
430
- const cols = Array.from({length: Math.min(30,lastStep ? lastStep - start30 + 1 : 0)}, (_,i)=> start30 + i);
431
- const grid = assets.map(a=>({
432
- asset:a,
433
- cells: cols.map(c => selections.some(s=> s.asset===a && s.step===c) ? 1 : 0)
434
  }));
435
 
436
  return (
437
  <div className="p-6 bg-gray-50 min-h-screen flex flex-col gap-6">
438
  <h1 className="text-xl font-semibold">Final Summary</h1>
439
-
440
  <div className="bg-white p-4 rounded-2xl shadow">
441
  <h2 className="font-medium mb-2">Overall Metrics</h2>
442
  <ul className="text-sm text-gray-700 space-y-1">
443
  <li>Total Picks: {N}</li>
444
- <li>Win Rate: {(N? (wins/N*100):0).toFixed(1)}%</li>
445
- <li>Cumulative Return: {(cum*100).toFixed(2)}%</li>
446
- <li>Volatility: {(stdev*100).toFixed(2)}%</li>
447
  <li>Sharpe (rough): {sharpe.toFixed(2)}</li>
448
- <li>Top Preference (All): {rankAll[0]?.asset ?? "-"} ({rankAll[0]?.votes ?? 0} votes)</li>
 
 
449
  </ul>
450
  </div>
451
-
452
  <div className="bg-white p-4 rounded-2xl shadow">
453
- <h2 className="font-medium mb-2">Selection Preference Ranking (All Assets)</h2>
454
  <div className="h-56">
455
  <ResponsiveContainer width="100%" height="100%">
456
- <BarChart data={rankAll} margin={{ left: 8, right: 8, top: 8, bottom: 8 }}>
457
  <CartesianGrid strokeDasharray="3 3" />
458
  <XAxis dataKey="asset" tick={{ fontSize: 10 }} />
459
  <YAxis allowDecimals={false} tick={{ fontSize: 10 }} />
@@ -463,29 +947,34 @@ function FinalSummary({ assets, selections }: { assets: string[]; selections: {
463
  </ResponsiveContainer>
464
  </div>
465
  </div>
466
-
467
  <div className="bg-white p-4 rounded-2xl shadow">
468
- <h2 className="font-medium mb-2">Selection Heatmap (Assets × Last 30 Steps)</h2>
469
  <div className="overflow-auto">
470
  <table className="text-xs border-collapse">
471
  <thead>
472
  <tr>
473
  <th className="p-1 pr-2 text-left sticky left-0 bg-white">Asset</th>
474
- {cols.map(c=> (
475
- <th key={c} className="px-1 py-1 text-center">{c}</th>
 
 
476
  ))}
477
  </tr>
478
  </thead>
479
  <tbody>
480
- {grid.map(row => (
481
  <tr key={row.asset}>
482
  <td className="p-1 pr-2 font-medium sticky left-0 bg-white">{row.asset}</td>
483
- {row.cells.map((v,j)=> (
484
- <td key={j} className="w-6 h-6" style={{
485
- background: v? "#2563eb" : "#e5e7eb",
486
- opacity: v? 0.9 : 1,
487
- border: "1px solid #ffffff"
488
- }} />
 
 
 
 
489
  ))}
490
  </tr>
491
  ))}
 
1
  import { useEffect, useMemo, useState } from "react";
2
  import {
3
+ LineChart,
4
+ Line,
5
+ XAxis,
6
+ YAxis,
7
+ Tooltip,
8
+ ResponsiveContainer,
9
+ Legend,
10
+ CartesianGrid,
11
+ AreaChart,
12
+ Area,
13
+ BarChart,
14
+ Bar,
15
  } from "recharts";
16
+ import {
17
+ apiGetLatestDataset,
18
+ apiUpsertDataset,
19
+ apiGetAnnotation,
20
+ apiUpsertAnnotation,
21
+ DataDict,
22
+ } from "./api";
23
+
24
+ type SeriesPoint = { date: string; close: number[] };
25
+ type ViewMode = "daily" | "intraday";
26
+
27
+ const START_DAY = 7;
28
+
29
+ /* --------------------- utils --------------------- */
30
+ function latestClose(p: SeriesPoint): number {
31
+ const arr = p.close;
32
+ if (!Array.isArray(arr) || arr.length === 0) throw new Error(`Empty close array at ${p.date}`);
33
+ const last = arr[arr.length - 1];
34
+ if (!Number.isFinite(last)) throw new Error(`Invalid close value at ${p.date}`);
35
+ return last;
36
+ }
37
+ function uuidv4() {
38
+ return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
39
+ const r = (Math.random() * 16) | 0,
40
+ v = c === "x" ? r : (r & 0x3) | 0x8;
41
+ return v.toString(16);
42
+ });
43
+ }
44
+ function getOrCreateUserId() {
45
+ const k = "annot_user_id";
46
+ let uid = localStorage.getItem(k);
47
+ if (!uid) {
48
+ uid = uuidv4();
49
+ localStorage.setItem(k, uid);
50
+ }
51
+ return uid;
52
+ }
53
+ function stableHash(str: string): string {
54
+ let h = 5381;
55
+ for (let i = 0; i < str.length; i++) h = (h * 33) ^ str.charCodeAt(i);
56
+ return (h >>> 0).toString(16);
57
+ }
58
 
59
+ /** Example data in the new format (close is a rolling intraday array up to 500) */
60
+ function generateFakeJSON(maxLen = 500): DataDict {
 
 
61
  const randTicker = () => Math.random().toString(36).substring(2, 6).toUpperCase();
62
  const tickers = Array.from({ length: 5 }, () => randTicker());
 
63
  const start = new Date("2024-01-02T00:00:00Z");
64
  const dates = Array.from({ length: 67 }, (_, i) => {
65
  const d = new Date(start);
66
  d.setDate(start.getDate() + i);
67
  return d.toISOString().slice(0, 10);
68
  });
69
+ const out: DataDict = {};
 
70
  for (let a = 0; a < 5; a++) {
71
  const ticker = tickers[a];
72
  let price = 80 + Math.random() * 40;
73
  const mu = (Math.random() * 0.1 - 0.05) / 252;
74
  const sigma = 0.15 + Math.random() * 0.35;
75
+ const series: SeriesPoint[] = [];
76
  for (let i = 0; i < dates.length; i++) {
77
+ // Fake minute series (varying length)
78
+ const mins = 200 + Math.floor(Math.random() * 100);
79
+ const intraday: number[] = [];
80
+ let p = price;
81
+ for (let m = 0; m < mins; m++) {
82
+ const dz = (Math.random() - 0.5) * 0.004;
83
+ p = Math.max(0.01, p * (1 + dz));
84
+ intraday.push(Number(p.toFixed(2)));
85
  }
86
+ series.push({ date: dates[i], close: intraday });
87
+ const drift = mu + (sigma / Math.sqrt(252)) * ((Math.random() - 0.5) * 2.0);
88
+ price = intraday[intraday.length - 1] * (1 + drift);
89
  }
90
+ out[ticker] = series;
91
  }
92
+ return out;
93
  }
94
 
95
+ /* --------------------- NEW: observation delta type --------------------- */
96
+ type ObservationDelta = {
97
+ step: number;
98
+ window_len_before: number; // confirm 前用户已看到的天数 d
99
+ new_day_idx: number; // 新增那一天 index(0-based)
100
+ new_date: string;
101
+ daily_close: Record<string, number>; // asset -> latestClose at new_day_idx
102
+ };
103
 
104
+ /* --------------------- component --------------------- */
105
  export default function App() {
106
+ const userId = getOrCreateUserId();
107
+
108
+ const [datasetId, setDatasetId] = useState<string | null>(null);
109
+ const [datasetName, setDatasetName] = useState<string>("");
110
+
111
+ const [rawData, setRawData] = useState<DataDict>({});
112
  const [assets, setAssets] = useState<string[]>([]);
113
  const [dates, setDates] = useState<string[]>([]);
114
 
115
+ // Dynamic caps
116
+ const maxDays = dates.length || 67;
117
+ const maxSteps = Math.max(0, maxDays - START_DAY);
118
+
119
+ const [step, setStep] = useState(1);
120
+ const [windowLen, setWindowLen] = useState(START_DAY);
121
  const [selectedAsset, setSelectedAsset] = useState<string | null>(null);
122
  const [hoverAsset, setHoverAsset] = useState<string | null>(null);
123
  const [selections, setSelections] = useState<{ step: number; date: string; asset: string; ret: number }[]>([]);
124
+ const [observations, setObservations] = useState<ObservationDelta[]>([]); // NEW
125
  const [message, setMessage] = useState("");
126
  const [confirming, setConfirming] = useState(false);
127
  const [finalSaved, setFinalSaved] = useState(false);
128
 
129
+ const [viewMode, setViewMode] = useState<ViewMode>("daily");
130
+ const [intradayDayIdx, setIntradayDayIdx] = useState<number | null>(null);
131
+
132
+ /* ---------- localStorage keys ---------- */
133
+ const LS_DATASET_KEY = "annot_dataset_meta";
134
+ const LS_DATA_PREFIX = (id: string) => `annot_dataset_${id}`;
135
+ const LS_ANN_PREFIX = (id: string, uid: string) => `annot_user_${uid}_ds_${id}`;
136
+
137
+ /* ---------- alias map ---------- */
138
+ const aliasMap = useMemo(() => {
139
+ const m: Record<string, string> = {};
140
+ assets.forEach((a, i) => {
141
+ m[a] = `Ticker ${i + 1}`;
142
+ });
143
+ return m;
144
+ }, [assets]);
145
+ const aliasOf = (a?: string | null) => (a && aliasMap[a]) || a || "";
146
+
147
+ /* ---------- boot ---------- */
148
  useEffect(() => {
149
+ const boot = async () => {
150
+ try {
151
+ const metaRaw = localStorage.getItem(LS_DATASET_KEY);
152
+ if (metaRaw) {
153
+ const meta = JSON.parse(metaRaw);
154
+ if (meta?.datasetId) {
155
+ const dJson = localStorage.getItem(LS_DATA_PREFIX(meta.datasetId));
156
+ if (dJson) {
157
+ const payload = JSON.parse(dJson) as {
158
+ data: DataDict;
159
+ assets: string[];
160
+ dates: string[];
161
+ name?: string;
162
+ };
163
+ loadDatasetIntoState(meta.datasetId, payload.data, payload.assets, payload.dates, payload.name || "");
164
+ await loadUserAnnotation(meta.datasetId);
165
+ return;
166
+ }
167
+ }
168
+ }
169
+ try {
170
+ const ds = await apiGetLatestDataset();
171
+ const id = (ds.id ?? ds.dataset_id) as string;
172
+ const name = ds.name || "";
173
+ const data = ds.data as DataDict;
174
+ const dsAssets = ds.assets as string[];
175
+ const dsDates = ds.dates as string[];
176
+ persistDatasetLocal(id, data, dsAssets, dsDates, name);
177
+ loadDatasetIntoState(id, data, dsAssets, dsDates, name);
178
+ await loadUserAnnotation(id);
179
+ return;
180
+ } catch {}
181
+ const example = generateFakeJSON();
182
+ const keys = Object.keys(example);
183
+ const dsDates = example[keys[0]].map((d) => d.date);
184
+ const id = stableHash(JSON.stringify({ assets: keys, dates: dsDates }));
185
+ persistDatasetLocal(id, example, keys, dsDates, "Example");
186
+ loadDatasetIntoState(id, example, keys, dsDates, "Example");
187
+ } catch (e) {
188
+ console.warn("Boot failed:", e);
189
+ }
190
+ };
191
+ boot();
192
+ // eslint-disable-next-line react-hooks/exhaustive-deps
193
  }, []);
194
 
195
+ function persistDatasetLocal(id: string, data: DataDict, a: string[], d: string[], name: string) {
196
+ localStorage.setItem(LS_DATASET_KEY, JSON.stringify({ datasetId: id, name }));
197
+ localStorage.setItem(LS_DATA_PREFIX(id), JSON.stringify({ data, assets: a, dates: d, name }));
198
+ }
199
+
200
+ function loadDatasetIntoState(id: string, data: DataDict, a: string[], d: string[], name: string) {
201
+ setDatasetId(id);
202
+ setDatasetName(name || "");
203
+ setRawData(data);
204
+ setAssets(a);
205
+ setDates(d);
206
+ setStep(1);
207
+ setWindowLen(Math.min(START_DAY, Math.max(1, d.length)));
208
+ setSelections([]);
209
+ setObservations([]); // NEW
210
+ setSelectedAsset(null);
211
+ setViewMode("daily");
212
+ setIntradayDayIdx(null);
213
+ setMessage(`Loaded dataset ${name || id}: ${a.length} assets, ${d.length} days.`);
214
+ setFinalSaved(false);
215
+ }
216
+
217
+ async function loadUserAnnotation(id: string) {
218
+ const localRaw = localStorage.getItem(LS_ANN_PREFIX(id, userId));
219
+ if (localRaw) {
220
+ try {
221
+ const ann = JSON.parse(localRaw);
222
+ setSelections(ann.selections || []);
223
+ setObservations(ann.observations || []); // NEW
224
+ setStep(ann.step || 1);
225
+ setWindowLen(ann.window_len || START_DAY);
226
+ setIntradayDayIdx(null);
227
+ } catch {}
228
+ }
229
+ try {
230
+ const ann = await apiGetAnnotation(id, userId);
231
+ setSelections(ann.selections || []);
232
+ setObservations(ann.observations || []); // NEW
233
+ setStep(ann.step || 1);
234
+ setWindowLen(ann.window_len || START_DAY);
235
+ setIntradayDayIdx(null);
236
+ localStorage.setItem(LS_ANN_PREFIX(id, userId), JSON.stringify(ann));
237
+ } catch {}
238
+ }
239
+
240
+ function persistAnnotationLocal() {
241
+ if (!datasetId) return;
242
+ const ann = {
243
+ user_id: userId,
244
+ dataset_id: datasetId,
245
+ selections,
246
+ observations, // NEW
247
+ step,
248
+ window_len: windowLen,
249
+ };
250
+ localStorage.setItem(LS_ANN_PREFIX(datasetId, userId), JSON.stringify(ann));
251
+ }
252
+
253
+ async function upsertAnnotationCloud() {
254
+ if (!datasetId) return;
255
+ try {
256
+ // Backend may not accept extra fields; keep a safe cast.
257
+ await apiUpsertAnnotation({
258
+ dataset_id: datasetId,
259
+ user_id: userId,
260
+ selections,
261
+ observations, // NEW (if backend supports, great; if not, backend should ignore or you'll adjust API)
262
+ step,
263
+ window_len: windowLen,
264
+ } as any);
265
+ } catch (e) {
266
+ console.warn("Upsert annotation failed:", e);
267
+ }
268
+ }
269
+
270
+ useEffect(() => {
271
+ persistAnnotationLocal();
272
+ upsertAnnotationCloud();
273
+ // eslint-disable-next-line react-hooks/exhaustive-deps
274
+ }, [selections, observations, step, windowLen]);
275
+
276
+ /* ---------- tie intraday selected day to window ---------- */
277
+ useEffect(() => {
278
+ if (!dates.length) return;
279
+ const lastIdx = Math.min(windowLen - 1, dates.length - 1);
280
+ if (intradayDayIdx === null || intradayDayIdx > lastIdx) setIntradayDayIdx(lastIdx);
281
+ }, [dates, windowLen, intradayDayIdx]);
282
+
283
+ /* ---------- upload handler ---------- */
284
+ async function onFile(e: any) {
285
+ const f = e.target.files?.[0];
286
+ if (!f) return;
287
+ const reader = new FileReader();
288
+ reader.onload = async () => {
289
+ try {
290
+ const json: DataDict = JSON.parse(String(reader.result));
291
+ const keys = Object.keys(json);
292
+ if (keys.length === 0) throw new Error("Empty dataset");
293
+ const firstArr = json[keys[0]];
294
+ if (!Array.isArray(firstArr) || !firstArr[0]?.date || !Array.isArray(firstArr[0]?.close)) {
295
+ throw new Error("Invalid series format. Need [{date, close:number[]}]");
296
+ }
297
+ const refDates = firstArr.map((p) => p.date);
298
+ const checkPoint = (p: SeriesPoint) => {
299
+ if (!p?.date) throw new Error("Missing date");
300
+ if (!Array.isArray(p.close) || p.close.length === 0) throw new Error(`Empty close array at ${p.date}`);
301
+ if (p.close.length > 500) throw new Error(`close array exceeds 500 at ${p.date}`);
302
+ for (const v of p.close)
303
+ if (typeof v !== "number" || !Number.isFinite(v)) throw new Error(`Non numeric close in array at ${p.date}`);
304
+ };
305
+ for (const k of keys) {
306
+ const arr = json[k];
307
+ if (!Array.isArray(arr) || arr.length !== firstArr.length) throw new Error("All series must have the same length");
308
+ for (let i = 0; i < arr.length; i++) {
309
+ const p = arr[i];
310
+ checkPoint(p);
311
+ if (p.date !== refDates[i]) throw new Error("Date misalignment across assets");
312
+ }
313
+ }
314
+ const dsDates = firstArr.map((d) => d.date);
315
+ const id = stableHash(JSON.stringify({ assets: keys, dates: dsDates }));
316
+ const name = f.name.replace(/\.[^.]+$/, "");
317
+ persistDatasetLocal(id, json, keys, dsDates, name);
318
+ loadDatasetIntoState(id, json, keys, dsDates, name);
319
+
320
+ try {
321
+ await apiUpsertDataset({ dataset_id: id, name, data: json, assets: keys, dates: dsDates });
322
+ await apiUpsertAnnotation({
323
+ dataset_id: id,
324
+ user_id: userId,
325
+ selections: [],
326
+ observations: [], // NEW
327
+ step: 1,
328
+ window_len: Math.min(START_DAY, Math.max(1, dsDates.length)),
329
+ } as any);
330
+ } catch (err: any) {
331
+ console.warn("Upload to backend failed:", err?.message || err);
332
+ }
333
+ } catch (err: any) {
334
+ setMessage("Failed to parse JSON: " + err.message);
335
+ }
336
+ };
337
+ reader.readAsText(f);
338
+ }
339
 
340
+ /* ---------- computed ---------- */
341
+ const isFinal = windowLen >= maxDays || selections.length >= maxSteps;
342
+
343
+ // Daily (normalized to day 0)
344
  const windowData = useMemo(() => {
345
  if (!dates.length || windowLen < 2) return [] as any[];
346
  const sliceDates = dates.slice(0, windowLen);
347
  return sliceDates.map((date, idx) => {
348
  const row: Record<string, any> = { date };
349
  assets.forEach((a) => {
350
+ const base = rawData[a]?.[0] ? latestClose(rawData[a][0] as any) : 1;
351
+ const val = rawData[a]?.[idx] ? latestClose((rawData[a] as any)[idx]) : base;
352
  row[a] = base ? val / base : 1;
353
  });
354
  return row;
355
  });
356
  }, [assets, dates, windowLen, rawData]);
357
 
358
+ // Intraday (single day, normalized to first tick of the day)
359
+ const visibleDates = useMemo(() => dates.slice(0, Math.max(0, windowLen)), [dates, windowLen]);
360
+ const activeIntradayIdx = intradayDayIdx ?? Math.min(windowLen - 1, dates.length - 1);
361
+ const intradayData = useMemo(() => {
362
+ if (!dates.length || activeIntradayIdx == null || activeIntradayIdx < 0) return [] as any[];
363
+ const maxBars = assets.reduce((m, a) => Math.max(m, (rawData[a] as any)?.[activeIntradayIdx]?.close?.length ?? 0), 0);
364
+ const dayFirstPrice: Record<string, number> = {};
365
+ assets.forEach((a) => {
366
+ const arr = (rawData[a] as any)?.[activeIntradayIdx]?.close ?? [];
367
+ dayFirstPrice[a] = arr.length ? arr[0] : 1;
368
+ });
369
+ const rows: any[] = [];
370
+ for (let i = 0; i < maxBars; i++) {
371
+ const row: Record<string, any> = { idx: i + 1 };
372
+ assets.forEach((a) => {
373
+ const arr = (rawData[a] as any)?.[activeIntradayIdx]?.close ?? [];
374
+ const val = arr[i];
375
+ row[a] = typeof val === "number" ? (dayFirstPrice[a] ? val / dayFirstPrice[a] : null) : null;
376
+ });
377
+ rows.push(row);
378
+ }
379
+ return rows;
380
+ }, [assets, rawData, dates.length, activeIntradayIdx]);
381
+
382
  function realizedNextDayReturn(asset: string) {
383
  const t = windowLen - 1;
384
  if (t + 1 >= dates.length) return null as any;
385
+ const series = rawData[asset] as any;
386
+ const ret = latestClose(series[t + 1]) / latestClose(series[t]) - 1;
387
  return { date: dates[t + 1], ret };
388
  }
389
 
390
  function loadExample() {
391
  const example = generateFakeJSON();
392
  const keys = Object.keys(example);
393
+ const first = example[keys[0]] as any[];
394
+ const dsDates = first.map((d) => d.date);
395
+ const id = stableHash(JSON.stringify({ assets: keys, dates: dsDates }));
396
+ persistDatasetLocal(id, example, keys, dsDates, "Example");
397
+ loadDatasetIntoState(id, example, keys, dsDates, "Example");
 
 
 
 
 
398
  }
399
 
400
  function resetSession() {
401
  setSelections([]);
402
+ setObservations([]); // NEW
403
  setSelectedAsset(null);
404
  setStep(1);
405
+ setWindowLen(Math.min(START_DAY, Math.max(1, dates.length)));
406
  setMessage("Session reset.");
407
+ setViewMode("daily");
408
+ setIntradayDayIdx(null);
409
+ if (datasetId) localStorage.removeItem(LS_ANN_PREFIX(datasetId, userId));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
410
  }
411
 
412
  function exportLog() {
 
414
  const url = URL.createObjectURL(blob);
415
  const a = document.createElement("a");
416
  a.href = url;
417
+ a.download = `selections_${new Date().toISOString().slice(0, 10)}_${userId}.json`;
418
  a.click();
419
  URL.revokeObjectURL(url);
420
  }
421
 
422
  function confirmSelection() {
423
  if (confirming) return;
424
+ if (!selectedAsset) {
425
+ setMessage("Select a line first.");
426
+ return;
427
+ }
428
  setConfirming(true);
429
 
430
  const res = realizedNextDayReturn(selectedAsset);
 
433
  setConfirming(false);
434
  return;
435
  }
436
+
437
+ // NEW: record observation delta for the newly revealed day (t+1)
438
+ const newDayIdx = windowLen; // currently visible: [0..windowLen-1], newly revealed day is index windowLen
439
+ if (newDayIdx < dates.length) {
440
+ try {
441
+ const daily_close: Record<string, number> = {};
442
+ for (const a of assets) {
443
+ const sp = (rawData[a] as any)?.[newDayIdx];
444
+ if (sp) daily_close[a] = latestClose(sp);
445
+ }
446
+ const obs: ObservationDelta = {
447
+ step,
448
+ window_len_before: windowLen,
449
+ new_day_idx: newDayIdx,
450
+ new_date: dates[newDayIdx],
451
+ daily_close,
452
+ };
453
+ setObservations((prev) => [...prev, obs]);
454
+ } catch (e) {
455
+ console.warn("Failed to record observation:", e);
456
+ }
457
+ }
458
+
459
  const entry = { step, date: res.date, asset: selectedAsset, ret: res.ret };
460
  setSelections((prev) => [...prev, entry]);
461
+ setWindowLen((w) => Math.min(w + 1, maxDays));
 
462
  setStep((s) => s + 1);
463
  setSelectedAsset(null);
464
  setConfirming(false);
465
+ setMessage(`Pick ${step}: ${aliasOf(selectedAsset)} → next-day return ${(res.ret * 100).toFixed(2)}%`);
466
  }
467
 
468
  const portfolioSeries = useMemo(() => {
 
485
  const wins = rets.filter((r) => r > 0).length;
486
  return { cumRet: cum - 1, stdev, sharpe, wins, N };
487
  }, [portfolioSeries, selections]);
488
+
489
  function buildFinalPayload() {
490
+ const countsAll = assets.reduce((acc: Record<string, number>, a: string) => {
491
+ acc[a] = 0;
492
+ return acc;
493
+ }, {} as Record<string, number>);
494
+ selections.forEach((s) => {
495
+ countsAll[s.asset] = (countsAll[s.asset] || 0) + 1;
496
+ });
497
+ const rankAll = assets
498
+ .map((a) => ({ asset: a, votes: countsAll[a] || 0 }))
499
+ .sort((x, y) => y.votes - x.votes);
500
 
501
  let value = 1;
502
+ const portfolio = selections.map((s) => {
503
+ value *= 1 + s.ret;
504
+ return { step: s.step, date: s.date, value };
505
+ });
506
 
507
+ const lastStep2 = selections.reduce((m, s) => Math.max(m, s.step), 0);
508
+ const start30_ = Math.max(1, lastStep2 - 30 + 1);
509
+ const lastCols = Array.from(
510
+ { length: Math.min(30, lastStep2 ? lastStep2 - start30_ + 1 : 0) },
511
+ (_, i) => start30_ + i
512
+ );
513
+ const heatGrid = assets.map((a) => ({
514
+ asset: a,
515
+ cells: lastCols.map((c) => (selections.some((s) => s.asset === a && s.step === c) ? 1 : 0)),
516
+ }));
517
 
518
  const rets = selections.map((s) => s.ret);
519
  const N = rets.length;
 
525
  const wins = rets.filter((r) => r > 0).length;
526
 
527
  return {
528
+ meta: {
529
+ saved_at: new Date().toISOString(),
530
+ start_day: START_DAY,
531
+ max_days: maxDays,
532
+ max_steps: maxSteps,
533
+ dataset_id: datasetId,
534
+ dataset_name: datasetName,
535
+ },
536
  assets,
537
  dates,
538
  selections,
539
+ observations, // NEW
540
  portfolio,
541
+ stats: { cumRet: cum - 1, stdev, sharpe, wins, N },
542
  preference_all: rankAll,
543
  heatmap_last30: { cols: lastCols, grid: heatGrid },
544
  };
545
  }
546
 
547
  useEffect(() => {
548
+ const final = windowLen >= maxDays || selections.length >= maxSteps;
549
+ if (final && !finalSaved) {
550
  try {
551
  const payload = buildFinalPayload();
552
  localStorage.setItem("asset_experiment_final", JSON.stringify(payload));
 
554
  const url = URL.createObjectURL(blob);
555
  const a = document.createElement("a");
556
  a.href = url;
557
+ a.download = `run_summary_${new Date().toISOString().slice(0, 10)}_${userId}.json`;
558
  a.click();
559
  URL.revokeObjectURL(url);
560
  setFinalSaved(true);
 
562
  console.warn("Failed to save final JSON:", e);
563
  }
564
  }
565
+ // eslint-disable-next-line react-hooks/exhaustive-deps
566
+ }, [windowLen, selections.length, finalSaved, maxDays, maxSteps]);
567
 
 
568
  useEffect(() => {
569
  function onKey(e: KeyboardEvent) {
570
  const tag = (e.target && (e.target as HTMLElement).tagName) || "";
571
  if (tag === "INPUT" || tag === "TEXTAREA") return;
572
  const idx = parseInt((e as any).key, 10) - 1;
573
+ if (!Number.isNaN(idx) && idx >= 0 && idx < assets.length) setSelectedAsset(assets[idx]);
574
+ if ((e as any).key === "Enter" && Boolean(selectedAsset) && windowLen < maxDays) confirmSelection();
 
 
 
 
575
  }
576
  window.addEventListener("keydown", onKey);
577
  return () => window.removeEventListener("keydown", onKey);
578
+ }, [assets, selectedAsset, windowLen, maxDays]);
579
 
580
+ if (!datasetId) {
581
  return (
582
+ <div className="p-6">
583
+ <h1 className="text-xl font-semibold mb-3">Asset Choice Simulation</h1>
584
+ <p className="text-sm text-gray-600 mb-2">
585
+ Upload a dataset (new format) or load example. Data persists locally & on the Space backend.
586
+ </p>
587
+ <input type="file" accept="application/json" onChange={onFile} className="text-sm" />
588
+ <div className="mt-4">
589
+ <button
590
+ onClick={loadExample}
591
+ className="text-sm px-3 py-1.5 rounded-xl bg-blue-100 text-blue-800 hover:bg-blue-200"
592
+ >
593
+ Load Example
594
+ </button>
595
+ </div>
596
+ </div>
597
  );
598
  }
599
 
600
+ const IntradayControls = () => (
601
+ <div className="flex items-center gap-2">
602
+ <span className="text-xs px-2 py-1 rounded bg-indigo-50 text-indigo-700">
603
+ Intraday • {visibleDates[intradayDayIdx ?? 0] || "-"}
604
+ </span>
605
+ <button
606
+ onClick={() => setIntradayDayIdx((i) => (i == null ? 0 : Math.max(0, i - 1)))}
607
+ className="text-xs px-2 py-1 rounded-xl border border-gray-200 hover:bg-gray-50"
608
+ disabled={(intradayDayIdx ?? 0) <= 0}
609
+ title="Prev day"
610
+ >
611
+
612
+ </button>
613
+ <select
614
+ className="text-xs px-2 py-1 rounded-xl border border-gray-200"
615
+ value={intradayDayIdx ?? 0}
616
+ onChange={(e) => setIntradayDayIdx(Number(e.target.value))}
617
+ >
618
+ {visibleDates.map((d, idx) => (
619
+ <option key={d} value={idx}>
620
+ {d}
621
+ </option>
622
+ ))}
623
+ </select>
624
+ <button
625
+ onClick={() =>
626
+ setIntradayDayIdx((i) => (i == null ? 0 : Math.min(visibleDates.length - 1, i + 1)))
627
+ }
628
+ className="text-xs px-2 py-1 rounded-xl border border-gray-200 hover:bg-gray-50"
629
+ disabled={(intradayDayIdx ?? 0) >= visibleDates.length - 1}
630
+ title="Next day"
631
+ >
632
+
633
+ </button>
634
+ </div>
635
+ );
636
+
637
  return (
638
  <div className="p-6 bg-gray-50 min-h-screen flex flex-col gap-6">
639
  <div className="flex flex-wrap justify-between items-center gap-3">
640
  <div className="flex items-center gap-2">
641
  <h1 className="text-xl font-semibold">Asset Choice Simulation</h1>
642
+ <span className="text-[10px] ml-2 px-1.5 py-0.5 rounded bg-amber-200 text-amber-900">
643
+ BUILD_TAG: 2025-10-28
644
+ </span>
645
+ <span className="text-xs text-gray-500">Dataset: {datasetName || datasetId}</span>
646
+ <span className="text-xs text-gray-500">User: {userId.slice(0, 8)}…</span>
647
+ <span className="text-xs text-gray-500">
648
+ Day {windowLen} / {maxDays}
649
+ </span>
650
  </div>
651
+ <div className="flex flex-wrap items-center gap-2">
652
  <input type="file" accept="application/json" onChange={onFile} className="text-sm" />
653
+ <button
654
+ onClick={loadExample}
655
+ className="text-sm px-3 py-1.5 rounded-xl bg-blue-100 text-blue-800 hover:bg-blue-200"
656
+ >
657
+ Load Example
658
+ </button>
659
+ <button
660
+ onClick={resetSession}
661
+ className="text-sm px-3 py-1.5 rounded-xl bg-gray-200 hover:bg-gray-300"
662
+ >
663
+ Reset (Keep data)
664
+ </button>
665
+
666
+ <div className="flex rounded-xl overflow-hidden border border-gray-200">
667
+ <button
668
+ className={`text-xs px-3 py-1.5 ${
669
+ viewMode === "daily"
670
+ ? "bg-gray-900 text-white"
671
+ : "bg-white text-gray-700 hover:bg-gray-50"
672
+ }`}
673
+ onClick={() => setViewMode("daily")}
674
+ >
675
+ Daily
676
+ </button>
677
+ <button
678
+ className={`text-xs px-3 py-1.5 ${
679
+ viewMode === "intraday"
680
+ ? "bg-gray-900 text-white"
681
+ : "bg-white text-gray-700 hover:bg-gray-50"
682
+ }`}
683
+ onClick={() => setViewMode("intraday")}
684
+ >
685
+ Intraday (1 day)
686
+ </button>
687
+ </div>
688
+
689
+ <button
690
+ onClick={exportLog}
691
+ className="text-sm px-3 py-1.5 rounded-xl bg-gray-900 text-white hover:bg-black"
692
+ >
693
+ Export My Log
694
+ </button>
695
  </div>
696
  </div>
697
 
698
  <div className="bg-white p-4 rounded-2xl shadow">
 
699
  <div className="flex flex-wrap items-center gap-2 mb-3">
700
  {assets.map((a, i) => (
701
  <button
702
  key={`pick-${a}`}
703
  onClick={() => setSelectedAsset(a)}
704
+ className={`text-xs px-2 py-1 rounded-xl border transition ${
705
+ selectedAsset === a
706
+ ? "bg-blue-600 text-white border-blue-600"
707
+ : "bg-white text-gray-700 border-gray-200 hover:bg-gray-50"
708
+ }`}
709
+ title={`Hotkey ${i + 1}`}
710
  >
711
+ <span
712
+ className="inline-block w-2.5 h-2.5 rounded-full mr-2"
713
+ style={{ backgroundColor: `hsl(${(360 / assets.length) * i},70%,50%)` }}
714
+ />
715
+ {i + 1}. {aliasOf(a)}
716
  </button>
717
  ))}
718
+ {assets.length > 0 && (
719
+ <span className="text-xs text-gray-500 ml-1">Hotkeys: 1..{assets.length}, Enter confirm</span>
720
  )}
721
  </div>
722
+
723
+ <div className="h-80 relative">
724
  <ResponsiveContainer width="100%" height="100%">
725
+ {viewMode === "intraday" ? (
726
+ <LineChart data={intradayData}>
727
+ <CartesianGrid strokeDasharray="3 3" />
728
+ <XAxis dataKey="idx" tick={{ fontSize: 10 }} />
729
+ <YAxis domain={["auto", "auto"]} tick={{ fontSize: 10 }} />
730
+ <Tooltip
731
+ contentStyle={{ fontSize: 12 }}
732
+ formatter={(v: any, n: any) => [v, aliasMap[n] || n]}
 
 
 
 
 
 
 
 
 
 
 
733
  />
734
+ <Legend
735
+ onClick={(o: any) => setSelectedAsset(o.value)}
736
+ wrapperStyle={{ cursor: "pointer" }}
737
+ formatter={(v: any) => aliasMap[v] || v}
738
+ />
739
+ {assets.map((a, i) => (
740
+ <Line
741
+ key={a}
742
+ type="linear"
743
+ dataKey={a}
744
+ name={aliasMap[a] || a}
745
+ strokeWidth={selectedAsset === a ? 5 : hoverAsset === a ? 4 : 2.5}
746
+ strokeOpacity={selectedAsset && selectedAsset !== a ? 0.3 : 1}
747
+ dot={false}
748
+ isAnimationActive={false}
749
+ stroke={`hsl(${(360 / assets.length) * i},70%,50%)`}
750
+ onMouseEnter={() => setHoverAsset(a)}
751
+ onMouseLeave={() => setHoverAsset(null)}
752
+ onClick={() => setSelectedAsset((p) => (p === a ? null : a))}
753
+ connectNulls={false}
754
+ />
755
+ ))}
756
+ </LineChart>
757
+ ) : (
758
+ <LineChart data={windowData}>
759
+ <CartesianGrid strokeDasharray="3 3" />
760
+ <XAxis dataKey="date" tick={{ fontSize: 10 }} />
761
+ <YAxis domain={["auto", "auto"]} tick={{ fontSize: 10 }} />
762
+ <Tooltip
763
+ contentStyle={{ fontSize: 12 }}
764
+ formatter={(v: any, n: any) => [v, aliasMap[n] || n]}
765
+ />
766
+ <Legend
767
+ onClick={(o: any) => setSelectedAsset(o.value)}
768
+ wrapperStyle={{ cursor: "pointer" }}
769
+ formatter={(v: any) => aliasMap[v] || v}
770
+ />
771
+ {assets.map((a, i) => (
772
+ <Line
773
+ key={a}
774
+ type="natural"
775
+ dataKey={a}
776
+ name={aliasMap[a] || a}
777
+ strokeWidth={selectedAsset === a ? 5 : hoverAsset === a ? 4 : 2.5}
778
+ strokeOpacity={selectedAsset && selectedAsset !== a ? 0.3 : 1}
779
+ dot={false}
780
+ isAnimationActive={false}
781
+ stroke={`hsl(${(360 / assets.length) * i},70%,50%)`}
782
+ onMouseEnter={() => setHoverAsset(a)}
783
+ onMouseLeave={() => setHoverAsset(null)}
784
+ onClick={() => setSelectedAsset((p) => (p === a ? null : a))}
785
+ />
786
+ ))}
787
+ </LineChart>
788
+ )}
789
  </ResponsiveContainer>
790
  </div>
791
 
792
+ {viewMode === "intraday" && (
793
+ <div className="mt-3">
794
+ <IntradayControls />
795
+ </div>
796
+ )}
797
+
798
+ {viewMode !== "intraday" && (
799
+ <div className="flex justify-between items-center mt-3">
800
+ <div className="text-sm text-gray-600">
801
+ Selected: {selectedAsset ? aliasOf(selectedAsset) : "(none)"}{" "}
802
+ {message && <span className="ml-2 text-gray-500">{message}</span>}
803
+ </div>
804
+ <button
805
+ onClick={confirmSelection}
806
+ disabled={!selectedAsset || windowLen >= maxDays}
807
+ className={`px-4 py-2 rounded-xl ${
808
+ selectedAsset && windowLen < maxDays ? "bg-blue-600 text-white" : "bg-gray-200 text-gray-500"
809
+ }`}
810
+ >
811
+ Confirm & Next Day →
812
+ </button>
813
+ </div>
814
+ )}
815
  </div>
816
 
817
  <div className="bg-white p-4 rounded-2xl shadow">
 
831
  <li>Cumulative Return: {(stats.cumRet * 100).toFixed(2)}%</li>
832
  <li>Volatility: {(stats.stdev * 100).toFixed(2)}%</li>
833
  <li>Sharpe: {stats.sharpe.toFixed(2)}</li>
834
+ <li>
835
+ Winning Days: {stats.wins}/{stats.N}
836
+ </li>
837
  </ul>
838
  </div>
839
 
 
840
  <div className="bg-white p-4 rounded-2xl shadow">
841
  <h2 className="font-medium mb-2">Daily Selections</h2>
842
  <div className="overflow-auto rounded-xl border border-gray-100">
 
850
  </tr>
851
  </thead>
852
  <tbody>
853
+ {selections
854
+ .slice()
855
+ .reverse()
856
+ .map((s) => (
857
+ <tr key={`${s.step}-${s.asset}-${s.date}`} className="odd:bg-white even:bg-gray-50">
858
+ <td className="px-2 py-1">{s.step}</td>
859
+ <td className="px-2 py-1">{s.date}</td>
860
+ <td className="px-2 py-1">{aliasOf(s.asset)}</td>
861
+ <td className={`px-2 py-1 text-right ${s.ret >= 0 ? "text-green-600" : "text-red-600"}`}>
862
+ {(s.ret * 100).toFixed(2)}%
863
+ </td>
864
+ </tr>
865
+ ))}
866
  </tbody>
867
  </table>
868
  </div>
 
871
  );
872
  }
873
 
874
+ /** Final summary component (UI shows aliases, data keeps real tickers) */
875
+ export function FinalSummary({
876
+ assets,
877
+ selections,
878
+ }: {
879
+ assets: string[];
880
+ selections: { step: number; date: string; asset: string; ret: number }[];
881
+ }) {
882
+ const rets = selections.map((s) => s.ret);
883
  const N = rets.length;
884
+ const cum = rets.reduce((v, r) => v * (1 + r), 1) - 1;
885
+ const mean = N ? rets.reduce((a, b) => a + b, 0) / N : 0;
886
+ const variance = N ? rets.reduce((a, b) => a + (b - mean) ** 2, 0) / N : 0;
887
  const stdev = Math.sqrt(variance);
888
+ const sharpe = stdev ? (mean * 252) / (stdev * Math.sqrt(252)) : 0;
889
+ const wins = rets.filter((r) => r > 0).length;
890
+
891
+ const countsAll: Record<string, number> = assets.reduce((acc: any, a: string) => {
892
+ acc[a] = 0;
893
+ return acc;
894
+ }, {} as Record<string, number>);
895
+ selections.forEach((s) => {
896
+ countsAll[s.asset] = (countsAll[s.asset] || 0) + 1;
897
+ });
898
+ const rankAll = assets.map((a) => ({ asset: a, votes: countsAll[a] || 0 })).sort((x, y) => y.votes - x.votes);
899
+
900
+ const aliasMap = useMemo(() => {
901
+ const m: Record<string, string> = {};
902
+ assets.forEach((a, i) => {
903
+ m[a] = `Ticker ${i + 1}`;
904
+ });
905
+ return m;
906
+ }, [assets]);
907
+
908
+ const rankAllForChart = useMemo(() => {
909
+ return rankAll.map((r) => ({ asset: aliasMap[r.asset] || r.asset, votes: r.votes }));
910
+ }, [rankAll, aliasMap]);
911
+
912
+ const lastStep = selections.reduce((m, s) => Math.max(m, s.step), 0);
913
  const start30 = Math.max(1, lastStep - 30 + 1);
914
+ const cols = Array.from({ length: Math.min(30, lastStep ? lastStep - start30 + 1 : 0) }, (_, i) => start30 + i);
915
+ const grid = assets.map((a) => ({
916
+ asset: aliasMap[a] || a,
917
+ cells: cols.map((c) => (selections.some((s) => s.asset === a && s.step === c) ? 1 : 0)),
918
  }));
919
 
920
  return (
921
  <div className="p-6 bg-gray-50 min-h-screen flex flex-col gap-6">
922
  <h1 className="text-xl font-semibold">Final Summary</h1>
 
923
  <div className="bg-white p-4 rounded-2xl shadow">
924
  <h2 className="font-medium mb-2">Overall Metrics</h2>
925
  <ul className="text-sm text-gray-700 space-y-1">
926
  <li>Total Picks: {N}</li>
927
+ <li>Win Rate: {(N ? (wins / N) * 100 : 0).toFixed(1)}%</li>
928
+ <li>Cumulative Return: {(cum * 100).toFixed(2)}%</li>
929
+ <li>Volatility: {(stdev * 100).toFixed(2)}%</li>
930
  <li>Sharpe (rough): {sharpe.toFixed(2)}</li>
931
+ <li>
932
+ Top Preference: {aliasMap[rankAll[0]?.asset ?? ""] ?? "-"} ({rankAll[0]?.votes ?? 0})
933
+ </li>
934
  </ul>
935
  </div>
 
936
  <div className="bg-white p-4 rounded-2xl shadow">
937
+ <h2 className="font-medium mb-2">Selection Preference Ranking</h2>
938
  <div className="h-56">
939
  <ResponsiveContainer width="100%" height="100%">
940
+ <BarChart data={rankAllForChart} margin={{ left: 8, right: 8, top: 8, bottom: 8 }}>
941
  <CartesianGrid strokeDasharray="3 3" />
942
  <XAxis dataKey="asset" tick={{ fontSize: 10 }} />
943
  <YAxis allowDecimals={false} tick={{ fontSize: 10 }} />
 
947
  </ResponsiveContainer>
948
  </div>
949
  </div>
 
950
  <div className="bg-white p-4 rounded-2xl shadow">
951
+ <h2 className="font-medium mb-2">Selection Heatmap (Last 30 Steps)</h2>
952
  <div className="overflow-auto">
953
  <table className="text-xs border-collapse">
954
  <thead>
955
  <tr>
956
  <th className="p-1 pr-2 text-left sticky left-0 bg-white">Asset</th>
957
+ {cols.map((c) => (
958
+ <th key={c} className="px-1 py-1 text-center">
959
+ {c}
960
+ </th>
961
  ))}
962
  </tr>
963
  </thead>
964
  <tbody>
965
+ {grid.map((row) => (
966
  <tr key={row.asset}>
967
  <td className="p-1 pr-2 font-medium sticky left-0 bg-white">{row.asset}</td>
968
+ {row.cells.map((v, j) => (
969
+ <td
970
+ key={j}
971
+ className="w-6 h-6"
972
+ style={{
973
+ background: v ? "#2563eb" : "#e5e7eb",
974
+ opacity: v ? 0.9 : 1,
975
+ border: "1px solid #ffffff",
976
+ }}
977
+ />
978
  ))}
979
  </tr>
980
  ))}