Yan Wang commited on
Commit
b029aed
·
1 Parent(s): be72531

updating App_New

Browse files
Files changed (1) hide show
  1. src/App_New.tsx +882 -860
src/App_New.tsx CHANGED
@@ -1,860 +1,882 @@
1
- import { useEffect, useMemo, useState } from "react";
2
- import {
3
- LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, Legend, CartesianGrid,
4
- AreaChart, Area, BarChart, Bar, ReferenceLine
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
- type ViewMode = "daily" | "intraday" | "multi";
16
-
17
- const START_DAY = 7;
18
-
19
- /* --------------------- utils --------------------- */
20
- function latestClose(p: SeriesPoint): number {
21
- const arr = p.close;
22
- if (!Array.isArray(arr) || arr.length === 0) throw new Error(`Empty close array at ${p.date}`);
23
- const last = arr[arr.length - 1];
24
- if (!Number.isFinite(last)) throw new Error(`Invalid close value at ${p.date}`);
25
- return last;
26
- }
27
- function uuidv4() {
28
- return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, c => {
29
- const r = (Math.random() * 16) | 0, v = c === "x" ? r : (r & 0x3) | 0x8;
30
- return v.toString(16);
31
- });
32
- }
33
- function getOrCreateUserId() {
34
- const k = "annot_user_id";
35
- let uid = localStorage.getItem(k);
36
- if (!uid) { uid = uuidv4(); localStorage.setItem(k, uid); }
37
- return uid;
38
- }
39
- function stableHash(str: string): string {
40
- let h = 5381;
41
- for (let i = 0; i < str.length; i++) h = (h * 33) ^ str.charCodeAt(i);
42
- return (h >>> 0).toString(16);
43
- }
44
-
45
- /** Example data in the new format (close is a rolling intraday array up to 500) */
46
- function generateFakeJSON(maxLen = 500): DataDict {
47
- const randTicker = () => Math.random().toString(36).substring(2, 6).toUpperCase();
48
- const tickers = Array.from({ length: 5 }, () => randTicker());
49
- const start = new Date("2024-01-02T00:00:00Z");
50
- const dates = Array.from({ length: 67 }, (_, i) => {
51
- const d = new Date(start); d.setDate(start.getDate() + i);
52
- return d.toISOString().slice(0, 10);
53
- });
54
- const out: DataDict = {};
55
- for (let a = 0; a < 5; a++) {
56
- const ticker = tickers[a];
57
- let price = 80 + Math.random() * 40;
58
- const mu = (Math.random() * 0.1 - 0.05) / 252;
59
- const sigma = 0.15 + Math.random() * 0.35;
60
- const series: SeriesPoint[] = [];
61
- for (let i = 0; i < dates.length; i++) {
62
- // Fake minute series (varying length)
63
- const mins = 200 + Math.floor(Math.random() * 100);
64
- const intraday: number[] = [];
65
- let p = price;
66
- for (let m = 0; m < mins; m++) {
67
- const dz = (Math.random() - 0.5) * 0.004;
68
- p = Math.max(0.01, p * (1 + dz));
69
- intraday.push(Number(p.toFixed(2)));
70
- }
71
- series.push({ date: dates[i], close: intraday });
72
- const drift = mu + (sigma / Math.sqrt(252)) * ((Math.random() - 0.5) * 2.0);
73
- price = intraday[intraday.length - 1] * (1 + drift);
74
- }
75
- out[ticker] = series;
76
- }
77
- return out;
78
- }
79
-
80
- /* --------------------- component --------------------- */
81
- export default function App() {
82
- const userId = getOrCreateUserId();
83
-
84
- const [datasetId, setDatasetId] = useState<string | null>(null);
85
- const [datasetName, setDatasetName] = useState<string>("");
86
-
87
- const [rawData, setRawData] = useState<DataDict>({});
88
- const [assets, setAssets] = useState<string[]>([]);
89
- const [dates, setDates] = useState<string[]>([]);
90
-
91
- // Dynamic caps
92
- const maxDays = dates.length || 67;
93
- const maxSteps = Math.max(0, maxDays - START_DAY);
94
-
95
- const [step, setStep] = useState(1);
96
- const [windowLen, setWindowLen] = useState(START_DAY);
97
- const [selectedAsset, setSelectedAsset] = useState<string | null>(null);
98
- const [hoverAsset, setHoverAsset] = useState<string | null>(null);
99
- const [selections, setSelections] = useState<{ step: number; date: string; asset: string; ret: number }[]>([]);
100
- const [message, setMessage] = useState("");
101
- const [confirming, setConfirming] = useState(false);
102
- const [finalSaved, setFinalSaved] = useState(false);
103
-
104
- const [viewMode, setViewMode] = useState<ViewMode>("daily");
105
- const [intradayDayIdx, setIntradayDayIdx] = useState<number | null>(null);
106
- const [multiNormalize, setMultiNormalize] = useState<boolean>(true); // toggle for multi view
107
-
108
- /* ---------- localStorage keys ---------- */
109
- const LS_DATASET_KEY = "annot_dataset_meta";
110
- const LS_DATA_PREFIX = (id: string) => `annot_dataset_${id}`;
111
- const LS_ANN_PREFIX = (id: string, uid: string) => `annot_user_${uid}_ds_${id}`;
112
-
113
- /* ---------- alias map ---------- */
114
- const aliasMap = useMemo(() => {
115
- const m: Record<string, string> = {};
116
- assets.forEach((a, i) => { m[a] = `Ticker ${i + 1}`; });
117
- return m;
118
- }, [assets]);
119
- const aliasOf = (a?: string | null) => (a && aliasMap[a]) || a || "";
120
-
121
- /* ---------- boot ---------- */
122
- useEffect(() => {
123
- const boot = async () => {
124
- try {
125
- const metaRaw = localStorage.getItem(LS_DATASET_KEY);
126
- if (metaRaw) {
127
- const meta = JSON.parse(metaRaw);
128
- if (meta?.datasetId) {
129
- const dJson = localStorage.getItem(LS_DATA_PREFIX(meta.datasetId));
130
- if (dJson) {
131
- const payload = JSON.parse(dJson) as { data: DataDict; assets: string[]; dates: string[]; name?: string };
132
- loadDatasetIntoState(meta.datasetId, payload.data, payload.assets, payload.dates, payload.name || "");
133
- await loadUserAnnotation(meta.datasetId);
134
- return;
135
- }
136
- }
137
- }
138
- try {
139
- const ds = await apiGetLatestDataset();
140
- const id = (ds.id ?? ds.dataset_id) as string;
141
- const name = ds.name || "";
142
- const data = ds.data as DataDict;
143
- const dsAssets = ds.assets as string[];
144
- const dsDates = ds.dates as string[];
145
- persistDatasetLocal(id, data, dsAssets, dsDates, name);
146
- loadDatasetIntoState(id, data, dsAssets, dsDates, name);
147
- await loadUserAnnotation(id);
148
- return;
149
- } catch {}
150
- const example = generateFakeJSON();
151
- const keys = Object.keys(example);
152
- const dsDates = example[keys[0]].map(d => d.date);
153
- const id = stableHash(JSON.stringify({ assets: keys, dates: dsDates }));
154
- persistDatasetLocal(id, example, keys, dsDates, "Example");
155
- loadDatasetIntoState(id, example, keys, dsDates, "Example");
156
- } catch (e) {
157
- console.warn("Boot failed:", e);
158
- }
159
- };
160
- boot();
161
- // eslint-disable-next-line react-hooks/exhaustive-deps
162
- }, []);
163
-
164
- function persistDatasetLocal(id: string, data: DataDict, a: string[], d: string[], name: string) {
165
- localStorage.setItem(LS_DATASET_KEY, JSON.stringify({ datasetId: id, name }));
166
- localStorage.setItem(LS_DATA_PREFIX(id), JSON.stringify({ data, assets: a, dates: d, name }));
167
- }
168
- function loadDatasetIntoState(id: string, data: DataDict, a: string[], d: string[], name: string) {
169
- setDatasetId(id);
170
- setDatasetName(name || "");
171
- setRawData(data);
172
- setAssets(a);
173
- setDates(d);
174
- setStep(1);
175
- setWindowLen(Math.min(START_DAY, Math.max(1, d.length)));
176
- setSelections([]);
177
- setSelectedAsset(null);
178
- setViewMode("daily");
179
- setIntradayDayIdx(null);
180
- setMessage(`Loaded dataset ${name || id}: ${a.length} assets, ${d.length} days.`);
181
- setFinalSaved(false);
182
- }
183
- async function loadUserAnnotation(id: string) {
184
- const localRaw = localStorage.getItem(LS_ANN_PREFIX(id, userId));
185
- if (localRaw) {
186
- try {
187
- const ann = JSON.parse(localRaw);
188
- setSelections(ann.selections || []);
189
- setStep(ann.step || 1);
190
- setWindowLen(ann.window_len || START_DAY);
191
- setIntradayDayIdx(null);
192
- } catch {}
193
- }
194
- try {
195
- const ann = await apiGetAnnotation(id, userId);
196
- setSelections(ann.selections || []);
197
- setStep(ann.step || 1);
198
- setWindowLen(ann.window_len || START_DAY);
199
- setIntradayDayIdx(null);
200
- localStorage.setItem(LS_ANN_PREFIX(id, userId), JSON.stringify(ann));
201
- } catch {}
202
- }
203
- function persistAnnotationLocal() {
204
- if (!datasetId) return;
205
- const ann = { user_id: userId, dataset_id: datasetId, selections, step, window_len: windowLen };
206
- localStorage.setItem(LS_ANN_PREFIX(datasetId, userId), JSON.stringify(ann));
207
- }
208
- async function upsertAnnotationCloud() {
209
- if (!datasetId) return;
210
- try {
211
- await apiUpsertAnnotation({ dataset_id: datasetId, user_id: userId, selections, step, window_len: windowLen });
212
- } catch (e) { console.warn("Upsert annotation failed:", e); }
213
- }
214
- useEffect(() => {
215
- persistAnnotationLocal();
216
- upsertAnnotationCloud();
217
- // eslint-disable-next-line react-hooks/exhaustive-deps
218
- }, [selections, step, windowLen]);
219
-
220
- /* ---------- tie intraday selected day to window ---------- */
221
- useEffect(() => {
222
- if (!dates.length) return;
223
- const lastIdx = Math.min(windowLen - 1, dates.length - 1);
224
- if (intradayDayIdx === null || intradayDayIdx > lastIdx) setIntradayDayIdx(lastIdx);
225
- }, [dates, windowLen, intradayDayIdx]);
226
-
227
- /* ---------- upload handler ---------- */
228
- async function onFile(e: any) {
229
- const f = e.target.files?.[0];
230
- if (!f) return;
231
- const reader = new FileReader();
232
- reader.onload = async () => {
233
- try {
234
- const json: DataDict = JSON.parse(String(reader.result));
235
- const keys = Object.keys(json);
236
- if (keys.length === 0) throw new Error("Empty dataset");
237
- const firstArr = json[keys[0]];
238
- if (!Array.isArray(firstArr) || !firstArr[0]?.date || !Array.isArray(firstArr[0]?.close)) {
239
- throw new Error("Invalid series format. Need [{date, close:number[]}]");
240
- }
241
- const refDates = firstArr.map(p => p.date);
242
- const checkPoint = (p: SeriesPoint) => {
243
- if (!p?.date) throw new Error("Missing date");
244
- if (!Array.isArray(p.close) || p.close.length === 0) throw new Error(`Empty close array at ${p.date}`);
245
- if (p.close.length > 500) throw new Error(`close array exceeds 500 at ${p.date}`);
246
- for (const v of p.close) if (typeof v !== "number" || !Number.isFinite(v)) throw new Error(`Non numeric close in array at ${p.date}`);
247
- };
248
- for (const k of keys) {
249
- const arr = json[k];
250
- if (!Array.isArray(arr) || arr.length !== firstArr.length) throw new Error("All series must have the same length");
251
- for (let i = 0; i < arr.length; i++) {
252
- const p = arr[i]; checkPoint(p);
253
- if (p.date !== refDates[i]) throw new Error("Date misalignment across assets");
254
- }
255
- }
256
- const dsDates = firstArr.map(d => d.date);
257
- const id = stableHash(JSON.stringify({ assets: keys, dates: dsDates }));
258
- const name = f.name.replace(/\.[^.]+$/, "");
259
- persistDatasetLocal(id, json, keys, dsDates, name);
260
- loadDatasetIntoState(id, json, keys, dsDates, name);
261
-
262
- try {
263
- await apiUpsertDataset({ dataset_id: id, name, data: json, assets: keys, dates: dsDates });
264
- await apiUpsertAnnotation({ dataset_id: id, user_id: userId, selections: [], step: 1, window_len: Math.min(START_DAY, Math.max(1, dsDates.length)) });
265
- } catch (err: any) {
266
- console.warn("Upload to backend failed:", err?.message || err);
267
- }
268
- } catch (err: any) {
269
- setMessage("Failed to parse JSON: " + err.message);
270
- }
271
- };
272
- reader.readAsText(f);
273
- }
274
-
275
- /* ---------- computed ---------- */
276
- const isFinal = windowLen >= maxDays || selections.length >= maxSteps;
277
-
278
- // Daily (normalized to day 0)
279
- const windowData = useMemo(() => {
280
- if (!dates.length || windowLen < 2) return [] as any[];
281
- const sliceDates = dates.slice(0, windowLen);
282
- return sliceDates.map((date, idx) => {
283
- const row: Record<string, any> = { date };
284
- assets.forEach((a) => {
285
- const base = rawData[a]?.[0] ? latestClose(rawData[a][0]) : 1;
286
- const val = rawData[a]?.[idx] ? latestClose(rawData[a][idx]) : base;
287
- row[a] = base ? val / base : 1;
288
- });
289
- return row;
290
- });
291
- }, [assets, dates, windowLen, rawData]);
292
-
293
- // Intraday (single day, normalized to first tick of the day)
294
- const visibleDates = useMemo(() => dates.slice(0, Math.max(0, windowLen)), [dates, windowLen]);
295
- const activeIntradayIdx = intradayDayIdx ?? Math.min(windowLen - 1, dates.length - 1);
296
- const intradayData = useMemo(() => {
297
- if (!dates.length || activeIntradayIdx == null || activeIntradayIdx < 0) return [] as any[];
298
- const maxBars = assets.reduce((m, a) => Math.max(m, rawData[a]?.[activeIntradayIdx]?.close?.length ?? 0), 0);
299
- const dayFirstPrice: Record<string, number> = {};
300
- assets.forEach(a => {
301
- const arr = rawData[a]?.[activeIntradayIdx]?.close ?? [];
302
- dayFirstPrice[a] = arr.length ? arr[0] : 1;
303
- });
304
- const rows: any[] = [];
305
- for (let i = 0; i < maxBars; i++) {
306
- const row: Record<string, any> = { idx: i + 1 };
307
- assets.forEach(a => {
308
- const arr = rawData[a]?.[activeIntradayIdx]?.close ?? [];
309
- const val = arr[i];
310
- row[a] = typeof val === "number" ? (dayFirstPrice[a] ? val / dayFirstPrice[a] : null) : null;
311
- });
312
- rows.push(row);
313
- }
314
- return rows;
315
- }, [assets, rawData, dates.length, activeIntradayIdx]);
316
-
317
- // Multi-day intraday with robust carry-forward
318
- const { multiRows, dayStarts } = useMemo(() => {
319
- const rows: any[] = [];
320
- const starts: { x: number; date: string }[] = [];
321
- if (!dates.length || windowLen < 1) return { multiRows: rows, dayStarts: starts };
322
-
323
- // previous minute price & cumulative index across days
324
- const prevPrice: Record<string, number | null> = {};
325
- const cumIdx: Record<string, number> = {};
326
- assets.forEach(a => { prevPrice[a] = null; cumIdx[a] = 1; });
327
-
328
- // helper: forward-fill within the day if possible; otherwise use prevPrice
329
- function resolvePriceForMinute(arr: number[] | undefined, i: number, a: string): number | null {
330
- if (arr && typeof arr[i] === "number") return arr[i]!;
331
- if (prevPrice[a] != null) return prevPrice[a] as number;
332
- // look ahead within the same day to find the first future tick to backfill start-of-day
333
- if (arr && arr.length) {
334
- for (let j = i + 1; j < arr.length; j++) {
335
- if (typeof arr[j] === "number") return arr[j]!;
336
- }
337
- }
338
- return null;
339
- }
340
-
341
- let x = 0;
342
- for (let d = 0; d < Math.min(windowLen, dates.length); d++) {
343
- const startX = x + 1;
344
- starts.push({ x: startX, date: dates[d] });
345
-
346
- const dayLenRaw = assets.reduce((m, a) => Math.max(m, rawData[a]?.[d]?.close?.length ?? 0), 0);
347
- const dayLen = Math.max(1, dayLenRaw); // ensure at least one row per day
348
-
349
- for (let i = 0; i < dayLen; i++) {
350
- const row: Record<string, any> = { x: x + 1, day: dates[d], minute: i + 1 };
351
-
352
- assets.forEach(a => {
353
- const arr = rawData[a]?.[d]?.close;
354
- const resolved = resolvePriceForMinute(arr, i, a);
355
-
356
- if (resolved != null) {
357
- // update cumIdx only when a "new real tick" occurs
358
- if (prevPrice[a] != null && resolved !== prevPrice[a]) {
359
- const r = resolved / (prevPrice[a] as number);
360
- if (Number.isFinite(r) && r > 0) cumIdx[a] *= r;
361
- }
362
- row[a] = multiNormalize ? cumIdx[a] : resolved;
363
- prevPrice[a] = resolved;
364
- } else {
365
- row[a] = null;
366
- }
367
- });
368
-
369
- rows.push(row);
370
- x++;
371
- }
372
- }
373
- return { multiRows: rows, dayStarts: starts };
374
- }, [assets, rawData, dates, windowLen, multiNormalize]);
375
-
376
- function realizedNextDayReturn(asset: string) {
377
- const t = windowLen - 1;
378
- if (t + 1 >= dates.length) return null as any;
379
- const series = rawData[asset];
380
- const ret = latestClose(series[t + 1]) / latestClose(series[t]) - 1;
381
- return { date: dates[t + 1], ret };
382
- }
383
-
384
- function loadExample() {
385
- const example = generateFakeJSON();
386
- const keys = Object.keys(example);
387
- const first = example[keys[0]];
388
- const dsDates = first.map((d) => d.date);
389
- const id = stableHash(JSON.stringify({ assets: keys, dates: dsDates }));
390
- persistDatasetLocal(id, example, keys, dsDates, "Example");
391
- loadDatasetIntoState(id, example, keys, dsDates, "Example");
392
- }
393
-
394
- function resetSession() {
395
- setSelections([]);
396
- setSelectedAsset(null);
397
- setStep(1);
398
- setWindowLen(Math.min(START_DAY, Math.max(1, dates.length)));
399
- setMessage("Session reset.");
400
- setViewMode("daily");
401
- setIntradayDayIdx(null);
402
- if (datasetId) localStorage.removeItem(LS_ANN_PREFIX(datasetId, userId));
403
- }
404
-
405
- function exportLog() {
406
- const blob = new Blob([JSON.stringify(selections, null, 2)], { type: "application/json" });
407
- const url = URL.createObjectURL(blob);
408
- const a = document.createElement("a");
409
- a.href = url;
410
- a.download = `selections_${new Date().toISOString().slice(0,10)}_${userId}.json`;
411
- a.click();
412
- URL.revokeObjectURL(url);
413
- }
414
-
415
- function confirmSelection() {
416
- if (confirming) return;
417
- if (!selectedAsset) { setMessage("Select a line first."); return; }
418
- setConfirming(true);
419
- const res = realizedNextDayReturn(selectedAsset);
420
- if (!res) { setMessage("No more data available."); setConfirming(false); return; }
421
- const entry = { step, date: res.date, asset: selectedAsset, ret: res.ret };
422
- setSelections((prev) => [...prev, entry]);
423
- setWindowLen((w) => Math.min(w + 1, maxDays));
424
- setStep((s) => s + 1);
425
- setSelectedAsset(null);
426
- setConfirming(false);
427
- setMessage(`Pick ${step}: ${aliasOf(selectedAsset)} → next-day return ${(res.ret * 100).toFixed(2)}%`);
428
- }
429
-
430
- const portfolioSeries = useMemo(() => {
431
- let value = 1;
432
- const pts = selections.map((s) => { value *= 1 + s.ret; return { step: s.step, date: s.date, value }; });
433
- return [{ step: 0, date: "start", value: 1 }, ...pts];
434
- }, [selections]);
435
-
436
- const stats = useMemo(() => {
437
- const rets = selections.map((s) => s.ret);
438
- const N = rets.length;
439
- const cum = portfolioSeries.at(-1)?.value ?? 1;
440
- const mean = N ? rets.reduce((a, b) => a + b, 0) / N : 0;
441
- const variance = N ? rets.reduce((a, b) => a + (b - mean) ** 2, 0) / N : 0;
442
- const stdev = Math.sqrt(variance);
443
- const sharpe = stdev ? (mean * 252) / (stdev * Math.sqrt(252)) : 0;
444
- const wins = rets.filter((r) => r > 0).length;
445
- return { cumRet: cum - 1, stdev, sharpe, wins, N };
446
- }, [portfolioSeries, selections]);
447
-
448
- function buildFinalPayload() {
449
- const lastStep = selections.reduce((m, s) => Math.max(m, s.step), 0);
450
- const start30 = Math.max(1, lastStep - 30 + 1);
451
- const countsAll = assets.reduce((acc: Record<string, number>, a: string) => { acc[a] = 0; return acc; }, {} as Record<string, number>);
452
- selections.forEach((s) => { countsAll[s.asset] = (countsAll[s.asset] || 0) + 1; });
453
- const rankAll = assets.map((a) => ({ asset: a, votes: countsAll[a] || 0 })).sort((x, y) => y.votes - x.votes);
454
- let value = 1;
455
- const portfolio = selections.map((s) => { value *= 1 + s.ret; return { step: s.step, date: s.date, value }; });
456
- const lastStep2 = selections.reduce((m, s) => Math.max(m, s.step), 0);
457
- const start30_ = Math.max(1, lastStep2 - 30 + 1);
458
- const lastCols = Array.from({ length: Math.min(30, lastStep2 ? lastStep2 - start30_ + 1 : 0) }, (_, i) => start30_ + i);
459
- const heatGrid = assets.map((a) => ({ asset: a, cells: lastCols.map((c) => (selections.some((s) => s.asset === a && s.step === c) ? 1 : 0)) }));
460
- const rets = selections.map((s) => s.ret);
461
- const N = rets.length;
462
- const cum = portfolio.at(-1)?.value ?? 1;
463
- const mean = N ? rets.reduce((a, b) => a + b, 0) / N : 0;
464
- const variance = N ? rets.reduce((a, b) => a + (b - mean) ** 2, 0) / N : 0;
465
- const stdev = Math.sqrt(variance);
466
- const sharpe = stdev ? (mean * 252) / (stdev * Math.sqrt(252)) : 0;
467
- const wins = rets.filter((r) => r > 0).length;
468
-
469
- return {
470
- meta: { saved_at: new Date().toISOString(), start_day: START_DAY, max_days: maxDays, max_steps: maxSteps, dataset_id: datasetId, dataset_name: datasetName },
471
- assets,
472
- dates,
473
- selections,
474
- portfolio,
475
- stats: { cumRet: (cum - 1), stdev, sharpe, wins, N },
476
- preference_all: rankAll,
477
- heatmap_last30: { cols: lastCols, grid: heatGrid },
478
- };
479
- }
480
-
481
- useEffect(() => {
482
- const final = windowLen >= maxDays || selections.length >= maxSteps;
483
- if (final && !finalSaved) {
484
- try {
485
- const payload = buildFinalPayload();
486
- localStorage.setItem("asset_experiment_final", JSON.stringify(payload));
487
- const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" });
488
- const url = URL.createObjectURL(blob);
489
- const a = document.createElement("a");
490
- a.href = url;
491
- a.download = `run_summary_${new Date().toISOString().slice(0, 10)}_${userId}.json`;
492
- a.click();
493
- URL.revokeObjectURL(url);
494
- setFinalSaved(true);
495
- } catch (e) { console.warn("Failed to save final JSON:", e); }
496
- }
497
- // eslint-disable-next-line react-hooks/exhaustive-deps
498
- }, [windowLen, selections.length, finalSaved, maxDays, maxSteps]);
499
-
500
- useEffect(() => {
501
- function onKey(e: KeyboardEvent) {
502
- const tag = (e.target && (e.target as HTMLElement).tagName) || "";
503
- if (tag === "INPUT" || tag === "TEXTAREA") return;
504
- const idx = parseInt((e as any).key, 10) - 1;
505
- if (!Number.isNaN(idx) && idx >= 0 && idx < assets.length) setSelectedAsset(assets[idx]);
506
- if ((e as any).key === "Enter" && Boolean(selectedAsset) && windowLen < maxDays) confirmSelection();
507
- }
508
- window.addEventListener("keydown", onKey);
509
- return () => window.removeEventListener("keydown", onKey);
510
- }, [assets, selectedAsset, windowLen, maxDays]);
511
-
512
- if (!datasetId) {
513
- return (
514
- <div className="p-6">
515
- <h1 className="text-xl font-semibold mb-3">Asset Choice Simulation</h1>
516
- <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>
517
- <input type="file" accept="application/json" onChange={onFile} className="text-sm" />
518
- <div className="mt-4">
519
- <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>
520
- </div>
521
- </div>
522
- );
523
- }
524
-
525
- const IntradayControls = () => (
526
- <div className="flex items-center gap-2">
527
- <span className="text-xs px-2 py-1 rounded bg-indigo-50 text-indigo-700">
528
- Intraday {visibleDates[intradayDayIdx ?? 0] || "-"}
529
- </span>
530
- <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>
531
- <select className="text-xs px-2 py-1 rounded-xl border border-gray-200" value={intradayDayIdx ?? 0} onChange={(e) => setIntradayDayIdx(Number(e.target.value))}>
532
- {visibleDates.map((d, idx) => <option key={d} value={idx}>{d}</option>)}
533
- </select>
534
- <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>
535
- </div>
536
- );
537
-
538
- // ------- helper to give Y-axis safe padding even when dataMin == dataMax -------
539
- const multiYAxisDomain: any = [
540
- (min: number) => {
541
- if (!Number.isFinite(min)) return "auto";
542
- const pad = Math.abs(min) * 0.005 || 0.01;
543
- return min - pad;
544
- },
545
- (max: number) => {
546
- if (!Number.isFinite(max)) return "auto";
547
- const pad = Math.abs(max) * 0.005 || 0.01;
548
- return max + pad;
549
- }
550
- ];
551
-
552
- return (
553
- <div className="p-6 bg-gray-50 min-h-screen flex flex-col gap-6">
554
- <div className="flex flex-wrap justify-between items-center gap-3">
555
- <div className="flex items-center gap-2">
556
- <h1 className="text-xl font-semibold">Asset Choice Simulation</h1>
557
- <span className="text-[10px] ml-2 px-1.5 py-0.5 rounded bg-amber-200 text-amber-900">BUILD_TAG: 2025-10-28</span>
558
- <span className="text-xs text-gray-500">Dataset: {datasetName || datasetId}</span>
559
- <span className="text-xs text-gray-500">User: {userId.slice(0,8)}…</span>
560
- <span className="text-xs text-gray-500">Day {windowLen} / {maxDays}</span>
561
- </div>
562
- <div className="flex flex-wrap items-center gap-2">
563
- <input type="file" accept="application/json" onChange={onFile} className="text-sm" />
564
- <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>
565
- <button onClick={resetSession} className="text-sm px-3 py-1.5 rounded-xl bg-gray-200 hover:bg-gray-300">Reset (Keep data)</button>
566
-
567
- <div className="flex rounded-xl overflow-hidden border border-gray-200">
568
- <button className={`text-xs px-3 py-1.5 ${viewMode==="daily" ? "bg-gray-900 text-white" : "bg-white text-gray-700 hover:bg-gray-50"}`} onClick={()=>setViewMode("daily")}>Daily</button>
569
- <button className={`text-xs px-3 py-1.5 ${viewMode==="intraday" ? "bg-gray-900 text-white" : "bg-white text-gray-700 hover:bg-gray-50"}`} onClick={()=>setViewMode("intraday")}>Intraday (1 day)</button>
570
- <button className={`text-xs px-3 py-1.5 ${viewMode==="multi" ? "bg-gray-900 text-white" : "bg-white text-gray-700 hover:bg-gray-50"}`} onClick={()=>setViewMode("multi")}>Multi-day Intraday</button>
571
- </div>
572
-
573
- {viewMode==="multi" && (
574
- <button
575
- className="text-xs px-3 py-1.5 rounded-xl border border-gray-200 hover:bg-gray-50"
576
- onClick={() => setMultiNormalize(v => !v)}
577
- title="Toggle normalized cumulative index vs raw price"
578
- >
579
- {multiNormalize ? "Normalized" : "Raw Price"}
580
- </button>
581
- )}
582
-
583
- <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>
584
- </div>
585
- </div>
586
-
587
- <div className="bg-white p-4 rounded-2xl shadow">
588
- <div className="flex flex-wrap items-center gap-2 mb-3">
589
- {assets.map((a, i) => (
590
- <button
591
- key={`pick-${a}`}
592
- onClick={() => setSelectedAsset(a)}
593
- 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"}`}
594
- title={`Hotkey ${i+1}`}
595
- >
596
- <span className="inline-block w-2.5 h-2.5 rounded-full mr-2" style={{backgroundColor: `hsl(${(360/assets.length)*i},70%,50%)`}} />
597
- {i+1}. {aliasOf(a)}
598
- </button>
599
- ))}
600
- {assets.length>0 && <span className="text-xs text-gray-500 ml-1">Hotkeys: 1..{assets.length}, Enter confirm</span>}
601
- </div>
602
-
603
- <div className="h-80 relative">
604
- <ResponsiveContainer width="100%" height="100%">
605
- {viewMode === "intraday" ? (
606
- <LineChart data={intradayData}>
607
- <CartesianGrid strokeDasharray="3 3" />
608
- <XAxis dataKey="idx" tick={{ fontSize: 10 }} />
609
- <YAxis domain={["auto", "auto"]} tick={{ fontSize: 10 }} />
610
- <Tooltip contentStyle={{ fontSize: 12 }} formatter={(v: any, n: any) => [v, aliasMap[n] || n]} />
611
- <Legend onClick={(o: any) => setSelectedAsset(o.value)} wrapperStyle={{ cursor: "pointer" }} formatter={(v: any) => aliasMap[v] || v} />
612
- {assets.map((a, i) => (
613
- <Line
614
- key={a}
615
- type="linear"
616
- dataKey={a}
617
- name={aliasMap[a] || a}
618
- strokeWidth={selectedAsset === a ? 5 : hoverAsset === a ? 4 : 2.5}
619
- strokeOpacity={selectedAsset && selectedAsset !== a ? 0.3 : 1}
620
- dot={false}
621
- isAnimationActive={false}
622
- stroke={`hsl(${(360 / assets.length) * i},70%,50%)`}
623
- onMouseEnter={() => setHoverAsset(a)}
624
- onMouseLeave={() => setHoverAsset(null)}
625
- onClick={() => setSelectedAsset((p) => (p === a ? null : a))}
626
- connectNulls={false}
627
- />
628
- ))}
629
- </LineChart>
630
- ) : viewMode === "multi" ? (
631
- <>
632
- <div className="absolute right-2 top-2 z-10 text-[10px] px-2 py-0.5 rounded bg-black/60 text-white">
633
- Multi {multiNormalize ? "Normalized (cum ×)" : "Raw Price"}
634
- </div>
635
- <LineChart data={multiRows}>
636
- <CartesianGrid strokeDasharray="3 3" />
637
- <XAxis
638
- dataKey="x"
639
- tick={{ fontSize: 10 }}
640
- label={{ value: "Minute (concatenated days)", position: "insideBottomRight", offset: -2, fontSize: 10 }}
641
- />
642
- {/* FIX 1: robust Y domain with padding so flat lines are visible */}
643
- <YAxis domain={multiYAxisDomain} tick={{ fontSize: 10 }} allowDataOverflow />
644
- <Tooltip
645
- contentStyle={{ fontSize: 12 }}
646
- labelFormatter={(_, payload: any) => {
647
- const p = payload?.[0]?.payload;
648
- return p ? `${p.day} #${p.minute}` : "";
649
- }}
650
- formatter={(value: any, name: any) => {
651
- const display = multiNormalize
652
- ? (Number.isFinite(value) ? `${(value as number).toFixed(4)}×` : value)
653
- : value;
654
- return [display, aliasMap[name] || name];
655
- }}
656
- />
657
- <Legend onClick={(o: any) => setSelectedAsset(o.value)} wrapperStyle={{ cursor: "pointer" }} formatter={(v: any) => aliasMap[v] || v} />
658
- {dayStarts.map(s => (
659
- <ReferenceLine key={s.x} x={s.x} stroke="#6b7280" strokeDasharray="4 2" ifOverflow="extendDomain" />
660
- ))}
661
- {assets.map((a, i) => (
662
- <Line
663
- key={a}
664
- type="linear"
665
- dataKey={a}
666
- name={aliasMap[a] || a}
667
- strokeWidth={selectedAsset === a ? 5 : hoverAsset === a ? 4 : 2.5}
668
- strokeOpacity={selectedAsset && selectedAsset !== a ? 0.3 : 1}
669
- dot={false}
670
- isAnimationActive={false}
671
- stroke={`hsl(${(360 / assets.length) * i},70%,50%)`}
672
- onMouseEnter={() => setHoverAsset(a)}
673
- onMouseLeave={() => setHoverAsset(null)}
674
- onClick={() => setSelectedAsset((p) => (p === a ? null : a))}
675
- /* FIX 2: connect null gaps so lines render across sparse minutes */
676
- connectNulls={true}
677
- />
678
- ))}
679
- </LineChart>
680
- </>
681
- ) : (
682
- <LineChart data={windowData}>
683
- <CartesianGrid strokeDasharray="3 3" />
684
- <XAxis dataKey="date" tick={{ fontSize: 10 }} />
685
- <YAxis domain={["auto", "auto"]} tick={{ fontSize: 10 }} />
686
- <Tooltip contentStyle={{ fontSize: 12 }} formatter={(v: any, n: any) => [v, aliasMap[n] || n]} />
687
- <Legend onClick={(o: any) => setSelectedAsset(o.value)} wrapperStyle={{ cursor: "pointer" }} formatter={(v: any) => aliasMap[v] || v} />
688
- {assets.map((a, i) => (
689
- <Line
690
- key={a}
691
- type="natural"
692
- dataKey={a}
693
- name={aliasMap[a] || a}
694
- strokeWidth={selectedAsset === a ? 5 : hoverAsset === a ? 4 : 2.5}
695
- strokeOpacity={selectedAsset && selectedAsset !== a ? 0.3 : 1}
696
- dot={false}
697
- isAnimationActive={false}
698
- stroke={`hsl(${(360 / assets.length) * i},70%,50%)`}
699
- onMouseEnter={() => setHoverAsset(a)}
700
- onMouseLeave={() => setHoverAsset(null)}
701
- onClick={() => setSelectedAsset((p) => (p === a ? null : a))}
702
- />
703
- ))}
704
- </LineChart>
705
- )}
706
- </ResponsiveContainer>
707
- </div>
708
-
709
- {viewMode==="intraday" && (
710
- <div className="mt-3">
711
- <IntradayControls />
712
- </div>
713
- )}
714
-
715
- {viewMode!=="multi" && (
716
- <div className="flex justify-between items-center mt-3">
717
- <div className="text-sm text-gray-600">
718
- Selected: {selectedAsset ? aliasOf(selectedAsset) : "(none)"} {message && <span className="ml-2 text-gray-500">{message}</span>}
719
- </div>
720
- <button onClick={confirmSelection} disabled={!selectedAsset || windowLen >= maxDays} className={`px-4 py-2 rounded-xl ${selectedAsset && windowLen < maxDays ? "bg-blue-600 text-white" : "bg-gray-200 text-gray-500"}`}>
721
- Confirm & Next Day →
722
- </button>
723
- </div>
724
- )}
725
- </div>
726
-
727
- <div className="bg-white p-4 rounded-2xl shadow">
728
- <h2 className="font-medium mb-2">Portfolio</h2>
729
- <div className="h-64">
730
- <ResponsiveContainer width="100%" height="100%">
731
- <AreaChart data={portfolioSeries}>
732
- <CartesianGrid strokeDasharray="3 3" />
733
- <XAxis dataKey="step" tick={{ fontSize: 10 }} />
734
- <YAxis domain={["auto", "auto"]} tick={{ fontSize: 10 }} />
735
- <Tooltip />
736
- <Area type="monotone" dataKey="value" stroke="#2563eb" fill="#bfdbfe" />
737
- </AreaChart>
738
- </ResponsiveContainer>
739
- </div>
740
- <ul className="text-sm text-gray-700 mt-2">
741
- <li>Cumulative Return: {(stats.cumRet * 100).toFixed(2)}%</li>
742
- <li>Volatility: {(stats.stdev * 100).toFixed(2)}%</li>
743
- <li>Sharpe: {stats.sharpe.toFixed(2)}</li>
744
- <li>Winning Days: {stats.wins}/{stats.N}</li>
745
- </ul>
746
- </div>
747
-
748
- <div className="bg-white p-4 rounded-2xl shadow">
749
- <h2 className="font-medium mb-2">Daily Selections</h2>
750
- <div className="overflow-auto rounded-xl border border-gray-100">
751
- <table className="min-w-full text-xs">
752
- <thead className="bg-gray-50 text-gray-500">
753
- <tr>
754
- <th className="px-2 py-1 text-left">Step</th>
755
- <th className="px-2 py-1 text-left">Date (t+1)</th>
756
- <th className="px-2 py-1 text-left">Asset</th>
757
- <th className="px-2 py-1 text-right">Return</th>
758
- </tr>
759
- </thead>
760
- <tbody>
761
- {selections.slice().reverse().map((s) => (
762
- <tr key={`${s.step}-${s.asset}-${s.date}`} className="odd:bg-white even:bg-gray-50">
763
- <td className="px-2 py-1">{s.step}</td>
764
- <td className="px-2 py-1">{s.date}</td>
765
- <td className="px-2 py-1">{aliasOf(s.asset)}</td>
766
- <td className={`px-2 py-1 text-right ${s.ret>=0?"text-green-600":"text-red-600"}`}>{(s.ret*100).toFixed(2)}%</td>
767
- </tr>
768
- ))}
769
- </tbody>
770
- </table>
771
- </div>
772
- </div>
773
- </div>
774
- );
775
- }
776
-
777
- /** Final summary component (UI shows aliases, data keeps real tickers) */
778
- export function FinalSummary({ assets, selections }: { assets: string[]; selections: { step: number; date: string; asset: string; ret: number }[] }) {
779
- const rets = selections.map(s=>s.ret);
780
- const N = rets.length;
781
- const cum = rets.reduce((v,r)=> v*(1+r), 1) - 1;
782
- const mean = N ? rets.reduce((a,b)=>a+b,0)/N : 0;
783
- const variance = N ? rets.reduce((a,b)=> a + (b-mean)**2, 0)/N : 0;
784
- const stdev = Math.sqrt(variance);
785
- const sharpe = stdev ? (mean*252)/(stdev*Math.sqrt(252)) : 0;
786
- const wins = rets.filter(r=>r>0).length;
787
-
788
- const countsAll: Record<string, number> = assets.reduce((acc: any,a: string)=>{acc[a]=0;return acc;},{} as Record<string, number>);
789
- selections.forEach(s=>{ countsAll[s.asset] = (countsAll[s.asset]||0)+1; });
790
- const rankAll = assets.map(a=>({ asset:a, votes: countsAll[a]||0 })).sort((x,y)=> y.votes - x.votes);
791
-
792
- const aliasMap = useMemo(() => {
793
- const m: Record<string, string> = {};
794
- assets.forEach((a, i) => { m[a] = `Ticker ${i + 1}`; });
795
- return m;
796
- }, [assets]);
797
-
798
- const rankAllForChart = useMemo(() => {
799
- return rankAll.map(r => ({ asset: aliasMap[r.asset] || r.asset, votes: r.votes }));
800
- }, [rankAll, aliasMap]);
801
-
802
- const lastStep = selections.reduce((m,s)=>Math.max(m,s.step),0);
803
- const start30 = Math.max(1, lastStep - 30 + 1);
804
- const cols = Array.from({length: Math.min(30,lastStep ? lastStep - start30 + 1 : 0)}, (_,i)=> start30 + i);
805
- const grid = assets.map(a=>({ asset: aliasMap[a] || a, cells: cols.map(c => selections.some(s=> s.asset===a && s.step===c) ? 1 : 0) }));
806
-
807
- return (
808
- <div className="p-6 bg-gray-50 min-h-screen flex flex-col gap-6">
809
- <h1 className="text-xl font-semibold">Final Summary</h1>
810
- <div className="bg-white p-4 rounded-2xl shadow">
811
- <h2 className="font-medium mb-2">Overall Metrics</h2>
812
- <ul className="text-sm text-gray-700 space-y-1">
813
- <li>Total Picks: {N}</li>
814
- <li>Win Rate: {(N? (wins/N*100):0).toFixed(1)}%</li>
815
- <li>Cumulative Return: {(cum*100).toFixed(2)}%</li>
816
- <li>Volatility: {(stdev*100).toFixed(2)}%</li>
817
- <li>Sharpe (rough): {sharpe.toFixed(2)}</li>
818
- <li>Top Preference: {aliasMap[rankAll[0]?.asset ?? ""] ?? "-" } ({rankAll[0]?.votes ?? 0})</li>
819
- </ul>
820
- </div>
821
- <div className="bg-white p-4 rounded-2xl shadow">
822
- <h2 className="font-medium mb-2">Selection Preference Ranking</h2>
823
- <div className="h-56">
824
- <ResponsiveContainer width="100%" height="100%">
825
- <BarChart data={rankAllForChart} margin={{ left: 8, right: 8, top: 8, bottom: 8 }}>
826
- <CartesianGrid strokeDasharray="3 3" />
827
- <XAxis dataKey="asset" tick={{ fontSize: 10 }} />
828
- <YAxis allowDecimals={false} tick={{ fontSize: 10 }} />
829
- <Tooltip />
830
- <Bar dataKey="votes" fill="#60a5fa" />
831
- </BarChart>
832
- </ResponsiveContainer>
833
- </div>
834
- </div>
835
- <div className="bg-white p-4 rounded-2xl shadow">
836
- <h2 className="font-medium mb-2">Selection Heatmap (Last 30 Steps)</h2>
837
- <div className="overflow-auto">
838
- <table className="text-xs border-collapse">
839
- <thead>
840
- <tr>
841
- <th className="p-1 pr-2 text-left sticky left-0 bg-white">Asset</th>
842
- {cols.map(c=> (<th key={c} className="px-1 py-1 text-center">{c}</th>))}
843
- </tr>
844
- </thead>
845
- <tbody>
846
- {grid.map(row => (
847
- <tr key={row.asset}>
848
- <td className="p-1 pr-2 font-medium sticky left-0 bg-white">{row.asset}</td>
849
- {row.cells.map((v,j)=> (
850
- <td key={j} className="w-6 h-6" style={{ background: v? "#2563eb" : "#e5e7eb", opacity: v? 0.9 : 1, border: "1px solid #ffffff" }} />
851
- ))}
852
- </tr>
853
- ))}
854
- </tbody>
855
- </table>
856
- </div>
857
- </div>
858
- </div>
859
- );
860
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useMemo, useState } from "react";
2
+ import {
3
+ LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, Legend, CartesianGrid,
4
+ AreaChart, Area, BarChart, Bar, ReferenceLine
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
+ type ViewMode = "daily" | "intraday" | "multi";
16
+
17
+ const START_DAY = 7;
18
+
19
+ /* --------------------- utils --------------------- */
20
+ function latestClose(p: SeriesPoint): number {
21
+ const arr = p.close;
22
+ if (!Array.isArray(arr) || arr.length === 0) throw new Error(`Empty close array at ${p.date}`);
23
+ const last = arr[arr.length - 1];
24
+ if (!Number.isFinite(last)) throw new Error(`Invalid close value at ${p.date}`);
25
+ return last;
26
+ }
27
+ function uuidv4() {
28
+ return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, c => {
29
+ const r = (Math.random() * 16) | 0, v = c === "x" ? r : (r & 0x3) | 0x8;
30
+ return v.toString(16);
31
+ });
32
+ }
33
+ function getOrCreateUserId() {
34
+ const k = "annot_user_id";
35
+ let uid = localStorage.getItem(k);
36
+ if (!uid) { uid = uuidv4(); localStorage.setItem(k, uid); }
37
+ return uid;
38
+ }
39
+ function stableHash(str: string): string {
40
+ let h = 5381;
41
+ for (let i = 0; i < str.length; i++) h = (h * 33) ^ str.charCodeAt(i);
42
+ return (h >>> 0).toString(16);
43
+ }
44
+
45
+ /** Example data in the new format (close is a rolling intraday array up to 500) */
46
+ function generateFakeJSON(maxLen = 500): DataDict {
47
+ const randTicker = () => Math.random().toString(36).substring(2, 6).toUpperCase();
48
+ const tickers = Array.from({ length: 5 }, () => randTicker());
49
+ const start = new Date("2024-01-02T00:00:00Z");
50
+ const dates = Array.from({ length: 67 }, (_, i) => {
51
+ const d = new Date(start); d.setDate(start.getDate() + i);
52
+ return d.toISOString().slice(0, 10);
53
+ });
54
+ const out: DataDict = {};
55
+ for (let a = 0; a < 5; a++) {
56
+ const ticker = tickers[a];
57
+ let price = 80 + Math.random() * 40;
58
+ const mu = (Math.random() * 0.1 - 0.05) / 252;
59
+ const sigma = 0.15 + Math.random() * 0.35;
60
+ const series: SeriesPoint[] = [];
61
+ for (let i = 0; i < dates.length; i++) {
62
+ // Fake minute series (varying length)
63
+ const mins = 200 + Math.floor(Math.random() * 100);
64
+ const intraday: number[] = [];
65
+ let p = price;
66
+ for (let m = 0; m < mins; m++) {
67
+ const dz = (Math.random() - 0.5) * 0.004;
68
+ p = Math.max(0.01, p * (1 + dz));
69
+ intraday.push(Number(p.toFixed(2)));
70
+ }
71
+ series.push({ date: dates[i], close: intraday });
72
+ const drift = mu + (sigma / Math.sqrt(252)) * ((Math.random() - 0.5) * 2.0);
73
+ price = intraday[intraday.length - 1] * (1 + drift);
74
+ }
75
+ out[ticker] = series;
76
+ }
77
+ return out;
78
+ }
79
+
80
+ /* --------------------- component --------------------- */
81
+ export default function App() {
82
+ const userId = getOrCreateUserId();
83
+
84
+ const [datasetId, setDatasetId] = useState<string | null>(null);
85
+ const [datasetName, setDatasetName] = useState<string>("");
86
+
87
+ const [rawData, setRawData] = useState<DataDict>({});
88
+ const [assets, setAssets] = useState<string[]>([]);
89
+ const [dates, setDates] = useState<string[]>([]);
90
+
91
+ // Dynamic caps
92
+ const maxDays = dates.length || 67;
93
+ const maxSteps = Math.max(0, maxDays - START_DAY);
94
+
95
+ const [step, setStep] = useState(1);
96
+ const [windowLen, setWindowLen] = useState(START_DAY);
97
+ const [selectedAsset, setSelectedAsset] = useState<string | null>(null);
98
+ const [hoverAsset, setHoverAsset] = useState<string | null>(null);
99
+ const [selections, setSelections] = useState<{ step: number; date: string; asset: string; ret: number }[]>([]);
100
+ const [message, setMessage] = useState("");
101
+ const [confirming, setConfirming] = useState(false);
102
+ const [finalSaved, setFinalSaved] = useState(false);
103
+
104
+ const [viewMode, setViewMode] = useState<ViewMode>("daily");
105
+ const [intradayDayIdx, setIntradayDayIdx] = useState<number | null>(null);
106
+ const [multiNormalize, setMultiNormalize] = useState<boolean>(true); // toggle for multi view
107
+
108
+ /* ---------- localStorage keys ---------- */
109
+ const LS_DATASET_KEY = "annot_dataset_meta";
110
+ const LS_DATA_PREFIX = (id: string) => `annot_dataset_${id}`;
111
+ const LS_ANN_PREFIX = (id: string, uid: string) => `annot_user_${uid}_ds_${id}`;
112
+
113
+ /* ---------- alias map ---------- */
114
+ const aliasMap = useMemo(() => {
115
+ const m: Record<string, string> = {};
116
+ assets.forEach((a, i) => { m[a] = `Ticker ${i + 1}`; });
117
+ return m;
118
+ }, [assets]);
119
+ const aliasOf = (a?: string | null) => (a && aliasMap[a]) || a || "";
120
+
121
+ /* ---------- boot ---------- */
122
+ useEffect(() => {
123
+ const boot = async () => {
124
+ try {
125
+ const metaRaw = localStorage.getItem(LS_DATASET_KEY);
126
+ if (metaRaw) {
127
+ const meta = JSON.parse(metaRaw);
128
+ if (meta?.datasetId) {
129
+ const dJson = localStorage.getItem(LS_DATA_PREFIX(meta.datasetId));
130
+ if (dJson) {
131
+ const payload = JSON.parse(dJson) as { data: DataDict; assets: string[]; dates: string[]; name?: string };
132
+ loadDatasetIntoState(meta.datasetId, payload.data, payload.assets, payload.dates, payload.name || "");
133
+ await loadUserAnnotation(meta.datasetId);
134
+ return;
135
+ }
136
+ }
137
+ }
138
+ try {
139
+ const ds = await apiGetLatestDataset();
140
+ const id = (ds.id ?? ds.dataset_id) as string;
141
+ const name = ds.name || "";
142
+ const data = ds.data as DataDict;
143
+ const dsAssets = ds.assets as string[];
144
+ const dsDates = ds.dates as string[];
145
+ persistDatasetLocal(id, data, dsAssets, dsDates, name);
146
+ loadDatasetIntoState(id, data, dsAssets, dsDates, name);
147
+ await loadUserAnnotation(id);
148
+ return;
149
+ } catch {}
150
+ const example = generateFakeJSON();
151
+ const keys = Object.keys(example);
152
+ const dsDates = example[keys[0]].map(d => d.date);
153
+ const id = stableHash(JSON.stringify({ assets: keys, dates: dsDates }));
154
+ persistDatasetLocal(id, example, keys, dsDates, "Example");
155
+ loadDatasetIntoState(id, example, keys, dsDates, "Example");
156
+ } catch (e) {
157
+ console.warn("Boot failed:", e);
158
+ }
159
+ };
160
+ boot();
161
+ // eslint-disable-next-line react-hooks/exhaustive-deps
162
+ }, []);
163
+
164
+ function persistDatasetLocal(id: string, data: DataDict, a: string[], d: string[], name: string) {
165
+ localStorage.setItem(LS_DATASET_KEY, JSON.stringify({ datasetId: id, name }));
166
+ localStorage.setItem(LS_DATA_PREFIX(id), JSON.stringify({ data, assets: a, dates: d, name }));
167
+ }
168
+ function loadDatasetIntoState(id: string, data: DataDict, a: string[], d: string[], name: string) {
169
+ setDatasetId(id);
170
+ setDatasetName(name || "");
171
+ setRawData(data);
172
+ setAssets(a);
173
+ setDates(d);
174
+ setStep(1);
175
+ setWindowLen(Math.min(START_DAY, Math.max(1, d.length)));
176
+ setSelections([]);
177
+ setSelectedAsset(null);
178
+ setViewMode("daily");
179
+ setIntradayDayIdx(null);
180
+ setMessage(`Loaded dataset ${name || id}: ${a.length} assets, ${d.length} days.`);
181
+ setFinalSaved(false);
182
+ }
183
+ async function loadUserAnnotation(id: string) {
184
+ const localRaw = localStorage.getItem(LS_ANN_PREFIX(id, userId));
185
+ if (localRaw) {
186
+ try {
187
+ const ann = JSON.parse(localRaw);
188
+ setSelections(ann.selections || []);
189
+ setStep(ann.step || 1);
190
+ setWindowLen(ann.window_len || START_DAY);
191
+ setIntradayDayIdx(null);
192
+ } catch {}
193
+ }
194
+ try {
195
+ const ann = await apiGetAnnotation(id, userId);
196
+ setSelections(ann.selections || []);
197
+ setStep(ann.step || 1);
198
+ setWindowLen(ann.window_len || START_DAY);
199
+ setIntradayDayIdx(null);
200
+ localStorage.setItem(LS_ANN_PREFIX(id, userId), JSON.stringify(ann));
201
+ } catch {}
202
+ }
203
+ function persistAnnotationLocal() {
204
+ if (!datasetId) return;
205
+ const ann = { user_id: userId, dataset_id: datasetId, selections, step, window_len: windowLen };
206
+ localStorage.setItem(LS_ANN_PREFIX(datasetId, userId), JSON.stringify(ann));
207
+ }
208
+ async function upsertAnnotationCloud() {
209
+ if (!datasetId) return;
210
+ try {
211
+ await apiUpsertAnnotation({ dataset_id: datasetId, user_id: userId, selections, step, window_len: windowLen });
212
+ } catch (e) { console.warn("Upsert annotation failed:", e); }
213
+ }
214
+ useEffect(() => {
215
+ persistAnnotationLocal();
216
+ upsertAnnotationCloud();
217
+ // eslint-disable-next-line react-hooks/exhaustive-deps
218
+ }, [selections, step, windowLen]);
219
+
220
+ /* ---------- tie intraday selected day to window ---------- */
221
+ useEffect(() => {
222
+ if (!dates.length) return;
223
+ const lastIdx = Math.min(windowLen - 1, dates.length - 1);
224
+ if (intradayDayIdx === null || intradayDayIdx > lastIdx) setIntradayDayIdx(lastIdx);
225
+ }, [dates, windowLen, intradayDayIdx]);
226
+
227
+ /* ---------- upload handler ---------- */
228
+ async function onFile(e: any) {
229
+ const f = e.target.files?.[0];
230
+ if (!f) return;
231
+ const reader = new FileReader();
232
+ reader.onload = async () => {
233
+ try {
234
+ const json: DataDict = JSON.parse(String(reader.result));
235
+ const keys = Object.keys(json);
236
+ if (keys.length === 0) throw new Error("Empty dataset");
237
+ const firstArr = json[keys[0]];
238
+ if (!Array.isArray(firstArr) || !firstArr[0]?.date || !Array.isArray(firstArr[0]?.close)) {
239
+ throw new Error("Invalid series format. Need [{date, close:number[]}]");
240
+ }
241
+ const refDates = firstArr.map(p => p.date);
242
+ const checkPoint = (p: SeriesPoint) => {
243
+ if (!p?.date) throw new Error("Missing date");
244
+ if (!Array.isArray(p.close) || p.close.length === 0) throw new Error(`Empty close array at ${p.date}`);
245
+ if (p.close.length > 500) throw new Error(`close array exceeds 500 at ${p.date}`);
246
+ for (const v of p.close) if (typeof v !== "number" || !Number.isFinite(v)) throw new Error(`Non numeric close in array at ${p.date}`);
247
+ };
248
+ for (const k of keys) {
249
+ const arr = json[k];
250
+ if (!Array.isArray(arr) || arr.length !== firstArr.length) throw new Error("All series must have the same length");
251
+ for (let i = 0; i < arr.length; i++) {
252
+ const p = arr[i]; checkPoint(p);
253
+ if (p.date !== refDates[i]) throw new Error("Date misalignment across assets");
254
+ }
255
+ }
256
+ const dsDates = firstArr.map(d => d.date);
257
+ const id = stableHash(JSON.stringify({ assets: keys, dates: dsDates }));
258
+ const name = f.name.replace(/\.[^.]+$/, "");
259
+ persistDatasetLocal(id, json, keys, dsDates, name);
260
+ loadDatasetIntoState(id, json, keys, dsDates, name);
261
+
262
+ try {
263
+ await apiUpsertDataset({ dataset_id: id, name, data: json, assets: keys, dates: dsDates });
264
+ await apiUpsertAnnotation({ dataset_id: id, user_id: userId, selections: [], step: 1, window_len: Math.min(START_DAY, Math.max(1, dsDates.length)) });
265
+ } catch (err: any) {
266
+ console.warn("Upload to backend failed:", err?.message || err);
267
+ }
268
+ } catch (err: any) {
269
+ setMessage("Failed to parse JSON: " + err.message);
270
+ }
271
+ };
272
+ reader.readAsText(f);
273
+ }
274
+
275
+ /* ---------- computed ---------- */
276
+ const isFinal = windowLen >= maxDays || selections.length >= maxSteps;
277
+
278
+ // Daily (normalized to day 0)
279
+ const windowData = useMemo(() => {
280
+ if (!dates.length || windowLen < 2) return [] as any[];
281
+ const sliceDates = dates.slice(0, windowLen);
282
+ return sliceDates.map((date, idx) => {
283
+ const row: Record<string, any> = { date };
284
+ assets.forEach((a) => {
285
+ const base = rawData[a]?.[0] ? latestClose(rawData[a][0]) : 1;
286
+ const val = rawData[a]?.[idx] ? latestClose(rawData[a][idx]) : base;
287
+ row[a] = base ? val / base : 1;
288
+ });
289
+ return row;
290
+ });
291
+ }, [assets, dates, windowLen, rawData]);
292
+
293
+ // Intraday (single day, normalized to first tick of the day)
294
+ const visibleDates = useMemo(() => dates.slice(0, Math.max(0, windowLen)), [dates, windowLen]);
295
+ const activeIntradayIdx = intradayDayIdx ?? Math.min(windowLen - 1, dates.length - 1);
296
+ const intradayData = useMemo(() => {
297
+ if (!dates.length || activeIntradayIdx == null || activeIntradayIdx < 0) return [] as any[];
298
+ const maxBars = assets.reduce((m, a) => Math.max(m, rawData[a]?.[activeIntradayIdx]?.close?.length ?? 0), 0);
299
+ const dayFirstPrice: Record<string, number> = {};
300
+ assets.forEach(a => {
301
+ const arr = rawData[a]?.[activeIntradayIdx]?.close ?? [];
302
+ dayFirstPrice[a] = arr.length ? arr[0] : 1;
303
+ });
304
+ const rows: any[] = [];
305
+ for (let i = 0; i < maxBars; i++) {
306
+ const row: Record<string, any> = { idx: i + 1 };
307
+ assets.forEach(a => {
308
+ const arr = rawData[a]?.[activeIntradayIdx]?.close ?? [];
309
+ const val = arr[i];
310
+ row[a] = typeof val === "number" ? (dayFirstPrice[a] ? val / dayFirstPrice[a] : null) : null;
311
+ });
312
+ rows.push(row);
313
+ }
314
+ return rows;
315
+ }, [assets, rawData, dates.length, activeIntradayIdx]);
316
+
317
+ // ---------------- Multi-day intraday (稳健逐日拼接 + 跨天累计) ----------------
318
+ const { multiRows, dayStarts } = useMemo(() => {
319
+ const rows: any[] = [];
320
+ const starts: { x: number; date: string }[] = [];
321
+ if (!dates.length || windowLen < 1) return { multiRows: rows, dayStarts: starts };
322
+
323
+ // 跨天累计倍率(仅 normalized 使用)
324
+ const cumIdx: Record<string, number> = {};
325
+ assets.forEach(a => { cumIdx[a] = 1; });
326
+
327
+ let x = 0;
328
+ const D = Math.min(windowLen, dates.length);
329
+
330
+ for (let d = 0; d < D; d++) {
331
+ starts.push({ x: x + 1, date: dates[d] });
332
+
333
+ // 本日最长分钟数(至少 1)
334
+ const dayLen = Math.max(
335
+ 1,
336
+ assets.reduce((m, a) => Math.max(m, rawData[a]?.[d]?.close?.length ?? 0), 0)
337
+ );
338
+
339
+ // 找“当日首个有效价”作为锚,并准备“本日最后一个有效价”
340
+ const dayFirst: Record<string, number | null> = {};
341
+ const lastSeen: Record<string, number | null> = {};
342
+
343
+ assets.forEach(a => {
344
+ const arr = rawData[a]?.[d]?.close ?? [];
345
+ let first: number | null = null;
346
+ for (let i = 0; i < arr.length; i++) {
347
+ const v = arr[i];
348
+ if (typeof v === "number" && Number.isFinite(v)) { first = v; break; }
349
+ }
350
+ dayFirst[a] = first;
351
+ lastSeen[a] = null;
352
+ });
353
+
354
+ // 逐分钟行;优先当前值,其次上一分钟值,再次用 dayFirst 兜底
355
+ for (let i = 0; i < dayLen; i++) {
356
+ const row: Record<string, any> = { x: x + 1, day: dates[d], minute: i + 1 };
357
+
358
+ assets.forEach(a => {
359
+ const arr = rawData[a]?.[d]?.close ?? [];
360
+ const v = arr[i];
361
+
362
+ let price: number | null = null;
363
+ if (typeof v === "number" && Number.isFinite(v)) {
364
+ price = v;
365
+ lastSeen[a] = v;
366
+ } else if (lastSeen[a] != null) {
367
+ price = lastSeen[a];
368
+ } else if (dayFirst[a] != null) {
369
+ price = dayFirst[a];
370
+ lastSeen[a] = price;
371
+ } else {
372
+ price = null; // 这一整天没有任��有效价
373
+ }
374
+
375
+ if (price == null) {
376
+ row[a] = null;
377
+ } else {
378
+ row[a] = multiNormalize
379
+ ? (dayFirst[a] && Number.isFinite(dayFirst[a]) ? cumIdx[a] * (price / (dayFirst[a] as number)) : null)
380
+ : price;
381
+ }
382
+ });
383
+
384
+ rows.push(row);
385
+ x++;
386
+ }
387
+
388
+ // 天末:若有有效价,则把“末价/首价”乘到 cumIdx,保证下一天从累计值继续
389
+ assets.forEach(a => {
390
+ if (multiNormalize && dayFirst[a] != null && lastSeen[a] != null && dayFirst[a]! > 0) {
391
+ cumIdx[a] *= (lastSeen[a]! / dayFirst[a]!);
392
+ }
393
+ });
394
+ }
395
+
396
+ return { multiRows: rows, dayStarts: starts };
397
+ }, [assets, rawData, dates, windowLen, multiNormalize]);
398
+
399
+ function realizedNextDayReturn(asset: string) {
400
+ const t = windowLen - 1;
401
+ if (t + 1 >= dates.length) return null as any;
402
+ const series = rawData[asset];
403
+ const ret = latestClose(series[t + 1]) / latestClose(series[t]) - 1;
404
+ return { date: dates[t + 1], ret };
405
+ }
406
+
407
+ function loadExample() {
408
+ const example = generateFakeJSON();
409
+ const keys = Object.keys(example);
410
+ const first = example[keys[0]];
411
+ const dsDates = first.map((d) => d.date);
412
+ const id = stableHash(JSON.stringify({ assets: keys, dates: dsDates }));
413
+ persistDatasetLocal(id, example, keys, dsDates, "Example");
414
+ loadDatasetIntoState(id, example, keys, dsDates, "Example");
415
+ }
416
+
417
+ function resetSession() {
418
+ setSelections([]);
419
+ setSelectedAsset(null);
420
+ setStep(1);
421
+ setWindowLen(Math.min(START_DAY, Math.max(1, dates.length)));
422
+ setMessage("Session reset.");
423
+ setViewMode("daily");
424
+ setIntradayDayIdx(null);
425
+ if (datasetId) localStorage.removeItem(LS_ANN_PREFIX(datasetId, userId));
426
+ }
427
+
428
+ function exportLog() {
429
+ const blob = new Blob([JSON.stringify(selections, null, 2)], { type: "application/json" });
430
+ const url = URL.createObjectURL(blob);
431
+ const a = document.createElement("a");
432
+ a.href = url;
433
+ a.download = `selections_${new Date().toISOString().slice(0,10)}_${userId}.json`;
434
+ a.click();
435
+ URL.revokeObjectURL(url);
436
+ }
437
+
438
+ function confirmSelection() {
439
+ if (confirming) return;
440
+ if (!selectedAsset) { setMessage("Select a line first."); return; }
441
+ setConfirming(true);
442
+ const res = realizedNextDayReturn(selectedAsset);
443
+ if (!res) { setMessage("No more data available."); setConfirming(false); return; }
444
+ const entry = { step, date: res.date, asset: selectedAsset, ret: res.ret };
445
+ setSelections((prev) => [...prev, entry]);
446
+ setWindowLen((w) => Math.min(w + 1, maxDays));
447
+ setStep((s) => s + 1);
448
+ setSelectedAsset(null);
449
+ setConfirming(false);
450
+ setMessage(`Pick ${step}: ${aliasOf(selectedAsset)} next-day return ${(res.ret * 100).toFixed(2)}%`);
451
+ }
452
+
453
+ const portfolioSeries = useMemo(() => {
454
+ let value = 1;
455
+ const pts = selections.map((s) => { value *= 1 + s.ret; return { step: s.step, date: s.date, value }; });
456
+ return [{ step: 0, date: "start", value: 1 }, ...pts];
457
+ }, [selections]);
458
+
459
+ const stats = useMemo(() => {
460
+ const rets = selections.map((s) => s.ret);
461
+ const N = rets.length;
462
+ const cum = portfolioSeries.at(-1)?.value ?? 1;
463
+ const mean = N ? rets.reduce((a, b) => a + b, 0) / N : 0;
464
+ const variance = N ? rets.reduce((a, b) => a + (b - mean) ** 2, 0) / N : 0;
465
+ const stdev = Math.sqrt(variance);
466
+ const sharpe = stdev ? (mean * 252) / (stdev * Math.sqrt(252)) : 0;
467
+ const wins = rets.filter((r) => r > 0).length;
468
+ return { cumRet: cum - 1, stdev, sharpe, wins, N };
469
+ }, [portfolioSeries, selections]);
470
+
471
+ function buildFinalPayload() {
472
+ const lastStep = selections.reduce((m, s) => Math.max(m, s.step), 0);
473
+ const start30 = Math.max(1, lastStep - 30 + 1);
474
+ const countsAll = assets.reduce((acc: Record<string, number>, a: string) => { acc[a] = 0; return acc; }, {} as Record<string, number>);
475
+ selections.forEach((s) => { countsAll[s.asset] = (countsAll[s.asset] || 0) + 1; });
476
+ const rankAll = assets.map((a) => ({ asset: a, votes: countsAll[a] || 0 })).sort((x, y) => y.votes - x.votes);
477
+ let value = 1;
478
+ const portfolio = selections.map((s) => { value *= 1 + s.ret; return { step: s.step, date: s.date, value }; });
479
+ const lastStep2 = selections.reduce((m, s) => Math.max(m, s.step), 0);
480
+ const start30_ = Math.max(1, lastStep2 - 30 + 1);
481
+ const lastCols = Array.from({ length: Math.min(30, lastStep2 ? lastStep2 - start30_ + 1 : 0) }, (_, i) => start30_ + i);
482
+ const heatGrid = assets.map((a) => ({ asset: a, cells: lastCols.map((c) => (selections.some((s) => s.asset === a && s.step === c) ? 1 : 0)) }));
483
+ const rets = selections.map((s) => s.ret);
484
+ const N = rets.length;
485
+ const cum = portfolio.at(-1)?.value ?? 1;
486
+ const mean = N ? rets.reduce((a, b) => a + b, 0) / N : 0;
487
+ const variance = N ? rets.reduce((a, b) => a + (b - mean) ** 2, 0) / N : 0;
488
+ const stdev = Math.sqrt(variance);
489
+ const sharpe = stdev ? (mean * 252) / (stdev * Math.sqrt(252)) : 0;
490
+ const wins = rets.filter((r) => r > 0).length;
491
+
492
+ return {
493
+ meta: { saved_at: new Date().toISOString(), start_day: START_DAY, max_days: maxDays, max_steps: maxSteps, dataset_id: datasetId, dataset_name: datasetName },
494
+ assets,
495
+ dates,
496
+ selections,
497
+ portfolio,
498
+ stats: { cumRet: (cum - 1), stdev, sharpe, wins, N },
499
+ preference_all: rankAll,
500
+ heatmap_last30: { cols: lastCols, grid: heatGrid },
501
+ };
502
+ }
503
+
504
+ useEffect(() => {
505
+ const final = windowLen >= maxDays || selections.length >= maxSteps;
506
+ if (final && !finalSaved) {
507
+ try {
508
+ const payload = buildFinalPayload();
509
+ localStorage.setItem("asset_experiment_final", JSON.stringify(payload));
510
+ const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" });
511
+ const url = URL.createObjectURL(blob);
512
+ const a = document.createElement("a");
513
+ a.href = url;
514
+ a.download = `run_summary_${new Date().toISOString().slice(0, 10)}_${userId}.json`;
515
+ a.click();
516
+ URL.revokeObjectURL(url);
517
+ setFinalSaved(true);
518
+ } catch (e) { console.warn("Failed to save final JSON:", e); }
519
+ }
520
+ // eslint-disable-next-line react-hooks/exhaustive-deps
521
+ }, [windowLen, selections.length, finalSaved, maxDays, maxSteps]);
522
+
523
+ useEffect(() => {
524
+ function onKey(e: KeyboardEvent) {
525
+ const tag = (e.target && (e.target as HTMLElement).tagName) || "";
526
+ if (tag === "INPUT" || tag === "TEXTAREA") return;
527
+ const idx = parseInt((e as any).key, 10) - 1;
528
+ if (!Number.isNaN(idx) && idx >= 0 && idx < assets.length) setSelectedAsset(assets[idx]);
529
+ if ((e as any).key === "Enter" && Boolean(selectedAsset) && windowLen < maxDays) confirmSelection();
530
+ }
531
+ window.addEventListener("keydown", onKey);
532
+ return () => window.removeEventListener("keydown", onKey);
533
+ }, [assets, selectedAsset, windowLen, maxDays]);
534
+
535
+ if (!datasetId) {
536
+ return (
537
+ <div className="p-6">
538
+ <h1 className="text-xl font-semibold mb-3">Asset Choice Simulation</h1>
539
+ <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>
540
+ <input type="file" accept="application/json" onChange={onFile} className="text-sm" />
541
+ <div className="mt-4">
542
+ <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>
543
+ </div>
544
+ </div>
545
+ );
546
+ }
547
+
548
+ const IntradayControls = () => (
549
+ <div className="flex items-center gap-2">
550
+ <span className="text-xs px-2 py-1 rounded bg-indigo-50 text-indigo-700">
551
+ Intraday • {visibleDates[intradayDayIdx ?? 0] || "-"}
552
+ </span>
553
+ <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>
554
+ <select className="text-xs px-2 py-1 rounded-xl border border-gray-200" value={intradayDayIdx ?? 0} onChange={(e) => setIntradayDayIdx(Number(e.target.value))}>
555
+ {visibleDates.map((d, idx) => <option key={d} value={idx}>{d}</option>)}
556
+ </select>
557
+ <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>
558
+ </div>
559
+ );
560
+
561
+ // ------- helper to give Y-axis safe padding even when dataMin == dataMax -------
562
+ const multiYAxisDomain: any = [
563
+ (min: number) => {
564
+ if (!Number.isFinite(min)) return "auto";
565
+ const pad = Math.abs(min) * 0.005 || 0.01;
566
+ return min - pad;
567
+ },
568
+ (max: number) => {
569
+ if (!Number.isFinite(max)) return "auto";
570
+ const pad = Math.abs(max) * 0.005 || 0.01;
571
+ return max + pad;
572
+ }
573
+ ];
574
+
575
+ return (
576
+ <div className="p-6 bg-gray-50 min-h-screen flex flex-col gap-6">
577
+ <div className="flex flex-wrap justify-between items-center gap-3">
578
+ <div className="flex items-center gap-2">
579
+ <h1 className="text-xl font-semibold">Asset Choice Simulation</h1>
580
+ <span className="text-[10px] ml-2 px-1.5 py-0.5 rounded bg-amber-200 text-amber-900">BUILD_TAG: 2025-10-28</span>
581
+ <span className="text-xs text-gray-500">Dataset: {datasetName || datasetId}</span>
582
+ <span className="text-xs text-gray-500">User: {userId.slice(0,8)}…</span>
583
+ <span className="text-xs text-gray-500">Day {windowLen} / {maxDays}</span>
584
+ </div>
585
+ <div className="flex flex-wrap items-center gap-2">
586
+ <input type="file" accept="application/json" onChange={onFile} className="text-sm" />
587
+ <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>
588
+ <button onClick={resetSession} className="text-sm px-3 py-1.5 rounded-xl bg-gray-200 hover:bg-gray-300">Reset (Keep data)</button>
589
+
590
+ <div className="flex rounded-xl overflow-hidden border border-gray-200">
591
+ <button className={`text-xs px-3 py-1.5 ${viewMode==="daily" ? "bg-gray-900 text-white" : "bg-white text-gray-700 hover:bg-gray-50"}`} onClick={()=>setViewMode("daily")}>Daily</button>
592
+ <button className={`text-xs px-3 py-1.5 ${viewMode==="intraday" ? "bg-gray-900 text-white" : "bg-white text-gray-700 hover:bg-gray-50"}`} onClick={()=>setViewMode("intraday")}>Intraday (1 day)</button>
593
+ <button className={`text-xs px-3 py-1.5 ${viewMode==="multi" ? "bg-gray-900 text-white" : "bg-white text-gray-700 hover:bg-gray-50"}`} onClick={()=>setViewMode("multi")}>Multi-day Intraday</button>
594
+ </div>
595
+
596
+ {viewMode==="multi" && (
597
+ <button
598
+ className="text-xs px-3 py-1.5 rounded-xl border border-gray-200 hover:bg-gray-50"
599
+ onClick={() => setMultiNormalize(v => !v)}
600
+ title="Toggle normalized cumulative index vs raw price"
601
+ >
602
+ {multiNormalize ? "Normalized" : "Raw Price"}
603
+ </button>
604
+ )}
605
+
606
+ <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>
607
+ </div>
608
+ </div>
609
+
610
+ <div className="bg-white p-4 rounded-2xl shadow">
611
+ <div className="flex flex-wrap items-center gap-2 mb-3">
612
+ {assets.map((a, i) => (
613
+ <button
614
+ key={`pick-${a}`}
615
+ onClick={() => setSelectedAsset(a)}
616
+ 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"}`}
617
+ title={`Hotkey ${i+1}`}
618
+ >
619
+ <span className="inline-block w-2.5 h-2.5 rounded-full mr-2" style={{backgroundColor: `hsl(${(360/assets.length)*i},70%,50%)`}} />
620
+ {i+1}. {aliasOf(a)}
621
+ </button>
622
+ ))}
623
+ {assets.length>0 && <span className="text-xs text-gray-500 ml-1">Hotkeys: 1..{assets.length}, Enter confirm</span>}
624
+ </div>
625
+
626
+ <div className="h-80 relative">
627
+ <ResponsiveContainer width="100%" height="100%">
628
+ {viewMode === "intraday" ? (
629
+ <LineChart data={intradayData}>
630
+ <CartesianGrid strokeDasharray="3 3" />
631
+ <XAxis dataKey="idx" tick={{ fontSize: 10 }} />
632
+ <YAxis domain={["auto", "auto"]} tick={{ fontSize: 10 }} />
633
+ <Tooltip contentStyle={{ fontSize: 12 }} formatter={(v: any, n: any) => [v, aliasMap[n] || n]} />
634
+ <Legend onClick={(o: any) => setSelectedAsset(o.value)} wrapperStyle={{ cursor: "pointer" }} formatter={(v: any) => aliasMap[v] || v} />
635
+ {assets.map((a, i) => (
636
+ <Line
637
+ key={a}
638
+ type="linear"
639
+ dataKey={a}
640
+ name={aliasMap[a] || a}
641
+ strokeWidth={selectedAsset === a ? 5 : hoverAsset === a ? 4 : 2.5}
642
+ strokeOpacity={selectedAsset && selectedAsset !== a ? 0.3 : 1}
643
+ dot={false}
644
+ isAnimationActive={false}
645
+ stroke={`hsl(${(360 / assets.length) * i},70%,50%)`}
646
+ onMouseEnter={() => setHoverAsset(a)}
647
+ onMouseLeave={() => setHoverAsset(null)}
648
+ onClick={() => setSelectedAsset((p) => (p === a ? null : a))}
649
+ connectNulls={false}
650
+ />
651
+ ))}
652
+ </LineChart>
653
+ ) : viewMode === "multi" ? (
654
+ <>
655
+ <div className="absolute right-2 top-2 z-10 text-[10px] px-2 py-0.5 rounded bg-black/60 text-white">
656
+ Multi • {multiNormalize ? "Normalized (cum ×)" : "Raw Price"}
657
+ </div>
658
+ <LineChart data={multiRows}>
659
+ <CartesianGrid strokeDasharray="3 3" />
660
+ <XAxis
661
+ dataKey="x"
662
+ tick={{ fontSize: 10 }}
663
+ label={{ value: "Minute (concatenated days)", position: "insideBottomRight", offset: -2, fontSize: 10 }}
664
+ />
665
+ {/* Y 轴安全 padding,避免平直小波动时看不见 */}
666
+ <YAxis domain={multiYAxisDomain} tick={{ fontSize: 10 }} allowDataOverflow />
667
+ <Tooltip
668
+ contentStyle={{ fontSize: 12 }}
669
+ labelFormatter={(_, payload: any) => {
670
+ const p = payload?.[0]?.payload;
671
+ return p ? `${p.day} #${p.minute}` : "";
672
+ }}
673
+ formatter={(value: any, name: any) => {
674
+ const display = multiNormalize
675
+ ? (Number.isFinite(value) ? `${(value as number).toFixed(4)}×` : value)
676
+ : value;
677
+ return [display, aliasMap[name] || name];
678
+ }}
679
+ />
680
+ <Legend onClick={(o: any) => setSelectedAsset(o.value)} wrapperStyle={{ cursor: "pointer" }} formatter={(v: any) => aliasMap[v] || v} />
681
+ {dayStarts.map(s => (
682
+ <ReferenceLine key={s.x} x={s.x} stroke="#6b7280" strokeDasharray="4 2" ifOverflow="extendDomain" />
683
+ ))}
684
+ {assets.map((a, i) => (
685
+ <Line
686
+ key={a}
687
+ type="linear"
688
+ dataKey={a}
689
+ name={aliasMap[a] || a}
690
+ strokeWidth={selectedAsset === a ? 5 : hoverAsset === a ? 4 : 2.5}
691
+ strokeOpacity={selectedAsset && selectedAsset !== a ? 0.3 : 1}
692
+ dot={false}
693
+ isAnimationActive={false}
694
+ stroke={`hsl(${(360 / assets.length) * i},70%,50%)`}
695
+ onMouseEnter={() => setHoverAsset(a)}
696
+ onMouseLeave={() => setHoverAsset(null)}
697
+ onClick={() => setSelectedAsset((p) => (p === a ? null : a))}
698
+ connectNulls={true} // 关键:跨缺口仍然连线
699
+ />
700
+ ))}
701
+ </LineChart>
702
+ </>
703
+ ) : (
704
+ <LineChart data={windowData}>
705
+ <CartesianGrid strokeDasharray="3 3" />
706
+ <XAxis dataKey="date" tick={{ fontSize: 10 }} />
707
+ <YAxis domain={["auto", "auto"]} tick={{ fontSize: 10 }} />
708
+ <Tooltip contentStyle={{ fontSize: 12 }} formatter={(v: any, n: any) => [v, aliasMap[n] || n]} />
709
+ <Legend onClick={(o: any) => setSelectedAsset(o.value)} wrapperStyle={{ cursor: "pointer" }} formatter={(v: any) => aliasMap[v] || v} />
710
+ {assets.map((a, i) => (
711
+ <Line
712
+ key={a}
713
+ type="natural"
714
+ dataKey={a}
715
+ name={aliasMap[a] || a}
716
+ strokeWidth={selectedAsset === a ? 5 : hoverAsset === a ? 4 : 2.5}
717
+ strokeOpacity={selectedAsset && selectedAsset !== a ? 0.3 : 1}
718
+ dot={false}
719
+ isAnimationActive={false}
720
+ stroke={`hsl(${(360 / assets.length) * i},70%,50%)`}
721
+ onMouseEnter={() => setHoverAsset(a)}
722
+ onMouseLeave={() => setHoverAsset(null)}
723
+ onClick={() => setSelectedAsset((p) => (p === a ? null : a))}
724
+ />
725
+ ))}
726
+ </LineChart>
727
+ )}
728
+ </ResponsiveContainer>
729
+ </div>
730
+
731
+ {viewMode==="intraday" && (
732
+ <div className="mt-3">
733
+ <IntradayControls />
734
+ </div>
735
+ )}
736
+
737
+ {viewMode!=="multi" && (
738
+ <div className="flex justify-between items-center mt-3">
739
+ <div className="text-sm text-gray-600">
740
+ Selected: {selectedAsset ? aliasOf(selectedAsset) : "(none)"} {message && <span className="ml-2 text-gray-500">{message}</span>}
741
+ </div>
742
+ <button onClick={confirmSelection} disabled={!selectedAsset || windowLen >= maxDays} className={`px-4 py-2 rounded-xl ${selectedAsset && windowLen < maxDays ? "bg-blue-600 text-white" : "bg-gray-200 text-gray-500"}`}>
743
+ Confirm & Next Day →
744
+ </button>
745
+ </div>
746
+ )}
747
+ </div>
748
+
749
+ <div className="bg-white p-4 rounded-2xl shadow">
750
+ <h2 className="font-medium mb-2">Portfolio</h2>
751
+ <div className="h-64">
752
+ <ResponsiveContainer width="100%" height="100%">
753
+ <AreaChart data={portfolioSeries}>
754
+ <CartesianGrid strokeDasharray="3 3" />
755
+ <XAxis dataKey="step" tick={{ fontSize: 10 }} />
756
+ <YAxis domain={["auto", "auto"]} tick={{ fontSize: 10 }} />
757
+ <Tooltip />
758
+ <Area type="monotone" dataKey="value" stroke="#2563eb" fill="#bfdbfe" />
759
+ </AreaChart>
760
+ </ResponsiveContainer>
761
+ </div>
762
+ <ul className="text-sm text-gray-700 mt-2">
763
+ <li>Cumulative Return: {(stats.cumRet * 100).toFixed(2)}%</li>
764
+ <li>Volatility: {(stats.stdev * 100).toFixed(2)}%</li>
765
+ <li>Sharpe: {stats.sharpe.toFixed(2)}</li>
766
+ <li>Winning Days: {stats.wins}/{stats.N}</li>
767
+ </ul>
768
+ </div>
769
+
770
+ <div className="bg-white p-4 rounded-2xl shadow">
771
+ <h2 className="font-medium mb-2">Daily Selections</h2>
772
+ <div className="overflow-auto rounded-xl border border-gray-100">
773
+ <table className="min-w-full text-xs">
774
+ <thead className="bg-gray-50 text-gray-500">
775
+ <tr>
776
+ <th className="px-2 py-1 text-left">Step</th>
777
+ <th className="px-2 py-1 text-left">Date (t+1)</th>
778
+ <th className="px-2 py-1 text-left">Asset</th>
779
+ <th className="px-2 py-1 text-right">Return</th>
780
+ </tr>
781
+ </thead>
782
+ <tbody>
783
+ {selections.slice().reverse().map((s) => (
784
+ <tr key={`${s.step}-${s.asset}-${s.date}`} className="odd:bg-white even:bg-gray-50">
785
+ <td className="px-2 py-1">{s.step}</td>
786
+ <td className="px-2 py-1">{s.date}</td>
787
+ <td className="px-2 py-1">{aliasOf(s.asset)}</td>
788
+ <td className={`px-2 py-1 text-right ${s.ret>=0?"text-green-600":"text-red-600"}`}>{(s.ret*100).toFixed(2)}%</td>
789
+ </tr>
790
+ ))}
791
+ </tbody>
792
+ </table>
793
+ </div>
794
+ </div>
795
+ </div>
796
+ );
797
+ }
798
+
799
+ /** Final summary component (UI shows aliases, data keeps real tickers) */
800
+ export function FinalSummary({ assets, selections }: { assets: string[]; selections: { step: number; date: string; asset: string; ret: number }[] }) {
801
+ const rets = selections.map(s=>s.ret);
802
+ const N = rets.length;
803
+ const cum = rets.reduce((v,r)=> v*(1+r), 1) - 1;
804
+ const mean = N ? rets.reduce((a,b)=>a+b,0)/N : 0;
805
+ const variance = N ? rets.reduce((a,b)=> a + (b-mean)**2, 0)/N : 0;
806
+ const stdev = Math.sqrt(variance);
807
+ const sharpe = stdev ? (mean*252)/(stdev*Math.sqrt(252)) : 0;
808
+ const wins = rets.filter(r=>r>0).length;
809
+
810
+ const countsAll: Record<string, number> = assets.reduce((acc: any,a: string)=>{acc[a]=0;return acc;},{} as Record<string, number>);
811
+ selections.forEach(s=>{ countsAll[s.asset] = (countsAll[s.asset]||0)+1; });
812
+ const rankAll = assets.map(a=>({ asset:a, votes: countsAll[a]||0 })).sort((x,y)=> y.votes - x.votes);
813
+
814
+ const aliasMap = useMemo(() => {
815
+ const m: Record<string, string> = {};
816
+ assets.forEach((a, i) => { m[a] = `Ticker ${i + 1}`; });
817
+ return m;
818
+ }, [assets]);
819
+
820
+ const rankAllForChart = useMemo(() => {
821
+ return rankAll.map(r => ({ asset: aliasMap[r.asset] || r.asset, votes: r.votes }));
822
+ }, [rankAll, aliasMap]);
823
+
824
+ const lastStep = selections.reduce((m,s)=>Math.max(m,s.step),0);
825
+ const start30 = Math.max(1, lastStep - 30 + 1);
826
+ const cols = Array.from({length: Math.min(30,lastStep ? lastStep - start30 + 1 : 0)}, (_,i)=> start30 + i);
827
+ const grid = assets.map(a=>({ asset: aliasMap[a] || a, cells: cols.map(c => selections.some(s=> s.asset===a && s.step===c) ? 1 : 0) }));
828
+
829
+ return (
830
+ <div className="p-6 bg-gray-50 min-h-screen flex flex-col gap-6">
831
+ <h1 className="text-xl font-semibold">Final Summary</h1>
832
+ <div className="bg-white p-4 rounded-2xl shadow">
833
+ <h2 className="font-medium mb-2">Overall Metrics</h2>
834
+ <ul className="text-sm text-gray-700 space-y-1">
835
+ <li>Total Picks: {N}</li>
836
+ <li>Win Rate: {(N? (wins/N*100):0).toFixed(1)}%</li>
837
+ <li>Cumulative Return: {(cum*100).toFixed(2)}%</li>
838
+ <li>Volatility: {(stdev*100).toFixed(2)}%</li>
839
+ <li>Sharpe (rough): {sharpe.toFixed(2)}</li>
840
+ <li>Top Preference: {aliasMap[rankAll[0]?.asset ?? ""] ?? "-" } ({rankAll[0]?.votes ?? 0})</li>
841
+ </ul>
842
+ </div>
843
+ <div className="bg-white p-4 rounded-2xl shadow">
844
+ <h2 className="font-medium mb-2">Selection Preference Ranking</h2>
845
+ <div className="h-56">
846
+ <ResponsiveContainer width="100%" height="100%">
847
+ <BarChart data={rankAllForChart} margin={{ left: 8, right: 8, top: 8, bottom: 8 }}>
848
+ <CartesianGrid strokeDasharray="3 3" />
849
+ <XAxis dataKey="asset" tick={{ fontSize: 10 }} />
850
+ <YAxis allowDecimals={false} tick={{ fontSize: 10 }} />
851
+ <Tooltip />
852
+ <Bar dataKey="votes" fill="#60a5fa" />
853
+ </BarChart>
854
+ </ResponsiveContainer>
855
+ </div>
856
+ </div>
857
+ <div className="bg-white p-4 rounded-2xl shadow">
858
+ <h2 className="font-medium mb-2">Selection Heatmap (Last 30 Steps)</h2>
859
+ <div className="overflow-auto">
860
+ <table className="text-xs border-collapse">
861
+ <thead>
862
+ <tr>
863
+ <th className="p-1 pr-2 text-left sticky left-0 bg-white">Asset</th>
864
+ {cols.map(c=> (<th key={c} className="px-1 py-1 text-center">{c}</th>))}
865
+ </tr>
866
+ </thead>
867
+ <tbody>
868
+ {grid.map(row => (
869
+ <tr key={row.asset}>
870
+ <td className="p-1 pr-2 font-medium sticky left-0 bg-white">{row.asset}</td>
871
+ {row.cells.map((v,j)=> (
872
+ <td key={j} className="w-6 h-6" style={{ background: v? "#2563eb" : "#e5e7eb", opacity: v? 0.9 : 1, border: "1px solid #ffffff" }} />
873
+ ))}
874
+ </tr>
875
+ ))}
876
+ </tbody>
877
+ </table>
878
+ </div>
879
+ </div>
880
+ </div>
881
+ );
882
+ }