Spaces:
Running
Running
Yan Wang
commited on
Commit
·
b029aed
1
Parent(s):
be72531
updating App_New
Browse files- 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
|
| 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 |
-
//
|
| 324 |
-
const
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
}
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
const
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
const
|
| 443 |
-
|
| 444 |
-
const
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
let value = 1;
|
| 455 |
-
const
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
|
| 459 |
-
|
| 460 |
-
const rets = selections.map((s) => s.ret);
|
| 461 |
-
const N = rets.length;
|
| 462 |
-
const cum =
|
| 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 |
-
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
};
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
|
| 482 |
-
const
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
|
| 491 |
-
|
| 492 |
-
|
| 493 |
-
|
| 494 |
-
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
|
| 503 |
-
|
| 504 |
-
|
| 505 |
-
|
| 506 |
-
|
| 507 |
-
|
| 508 |
-
|
| 509 |
-
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
|
| 513 |
-
|
| 514 |
-
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
|
| 518 |
-
|
| 519 |
-
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
|
| 525 |
-
|
| 526 |
-
|
| 527 |
-
|
| 528 |
-
|
| 529 |
-
|
| 530 |
-
|
| 531 |
-
|
| 532 |
-
|
| 533 |
-
|
| 534 |
-
|
| 535 |
-
|
| 536 |
-
|
| 537 |
-
|
| 538 |
-
|
| 539 |
-
|
| 540 |
-
|
| 541 |
-
|
| 542 |
-
|
| 543 |
-
|
| 544 |
-
|
| 545 |
-
|
| 546 |
-
|
| 547 |
-
|
| 548 |
-
|
| 549 |
-
|
| 550 |
-
|
| 551 |
-
|
| 552 |
-
|
| 553 |
-
|
| 554 |
-
<
|
| 555 |
-
<
|
| 556 |
-
|
| 557 |
-
|
| 558 |
-
|
| 559 |
-
|
| 560 |
-
|
| 561 |
-
|
| 562 |
-
|
| 563 |
-
|
| 564 |
-
|
| 565 |
-
|
| 566 |
-
|
| 567 |
-
|
| 568 |
-
|
| 569 |
-
|
| 570 |
-
|
| 571 |
-
|
| 572 |
-
|
| 573 |
-
|
| 574 |
-
|
| 575 |
-
|
| 576 |
-
|
| 577 |
-
|
| 578 |
-
|
| 579 |
-
|
| 580 |
-
|
| 581 |
-
|
| 582 |
-
|
| 583 |
-
<
|
| 584 |
-
</div>
|
| 585 |
-
|
| 586 |
-
|
| 587 |
-
|
| 588 |
-
|
| 589 |
-
|
| 590 |
-
|
| 591 |
-
|
| 592 |
-
|
| 593 |
-
|
| 594 |
-
|
| 595 |
-
|
| 596 |
-
|
| 597 |
-
|
| 598 |
-
|
| 599 |
-
|
| 600 |
-
|
| 601 |
-
|
| 602 |
-
|
| 603 |
-
|
| 604 |
-
|
| 605 |
-
|
| 606 |
-
|
| 607 |
-
|
| 608 |
-
|
| 609 |
-
|
| 610 |
-
|
| 611 |
-
|
| 612 |
-
|
| 613 |
-
|
| 614 |
-
|
| 615 |
-
|
| 616 |
-
|
| 617 |
-
|
| 618 |
-
|
| 619 |
-
|
| 620 |
-
|
| 621 |
-
|
| 622 |
-
|
| 623 |
-
|
| 624 |
-
|
| 625 |
-
|
| 626 |
-
|
| 627 |
-
|
| 628 |
-
|
| 629 |
-
|
| 630 |
-
|
| 631 |
-
|
| 632 |
-
<
|
| 633 |
-
|
| 634 |
-
|
| 635 |
-
|
| 636 |
-
<
|
| 637 |
-
|
| 638 |
-
|
| 639 |
-
|
| 640 |
-
|
| 641 |
-
|
| 642 |
-
|
| 643 |
-
|
| 644 |
-
|
| 645 |
-
|
| 646 |
-
|
| 647 |
-
|
| 648 |
-
|
| 649 |
-
}
|
| 650 |
-
|
| 651 |
-
|
| 652 |
-
|
| 653 |
-
|
| 654 |
-
|
| 655 |
-
|
| 656 |
-
|
| 657 |
-
|
| 658 |
-
|
| 659 |
-
|
| 660 |
-
|
| 661 |
-
|
| 662 |
-
|
| 663 |
-
|
| 664 |
-
|
| 665 |
-
|
| 666 |
-
|
| 667 |
-
|
| 668 |
-
|
| 669 |
-
|
| 670 |
-
|
| 671 |
-
|
| 672 |
-
|
| 673 |
-
|
| 674 |
-
|
| 675 |
-
|
| 676 |
-
|
| 677 |
-
|
| 678 |
-
|
| 679 |
-
|
| 680 |
-
|
| 681 |
-
|
| 682 |
-
|
| 683 |
-
|
| 684 |
-
|
| 685 |
-
|
| 686 |
-
|
| 687 |
-
|
| 688 |
-
|
| 689 |
-
|
| 690 |
-
|
| 691 |
-
|
| 692 |
-
|
| 693 |
-
|
| 694 |
-
|
| 695 |
-
|
| 696 |
-
|
| 697 |
-
|
| 698 |
-
|
| 699 |
-
|
| 700 |
-
|
| 701 |
-
|
| 702 |
-
|
| 703 |
-
|
| 704 |
-
|
| 705 |
-
|
| 706 |
-
|
| 707 |
-
|
| 708 |
-
|
| 709 |
-
|
| 710 |
-
|
| 711 |
-
|
| 712 |
-
|
| 713 |
-
|
| 714 |
-
|
| 715 |
-
|
| 716 |
-
|
| 717 |
-
|
| 718 |
-
|
| 719 |
-
|
| 720 |
-
|
| 721 |
-
|
| 722 |
-
|
| 723 |
-
|
| 724 |
-
|
| 725 |
-
|
| 726 |
-
|
| 727 |
-
|
| 728 |
-
|
| 729 |
-
|
| 730 |
-
|
| 731 |
-
|
| 732 |
-
|
| 733 |
-
|
| 734 |
-
|
| 735 |
-
|
| 736 |
-
|
| 737 |
-
|
| 738 |
-
|
| 739 |
-
|
| 740 |
-
|
| 741 |
-
|
| 742 |
-
|
| 743 |
-
|
| 744 |
-
|
| 745 |
-
|
| 746 |
-
|
| 747 |
-
|
| 748 |
-
|
| 749 |
-
|
| 750 |
-
<
|
| 751 |
-
|
| 752 |
-
|
| 753 |
-
|
| 754 |
-
|
| 755 |
-
|
| 756 |
-
|
| 757 |
-
|
| 758 |
-
|
| 759 |
-
</
|
| 760 |
-
|
| 761 |
-
|
| 762 |
-
|
| 763 |
-
|
| 764 |
-
|
| 765 |
-
|
| 766 |
-
|
| 767 |
-
|
| 768 |
-
|
| 769 |
-
|
| 770 |
-
|
| 771 |
-
</
|
| 772 |
-
|
| 773 |
-
|
| 774 |
-
|
| 775 |
-
|
| 776 |
-
|
| 777 |
-
|
| 778 |
-
|
| 779 |
-
|
| 780 |
-
|
| 781 |
-
|
| 782 |
-
|
| 783 |
-
|
| 784 |
-
|
| 785 |
-
|
| 786 |
-
|
| 787 |
-
|
| 788 |
-
|
| 789 |
-
|
| 790 |
-
|
| 791 |
-
|
| 792 |
-
|
| 793 |
-
|
| 794 |
-
|
| 795 |
-
|
| 796 |
-
|
| 797 |
-
|
| 798 |
-
|
| 799 |
-
|
| 800 |
-
|
| 801 |
-
|
| 802 |
-
const
|
| 803 |
-
const
|
| 804 |
-
const
|
| 805 |
-
const
|
| 806 |
-
|
| 807 |
-
|
| 808 |
-
|
| 809 |
-
|
| 810 |
-
|
| 811 |
-
|
| 812 |
-
|
| 813 |
-
|
| 814 |
-
|
| 815 |
-
|
| 816 |
-
|
| 817 |
-
|
| 818 |
-
|
| 819 |
-
|
| 820 |
-
|
| 821 |
-
|
| 822 |
-
|
| 823 |
-
|
| 824 |
-
|
| 825 |
-
|
| 826 |
-
|
| 827 |
-
|
| 828 |
-
|
| 829 |
-
|
| 830 |
-
|
| 831 |
-
|
| 832 |
-
|
| 833 |
-
</
|
| 834 |
-
|
| 835 |
-
|
| 836 |
-
|
| 837 |
-
|
| 838 |
-
<
|
| 839 |
-
|
| 840 |
-
|
| 841 |
-
|
| 842 |
-
|
| 843 |
-
|
| 844 |
-
|
| 845 |
-
|
| 846 |
-
|
| 847 |
-
|
| 848 |
-
|
| 849 |
-
|
| 850 |
-
|
| 851 |
-
|
| 852 |
-
|
| 853 |
-
|
| 854 |
-
|
| 855 |
-
|
| 856 |
-
|
| 857 |
-
|
| 858 |
-
|
| 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 |
+
}
|