fix(strategy-ui): correct rankings, currency, injection view and staleness

- MaxDD ranks shallower drawdowns first (values are negative); ruined
  results are badged and never ranked as best.
- Metrics render '—' for null/non-finite values instead of crashing.
- Amounts use the market's currency (CNY for CN) instead of hard-coded USD.
- Injection scenarios show invested / final value / profit / return on
  invested from capitalFlows and label return metrics as time-weighted.
- Precomputed results show their data end date and warn when older than 7
  days; live strategies warn when not updated for 4 days, show start date,
  price as-of, stale holdings, and same-window benchmark with excess return.
- Unknown precomputed scenario returns 400 instead of silently serving the
  first scenario; the client uses the scenarioId it receives.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-25 01:22:07 +08:00
parent 8946267d80
commit 5005cc28ec
5 changed files with 441 additions and 119 deletions

View File

@@ -9,12 +9,23 @@ import {
type Time, type Time,
} from "lightweight-charts"; } from "lightweight-charts";
import { ArrowDownRight, ArrowUpRight, RefreshCw, Wallet } from "lucide-react"; import { ArrowDownRight, ArrowUpRight, RefreshCw, Wallet } from "lucide-react";
import type { import {
LiveStrategyState, calendarDaysSince,
StrategyMarket, currencyForMarket,
TradeLogEntry, formatMoney,
formatNumber,
formatPct,
type EquityPoint,
type LiveStrategyState,
type StrategyCurrency,
type StrategyMarket,
type TradeLogEntry,
} from "../shared/strategyTypes"; } 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<T>(path: string, init?: RequestInit): Promise<T> { async function api<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(path, { const response = await fetch(path, {
...init, ...init,
@@ -123,7 +134,7 @@ export function LivePage() {
className={`ghostButton ${loading ? "loading" : ""}`} className={`ghostButton ${loading ? "loading" : ""}`}
onClick={() => refresh()} onClick={() => refresh()}
disabled={loading} disabled={loading}
title="重新拉取最新行情和持仓" title="重新读取 live 状态(不会拉取新行情)"
type="button" type="button"
> >
<RefreshCw size={14} /> <RefreshCw size={14} />
@@ -156,7 +167,7 @@ export function LivePage() {
<span className="liveListName">{s.name}</span> <span className="liveListName">{s.name}</span>
</div> </div>
<div className="liveListBody"> <div className="liveListBody">
<span className="liveListValue">{money(s.currentValue)}</span> <span className="liveListValue">{money(s.currentValue, currencyOf(s))}</span>
<span <span
className={`liveListPnl ${s.unrealizedPnl >= 0 ? "positive" : "negative"}`} className={`liveListPnl ${s.unrealizedPnl >= 0 ? "positive" : "negative"}`}
> >
@@ -165,8 +176,11 @@ export function LivePage() {
</span> </span>
</div> </div>
<div className="liveListMeta"> <div className="liveListMeta">
<span>持仓 {Object.keys(s.holdings).length}</span> <span>持仓 {Object.keys(s.holdings).length} · 起 {s.startDate ?? "—"}</span>
<span>{s.lastUpdated ?? "-"}</span> <span>
{isStale(s) && <span className="staleBadge">滞后</span>}
{s.lastUpdated ?? "-"}
</span>
</div> </div>
</button> </button>
))} ))}
@@ -191,6 +205,9 @@ export function LivePage() {
function LiveStrategyDetail({ strategy }: { strategy: LiveStrategyState }) { function LiveStrategyDetail({ strategy }: { strategy: LiveStrategyState }) {
const m = strategy; const m = strategy;
const currency = currencyOf(m);
const staleDays = calendarDaysSince(m.lastUpdated);
const bench = benchmarkComparison(m);
return ( return (
<> <>
<div className="panel liveSummary"> <div className="panel liveSummary">
@@ -199,43 +216,90 @@ function LiveStrategyDetail({ strategy }: { strategy: LiveStrategyState }) {
<p className="eyebrow">{m.name}</p> <p className="eyebrow">{m.name}</p>
<h2>{m.label}</h2> <h2>{m.label}</h2>
<p className="hint"> <p className="hint">
{m.market.toUpperCase()} · 初始 {money(m.initialCapital)} · 更新于 {m.lastUpdated ?? "-"} {m.market.toUpperCase()} · {currency} · 初始 {money(m.initialCapital, currency)} · 起始 {m.startDate ?? "—"}
{m.priceAsOf && m.priceAsOf !== m.lastUpdated ? ` · 价格 ${m.priceAsOf}` : ""} {" "}· 更新于 {m.lastUpdated ?? "-"} · 价格截至 {m.priceAsOf ?? "—"}
{isStale(m) && <span className="staleBadge">已 {staleDays} 天未更新</span>}
</p> </p>
</div> </div>
<div className={`liveTotalPnl ${m.unrealizedPnl >= 0 ? "positive" : "negative"}`}> <div className={`liveTotalPnl ${m.unrealizedPnl >= 0 ? "positive" : "negative"}`}>
<strong>{money(m.currentValue)}</strong> <strong>{money(m.currentValue, currency)}</strong>
<span> <span>
{m.unrealizedPnl >= 0 ? "+" : ""} {m.unrealizedPnl >= 0 ? "+" : ""}
{money(m.unrealizedPnl)} ({pct(m.unrealizedPnlPct)}) {money(m.unrealizedPnl, currency)} ({pct(m.unrealizedPnlPct)})
</span> </span>
</div> </div>
</div> </div>
<div className="liveSummaryGrid"> <div className="liveSummaryGrid">
<SummaryCell label="现金" value={money(m.cash)} /> <SummaryCell label="现金" value={money(m.cash, currency)} />
<SummaryCell label="持仓市值" value={money(m.marketValue)} /> <SummaryCell label="持仓市值" value={money(m.marketValue, currency)} />
<SummaryCell label="总资产" value={money(m.currentValue)} /> <SummaryCell label="总资产" value={money(m.currentValue, currency)} />
<SummaryCell label="持仓数" value={`${Object.keys(m.holdings).length}`} /> <SummaryCell label="持仓数" value={`${Object.keys(m.holdings).length}`} />
<SummaryCell label="累计交易" value={`${m.tradeLog.length}`} /> <SummaryCell label="累计交易" value={`${m.tradeLog.length}`} />
{m.metrics && <SummaryCell label="Sharpe" value={m.metrics.sharpeRatio.toFixed(2)} />} {m.metrics && <SummaryCell label="Sharpe" value={formatNumber(m.metrics.sharpeRatio)} />}
{m.metrics && <SummaryCell label="最大回撤" value={pct(m.metrics.maxDrawdown)} negative />} {m.metrics && <SummaryCell label="最大回撤" value={pct(m.metrics.maxDrawdown)} negative />}
{m.metrics && <SummaryCell label="日胜率" value={pct(m.metrics.winRate)} />} {m.metrics && <SummaryCell label="日胜率" value={pct(m.metrics.winRate)} />}
{bench ? (
<>
<SummaryCell label={`同期基准 ${bench.name}`} value={pct(bench.benchmarkReturn)} />
<SummaryCell
label="超额收益 (同期)"
value={pct(bench.excessReturn)}
positive={bench.excessReturn > 0}
negative={bench.excessReturn < 0}
/>
</>
) : (
<SummaryCell label="基准数据暂缺" value="—" />
)}
</div> </div>
</div> </div>
<LiveEquityChart equityCurve={m.equityCurve} initialCapital={m.initialCapital} /> <LiveEquityChart
equityCurve={m.equityCurve}
initialCapital={m.initialCapital}
currency={currency}
benchmark={bench ? m.benchmark : undefined}
/>
<HoldingsPanel <HoldingsPanel
holdings={m.holdingsDetail} holdings={m.holdingsDetail}
cash={m.cash} cash={m.cash}
currentValue={m.currentValue} currentValue={m.currentValue}
currency={currency}
/> />
<TradeLogPanel log={m.tradeLog} /> <TradeLogPanel log={m.tradeLog} currency={currency} />
</> </>
); );
} }
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({ function SummaryCell({
label, label,
value, value,
@@ -259,9 +323,13 @@ function SummaryCell({
function LiveEquityChart({ function LiveEquityChart({
equityCurve, equityCurve,
initialCapital, initialCapital,
currency,
benchmark,
}: { }: {
equityCurve: { date: string; value: number }[]; equityCurve: EquityPoint[];
initialCapital: number; initialCapital: number;
currency: StrategyCurrency;
benchmark?: { name: string; equityCurve: EquityPoint[] };
}) { }) {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const chartRef = useRef<IChartApi | null>(null); const chartRef = useRef<IChartApi | null>(null);
@@ -283,9 +351,24 @@ function LiveEquityChart({
crosshair: { mode: CrosshairMode.Normal }, crosshair: { mode: CrosshairMode.Normal },
timeScale: { borderColor: "#1d2530", timeVisible: false }, timeScale: { borderColor: "#1d2530", timeVisible: false },
rightPriceScale: { borderColor: "#1d2530" }, rightPriceScale: { borderColor: "#1d2530" },
localization: { priceFormatter: (price: number) => formatMoney(price, currency) },
}); });
chartRef.current = chart; 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, { const eqSeries = chart.addSeries(LineSeries, {
color: "#4db6ff", color: "#4db6ff",
lineWidth: 2, lineWidth: 2,
@@ -321,7 +404,7 @@ function LiveEquityChart({
chart.remove(); chart.remove();
chartRef.current = null; chartRef.current = null;
}; };
}, [equityCurve, initialCapital]); }, [equityCurve, initialCapital, currency, benchmark]);
return ( return (
<div className="panel chartPanel"> <div className="panel chartPanel">
@@ -334,6 +417,7 @@ function LiveEquityChart({
{equityCurve.length > 0 {equityCurve.length > 0
? ` · ${equityCurve[0].date} → ${equityCurve[equityCurve.length - 1].date}` ? ` · ${equityCurve[0].date} → ${equityCurve[equityCurve.length - 1].date}`
: ""} : ""}
{benchmark ? ` · 虚线为 ${benchmark.name}(同期,按初始资金归一)` : " · 基准数据暂缺"}
</p> </p>
</div> </div>
</div> </div>
@@ -449,10 +533,12 @@ function HoldingsPanel({
holdings, holdings,
cash, cash,
currentValue, currentValue,
currency,
}: { }: {
holdings: LiveStrategyState["holdingsDetail"]; holdings: LiveStrategyState["holdingsDetail"];
cash: number; cash: number;
currentValue: number; currentValue: number;
currency: StrategyCurrency;
}) { }) {
const [{ page, pageSize }, setPage, { totalPages, sliceStart, sliceEnd }] = const [{ page, pageSize }, setPage, { totalPages, sliceStart, sliceEnd }] =
usePagination(holdings.length, [holdings]); usePagination(holdings.length, [holdings]);
@@ -488,8 +574,13 @@ function HoldingsPanel({
<strong>{h.ticker}</strong> <strong>{h.ticker}</strong>
</td> </td>
<td>{h.shares.toLocaleString(undefined, { maximumFractionDigits: 2 })}</td> <td>{h.shares.toLocaleString(undefined, { maximumFractionDigits: 2 })}</td>
<td>{h.lastPrice !== null ? `$${h.lastPrice.toFixed(2)}` : "-"}</td> <td>
<td>{h.marketValue !== null ? money(h.marketValue) : "-"}</td> {money(h.lastPrice, currency, 2)}
{h.priceStale && (
<span className="staleBadge" title="最新行情缺失,使用最近一次记录价格">价格未更新</span>
)}
</td>
<td>{money(h.marketValue, currency)}</td>
<td>{pct(weight)}</td> <td>{pct(weight)}</td>
</tr> </tr>
); );
@@ -502,7 +593,7 @@ function HoldingsPanel({
</td> </td>
<td>-</td> <td>-</td>
<td>-</td> <td>-</td>
<td>{money(cash)}</td> <td>{money(cash, currency)}</td>
<td>{pct(cashWeight)}</td> <td>{pct(cashWeight)}</td>
</tr> </tr>
</tfoot> </tfoot>
@@ -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. // Render newest-first; the trader logs trades chronologically.
const reversed = useMemo(() => [...log].reverse(), [log]); const reversed = useMemo(() => [...log].reverse(), [log]);
const [{ page, pageSize }, setPage, { totalPages, sliceStart, sliceEnd }] = const [{ page, pageSize }, setPage, { totalPages, sliceStart, sliceEnd }] =
@@ -568,9 +659,9 @@ function TradeLogPanel({ log }: { log: TradeLogEntry[] }) {
<strong>{t.ticker}</strong> <strong>{t.ticker}</strong>
</td> </td>
<td>{t.shares.toLocaleString(undefined, { maximumFractionDigits: 2 })}</td> <td>{t.shares.toLocaleString(undefined, { maximumFractionDigits: 2 })}</td>
<td>${t.price.toFixed(2)}</td> <td>{money(t.price, currency, 2)}</td>
<td>{money(t.value)}</td> <td>{money(t.value, currency)}</td>
<td>{money(t.commission)}</td> <td>{money(t.commission, currency, 2)}</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
@@ -591,16 +682,5 @@ function TradeLogPanel({ log }: { log: TradeLogEntry[] }) {
); );
} }
function money(v: number): string { const money = formatMoney;
if (typeof v !== "number" || !Number.isFinite(v)) return "-"; const pct = formatPct;
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)}%`;
}

View File

@@ -10,14 +10,19 @@ import {
type Time, type Time,
} from "lightweight-charts"; } from "lightweight-charts";
import { ArrowDown, ArrowUp, BarChart3, Trophy, TrendingDown, TrendingUp } from "lucide-react"; import { ArrowDown, ArrowUp, BarChart3, Trophy, TrendingDown, TrendingUp } from "lucide-react";
import type { import {
BacktestResult, calendarDaysSince,
PrecomputedIndex, currencyForMarket,
PrecomputedScenarioInfo, formatMoney,
StrategyInfo, formatNumber,
StrategyMarket, formatPct,
StrategyMetrics, type BacktestResult,
StrategyResult, type PrecomputedIndex,
type PrecomputedScenarioInfo,
type StrategyInfo,
type StrategyMarket,
type StrategyMetrics,
type StrategyResult,
} from "../shared/strategyTypes"; } from "../shared/strategyTypes";
const STRATEGY_COLORS = [ const STRATEGY_COLORS = [
@@ -26,6 +31,8 @@ const STRATEGY_COLORS = [
]; ];
const MAX_CHART_SELECTION = 10; const MAX_CHART_SELECTION = 10;
// Precomputed data older than this (calendar days) is flagged as stale.
const STALE_DATA_DAYS = 7;
type RankMetric = type RankMetric =
| "annualizedReturn" | "annualizedReturn"
@@ -43,7 +50,8 @@ const RANK_METRICS: Array<{ key: RankMetric; label: string; better: "high" | "lo
{ key: "totalReturn", label: "总收益", better: "high" }, { key: "totalReturn", label: "总收益", better: "high" },
{ key: "calmarRatio", label: "Calmar", better: "high" }, { key: "calmarRatio", label: "Calmar", better: "high" },
{ key: "sortinoRatio", label: "Sortino", 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: "winRate", label: "日胜率", better: "high" },
{ key: "rebalanceWinRate", label: "再平衡胜率", better: "high" }, { key: "rebalanceWinRate", label: "再平衡胜率", better: "high" },
]; ];
@@ -92,9 +100,11 @@ export function StrategyPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps // 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( const currentScenario = useMemo(
() => scenarioList.find((s) => s.id === scenarioId) ?? null, () => scenarioList.find((s) => s.id === servedScenarioId) ?? null,
[scenarioList, scenarioId], [scenarioList, servedScenarioId],
); );
useEffect(() => { useEffect(() => {
@@ -105,7 +115,7 @@ export function StrategyPage() {
api<BacktestResult>(`/api/strategies/precomputed?market=${market}&scenario=${encodeURIComponent(scenarioId)}`) api<BacktestResult>(`/api/strategies/precomputed?market=${market}&scenario=${encodeURIComponent(scenarioId)}`)
.then((res) => { .then((res) => {
setPrecomputed(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)); const top = new Set(ranked.slice(0, 5).map((s) => s.name));
setSelected(top); setSelected(top);
if (ranked.length > 0) setFocusStrategy(ranked[0].name); if (ranked.length > 0) setFocusStrategy(ranked[0].name);
@@ -175,7 +185,7 @@ export function StrategyPage() {
function selectTopN(metric: RankMetric, n: number) { function selectTopN(metric: RankMetric, n: number) {
if (!precomputed) return; 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)); const top = new Set(ranked.slice(0, n).map((s) => s.name));
setSelected(top); setSelected(top);
if (ranked.length > 0) setFocusStrategy(ranked[0].name); if (ranked.length > 0) setFocusStrategy(ranked[0].name);
@@ -188,11 +198,18 @@ export function StrategyPage() {
const meta = precomputed?.meta; const meta = precomputed?.meta;
const computedAt = precomputed?.computedAt ? new Date(precomputed.computedAt) : null; const computedAt = precomputed?.computedAt ? new Date(precomputed.computedAt) : null;
const dataAgeDays = calendarDaysSince(meta?.endDate);
const dataStale = dataAgeDays !== null && dataAgeDays > STALE_DATA_DAYS;
return ( return (
<div className="strategyPage"> <div className="strategyPage">
{error && <div className="alert">{error}</div>} {error && <div className="alert">{error}</div>}
{precomputedError && <div className="alert">{precomputedError}</div>} {precomputedError && <div className="alert">{precomputedError}</div>}
{dataStale && (
<div className="alert warning">
预计算数据截至 {meta?.endDate},已滞后 {dataAgeDays} 天。在 quant 项目重新运行 <code>precompute_backtest.py</code> 以更新。
</div>
)}
<div className="strategyLayout"> <div className="strategyLayout">
{/* Left: Strategy selector */} {/* Left: Strategy selector */}
@@ -220,10 +237,10 @@ export function StrategyPage() {
className={`scenarioOption ${scenarioId === sc.id ? "active" : ""}`} className={`scenarioOption ${scenarioId === sc.id ? "active" : ""}`}
onClick={() => setScenarioId(sc.id)} onClick={() => setScenarioId(sc.id)}
type="button" type="button"
title={sc.description ?? sc.label} title={scenarioDescription(sc, market)}
> >
<strong>{sc.label}</strong> <strong>{scenarioLabel(sc, market)}</strong>
<span>{summarizeScenario(sc)}</span> <span>{summarizeScenario(sc, market)}</span>
</button> </button>
))} ))}
</div> </div>
@@ -231,8 +248,9 @@ export function StrategyPage() {
)} )}
{meta && ( {meta && (
<p className="precomputedHint"> <p className="precomputedHint">
{meta.years} 年 · 离线预计算 {meta.years} 年 · 离线预计算 · 数据截至 {meta.endDate ?? "—"}
{computedAt ? ` · ${computedAt.toLocaleDateString()}` : ""} {computedAt ? ` · 计算于 ${computedAt.toLocaleDateString()}` : ""}
{dataStale && <span className="staleBadge">数据滞后 {dataAgeDays} 天</span>}
</p> </p>
)} )}
</div> </div>
@@ -279,7 +297,7 @@ export function StrategyPage() {
key={s.name} key={s.name}
title={s.name} title={s.name}
> >
<span className="strategyRank" data-top={i < 3 ? "1" : "0"}>#{i + 1}</span> <span className="strategyRank" data-top={i < 3 && !s.ruined ? "1" : "0"}>#{i + 1}</span>
<input <input
type="checkbox" type="checkbox"
checked={isSelected} checked={isSelected}
@@ -289,12 +307,15 @@ export function StrategyPage() {
className="strategyDot" className="strategyDot"
style={{ background: color ?? "transparent", borderColor: color ?? "#2a3344" }} style={{ background: color ?? "transparent", borderColor: color ?? "#2a3344" }}
/> />
<span className="strategyLabel">{displayStrategyName(s)}</span> <span className="strategyLabel">
<span className={`strategyMetric ${s.metrics.annualizedReturn >= 0 ? "positive" : "negative"}`}> {displayStrategyName(s)}
{s.ruined && <RuinedBadge />}
</span>
<span className={`strategyMetric ${signClass(s.metrics.annualizedReturn)}`}>
{pct(s.metrics.annualizedReturn)} {pct(s.metrics.annualizedReturn)}
</span> </span>
<span className="strategyMetricMuted"> <span className="strategyMetricMuted">
{s.metrics.sharpeRatio.toFixed(2)} {num(s.metrics.sharpeRatio)}
</span> </span>
</label> </label>
); );
@@ -321,6 +342,7 @@ export function StrategyPage() {
onChangeMetric={setRankMetric} onChangeMetric={setRankMetric}
categoryByName={categoryByName} categoryByName={categoryByName}
rankIndex={rankIndex} rankIndex={rankIndex}
timeWeighted={hasInjections(chartResult)}
/> />
<div className="insightGrid"> <div className="insightGrid">
<MetricsPanel result={chartResult} focusStrategy={focusStrategy} onFocus={setFocusStrategy} /> <MetricsPanel result={chartResult} focusStrategy={focusStrategy} onFocus={setFocusStrategy} />
@@ -354,6 +376,7 @@ function RankingLeaderboard({
onChangeMetric, onChangeMetric,
categoryByName, categoryByName,
rankIndex, rankIndex,
timeWeighted,
}: { }: {
strategies: StrategyResult[]; strategies: StrategyResult[];
selected: Set<string>; selected: Set<string>;
@@ -363,6 +386,7 @@ function RankingLeaderboard({
onChangeMetric: (metric: RankMetric) => void; onChangeMetric: (metric: RankMetric) => void;
categoryByName: Map<string, string>; categoryByName: Map<string, string>;
rankIndex: Map<string, number>; rankIndex: Map<string, number>;
timeWeighted: boolean;
}) { }) {
return ( return (
<div className="panel rankingPanel"> <div className="panel rankingPanel">
@@ -370,7 +394,10 @@ function RankingLeaderboard({
<div> <div>
<p className="eyebrow">Leaderboard</p> <p className="eyebrow">Leaderboard</p>
<h2>策略排名</h2> <h2>策略排名</h2>
<p>点击行加入/移除对比;列头切换排序指标</p> <p>
点击行加入/移除对比;列头切换排序指标
{timeWeighted ? ";收益/风险指标为时间加权 (TWR),不含注资影响" : ""}
</p>
</div> </div>
<span>{strategies.length} 个策略</span> <span>{strategies.length} 个策略</span>
</div> </div>
@@ -411,10 +438,11 @@ function RankingLeaderboard({
}} }}
> >
<td className="rankCol"> <td className="rankCol">
<span className={`rankBadge ${rank <= 3 ? "top" : ""}`}>{rank}</span> <span className={`rankBadge ${rank <= 3 && !s.ruined ? "top" : ""}`}>{rank}</span>
</td> </td>
<td> <td>
<strong>{displayStrategyName(s)}</strong> <strong>{displayStrategyName(s)}</strong>
{s.ruined && <RuinedBadge />}
<span className="rankSub">{s.name}</span> <span className="rankSub">{s.name}</span>
</td> </td>
<td> <td>
@@ -422,18 +450,18 @@ function RankingLeaderboard({
{categoryLabel(categoryByName.get(s.name) ?? "other")} {categoryLabel(categoryByName.get(s.name) ?? "other")}
</span> </span>
</td> </td>
<td>{s.metrics.sharpeRatio.toFixed(2)}</td> <td>{num(s.metrics.sharpeRatio)}</td>
<td className={s.metrics.annualizedReturn >= 0 ? "positive" : "negative"}> <td className={signClass(s.metrics.annualizedReturn)}>
{pct(s.metrics.annualizedReturn)} {pct(s.metrics.annualizedReturn)}
</td> </td>
<td className={s.metrics.totalReturn >= 0 ? "positive" : "negative"}> <td className={signClass(s.metrics.totalReturn)}>
{pct(s.metrics.totalReturn)} {pct(s.metrics.totalReturn)}
</td> </td>
<td>{s.metrics.calmarRatio.toFixed(2)}</td> <td>{num(s.metrics.calmarRatio)}</td>
<td>{s.metrics.sortinoRatio.toFixed(2)}</td> <td>{num(s.metrics.sortinoRatio)}</td>
<td className="negative">{pct(s.metrics.maxDrawdown)}</td> <td className="negative">{pct(s.metrics.maxDrawdown)}</td>
<td>{pct(s.metrics.winRate)}</td> <td>{pct(s.metrics.winRate)}</td>
<td>{pctOrDash(s.metrics.rebalanceWinRate)}</td> <td>{pct(s.metrics.rebalanceWinRate)}</td>
</tr> </tr>
); );
})} })}
@@ -462,6 +490,7 @@ function EquityCurveChart({
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const chartRef = useRef<IChartApi | null>(null); const chartRef = useRef<IChartApi | null>(null);
const seriesMapRef = useRef<Map<string, ISeriesApi<"Line">>>(new Map()); const seriesMapRef = useRef<Map<string, ISeriesApi<"Line">>>(new Map());
const currency = currencyForMarket(result.meta.market);
useEffect(() => { useEffect(() => {
if (!containerRef.current) return; if (!containerRef.current) return;
@@ -501,6 +530,9 @@ function EquityCurveChart({
const chart = chartRef.current; const chart = chartRef.current;
if (!chart) return; if (!chart) return;
chart.applyOptions({
localization: { priceFormatter: (price: number) => formatMoney(price, currency) },
});
for (const series of seriesMapRef.current.values()) { for (const series of seriesMapRef.current.values()) {
chart.removeSeries(series); chart.removeSeries(series);
} }
@@ -539,7 +571,7 @@ function EquityCurveChart({
} }
chart.timeScale().fitContent(); chart.timeScale().fitContent();
}, [result, focusStrategy]); }, [result, focusStrategy, currency]);
return ( return (
<div className="panel chartPanel"> <div className="panel chartPanel">
@@ -553,11 +585,11 @@ function EquityCurveChart({
<span className="equityMetaSep">·</span> <span className="equityMetaSep">·</span>
<span className="equityMetaItem">{result.meta.years} 年</span> <span className="equityMetaItem">{result.meta.years} 年</span>
<span className="equityMetaSep">·</span> <span className="equityMetaSep">·</span>
<span className="equityMetaItem">{money(result.meta.initialCapital)} 初始</span> <span className="equityMetaItem">{formatMoney(result.meta.initialCapital, currency)} 初始</span>
{scenario?.injectionSchedule && ( {scenario?.injectionSchedule && (
<> <>
<span className="equityMetaSep">·</span> <span className="equityMetaSep">·</span>
<span className="equityMetaItem">{summarizeScenario(scenario)}</span> <span className="equityMetaItem">{summarizeScenario(scenario, result.meta.market)}</span>
</> </>
)} )}
</div> </div>
@@ -603,36 +635,67 @@ function MetricsPanel({
onFocus: (name: string) => void; onFocus: (name: string) => void;
}) { }) {
const focused = result.strategies.find((s) => s.name === focusStrategy) ?? result.strategies[0]; 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 ( return (
<div className="panel metricsPanel"> <div className="panel metricsPanel">
<div className="panelHeader"> <div className="panelHeader">
<h2>核心指标</h2> <h2>核心指标</h2>
{focused && <span>{displayStrategyName(focused)}</span>} {focused && (
<span>
{displayStrategyName(focused)}
{focused.ruined && <RuinedBadge />}
</span>
)}
</div> </div>
{focused ? ( {focused ? (
<> <>
{timeWeighted && flows && (
<>
<p className="hint">资金视角(含注资)</p>
<div className="metricsGrid">
<MetricCell label="投入本金" value={formatMoney(flows.invested, currency)} />
<MetricCell label="期末市值" value={formatMoney(flows.finalValue, currency)} />
<MetricCell
label="盈亏"
value={formatMoney(flows.profit, currency)}
positive={flows.profit > 0}
negative={flows.profit < 0}
/>
<MetricCell
label="本金收益率"
value={pct(flows.returnOnInvested)}
positive={isPositive(flows.returnOnInvested)}
negative={isNegative(flows.returnOnInvested)}
/>
</div>
</>
)}
{timeWeighted && <p className="hint">收益/风险指标为时间加权 (TWR),剔除注资影响</p>}
<div className="metricsGrid"> <div className="metricsGrid">
<MetricCell label="总收益" value={pct(focused.metrics.totalReturn)} positive={focused.metrics.totalReturn > 0} /> <MetricCell label={`总收益${twr}`} value={pct(focused.metrics.totalReturn)} positive={isPositive(focused.metrics.totalReturn)} />
<MetricCell label="年化收益" value={pct(focused.metrics.annualizedReturn)} positive={focused.metrics.annualizedReturn > 0} /> <MetricCell label={`年化收益${twr}`} value={pct(focused.metrics.annualizedReturn)} positive={isPositive(focused.metrics.annualizedReturn)} />
<MetricCell label="年化波动" value={pct(focused.metrics.annualizedVolatility)} /> <MetricCell label="年化波动" value={pct(focused.metrics.annualizedVolatility)} />
<MetricCell label="Sharpe" value={focused.metrics.sharpeRatio.toFixed(2)} positive={focused.metrics.sharpeRatio > 1} /> <MetricCell label="Sharpe" value={num(focused.metrics.sharpeRatio)} positive={isAbove(focused.metrics.sharpeRatio, 1)} />
<MetricCell label="Sortino" value={focused.metrics.sortinoRatio.toFixed(2)} positive={focused.metrics.sortinoRatio > 1} /> <MetricCell label="Sortino" value={num(focused.metrics.sortinoRatio)} positive={isAbove(focused.metrics.sortinoRatio, 1)} />
<MetricCell label="最大回撤" value={pct(focused.metrics.maxDrawdown)} negative /> <MetricCell label="最大回撤" value={pct(focused.metrics.maxDrawdown)} negative />
<MetricCell label="Calmar" value={focused.metrics.calmarRatio.toFixed(2)} positive={focused.metrics.calmarRatio > 0.5} /> <MetricCell label="Calmar" value={num(focused.metrics.calmarRatio)} positive={isAbove(focused.metrics.calmarRatio, 0.5)} />
<MetricCell label="日胜率" value={pct(focused.metrics.winRate)} positive={focused.metrics.winRate > 0.5} /> <MetricCell label="日胜率" value={pct(focused.metrics.winRate)} positive={isAbove(focused.metrics.winRate, 0.5)} />
<MetricCell <MetricCell
label="再平衡胜率" label="再平衡胜率"
value={pctOrDash(focused.metrics.rebalanceWinRate)} value={pct(focused.metrics.rebalanceWinRate)}
positive={ positive={isAbove(focused.metrics.rebalanceWinRate, 0.5)}
typeof focused.metrics.rebalanceWinRate === "number" &&
focused.metrics.rebalanceWinRate > 0.5
}
/> />
</div> </div>
<div className="focusSummary"> <div className="focusSummary">
<span><BarChart3 size={15} /> 终值 {money(focused.equityCurve.at(-1)?.value)}</span> <span><BarChart3 size={15} /> 终值 {formatMoney(flows?.finalValue ?? focused.equityCurve.at(-1)?.value, currency)}</span>
<span><TrendingDown size={15} /> 最大回撤 {pct(focused.metrics.maxDrawdown)}</span> <span><TrendingDown size={15} /> 最大回撤 {pct(focused.metrics.maxDrawdown)}</span>
<span><TrendingUp size={15} /> 年化 {pct(focused.metrics.annualizedReturn)}</span> <span><TrendingUp size={15} /> 年化{twr} {pct(focused.metrics.annualizedReturn)}</span>
<span>交易 {focused.trades.length.toLocaleString()} 天</span> <span>交易 {focused.trades.length.toLocaleString()} 天</span>
</div> </div>
</> </>
@@ -646,7 +709,15 @@ function MetricsPanel({
<thead> <thead>
<tr> <tr>
<th>策略</th> <th>策略</th>
<th>年化</th> {showFlows && (
<>
<th>投入本金</th>
<th>期末市值</th>
<th>盈亏</th>
<th>本金收益率</th>
</>
)}
<th>年化{twr}</th>
<th>Sharpe</th> <th>Sharpe</th>
<th>MaxDD</th> <th>MaxDD</th>
<th>日胜率</th> <th>日胜率</th>
@@ -658,13 +729,22 @@ function MetricsPanel({
<tr key={s.name} className={s.name === focusStrategy ? "focused" : ""} onClick={() => onFocus(s.name)}> <tr key={s.name} className={s.name === focusStrategy ? "focused" : ""} onClick={() => onFocus(s.name)}>
<td> <td>
<strong>{displayStrategyName(s)}</strong> <strong>{displayStrategyName(s)}</strong>
{s.ruined && <RuinedBadge />}
<span>{s.name}</span> <span>{s.name}</span>
</td> </td>
<td className={s.metrics.annualizedReturn > 0 ? "positive" : "negative"}>{pct(s.metrics.annualizedReturn)}</td> {showFlows && (
<td>{s.metrics.sharpeRatio.toFixed(2)}</td> <>
<td>{formatMoney(s.capitalFlows?.invested, currency)}</td>
<td>{formatMoney(s.capitalFlows?.finalValue, currency)}</td>
<td className={signClass(s.capitalFlows?.profit)}>{formatMoney(s.capitalFlows?.profit, currency)}</td>
<td className={signClass(s.capitalFlows?.returnOnInvested)}>{pct(s.capitalFlows?.returnOnInvested)}</td>
</>
)}
<td className={signClass(s.metrics.annualizedReturn)}>{pct(s.metrics.annualizedReturn)}</td>
<td>{num(s.metrics.sharpeRatio)}</td>
<td className="negative">{pct(s.metrics.maxDrawdown)}</td> <td className="negative">{pct(s.metrics.maxDrawdown)}</td>
<td>{pct(s.metrics.winRate)}</td> <td>{pct(s.metrics.winRate)}</td>
<td>{pctOrDash(s.metrics.rebalanceWinRate)}</td> <td>{pct(s.metrics.rebalanceWinRate)}</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
@@ -749,7 +829,7 @@ function ReturnHeatmap({ result, focusStrategy }: { result: BacktestResult; focu
return ( return (
<div className="panel heatmapPanel"> <div className="panel heatmapPanel">
<div className="panelHeader"> <div className="panelHeader">
<h2>收益热力图</h2> <h2>收益热力图{hasInjections(result) ? " (TWR)" : ""}</h2>
<span>{displayStrategyName(focused)}</span> <span>{displayStrategyName(focused)}</span>
</div> </div>
<div className="heatmapContainer"> <div className="heatmapContainer">
@@ -767,15 +847,15 @@ function ReturnHeatmap({ result, focusStrategy }: { result: BacktestResult; focu
<td className="yearLabel">{year}</td> <td className="yearLabel">{year}</td>
{months.map((month) => { {months.map((month) => {
const key = `${year}-${month}`; const key = `${year}-${month}`;
const val = monthlyReturns[key]; const val = finiteOrUndefined(monthlyReturns[key]);
return ( return (
<td key={month} className="heatCell" style={{ background: heatColor(val) }}> <td key={month} className="heatCell" style={{ background: heatColor(val) }}>
{val !== undefined ? `${(val * 100).toFixed(1)}` : ""} {val !== undefined ? `${(val * 100).toFixed(1)}` : ""}
</td> </td>
); );
})} })}
<td className={`yearReturn ${(yearlyReturns[year] ?? 0) >= 0 ? "positive" : "negative"}`}> <td className={`yearReturn ${signClass(yearlyReturns[year])}`}>
{yearlyReturns[year] !== undefined ? pct(yearlyReturns[year]) : "-"} {pct(yearlyReturns[year])}
</td> </td>
</tr> </tr>
))} ))}
@@ -797,18 +877,52 @@ function rankBy(strategies: StrategyResult[], metric: RankMetric): StrategyResul
const v = s.metrics[metric as keyof StrategyMetrics]; const v = s.metrics[metric as keyof StrategyMetrics];
return typeof v === "number" && Number.isFinite(v) ? v : (better === "high" ? -Infinity : Infinity); 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 { function hasInjections(result: BacktestResult): boolean {
if (!sc.injectionSchedule) return `${money(sc.capital)} 一次性`; 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; 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) { if (inj.frequency === "yearly_months" && inj.months?.length) {
const monthLabel = inj.months.join("/"); 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" ? "每季" : "每年"; 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 { function categoryLabel(cat: string): string {
@@ -820,18 +934,38 @@ function categoryLabel(cat: string): string {
return cat; return cat;
} }
function pct(val: number): string { const pct = formatPct;
return `${(val * 100).toFixed(1)}%`; 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 { function isPositive(val: number | null | undefined): boolean {
return typeof val === "number" && Number.isFinite(val) ? pct(val) : "-"; return (finiteOrUndefined(val) ?? 0) > 0;
} }
function money(value: number | undefined): string { function isNegative(val: number | null | undefined): boolean {
return typeof value === "number" && Number.isFinite(value) return (finiteOrUndefined(val) ?? 0) < 0;
? value.toLocaleString(undefined, { maximumFractionDigits: 0, style: "currency", currency: "USD" }) }
: "-";
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 (
<span className="ruinedBadge" title="资产曾归零,结果不可作为可行策略参考">
爆仓/不可行
</span>
);
} }
function displayStrategyName(strategy: Pick<StrategyInfo | StrategyResult, "name" | "label">): string { function displayStrategyName(strategy: Pick<StrategyInfo | StrategyResult, "name" | "label">): string {

View File

@@ -150,6 +150,37 @@ h2 {
border-radius: 6px; 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 { .statusBar {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;

View File

@@ -172,6 +172,7 @@ function resolveScenarios(raw: { scenarios?: Record<string, unknown>; markets?:
label: string; label: string;
description?: string; description?: string;
capital: number; capital: number;
capitalByMarket?: Record<string, number>;
injectionSchedule: unknown; injectionSchedule: unknown;
markets: Record<string, unknown>; markets: Record<string, unknown>;
}>; }>;
@@ -191,6 +192,7 @@ function resolveScenarios(raw: { scenarios?: Record<string, unknown>; markets?:
label: string; label: string;
description?: string; description?: string;
capital: number; capital: number;
capitalByMarket?: Record<string, number>;
injectionSchedule: unknown; injectionSchedule: unknown;
markets: Record<string, unknown>; markets: Record<string, unknown>;
}>; }>;
@@ -207,6 +209,7 @@ app.get("/api/strategies/precomputed/index", (_request, response, next) => {
label: sc.label, label: sc.label,
description: sc.description, description: sc.description,
capital: sc.capital, capital: sc.capital,
capitalByMarket: sc.capitalByMarket,
injectionSchedule: sc.injectionSchedule ?? null, injectionSchedule: sc.injectionSchedule ?? null,
availableMarkets: Object.keys(sc.markets ?? {}), availableMarkets: Object.keys(sc.markets ?? {}),
})); }));
@@ -223,9 +226,12 @@ app.get("/api/strategies/precomputed", (_request, response, next) => {
const raw = loadPrecomputedFile(); const raw = loadPrecomputedFile();
const scenarios = resolveScenarios(raw); const scenarios = resolveScenarios(raw);
const scenarioIds = Object.keys(scenarios); const scenarioIds = Object.keys(scenarios);
const scenarioId = requestedScenario && scenarios[requestedScenario] // An explicit but unknown scenario is a client error; silently serving
? requestedScenario // another scenario would mislabel the results.
: scenarioIds[0]; if (requestedScenario && !Object.hasOwn(scenarios, requestedScenario)) {
throw new Error(`Unknown pre-computed scenario: ${requestedScenario}`);
}
const scenarioId = requestedScenario ?? scenarioIds[0];
const scenario = scenarios[scenarioId]; const scenario = scenarios[scenarioId];
if (!scenario) { if (!scenario) {
throw new Error("No pre-computed scenarios available."); throw new Error("No pre-computed scenarios available.");

View File

@@ -1,4 +1,5 @@
export type StrategyMarket = "us" | "cn"; export type StrategyMarket = "us" | "cn";
export type StrategyCurrency = "USD" | "CNY";
export type StrategyCategory = "stock_picker" | "tactical_allocation" | "factor_combo" | "other"; export type StrategyCategory = "stock_picker" | "tactical_allocation" | "factor_combo" | "other";
export interface StrategyParam { export interface StrategyParam {
@@ -29,6 +30,9 @@ export interface PrecomputedScenarioInfo {
label: string; label: string;
description?: string; description?: string;
capital: number; capital: number;
// Per-market starting capital in that market's currency. Absent in older
// precomputed files, where `capital` applies to every market.
capitalByMarket?: Partial<Record<StrategyMarket, number>>;
injectionSchedule: InjectionSchedule | null; injectionSchedule: InjectionSchedule | null;
availableMarkets: StrategyMarket[]; availableMarkets: StrategyMarket[];
} }
@@ -73,26 +77,42 @@ export interface InjectionEvent {
amount: number; 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 { export interface StrategyMetrics {
totalReturn: number; totalReturn: number | null;
annualizedReturn: number; annualizedReturn: number | null;
annualizedVolatility: number; annualizedVolatility: number | null;
sharpeRatio: number; sharpeRatio: number | null;
sortinoRatio: number; sortinoRatio: number | null;
maxDrawdown: number; // Negative fraction; closer to zero is better.
calmarRatio: number; maxDrawdown: number | null;
winRate: number; calmarRatio: number | null;
winRate: number | null;
// Fraction of inter-rebalance periods with positive return. Null for // Fraction of inter-rebalance periods with positive return. Null for
// strategies with fewer than two rebalances in the window (buy-and-hold). // strategies with fewer than two rebalances in the window (buy-and-hold).
rebalanceWinRate: number | null; 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 { export interface StrategyResult {
name: string; name: string;
label: string; label: string;
// Money curve (includes injections when a schedule exists).
equityCurve: EquityPoint[]; equityCurve: EquityPoint[];
// Same strategy without injections; metrics are computed on this curve.
baseEquityCurve: EquityPoint[] | null; baseEquityCurve: EquityPoint[] | null;
metrics: StrategyMetrics; metrics: StrategyMetrics;
capitalFlows?: CapitalFlows;
// Equity hit zero at some point; the result is not investable.
ruined?: boolean;
drawdowns: DrawdownPeriod[]; drawdowns: DrawdownPeriod[];
trades: TradeEvent[]; trades: TradeEvent[];
injections: InjectionEvent[]; injections: InjectionEvent[];
@@ -113,6 +133,8 @@ export interface BacktestMeta {
export interface BacktestResult { export interface BacktestResult {
id: string; id: string;
// Set by the precomputed endpoint to the scenario actually served.
scenarioId?: string;
meta: BacktestMeta; meta: BacktestMeta;
strategies: StrategyResult[]; strategies: StrategyResult[];
benchmark: { name: string; equityCurve: EquityPoint[] }; benchmark: { name: string; equityCurve: EquityPoint[] };
@@ -124,6 +146,8 @@ export interface HoldingDetail {
shares: number; shares: number;
lastPrice: number | null; lastPrice: number | null;
marketValue: number | null; marketValue: number | null;
// Latest panel price missing; lastPrice is the last recorded trade price.
priceStale?: boolean;
} }
export interface TradeLogEntry { export interface TradeLogEntry {
@@ -141,7 +165,10 @@ export interface LiveStrategyState {
label: string; label: string;
category: StrategyCategory | string; category: StrategyCategory | string;
market: StrategyMarket; market: StrategyMarket;
currency?: StrategyCurrency;
initialCapital: number; initialCapital: number;
// First live session; strategies may start on different dates.
startDate?: string | null;
currentValue: number; currentValue: number;
cash: number; cash: number;
marketValue: number; marketValue: number;
@@ -150,7 +177,11 @@ export interface LiveStrategyState {
holdings: Record<string, number>; holdings: Record<string, number>;
holdingsDetail: HoldingDetail[]; holdingsDetail: HoldingDetail[];
priceAsOf: string | null; priceAsOf: string | null;
// Starts with an anchor point at initialCapital one day before startDate.
equityCurve: EquityPoint[]; 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; metrics: StrategyMetrics | null;
drawdowns: DrawdownPeriod[]; drawdowns: DrawdownPeriod[];
trades: TradeEvent[]; trades: TradeEvent[];
@@ -159,3 +190,43 @@ export interface LiveStrategyState {
yearlyReturns: Record<string, number>; yearlyReturns: Record<string, number>;
lastUpdated: string | null; lastUpdated: string | null;
} }
// ---------------------------------------------------------------------------
// Display helpers shared by the strategy pages
// ---------------------------------------------------------------------------
const MARKET_CURRENCY: Record<StrategyMarket, StrategyCurrency> = { 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);
}