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"; // 移除 multi 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; } /* --------------------- component --------------------- */ export default function App() { const userId = getOrCreateUserId(); const [datasetId, setDatasetId] = useState(null); const [datasetName, setDatasetName] = useState(""); const [rawData, setRawData] = useState({}); const [assets, setAssets] = useState([]); const [dates, setDates] = useState([]); // 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(null); const [hoverAsset, setHoverAsset] = useState(null); const [selections, setSelections] = useState<{ step: number; date: string; asset: string; ret: number }[]>([]); const [message, setMessage] = useState(""); const [confirming, setConfirming] = useState(false); const [finalSaved, setFinalSaved] = useState(false); const [viewMode, setViewMode] = useState("daily"); const [intradayDayIdx, setIntradayDayIdx] = useState(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 = {}; 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([]); 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 || []); setStep(ann.step || 1); setWindowLen(ann.window_len || START_DAY); setIntradayDayIdx(null); } catch {} } try { const ann = await apiGetAnnotation(id, userId); setSelections(ann.selections || []); 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, step, window_len: windowLen }; localStorage.setItem(LS_ANN_PREFIX(datasetId, userId), JSON.stringify(ann)); } async function upsertAnnotationCloud() { if (!datasetId) return; try { await apiUpsertAnnotation({ dataset_id: datasetId, user_id: userId, selections, step, window_len: windowLen }); } catch (e) { console.warn("Upsert annotation failed:", e); } } useEffect(() => { persistAnnotationLocal(); upsertAnnotationCloud(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [selections, 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: [], step: 1, window_len: Math.min(START_DAY, Math.max(1, dsDates.length)) }); } 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 = { date }; assets.forEach((a) => { const base = rawData[a]?.[0] ? latestClose(rawData[a][0]) : 1; const val = rawData[a]?.[idx] ? latestClose(rawData[a][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]?.[activeIntradayIdx]?.close?.length ?? 0), 0); const dayFirstPrice: Record = {}; assets.forEach(a => { const arr = rawData[a]?.[activeIntradayIdx]?.close ?? []; dayFirstPrice[a] = arr.length ? arr[0] : 1; }); const rows: any[] = []; for (let i = 0; i < maxBars; i++) { const row: Record = { idx: i + 1 }; assets.forEach(a => { const arr = rawData[a]?.[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]; 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]]; 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([]); 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; } 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 lastStep = selections.reduce((m, s) => Math.max(m, s.step), 0); const start30 = Math.max(1, lastStep - 30 + 1); const countsAll = assets.reduce((acc: Record, a: string) => { acc[a] = 0; return acc; }, {} as Record); 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, 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 (

Asset Choice Simulation

Upload a dataset (new format) or load example. Data persists locally & on the Space backend.

); } const IntradayControls = () => (
Intraday • {visibleDates[intradayDayIdx ?? 0] || "-"}
); return (

Asset Choice Simulation

BUILD_TAG: 2025-10-28 Dataset: {datasetName || datasetId} User: {userId.slice(0,8)}… Day {windowLen} / {maxDays}
{assets.map((a, i) => ( ))} {assets.length>0 && Hotkeys: 1..{assets.length}, Enter confirm}
{viewMode === "intraday" ? ( [v, aliasMap[n] || n]} /> setSelectedAsset(o.value)} wrapperStyle={{ cursor: "pointer" }} formatter={(v: any) => aliasMap[v] || v} /> {assets.map((a, i) => ( setHoverAsset(a)} onMouseLeave={() => setHoverAsset(null)} onClick={() => setSelectedAsset((p) => (p === a ? null : a))} connectNulls={false} /> ))} ) : ( [v, aliasMap[n] || n]} /> setSelectedAsset(o.value)} wrapperStyle={{ cursor: "pointer" }} formatter={(v: any) => aliasMap[v] || v} /> {assets.map((a, i) => ( setHoverAsset(a)} onMouseLeave={() => setHoverAsset(null)} onClick={() => setSelectedAsset((p) => (p === a ? null : a))} /> ))} )}
{viewMode==="intraday" && (
)} {viewMode!=="intraday" && (
Selected: {selectedAsset ? aliasOf(selectedAsset) : "(none)"} {message && {message}}
)}

Portfolio

  • Cumulative Return: {(stats.cumRet * 100).toFixed(2)}%
  • Volatility: {(stats.stdev * 100).toFixed(2)}%
  • Sharpe: {stats.sharpe.toFixed(2)}
  • Winning Days: {stats.wins}/{stats.N}

Daily Selections

{selections.slice().reverse().map((s) => ( ))}
Step Date (t+1) Asset Return
{s.step} {s.date} {aliasOf(s.asset)} =0?"text-green-600":"text-red-600"}`}>{(s.ret*100).toFixed(2)}%
); } /** 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 = assets.reduce((acc: any,a: string)=>{acc[a]=0;return acc;},{} as Record); 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 = {}; 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 (

Final Summary

Overall Metrics

  • Total Picks: {N}
  • Win Rate: {(N? (wins/N*100):0).toFixed(1)}%
  • Cumulative Return: {(cum*100).toFixed(2)}%
  • Volatility: {(stdev*100).toFixed(2)}%
  • Sharpe (rough): {sharpe.toFixed(2)}
  • Top Preference: {aliasMap[rankAll[0]?.asset ?? ""] ?? "-" } ({rankAll[0]?.votes ?? 0})

Selection Preference Ranking

Selection Heatmap (Last 30 Steps)

{cols.map(c=> ())} {grid.map(row => ( {row.cells.map((v,j)=> ( ))}
Asset{c}
{row.asset} ))}
); }