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
@@ -0,0 +1,99 @@
"use client";
/**
* 归档页的**交互动作**(数据在服务端渲染,这里只管「页面之外」的动作)。
*
* 拆出来的原因:归档详情页改为 Server Component(只读内容必须能被 SSR 直出,
* 否则不带 JS 的抓取/分享预览看不到「选股条件」与「交易执行依据」);
* 而删除、导出、折叠 spec 这些必须有事件处理,就集中放在这个客户端组件里。
*
* 删除的确认文案必须写清后果:结果只存归档一份(`job.result_json` 对新记录为 NULL,
* `GET /api/jobs/{id}` 是从归档回读的),所以删归档 = 该次回测结果彻底消失。
* 先前的文案写成「不影响作业记录」,那是**误导**。
*/
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { apiDelete, apiGetText } from "@/lib/api";
import { Banner, Btn, Card } from "@/components/ui";
/**
* 刻意只收 `id` / `spec`(**不接收完整 result**):归档结果可达数 MB,而服务端已经把它
* 传给图表组件了;再传一份给本组件会让 RSC 载荷翻倍。导出时按需拉取。
*/
export function ArchiveActions({ id, spec }: { id: string; spec: unknown }) {
const router = useRouter();
const [deleting, setDeleting] = useState(false);
const [error, setError] = useState("");
const [showSpec, setShowSpec] = useState(false);
async function downloadJson() {
setError("");
try {
const text = await apiGetText(`/experiments/${encodeURIComponent(id)}`);
const blob = new Blob([text], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${id}-archive.json`;
a.click();
URL.revokeObjectURL(url);
} catch (e) {
setError(`导出失败:${(e as Error).message}`);
}
}
async function remove() {
const ok = window.confirm(
`删除归档 ${id}?\n\n` +
`· 删除后这份快照(结果 + spec)无法再查看,也不可恢复;\n` +
`· 结果只存归档这一份 —— 删除后连该次执行的作业记录也读不回结果\n` +
` (完整存档上线前的早期归档在作业表里另有副本,但不应依赖);\n` +
`· 想留底请先点左侧「导出完整 JSON」。\n\n确认删除?`
);
if (!ok) return;
setDeleting(true);
setError("");
try {
await apiDelete(`/experiments/${encodeURIComponent(id)}`);
router.push("/experiments");
} catch (e) {
setError((e as Error).message);
setDeleting(false);
}
}
return (
<>
{error ? <Banner tone="error">{error}</Banner> : null}
<div className="row" style={{ gap: 8, marginBottom: 12, flexWrap: "wrap" }}>
<Link href="/experiments" className="btn">
<span>实验对比</span>
</Link>
<Link href={`/experiments?exp=${encodeURIComponent(id)}`} className="btn">
<span>加入对比</span>
</Link>
<Link
href={`/backtest?from_experiment=${encodeURIComponent(id)}`}
className="btn btn--primary"
title="在回测页复用这份归档的参数(并直接载入该归档的结果),可微调后重跑"
>
<span>以此参数再跑</span>
</Link>
<Btn size="sm" icon="download" onClick={downloadJson}>
导出完整 JSON
</Btn>
<Btn size="sm" icon="book" onClick={() => setShowSpec((v) => !v)}>
{showSpec ? "收起原始 spec" : "查看原始 spec(JSON)"}
</Btn>
<Btn size="sm" icon="trash" variant="danger" loading={deleting} disabled={deleting} onClick={remove}>
删除归档
</Btn>
</div>
{showSpec ? (
<Card title="归档 spec(JSON 原文,复现依据)">
<pre className="mono codeblock">{JSON.stringify(spec ?? {}, null, 2)}</pre>
</Card>
) : null}
</>
);
}
@@ -0,0 +1,222 @@
"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>
);
}
@@ -0,0 +1,656 @@
"use client";
/**
* 回测结果视图(**回测页**与**实验归档页**共用同一组件)。
*
* 共用是刻意的:归档要能被「往复查看」,就必须和刚跑完时看到的形态完全一致——
* 两处各写一套迟早会漂移,导致「归档里少了一张图/多了一列」这种隐性不一致。
*
* 三处模式差异用 props 表达:
* - `name`:策略名(运行时的策略);归档页传归档里记录的策略名(若有)。
* - `archive`:传入后额外展示归档元数据(实验 id / 数据版本 / 代码版本 / 归档时间)
* 与**归档完整度**(`result.archive_meta.truncated` 为真时显式提示曲线被裁剪,不静默)。
*/
import { useMemo, useRef, useState } from "react";
import {
Card,
Pill,
Btn,
Empty,
BacktestMetrics,
MonthlyReturnsTable,
UnimplementedNote,
} from "@/components/ui";
import { LwChart, type LwMarker, type LwSeries } from "@/components/charts/LwChart";
import { CHART, fmtNum, fmtPct } from "@/components/charts/theme";
import Link from "next/link";
import { SymbolLink, useSymbolNames } from "@/lib/symbols";
import { adjustLabel } from "@/lib/labels";
import type { ActionRecord, BacktestResult, SymbolCurve } from "@/lib/types";
export interface ArchiveInfo {
id: string;
created_at?: string | null;
code_version?: string | null;
data_version?: string | null;
job_id?: string | null;
}
function pad(n: number): string {
return String(n).padStart(2, "0");
}
function fmtDateTime(v?: string | null): string {
if (!v) return "—";
const d = new Date(v);
if (Number.isNaN(d.getTime())) return v;
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
export function BacktestResultView({
result,
name,
archive,
}: {
result: BacktestResult;
name?: string;
archive?: ArchiveInfo;
}) {
const s = result.summary;
const last = result.positions.at(-1);
const holdings = result.positions.filter((p) => p.date === last?.date);
const curves = result.symbol_curves ?? [];
const [active, setActive] = useState<string>(curves[0]?.symbol ?? "");
const [curveQuery, setCurveQuery] = useState("");
// 老实验(后端填充 name 之前归档的结果)里 curve.name 为空:
// 名称统一从 useSymbolNames 缓存取,保证「有代码必有名称」在任何历史结果上都成立
const { names: nameCache } = useSymbolNames();
const nameOf = (c: SymbolCurve) => c.name ?? nameCache[c.symbol] ?? "";
const topRef = useRef<HTMLDivElement | null>(null);
// 组合净值上的买卖点:同一日的成交合并成一个标记,落在当日净值上
const equitySeries = useMemo<LwSeries[]>(
() => [
{
key: "equity",
label: "组合净值(元)",
type: "area",
color: CHART.pos,
data: result.equity_curve.map((p) => ({ time: p.date, value: p.value })),
lastValueVisible: true,
},
],
[result.equity_curve]
);
const equityMarkers = useMemo<LwMarker[]>(() => {
const byDate = new Map<string, { BUY: boolean; SELL: boolean }>();
for (const f of result.fills ?? []) {
const cur = byDate.get(f.date) ?? { BUY: false, SELL: false };
cur[f.signal] = true;
byDate.set(f.date, cur);
}
const equity = new Set(result.equity_curve.map((p) => p.date));
const out: LwMarker[] = [];
for (const [d, kinds] of byDate) {
if (!equity.has(d)) continue;
if (kinds.BUY) out.push({ time: d, kind: "BUY", text: "买" });
if (kinds.SELL) out.push({ time: d, kind: "SELL", text: "卖" });
}
return out;
}, [result.fills, result.equity_curve]);
const filteredCurves = useMemo(() => {
const q = curveQuery.trim().toLowerCase();
if (!q) return curves;
return curves.filter(
(c) => c.symbol.toLowerCase().includes(q) || nameOf(c).toLowerCase().includes(q)
);
// nameOf 只依赖 nameCache 与 c,二者都在依赖里
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [curves, curveQuery, nameCache]);
const activeCurve = curves.find((c) => c.symbol === active) ?? filteredCurves[0] ?? curves[0];
const buyDays = equityMarkers.filter((m) => m.kind === "BUY").length;
const sellDays = equityMarkers.filter((m) => m.kind === "SELL").length;
const SECTIONS = [
{ id: "sec-equity", label: "整体收益" },
{ id: "sec-symbols", label: "个股曲线" },
{ id: "sec-monthly", label: "月度/年度" },
{ id: "sec-holdings", label: "持仓" },
{ id: "sec-trades", label: "成交明细" },
];
return (
<>
<div ref={topRef} />
<div className="sticky-bar" style={{ gap: 10 }}>
<b style={{ fontSize: 13 }}>结果分区</b>
{SECTIONS.map((s) => (
<a key={s.id} href={`#${s.id}`} className="btn btn--sm">
<span>{s.label}</span>
</a>
))}
<span className="hint">共 {result.equity_curve.length} 个交易日 · {result.trades.length} 笔成交</span>
</div>
{archive ? <ArchiveMetaBar archive={archive} /> : null}
<ArchiveCompletenessNote result={result} archive={archive} />
<div className="between" style={{ margin: "6px 0 14px" }}>
<div className="row" style={{ flexWrap: "wrap" }}>
<Pill tone="pos" icon="check">
完成
</Pill>
{name ? (
<Pill tone="violet" icon="book">
{name}
</Pill>
) : null}
<Pill tone="violet" icon="target">
{poolLabel(result)}
</Pill>
<Pill>{adjustLabelFromSnapshot(result.config_snapshot)} 口径</Pill>
{stBasisNote(result) && (
<Pill tone="warn" icon="alert">
{stBasisNote(result)}
</Pill>
)}
<Pill>
{s.start} ~ {s.end}
</Pill>
</div>
<div className="row" style={{ fontSize: 22, fontWeight: 700 }}>
总收益 <SignedText value={s.total_return_pct} />
</div>
</div>
<BacktestMetrics s={s} />
<div className="chart-grid" id="sec-equity">
<Card
icon="chartLine"
title="整体收益趋势(含买卖点)"
tools={
<Pill tone="pos">
期末 {fmtNum(s.final_equity)} · 买入 {buyDays} 日 / 卖出 {sellDays} 日
</Pill>
}
>
<LwChart
series={equitySeries}
markers={equityMarkers}
height={320}
valueFormat={(v) => fmtNum(v)}
ariaLabel="组合净值曲线与买卖点"
/>
<div className="hint" style={{ marginTop: 6 }}>
▲ 绿 = 当日有买入成交,▼ 红 = 当日有卖出成交;点位取当日组合净值。
悬停可看任意日期的净值;拖动/滚轮可缩放区间,双击图例可临时隐藏曲线。
</div>
</Card>
<Card
icon="chartLine"
title="回撤(%)"
tools={<Pill tone="neg">最大 {s.max_drawdown_pct.toFixed(2)}%</Pill>}
>
<LwChart
series={[
{
key: "dd",
label: "回撤(%)",
type: "area",
color: CHART.neg,
data: result.drawdown.map((p) => ({ time: p.date, value: p.value })),
lastValueVisible: true,
},
]}
height={320}
valueFormat={(v) => `${v.toFixed(2)}%`}
zeroLine
ariaLabel="回撤曲线"
/>
</Card>
</div>
<Card
id="sec-symbols"
icon="target"
title="个股收益率趋势(持仓期累计收益,含买卖点)"
tools={
<div className="row" style={{ gap: 8 }}>
<input
className="input"
style={{ width: 180 }}
placeholder="搜索代码或名称"
value={curveQuery}
onChange={(e) => setCurveQuery(e.target.value)}
aria-label="搜索个股"
/>
<Pill>{curves.length} 只有成交</Pill>
</div>
}
>
{curves.length === 0 ? (
<Empty title="无个股曲线" hint="回测区间内没有产生成交。" />
) : (
<>
<div className="row" style={{ gap: 8, marginBottom: 10, flexWrap: "wrap" }}>
<select
className="input"
style={{ width: 280 }}
value={activeCurve?.symbol ?? ""}
onChange={(e) => setActive(e.target.value)}
>
{filteredCurves.map((c) => {
const nm = nameOf(c);
return (
<option key={c.symbol} value={c.symbol}>
{c.symbol} {nm || "—"}({fmtPct(c.final_return_pct)})
</option>
);
})}
</select>
<SymbolLink
symbol={activeCurve?.symbol ?? ""}
name={activeCurve ? nameOf(activeCurve) : undefined}
/>
<span className="hint">
曲线口径:该股被持有期间按日复利累计(建仓当日为 0%);未持有期间不绘制,
分段间以直线连接,请以买卖点区分持仓区间。
</span>
</div>
{activeCurve ? <SymbolCurveChart curve={activeCurve} /> : null}
<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>
{filteredCurves.map((c) => (
<tr key={c.symbol} className={c.symbol === activeCurve?.symbol ? "row-active" : ""}>
<td className="sym-cell">
<SymbolLink symbol={c.symbol} name={c.name} />
</td>
<td className={c.final_return_pct >= 0 ? "tone-pos" : "tone-neg"}>
{c.final_return_pct.toFixed(2)}%
</td>
<td className="mono dim">
{(c.marks ?? []).filter((m) => m.signal === "BUY").length}
</td>
<td className="mono dim">
{(c.marks ?? []).filter((m) => m.signal === "SELL").length}
</td>
<td>
<Btn size="sm" onClick={() => setActive(c.symbol)}>
查看曲线
</Btn>
</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
</Card>
<Card id="sec-monthly" icon="calendar" title="月度收益(%)">
<LwChart
series={[
{
key: "monthly",
label: "月度收益(%)",
type: "bar",
color: CHART.accent,
data: result.monthly_returns.map((m) => ({
time: `${m.year}-${String(m.month).padStart(2, "0")}-01`,
value: m.return_pct,
})),
},
]}
height={200}
valueFormat={(v) => `${v.toFixed(2)}%`}
zeroLine
legend={false}
ariaLabel="月度收益柱状图"
/>
<details style={{ marginTop: 8 }}>
<summary className="hint" style={{ cursor: "pointer" }}>
查看月度收益明细表
</summary>
<MonthlyReturnsTable rows={result.monthly_returns} />
</details>
</Card>
<Card
id="sec-holdings"
icon="target"
title={`最新持仓 · ${last?.date ?? "—"}`}
tools={<Pill>{holdings.length} 只</Pill>}
>
{holdings.length === 0 ? (
<Empty title="无持仓记录" hint="回测区间内没有产生持仓。" />
) : (
<div className="table-wrap">
<table className="tbl">
<thead>
<tr>
<th>股票</th>
<th>权重</th>
</tr>
</thead>
<tbody>
{holdings.map((p) => (
<tr key={p.symbol}>
<td className="sym-cell">
<SymbolLink symbol={p.symbol} name={p.name} />
</td>
<td className="mono">{(p.weight * 100).toFixed(2)}%</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Card>
{result.yearly_returns.length ? (
<Card
icon="calendar"
title="年度收益(%)"
tools={
<Pill>
均值{" "}
{(
result.yearly_returns.reduce((a, y) => a + y.return_pct, 0) /
result.yearly_returns.length
).toFixed(2)}
%
</Pill>
}
>
<div className="chips">
{result.yearly_returns.map((y) => (
<span className="chip" key={y.year}>
<b>{y.year}</b>
<span className={y.return_pct >= 0 ? "tone-pos" : "tone-neg"}>
{y.return_pct.toFixed(2)}%
</span>
</span>
))}
</div>
</Card>
) : null}
{result.trades.length ? (
<Card
id="sec-trades"
icon="scale"
title={`成交明细 · ${result.trades.length} 笔`}
tools={<Pill>{result.turnover_pct.toFixed(2)}% 累计换手</Pill>}
>
<div className="table-wrap">
<table className="tbl">
<thead>
<tr>
<th>买入日</th>
<th>卖出日</th>
<th>股票</th>
<th>买价</th>
<th>卖价</th>
<th>收益</th>
</tr>
</thead>
<tbody>
{result.trades.map((t) => (
<tr key={`${t.symbol}-${t.entry_date}-${t.exit_date}`}>
<td className="mono dim">{t.entry_date}</td>
<td className="mono dim">{t.exit_date}</td>
<td className="sym-cell">
<SymbolLink symbol={t.symbol} name={t.name} />
</td>
<td className="mono">{t.entry_price?.toFixed(2) ?? "-"}</td>
<td className="mono">{t.exit_price?.toFixed(2) ?? "-"}</td>
<td className={t.return_pct >= 0 ? "tone-pos" : "tone-neg"}>
{t.return_pct.toFixed(2)}%
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
) : null}
<NotFilledCard signals={result.signal_history ?? []} />
<UnimplementedNote items={result.unimplemented} />
</>
);
}
/** 未成交意图(涨停/停牌/顺延)透明化,避免「信号有了却没买」无法解释。 */
function NotFilledCard({ signals }: { signals: ActionRecord[] }) {
const rejects = signals.filter((a) => !a.filled && a.reject_reason);
if (rejects.length === 0) return null;
const byReason = new Map<string, number>();
for (const r of rejects) {
const key = (r.reject_reason ?? "").replace(/,?顺延.*$/, "(顺延)");
byReason.set(key, (byReason.get(key) ?? 0) + 1);
}
return (
<Card icon="info" title={`未成交意图 · ${rejects.length} 条`}>
<div className="chips">
{[...byReason.entries()].map(([reason, n]) => (
<span className="chip" key={reason}>
{reason} <b>{n}</b>
</span>
))}
</div>
<div className="hint" style={{ marginTop: 8 }}>
涨停/停牌导致的未成交按「信号已记录、成交未发生」处理(v3 §20.3):顺延买入会在之后首个
可成交交易日按收盘价成交;到下一次调仓仍未成交则作废。
</div>
</Card>
);
}
function SymbolCurveChart({ curve }: { curve: SymbolCurve }) {
const valueByDate = useMemo(() => new Map(curve.points.map((p) => [p.date, p.value])), [curve]);
const markers = useMemo<LwMarker[]>(
() =>
(curve.marks ?? [])
.filter((a) => valueByDate.has(a.date))
.map((a) => ({
time: a.date,
kind: a.signal,
text: a.signal === "BUY" ? "买" : "卖",
})),
[curve, valueByDate]
);
return (
<LwChart
series={[
{
key: "sym",
label: `${curve.symbol} 持仓期累计收益(%)`,
type: "area",
color: CHART.accent,
data: curve.points.map((p) => ({ time: p.date, value: p.value })),
lastValueVisible: true,
},
]}
markers={markers}
height={300}
valueFormat={(v) => `${v.toFixed(2)}%`}
zeroLine
ariaLabel={`${curve.symbol} 持仓期收益曲线与买卖点`}
/>
);
}
function poolLabel(result: BacktestResult): string {
const sel = result.config_snapshot?.selection as
| { top_n?: number; hold_top_x?: number | null }
| undefined;
if (!sel) return "候选池 —";
return `候选池 ${sel.top_n ?? "—"} → 持仓 ${sel.hold_top_x ?? sel.top_n ?? "—"}`;
}
/**
* ST(风险警示)剔除口径提示。
*
* 后端逐择股日按**当时名称**判定 exclude_st(依赖 `sync namechange`);未同步名称历史时
* 回退「最新名称快照」,会把「曾为高股息、后来才 ST/退市」的股息陷阱样本整段排除,
* 收益被高估(实测案例 +24.86% → +35.71%)。此处把口径显式暴露给使用者,避免误读。
*/
function stBasisNote(result: BacktestResult): string | null {
const basis = result.config_snapshot?.price_basis as
| { name_basis?: { point_in_time?: boolean } | null }
| undefined;
const nb = basis?.name_basis;
if (!nb) return null; // 未启用 exclude_st
return nb.point_in_time ? "ST 按时点名称判定" : "ST 用最新名称(可能高估)";
}
function adjustLabelFromSnapshot(snapshot: Record<string, unknown>): string {
const basis = snapshot?.price_basis as { adjust_mode?: string } | undefined;
const mode = basis?.adjust_mode ?? "none";
return mode === "hfq" ? "后复权" : mode === "qfq" ? "前复权" : "不复权";
}
function SignedText({ value }: { value: number }) {
const cls = value >= 0 ? "tone-pos" : "tone-neg";
return (
<span className={cls}>
{value > 0 ? "+" : ""}
{value.toFixed(2)}%
</span>
);
}
/**
* 归档元数据条:**归档能否被信任地复现,取决于这几项**,所以放在最显眼的位置。
* - 代码版本:跑出这个结果时的 git short rev
* - 数据版本:当时的数据快照指纹(由后端在归档时写入;老归档可能为空 → 如实显示「—」)
* - 实验 / 作业 id:可追到执行记录
*/
function ArchiveMetaBar({ archive }: { archive: ArchiveInfo }) {
return (
<div className="archive-bar">
<Pill tone="violet" icon="archive">
归档 {archive.id}
</Pill>
<span className="hint">
归档时间 <b className="mono">{fmtDateTime(archive.created_at)}</b> · 代码版本{" "}
<b className="mono">{archive.code_version ?? "—"}</b> · 数据版本{" "}
<b className="mono">{archive.data_version ?? "未记录"}</b>
{archive.job_id ? (
<>
{" "}
· 作业 <b className="mono">{archive.job_id}</b>
</>
) : null}
</span>
</div>
);
}
/**
* 归档完整度提示。
*
* `archive_meta.truncated` 为真表示:归档时结果 JSON 超过安全预算,个股曲线被裁剪
* (后端会在 `unimplemented` 里同时留下说明)。**必须显式告知**,否则用户会以为
* 「这只股票没被买过」,而实际只是没存下来。
*/
function ArchiveCompletenessNote({
result,
archive,
}: {
result: BacktestResult;
archive?: ArchiveInfo;
}) {
const meta = result.archive_meta as
| { curves_stored?: number; curves_total?: number; truncated?: boolean; budget_chars?: number }
| undefined;
const legacyNote = (result.unimplemented ?? []).find((u) => u.includes("个股收益曲线"));
// 情况 1:归档带完整度元数据(本轮之后产生的归档)
if (meta && meta.curves_total !== undefined) {
const total = meta.curves_total ?? 0;
const stored = meta.curves_stored ?? 0;
return (
<div className="archive-bar">
{meta.truncated ? (
<Pill tone="warn" icon="alert">
归档不完整
</Pill>
) : (
<Pill tone="pos" icon="check">
归档完整
</Pill>
)}
<span className="hint">
个股曲线已存 <b className="mono">{stored}</b> / 期内持有 <b className="mono">{total}</b> 只
{meta.truncated
? "(超出结果体积预算被裁剪:请在下方用「成交明细 / 未成交意图」查缺失个股,或缩短区间后重跑以得到完整曲线)"
: "(全部持有过的股票都可在此查看)"}
</span>
</div>
);
}
// 情况 2:老归档没有元数据,但结果里留了截断说明 → **不能装作完整**
if (legacyNote) {
const held = /期内共持有\s*(\d+)\s*只/.exec(legacyNote)?.[1];
const kept = /最大的\s*(\d+)\s*只/.exec(legacyNote)?.[1];
return (
<div className="archive-bar">
<Pill tone="warn" icon="alert">
归档不完整
</Pill>
<span className="hint">
该归档产生于「完整存档」上线之前:个股曲线只有 <b className="mono">{kept ?? "60"}</b> 只(期内共持有{" "}
<b className="mono">{held ?? "更多"}</b> 只),换手但收益排名不靠前的个股在这里看不到曲线。
成交明细 / 未成交意图仍是完整的。要看全部个股曲线,请
{archive ? (
<>
{" "}
<Link href={`/backtest?from_experiment=${encodeURIComponent(archive.id)}`} className="btn btn--sm">
<span>以此参数重跑</span>
</Link>{" "}
生成一份完整归档。
</>
) : (
"以此参数重跑一次(新归档将保存全部持有过的个股曲线)。"
)}
</span>
</div>
);
}
// 情况 3:既无元数据也无截断说明 → 无从判断,不猜(只在老归档上出现)
if (archive) {
return (
<div className="archive-bar">
<Pill icon="info">
完整度未标注
</Pill>
<span className="hint">
该归档早于「归档完整度元数据」上线,无法确认个股曲线是否被裁剪过
(成交明细 / 未成交意图始终是完整的)。
</span>
</div>
);
}
return null;
}
-117
View File
@@ -1,117 +0,0 @@
"use client";
/** 基于 ECharts 的折线图(净值 / 回撤),按需引入模块以压缩 bundle。 */
import { useEffect, useRef } from "react";
import * as echarts from "echarts/core";
import { LineChart as EChartsLine } from "echarts/charts";
import { GridComponent, TitleComponent, TooltipComponent } from "echarts/components";
import { CanvasRenderer } from "echarts/renderers";
echarts.use([EChartsLine, GridComponent, TitleComponent, TooltipComponent, CanvasRenderer]);
export interface XY {
date: string;
value: number;
}
const C = {
text: "#a8b3c9",
faint: "#8b98b2",
line: "rgba(150,165,195,0.13)",
tooltipBg: "#141d30",
tooltipBorder: "rgba(150,165,195,0.28)",
};
export function LineChart({
title,
data,
color = "#3fb6ff",
fill = false,
height = 320,
yFmt,
xFmt,
}: {
title?: string;
data: XY[];
color?: string;
/** 线下渐变面积(净值类曲线推荐开启) */
fill?: boolean;
height?: number;
yFmt?: (v: number) => string;
xFmt?: (v: string) => string;
}) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!ref.current || data.length === 0) return;
const chart = echarts.init(ref.current);
const areaColor = {
type: "linear",
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: color + "3d" },
{ offset: 1, color: color + "00" },
],
};
chart.setOption({
backgroundColor: "transparent",
title: title
? {
text: title,
textStyle: { color: "#e9eef7", fontSize: 13, fontWeight: 600 },
}
: undefined,
tooltip: {
trigger: "axis",
backgroundColor: C.tooltipBg,
borderColor: C.tooltipBorder,
textStyle: { color: "#e9eef7", fontSize: 12 },
valueFormatter: (v: unknown) =>
typeof v === "number" ? (yFmt ? yFmt(v) : String(v)) : String(v),
},
grid: { left: 8, right: 14, top: title ? 42 : 18, bottom: 8, containLabel: true },
xAxis: {
type: "category",
boundaryGap: false,
data: data.map((p) => p.date),
axisLine: { lineStyle: { color: C.line } },
axisTick: { show: false },
axisLabel: {
color: C.faint,
fontSize: 10,
formatter: (v: string) => (xFmt ? xFmt(v) : v),
},
},
yAxis: {
type: "value",
scale: true,
axisLabel: { color: C.faint, fontSize: 10, formatter: yFmt },
splitLine: { lineStyle: { color: C.line } },
},
series: [
{
type: "line",
showSymbol: false,
symbol: "circle",
data: data.map((p) => p.value),
lineStyle: { color, width: 1.8 },
itemStyle: { color },
areaStyle: fill ? { color: areaColor } : undefined,
emphasis: { focus: "series" },
},
],
});
const onResize = () => chart.resize();
window.addEventListener("resize", onResize);
return () => {
window.removeEventListener("resize", onResize);
chart.dispose();
};
}, [data, color, fill, title, height, yFmt, xFmt]);
if (data.length === 0) return <div className="hint">暂无数据</div>;
return <div ref={ref} style={{ height, width: "100%" }} />;
}
+36
View File
@@ -0,0 +1,36 @@
import type { ReactNode } from "react";
/**
* 富文本渲染:把后端说明文本里的 **粗体** 与 `行内代码` 渲染成真实样式。
*
* 为什么需要:`describe_strategy`(以及引擎的 `unimplemented` 说明)用 Markdown 的
* `**…**` / `` `…` `` 做**强调协议**——纯文本接口下这是合理的表达方式。但 React 不会
* 解析 Markdown,直接 `{text}` 会把星号和反引号**原样显示**出来(实测归档页/策略页
* 有 10 处这样的漏字),让「说明」看起来像没写完的草稿。
*
* 为什么不引 markdown 渲染器:说明文本来自后端(可信),但仍不需要为两种标记引入
* `dangerouslySetInnerHTML` 与整个解析器;这里只做**结构化切分后生成 React 元素**,
* 不产生 HTML 注入面,也不支持链接/图片等其它语法(不支持就按字面显示,不猜)。
*/
export function RichText({ text }: { text?: string | null }): ReactNode {
if (!text) return null;
// 一次切分同时处理 **粗体** 与 `代码`(分隔符保留在结果里以便识别)
const parts = text.split(/(\*\*[^*]+\*\*|`[^`]+`)/g).filter((p) => p !== "");
return (
<>
{parts.map((p, i) => {
if (p.length > 4 && p.startsWith("**") && p.endsWith("**")) {
return <b key={i}>{p.slice(2, -2)}</b>;
}
if (p.length > 2 && p.startsWith("`") && p.endsWith("`")) {
return (
<code key={i} className="inline-code">
{p.slice(1, -1)}
</code>
);
}
return <span key={i}>{p}</span>;
})}
</>
);
}
@@ -1,200 +0,0 @@
"use client";
/**
* K 线图(v3 §20 Chart):蜡烛图 + 成交量 + MA 指标 + 事件标记。
*
* 标记语义(v3 §20.3 Signal↔Fill 严格区分):
* - fills(实际成交):实心标记 —— fill_buy 红色 ▲ / fill_sell 绿色 ▼
* - signals(未成交意图):空心标记 —— signal_buy 红圈 ▲ / signal_sell 绿圈
* 组件只做展示,绝不自行计算(数据来自后端 Chart API)。
*
* 基于 ECharts candlestick(ECharts 实现);另一套实现见 CandleChartLW.tsx(TradingView
* Lightweight Charts),两套共用同一 props 接口(chartTypes.ts)供对比。
*/
import { useEffect, useRef } from "react";
import * as echarts from "echarts/core";
import { BarChart, CandlestickChart, LineChart, ScatterChart } from "echarts/charts";
import { GridComponent, TooltipComponent } from "echarts/components";
import { CanvasRenderer } from "echarts/renderers";
import type { ChartMarker } from "@/lib/types";
import type { CandleChartProps } from "./chartTypes";
export type { CandleDatum } from "./chartTypes";
echarts.use([
CandlestickChart,
LineChart,
BarChart,
ScatterChart,
GridComponent,
TooltipComponent,
CanvasRenderer,
]);
const C = {
faint: "#8b98b2",
line: "rgba(150,165,195,0.13)",
tooltipBg: "#141d30",
tooltipBorder: "rgba(150,165,195,0.28)",
up: "#e5484d",
down: "#2fb36b",
ma20: "#3fb6ff",
ma60: "#d4a72c",
};
export function CandleChart({
data,
volume = [],
indicators = {},
fills = [],
signals = [],
height = 480,
name = "",
}: CandleChartProps) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!ref.current || data.length === 0) return;
const chart = echarts.init(ref.current);
const dates = data.map((d) => d.time);
const vol = volume ?? [];
// echarts option 结构较宽,series 用宽松类型(组件层实现细节)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const series: any[] = [
{
name: "K线",
type: "candlestick",
data: data.map((d) => [d.open, d.close, d.low, d.high]),
itemStyle: {
color: C.up, // 阳线(收盘>开盘)红涨
color0: C.down,
borderColor: C.up,
borderColor0: C.down,
},
},
];
for (const [name, pts] of Object.entries(indicators)) {
const vals = dates.map((t) => {
const p = pts.find((x) => x.time === t);
return p?.value ?? null;
});
series.push({
name,
type: "line",
data: vals,
smooth: false,
showSymbol: false,
lineStyle: { width: 1.2 },
itemStyle: { color: C[name as keyof typeof C] ?? "#b98bff" },
connectNulls: true,
});
}
// 成交标记(实心)与未成交信号(空心):以散点叠加在价格坐标
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const mk = (m: ChartMarker, filled: boolean): any => {
const buy = m.kind.endsWith("buy");
return {
value: [m.time, m.price ?? 0],
symbol: filled ? (buy ? "triangle" : "triangle") : buy ? "triangle" : "triangle",
symbolRotate: buy ? 0 : 180,
symbolSize: 13,
itemStyle: {
color: filled ? (buy ? C.up : C.down) : "transparent",
borderColor: buy ? C.up : C.down,
borderWidth: 1.6,
},
tooltip: { formatter: m.text.join("<br/>") },
};
};
series.push(
{ name: "成交", type: "scatter", data: fills.map((m) => mk(m, true)), z: 6 },
{ name: "信号(未成交)", type: "scatter", data: signals.map((m) => mk(m, false)), z: 5 }
);
const hasVol = vol.length > 0;
const mainGridBottom = hasVol ? 58 : 14;
series.push({
name: "成交量",
type: "bar",
xAxisIndex: 1,
yAxisIndex: 1,
data: dates.map((t) => {
const p = vol.find((x) => x.time === t);
return p?.value ?? 0;
}),
itemStyle: { color: "rgba(150,165,195,0.35)" },
});
chart.setOption({
backgroundColor: "transparent",
tooltip: {
trigger: "axis",
axisPointer: { type: "cross" },
backgroundColor: C.tooltipBg,
borderColor: C.tooltipBorder,
textStyle: { color: "#e9eef7", fontSize: 12 },
},
axisPointer: { link: [{ xAxisIndex: "all" }] },
grid: [
{ left: 8, right: 18, top: 16, height: hasVol ? "62%" : "82%", containLabel: true },
...(hasVol
? [{ left: 8, right: 18, top: hasVol ? "76%" : undefined, height: "14%", containLabel: true }]
: []),
],
xAxis: [
{
type: "category",
data: dates,
boundaryGap: true,
axisLine: { lineStyle: { color: C.line } },
axisTick: { show: false },
axisLabel: { color: C.faint, fontSize: 10 },
splitLine: { show: false },
},
...(hasVol
? [{
type: "category",
gridIndex: 1,
data: dates,
boundaryGap: true,
axisLine: { lineStyle: { color: C.line } },
axisTick: { show: false },
axisLabel: { show: false },
splitLine: { show: false },
}]
: []),
],
yAxis: [
{
type: "value",
scale: true,
splitNumber: 4,
axisLabel: { color: C.faint, fontSize: 10 },
splitLine: { lineStyle: { color: C.line } },
},
...(hasVol
? [{
type: "value",
gridIndex: 1,
scale: true,
axisLabel: { color: C.faint, fontSize: 9 },
splitLine: { show: false },
}]
: []),
],
series,
legend: {
show: false,
},
});
const onResize = () => chart.resize();
window.addEventListener("resize", onResize);
return () => {
window.removeEventListener("resize", onResize);
chart.dispose();
};
}, [data, volume, indicators, fills, signals, height]);
if (data.length === 0) return <div className="hint">暂无 K 线数据</div>;
return <div ref={ref} style={{ height, width: "100%" }} aria-label={`${name} K 线图`} />;
}
@@ -1,12 +1,16 @@
"use client";
/** K 线图统一入口:可切换 ECharts / TradingView Lightweight Charts 两套实现(对比期)。 */
/**
* K 线图统一入口(TradingView Lightweight Charts)。
*
* 为什么去掉 `library` 参数:ECharts 版(CandleChart.tsx)已随「图表统一到
* lightweight-charts」下线并删除;如果继续保留切换参数,调用方会以为还有第二套实现可用
* (AGENT §24:未实现必须如实标注,不许假装)。props 仍沿用 chartTypes.ts 的 CandleChartProps,
* 单只股票页与后续调用方无需改接口。
*/
import type { CandleChartProps } from "./chartTypes";
import { CandleChart } from "./CandleChart";
import { CandleChartLW } from "./CandleChartLW";
export type ChartLibrary = "echarts" | "lightweight";
export function StockChart({ library = "lightweight", ...props }: CandleChartProps & { library?: ChartLibrary }) {
return library === "echarts" ? <CandleChart {...props} /> : <CandleChartLW {...props} />;
export function StockChart(props: CandleChartProps) {
return <CandleChartLW {...props} />;
}
@@ -0,0 +1,97 @@
"use client";
/**
* 策略说明卡片:一句话说明 + 计算公式 + 执行步骤 + 注意事项。
*
* 内容全部来自后端 describe_strategy(依 spec 真实推导);本组件只负责呈现,
* 并用 `RichText` 渲染后端使用的 **粗体** / `代码` 强调协议(否则星号会字面漏出)。
* 接口不可用时**如实显示「说明暂不可用」并给出原因**,不伪造文案(AGENT §24)。
*/
import { RichText } from "@/components/RichText";
import { Card, Pill } from "@/components/ui";
import type { StrategyDoc } from "@/lib/types";
export function StrategyDocBody({ doc }: { doc: StrategyDoc }) {
return (
<div className="doc-block">
<div className="doc-summary">
<span aria-hidden>💡</span>
<span><RichText text={doc.summary} /></span>
</div>
{doc.formula ? (
<div className="doc-formula">
<b style={{ fontSize: 13 }}>计算公式</b>
<pre className="mono"><RichText text={doc.formula} /></pre>
</div>
) : null}
{doc.steps?.length ? (
<div>
<b style={{ fontSize: 13 }}>执行步骤</b>
<ol className="doc-steps">
{doc.steps.map((s, i) => (
<li key={i}><RichText text={s} /></li>
))}
</ol>
</div>
) : null}
{doc.warnings?.length ? (
<div className="chips">
{doc.warnings.map((w, i) => (
<span className="chip" key={i} title={w}>
<span className="tone-warn">注意</span> <RichText text={w} />
</span>
))}
</div>
) : null}
</div>
);
}
export function StrategyDocCard({
doc,
loading,
error,
title = "策略说明与计算公式",
compact = false,
}: {
doc: StrategyDoc | null;
loading: boolean;
error: string;
title?: string;
compact?: boolean;
}) {
return (
<Card
icon="book"
title={title}
tools={
loading ? (
<Pill tone="accent" icon="spinner">
生成中
</Pill>
) : doc ? (
<Pill tone="pos" icon="check">
由后端依参数推导
</Pill>
) : null
}
>
{doc ? (
<StrategyDocBody doc={doc} />
) : loading ? (
<div className="hint">正在生成说明与公式…</div>
) : (
<div className="hint">
说明暂不可用{error ? `:${error}` : "(后端 /api/strategies/describe 未就绪)"}。
策略仍可正常保存与运行;说明与公式由后端依 spec 推导,前端不自行拼造文案。
</div>
)}
{!compact && doc && (
<div className="hint" style={{ marginTop: 10 }}>
<RichText text="本说明由后端 `describe_strategy` 从策略定义推导,与引擎实际执行的规则同源," />
因此不会出现「文档写一套、代码跑另一套」。
</div>
)}
</Card>
);
}
@@ -0,0 +1,717 @@
"use client";
/**
* 策略参数表单(受控组件)—— 策略库 / 回测页 / 选股直通 共用同一份参数模型。
*
* 为什么抽出来:策略库要「新建/编辑策略」、回测页要「保存为策略/从策略载入」、
* 选股页要「按此条件回测」,三处字段与校验完全同构。若各写一遍,必然出现
* 「选股页能设的条件在回测页设不了」这类口径漂移(本平台的核心风险)。
* 因此参数只有一个模型 `StrategyParams`,一个表单组件,一套校验。
*
* 组件**不持有业务状态**:value/onChange 由父组件控制,父组件负责提交与落库。
*/
import type { FactorMeta, ResearchCondition, ResearchSpec, StrategyDefinition } from "@/lib/types";
import { Btn, Field } from "@/components/ui";
export type Op = ResearchCondition["op"];
export const OPS: { value: Op; label: string }[] = [
{ value: "gt", label: ">" },
{ value: "gte", label: "≥" },
{ value: "lt", label: "<" },
{ value: "lte", label: "≤" },
{ value: "eq", label: "=" },
{ value: "ne", label: "≠" },
{ value: "in", label: "属于" },
{ value: "not_in", label: "不属于" },
];
export interface StrategyParams {
name: string;
description: string;
/** 加权因子列表(打分公式 = Σ weight × factor) */
factors: { name: string; weight: number }[];
priceAdjustment: "none" | "qfq" | "hfq";
/** n:候选池(择股条件选出的股数) */
topN: number;
/** x:实际持仓数,必须 ≤ n */
holdX: number;
/** m:择股间隔(月),0 = 不单独设(跟随 y) */
mMonths: number;
/** y:调仓间隔(月),0 = 跟随 m */
yMonths: number;
rebalance: "monthly" | "weekly";
/**
* 买不进(涨停/停牌)时的补位策略 —— 三态,与后端两个互斥字段一一对应:
* - `substitute`:allow_substitute=true, defer_buy=false(换一只买)
* - `defer`:allow_substitute=false, defer_buy=true(顺延到之后首个不涨停的交易日)
* - `none`:两者皆 false(不补位,可能少持几只)
* 后端拒绝两者同时为 true,因此这里不提供「都选」的组合。
*/
fillPolicy: FillPolicy;
conditions: ResearchCondition[];
excludeSt: boolean;
minListingDays: number;
commission: number; // %
stamp: number; // %
slippage: number; // %
minCommission: number; // 元/笔
capital: number;
start: string;
end: string;
}
/** 用户案例默认参数:全市场股息率最高的 n 只 → 持仓前 x 只,每 m 个月择股、每 y 个月调仓。 */
export type FillPolicy = "substitute" | "defer" | "none";
export const CASE_PRESET: Omit<StrategyParams, "start" | "end" | "name" | "description"> = {
factors: [{ name: "dividend_yield", weight: 1 }],
priceAdjustment: "hfq",
topN: 20,
holdX: 20,
mMonths: 6,
yMonths: 6,
rebalance: "monthly",
fillPolicy: "defer",
conditions: [{ field: "dv_ratio", op: "lte", value: 30 }],
excludeSt: true,
minListingDays: 250,
commission: 0.03,
stamp: 0.05,
slippage: 0.1,
minCommission: 5,
capital: 1_000_000,
};
export function emptyParams(range: { start: string; end: string }): StrategyParams {
return {
...CASE_PRESET,
name: "",
description: "",
factors: [{ name: "momentum_60", weight: 1 }],
conditions: [],
start: range.start,
end: range.end,
};
}
export function casePreset(range: { start: string; end: string }): StrategyParams {
return { ...CASE_PRESET, name: "", description: "", start: "2020-01-01", end: range.end };
}
/** 参数 → ResearchSpec(回测提交体)。m/y 的 0 语义与后端一致。 */
export function paramsToSpec(p: StrategyParams): ResearchSpec {
const factors = p.factors.filter((f) => f.name.trim() !== "");
return {
type: "backtest",
universe: { exclude_st: p.excludeSt, min_listing_days: p.minListingDays },
price_adjustment: p.priceAdjustment,
factors: factors.length ? factors : [{ name: "momentum_60", weight: 1 }],
conditions: p.conditions.filter((c) => c.field.trim() !== ""),
selection: {
top_n: p.topN,
hold_top_x: p.holdX,
allow_substitute: p.fillPolicy === "substitute",
defer_buy: p.fillPolicy === "defer",
},
rebalance: p.rebalance,
// m=0 表示「每次调仓都择股」:若同时给了 y>0,则择股间隔跟随 y
// (后端禁止只给 y 而不给 m —— 无锚点无法确定择股日集合)
selection_interval_months: p.mMonths > 0 ? p.mMonths : p.yMonths > 0 ? p.yMonths : null,
rebalance_interval_months: p.yMonths > 0 ? p.yMonths : p.mMonths > 0 ? p.mMonths : null,
costs: {
commission_rate: p.commission / 100,
stamp_tax_rate: p.stamp / 100,
slippage_rate: p.slippage / 100,
min_commission: p.minCommission,
},
initial_capital: p.capital,
period: [p.start, p.end],
};
}
/** ResearchSpec → 参数(从实验详情「以此参数回测」时使用) */
export function paramsFromSpec(
spec: Partial<ResearchSpec> & { config_snapshot?: Record<string, unknown> },
base: StrategyParams
): StrategyParams {
const snap = (spec.config_snapshot ?? {}) as Partial<ResearchSpec>;
const s = (snap.factors ? snap : spec) as Partial<ResearchSpec>;
const sel: NonNullable<ResearchSpec["selection"]> = s.selection ?? { top_n: base.topN };
const costs: NonNullable<ResearchSpec["costs"]> = s.costs ?? {};
return {
...base,
factors: s.factors?.length ? s.factors.map((f) => ({ ...f })) : base.factors,
priceAdjustment: s.price_adjustment ?? base.priceAdjustment,
topN: sel.top_n ?? base.topN,
holdX: sel.hold_top_x ?? sel.top_n ?? base.holdX,
mMonths: s.selection_interval_months ?? base.mMonths,
yMonths: s.rebalance_interval_months ?? base.yMonths,
rebalance: s.rebalance ?? base.rebalance,
fillPolicy: sel.defer_buy ? "defer" : sel.allow_substitute === false ? "none" : "substitute",
conditions: (s.conditions ?? []).map((c) => ({ ...c })),
excludeSt: s.universe?.exclude_st ?? base.excludeSt,
minListingDays: s.universe?.min_listing_days ?? base.minListingDays,
commission: (costs.commission_rate ?? base.commission / 100) * 100,
stamp: (costs.stamp_tax_rate ?? base.stamp / 100) * 100,
slippage: (costs.slippage_rate ?? base.slippage / 100) * 100,
minCommission: costs.min_commission ?? base.minCommission,
capital: s.initial_capital ?? base.capital,
start: s.period?.[0] ?? base.start,
end: s.period?.[1] ?? base.end,
};
}
/** 已保存策略 → 参数 */
export function paramsFromStrategy(st: StrategyDefinition, base: StrategyParams): StrategyParams {
const sel = st.selection ?? {};
const costs = st.costs ?? {};
return {
...base,
name: st.name,
description: st.description ?? "",
factors: st.factors?.length ? st.factors.map((f) => ({ ...f })) : base.factors,
priceAdjustment: st.price_adjustment ?? base.priceAdjustment,
topN: sel.top_n ?? base.topN,
holdX: sel.hold_top_x ?? sel.top_n ?? base.holdX,
mMonths: st.selection_interval_months ?? base.mMonths,
yMonths: st.rebalance_interval_months ?? base.yMonths,
rebalance: st.rebalance ?? base.rebalance,
fillPolicy: sel.defer_buy ? "defer" : sel.allow_substitute === false ? "none" : "substitute",
conditions: (st.conditions ?? []).map((c) => ({ ...c })),
excludeSt: st.universe?.exclude_st ?? base.excludeSt,
minListingDays: st.universe?.min_listing_days ?? base.minListingDays,
commission: (costs.commission_rate ?? base.commission / 100) * 100,
stamp: (costs.stamp_tax_rate ?? base.stamp / 100) * 100,
slippage: (costs.slippage_rate ?? base.slippage / 100) * 100,
minCommission: costs.min_commission ?? base.minCommission,
};
}
/** 参数 → 策略定义(保存到策略库;period/capital 不入库,回测时再补) */
export function strategyFromParams(p: StrategyParams, id?: string): StrategyDefinition {
const factors = p.factors.filter((f) => f.name.trim() !== "");
return {
...(id ? { id } : {}),
name: p.name.trim(),
description: p.description.trim(),
spec_type: "backtest",
universe: { exclude_st: p.excludeSt, min_listing_days: p.minListingDays },
price_adjustment: p.priceAdjustment,
factors: factors.length ? factors : [{ name: "momentum_60", weight: 1 }],
conditions: p.conditions.filter((c) => c.field.trim() !== ""),
selection: {
top_n: p.topN,
hold_top_x: p.holdX,
allow_substitute: p.fillPolicy === "substitute",
defer_buy: p.fillPolicy === "defer",
},
rebalance: p.rebalance,
selection_interval_months: p.mMonths > 0 ? p.mMonths : p.yMonths > 0 ? p.yMonths : null,
rebalance_interval_months: p.yMonths > 0 ? p.yMonths : p.mMonths > 0 ? p.mMonths : null,
costs: {
commission_rate: p.commission / 100,
stamp_tax_rate: p.stamp / 100,
slippage_rate: p.slippage / 100,
min_commission: p.minCommission,
},
portfolio: {},
};
}
/** 表单校验(返回 field → 错误文案;空对象 = 通过) */
export function validateParams(
p: StrategyParams,
opts: { requireMeta?: boolean } = {}
): Record<string, string> {
const e: Record<string, string> = {};
if (opts.requireMeta) {
if (!p.name.trim()) e.name = "策略名必填(便于在策略库中识别)";
else if (p.name.trim().length > 64) e.name = "策略名最多 64 字";
if (!p.description.trim()) e.description = "一句话说明必填:说清这个策略做什么";
// strategy.description 落库列为 String(300):超长会被 MySQL 严格模式拒绝,
// 因此在表单层就拦住并说明原因(而不是让用户在保存时吃一个 500)
else if (p.description.trim().length > 300)
e.description = `一句话说明最长 300 字(当前 ${p.description.trim().length} 字)`;
}
if (!p.factors.some((f) => f.name.trim())) e.factors = "至少选择一个因子";
else {
// 后端 ResearchSpec 校验会拒绝重复因子名(z-score 叠加两次没有意义且易误读),
// 这里提前拦住,避免用户填完参数后才吃一个 400
const used = p.factors.map((f) => f.name.trim()).filter(Boolean);
const dup = used.find((n, i) => used.indexOf(n) !== i);
if (dup) e.factors = `因子「${dup}」重复了:同一因子只应出现一次(想加权重请调权重值)`;
}
if (p.topN < 1) e.topN = "候选池 n 至少为 1";
if (p.holdX < 1) e.holdX = "持仓数 x 至少为 1";
if (p.holdX > p.topN) e.holdX = `持仓数 x=${p.holdX} 不能大于候选池 n=${p.topN}`;
if (p.mMonths < 0 || p.mMonths > 60) e.mMonths = "m 需在 0~60 之间";
if (p.yMonths < 0 || p.yMonths > 60) e.yMonths = "y 需在 0~60 之间";
if (p.commission < 0 || p.stamp < 0 || p.slippage < 0) e.costs = "费率不能为负";
if (p.minCommission < 0) e.minCommission = "最低佣金不能为负";
if (p.capital < 10000) e.capital = "初始资金建议 ≥ 1 万";
if (!p.start || !p.end) e.period = "起止日期都必填";
else if (p.start >= p.end) e.period = "开始日期必须早于结束日期";
for (const c of p.conditions) {
if (!c.field.trim()) {
e.conditions = "存在空的条件字段:请填写字段名或删除该条件";
break;
}
}
return e;
}
export interface StrategyParamsFormProps {
value: StrategyParams;
onChange: (next: StrategyParams) => void;
factorOptions: FactorMeta[];
/** 显示策略名 + 一句话说明(策略库编辑/保存为策略时) */
showMeta?: boolean;
/** 显示回测区间与初始资金(回测执行时才需要) */
showPeriod?: boolean;
/** 显示操作按钮区(表单内提交按钮) */
disabled?: boolean;
errors?: Record<string, string>;
/** 因子选择是否允许加权多项(默认允许) */
multiFactor?: boolean;
}
export function StrategyParamsForm({
value: p,
onChange,
factorOptions,
showMeta = false,
showPeriod = true,
disabled = false,
errors = {},
multiFactor = true,
}: StrategyParamsFormProps) {
const set = (patch: Partial<StrategyParams>) => onChange({ ...p, ...patch });
const setFactor = (i: number, patch: Partial<{ name: string; weight: number }>) =>
set({ factors: p.factors.map((f, j) => (j === i ? { ...f, ...patch } : f)) });
return (
<>
{showMeta && (
<div className="form-grid">
<Field label="策略名(必填)" hint="在策略库中唯一">
<input
className="input"
value={p.name}
maxLength={64}
placeholder="例:高股息 6 月择股 · 低频"
disabled={disabled}
onChange={(e) => set({ name: e.target.value })}
/>
{errors.name && (
<div className="field-err" role="alert">
{errors.name}
</div>
)}
</Field>
<Field
label="一句话说明(必填)"
hint={`说清这个策略做什么、怎么选股(${p.description.length}/300,落库列宽上限)`}
>
<input
className="input"
value={p.description}
maxLength={300}
placeholder="例:全市场股息率最高的 20 只,每 6 个月重新择股并等权持有"
disabled={disabled}
onChange={(e) => set({ description: e.target.value })}
/>
{errors.description && (
<div className="field-err" role="alert">
{errors.description}
</div>
)}
</Field>
</div>
)}
{/* ---------- 因子(打分公式) ---------- */}
<div className="between" style={{ marginBottom: 8 }}>
<b style={{ fontSize: 13 }}>打分因子(score = Σ 权重 × 因子值,越大越优先)</b>
{multiFactor && (
<Btn
icon="layers"
disabled={disabled}
onClick={() => set({ factors: [...p.factors, { name: factorOptions[0]?.name ?? "", weight: 1 }] })}
>
添加因子
</Btn>
)}
</div>
<div className="row" style={{ flexWrap: "wrap", gap: 8, marginBottom: 6 }}>
{p.factors.map((f, i) => (
<div className="row" key={i} style={{ gap: 6 }}>
<select
className="input"
style={{ width: 220 }}
value={f.name}
disabled={disabled}
onChange={(e) => setFactor(i, { name: e.target.value })}
>
{!factorOptions.some((o) => o.name === f.name) && f.name ? (
<option value={f.name}>{f.name}(未在因子表)</option>
) : null}
{factorOptions.map((o) => (
<option key={o.name} value={o.name}>
{o.name}
</option>
))}
</select>
<input
className="input mono"
style={{ width: 84 }}
type="number"
step="0.1"
value={f.weight}
disabled={disabled}
title="权重"
onChange={(e) => setFactor(i, { weight: Number(e.target.value) })}
/>
{multiFactor && p.factors.length > 1 && (
<Btn
icon="x"
disabled={disabled}
onClick={() => set({ factors: p.factors.filter((_, j) => j !== i) })}
>
删除
</Btn>
)}
</div>
))}
</div>
{errors.factors && (
<div className="field-err" role="alert">
{errors.factors}
</div>
)}
{/* ---------- 选股规模与周期 ---------- */}
<div className="form-grid" style={{ marginTop: 12 }}>
<Field label="候选池 n(择股条件选出股数)">
<input
className="input"
type="number"
min={1}
max={500}
value={p.topN}
disabled={disabled}
onChange={(e) => set({ topN: Number(e.target.value) })}
/>
</Field>
<Field label="持仓数 x(≤ n)">
<input
className={`input${errors.holdX ? " input--invalid" : ""}`}
type="number"
min={1}
max={p.topN}
value={p.holdX}
disabled={disabled}
onChange={(e) => set({ holdX: Number(e.target.value) })}
/>
{errors.holdX && (
<div className="field-err" role="alert">
{errors.holdX}
</div>
)}
</Field>
<Field label="择股间隔 m(月,0=跟随 y)" hint="m:多久重新挑一次股">
<input
className="input"
type="number"
min={0}
max={60}
value={p.mMonths}
disabled={disabled}
onChange={(e) => set({ mMonths: Number(e.target.value) })}
/>
{errors.mMonths && (
<div className="field-err" role="alert">
{errors.mMonths}
</div>
)}
</Field>
<Field label="调仓间隔 y(月,0=跟随 m)" hint="y:多久按最新选股结果换一次仓">
<input
className="input"
type="number"
min={0}
max={60}
value={p.yMonths}
disabled={disabled}
onChange={(e) => set({ yMonths: Number(e.target.value) })}
/>
{errors.yMonths && (
<div className="field-err" role="alert">
{errors.yMonths}
</div>
)}
</Field>
<Field label="调仓频率(m、y 均为 0 时生效)">
<select
className="input"
value={p.rebalance}
disabled={disabled}
onChange={(e) => set({ rebalance: e.target.value as "monthly" | "weekly" })}
>
<option value="monthly">月度</option>
<option value="weekly">周度</option>
</select>
</Field>
<Field
label="买不进时的补位(涨停 / 停牌)"
hint="后端两个字段互斥,这里用三选一表达,避免出现「都不生效」的静默组合"
>
<div className="radio-col">
{(
[
["substitute", "换一只买", "从候选池之外按复合分往下找可买标的,补足持仓数"],
["defer", "顺延买入", "等它到之后首个不涨停的交易日再按收盘价买入(到下次调仓仍未成交则作废)"],
["none", "不补位", "买不进就空着,实际持仓可能少于持仓数 x"],
] as const
).map(([val, label, desc]) => (
<label key={val} className="radio-row" title={desc}>
<input
type="radio"
name="fill-policy"
checked={p.fillPolicy === val}
disabled={disabled}
onChange={() => set({ fillPolicy: val })}
/>
<span>
<b>{label}</b>
<span className="hint"> {desc}</span>
</span>
</label>
))}
</div>
</Field>
</div>
{/* ---------- 口径与成本 ---------- */}
<div className="form-grid" style={{ marginTop: 12 }}>
<Field label="复权口径" hint="股息类策略建议后复权 hfq(把分红再投资计入)">
<select
className="input"
value={p.priceAdjustment}
disabled={disabled}
onChange={(e) => set({ priceAdjustment: e.target.value as "none" | "qfq" | "hfq" })}
>
<option value="hfq">后复权 hfq(股息策略推荐)</option>
<option value="qfq">前复权 qfq</option>
<option value="none">不复权 none</option>
</select>
</Field>
<Field label="标的范围">
<label className="row" style={{ gap: 6, color: "var(--text-2)", fontSize: 13, cursor: "pointer" }}>
<input
type="checkbox"
checked={p.excludeSt}
disabled={disabled}
onChange={(e) => set({ excludeSt: e.target.checked })}
/>
剔除 ST(按择股日当时名称判定)
</label>
</Field>
<Field label="最少上市天数" hint="避免次新股噪声;0 = 不限制">
<input
className="input"
type="number"
min={0}
step={10}
value={p.minListingDays}
disabled={disabled}
onChange={(e) => set({ minListingDays: Number(e.target.value) })}
/>
</Field>
<Field label="手续费率 %">
<input
className="input"
type="number"
step="0.01"
min={0}
value={p.commission}
disabled={disabled}
onChange={(e) => set({ commission: Number(e.target.value) })}
/>
</Field>
<Field label="印花税率 %" hint="仅卖出收取">
<input
className="input"
type="number"
step="0.01"
min={0}
value={p.stamp}
disabled={disabled}
onChange={(e) => set({ stamp: Number(e.target.value) })}
/>
</Field>
<Field label="滑点率 %">
<input
className="input"
type="number"
step="0.01"
min={0}
value={p.slippage}
disabled={disabled}
onChange={(e) => set({ slippage: Number(e.target.value) })}
/>
</Field>
<Field label="最低佣金(元/笔)">
<input
className="input"
type="number"
step="1"
min={0}
value={p.minCommission}
disabled={disabled}
onChange={(e) => set({ minCommission: Number(e.target.value) })}
/>
{errors.minCommission && (
<div className="field-err" role="alert">
{errors.minCommission}
</div>
)}
</Field>
{showPeriod && (
<>
<Field label="初始资金(元)">
<input
className="input"
type="number"
step={100000}
min={10000}
value={p.capital}
disabled={disabled}
onChange={(e) => set({ capital: Number(e.target.value) })}
/>
{errors.capital && (
<div className="field-err" role="alert">
{errors.capital}
</div>
)}
</Field>
<Field label="开始日期">
<input
type="date"
className="input"
value={p.start}
disabled={disabled}
onChange={(e) => set({ start: e.target.value })}
/>
</Field>
<Field label="结束日期">
<input
type="date"
className="input"
value={p.end}
disabled={disabled}
onChange={(e) => set({ end: e.target.value })}
/>
</Field>
</>
)}
</div>
{errors.period && (
<div className="field-err" role="alert">
{errors.period}
</div>
)}
{/* ---------- 选股过滤条件 ---------- */}
<div style={{ marginTop: 14 }}>
<div className="between" style={{ marginBottom: 8 }}>
<b style={{ fontSize: 13 }}>选股过滤条件(AND,universe 之后、因子排序之前执行)</b>
<Btn
icon="layers"
disabled={disabled}
onClick={() => set({ conditions: [...p.conditions, { field: "", op: "gte", value: 0 }] })}
>
添加条件
</Btn>
</div>
{p.conditions.length === 0 ? (
<div className="hint">未设置条件:候选池 = universe 内因子分最高的 n 只。</div>
) : (
<div className="row" style={{ flexWrap: "wrap", gap: 8 }}>
{p.conditions.map((c, i) => (
<div className="row" key={i} style={{ gap: 6 }}>
<input
className="input"
style={{ width: 190 }}
placeholder="字段(dv_ratio / pe / ma60 …)"
value={c.field}
disabled={disabled}
onChange={(e) =>
set({
conditions: p.conditions.map((x, j) =>
j === i ? { ...x, field: e.target.value } : x
),
})
}
/>
<select
className="input"
style={{ width: 76 }}
value={c.op}
disabled={disabled}
onChange={(e) =>
set({
conditions: p.conditions.map((x, j) =>
j === i ? { ...x, op: e.target.value as Op } : x
),
})
}
>
{OPS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
<input
className="input"
style={{ width: 110 }}
value={String(c.value ?? "")}
disabled={disabled}
onChange={(e) => {
const raw = e.target.value;
const num = Number(raw);
set({
conditions: p.conditions.map((x, j) =>
j === i
? { ...x, value: raw !== "" && !Number.isNaN(num) ? num : raw }
: x
),
});
}}
/>
<Btn
icon="x"
disabled={disabled}
onClick={() => set({ conditions: p.conditions.filter((_, j) => j !== i) })}
>
删除
</Btn>
</div>
))}
</div>
)}
{errors.conditions && (
<div className="field-err" role="alert">
{errors.conditions}
</div>
)}
<div className="hint" style={{ marginTop: 6 }}>
可用字段:每日指标 dv_ratio / dv_ttm / pe / pb / total_mv、行情 close / volume /
amount、技术 ma20 / ma60、已注册因子名、static.industry 等、fundamental.roe 等
(财务按公告日 ≤ 择股日取用)。
</div>
</div>
</>
);
}
+19 -6
View File
@@ -12,6 +12,11 @@ interface NavItem {
icon: IconName;
}
/**
* 导航分组:按「研究闭环」的真实使用顺序排列 ——
* 找标的(股票池)→ 出候选(筛选)→ 定规则(策略库)→ 验规则(回测)→ 复盘(实验)。
* 「交易信号」「因子研究」是可选支路,放在同组但靠后;单独建组会让闭环读起来断裂。
*/
const GROUPS: { title: string; items: NavItem[] }[] = [
{
title: "研究",
@@ -19,15 +24,23 @@ const GROUPS: { title: string; items: NavItem[] }[] = [
{ href: "/", label: "总览", icon: "grid" },
{ href: "/stocks", label: "股票池", icon: "candles" },
{ href: "/selection", label: "股票筛选", icon: "target" },
{ href: "/signals", label: "交易信号", icon: "scale" },
{ href: "/factors", label: "因子研究", icon: "flask" },
{ href: "/factors/compose", label: "因子组合", icon: "layers" },
{ href: "/backtest", label: "选股回测", icon: "gauge" },
],
},
{
title: "归档",
items: [{ href: "/experiments", label: "实验", icon: "archive" }],
title: "策略",
items: [
{ href: "/strategies", label: "策略库", icon: "book" },
{ href: "/backtest", label: "选股回测", icon: "gauge" },
{ href: "/experiments", label: "实验对比", icon: "archive" },
],
},
{
title: "因子与信号",
items: [
{ href: "/factors", label: "因子研究", icon: "flask" },
{ href: "/factors/compose", label: "因子组合", icon: "layers" },
{ href: "/signals", label: "交易信号", icon: "scale" },
],
},
];
+382
View File
@@ -0,0 +1,382 @@
"use client";
/**
* 通用折线/面积/柱状图 —— TradingView Lightweight Charts 实现(唯一图表基座)。
*
* 为什么自建而不直接用 LW 原语:LW 没有内置 tooltip 与图例,且硬性要求
* ① 标记时间必须存在于序列数据中,② 标记必须按时间升序。
* 本组件把这两条约束在内部处理掉(过滤 + 排序),避免每个页面各写一遍而踩坑
* —— 回测买卖点必须精确落在净值曲线日期上(v3 §20.3)。
*
* 设计要点:
* - 图表实例只在「结构签名」(序列个数/类型/高度)变化时重建;数据变化走 setData
* + 数值校验和作为数据签名,避免每次渲染重建导致闪烁、丢失缩放位置。
* - 多序列共用价格轴(实验对比均为收益率 %,可直接叠加比较)。
* - 图例可点击隐藏/显示单条序列;tooltip 显示十字光标处全部序列数值。
*/
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
ColorType,
CrosshairMode,
LineStyle,
createChart,
type HistogramData,
type IChartApi,
type ISeriesApi,
type LineData,
type MouseEventParams,
type SeriesMarker,
type Time,
} from "lightweight-charts";
import { CHART, MARKER_COLORS } from "./theme";
import { isBuyMarker, prepareMarkers } from "./markers";
export interface LwPoint {
time: string;
value: number;
}
export interface LwSeries {
key: string;
label: string;
data: LwPoint[];
color?: string;
/** line=折线;area=带渐变面积;bar=柱状(月度收益等) */
type?: "line" | "area" | "bar";
lineWidth?: 1 | 2 | 3 | 4;
dashed?: boolean;
/** 是否在价格轴显示最新值标签(多序列对比时只留一条即可) */
lastValueVisible?: boolean;
}
export interface LwMarker {
time: string;
kind: keyof typeof MARKER_COLORS;
label?: string;
/** 覆盖默认文本(默认买 B / 卖 S) */
text?: string;
}
export interface LwChartProps {
series: LwSeries[];
markers?: LwMarker[];
height?: number;
/** 悬浮提示与价格轴的数值格式 */
valueFormat?: (v: number) => string;
/** 画一条 0 基准虚线(收益率曲线推荐开启) */
zeroLine?: boolean;
legend?: boolean;
ariaLabel?: string;
emptyHint?: string;
}
type AnySeries = ISeriesApi<"Line"> | ISeriesApi<"Area"> | ISeriesApi<"Histogram">;
function toTime(t: string): Time {
return t as Time;
}
/** 结构签名:序列个数/类型/高度 —— 变化才重建图表实例 */
function structureKey(series: LwSeries[], height: number): string {
return `${height}|${series.map((s) => `${s.key}:${s.type ?? "line"}`).join(",")}`;
}
/**
* 数据签名:长度 + 首末时间 + **数值校验和**。
* 必须含数值校验和:只改成本/滑点后曲线数值变了但日期与长度不变,
* 若签名只看长度就会漏更新(图表停留在上一次结果)。
*/
function dataKey(series: LwSeries[], markers: LwMarker[]): string {
const parts = series.map((s) => {
let sum = 0;
for (const p of s.data) sum += p.value;
return `${s.key}:${s.data.length}:${s.data[0]?.time ?? ""}:${s.data.at(-1)?.time ?? ""}:${sum.toFixed(4)}`;
});
const mk = markers.map((m) => `${m.time}${m.kind}`).join(",");
return `${parts.join("|")}#${mk}`;
}
export function LwChart({
series,
markers = [],
height = 320,
valueFormat,
zeroLine = false,
legend = true,
ariaLabel,
emptyHint = "暂无数据",
}: LwChartProps) {
const wrapRef = useRef<HTMLDivElement | null>(null);
const boxRef = useRef<HTMLDivElement | null>(null);
const chartRef = useRef<IChartApi | null>(null);
const seriesRef = useRef<Map<string, AnySeries>>(new Map());
const zeroLineDrawn = useRef(false);
/** 图例隐藏集合:tooltip 订阅里读取,用 ref 避免闭包过期 */
const hiddenRef = useRef<Set<string>>(new Set());
const fmtRef = useRef(valueFormat);
fmtRef.current = valueFormat;
const [hidden, setHidden] = useState<Set<string>>(new Set());
const [tip, setTip] = useState<{
x: number;
y: number;
time: string;
rows: { label: string; color: string; text: string }[];
} | null>(null);
hiddenRef.current = hidden;
const struct = structureKey(series, height);
const dkey = dataKey(series, markers);
const fmt = useCallback(
(v: number) => (fmtRef.current ? fmtRef.current(v) : v.toFixed(2)),
[]
);
/* ---------- 创建/销毁图表实例 ---------- */
useEffect(() => {
const el = boxRef.current;
if (!el) return;
const chart = createChart(el, {
autoSize: true,
height,
layout: {
background: { type: ColorType.Solid, color: CHART.background },
textColor: CHART.text,
fontSize: 11,
},
grid: {
vertLines: { color: CHART.grid },
horzLines: { color: CHART.grid },
},
rightPriceScale: {
borderColor: CHART.border,
scaleMargins: { top: 0.12, bottom: 0.12 },
},
timeScale: { borderColor: CHART.border, timeVisible: false, secondsVisible: false },
crosshair: {
mode: CrosshairMode.Normal,
vertLine: { color: CHART.crosshair, width: 1, style: LineStyle.Dashed, labelVisible: true },
horzLine: { color: CHART.crosshair, width: 1, style: LineStyle.Dashed, labelVisible: true },
},
});
chartRef.current = chart;
zeroLineDrawn.current = false;
const map = new Map<string, AnySeries>();
series.forEach((s, i) => {
const color = s.color ?? CHART.series[i % CHART.series.length];
let api: AnySeries;
if (s.type === "bar") {
api = chart.addHistogramSeries({
color,
priceLineVisible: false,
lastValueVisible: false,
});
} else if (s.type === "area") {
api = chart.addAreaSeries({
lineColor: color,
topColor: `${color}55`,
bottomColor: `${color}05`,
lineWidth: s.lineWidth ?? 2,
priceLineVisible: false,
lastValueVisible: s.lastValueVisible ?? false,
});
} else {
api = chart.addLineSeries({
color,
lineWidth: s.lineWidth ?? 2,
lineStyle: s.dashed ? LineStyle.Dashed : LineStyle.Solid,
priceLineVisible: false,
lastValueVisible: s.lastValueVisible ?? series.length === 1,
crosshairMarkerVisible: true,
crosshairMarkerRadius: 4,
});
}
map.set(s.key, api);
});
seriesRef.current = map;
// tooltip:LW 无内置 tooltip,订阅十字光标自行渲染
const onMove = (param: MouseEventParams) => {
if (!param.time || !param.point) {
setTip(null);
return;
}
const rows: { label: string; color: string; text: string }[] = [];
series.forEach((s, i) => {
const api = map.get(s.key);
if (!api || hiddenRef.current.has(s.key)) return;
const v = param.seriesData.get(api as unknown as ISeriesApi<"Line">) as
| LineData
| HistogramData
| undefined;
if (!v || typeof v.value !== "number") return;
rows.push({
label: s.label,
color: s.color ?? CHART.series[i % CHART.series.length],
text: fmt(v.value),
});
});
if (!rows.length) {
setTip(null);
return;
}
setTip({ x: param.point.x, y: param.point.y, time: String(param.time), rows });
};
chart.subscribeCrosshairMove(onMove);
const onLeave = () => setTip(null);
el.addEventListener("mouseleave", onLeave);
return () => {
el.removeEventListener("mouseleave", onLeave);
chart.unsubscribeCrosshairMove(onMove);
chart.remove();
chartRef.current = null;
seriesRef.current = new Map();
};
// 仅结构变化才重建;数据/标记由下方 effect 推入
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [struct, fmt]);
/* ---------- 推入数据与标记 ---------- */
useEffect(() => {
const chart = chartRef.current;
if (!chart) return;
series.forEach((s) => {
const api = seriesRef.current.get(s.key);
if (!api) return;
if (s.type === "bar") {
api.setData(
s.data.map((p) => ({ time: toTime(p.time), value: p.value })) as HistogramData[]
);
} else {
api.setData(s.data.map((p) => ({ time: toTime(p.time), value: p.value })) as LineData[]);
}
});
// 0 基准线:每个图表实例只画一次(重复调用会叠加多条价格线)
if (zeroLine && !zeroLineDrawn.current) {
const first = seriesRef.current.get(series[0]?.key ?? "");
first?.createPriceLine({
price: 0,
color: CHART.faint,
lineWidth: 1,
lineStyle: LineStyle.Dotted,
axisLabelVisible: false,
title: "",
});
zeroLineDrawn.current = true;
}
// 标记只能挂在某个序列上;统一挂第一条非柱状序列(净值/收益曲线)
const primaryKey = series.find((s) => s.type !== "bar")?.key ?? series[0]?.key;
const primary = primaryKey ? seriesRef.current.get(primaryKey) : undefined;
if (primary && "setMarkers" in primary) {
const validTimes = new Set(
series.find((s) => s.key === primaryKey)?.data.map((p) => p.time) ?? []
);
// LW 硬约束:标记时间必须存在且升序 → 先过滤再排序,否则买卖点会丢或抛错。
// 传空数组同样重要:结果切换后必须清掉上一次的标记。
const ms: SeriesMarker<Time>[] = prepareMarkers(markers, [...validTimes]).map((m) => ({
time: toTime(m.time),
position: isBuyMarker(m.kind) ? "belowBar" : "aboveBar",
color: MARKER_COLORS[m.kind],
shape: isBuyMarker(m.kind) ? "arrowUp" : "arrowDown",
size: 1,
text: m.text,
}));
(primary as ISeriesApi<"Line">).setMarkers(ms);
// 把「实际画上去的标记数」暴露成 DOM 契约:便于端到端断言,
// 也避免「页面传了 30 个买点、图上其实一个没显示」这类静默失败无从发现
if (wrapRef.current) wrapRef.current.dataset.markerCount = String(ms.length);
}
chart.timeScale().fitContent();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dkey, struct, zeroLine]);
/* ---------- 图例显隐 ---------- */
useEffect(() => {
series.forEach((s) => {
const api = seriesRef.current.get(s.key);
if (api) api.applyOptions({ visible: !hidden.has(s.key) });
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hidden, struct, dkey]);
const legendItems = useMemo(
() =>
series.map((s, i) => ({
key: s.key,
label: s.label,
color: s.color ?? CHART.series[i % CHART.series.length],
last: s.data.at(-1)?.value,
})),
[series]
);
const hasData = series.some((s) => s.data.length > 0);
return (
<div className="lw-wrap" ref={wrapRef}>
{legend && legendItems.length > 0 && (
<div className="lw-legend" role="group" aria-label="图例(点击可隐藏/显示)">
{legendItems.map((it) => (
<button
key={it.key}
type="button"
className={hidden.has(it.key) ? "lw-legend-item is-off" : "lw-legend-item"}
onClick={() =>
setHidden((prev) => {
const next = new Set(prev);
if (next.has(it.key)) next.delete(it.key);
else next.add(it.key);
return next;
})
}
aria-pressed={!hidden.has(it.key)}
title={`点击${hidden.has(it.key) ? "显示" : "隐藏"}「${it.label}」`}
>
<i style={{ background: it.color }} />
<span>{it.label}</span>
{typeof it.last === "number" && <b className="mono">{fmt(it.last)}</b>}
</button>
))}
</div>
)}
<div className="lw-canvas" style={{ height }}>
<div
ref={boxRef}
style={{ height, width: "100%" }}
role="img"
aria-label={ariaLabel ?? series.map((s) => s.label).join(" / ")}
/>
{tip && (
<div
className="lw-tip"
style={{
left: Math.max(8, Math.min(tip.x + 14, (boxRef.current?.clientWidth ?? 300) - 150)),
top: Math.max(8, tip.y - 12),
}}
>
<div className="lw-tip-time mono">{tip.time}</div>
{tip.rows.map((r) => (
<div key={r.label} className="lw-tip-row">
<i style={{ background: r.color }} />
<span className="lw-tip-label">{r.label}</span>
<b className="mono">{r.text}</b>
</div>
))}
</div>
)}
</div>
{!hasData && <div className="hint">{emptyHint}</div>}
</div>
);
}
@@ -0,0 +1,100 @@
/**
* 买卖点标记准备逻辑的单元测试。
*
* 为什么值得单独测:LW 对标记的时间存在性/升序是**硬约束**,违反时静默丢标记,
* 页面上表现为「明明有成交、图上没有买卖点」,靠肉眼看图很难发现。
*
* 运行:`npm run test:charts`(node --test + 原生 TS 类型擦除,无需额外依赖)
*/
import assert from "node:assert/strict";
import { test } from "node:test";
import { prepareMarkers } from "../markers.ts";
const TIMES = ["2024-01-02", "2024-01-03", "2024-01-04", "2024-01-05"];
test("过滤掉 series 中不存在的日期(LW 会因为这种标记整组不显示)", () => {
const out = prepareMarkers(
[
{ time: "2024-01-02", kind: "BUY" },
{ time: "2024-01-09", kind: "BUY" }, // 不存在
{ time: "2023-12-29", kind: "SELL" }, // 不存在
],
TIMES
);
assert.deepEqual(
out.map((m) => m.time),
["2024-01-02"]
);
});
test("输出严格按时间升序(即使输入是乱的)", () => {
const out = prepareMarkers(
[
{ time: "2024-01-05", kind: "SELL" },
{ time: "2024-01-02", kind: "BUY" },
{ time: "2024-01-04", kind: "SELL" },
{ time: "2024-01-03", kind: "BUY" },
],
TIMES
);
assert.deepEqual(
out.map((m) => m.time),
["2024-01-02", "2024-01-03", "2024-01-04", "2024-01-05"]
);
});
test("同一时间同一方向去重,但买卖同日各自保留(调仓日既卖又买)", () => {
const out = prepareMarkers(
[
{ time: "2024-01-03", kind: "BUY" },
{ time: "2024-01-03", kind: "BUY" },
{ time: "2024-01-03", kind: "SELL" },
{ time: "2024-01-03", kind: "SELL" },
],
TIMES
);
assert.equal(out.length, 2);
assert.deepEqual(
out.map((m) => m.kind),
["BUY", "SELL"]
);
});
test("未有显式文案时给默认 B/S", () => {
const out = prepareMarkers(
[
{ time: "2024-01-02", kind: "BUY" },
{ time: "2024-01-03", kind: "SELL" },
{ time: "2024-01-04", kind: "SIG_BUY", label: "信号" },
{ time: "2024-01-05", kind: "SELL", text: "清仓" },
],
TIMES
);
assert.deepEqual(
out.map((m) => m.text),
["B", "S", "信号", "清仓"]
);
});
test("空标记不会崩,且返回空数组(结果切换后靠它清掉上一次的标记)", () => {
assert.deepEqual(prepareMarkers([], TIMES), []);
});
test("series 无数据时全部标记被丢弃(而不是抛错)", () => {
assert.deepEqual(prepareMarkers([{ time: "2024-01-02", kind: "BUY" }], []), []);
});
test("SIG_BUY/SIG_SELL 也走 BUY/SELL 的默认文案规则", () => {
const out = prepareMarkers(
[
{ time: "2024-01-02", kind: "SIG_BUY" },
{ time: "2024-01-03", kind: "SIG_SELL" },
],
TIMES
);
assert.deepEqual(
out.map((m) => m.text),
["B", "S"]
);
});
+55
View File
@@ -0,0 +1,55 @@
/**
* 买卖点标记的准备逻辑(纯函数,无 DOM/无图表实例依赖)。
*
* 之所以单独成模块:Lightweight Charts 对标记有两条**硬约束**,
* 违反时不会报错、只会静默丢标记或抛异常——
* 1. 标记的 `time` 必须存在于它所挂载的那条 series 的数据中;
* 2. 标记数组必须按时间**升序**。
* 这两条属于「错了也看不出来」的坑,因此抽成纯函数并配单元测试
* (`components/charts/__tests__/markers.test.ts`,见 package.json 的 test:charts)。
*/
import type { LwMarker } from "./LwChart";
/**
* 判定「买入方向」。
*
* 注意不能用 `kind.startsWith("BUY")`:`"SIG_BUY"` 并不以 `"BUY"` 开头,
* 那样会把信号买点画成卖出箭头(aboveBar/arrowDown)—— 这正是本文件单元测试
* 捕获到的问题,因此这里显式枚举而不是做前缀匹配。
*/
export function isBuyMarker(kind: LwMarker["kind"]): boolean {
return kind === "BUY" || kind === "SIG_BUY";
}
/** 后端语义(BUY/SELL)与图表语义(箭头位置/颜色)的映射在 LwChart 内完成,这里只做几何准备 */
export interface PreparedMarker {
time: string;
/** 原始语义,交给调用方决定颜色/形状 */
kind: LwMarker["kind"];
text: string;
}
/**
* 过滤到真实存在的时间点、去重、按时间升序。
*
* @param markers 页面给的语义标记(顺序任意、可能带有 series 里不存在的日期)
* @param times 该 series 实际拥有的时间点(升序)
*/
export function prepareMarkers(markers: LwMarker[], times: string[]): PreparedMarker[] {
const valid = new Set(times);
const seen = new Set<string>();
const out: PreparedMarker[] = [];
for (const m of markers) {
if (!valid.has(m.time)) continue;
// 同一时间同一方向只保留一个(例如一天内多笔买入合并为一个买点)
const key = `${m.time}|${m.kind}`;
if (seen.has(key)) continue;
seen.add(key);
out.push({
time: m.time,
kind: m.kind,
text: m.text ?? m.label ?? (isBuyMarker(m.kind) ? "B" : "S"),
});
}
return out.sort((a, b) => (a.time < b.time ? -1 : a.time > b.time ? 1 : 0));
}
+81
View File
@@ -0,0 +1,81 @@
/**
* 图表统一主题(TradingView Lightweight Charts)。
*
* 单一来源:所有图表组件只从这里取色,避免各页面各自硬编码导致深浅不一的观感。
* 与 globals.css 的 CSS 变量保持同一套语义色(pos=涨/盈利口径按 A 股习惯为红涨绿跌,
* 但收益曲线的“正/负”沿用页面既有的绿正红负,故分 `up/down` 与 `pos/neg` 两组)。
*/
export const CHART = {
/** 透明底:跟随卡片背景,避免图表出现色块拼接 */
background: "transparent",
text: "#a8b3c9",
textStrong: "#e9eef7",
faint: "#8b98b2",
grid: "rgba(150,165,195,0.10)",
border: "rgba(150,165,195,0.22)",
crosshair: "rgba(150,165,195,0.45)",
tooltipBg: "rgba(20,29,48,0.96)",
tooltipBorder: "rgba(150,165,195,0.28)",
/** 收益语义(绿=正、红=负,与页面 Pill/Metric 一致) */
pos: "#3ddc97",
neg: "#ff7a7a",
accent: "#3fb6ff",
violet: "#b98bff",
amber: "#d4a72c",
/** A 股涨跌语义(红涨绿跌) */
up: "#e5484d",
down: "#2fb36b",
/** 多序列叠加配色(实验对比用,最多 8 条) */
series: [
"#3fb6ff",
"#3ddc97",
"#d4a72c",
"#b98bff",
"#ff7a7a",
"#4dd0e1",
"#f06292",
"#9ccc65",
],
} as const;
/** 买卖点标记语义色:与图例文字一致,买入=绿、卖出=红/紫 */
export const MARKER_COLORS = {
BUY: "#3ddc97",
SELL: "#ff5a5a",
SIG_BUY: "#b98bff",
SIG_SELL: "#8b7bd8",
} as const;
export function seriesColor(index: number): string {
return CHART.series[index % CHART.series.length];
}
/** 去掉 "YYYY-MM-DD" 的年份,用于横轴/表格紧凑显示 */
export function shortDate(d: string): string {
return d.length >= 10 ? d.slice(5) : d;
}
/** 千分位 + 指定小数位(净值/金额) */
export function fmtNum(v: number, digits = 0): string {
return v.toLocaleString("zh-CN", {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
});
}
/** 百分比(入参已是百分数,如 24.86 → "+24.86%") */
export function fmtPct(v: number, digits = 2): string {
return `${v > 0 ? "+" : ""}${v.toFixed(digits)}%`;
}
/** 紧凑金额:12.5 万 / 1.23 亿 */
export function fmtMoney(v: number): string {
const abs = Math.abs(v);
if (abs >= 1e8) return `${(v / 1e8).toFixed(2)} 亿`;
if (abs >= 1e4) return `${(v / 1e4).toFixed(2)} 万`;
return fmtNum(v, 0);
}
+33 -1
View File
@@ -36,7 +36,12 @@ export type IconName =
| "link"
| "spark"
| "clock"
| "filter";
| "filter"
| "plus"
| "edit"
| "trash"
| "download"
| "copy";
const P: Record<
IconName,
@@ -140,6 +145,33 @@ const P: Record<
</>
),
check: () => <path d="m5 12.5 4.5 4.5L19 7.5" strokeLinecap="round" strokeLinejoin="round" />,
plus: () => <path d="M12 5.5v13M5.5 12h13" strokeLinecap="round" />,
edit: () => (
<>
<path d="M4.5 19.5h4l10-10a2.12 2.12 0 0 0-3-3l-10 10v3Z" strokeLinejoin="round" />
<path d="m14.5 6.5 3 3" strokeLinecap="round" />
</>
),
trash: () => (
<>
<path d="M5 7.5h14" strokeLinecap="round" />
<path d="M9.5 7.5V5.5h5v2" strokeLinejoin="round" />
<path d="M6.5 7.5 7.4 19a1.5 1.5 0 0 0 1.5 1.4h6.2a1.5 1.5 0 0 0 1.5-1.4l.9-11.5" strokeLinejoin="round" />
</>
),
download: () => (
<>
<path d="M12 3.5v10.5" strokeLinecap="round" />
<path d="M7.5 10 12 14.5 16.5 10" strokeLinecap="round" strokeLinejoin="round" />
<path d="M4.5 19.5h15" strokeLinecap="round" />
</>
),
copy: () => (
<>
<rect x="9" y="9" width="10.5" height="10.5" rx="2" />
<path d="M15 6.5A2 2 0 0 0 13 4.5H6.5a2 2 0 0 0-2 2V13a2 2 0 0 0 2 2" strokeLinecap="round" />
</>
),
info: () => (
<>
<circle cx="12" cy="12" r="9" />
+8 -2
View File
@@ -3,6 +3,7 @@
* 按钮 / 表单字段 / 提示条 / 空态 / 骨架 / 进度 / 回测结果区块。
*/
import { useId } from "react";
import { RichText } from "@/components/RichText";
import type { ButtonHTMLAttributes, InputHTMLAttributes, ReactNode } from "react";
import type { BacktestSummary } from "@/lib/types";
import { Icon, type IconName } from "@/components/icons";
@@ -43,6 +44,7 @@ export function Card({
children,
flush = false,
className,
id,
}: {
title?: ReactNode;
sub?: ReactNode;
@@ -51,10 +53,12 @@ export function Card({
children: ReactNode;
flush?: boolean;
className?: string;
/** 锚点 id:长结果页的分区导航(#sec-equity 等)依赖它 */
id?: string;
}) {
const hasHead = title != null || tools != null;
return (
<section className={cx("card", className)}>
<section className={cx("card", className)} id={id} style={id ? { scrollMarginTop: 76 } : undefined}>
{hasHead ? (
<div className="card__head">
<div className="card__title">
@@ -441,7 +445,9 @@ export function UnimplementedNote({ items }: { items: string[] }) {
<Card title="未建模约束(如实标注)" icon="info">
<ul className="hint" style={{ margin: 0, paddingLeft: 18 }}>
{items.map((u, i) => (
<li key={i}>{u}</li>
<li key={i}>
<RichText text={u} />
</li>
))}
</ul>
</Card>