feat(backtest): 买卖点理由(用数据说话)+ 因子曲线 + 曲线新页面放大
用户要求:「所有买卖点详细说明买卖理由,用数据说话」「回测图上增加因子相关曲线
(买卖依据是股息率,就加股息率曲线)」「所有曲线能弹出新页面放大」。
一、买卖理由(后端产出结构化数据,前端只展示)
- 新增 `quant/trade_reasons.py`:封闭词表 + 文案构造器,组合引擎与单策略引擎共用,
避免两个引擎对同一件事写出两种说法。理由里带**引擎当时的真实数字**:
综合分名次/候选数/综合分/各因子原始值/持有交易日/预算与最低佣金/涨停比值等。
- 买入:按名次建仓、顺延成交、涨停未买、停牌未买、现金不足、不足最低佣金;
卖出:跌出 TopN(含第几名掉出)、被股票池过滤(与「跌出 TopN」分开写)、
超 Tmax 强制了结、Tmin 保护暂留、停牌/跌停顺延。
- `ActionRecord.reason` 覆盖**成交与未成交**全部买卖点(原 `reject_reason` 保留不动,
老归档仍可读);`Trade.entry_reason / exit_reason` 跟着成交记录走。
- 名次来自调仓日完整排名(新增 `_ranked_by_day`),拿不到名次时如实写「未给出名次」,
绝不编造一个名次填进去。
- 未成交明细不再只写执行层原因:把「为什么选中它、当时各因子多少」一并给出。
二、因子曲线
- `FactorCurve`:每个策略因子一条曲线,值为**当日持仓按市值加权平均的原始值**
(不做 z-score、不按方向取反,空仓日不落点、不插值、不用 0 填充),并带
label/direction/unit 供界面说明口径;`FactorDef/FactorTemplate` 新增 `unit`
(股息率 %、量比/接近新高 倍数、动量等 小数),11 个内置因子实例已逐一核对。
- 归档体积预算照旧按整包计量,无需改迁移。
三、界面
- 结果页新增「买卖说明」区块:全部买卖点 + 理由 + 数字标签,支持方向/成交状态/关键字
筛选与日期排序;成交明细表加「为什么买 / 为什么卖」两列;新增「因子曲线」区块,
每条曲线标出组合成交日,直接对照「买卖发生在什么水平」。
- 「新页面放大」:每条曲线(净值/回撤/因子/个股/月度)都能开 `/charts/{归档id}?s=...`
整页看大图;放大页是 Server Component,数据从归档直出,URL 可分享且与归档一致。
未归档的结果如实说明「未归档,无法放大」,不给坏链接。
- 数字格式与后端 `f"{v:.4f}"` 同规则(四舍六入五成双):修掉 0.03125 在理由原文里
显示 0.0312、旁边标签显示 0.0313 的不一致(17 组边界值与 Python 逐一比对一致)。
- `/factors/compose` 结果区改用同一个 `BacktestResultView`,两处口径不会再漂移。
验证:
- 新增 `tests/test_trade_reasons.py` 8 条(买入数字、跌出 TopN 名次、不在候选池、
Tmax、Tmin 暂留、涨停未成交、因子曲线加权值、空仓不落点);后端 510 条全过,ruff clean。
- 真实数据端到端:`/api/combos/run` 6 个月高股息组合(EXP-8EA2819B)13 个买卖点
100% 带理由与数字,因子曲线 dividend_yield 117 点、单位 %;
`scripts/verify_backtest_page_contract.py`(4 年、301 个买卖点、140 笔成交)扩展断言
理由词表/名次/因子值/曲线单调性后通过。
- 浏览器实测:归档详情页与放大页 `/charts/...?s=factor:dividend_yield` 等 5 种曲线
全部 200 渲染,截图确认表格与曲线数值正确。
This commit is contained in:
@@ -62,8 +62,17 @@ export interface LwChartProps {
|
||||
series: LwSeries[];
|
||||
markers?: LwMarker[];
|
||||
height?: number;
|
||||
/** 悬浮提示与价格轴的数值格式 */
|
||||
/** 悬浮提示与价格轴的数值格式(客户端组件用;Server Component 请用 formatKey) */
|
||||
valueFormat?: (v: number) => string;
|
||||
/**
|
||||
* 数值格式的**可序列化标识**。
|
||||
*
|
||||
* 为什么需要它:Server Component 不能把函数传给 Client Component(`valueFormat`
|
||||
* 会直接 500:「Functions cannot be passed directly to Client Components」)。
|
||||
* 因此服务端渲染的图表(归档详情、曲线放大页)用这个标识指定格式,由本组件在
|
||||
* 客户端解析成函数 —— 两端看到的数字格式仍然只有一份定义。
|
||||
*/
|
||||
formatKey?: LwFormatKey;
|
||||
/** 画一条 0 基准虚线(收益率曲线推荐开启) */
|
||||
zeroLine?: boolean;
|
||||
legend?: boolean;
|
||||
@@ -73,6 +82,27 @@ export interface LwChartProps {
|
||||
|
||||
type AnySeries = ISeriesApi<"Line"> | ISeriesApi<"Area"> | ISeriesApi<"Histogram">;
|
||||
|
||||
/** 数值格式的可序列化标识(Server Component ↔ Client Component 的桥) */
|
||||
export type LwFormatKey =
|
||||
| "num"
|
||||
| "num0"
|
||||
| "pct2"
|
||||
| "pct3"
|
||||
| "times3"
|
||||
| "auto4"
|
||||
/** 原值为小数、按百分数显示(0.15 → 15.00%) */
|
||||
| "frac-pct";
|
||||
|
||||
export const FORMATTERS: Record<LwFormatKey, (v: number) => string> = {
|
||||
num: (v) => v.toLocaleString("zh-CN", { maximumFractionDigits: 2 }),
|
||||
num0: (v) => v.toLocaleString("zh-CN", { maximumFractionDigits: 0 }),
|
||||
pct2: (v) => `${v.toFixed(2)}%`,
|
||||
pct3: (v) => `${v.toFixed(3)}%`,
|
||||
times3: (v) => `${v.toFixed(3)}×`,
|
||||
auto4: (v) => v.toFixed(4),
|
||||
"frac-pct": (v) => `${(v * 100).toFixed(2)}%`,
|
||||
};
|
||||
|
||||
function toTime(t: string): Time {
|
||||
return t as Time;
|
||||
}
|
||||
@@ -102,6 +132,7 @@ export function LwChart({
|
||||
markers = [],
|
||||
height = 320,
|
||||
valueFormat,
|
||||
formatKey,
|
||||
zeroLine = false,
|
||||
legend = true,
|
||||
ariaLabel,
|
||||
@@ -114,8 +145,9 @@ export function LwChart({
|
||||
const zeroLineDrawn = useRef(false);
|
||||
/** 图例隐藏集合:tooltip 订阅里读取,用 ref 避免闭包过期 */
|
||||
const hiddenRef = useRef<Set<string>>(new Set());
|
||||
const fmtRef = useRef(valueFormat);
|
||||
fmtRef.current = valueFormat;
|
||||
const resolvedFormat = valueFormat ?? (formatKey ? FORMATTERS[formatKey] : undefined);
|
||||
const fmtRef = useRef(resolvedFormat);
|
||||
fmtRef.current = resolvedFormat;
|
||||
|
||||
const [hidden, setHidden] = useState<Set<string>>(new Set());
|
||||
const [tip, setTip] = useState<{
|
||||
|
||||
Reference in New Issue
Block a user