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
+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);
}