Files
Simon 23972e7063 feat: 股息率案例口径 + 策略库与图表统一 + 回测存档完整化
汇总三轮未提交的开发(每轮均在本机 MariaDB + 真实浏览器上验证):

1) 股息率案例(全市场股息率最高 n 只,默认 20,每 m 月择股)
   - 新增日频估值表 daily_basic + 迁移;股息率因子(dv_ratio / dividend_yield / TTM)
   - 名称历史表 stock_name_history:剔除 ST 按**择股日当时名称**判定,消除
     「曾高股息后 ST」的股息陷阱(实测 3.70pp 偏差)
   - 区间择股/调仓双周期(m 择股 / y 调仓)、指数成分与白名单、停牌近似剔除
   - 复权因子口径核对(4,164,742 行、缺失 0.0%)、收盘价成交与涨跌停拦单
   - 案例实测:2020-01-01~2026-09-04 总收益 +24.86%(年化 3.52%、回撤 -28.58%)

2) 策略库与前端统一
   - strategy 表 + CRUD/PUT 原地更新 + `describe_strategy` 按 spec 真实推导
     「一句话说明 + 计算公式 + 执行步骤 + 注意事项」(与引擎实执行规则同源)
   - 任何出现股票代码处都成对显示名称且可点击进个股页
   - 全站图表基座统一 TradingView Lightweight Charts(ECharts 依赖、
     锁文件、组件与文档标注一并清除),买卖点标记只落在真实交易日上

3) 回测存档完整化(可往复查看)
   - 同步端点(POST /api/backtests、/api/factor-tests)此前完全不落库 → 现在同样归档,
     归档 id 经响应头 X-Experiment-Id 返回(不破坏 response_model)
   - data_version 首次真实写入(数据快照指纹:最新交易日 + 各表规模)
   - 个股收益曲线默认**全量保存**(此前硬截断 60 只);超出体积预算才裁剪,
     并写 archive_meta(机器可读)+ unimplemented(人可读)如实标注
   - 列表 kind/q 过滤 + X-Total-Count(此前 limit=50 静默截断)、DELETE 归档
   - 只读归档页 /experiments/{id}(Server Component,SSR 直出**选股条件**与
     **交易执行依据**);结果视图按 kind 分发(backtest/factor_test/selection),
     非回测归档不套用回测口径
   - 新增 CLI:prune_experiments(保留策略,默认 dry-run)、
     restore_experiment_from_job(从 Job 副本按原 id 重建被删的历史归档,默认 dry-run)

门禁:pytest 388 passed、ruff All checks passed、tsc 0 错误、图表单测 7 passed、
next build 成功、契约脚本 verify_strategy_workspace 59/59(含按 kind 逐类验证归档页)。
2026-09-20 07:31:04 +08:00

222 lines
8.6 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
/**
* 归档结果的**按类型分发**视图:归档页只依赖这一个入口,不在页面里猜结果结构。
*
* 为什么必须存在(真实缺陷驱动):归档详情页原先在 `result` 非空时**无条件**渲染
* `BacktestResultView`,而 `factor_test` / `selection` 归档的结果结构与回测完全不同
* (没有 `summary` / `equity_curve` / `positions`)。实测后果:库里 4 条因子测试归档
* 与 1 条选股归档,从列表点「打开归档」**整页 500**(SSR 阶段 `result.positions.at(-1)`
* 抛 TypeError,且没有 error.tsx 兜底)——列表对每一行都给链接,所以这是必现路径。
*
* 现在按后端 `kind` 显式分发:
* - `backtest` → `BacktestResultView`(净值/回撤/个股曲线/持仓/成交/月度年度/未建模)
* - `factor_test` → IC / RankIC / ICIR / 正收益占比 / 样本日数 + **分层收益柱状图**
* - `selection` → 选股统计 + 候选明细表(代码**带名称**且可点击进个股页)
* - 其它未知类型 → 如实说明「暂不支持预览」并引导导出 JSON,**不假装能渲染**
*
* 纯派生逻辑(字段计数)不在这里,而在 `lib/archive.ts` —— Server Component 不能调用
* `"use client"` 模块导出的普通函数,放错位置会让归档页整页 500(已实测踩过)。
*
* 与 `/factors`、`/selection` 页面各自的渲染器并存是刻意的:那两个页面带交互
* (运行、直通回测、导出),归档页是**只读快照**,两类用途的文案与动作不同。
*/
import type { BacktestResult, FactorTestReport, SelectionResult } from "@/lib/types";
import type { AnyArchiveResult } from "@/lib/archive";
import { BacktestResultView } from "@/components/BacktestResultView";
import { RichText } from "@/components/RichText";
import { Card, Empty, Metric, UnimplementedNote } from "@/components/ui";
import { SymbolLink } from "@/lib/symbols";
export interface ArchiveInfo {
id: string;
created_at?: string | null;
code_version?: string | null;
data_version?: string | null;
job_id?: string | null;
}
export function ArchiveResultView({
kind,
result,
archive,
}: {
kind: string;
result: AnyArchiveResult;
archive: ArchiveInfo;
}) {
if (kind === "backtest") {
return <BacktestResultView result={result as BacktestResult} archive={archive} />;
}
if (kind === "factor_test") {
return <FactorReportView report={result as FactorTestReport} />;
}
if (kind === "selection") {
return <SelectionSnapshotView result={result as SelectionResult} />;
}
return (
<Card icon="archive" title={`归档结果(类型:${kind})`}>
<Empty
icon="info"
title="暂不支持预览该类型的归档结果"
hint={`归档类型「${kind}」没有对应的展示视图。结果本身已完整存档,可用上方「导出完整 JSON」查看原始内容 —— 这里不猜测结构、也不假装能渲染。`}
/>
</Card>
);
}
/* ------------------------------------------------------------------ */
function FactorReportView({ report }: { report: FactorTestReport }) {
const qs = report.quantile_returns ?? [];
const maxAbs = Math.max(0.001, ...qs.map((q) => Math.abs(q.return_pct)));
return (
<div className="stack" style={{ gap: 16 }}>
<Card
icon="chartLine"
title={`因子测试结果(${report.factor_name})`}
tools={<span className="hint">归档快照,只读</span>}
>
<div className="metric-grid" style={{ marginBottom: 0 }}>
<Metric
label="IC 均值"
icon="chartLine"
value={report.ic_mean.toFixed(4)}
tone={report.ic_mean >= 0 ? "pos" : "neg"}
/>
<Metric
label="RankIC 均值"
value={report.rank_ic_mean.toFixed(4)}
tone={report.rank_ic_mean >= 0 ? "pos" : "neg"}
/>
<Metric
label="ICIR"
value={report.icir.toFixed(3)}
tone={report.icir >= 1 ? "pos" : "plain"}
sub="越大越稳定"
/>
<Metric
label="正收益占比"
value={`${report.positive_ratio_pct.toFixed(1)}%`}
tone={report.positive_ratio_pct >= 50 ? "pos" : "warn"}
/>
<Metric label="样本日数" value={report.sample_days} icon="calendar" />
</div>
{qs.length > 0 ? (
<div style={{ marginTop: 14 }}>
<div className="field__label" style={{ marginBottom: 2 }}>
分层表现(Q1 最低因子值 → Q{qs.length} 最高)
</div>
<div className="vbars" role="img" aria-label="分层收益柱状图">
{qs.map((q) => {
const v = q.return_pct;
const h = Math.max(4, Math.round((Math.abs(v) / maxAbs) * 72));
return (
<div className="vbars__item" key={q.quantile}>
<span className="vbars__val">
{v >= 0 ? "+" : ""}
{v.toFixed(2)}%
</span>
<div
className={`vbars__bar ${v >= 0 ? "is-pos" : "is-neg"}`}
style={{ height: h }}
/>
<span className="vbars__cap">Q{q.quantile + 1}</span>
</div>
);
})}
</div>
</div>
) : null}
<div className="hint" style={{ marginTop: 12 }}>
<RichText text="读数:IC / RankIC 为正表示与未来收益正相关,ICIR 越大越稳定;分层收益若高分层显著高于低分层说明单调性好。**单因子测试 ≠ 策略有效**,需结合样本外与稳健性分析。" />
</div>
</Card>
<UnimplementedNote items={report.unimplemented ?? []} />
</div>
);
}
function SelectionSnapshotView({ result }: { result: SelectionResult }) {
const c = result.candidates ?? [];
return (
<div className="stack" style={{ gap: 16 }}>
<Card
icon="grid"
title={`选股结果(as_of ${result.as_of_date},方式 ${result.method === "score" ? "因子评分" : "条件筛选"})`}
tools={<span className="hint">归档快照,只读</span>}
>
<div className="metric-grid" style={{ marginBottom: 0 }}>
<Metric
label="股票池"
icon="database"
value={result.statistics?.universe_size ?? 0}
/>
<Metric
label="有效评分"
icon="chartLine"
value={result.statistics?.evaluated ?? 0}
/>
<Metric
label="选出"
icon="check"
value={result.statistics?.selected ?? 0}
tone="accent"
/>
<Metric label="候选明细" icon="grid" value={c.length} />
</div>
{c.length > 0 ? (
<div className="table-wrap" style={{ marginTop: 12 }}>
<table className="tbl">
<thead>
<tr>
<th>#</th>
<th>代码 / 名称</th>
<th>得分</th>
<th>因子值</th>
<th>入选理由</th>
</tr>
</thead>
<tbody>
{c.map((row) => (
<tr key={row.symbol}>
<td className="mono dim">{row.rank}</td>
<td>
<SymbolLink symbol={row.symbol} name={row.name ?? undefined} />
</td>
<td className="mono">{row.score.toFixed(4)}</td>
<td style={{ fontSize: 12 }}>
{Object.entries(row.factor_values ?? {})
.map(([k, v]) =>
`${k}=${typeof v === "number" ? v.toFixed(4) : v}`
)
.join(" ")}
</td>
<td>
<div className="chips">
{(row.selection_reason ?? []).map((r) => (
<span className="chip" key={r}>
{r.slice(0, 40)}
</span>
))}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="hint" style={{ marginTop: 12 }}>
该归档没有候选明细(结果为空或历史版本)。
</div>
)}
</Card>
<UnimplementedNote items={result.unimplemented ?? []} />
</div>
);
}