Files
qlib/frontend/web/app/strategies/page.tsx
T
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

519 lines
17 KiB
TypeScript
Raw 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";
/**
* 策略库(/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
);
}