汇总三轮未提交的开发(每轮均在本机 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 逐类验证归档页)。
382 lines
13 KiB
TypeScript
382 lines
13 KiB
TypeScript
"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>
|
||
);
|
||
} |