diff --git a/src/client/LivePage.tsx b/src/client/LivePage.tsx index 60f3c2c..01f05e2 100644 --- a/src/client/LivePage.tsx +++ b/src/client/LivePage.tsx @@ -9,12 +9,23 @@ import { type Time, } from "lightweight-charts"; import { ArrowDownRight, ArrowUpRight, RefreshCw, Wallet } from "lucide-react"; -import type { - LiveStrategyState, - StrategyMarket, - TradeLogEntry, +import { + calendarDaysSince, + currencyForMarket, + formatMoney, + formatNumber, + formatPct, + type EquityPoint, + type LiveStrategyState, + type StrategyCurrency, + type StrategyMarket, + type TradeLogEntry, } from "../shared/strategyTypes"; +// A strategy whose state was last updated more than this many calendar days +// ago is flagged (covers a normal weekend plus a holiday). +const LIVE_STALE_DAYS = 4; + async function api(path: string, init?: RequestInit): Promise { const response = await fetch(path, { ...init, @@ -123,7 +134,7 @@ export function LivePage() { className={`ghostButton ${loading ? "loading" : ""}`} onClick={() => refresh()} disabled={loading} - title="重新拉取最新行情和持仓" + title="重新读取 live 状态(不会拉取新行情)" type="button" > @@ -156,7 +167,7 @@ export function LivePage() { {s.name}
- {money(s.currentValue)} + {money(s.currentValue, currencyOf(s))} = 0 ? "positive" : "negative"}`} > @@ -165,8 +176,11 @@ export function LivePage() {
- 持仓 {Object.keys(s.holdings).length} - {s.lastUpdated ?? "-"} + 持仓 {Object.keys(s.holdings).length} · 起 {s.startDate ?? "—"} + + {isStale(s) && 滞后} + {s.lastUpdated ?? "-"} +
))} @@ -191,6 +205,9 @@ export function LivePage() { function LiveStrategyDetail({ strategy }: { strategy: LiveStrategyState }) { const m = strategy; + const currency = currencyOf(m); + const staleDays = calendarDaysSince(m.lastUpdated); + const bench = benchmarkComparison(m); return ( <>
@@ -199,43 +216,90 @@ function LiveStrategyDetail({ strategy }: { strategy: LiveStrategyState }) {

{m.name}

{m.label}

- {m.market.toUpperCase()} · 初始 {money(m.initialCapital)} · 更新于 {m.lastUpdated ?? "-"} - {m.priceAsOf && m.priceAsOf !== m.lastUpdated ? ` · 价格 ${m.priceAsOf}` : ""} + {m.market.toUpperCase()} · {currency} · 初始 {money(m.initialCapital, currency)} · 起始 {m.startDate ?? "—"} + {" "}· 更新于 {m.lastUpdated ?? "-"} · 价格截至 {m.priceAsOf ?? "—"} + {isStale(m) && 已 {staleDays} 天未更新}

= 0 ? "positive" : "negative"}`}> - {money(m.currentValue)} + {money(m.currentValue, currency)} {m.unrealizedPnl >= 0 ? "+" : ""} - {money(m.unrealizedPnl)} ({pct(m.unrealizedPnlPct)}) + {money(m.unrealizedPnl, currency)} ({pct(m.unrealizedPnlPct)})
- - - + + + - {m.metrics && } + {m.metrics && } {m.metrics && } {m.metrics && } + {bench ? ( + <> + + 0} + negative={bench.excessReturn < 0} + /> + + ) : ( + + )}
- + - + ); } +function currencyOf(s: LiveStrategyState): StrategyCurrency { + return s.currency ?? currencyForMarket(s.market); +} + +function isStale(s: LiveStrategyState): boolean { + const days = calendarDaysSince(s.lastUpdated); + return days !== null && days > LIVE_STALE_DAYS; +} + +// Strategy vs. benchmark over the benchmark's window. The benchmark is rebased +// to initialCapital, so both returns share the same starting capital. +function benchmarkComparison(s: LiveStrategyState) { + const curve = s.benchmark?.equityCurve ?? []; + const first = curve[0]?.value; + const last = curve.at(-1); + if (!last || !first || !s.initialCapital || s.equityCurve.length === 0) return null; + const strategyEnd = s.equityCurve.find((p) => p.date === last.date) ?? s.equityCurve.at(-1)!; + const benchmarkReturn = last.value / first - 1; + const strategyReturn = strategyEnd.value / s.initialCapital - 1; + if (!Number.isFinite(benchmarkReturn) || !Number.isFinite(strategyReturn)) return null; + return { + name: s.benchmark?.name ?? "基准", + benchmarkReturn, + excessReturn: strategyReturn - benchmarkReturn, + }; +} + function SummaryCell({ label, value, @@ -259,9 +323,13 @@ function SummaryCell({ function LiveEquityChart({ equityCurve, initialCapital, + currency, + benchmark, }: { - equityCurve: { date: string; value: number }[]; + equityCurve: EquityPoint[]; initialCapital: number; + currency: StrategyCurrency; + benchmark?: { name: string; equityCurve: EquityPoint[] }; }) { const containerRef = useRef(null); const chartRef = useRef(null); @@ -283,9 +351,24 @@ function LiveEquityChart({ crosshair: { mode: CrosshairMode.Normal }, timeScale: { borderColor: "#1d2530", timeVisible: false }, rightPriceScale: { borderColor: "#1d2530" }, + localization: { priceFormatter: (price: number) => formatMoney(price, currency) }, }); chartRef.current = chart; + if (benchmark && benchmark.equityCurve.length > 0) { + const benchSeries = chart.addSeries(LineSeries, { + color: "#7c8694", + lineWidth: 1, + lineStyle: 2, + title: benchmark.name, + crosshairMarkerVisible: false, + priceLineVisible: false, + }); + benchSeries.setData( + benchmark.equityCurve.map((p) => ({ time: p.date as Time, value: p.value })), + ); + } + const eqSeries = chart.addSeries(LineSeries, { color: "#4db6ff", lineWidth: 2, @@ -321,7 +404,7 @@ function LiveEquityChart({ chart.remove(); chartRef.current = null; }; - }, [equityCurve, initialCapital]); + }, [equityCurve, initialCapital, currency, benchmark]); return (
@@ -334,6 +417,7 @@ function LiveEquityChart({ {equityCurve.length > 0 ? ` · ${equityCurve[0].date} → ${equityCurve[equityCurve.length - 1].date}` : ""} + {benchmark ? ` · 虚线为 ${benchmark.name}(同期,按初始资金归一)` : " · 基准数据暂缺"}

@@ -449,10 +533,12 @@ function HoldingsPanel({ holdings, cash, currentValue, + currency, }: { holdings: LiveStrategyState["holdingsDetail"]; cash: number; currentValue: number; + currency: StrategyCurrency; }) { const [{ page, pageSize }, setPage, { totalPages, sliceStart, sliceEnd }] = usePagination(holdings.length, [holdings]); @@ -488,8 +574,13 @@ function HoldingsPanel({ {h.ticker} {h.shares.toLocaleString(undefined, { maximumFractionDigits: 2 })} - {h.lastPrice !== null ? `$${h.lastPrice.toFixed(2)}` : "-"} - {h.marketValue !== null ? money(h.marketValue) : "-"} + + {money(h.lastPrice, currency, 2)} + {h.priceStale && ( + 价格未更新 + )} + + {money(h.marketValue, currency)} {pct(weight)} ); @@ -502,7 +593,7 @@ function HoldingsPanel({ - - - {money(cash)} + {money(cash, currency)} {pct(cashWeight)} @@ -523,7 +614,7 @@ function HoldingsPanel({ ); } -function TradeLogPanel({ log }: { log: TradeLogEntry[] }) { +function TradeLogPanel({ log, currency }: { log: TradeLogEntry[]; currency: StrategyCurrency }) { // Render newest-first; the trader logs trades chronologically. const reversed = useMemo(() => [...log].reverse(), [log]); const [{ page, pageSize }, setPage, { totalPages, sliceStart, sliceEnd }] = @@ -568,9 +659,9 @@ function TradeLogPanel({ log }: { log: TradeLogEntry[] }) { {t.ticker} {t.shares.toLocaleString(undefined, { maximumFractionDigits: 2 })} - ${t.price.toFixed(2)} - {money(t.value)} - {money(t.commission)} + {money(t.price, currency, 2)} + {money(t.value, currency)} + {money(t.commission, currency, 2)} ))} @@ -591,16 +682,5 @@ function TradeLogPanel({ log }: { log: TradeLogEntry[] }) { ); } -function money(v: number): string { - if (typeof v !== "number" || !Number.isFinite(v)) return "-"; - return v.toLocaleString(undefined, { - maximumFractionDigits: 0, - style: "currency", - currency: "USD", - }); -} - -function pct(v: number): string { - if (typeof v !== "number" || !Number.isFinite(v)) return "-"; - return `${(v * 100).toFixed(1)}%`; -} +const money = formatMoney; +const pct = formatPct; diff --git a/src/client/StrategyPage.tsx b/src/client/StrategyPage.tsx index 9c68046..1961505 100644 --- a/src/client/StrategyPage.tsx +++ b/src/client/StrategyPage.tsx @@ -10,14 +10,19 @@ import { type Time, } from "lightweight-charts"; import { ArrowDown, ArrowUp, BarChart3, Trophy, TrendingDown, TrendingUp } from "lucide-react"; -import type { - BacktestResult, - PrecomputedIndex, - PrecomputedScenarioInfo, - StrategyInfo, - StrategyMarket, - StrategyMetrics, - StrategyResult, +import { + calendarDaysSince, + currencyForMarket, + formatMoney, + formatNumber, + formatPct, + type BacktestResult, + type PrecomputedIndex, + type PrecomputedScenarioInfo, + type StrategyInfo, + type StrategyMarket, + type StrategyMetrics, + type StrategyResult, } from "../shared/strategyTypes"; const STRATEGY_COLORS = [ @@ -26,6 +31,8 @@ const STRATEGY_COLORS = [ ]; const MAX_CHART_SELECTION = 10; +// Precomputed data older than this (calendar days) is flagged as stale. +const STALE_DATA_DAYS = 7; type RankMetric = | "annualizedReturn" @@ -43,7 +50,8 @@ const RANK_METRICS: Array<{ key: RankMetric; label: string; better: "high" | "lo { key: "totalReturn", label: "总收益", better: "high" }, { key: "calmarRatio", label: "Calmar", better: "high" }, { key: "sortinoRatio", label: "Sortino", better: "high" }, - { key: "maxDrawdown", label: "MaxDD", better: "low" }, + // maxDrawdown is negative: closer to zero (higher) is better. + { key: "maxDrawdown", label: "MaxDD", better: "high" }, { key: "winRate", label: "日胜率", better: "high" }, { key: "rebalanceWinRate", label: "再平衡胜率", better: "high" }, ]; @@ -92,9 +100,11 @@ export function StrategyPage() { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + // Prefer the scenario the server says it served over the requested one. + const servedScenarioId = precomputed?.scenarioId ?? scenarioId; const currentScenario = useMemo( - () => scenarioList.find((s) => s.id === scenarioId) ?? null, - [scenarioList, scenarioId], + () => scenarioList.find((s) => s.id === servedScenarioId) ?? null, + [scenarioList, servedScenarioId], ); useEffect(() => { @@ -105,7 +115,7 @@ export function StrategyPage() { api(`/api/strategies/precomputed?market=${market}&scenario=${encodeURIComponent(scenarioId)}`) .then((res) => { setPrecomputed(res); - const ranked = rankBy(res.strategies, rankMetric); + const ranked = rankBy(res.strategies, rankMetric).filter((s) => !s.ruined); const top = new Set(ranked.slice(0, 5).map((s) => s.name)); setSelected(top); if (ranked.length > 0) setFocusStrategy(ranked[0].name); @@ -175,7 +185,7 @@ export function StrategyPage() { function selectTopN(metric: RankMetric, n: number) { if (!precomputed) return; - const ranked = rankBy(filteredStrategies, metric); + const ranked = rankBy(filteredStrategies, metric).filter((s) => !s.ruined); const top = new Set(ranked.slice(0, n).map((s) => s.name)); setSelected(top); if (ranked.length > 0) setFocusStrategy(ranked[0].name); @@ -188,11 +198,18 @@ export function StrategyPage() { const meta = precomputed?.meta; const computedAt = precomputed?.computedAt ? new Date(precomputed.computedAt) : null; + const dataAgeDays = calendarDaysSince(meta?.endDate); + const dataStale = dataAgeDays !== null && dataAgeDays > STALE_DATA_DAYS; return (
{error &&
{error}
} {precomputedError &&
{precomputedError}
} + {dataStale && ( +
+ 预计算数据截至 {meta?.endDate},已滞后 {dataAgeDays} 天。在 quant 项目重新运行 precompute_backtest.py 以更新。 +
+ )}
{/* Left: Strategy selector */} @@ -220,10 +237,10 @@ export function StrategyPage() { className={`scenarioOption ${scenarioId === sc.id ? "active" : ""}`} onClick={() => setScenarioId(sc.id)} type="button" - title={sc.description ?? sc.label} + title={scenarioDescription(sc, market)} > - {sc.label} - {summarizeScenario(sc)} + {scenarioLabel(sc, market)} + {summarizeScenario(sc, market)} ))}
@@ -231,8 +248,9 @@ export function StrategyPage() { )} {meta && (

- {meta.years} 年 · 离线预计算 - {computedAt ? ` · ${computedAt.toLocaleDateString()}` : ""} + {meta.years} 年 · 离线预计算 · 数据截至 {meta.endDate ?? "—"} + {computedAt ? ` · 计算于 ${computedAt.toLocaleDateString()}` : ""} + {dataStale && 数据滞后 {dataAgeDays} 天}

)}
@@ -279,7 +297,7 @@ export function StrategyPage() { key={s.name} title={s.name} > - #{i + 1} + #{i + 1} - {displayStrategyName(s)} - = 0 ? "positive" : "negative"}`}> + + {displayStrategyName(s)} + {s.ruined && } + + {pct(s.metrics.annualizedReturn)} - {s.metrics.sharpeRatio.toFixed(2)} + {num(s.metrics.sharpeRatio)} ); @@ -321,6 +342,7 @@ export function StrategyPage() { onChangeMetric={setRankMetric} categoryByName={categoryByName} rankIndex={rankIndex} + timeWeighted={hasInjections(chartResult)} />
@@ -354,6 +376,7 @@ function RankingLeaderboard({ onChangeMetric, categoryByName, rankIndex, + timeWeighted, }: { strategies: StrategyResult[]; selected: Set; @@ -363,6 +386,7 @@ function RankingLeaderboard({ onChangeMetric: (metric: RankMetric) => void; categoryByName: Map; rankIndex: Map; + timeWeighted: boolean; }) { return (
@@ -370,7 +394,10 @@ function RankingLeaderboard({

Leaderboard

策略排名

-

点击行加入/移除对比;列头切换排序指标

+

+ 点击行加入/移除对比;列头切换排序指标 + {timeWeighted ? ";收益/风险指标为时间加权 (TWR),不含注资影响" : ""} +

{strategies.length} 个策略
@@ -411,10 +438,11 @@ function RankingLeaderboard({ }} > - {rank} + {rank} {displayStrategyName(s)} + {s.ruined && } {s.name} @@ -422,18 +450,18 @@ function RankingLeaderboard({ {categoryLabel(categoryByName.get(s.name) ?? "other")} - {s.metrics.sharpeRatio.toFixed(2)} - = 0 ? "positive" : "negative"}> + {num(s.metrics.sharpeRatio)} + {pct(s.metrics.annualizedReturn)} - = 0 ? "positive" : "negative"}> + {pct(s.metrics.totalReturn)} - {s.metrics.calmarRatio.toFixed(2)} - {s.metrics.sortinoRatio.toFixed(2)} + {num(s.metrics.calmarRatio)} + {num(s.metrics.sortinoRatio)} {pct(s.metrics.maxDrawdown)} {pct(s.metrics.winRate)} - {pctOrDash(s.metrics.rebalanceWinRate)} + {pct(s.metrics.rebalanceWinRate)} ); })} @@ -462,6 +490,7 @@ function EquityCurveChart({ const containerRef = useRef(null); const chartRef = useRef(null); const seriesMapRef = useRef>>(new Map()); + const currency = currencyForMarket(result.meta.market); useEffect(() => { if (!containerRef.current) return; @@ -501,6 +530,9 @@ function EquityCurveChart({ const chart = chartRef.current; if (!chart) return; + chart.applyOptions({ + localization: { priceFormatter: (price: number) => formatMoney(price, currency) }, + }); for (const series of seriesMapRef.current.values()) { chart.removeSeries(series); } @@ -539,7 +571,7 @@ function EquityCurveChart({ } chart.timeScale().fitContent(); - }, [result, focusStrategy]); + }, [result, focusStrategy, currency]); return (
@@ -553,11 +585,11 @@ function EquityCurveChart({ · {result.meta.years} 年 · - {money(result.meta.initialCapital)} 初始 + {formatMoney(result.meta.initialCapital, currency)} 初始 {scenario?.injectionSchedule && ( <> · - {summarizeScenario(scenario)} + {summarizeScenario(scenario, result.meta.market)} )}
@@ -603,36 +635,67 @@ function MetricsPanel({ onFocus: (name: string) => void; }) { const focused = result.strategies.find((s) => s.name === focusStrategy) ?? result.strategies[0]; + const currency = currencyForMarket(result.meta.market); + // With cash injections the metrics are time-weighted; the money view comes + // from capitalFlows (never recomputed from the money curve here). + const timeWeighted = hasInjections(result); + const twr = timeWeighted ? " (TWR)" : ""; + const showFlows = timeWeighted && result.strategies.some((s) => s.capitalFlows); + const flows = focused?.capitalFlows; return (

核心指标

- {focused && {displayStrategyName(focused)}} + {focused && ( + + {displayStrategyName(focused)} + {focused.ruined && } + + )}
{focused ? ( <> + {timeWeighted && flows && ( + <> +

资金视角(含注资)

+
+ + + 0} + negative={flows.profit < 0} + /> + +
+ + )} + {timeWeighted &&

收益/风险指标为时间加权 (TWR),剔除注资影响

}
- 0} /> - 0} /> + + - 1} /> - 1} /> + + - 0.5} /> - 0.5} /> + + 0.5 - } + value={pct(focused.metrics.rebalanceWinRate)} + positive={isAbove(focused.metrics.rebalanceWinRate, 0.5)} />
- 终值 {money(focused.equityCurve.at(-1)?.value)} + 终值 {formatMoney(flows?.finalValue ?? focused.equityCurve.at(-1)?.value, currency)} 最大回撤 {pct(focused.metrics.maxDrawdown)} - 年化 {pct(focused.metrics.annualizedReturn)} + 年化{twr} {pct(focused.metrics.annualizedReturn)} 交易 {focused.trades.length.toLocaleString()} 天
@@ -646,7 +709,15 @@ function MetricsPanel({ 策略 - 年化 + {showFlows && ( + <> + 投入本金 + 期末市值 + 盈亏 + 本金收益率 + + )} + 年化{twr} Sharpe MaxDD 日胜率 @@ -658,13 +729,22 @@ function MetricsPanel({ onFocus(s.name)}> {displayStrategyName(s)} + {s.ruined && } {s.name} - 0 ? "positive" : "negative"}>{pct(s.metrics.annualizedReturn)} - {s.metrics.sharpeRatio.toFixed(2)} + {showFlows && ( + <> + {formatMoney(s.capitalFlows?.invested, currency)} + {formatMoney(s.capitalFlows?.finalValue, currency)} + {formatMoney(s.capitalFlows?.profit, currency)} + {pct(s.capitalFlows?.returnOnInvested)} + + )} + {pct(s.metrics.annualizedReturn)} + {num(s.metrics.sharpeRatio)} {pct(s.metrics.maxDrawdown)} {pct(s.metrics.winRate)} - {pctOrDash(s.metrics.rebalanceWinRate)} + {pct(s.metrics.rebalanceWinRate)} ))} @@ -749,7 +829,7 @@ function ReturnHeatmap({ result, focusStrategy }: { result: BacktestResult; focu return (
-

收益热力图

+

收益热力图{hasInjections(result) ? " (TWR)" : ""}

{displayStrategyName(focused)}
@@ -767,15 +847,15 @@ function ReturnHeatmap({ result, focusStrategy }: { result: BacktestResult; focu {year} {months.map((month) => { const key = `${year}-${month}`; - const val = monthlyReturns[key]; + const val = finiteOrUndefined(monthlyReturns[key]); return ( {val !== undefined ? `${(val * 100).toFixed(1)}` : ""} ); })} - = 0 ? "positive" : "negative"}`}> - {yearlyReturns[year] !== undefined ? pct(yearlyReturns[year]) : "-"} + + {pct(yearlyReturns[year])} ))} @@ -797,18 +877,52 @@ function rankBy(strategies: StrategyResult[], metric: RankMetric): StrategyResul const v = s.metrics[metric as keyof StrategyMetrics]; return typeof v === "number" && Number.isFinite(v) ? v : (better === "high" ? -Infinity : Infinity); }; - return [...strategies].sort((a, b) => dir * (get(a) - get(b))); + // Ruined strategies always rank last so they never take a "best" slot. + return [...strategies].sort( + (a, b) => Number(!!a.ruined) - Number(!!b.ruined) || dir * (get(a) - get(b)), + ); } -function summarizeScenario(sc: PrecomputedScenarioInfo): string { - if (!sc.injectionSchedule) return `${money(sc.capital)} 一次性`; +function hasInjections(result: BacktestResult): boolean { + return !!result.meta.injectionSchedule; +} + +// Scenario capital in the given market's currency. Older files only carry the +// base (US) `capital`, which then applies to every market. +function scenarioCapital(sc: PrecomputedScenarioInfo, market: StrategyMarket): number { + return sc.capitalByMarket?.[market] ?? sc.capital; +} + +function summarizeScenario(sc: PrecomputedScenarioInfo, market: StrategyMarket): string { + const currency = currencyForMarket(market); + const capital = scenarioCapital(sc, market); + const fmt = (v: number) => formatMoney(v, currency); + if (!sc.injectionSchedule) return `${fmt(capital)} 一次性`; const inj = sc.injectionSchedule; + // The index lists the injection amount in base capital; scale to this market. + const amount = sc.capital ? (inj.amount * capital) / sc.capital : inj.amount; if (inj.frequency === "yearly_months" && inj.months?.length) { const monthLabel = inj.months.join("/"); - return `${money(sc.capital)} 起 · 每年 ${monthLabel} 月注入 ${money(inj.amount)}`; + return `${fmt(capital)} 起 · 每年 ${monthLabel} 月注入 ${fmt(amount)}`; } const freqLabel = inj.frequency === "monthly" ? "每月" : inj.frequency === "quarterly" ? "每季" : "每年"; - return `${money(sc.capital)} 起 · ${freqLabel}注入 ${money(inj.amount)}`; + return `${fmt(capital)} 起 · ${freqLabel}注入 ${fmt(amount)}`; +} + +// Older precomputed files baked USD amounts into scenario copy; don't show +// that copy for non-US markets. +function hasUsdCopy(text: string | undefined): boolean { + return !!text && /\$|美元|USD/.test(text); +} + +function scenarioLabel(sc: PrecomputedScenarioInfo, market: StrategyMarket): string { + if (market === "us" || !hasUsdCopy(sc.label)) return sc.label; + return sc.injectionSchedule ? "起始资金 + 定期追加" : "起始资金一次性投入"; +} + +function scenarioDescription(sc: PrecomputedScenarioInfo, market: StrategyMarket): string { + if (sc.description && (market === "us" || !hasUsdCopy(sc.description))) return sc.description; + return summarizeScenario(sc, market); } function categoryLabel(cat: string): string { @@ -820,18 +934,38 @@ function categoryLabel(cat: string): string { return cat; } -function pct(val: number): string { - return `${(val * 100).toFixed(1)}%`; +const pct = formatPct; +const num = formatNumber; + +function finiteOrUndefined(val: number | null | undefined): number | undefined { + return typeof val === "number" && Number.isFinite(val) ? val : undefined; } -function pctOrDash(val: number | null | undefined): string { - return typeof val === "number" && Number.isFinite(val) ? pct(val) : "-"; +function isPositive(val: number | null | undefined): boolean { + return (finiteOrUndefined(val) ?? 0) > 0; } -function money(value: number | undefined): string { - return typeof value === "number" && Number.isFinite(value) - ? value.toLocaleString(undefined, { maximumFractionDigits: 0, style: "currency", currency: "USD" }) - : "-"; +function isNegative(val: number | null | undefined): boolean { + return (finiteOrUndefined(val) ?? 0) < 0; +} + +function isAbove(val: number | null | undefined, threshold: number): boolean { + const v = finiteOrUndefined(val); + return v !== undefined && v > threshold; +} + +function signClass(val: number | null | undefined): string { + const v = finiteOrUndefined(val); + if (v === undefined) return ""; + return v >= 0 ? "positive" : "negative"; +} + +function RuinedBadge() { + return ( + + 爆仓/不可行 + + ); } function displayStrategyName(strategy: Pick): string { diff --git a/src/client/styles.css b/src/client/styles.css index f0abd65..9a1b36c 100644 --- a/src/client/styles.css +++ b/src/client/styles.css @@ -150,6 +150,37 @@ h2 { border-radius: 6px; } +.alert.warning { + border-color: #6b5320; + background: #21180c; + color: #e6c071; +} + +/* Inline pills: stale data (amber) and ruined backtest (red). */ +.staleBadge, +.ruinedBadge { + display: inline-block; + margin: 0 6px; + padding: 1px 6px; + border-radius: 999px; + font-size: 10px; + font-weight: 800; + white-space: nowrap; + vertical-align: middle; +} + +.staleBadge { + color: #e6c071; + background: #21180c; + border: 1px solid #6b5320; +} + +.ruinedBadge { + color: #ff99a1; + background: #2a1117; + border: 1px solid #7a2b34; +} + .statusBar { display: flex; flex-wrap: wrap; diff --git a/src/server/index.ts b/src/server/index.ts index 6cc60ee..b83701e 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -172,6 +172,7 @@ function resolveScenarios(raw: { scenarios?: Record; markets?: label: string; description?: string; capital: number; + capitalByMarket?: Record; injectionSchedule: unknown; markets: Record; }>; @@ -191,6 +192,7 @@ function resolveScenarios(raw: { scenarios?: Record; markets?: label: string; description?: string; capital: number; + capitalByMarket?: Record; injectionSchedule: unknown; markets: Record; }>; @@ -207,6 +209,7 @@ app.get("/api/strategies/precomputed/index", (_request, response, next) => { label: sc.label, description: sc.description, capital: sc.capital, + capitalByMarket: sc.capitalByMarket, injectionSchedule: sc.injectionSchedule ?? null, availableMarkets: Object.keys(sc.markets ?? {}), })); @@ -223,9 +226,12 @@ app.get("/api/strategies/precomputed", (_request, response, next) => { const raw = loadPrecomputedFile(); const scenarios = resolveScenarios(raw); const scenarioIds = Object.keys(scenarios); - const scenarioId = requestedScenario && scenarios[requestedScenario] - ? requestedScenario - : scenarioIds[0]; + // An explicit but unknown scenario is a client error; silently serving + // another scenario would mislabel the results. + if (requestedScenario && !Object.hasOwn(scenarios, requestedScenario)) { + throw new Error(`Unknown pre-computed scenario: ${requestedScenario}`); + } + const scenarioId = requestedScenario ?? scenarioIds[0]; const scenario = scenarios[scenarioId]; if (!scenario) { throw new Error("No pre-computed scenarios available."); diff --git a/src/shared/strategyTypes.ts b/src/shared/strategyTypes.ts index 455625a..acd3ba6 100644 --- a/src/shared/strategyTypes.ts +++ b/src/shared/strategyTypes.ts @@ -1,4 +1,5 @@ export type StrategyMarket = "us" | "cn"; +export type StrategyCurrency = "USD" | "CNY"; export type StrategyCategory = "stock_picker" | "tactical_allocation" | "factor_combo" | "other"; export interface StrategyParam { @@ -29,6 +30,9 @@ export interface PrecomputedScenarioInfo { label: string; description?: string; capital: number; + // Per-market starting capital in that market's currency. Absent in older + // precomputed files, where `capital` applies to every market. + capitalByMarket?: Partial>; injectionSchedule: InjectionSchedule | null; availableMarkets: StrategyMarket[]; } @@ -73,26 +77,42 @@ export interface InjectionEvent { amount: number; } +// Backtest metrics are time-weighted (computed on the no-injection curve). +// The bridge serializes NaN as null, so every field may be null. export interface StrategyMetrics { - totalReturn: number; - annualizedReturn: number; - annualizedVolatility: number; - sharpeRatio: number; - sortinoRatio: number; - maxDrawdown: number; - calmarRatio: number; - winRate: number; + totalReturn: number | null; + annualizedReturn: number | null; + annualizedVolatility: number | null; + sharpeRatio: number | null; + sortinoRatio: number | null; + // Negative fraction; closer to zero is better. + maxDrawdown: number | null; + calmarRatio: number | null; + winRate: number | null; // Fraction of inter-rebalance periods with positive return. Null for // strategies with fewer than two rebalances in the window (buy-and-hold). rebalanceWinRate: number | null; } +// Money view of a run with cash injections: what was put in vs. what it is worth. +export interface CapitalFlows { + invested: number; + finalValue: number; + profit: number; + returnOnInvested: number | null; +} + export interface StrategyResult { name: string; label: string; + // Money curve (includes injections when a schedule exists). equityCurve: EquityPoint[]; + // Same strategy without injections; metrics are computed on this curve. baseEquityCurve: EquityPoint[] | null; metrics: StrategyMetrics; + capitalFlows?: CapitalFlows; + // Equity hit zero at some point; the result is not investable. + ruined?: boolean; drawdowns: DrawdownPeriod[]; trades: TradeEvent[]; injections: InjectionEvent[]; @@ -113,6 +133,8 @@ export interface BacktestMeta { export interface BacktestResult { id: string; + // Set by the precomputed endpoint to the scenario actually served. + scenarioId?: string; meta: BacktestMeta; strategies: StrategyResult[]; benchmark: { name: string; equityCurve: EquityPoint[] }; @@ -124,6 +146,8 @@ export interface HoldingDetail { shares: number; lastPrice: number | null; marketValue: number | null; + // Latest panel price missing; lastPrice is the last recorded trade price. + priceStale?: boolean; } export interface TradeLogEntry { @@ -141,7 +165,10 @@ export interface LiveStrategyState { label: string; category: StrategyCategory | string; market: StrategyMarket; + currency?: StrategyCurrency; initialCapital: number; + // First live session; strategies may start on different dates. + startDate?: string | null; currentValue: number; cash: number; marketValue: number; @@ -150,7 +177,11 @@ export interface LiveStrategyState { holdings: Record; holdingsDetail: HoldingDetail[]; priceAsOf: string | null; + // Starts with an anchor point at initialCapital one day before startDate. equityCurve: EquityPoint[]; + // Market benchmark rebased to initialCapital over the strategy's sessions. + // Empty when the price panel lacks the benchmark. + benchmark?: { name: string; equityCurve: EquityPoint[] }; metrics: StrategyMetrics | null; drawdowns: DrawdownPeriod[]; trades: TradeEvent[]; @@ -159,3 +190,43 @@ export interface LiveStrategyState { yearlyReturns: Record; lastUpdated: string | null; } + +// --------------------------------------------------------------------------- +// Display helpers shared by the strategy pages +// --------------------------------------------------------------------------- + +const MARKET_CURRENCY: Record = { us: "USD", cn: "CNY" }; + +export function currencyForMarket(market: string | undefined): StrategyCurrency { + return MARKET_CURRENCY[market as StrategyMarket] ?? "USD"; +} + +export function formatMoney(value: number | null | undefined, currency: StrategyCurrency, fractionDigits = 0): string { + if (typeof value !== "number" || !Number.isFinite(value)) return "—"; + return value.toLocaleString(undefined, { + style: "currency", + currency, + currencyDisplay: "narrowSymbol", + minimumFractionDigits: fractionDigits, + maximumFractionDigits: fractionDigits, + }); +} + +export function formatPct(value: number | null | undefined, fractionDigits = 1): string { + if (typeof value !== "number" || !Number.isFinite(value)) return "—"; + return `${(value * 100).toFixed(fractionDigits)}%`; +} + +export function formatNumber(value: number | null | undefined, fractionDigits = 2): string { + if (typeof value !== "number" || !Number.isFinite(value)) return "—"; + return value.toFixed(fractionDigits); +} + +// Whole calendar days from a YYYY-MM-DD date to today (local time); null if unparseable. +export function calendarDaysSince(date: string | null | undefined, now: Date = new Date()): number | null { + const match = typeof date === "string" ? /^(\d{4})-(\d{2})-(\d{2})/.exec(date) : null; + if (!match) return null; + const then = new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3])); + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + return Math.round((today.getTime() - then.getTime()) / 86_400_000); +}