Spaces:
Running
Running
| import { useEffect, useMemo, useState } from "react"; | |
| import { | |
| LineChart, | |
| Line, | |
| XAxis, | |
| YAxis, | |
| Tooltip, | |
| ResponsiveContainer, | |
| Legend, | |
| CartesianGrid, | |
| AreaChart, | |
| Area, | |
| BarChart, | |
| Bar, | |
| } from "recharts"; | |
| import { | |
| apiGetLatestDataset, | |
| apiUpsertDataset, | |
| apiGetAnnotation, | |
| apiUpsertAnnotation, | |
| DataDict, | |
| } from "./api"; | |
| type SeriesPoint = { date: string; close: number[] }; | |
| type ViewMode = "daily" | "intraday"; | |
| const START_DAY = 7; | |
| /* --------------------- utils --------------------- */ | |
| function latestClose(p: SeriesPoint): number { | |
| const arr = p.close; | |
| if (!Array.isArray(arr) || arr.length === 0) throw new Error(`Empty close array at ${p.date}`); | |
| const last = arr[arr.length - 1]; | |
| if (!Number.isFinite(last)) throw new Error(`Invalid close value at ${p.date}`); | |
| return last; | |
| } | |
| function uuidv4() { | |
| return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => { | |
| const r = (Math.random() * 16) | 0, | |
| v = c === "x" ? r : (r & 0x3) | 0x8; | |
| return v.toString(16); | |
| }); | |
| } | |
| function getOrCreateUserId() { | |
| const k = "annot_user_id"; | |
| let uid = localStorage.getItem(k); | |
| if (!uid) { | |
| uid = uuidv4(); | |
| localStorage.setItem(k, uid); | |
| } | |
| return uid; | |
| } | |
| function stableHash(str: string): string { | |
| let h = 5381; | |
| for (let i = 0; i < str.length; i++) h = (h * 33) ^ str.charCodeAt(i); | |
| return (h >>> 0).toString(16); | |
| } | |
| /** Example data in the new format (close is a rolling intraday array up to 500) */ | |
| function generateFakeJSON(maxLen = 500): DataDict { | |
| const randTicker = () => Math.random().toString(36).substring(2, 6).toUpperCase(); | |
| const tickers = Array.from({ length: 5 }, () => randTicker()); | |
| const start = new Date("2024-01-02T00:00:00Z"); | |
| const dates = Array.from({ length: 67 }, (_, i) => { | |
| const d = new Date(start); | |
| d.setDate(start.getDate() + i); | |
| return d.toISOString().slice(0, 10); | |
| }); | |
| const out: DataDict = {}; | |
| for (let a = 0; a < 5; a++) { | |
| const ticker = tickers[a]; | |
| let price = 80 + Math.random() * 40; | |
| const mu = (Math.random() * 0.1 - 0.05) / 252; | |
| const sigma = 0.15 + Math.random() * 0.35; | |
| const series: SeriesPoint[] = []; | |
| for (let i = 0; i < dates.length; i++) { | |
| // Fake minute series (varying length) | |
| const mins = 200 + Math.floor(Math.random() * 100); | |
| const intraday: number[] = []; | |
| let p = price; | |
| for (let m = 0; m < mins; m++) { | |
| const dz = (Math.random() - 0.5) * 0.004; | |
| p = Math.max(0.01, p * (1 + dz)); | |
| intraday.push(Number(p.toFixed(2))); | |
| } | |
| series.push({ date: dates[i], close: intraday }); | |
| const drift = mu + (sigma / Math.sqrt(252)) * ((Math.random() - 0.5) * 2.0); | |
| price = intraday[intraday.length - 1] * (1 + drift); | |
| } | |
| out[ticker] = series; | |
| } | |
| return out; | |
| } | |
| /* --------------------- NEW: observation delta type --------------------- */ | |
| type ObservationDelta = { | |
| step: number; | |
| window_len_before: number; // confirm 前用户已看到的天数 d | |
| new_day_idx: number; // 新增那一天 index(0-based) | |
| new_date: string; | |
| daily_close: Record<string, number>; // asset -> latestClose at new_day_idx | |
| }; | |
| /* --------------------- component --------------------- */ | |
| export default function App() { | |
| const userId = getOrCreateUserId(); | |
| const [datasetId, setDatasetId] = useState<string | null>(null); | |
| const [datasetName, setDatasetName] = useState<string>(""); | |
| const [rawData, setRawData] = useState<DataDict>({}); | |
| const [assets, setAssets] = useState<string[]>([]); | |
| const [dates, setDates] = useState<string[]>([]); | |
| // Dynamic caps | |
| const maxDays = dates.length || 67; | |
| const maxSteps = Math.max(0, maxDays - START_DAY); | |
| const [step, setStep] = useState(1); | |
| const [windowLen, setWindowLen] = useState(START_DAY); | |
| const [selectedAsset, setSelectedAsset] = useState<string | null>(null); | |
| const [hoverAsset, setHoverAsset] = useState<string | null>(null); | |
| const [selections, setSelections] = useState<{ step: number; date: string; asset: string; ret: number }[]>([]); | |
| const [observations, setObservations] = useState<ObservationDelta[]>([]); // NEW | |
| const [message, setMessage] = useState(""); | |
| const [confirming, setConfirming] = useState(false); | |
| const [finalSaved, setFinalSaved] = useState(false); | |
| const [viewMode, setViewMode] = useState<ViewMode>("daily"); | |
| const [intradayDayIdx, setIntradayDayIdx] = useState<number | null>(null); | |
| /* ---------- localStorage keys ---------- */ | |
| const LS_DATASET_KEY = "annot_dataset_meta"; | |
| const LS_DATA_PREFIX = (id: string) => `annot_dataset_${id}`; | |
| const LS_ANN_PREFIX = (id: string, uid: string) => `annot_user_${uid}_ds_${id}`; | |
| /* ---------- alias map ---------- */ | |
| const aliasMap = useMemo(() => { | |
| const m: Record<string, string> = {}; | |
| assets.forEach((a, i) => { | |
| m[a] = `Ticker ${i + 1}`; | |
| }); | |
| return m; | |
| }, [assets]); | |
| const aliasOf = (a?: string | null) => (a && aliasMap[a]) || a || ""; | |
| /* ---------- boot ---------- */ | |
| useEffect(() => { | |
| const boot = async () => { | |
| try { | |
| const metaRaw = localStorage.getItem(LS_DATASET_KEY); | |
| if (metaRaw) { | |
| const meta = JSON.parse(metaRaw); | |
| if (meta?.datasetId) { | |
| const dJson = localStorage.getItem(LS_DATA_PREFIX(meta.datasetId)); | |
| if (dJson) { | |
| const payload = JSON.parse(dJson) as { | |
| data: DataDict; | |
| assets: string[]; | |
| dates: string[]; | |
| name?: string; | |
| }; | |
| loadDatasetIntoState(meta.datasetId, payload.data, payload.assets, payload.dates, payload.name || ""); | |
| await loadUserAnnotation(meta.datasetId); | |
| return; | |
| } | |
| } | |
| } | |
| try { | |
| const ds = await apiGetLatestDataset(); | |
| const id = (ds.id ?? ds.dataset_id) as string; | |
| const name = ds.name || ""; | |
| const data = ds.data as DataDict; | |
| const dsAssets = ds.assets as string[]; | |
| const dsDates = ds.dates as string[]; | |
| persistDatasetLocal(id, data, dsAssets, dsDates, name); | |
| loadDatasetIntoState(id, data, dsAssets, dsDates, name); | |
| await loadUserAnnotation(id); | |
| return; | |
| } catch {} | |
| const example = generateFakeJSON(); | |
| const keys = Object.keys(example); | |
| const dsDates = example[keys[0]].map((d) => d.date); | |
| const id = stableHash(JSON.stringify({ assets: keys, dates: dsDates })); | |
| persistDatasetLocal(id, example, keys, dsDates, "Example"); | |
| loadDatasetIntoState(id, example, keys, dsDates, "Example"); | |
| } catch (e) { | |
| console.warn("Boot failed:", e); | |
| } | |
| }; | |
| boot(); | |
| // eslint-disable-next-line react-hooks/exhaustive-deps | |
| }, []); | |
| function persistDatasetLocal(id: string, data: DataDict, a: string[], d: string[], name: string) { | |
| localStorage.setItem(LS_DATASET_KEY, JSON.stringify({ datasetId: id, name })); | |
| localStorage.setItem(LS_DATA_PREFIX(id), JSON.stringify({ data, assets: a, dates: d, name })); | |
| } | |
| function loadDatasetIntoState(id: string, data: DataDict, a: string[], d: string[], name: string) { | |
| setDatasetId(id); | |
| setDatasetName(name || ""); | |
| setRawData(data); | |
| setAssets(a); | |
| setDates(d); | |
| setStep(1); | |
| setWindowLen(Math.min(START_DAY, Math.max(1, d.length))); | |
| setSelections([]); | |
| setObservations([]); // NEW | |
| setSelectedAsset(null); | |
| setViewMode("daily"); | |
| setIntradayDayIdx(null); | |
| setMessage(`Loaded dataset ${name || id}: ${a.length} assets, ${d.length} days.`); | |
| setFinalSaved(false); | |
| } | |
| async function loadUserAnnotation(id: string) { | |
| const localRaw = localStorage.getItem(LS_ANN_PREFIX(id, userId)); | |
| if (localRaw) { | |
| try { | |
| const ann = JSON.parse(localRaw); | |
| setSelections(ann.selections || []); | |
| setObservations(ann.observations || []); // NEW | |
| setStep(ann.step || 1); | |
| setWindowLen(ann.window_len || START_DAY); | |
| setIntradayDayIdx(null); | |
| } catch {} | |
| } | |
| try { | |
| const ann = await apiGetAnnotation(id, userId); | |
| setSelections(ann.selections || []); | |
| setObservations(ann.observations || []); // NEW | |
| setStep(ann.step || 1); | |
| setWindowLen(ann.window_len || START_DAY); | |
| setIntradayDayIdx(null); | |
| localStorage.setItem(LS_ANN_PREFIX(id, userId), JSON.stringify(ann)); | |
| } catch {} | |
| } | |
| function persistAnnotationLocal() { | |
| if (!datasetId) return; | |
| const ann = { | |
| user_id: userId, | |
| dataset_id: datasetId, | |
| selections, | |
| observations, // NEW | |
| step, | |
| window_len: windowLen, | |
| }; | |
| localStorage.setItem(LS_ANN_PREFIX(datasetId, userId), JSON.stringify(ann)); | |
| } | |
| async function upsertAnnotationCloud() { | |
| if (!datasetId) return; | |
| try { | |
| // Backend may not accept extra fields; keep a safe cast. | |
| await apiUpsertAnnotation({ | |
| dataset_id: datasetId, | |
| user_id: userId, | |
| selections, | |
| observations, // NEW (if backend supports, great; if not, backend should ignore or you'll adjust API) | |
| step, | |
| window_len: windowLen, | |
| } as any); | |
| } catch (e) { | |
| console.warn("Upsert annotation failed:", e); | |
| } | |
| } | |
| useEffect(() => { | |
| persistAnnotationLocal(); | |
| upsertAnnotationCloud(); | |
| // eslint-disable-next-line react-hooks/exhaustive-deps | |
| }, [selections, observations, step, windowLen]); | |
| /* ---------- tie intraday selected day to window ---------- */ | |
| useEffect(() => { | |
| if (!dates.length) return; | |
| const lastIdx = Math.min(windowLen - 1, dates.length - 1); | |
| if (intradayDayIdx === null || intradayDayIdx > lastIdx) setIntradayDayIdx(lastIdx); | |
| }, [dates, windowLen, intradayDayIdx]); | |
| /* ---------- upload handler ---------- */ | |
| async function onFile(e: any) { | |
| const f = e.target.files?.[0]; | |
| if (!f) return; | |
| const reader = new FileReader(); | |
| reader.onload = async () => { | |
| try { | |
| const json: DataDict = JSON.parse(String(reader.result)); | |
| const keys = Object.keys(json); | |
| if (keys.length === 0) throw new Error("Empty dataset"); | |
| const firstArr = json[keys[0]]; | |
| if (!Array.isArray(firstArr) || !firstArr[0]?.date || !Array.isArray(firstArr[0]?.close)) { | |
| throw new Error("Invalid series format. Need [{date, close:number[]}]"); | |
| } | |
| const refDates = firstArr.map((p) => p.date); | |
| const checkPoint = (p: SeriesPoint) => { | |
| if (!p?.date) throw new Error("Missing date"); | |
| if (!Array.isArray(p.close) || p.close.length === 0) throw new Error(`Empty close array at ${p.date}`); | |
| if (p.close.length > 500) throw new Error(`close array exceeds 500 at ${p.date}`); | |
| for (const v of p.close) | |
| if (typeof v !== "number" || !Number.isFinite(v)) throw new Error(`Non numeric close in array at ${p.date}`); | |
| }; | |
| for (const k of keys) { | |
| const arr = json[k]; | |
| if (!Array.isArray(arr) || arr.length !== firstArr.length) throw new Error("All series must have the same length"); | |
| for (let i = 0; i < arr.length; i++) { | |
| const p = arr[i]; | |
| checkPoint(p); | |
| if (p.date !== refDates[i]) throw new Error("Date misalignment across assets"); | |
| } | |
| } | |
| const dsDates = firstArr.map((d) => d.date); | |
| const id = stableHash(JSON.stringify({ assets: keys, dates: dsDates })); | |
| const name = f.name.replace(/\.[^.]+$/, ""); | |
| persistDatasetLocal(id, json, keys, dsDates, name); | |
| loadDatasetIntoState(id, json, keys, dsDates, name); | |
| try { | |
| await apiUpsertDataset({ dataset_id: id, name, data: json, assets: keys, dates: dsDates }); | |
| await apiUpsertAnnotation({ | |
| dataset_id: id, | |
| user_id: userId, | |
| selections: [], | |
| observations: [], // NEW | |
| step: 1, | |
| window_len: Math.min(START_DAY, Math.max(1, dsDates.length)), | |
| } as any); | |
| } catch (err: any) { | |
| console.warn("Upload to backend failed:", err?.message || err); | |
| } | |
| } catch (err: any) { | |
| setMessage("Failed to parse JSON: " + err.message); | |
| } | |
| }; | |
| reader.readAsText(f); | |
| } | |
| /* ---------- computed ---------- */ | |
| const isFinal = windowLen >= maxDays || selections.length >= maxSteps; | |
| // Daily (normalized to day 0) | |
| const windowData = useMemo(() => { | |
| if (!dates.length || windowLen < 2) return [] as any[]; | |
| const sliceDates = dates.slice(0, windowLen); | |
| return sliceDates.map((date, idx) => { | |
| const row: Record<string, any> = { date }; | |
| assets.forEach((a) => { | |
| const base = rawData[a]?.[0] ? latestClose(rawData[a][0] as any) : 1; | |
| const val = rawData[a]?.[idx] ? latestClose((rawData[a] as any)[idx]) : base; | |
| row[a] = base ? val / base : 1; | |
| }); | |
| return row; | |
| }); | |
| }, [assets, dates, windowLen, rawData]); | |
| // Intraday (single day, normalized to first tick of the day) | |
| const visibleDates = useMemo(() => dates.slice(0, Math.max(0, windowLen)), [dates, windowLen]); | |
| const activeIntradayIdx = intradayDayIdx ?? Math.min(windowLen - 1, dates.length - 1); | |
| const intradayData = useMemo(() => { | |
| if (!dates.length || activeIntradayIdx == null || activeIntradayIdx < 0) return [] as any[]; | |
| const maxBars = assets.reduce((m, a) => Math.max(m, (rawData[a] as any)?.[activeIntradayIdx]?.close?.length ?? 0), 0); | |
| const dayFirstPrice: Record<string, number> = {}; | |
| assets.forEach((a) => { | |
| const arr = (rawData[a] as any)?.[activeIntradayIdx]?.close ?? []; | |
| dayFirstPrice[a] = arr.length ? arr[0] : 1; | |
| }); | |
| const rows: any[] = []; | |
| for (let i = 0; i < maxBars; i++) { | |
| const row: Record<string, any> = { idx: i + 1 }; | |
| assets.forEach((a) => { | |
| const arr = (rawData[a] as any)?.[activeIntradayIdx]?.close ?? []; | |
| const val = arr[i]; | |
| row[a] = typeof val === "number" ? (dayFirstPrice[a] ? val / dayFirstPrice[a] : null) : null; | |
| }); | |
| rows.push(row); | |
| } | |
| return rows; | |
| }, [assets, rawData, dates.length, activeIntradayIdx]); | |
| function realizedNextDayReturn(asset: string) { | |
| const t = windowLen - 1; | |
| if (t + 1 >= dates.length) return null as any; | |
| const series = rawData[asset] as any; | |
| const ret = latestClose(series[t + 1]) / latestClose(series[t]) - 1; | |
| return { date: dates[t + 1], ret }; | |
| } | |
| function loadExample() { | |
| const example = generateFakeJSON(); | |
| const keys = Object.keys(example); | |
| const first = example[keys[0]] as any[]; | |
| const dsDates = first.map((d) => d.date); | |
| const id = stableHash(JSON.stringify({ assets: keys, dates: dsDates })); | |
| persistDatasetLocal(id, example, keys, dsDates, "Example"); | |
| loadDatasetIntoState(id, example, keys, dsDates, "Example"); | |
| } | |
| function resetSession() { | |
| setSelections([]); | |
| setObservations([]); // NEW | |
| setSelectedAsset(null); | |
| setStep(1); | |
| setWindowLen(Math.min(START_DAY, Math.max(1, dates.length))); | |
| setMessage("Session reset."); | |
| setViewMode("daily"); | |
| setIntradayDayIdx(null); | |
| if (datasetId) localStorage.removeItem(LS_ANN_PREFIX(datasetId, userId)); | |
| } | |
| function exportLog() { | |
| const blob = new Blob([JSON.stringify(selections, null, 2)], { type: "application/json" }); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement("a"); | |
| a.href = url; | |
| a.download = `selections_${new Date().toISOString().slice(0, 10)}_${userId}.json`; | |
| a.click(); | |
| URL.revokeObjectURL(url); | |
| } | |
| function confirmSelection() { | |
| if (confirming) return; | |
| if (!selectedAsset) { | |
| setMessage("Select a line first."); | |
| return; | |
| } | |
| setConfirming(true); | |
| const res = realizedNextDayReturn(selectedAsset); | |
| if (!res) { | |
| setMessage("No more data available."); | |
| setConfirming(false); | |
| return; | |
| } | |
| // NEW: record observation delta for the newly revealed day (t+1) | |
| const newDayIdx = windowLen; // currently visible: [0..windowLen-1], newly revealed day is index windowLen | |
| if (newDayIdx < dates.length) { | |
| try { | |
| const daily_close: Record<string, number> = {}; | |
| for (const a of assets) { | |
| const sp = (rawData[a] as any)?.[newDayIdx]; | |
| if (sp) daily_close[a] = latestClose(sp); | |
| } | |
| const obs: ObservationDelta = { | |
| step, | |
| window_len_before: windowLen, | |
| new_day_idx: newDayIdx, | |
| new_date: dates[newDayIdx], | |
| daily_close, | |
| }; | |
| setObservations((prev) => [...prev, obs]); | |
| } catch (e) { | |
| console.warn("Failed to record observation:", e); | |
| } | |
| } | |
| const entry = { step, date: res.date, asset: selectedAsset, ret: res.ret }; | |
| setSelections((prev) => [...prev, entry]); | |
| setWindowLen((w) => Math.min(w + 1, maxDays)); | |
| setStep((s) => s + 1); | |
| setSelectedAsset(null); | |
| setConfirming(false); | |
| setMessage(`Pick ${step}: ${aliasOf(selectedAsset)} → next-day return ${(res.ret * 100).toFixed(2)}%`); | |
| } | |
| const portfolioSeries = useMemo(() => { | |
| let value = 1; | |
| const pts = selections.map((s) => { | |
| value *= 1 + s.ret; | |
| return { step: s.step, date: s.date, value }; | |
| }); | |
| return [{ step: 0, date: "start", value: 1 }, ...pts]; | |
| }, [selections]); | |
| const stats = useMemo(() => { | |
| const rets = selections.map((s) => s.ret); | |
| const N = rets.length; | |
| const cum = portfolioSeries.at(-1)?.value ?? 1; | |
| const mean = N ? rets.reduce((a, b) => a + b, 0) / N : 0; | |
| const variance = N ? rets.reduce((a, b) => a + (b - mean) ** 2, 0) / N : 0; | |
| const stdev = Math.sqrt(variance); | |
| const sharpe = stdev ? (mean * 252) / (stdev * Math.sqrt(252)) : 0; | |
| const wins = rets.filter((r) => r > 0).length; | |
| return { cumRet: cum - 1, stdev, sharpe, wins, N }; | |
| }, [portfolioSeries, selections]); | |
| function buildFinalPayload() { | |
| const countsAll = assets.reduce((acc: Record<string, number>, a: string) => { | |
| acc[a] = 0; | |
| return acc; | |
| }, {} as Record<string, number>); | |
| selections.forEach((s) => { | |
| countsAll[s.asset] = (countsAll[s.asset] || 0) + 1; | |
| }); | |
| const rankAll = assets | |
| .map((a) => ({ asset: a, votes: countsAll[a] || 0 })) | |
| .sort((x, y) => y.votes - x.votes); | |
| let value = 1; | |
| const portfolio = selections.map((s) => { | |
| value *= 1 + s.ret; | |
| return { step: s.step, date: s.date, value }; | |
| }); | |
| const lastStep2 = selections.reduce((m, s) => Math.max(m, s.step), 0); | |
| const start30_ = Math.max(1, lastStep2 - 30 + 1); | |
| const lastCols = Array.from( | |
| { length: Math.min(30, lastStep2 ? lastStep2 - start30_ + 1 : 0) }, | |
| (_, i) => start30_ + i | |
| ); | |
| const heatGrid = assets.map((a) => ({ | |
| asset: a, | |
| cells: lastCols.map((c) => (selections.some((s) => s.asset === a && s.step === c) ? 1 : 0)), | |
| })); | |
| const rets = selections.map((s) => s.ret); | |
| const N = rets.length; | |
| const cum = portfolio.at(-1)?.value ?? 1; | |
| const mean = N ? rets.reduce((a, b) => a + b, 0) / N : 0; | |
| const variance = N ? rets.reduce((a, b) => a + (b - mean) ** 2, 0) / N : 0; | |
| const stdev = Math.sqrt(variance); | |
| const sharpe = stdev ? (mean * 252) / (stdev * Math.sqrt(252)) : 0; | |
| const wins = rets.filter((r) => r > 0).length; | |
| return { | |
| meta: { | |
| saved_at: new Date().toISOString(), | |
| start_day: START_DAY, | |
| max_days: maxDays, | |
| max_steps: maxSteps, | |
| dataset_id: datasetId, | |
| dataset_name: datasetName, | |
| }, | |
| assets, | |
| dates, | |
| selections, | |
| observations, // NEW | |
| portfolio, | |
| stats: { cumRet: cum - 1, stdev, sharpe, wins, N }, | |
| preference_all: rankAll, | |
| heatmap_last30: { cols: lastCols, grid: heatGrid }, | |
| }; | |
| } | |
| useEffect(() => { | |
| const final = windowLen >= maxDays || selections.length >= maxSteps; | |
| if (final && !finalSaved) { | |
| try { | |
| const payload = buildFinalPayload(); | |
| localStorage.setItem("asset_experiment_final", JSON.stringify(payload)); | |
| const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" }); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement("a"); | |
| a.href = url; | |
| a.download = `run_summary_${new Date().toISOString().slice(0, 10)}_${userId}.json`; | |
| a.click(); | |
| URL.revokeObjectURL(url); | |
| setFinalSaved(true); | |
| } catch (e) { | |
| console.warn("Failed to save final JSON:", e); | |
| } | |
| } | |
| // eslint-disable-next-line react-hooks/exhaustive-deps | |
| }, [windowLen, selections.length, finalSaved, maxDays, maxSteps]); | |
| useEffect(() => { | |
| function onKey(e: KeyboardEvent) { | |
| const tag = (e.target && (e.target as HTMLElement).tagName) || ""; | |
| if (tag === "INPUT" || tag === "TEXTAREA") return; | |
| const idx = parseInt((e as any).key, 10) - 1; | |
| if (!Number.isNaN(idx) && idx >= 0 && idx < assets.length) setSelectedAsset(assets[idx]); | |
| if ((e as any).key === "Enter" && Boolean(selectedAsset) && windowLen < maxDays) confirmSelection(); | |
| } | |
| window.addEventListener("keydown", onKey); | |
| return () => window.removeEventListener("keydown", onKey); | |
| }, [assets, selectedAsset, windowLen, maxDays]); | |
| if (!datasetId) { | |
| return ( | |
| <div className="p-6"> | |
| <h1 className="text-xl font-semibold mb-3">Asset Choice Simulation</h1> | |
| <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> | |
| <input type="file" accept="application/json" onChange={onFile} className="text-sm" /> | |
| <div className="mt-4"> | |
| <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> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| const IntradayControls = () => ( | |
| <div className="flex items-center gap-2"> | |
| <span className="text-xs px-2 py-1 rounded bg-indigo-50 text-indigo-700"> | |
| Intraday • {visibleDates[intradayDayIdx ?? 0] || "-"} | |
| </span> | |
| <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> | |
| <select | |
| className="text-xs px-2 py-1 rounded-xl border border-gray-200" | |
| value={intradayDayIdx ?? 0} | |
| onChange={(e) => setIntradayDayIdx(Number(e.target.value))} | |
| > | |
| {visibleDates.map((d, idx) => ( | |
| <option key={d} value={idx}> | |
| {d} | |
| </option> | |
| ))} | |
| </select> | |
| <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> | |
| </div> | |
| ); | |
| return ( | |
| <div className="p-6 bg-gray-50 min-h-screen flex flex-col gap-6"> | |
| <div className="flex flex-wrap justify-between items-center gap-3"> | |
| <div className="flex items-center gap-2"> | |
| <h1 className="text-xl font-semibold">Asset Choice Simulation</h1> | |
| <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> | |
| <span className="text-xs text-gray-500">Dataset: {datasetName || datasetId}</span> | |
| <span className="text-xs text-gray-500">User: {userId.slice(0, 8)}…</span> | |
| <span className="text-xs text-gray-500"> | |
| Day {windowLen} / {maxDays} | |
| </span> | |
| </div> | |
| <div className="flex flex-wrap items-center gap-2"> | |
| <input type="file" accept="application/json" onChange={onFile} className="text-sm" /> | |
| <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> | |
| <button | |
| onClick={resetSession} | |
| className="text-sm px-3 py-1.5 rounded-xl bg-gray-200 hover:bg-gray-300" | |
| > | |
| Reset (Keep data) | |
| </button> | |
| <div className="flex rounded-xl overflow-hidden border border-gray-200"> | |
| <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> | |
| <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> | |
| </div> | |
| <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> | |
| </div> | |
| </div> | |
| <div className="bg-white p-4 rounded-2xl shadow"> | |
| <div className="flex flex-wrap items-center gap-2 mb-3"> | |
| {assets.map((a, i) => ( | |
| <button | |
| key={`pick-${a}`} | |
| onClick={() => setSelectedAsset(a)} | |
| 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" | |
| }`} | |
| title={`Hotkey ${i + 1}`} | |
| > | |
| <span | |
| className="inline-block w-2.5 h-2.5 rounded-full mr-2" | |
| style={{ backgroundColor: `hsl(${(360 / assets.length) * i},70%,50%)` }} | |
| /> | |
| {i + 1}. {aliasOf(a)} | |
| </button> | |
| ))} | |
| {assets.length > 0 && ( | |
| <span className="text-xs text-gray-500 ml-1">Hotkeys: 1..{assets.length}, Enter confirm</span> | |
| )} | |
| </div> | |
| <div className="h-80 relative"> | |
| <ResponsiveContainer width="100%" height="100%"> | |
| {viewMode === "intraday" ? ( | |
| <LineChart data={intradayData}> | |
| <CartesianGrid strokeDasharray="3 3" /> | |
| <XAxis dataKey="idx" tick={{ fontSize: 10 }} /> | |
| <YAxis domain={["auto", "auto"]} tick={{ fontSize: 10 }} /> | |
| <Tooltip | |
| contentStyle={{ fontSize: 12 }} | |
| formatter={(v: any, n: any) => [v, aliasMap[n] || n]} | |
| /> | |
| <Legend | |
| onClick={(o: any) => setSelectedAsset(o.value)} | |
| wrapperStyle={{ cursor: "pointer" }} | |
| formatter={(v: any) => aliasMap[v] || v} | |
| /> | |
| {assets.map((a, i) => ( | |
| <Line | |
| key={a} | |
| type="linear" | |
| dataKey={a} | |
| name={aliasMap[a] || a} | |
| strokeWidth={selectedAsset === a ? 5 : hoverAsset === a ? 4 : 2.5} | |
| strokeOpacity={selectedAsset && selectedAsset !== a ? 0.3 : 1} | |
| dot={false} | |
| isAnimationActive={false} | |
| stroke={`hsl(${(360 / assets.length) * i},70%,50%)`} | |
| onMouseEnter={() => setHoverAsset(a)} | |
| onMouseLeave={() => setHoverAsset(null)} | |
| onClick={() => setSelectedAsset((p) => (p === a ? null : a))} | |
| connectNulls={false} | |
| /> | |
| ))} | |
| </LineChart> | |
| ) : ( | |
| <LineChart data={windowData}> | |
| <CartesianGrid strokeDasharray="3 3" /> | |
| <XAxis dataKey="date" tick={{ fontSize: 10 }} /> | |
| <YAxis domain={["auto", "auto"]} tick={{ fontSize: 10 }} /> | |
| <Tooltip | |
| contentStyle={{ fontSize: 12 }} | |
| formatter={(v: any, n: any) => [v, aliasMap[n] || n]} | |
| /> | |
| <Legend | |
| onClick={(o: any) => setSelectedAsset(o.value)} | |
| wrapperStyle={{ cursor: "pointer" }} | |
| formatter={(v: any) => aliasMap[v] || v} | |
| /> | |
| {assets.map((a, i) => ( | |
| <Line | |
| key={a} | |
| type="natural" | |
| dataKey={a} | |
| name={aliasMap[a] || a} | |
| strokeWidth={selectedAsset === a ? 5 : hoverAsset === a ? 4 : 2.5} | |
| strokeOpacity={selectedAsset && selectedAsset !== a ? 0.3 : 1} | |
| dot={false} | |
| isAnimationActive={false} | |
| stroke={`hsl(${(360 / assets.length) * i},70%,50%)`} | |
| onMouseEnter={() => setHoverAsset(a)} | |
| onMouseLeave={() => setHoverAsset(null)} | |
| onClick={() => setSelectedAsset((p) => (p === a ? null : a))} | |
| /> | |
| ))} | |
| </LineChart> | |
| )} | |
| </ResponsiveContainer> | |
| </div> | |
| {viewMode === "intraday" && ( | |
| <div className="mt-3"> | |
| <IntradayControls /> | |
| </div> | |
| )} | |
| {viewMode !== "intraday" && ( | |
| <div className="flex justify-between items-center mt-3"> | |
| <div className="text-sm text-gray-600"> | |
| Selected: {selectedAsset ? aliasOf(selectedAsset) : "(none)"}{" "} | |
| {message && <span className="ml-2 text-gray-500">{message}</span>} | |
| </div> | |
| <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" | |
| }`} | |
| > | |
| Confirm & Next Day → | |
| </button> | |
| </div> | |
| )} | |
| </div> | |
| <div className="bg-white p-4 rounded-2xl shadow"> | |
| <h2 className="font-medium mb-2">Portfolio</h2> | |
| <div className="h-64"> | |
| <ResponsiveContainer width="100%" height="100%"> | |
| <AreaChart data={portfolioSeries}> | |
| <CartesianGrid strokeDasharray="3 3" /> | |
| <XAxis dataKey="step" tick={{ fontSize: 10 }} /> | |
| <YAxis domain={["auto", "auto"]} tick={{ fontSize: 10 }} /> | |
| <Tooltip /> | |
| <Area type="monotone" dataKey="value" stroke="#2563eb" fill="#bfdbfe" /> | |
| </AreaChart> | |
| </ResponsiveContainer> | |
| </div> | |
| <ul className="text-sm text-gray-700 mt-2"> | |
| <li>Cumulative Return: {(stats.cumRet * 100).toFixed(2)}%</li> | |
| <li>Volatility: {(stats.stdev * 100).toFixed(2)}%</li> | |
| <li>Sharpe: {stats.sharpe.toFixed(2)}</li> | |
| <li> | |
| Winning Days: {stats.wins}/{stats.N} | |
| </li> | |
| </ul> | |
| </div> | |
| <div className="bg-white p-4 rounded-2xl shadow"> | |
| <h2 className="font-medium mb-2">Daily Selections</h2> | |
| <div className="overflow-auto rounded-xl border border-gray-100"> | |
| <table className="min-w-full text-xs"> | |
| <thead className="bg-gray-50 text-gray-500"> | |
| <tr> | |
| <th className="px-2 py-1 text-left">Step</th> | |
| <th className="px-2 py-1 text-left">Date (t+1)</th> | |
| <th className="px-2 py-1 text-left">Asset</th> | |
| <th className="px-2 py-1 text-right">Return</th> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| {selections | |
| .slice() | |
| .reverse() | |
| .map((s) => ( | |
| <tr key={`${s.step}-${s.asset}-${s.date}`} className="odd:bg-white even:bg-gray-50"> | |
| <td className="px-2 py-1">{s.step}</td> | |
| <td className="px-2 py-1">{s.date}</td> | |
| <td className="px-2 py-1">{aliasOf(s.asset)}</td> | |
| <td className={`px-2 py-1 text-right ${s.ret >= 0 ? "text-green-600" : "text-red-600"}`}> | |
| {(s.ret * 100).toFixed(2)}% | |
| </td> | |
| </tr> | |
| ))} | |
| </tbody> | |
| </table> | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| /** Final summary component (UI shows aliases, data keeps real tickers) */ | |
| export function FinalSummary({ | |
| assets, | |
| selections, | |
| }: { | |
| assets: string[]; | |
| selections: { step: number; date: string; asset: string; ret: number }[]; | |
| }) { | |
| const rets = selections.map((s) => s.ret); | |
| const N = rets.length; | |
| const cum = rets.reduce((v, r) => v * (1 + r), 1) - 1; | |
| const mean = N ? rets.reduce((a, b) => a + b, 0) / N : 0; | |
| const variance = N ? rets.reduce((a, b) => a + (b - mean) ** 2, 0) / N : 0; | |
| const stdev = Math.sqrt(variance); | |
| const sharpe = stdev ? (mean * 252) / (stdev * Math.sqrt(252)) : 0; | |
| const wins = rets.filter((r) => r > 0).length; | |
| const countsAll: Record<string, number> = assets.reduce((acc: any, a: string) => { | |
| acc[a] = 0; | |
| return acc; | |
| }, {} as Record<string, number>); | |
| selections.forEach((s) => { | |
| countsAll[s.asset] = (countsAll[s.asset] || 0) + 1; | |
| }); | |
| const rankAll = assets.map((a) => ({ asset: a, votes: countsAll[a] || 0 })).sort((x, y) => y.votes - x.votes); | |
| const aliasMap = useMemo(() => { | |
| const m: Record<string, string> = {}; | |
| assets.forEach((a, i) => { | |
| m[a] = `Ticker ${i + 1}`; | |
| }); | |
| return m; | |
| }, [assets]); | |
| const rankAllForChart = useMemo(() => { | |
| return rankAll.map((r) => ({ asset: aliasMap[r.asset] || r.asset, votes: r.votes })); | |
| }, [rankAll, aliasMap]); | |
| const lastStep = selections.reduce((m, s) => Math.max(m, s.step), 0); | |
| const start30 = Math.max(1, lastStep - 30 + 1); | |
| const cols = Array.from({ length: Math.min(30, lastStep ? lastStep - start30 + 1 : 0) }, (_, i) => start30 + i); | |
| const grid = assets.map((a) => ({ | |
| asset: aliasMap[a] || a, | |
| cells: cols.map((c) => (selections.some((s) => s.asset === a && s.step === c) ? 1 : 0)), | |
| })); | |
| return ( | |
| <div className="p-6 bg-gray-50 min-h-screen flex flex-col gap-6"> | |
| <h1 className="text-xl font-semibold">Final Summary</h1> | |
| <div className="bg-white p-4 rounded-2xl shadow"> | |
| <h2 className="font-medium mb-2">Overall Metrics</h2> | |
| <ul className="text-sm text-gray-700 space-y-1"> | |
| <li>Total Picks: {N}</li> | |
| <li>Win Rate: {(N ? (wins / N) * 100 : 0).toFixed(1)}%</li> | |
| <li>Cumulative Return: {(cum * 100).toFixed(2)}%</li> | |
| <li>Volatility: {(stdev * 100).toFixed(2)}%</li> | |
| <li>Sharpe (rough): {sharpe.toFixed(2)}</li> | |
| <li> | |
| Top Preference: {aliasMap[rankAll[0]?.asset ?? ""] ?? "-"} ({rankAll[0]?.votes ?? 0}) | |
| </li> | |
| </ul> | |
| </div> | |
| <div className="bg-white p-4 rounded-2xl shadow"> | |
| <h2 className="font-medium mb-2">Selection Preference Ranking</h2> | |
| <div className="h-56"> | |
| <ResponsiveContainer width="100%" height="100%"> | |
| <BarChart data={rankAllForChart} margin={{ left: 8, right: 8, top: 8, bottom: 8 }}> | |
| <CartesianGrid strokeDasharray="3 3" /> | |
| <XAxis dataKey="asset" tick={{ fontSize: 10 }} /> | |
| <YAxis allowDecimals={false} tick={{ fontSize: 10 }} /> | |
| <Tooltip /> | |
| <Bar dataKey="votes" fill="#60a5fa" /> | |
| </BarChart> | |
| </ResponsiveContainer> | |
| </div> | |
| </div> | |
| <div className="bg-white p-4 rounded-2xl shadow"> | |
| <h2 className="font-medium mb-2">Selection Heatmap (Last 30 Steps)</h2> | |
| <div className="overflow-auto"> | |
| <table className="text-xs border-collapse"> | |
| <thead> | |
| <tr> | |
| <th className="p-1 pr-2 text-left sticky left-0 bg-white">Asset</th> | |
| {cols.map((c) => ( | |
| <th key={c} className="px-1 py-1 text-center"> | |
| {c} | |
| </th> | |
| ))} | |
| </tr> | |
| </thead> | |
| <tbody> | |
| {grid.map((row) => ( | |
| <tr key={row.asset}> | |
| <td className="p-1 pr-2 font-medium sticky left-0 bg-white">{row.asset}</td> | |
| {row.cells.map((v, j) => ( | |
| <td | |
| key={j} | |
| className="w-6 h-6" | |
| style={{ | |
| background: v ? "#2563eb" : "#e5e7eb", | |
| opacity: v ? 0.9 : 1, | |
| border: "1px solid #ffffff", | |
| }} | |
| /> | |
| ))} | |
| </tr> | |
| ))} | |
| </tbody> | |
| </table> | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| } | |