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:
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* 回测结果曲线 → 图表序列的**唯一构造处**。
|
||||
*
|
||||
* 为什么单独抽出来:同一条曲线会在三个地方出现 —— 回测结果页、归档详情页、
|
||||
* 以及「新页面放大」的 `/charts/{id}`。三处各写一份格式化逻辑,迟早出现
|
||||
* 「同一张图两个页面数值口径不一样」。这里把每类曲线的取数、颜色、数值格式、
|
||||
* 买卖点标注统一成函数,页面只管摆放。
|
||||
*/
|
||||
|
||||
import type { LwFormatKey, LwSeries, LwMarker } from "@/components/charts/LwChart";
|
||||
import { CHART, fmtNum } from "@/components/charts/theme";
|
||||
import type { ActionRecord, BacktestResult, FactorCurve, SymbolCurve } from "@/lib/types";
|
||||
|
||||
/** 因子值的量纲说明与人读格式("%" / "倍数" / "小数",None = 无量纲) */
|
||||
export function factorValueFormat(curve: FactorCurve): (v: number) => string {
|
||||
if (curve.unit === "%") return (v) => `${v.toFixed(3)}%`;
|
||||
if (curve.unit === "倍数") return (v) => `${v.toFixed(3)}×`;
|
||||
// 小数:原值 0.15 = 15% —— 图上按百分数显示更好读,但标签里会注明「原值为小数」
|
||||
if (curve.unit === "小数") return (v) => `${(v * 100).toFixed(2)}%`;
|
||||
return (v) => v.toFixed(4);
|
||||
}
|
||||
|
||||
/**
|
||||
* 因子曲线的格式标识(给 Server Component 用)。
|
||||
*
|
||||
* 与 `factorValueFormat` 必须一致:两处定义同一件事会漂移,因此这里直接按 unit 分支,
|
||||
* 且在单测/自检里比对两者的输出。
|
||||
*/
|
||||
export function factorFormatKey(curve: FactorCurve): LwFormatKey {
|
||||
if (curve.unit === "%") return "pct3";
|
||||
if (curve.unit === "倍数") return "times3";
|
||||
if (curve.unit === "小数") return "frac-pct";
|
||||
return "auto4";
|
||||
}
|
||||
|
||||
/** 因子曲线口径的完整说明(图上必须写,避免把「持仓加权平均」读成别的口径) */
|
||||
export function factorCurveNote(curve: FactorCurve): string {
|
||||
const unitNote =
|
||||
curve.unit === "小数"
|
||||
? "原值为小数(0.15 即 15%),图上按百分数显示"
|
||||
: curve.unit
|
||||
? `单位:${curve.unit}`
|
||||
: "无量纲";
|
||||
const dir = curve.direction === "lower_is_better" ? "越低越好" : "越高越好";
|
||||
return (
|
||||
`口径:每个交易日**当日持仓按市值加权平均**的因子原始值(不做 z-score、不按方向取反),` +
|
||||
`空仓日不落点。${unitNote};方向 ${dir}。`
|
||||
);
|
||||
}
|
||||
|
||||
/** 因子曲线序列 */
|
||||
export function factorSeries(curve: FactorCurve, index = 0): LwSeries {
|
||||
const colors = [CHART.accent, CHART.violet, CHART.pos, CHART.neg, CHART.amber];
|
||||
return {
|
||||
key: `factor-${curve.name}`,
|
||||
label: `${curve.label}(持仓加权)`,
|
||||
type: "line",
|
||||
lineWidth: 2,
|
||||
color: colors[index % colors.length],
|
||||
data: curve.points.map((p) => ({ time: p.date, value: p.value })),
|
||||
lastValueVisible: true,
|
||||
};
|
||||
}
|
||||
|
||||
/** 同一日多次成交合并成一个标记(图上不叠字) */
|
||||
export function portfolioMarkers(
|
||||
fills: ActionRecord[] | undefined,
|
||||
validDates?: Set<string>
|
||||
): LwMarker[] {
|
||||
const byDate = new Map<string, { BUY: boolean; SELL: boolean }>();
|
||||
for (const f of fills ?? []) {
|
||||
if (validDates && !validDates.has(f.date)) continue;
|
||||
const cur = byDate.get(f.date) ?? { BUY: false, SELL: false };
|
||||
cur[f.signal] = true;
|
||||
byDate.set(f.date, cur);
|
||||
}
|
||||
const out: LwMarker[] = [];
|
||||
for (const [time, kinds] of byDate) {
|
||||
if (kinds.BUY) out.push({ time, kind: "BUY", text: "买" });
|
||||
if (kinds.SELL) out.push({ time, kind: "SELL", text: "卖" });
|
||||
}
|
||||
return out.sort((a, b) => (a.time < b.time ? -1 : 1));
|
||||
}
|
||||
|
||||
export function equitySeries(result: BacktestResult): LwSeries[] {
|
||||
return [
|
||||
{
|
||||
key: "equity",
|
||||
label: "组合净值(元)",
|
||||
type: "area",
|
||||
color: CHART.pos,
|
||||
data: result.equity_curve.map((p) => ({ time: p.date, value: p.value })),
|
||||
lastValueVisible: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function drawdownSeries(result: BacktestResult): LwSeries[] {
|
||||
return [
|
||||
{
|
||||
key: "dd",
|
||||
label: "回撤(%)",
|
||||
type: "area",
|
||||
color: CHART.neg,
|
||||
data: result.drawdown.map((p) => ({ time: p.date, value: p.value })),
|
||||
lastValueVisible: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function symbolSeries(curve: SymbolCurve): LwSeries[] {
|
||||
return [
|
||||
{
|
||||
key: "sym",
|
||||
label: `${curve.symbol} 持仓期累计收益(%)`,
|
||||
type: "area",
|
||||
color: CHART.accent,
|
||||
data: curve.points.map((p) => ({ time: p.date, value: p.value })),
|
||||
lastValueVisible: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function symbolMarkers(curve: SymbolCurve): LwMarker[] {
|
||||
const dates = new Set(curve.points.map((p) => p.date));
|
||||
return (curve.marks ?? [])
|
||||
.filter((a) => dates.has(a.date))
|
||||
.map((a) => ({
|
||||
time: a.date,
|
||||
kind: a.signal,
|
||||
text: a.signal === "BUY" ? "买" : "卖",
|
||||
}));
|
||||
}
|
||||
|
||||
/** 月度收益(柱状) */
|
||||
export function monthlySeries(result: BacktestResult): LwSeries[] {
|
||||
return [
|
||||
{
|
||||
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,
|
||||
})),
|
||||
lastValueVisible: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export const equityValueFormat = (v: number) => fmtNum(v);
|
||||
@@ -191,6 +191,59 @@ export interface MarkPoint {
|
||||
}
|
||||
|
||||
/** 交易意图与成交记录(Signal ↔ Fill,v3 §20.3)。signal 为空字符串表示组合级提示。 */
|
||||
/**
|
||||
* 一次交易意图 / 成交的**结构化理由**(引擎给出的真实数字)。
|
||||
*
|
||||
* `code` 是封闭的原因分类(后端 `quant/trade_reasons.py`),前端据此筛选,
|
||||
* 不去解析 `text`;`data` 里的每个数字都来自引擎当时的计算 —— 界面只展示,不推算。
|
||||
*/
|
||||
export interface TradeReason {
|
||||
code: string;
|
||||
text: string;
|
||||
data: {
|
||||
rank?: number;
|
||||
total?: number;
|
||||
top_n?: number;
|
||||
score?: number;
|
||||
/** 各因子当时的**原始值**(key = 因子引擎键,可能是参数化键) */
|
||||
factors?: Record<string, number>;
|
||||
hold_days?: number;
|
||||
tmin?: number;
|
||||
tmax?: number;
|
||||
price?: number;
|
||||
budget?: number;
|
||||
min_commission?: number;
|
||||
close?: number;
|
||||
prev_close?: number;
|
||||
close_prev_ratio?: number;
|
||||
limit_ratio?: number;
|
||||
return_pct?: number;
|
||||
/** false = 该股已不在候选池(被股票池/条件过滤) */
|
||||
in_pool?: boolean;
|
||||
[k: string]: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
/** 原因分类的中文短标签(与后端 REASON_LABELS 对齐) */
|
||||
export const REASON_LABELS: Record<string, string> = {
|
||||
buy_enter_topn: "按名次建仓",
|
||||
buy_defer_filled: "顺延后成交",
|
||||
buy_skip_limit_up: "涨停未买",
|
||||
buy_skip_halted: "停牌未买",
|
||||
buy_skip_no_cash: "现金不足",
|
||||
buy_skip_min_commission: "不足最低佣金",
|
||||
sell_drop_topn: "跌出 TopN",
|
||||
sell_force_tmax: "持有超 Tmax",
|
||||
sell_defer_tmin: "Tmin 保护暂留",
|
||||
sell_defer_halted: "停牌未卖",
|
||||
sell_defer_limit_down: "跌停未卖",
|
||||
};
|
||||
|
||||
export function reasonLabel(code?: string | null): string {
|
||||
if (!code) return "—";
|
||||
return REASON_LABELS[code] ?? code;
|
||||
}
|
||||
|
||||
export interface ActionRecord {
|
||||
date: string;
|
||||
symbol: string;
|
||||
@@ -200,6 +253,17 @@ export interface ActionRecord {
|
||||
filled: boolean;
|
||||
reject_reason?: string | null;
|
||||
price?: number | null;
|
||||
reason?: TradeReason | null;
|
||||
}
|
||||
|
||||
/** 单个因子的时间序列(回测期内持仓组合加权平均的**原始值**) */
|
||||
export interface FactorCurve {
|
||||
name: string;
|
||||
label: string;
|
||||
direction: string;
|
||||
/** 量纲:% / 倍数 / 小数(小数意味着 0.15 = 15%,图上不换算) */
|
||||
unit?: string | null;
|
||||
points: CurvePoint[];
|
||||
}
|
||||
|
||||
/** 个股收益率曲线 + 该股买卖点标注 */
|
||||
@@ -219,11 +283,23 @@ export interface BacktestResult {
|
||||
monthly_returns: { year: number; month: number; return_pct: number }[];
|
||||
yearly_returns: { year: number; return_pct: number }[];
|
||||
positions: { date: string; symbol: string; name?: string | null; weight: number }[];
|
||||
trades: { entry_date: string; exit_date: string; symbol: string; name?: string | null; entry_price?: number; exit_price?: number; return_pct: number }[];
|
||||
trades: {
|
||||
entry_date: string;
|
||||
exit_date: string;
|
||||
symbol: string;
|
||||
name?: string | null;
|
||||
entry_price?: number;
|
||||
exit_price?: number;
|
||||
return_pct: number;
|
||||
entry_reason?: TradeReason | null;
|
||||
exit_reason?: TradeReason | null;
|
||||
}[];
|
||||
selection_history?: { date: string; symbol: string; name?: string | null; rank: number; score: number }[];
|
||||
signal_history?: ActionRecord[];
|
||||
fills?: ActionRecord[];
|
||||
symbol_curves?: SymbolCurve[];
|
||||
/** 策略用到的每个因子的时间序列(持仓加权平均原始值):解释买卖依据 */
|
||||
factor_curves?: FactorCurve[];
|
||||
turnover_pct: number;
|
||||
unimplemented: string[];
|
||||
config_snapshot: Record<string, unknown>;
|
||||
|
||||
Reference in New Issue
Block a user