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 c2882ae4cf
commit eefd879a93
5 changed files with 441 additions and 119 deletions

View File

@@ -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<T>(path: string, init?: RequestInit): Promise<T> {
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"
>
<RefreshCw size={14} />
@@ -156,7 +167,7 @@ export function LivePage() {
<span className="liveListName">{s.name}</span>
</div>
<div className="liveListBody">
<span className="liveListValue">{money(s.currentValue)}</span>
<span className="liveListValue">{money(s.currentValue, currencyOf(s))}</span>
<span
className={`liveListPnl ${s.unrealizedPnl >= 0 ? "positive" : "negative"}`}
>
@@ -165,8 +176,11 @@ export function LivePage() {
</span>
</div>
<div className="liveListMeta">
<span>持仓 {Object.keys(s.holdings).length}</span>
<span>{s.lastUpdated ?? "-"}</span>
<span>持仓 {Object.keys(s.holdings).length} · 起 {s.startDate ?? "—"}</span>
<span>
{isStale(s) && <span className="staleBadge">滞后</span>}
{s.lastUpdated ?? "-"}
</span>
</div>
</button>
))}
@@ -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 (
<>
<div className="panel liveSummary">
@@ -199,43 +216,90 @@ function LiveStrategyDetail({ strategy }: { strategy: LiveStrategyState }) {
<p className="eyebrow">{m.name}</p>
<h2>{m.label}</h2>
<p className="hint">
{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) && <span className="staleBadge">已 {staleDays} 天未更新</span>}
</p>
</div>
<div className={`liveTotalPnl ${m.unrealizedPnl >= 0 ? "positive" : "negative"}`}>
<strong>{money(m.currentValue)}</strong>
<strong>{money(m.currentValue, currency)}</strong>
<span>
{m.unrealizedPnl >= 0 ? "+" : ""}
{money(m.unrealizedPnl)} ({pct(m.unrealizedPnlPct)})
{money(m.unrealizedPnl, currency)} ({pct(m.unrealizedPnlPct)})
</span>
</div>
</div>
<div className="liveSummaryGrid">
<SummaryCell label="现金" value={money(m.cash)} />
<SummaryCell label="持仓市值" value={money(m.marketValue)} />
<SummaryCell label="总资产" value={money(m.currentValue)} />
<SummaryCell label="现金" value={money(m.cash, currency)} />
<SummaryCell label="持仓市值" value={money(m.marketValue, currency)} />
<SummaryCell label="总资产" value={money(m.currentValue, currency)} />
<SummaryCell label="持仓数" value={`${Object.keys(m.holdings).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.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>
<LiveEquityChart equityCurve={m.equityCurve} initialCapital={m.initialCapital} />
<LiveEquityChart
equityCurve={m.equityCurve}
initialCapital={m.initialCapital}
currency={currency}
benchmark={bench ? m.benchmark : undefined}
/>
<HoldingsPanel
holdings={m.holdingsDetail}
cash={m.cash}
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({
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<HTMLDivElement>(null);
const chartRef = useRef<IChartApi | null>(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 (
<div className="panel chartPanel">
@@ -334,6 +417,7 @@ function LiveEquityChart({
{equityCurve.length > 0
? ` · ${equityCurve[0].date} → ${equityCurve[equityCurve.length - 1].date}`
: ""}
{benchmark ? ` · 虚线为 ${benchmark.name}(同期,按初始资金归一)` : " · 基准数据暂缺"}
</p>
</div>
</div>
@@ -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({
<strong>{h.ticker}</strong>
</td>
<td>{h.shares.toLocaleString(undefined, { maximumFractionDigits: 2 })}</td>
<td>{h.lastPrice !== null ? `$${h.lastPrice.toFixed(2)}` : "-"}</td>
<td>{h.marketValue !== null ? money(h.marketValue) : "-"}</td>
<td>
{money(h.lastPrice, currency, 2)}
{h.priceStale && (
<span className="staleBadge" title="最新行情缺失,使用最近一次记录价格">价格未更新</span>
)}
</td>
<td>{money(h.marketValue, currency)}</td>
<td>{pct(weight)}</td>
</tr>
);
@@ -502,7 +593,7 @@ function HoldingsPanel({
</td>
<td>-</td>
<td>-</td>
<td>{money(cash)}</td>
<td>{money(cash, currency)}</td>
<td>{pct(cashWeight)}</td>
</tr>
</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.
const reversed = useMemo(() => [...log].reverse(), [log]);
const [{ page, pageSize }, setPage, { totalPages, sliceStart, sliceEnd }] =
@@ -568,9 +659,9 @@ function TradeLogPanel({ log }: { log: TradeLogEntry[] }) {
<strong>{t.ticker}</strong>
</td>
<td>{t.shares.toLocaleString(undefined, { maximumFractionDigits: 2 })}</td>
<td>${t.price.toFixed(2)}</td>
<td>{money(t.value)}</td>
<td>{money(t.commission)}</td>
<td>{money(t.price, currency, 2)}</td>
<td>{money(t.value, currency)}</td>
<td>{money(t.commission, currency, 2)}</td>
</tr>
))}
</tbody>
@@ -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;

View File

@@ -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<BacktestResult>(`/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 (
<div className="strategyPage">
{error && <div className="alert">{error}</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">
{/* 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)}
>
<strong>{sc.label}</strong>
<span>{summarizeScenario(sc)}</span>
<strong>{scenarioLabel(sc, market)}</strong>
<span>{summarizeScenario(sc, market)}</span>
</button>
))}
</div>
@@ -231,8 +248,9 @@ export function StrategyPage() {
)}
{meta && (
<p className="precomputedHint">
{meta.years} 年 · 离线预计算
{computedAt ? ` · ${computedAt.toLocaleDateString()}` : ""}
{meta.years} 年 · 离线预计算 · 数据截至 {meta.endDate ?? "—"}
{computedAt ? ` · 计算于 ${computedAt.toLocaleDateString()}` : ""}
{dataStale && <span className="staleBadge">数据滞后 {dataAgeDays} 天</span>}
</p>
)}
</div>
@@ -279,7 +297,7 @@ export function StrategyPage() {
key={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
type="checkbox"
checked={isSelected}
@@ -289,12 +307,15 @@ export function StrategyPage() {
className="strategyDot"
style={{ background: color ?? "transparent", borderColor: color ?? "#2a3344" }}
/>
<span className="strategyLabel">{displayStrategyName(s)}</span>
<span className={`strategyMetric ${s.metrics.annualizedReturn >= 0 ? "positive" : "negative"}`}>
<span className="strategyLabel">
{displayStrategyName(s)}
{s.ruined && <RuinedBadge />}
</span>
<span className={`strategyMetric ${signClass(s.metrics.annualizedReturn)}`}>
{pct(s.metrics.annualizedReturn)}
</span>
<span className="strategyMetricMuted">
{s.metrics.sharpeRatio.toFixed(2)}
{num(s.metrics.sharpeRatio)}
</span>
</label>
);
@@ -321,6 +342,7 @@ export function StrategyPage() {
onChangeMetric={setRankMetric}
categoryByName={categoryByName}
rankIndex={rankIndex}
timeWeighted={hasInjections(chartResult)}
/>
<div className="insightGrid">
<MetricsPanel result={chartResult} focusStrategy={focusStrategy} onFocus={setFocusStrategy} />
@@ -354,6 +376,7 @@ function RankingLeaderboard({
onChangeMetric,
categoryByName,
rankIndex,
timeWeighted,
}: {
strategies: StrategyResult[];
selected: Set<string>;
@@ -363,6 +386,7 @@ function RankingLeaderboard({
onChangeMetric: (metric: RankMetric) => void;
categoryByName: Map<string, string>;
rankIndex: Map<string, number>;
timeWeighted: boolean;
}) {
return (
<div className="panel rankingPanel">
@@ -370,7 +394,10 @@ function RankingLeaderboard({
<div>
<p className="eyebrow">Leaderboard</p>
<h2>策略排名</h2>
<p>点击行加入/移除对比;列头切换排序指标</p>
<p>
点击行加入/移除对比;列头切换排序指标
{timeWeighted ? ";收益/风险指标为时间加权 (TWR),不含注资影响" : ""}
</p>
</div>
<span>{strategies.length} 个策略</span>
</div>
@@ -411,10 +438,11 @@ function RankingLeaderboard({
}}
>
<td className="rankCol">
<span className={`rankBadge ${rank <= 3 ? "top" : ""}`}>{rank}</span>
<span className={`rankBadge ${rank <= 3 && !s.ruined ? "top" : ""}`}>{rank}</span>
</td>
<td>
<strong>{displayStrategyName(s)}</strong>
{s.ruined && <RuinedBadge />}
<span className="rankSub">{s.name}</span>
</td>
<td>
@@ -422,18 +450,18 @@ function RankingLeaderboard({
{categoryLabel(categoryByName.get(s.name) ?? "other")}
</span>
</td>
<td>{s.metrics.sharpeRatio.toFixed(2)}</td>
<td className={s.metrics.annualizedReturn >= 0 ? "positive" : "negative"}>
<td>{num(s.metrics.sharpeRatio)}</td>
<td className={signClass(s.metrics.annualizedReturn)}>
{pct(s.metrics.annualizedReturn)}
</td>
<td className={s.metrics.totalReturn >= 0 ? "positive" : "negative"}>
<td className={signClass(s.metrics.totalReturn)}>
{pct(s.metrics.totalReturn)}
</td>
<td>{s.metrics.calmarRatio.toFixed(2)}</td>
<td>{s.metrics.sortinoRatio.toFixed(2)}</td>
<td>{num(s.metrics.calmarRatio)}</td>
<td>{num(s.metrics.sortinoRatio)}</td>
<td className="negative">{pct(s.metrics.maxDrawdown)}</td>
<td>{pct(s.metrics.winRate)}</td>
<td>{pctOrDash(s.metrics.rebalanceWinRate)}</td>
<td>{pct(s.metrics.rebalanceWinRate)}</td>
</tr>
);
})}
@@ -462,6 +490,7 @@ function EquityCurveChart({
const containerRef = useRef<HTMLDivElement>(null);
const chartRef = useRef<IChartApi | null>(null);
const seriesMapRef = useRef<Map<string, ISeriesApi<"Line">>>(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 (
<div className="panel chartPanel">
@@ -553,11 +585,11 @@ function EquityCurveChart({
<span className="equityMetaSep">·</span>
<span className="equityMetaItem">{result.meta.years} 年</span>
<span className="equityMetaSep">·</span>
<span className="equityMetaItem">{money(result.meta.initialCapital)} 初始</span>
<span className="equityMetaItem">{formatMoney(result.meta.initialCapital, currency)} 初始</span>
{scenario?.injectionSchedule && (
<>
<span className="equityMetaSep">·</span>
<span className="equityMetaItem">{summarizeScenario(scenario)}</span>
<span className="equityMetaItem">{summarizeScenario(scenario, result.meta.market)}</span>
</>
)}
</div>
@@ -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 (
<div className="panel metricsPanel">
<div className="panelHeader">
<h2>核心指标</h2>
{focused && <span>{displayStrategyName(focused)}</span>}
{focused && (
<span>
{displayStrategyName(focused)}
{focused.ruined && <RuinedBadge />}
</span>
)}
</div>
{focused ? (
<>
{timeWeighted && flows && (
<>
<p className="hint">资金视角(含注资)</p>
<div className="metricsGrid">
<MetricCell label="总收益" value={pct(focused.metrics.totalReturn)} positive={focused.metrics.totalReturn > 0} />
<MetricCell label="年化收益" value={pct(focused.metrics.annualizedReturn)} positive={focused.metrics.annualizedReturn > 0} />
<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">
<MetricCell label={`总收益${twr}`} value={pct(focused.metrics.totalReturn)} positive={isPositive(focused.metrics.totalReturn)} />
<MetricCell label={`年化收益${twr}`} value={pct(focused.metrics.annualizedReturn)} positive={isPositive(focused.metrics.annualizedReturn)} />
<MetricCell label="年化波动" value={pct(focused.metrics.annualizedVolatility)} />
<MetricCell label="Sharpe" value={focused.metrics.sharpeRatio.toFixed(2)} positive={focused.metrics.sharpeRatio > 1} />
<MetricCell label="Sortino" value={focused.metrics.sortinoRatio.toFixed(2)} positive={focused.metrics.sortinoRatio > 1} />
<MetricCell label="Sharpe" value={num(focused.metrics.sharpeRatio)} positive={isAbove(focused.metrics.sharpeRatio, 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="Calmar" value={focused.metrics.calmarRatio.toFixed(2)} positive={focused.metrics.calmarRatio > 0.5} />
<MetricCell label="日胜率" value={pct(focused.metrics.winRate)} positive={focused.metrics.winRate > 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={isAbove(focused.metrics.winRate, 0.5)} />
<MetricCell
label="再平衡胜率"
value={pctOrDash(focused.metrics.rebalanceWinRate)}
positive={
typeof focused.metrics.rebalanceWinRate === "number" &&
focused.metrics.rebalanceWinRate > 0.5
}
value={pct(focused.metrics.rebalanceWinRate)}
positive={isAbove(focused.metrics.rebalanceWinRate, 0.5)}
/>
</div>
<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><TrendingUp size={15} /> 年化 {pct(focused.metrics.annualizedReturn)}</span>
<span><TrendingUp size={15} /> 年化{twr} {pct(focused.metrics.annualizedReturn)}</span>
<span>交易 {focused.trades.length.toLocaleString()} 天</span>
</div>
</>
@@ -646,7 +709,15 @@ function MetricsPanel({
<thead>
<tr>
<th>策略</th>
<th>年化</th>
{showFlows && (
<>
<th>投入本金</th>
<th>期末市值</th>
<th>盈亏</th>
<th>本金收益率</th>
</>
)}
<th>年化{twr}</th>
<th>Sharpe</th>
<th>MaxDD</th>
<th>日胜率</th>
@@ -658,13 +729,22 @@ function MetricsPanel({
<tr key={s.name} className={s.name === focusStrategy ? "focused" : ""} onClick={() => onFocus(s.name)}>
<td>
<strong>{displayStrategyName(s)}</strong>
{s.ruined && <RuinedBadge />}
<span>{s.name}</span>
</td>
<td className={s.metrics.annualizedReturn > 0 ? "positive" : "negative"}>{pct(s.metrics.annualizedReturn)}</td>
<td>{s.metrics.sharpeRatio.toFixed(2)}</td>
{showFlows && (
<>
<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>{pct(s.metrics.winRate)}</td>
<td>{pctOrDash(s.metrics.rebalanceWinRate)}</td>
<td>{pct(s.metrics.rebalanceWinRate)}</td>
</tr>
))}
</tbody>
@@ -749,7 +829,7 @@ function ReturnHeatmap({ result, focusStrategy }: { result: BacktestResult; focu
return (
<div className="panel heatmapPanel">
<div className="panelHeader">
<h2>收益热力图</h2>
<h2>收益热力图{hasInjections(result) ? " (TWR)" : ""}</h2>
<span>{displayStrategyName(focused)}</span>
</div>
<div className="heatmapContainer">
@@ -767,15 +847,15 @@ function ReturnHeatmap({ result, focusStrategy }: { result: BacktestResult; focu
<td className="yearLabel">{year}</td>
{months.map((month) => {
const key = `${year}-${month}`;
const val = monthlyReturns[key];
const val = finiteOrUndefined(monthlyReturns[key]);
return (
<td key={month} className="heatCell" style={{ background: heatColor(val) }}>
{val !== undefined ? `${(val * 100).toFixed(1)}` : ""}
</td>
);
})}
<td className={`yearReturn ${(yearlyReturns[year] ?? 0) >= 0 ? "positive" : "negative"}`}>
{yearlyReturns[year] !== undefined ? pct(yearlyReturns[year]) : "-"}
<td className={`yearReturn ${signClass(yearlyReturns[year])}`}>
{pct(yearlyReturns[year])}
</td>
</tr>
))}
@@ -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 (
<span className="ruinedBadge" title="资产曾归零,结果不可作为可行策略参考">
爆仓/不可行
</span>
);
}
function displayStrategyName(strategy: Pick<StrategyInfo | StrategyResult, "name" | "label">): string {

View File

@@ -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;

View File

@@ -172,6 +172,7 @@ function resolveScenarios(raw: { scenarios?: Record<string, unknown>; markets?:
label: string;
description?: string;
capital: number;
capitalByMarket?: Record<string, number>;
injectionSchedule: unknown;
markets: Record<string, unknown>;
}>;
@@ -191,6 +192,7 @@ function resolveScenarios(raw: { scenarios?: Record<string, unknown>; markets?:
label: string;
description?: string;
capital: number;
capitalByMarket?: Record<string, number>;
injectionSchedule: unknown;
markets: Record<string, unknown>;
}>;
@@ -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.");

View File

@@ -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<Record<StrategyMarket, number>>;
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<string, number>;
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<string, number>;
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);
}