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 逐类验证归档页)。
This commit is contained in:
Simon
2026-09-20 07:31:04 +08:00
parent 7e15b7251e
commit 23972e7063
112 changed files with 17908 additions and 3893 deletions
+518
View File
@@ -0,0 +1,518 @@
"use client";
/**
* 策略库(/strategies)—— 策略的命名资产中心。
*
* 职责:
* - 列表 + 检索:每个策略显示**一句话说明**与**计算公式**(后端 describe_strategy 推导)。
* - 新建 / 编辑:复用 StrategyParamsForm(与回测页同一份参数模型与校验)。
* - 一键回测:`POST /strategies/{id}/expand`(补 period + 资金)→ `POST /jobs` → 轮询 →
* 就地展示核心指标,并提供「查看实验详情 / 回到回测页看曲线」。
* - 去回测页:`/backtest?strategy={id}`(回测页载入后可保存为策略、可微调)。
*
* 设计取舍:编辑不弹窗而是就地展开表单(页面滚动位置不丢,参数多,弹窗太挤)。
*/
import Link from "next/link";
import { useCallback, useEffect, useMemo, useState } from "react";
import { apiDelete, apiGet, apiPost, apiPut } from "@/lib/api";
import { STAGE_LABEL, submitJob, waitJob, type JobOutcome } from "@/lib/jobs";
import { recentRange } from "@/lib/dates";
import type { BacktestResult, FactorMeta, StrategyDefinition } from "@/lib/types";
import {
Btn,
Banner,
Card,
Empty,
Loading,
PageHeader,
Pill,
Progress,
BacktestMetrics,
Field,
} from "@/components/ui";
import {
StrategyParamsForm,
casePreset,
emptyParams,
paramsFromStrategy,
strategyFromParams,
validateParams,
type StrategyParams,
} from "@/components/StrategyParamsForm";
import { StrategyDocBody, StrategyDocCard } from "@/components/StrategyDocCard";
import { useStrategyDoc, useStrategyDocById } from "@/lib/strategy";
export default function StrategiesPage() {
const [list, setList] = useState<StrategyDefinition[] | null>(null);
const [factors, setFactors] = useState<FactorMeta[]>([]);
const [query, setQuery] = useState("");
const [err, setErr] = useState("");
const [msg, setMsg] = useState("");
const [editing, setEditing] = useState<StrategyParams | null>(null);
const [editingId, setEditingId] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [docId, setDocId] = useState<string | null>(null);
const range = useMemo(() => recentRange(), []);
const load = useCallback(async () => {
try {
const rows = await apiGet<StrategyDefinition[]>("/strategies");
setList(rows);
setErr("");
} catch (e) {
setErr((e as Error).message);
setList([]);
}
}, []);
useEffect(() => {
void load();
let alive = true;
apiGet<FactorMeta[]>("/factors")
.then((f) => alive && setFactors(f))
.catch(() => alive && setFactors([]));
return () => {
alive = false;
};
}, [load]);
const filtered = useMemo(() => {
if (!list) return null;
const q = query.trim().toLowerCase();
if (!q) return list;
return list.filter((s) => {
const hay = [
s.name,
s.description,
(s.factors ?? []).map((f) => f.name).join(" "),
(s.conditions ?? []).map((c) => c.field).join(" "),
]
.join(" ")
.toLowerCase();
return hay.includes(q);
});
}, [list, query]);
/* ---------- 新建 / 编辑 ---------- */
function startCreate() {
setEditingId(null);
setEditing(emptyParams(range));
setDocId(null);
setMsg("");
setErr("");
}
function startEdit(s: StrategyDefinition) {
setEditingId(s.id ?? null);
setEditing(paramsFromStrategy(s, { ...emptyParams(range) }));
setDocId(s.id ?? null);
setMsg("");
setErr("");
}
async function save() {
if (!editing) return;
const errors = validateParams(editing, { requireMeta: true });
if (Object.keys(errors).length) {
setErr(Object.values(errors)[0]);
return;
}
setBusy(true);
setErr("");
try {
const body = strategyFromParams(editing, editingId ?? undefined);
if (editingId) {
await apiPut<StrategyDefinition>(`/strategies/${encodeURIComponent(editingId)}`, body);
setMsg(`已更新策略「${body.name}」`);
} else {
const saved = await apiPost<StrategyDefinition>("/strategies", body);
setMsg(`已保存策略「${saved.name}」(${saved.id})`);
}
setEditing(null);
setEditingId(null);
setDocId(null);
await load();
} catch (e) {
setErr((e as Error).message);
} finally {
setBusy(false);
}
}
async function remove(s: StrategyDefinition) {
if (!s.id) return;
if (!window.confirm(`删除策略「${s.name}」?此操作不可撤销(已跑过的实验不受影响)。`)) return;
setBusy(true);
try {
await apiDelete(`/strategies/${encodeURIComponent(s.id)}`);
setMsg(`已删除策略「${s.name}」`);
await load();
} catch (e) {
setErr((e as Error).message);
} finally {
setBusy(false);
}
}
return (
<>
<PageHeader
title="策略库"
sub="把调好的参数存成命名策略:每个策略都有「一句话说明 + 计算公式 + 执行步骤」,可一键回测、可复现、可对比。"
actions={
<div className="row" style={{ gap: 8 }}>
<Link href="/experiments" className="btn">
<span>查看实验</span>
</Link>
<Btn variant="primary" icon="plus" onClick={startCreate} disabled={busy}>
新建策略
</Btn>
</div>
}
/>
{err ? <Banner tone="error">{err}</Banner> : null}
{msg ? <Banner tone="info">{msg}</Banner> : null}
{/* ---------- 编辑器 ---------- */}
{editing ? (
<Card
icon={editingId ? "edit" : "plus"}
title={editingId ? `编辑策略 · ${editingId}` : "新建策略"}
tools={
<div className="row" style={{ gap: 8 }}>
<Btn onClick={() => { setEditing(null); setEditingId(null); setDocId(null); }} disabled={busy}>
取消
</Btn>
<Btn variant="primary" icon="check" loading={busy} onClick={save} disabled={busy}>
{editingId ? "保存修改" : "保存到策略库"}
</Btn>
</div>
}
>
<StrategyParamsForm
value={editing}
onChange={setEditing}
factorOptions={factors}
showMeta
showPeriod={false}
disabled={busy}
errors={validateParams(editing, { requireMeta: true })}
/>
<div className="hint" style={{ marginTop: 10 }}>
策略只保存「怎么选股/怎么调仓/怎么收费」,**不含回测区间与初始资金** ——
这两项在运行回测时才指定,因此同一策略可用于不同区间的复现与对比。
</div>
<div className="row" style={{ marginTop: 12, gap: 8 }}>
<Btn
icon="target"
disabled={busy}
onClick={() =>
setEditing({
...casePreset(range),
name: editing.name,
description: editing.description,
})
}
>
载入高股息案例默认参数
</Btn>
</div>
</Card>
) : null}
{editing ? (
<EditingDoc params={editing} savedId={editingId} />
) : null}
{docId && !editing ? <SavedDoc id={docId} onClose={() => setDocId(null)} /> : null}
{/* ---------- 列表 ---------- */}
<Card
icon="archive"
title={`已保存策略${list ? ` · ${list.length}` : ""}`}
tools={
<input
className="input"
style={{ width: 220 }}
placeholder="搜索策略名 / 说明 / 因子 / 条件"
value={query}
onChange={(e) => setQuery(e.target.value)}
aria-label="搜索策略"
/>
}
>
{filtered === null ? (
<Loading label="读取策略库…" />
) : filtered.length === 0 ? (
<Empty
icon="archive"
title={list && list.length ? "没有匹配的策略" : "策略库还是空的"}
hint={
list && list.length
? "换个关键词试试。"
: "点右上角「新建策略」保存第一个策略;也可以先去回测页调好参数,再点「保存为策略」。"
}
/>
) : (
<div className="strategy-grid">
{filtered.map((s) => (
<StrategyCard
key={s.id ?? s.name}
s={s}
range={range}
busy={busy}
onEdit={() => startEdit(s)}
onDelete={() => remove(s)}
onDoc={() => setDocId(s.id ?? null)}
/>
))}
</div>
)}
</Card>
</>
);
}
/* ------------------------------------------------------------------ */
/** 未保存参数的实时说明预览 */
function EditingDoc({ params, savedId }: { params: StrategyParams; savedId: string | null }) {
const live = useStrategyDoc(params, !savedId);
const saved = useStrategyDocById(savedId);
const state = savedId ? saved : live;
return (
<StrategyDocCard
doc={state.doc}
loading={state.loading}
error={state.error}
title="策略说明与计算公式(随参数实时更新)"
/>
);
}
function SavedDoc({ id, onClose }: { id: string; onClose: () => void }) {
const state = useStrategyDocById(id);
return (
<Card
icon="book"
title={`策略说明 · ${id}`}
tools={<Btn size="sm" onClick={onClose}>收起</Btn>}
>
{state.doc ? (
<StrategyDocBody doc={state.doc} />
) : (
<div className="hint">
{state.loading
? "正在生成说明与公式…"
: `说明暂不可用${state.error ? `:${state.error}` : "(后端 /api/strategies/{id}/describe 未就绪)"}。`}
</div>
)}
</Card>
);
}
/** 单个策略卡片:说明 + 参数摘要 + 一键回测 */
function StrategyCard({
s,
range,
busy,
onEdit,
onDelete,
onDoc,
}: {
s: StrategyDefinition;
range: { start: string; end: string };
busy: boolean;
onEdit: () => void;
onDelete: () => void;
onDoc: () => void;
}) {
const [start, setStart] = useState("2020-01-01");
const [end, setEnd] = useState(range.end);
const [capital, setCapital] = useState(1_000_000);
const [running, setRunning] = useState(false);
const [jobId, setJobId] = useState("");
const [result, setResult] = useState<BacktestResult | null>(null);
const [stage, setStage] = useState("");
const [expId, setExpId] = useState("");
const [error, setError] = useState("");
const [open, setOpen] = useState(false);
const sel = s.selection ?? {};
const costs = s.costs ?? {};
async function run() {
if (!s.id) return;
if (start >= end) {
setError("开始日期必须早于结束日期");
return;
}
setRunning(true);
setError("");
setResult(null);
setExpId("");
try {
const spec = await apiPost<Record<string, unknown>>(
`/strategies/${encodeURIComponent(s.id)}/expand`,
{ period: [start, end], initial_capital: capital }
);
const { job_id } = await submitJob(spec as never);
setJobId(job_id);
setStage("queued");
const out: JobOutcome<BacktestResult> = await waitJob<BacktestResult>(job_id, 900_000, (info) =>
setStage(info.stage)
);
if (out.status === "success" && out.result) {
setResult(out.result);
setExpId(out.experimentId ?? "");
} else {
setError(`任务${out.status}${out.error ? `:${out.error}` : ""}`);
}
} catch (e) {
setError((e as Error).message);
} finally {
setRunning(false);
setJobId("");
setStage("");
}
}
return (
<div className="card" style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<div className="between">
<div style={{ minWidth: 0 }}>
<div className="card__title" style={{ marginBottom: 2 }}>
{s.name}
</div>
<div className="row" style={{ gap: 6, flexWrap: "wrap" }}>
<span className="mono hint">{s.id}</span>
{s.created_at ? <span className="hint">创建 {String(s.created_at).slice(0, 10)}</span> : null}
</div>
</div>
<Pill tone="violet" icon="target">
{s.factors?.map((f) => f.name).join(" + ") || "—"}
</Pill>
</div>
<div className="strategy-desc">{s.description || "(无说明:建议补一句话说明,便于日后识别)"}</div>
<div className="chips">
<span className="chip">
候选池 <b>{sel.top_n ?? "—"}</b> → 持仓 <b>{sel.hold_top_x ?? sel.top_n ?? "—"}</b>
</span>
<span className="chip">
择股 <b>{s.selection_interval_months ?? "跟随 y"}</b> 月 / 调仓{" "}
<b>{s.rebalance_interval_months ?? "跟随 m"}</b> 月
</span>
<span className="chip">
复权 <b>{adjLabel(s.price_adjustment)}</b>
</span>
<span className="chip">
{s.universe?.exclude_st ? "剔除 ST" : "含 ST"} · 费率{" "}
<b>{((costs.commission_rate ?? 0) * 100).toFixed(3)}%</b> + 印花{" "}
<b>{((costs.stamp_tax_rate ?? 0) * 100).toFixed(2)}%</b>
</span>
{(s.conditions ?? []).length ? (
<span className="chip">
条件 <b>{(s.conditions ?? []).map((c) => `${c.field} ${opLabel(c.op)} ${c.value ?? ""}`).join(" 且 ")}</b>
</span>
) : null}
</div>
<div className="strategy-actions">
<Btn size="sm" variant="primary" icon="play" loading={running} disabled={running || busy} onClick={run}>
{running ? "后台运行中…" : "一键回测"}
</Btn>
<Link href={`/backtest?strategy=${encodeURIComponent(s.id ?? "")}`} className="btn btn--sm">
<span>载入回测页(可微调)</span>
</Link>
<Btn size="sm" icon="book" onClick={onDoc} disabled={busy}>
看说明/公式
</Btn>
<Btn size="sm" icon="edit" onClick={onEdit} disabled={busy}>
编辑
</Btn>
<Btn size="sm" variant="danger" icon="trash" onClick={onDelete} disabled={busy}>
删除
</Btn>
</div>
{/* 运行区间(默认 2020-01-01 ~ 最近交易日) */}
<details open={open} onToggle={(e) => setOpen((e.target as HTMLDetailsElement).open)}>
<summary className="hint" style={{ cursor: "pointer" }}>
回测区间与资金(一键回测使用)
</summary>
<div className="form-grid" style={{ marginTop: 8 }}>
<Field label="开始日期">
<input type="date" className="input" value={start} onChange={(e) => setStart(e.target.value)} disabled={running} />
</Field>
<Field label="结束日期">
<input type="date" className="input" value={end} onChange={(e) => setEnd(e.target.value)} disabled={running} />
</Field>
<Field label="初始资金(元)">
<input
type="number"
className="input"
step={100000}
min={10000}
value={capital}
onChange={(e) => setCapital(Number(e.target.value))}
disabled={running}
/>
</Field>
</div>
</details>
{running ? (
<Progress
value={35}
label={
<>
任务 <span className="mono">{jobId || "排队中…"}</span> 后台执行中(全市场回测通常 1~5 分钟)
</>
}
/>
) : null}
{error ? <Banner tone="error">{error}</Banner> : null}
{result ? (
<div>
<div className="between" style={{ marginBottom: 8 }}>
<Pill tone="pos" icon="check">
回测完成
</Pill>
<Link
href={expId ? `/experiments?exp=${encodeURIComponent(expId)}` : "/experiments"}
className="btn btn--sm"
>
<span>在实验中对比</span>
</Link>
{expId ? (
<>
<Link href={`/experiments/${encodeURIComponent(expId)}`} className="btn btn--sm">
<span>打开归档(完整快照)</span>
</Link>
<Link href={`/backtest?from_experiment=${encodeURIComponent(expId)}`} className="btn btn--sm">
<span>以此参数再跑</span>
</Link>
</>
) : null}
</div>
<BacktestMetrics s={result.summary} />
</div>
) : null}
</div>
);
}
function adjLabel(m?: string): string {
return m === "hfq" ? "后复权" : m === "qfq" ? "前复权" : "不复权";
}
function opLabel(op: string): string {
return (
{ gt: ">", gte: "≥", lt: "<", lte: "≤", eq: "=", ne: "≠", in: "属于", not_in: "不属于" }[op] ?? op
);
}