Files
qlib/backend/app/domain/entities/research.py
T
Simon 48a97c2a12 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 渲染,截图确认表格与曲线数值正确。
2026-10-01 17:57:00 +08:00

556 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""研究领域对象:Research Specification、标准化研究结果。
原则(AGENT.md §16/§21/§24、ARCHITECTURE §14):
- 前端 / Agent / 后端统一经 Research Specification 描述任务,禁止直接拼引擎配置
- 回测结果一律标准化为 BacktestResult;未建模的成本/市场约束显式列在
unimplemented,禁止默认「无成本 / 永远可成交」假设
"""
from __future__ import annotations
from datetime import date, datetime
from pydantic import BaseModel, Field, field_validator, model_validator
# ---------- Research Specification ----------
class UniverseSpec(BaseModel):
"""股票池口径。MVP:市场 + 过滤条件;指数成分等 Phase 3 扩展。
symbols 白名单:非空时仅这些股票参与(再叠加其余过滤);供自选池/测试使用。
market 目前为预留字段(stock.market 存储主板/创业板/科创板等中文枚举,过滤未启用)。
"""
market: str = Field(default="CN_A", description="CN_A / CN_B / ...(预留)")
exclude_st: bool = True
exclude_suspended: bool = True
min_listing_days: int = Field(default=250, ge=0, description="上市至少 N 个自然日")
index_code: str | None = Field(
default=None, description="指数成分过滤(如 000300.SH):按 as_of 当日历史成分(v3 §9)"
)
symbols: list[str] = Field(
default_factory=list,
description="白名单(可选):非空时仅这些 symbol 参与选股/回测",
)
class FactorSpec(BaseModel):
"""引用一个已注册因子并给定权重。"""
name: str
weight: float = Field(default=1.0, gt=0)
class ConditionSpec(BaseModel):
"""结构化选股条件(回测与选股共用)。
字段域:
- static.*:股票基础字段(industry / market / area / exchange / status…)
- 行情/技术字段:close / ma20 / ma60 / volume 及全部已注册因子名(momentum_60 等),
以及每日指标列(dv_ratio / dv_ttm / pe / pb / total_mv …)
- fundamental.*:财务字段(eps / roe / total_revenue / net_profit / gross_margin),
仅取 announce_date <= as_of 的最新已公告值(防未来函数)
右操作数取 value(字面量)或 ref(另一字段名),二者二选一。
字段域的事实来源:`quant/condition_fields.py`(字段库注册表,含中文名与口径)。
`/api/condition-fields`(前端下拉)、该注册表与引擎求值共用同一份定义,
避免「前端列一个、引擎算另一个」的漂移。
定义位置说明:本模型被 ResearchSpec(回测)与 SelectionQuery(选股)共用,
故落在 research.py(被 selection.py 依赖的低层模块),selection.py 再 re-export,
避免循环导入。
"""
field: str
op: str = Field(pattern="^(gt|gte|lt|lte|eq|ne|in|not_in)$")
value: float | int | str | list | None = None
ref: str | None = None # 与另一字段比较(如 close vs ma60)
@model_validator(mode="after")
def _require_operand(self) -> ConditionSpec:
if self.value is None and self.ref is None:
raise ValueError("value 与 ref 必须提供一个")
if self.value is not None and self.ref is not None:
raise ValueError("value 与 ref 只能提供一个")
if self.op in ("in", "not_in") and not isinstance(self.value, list):
raise ValueError("in/not_in 的 value 必须是列表")
return self
class SelectionSpec(BaseModel):
"""选股方式(两级截断)。
口径(用户案例「选 n 只 → 持仓前 x 只」):
- `top_n` = n:**候选池**大小。universe ∩ conditions 过滤后,按复合因子分降序取前 n
只 → 这就是「择股条件选出来的股数」(写入 selection_history)。
- `hold_top_x` = x:**实际持仓数**,取候选池前 x 只等权。x 必须 ≤ n;
另受「池内实际可买股票数」约束(过滤/缺数据会让实际池子小于 n)。
None → 等于 top_n(此时与旧行为一致:选出多少就持多少)。
`allow_substitute` 与 `defer_buy` 决定「买不进」时的处理(两者互斥,只能选一个):
- `allow_substitute=True`(**默认**,保持历史语义不变):从 n 名**之外**继续往下找
可买标的补足 x 只 —— 引擎既有行为,见 v3 §20.3 的 Signal↔Fill 测试。
- `defer_buy=True`(本项目「只买选出来的前 x 只」口径,推荐显式开启):
**不替补**,把这只股票的买单**顺延到之后第一个可成交的交易日**(涨停/停牌解除后
按当日收盘价买入);到下一次调仓仍未成交则作废,未投入资金留作现金。
- 两者都 False:意图被拒后直接放弃,资金留现金(不替补也不顺延)。
默认值刻意保持「向后兼容」:既有 Strategy / Experiment 的语义不因本次扩展而静默改变
(AGENT.md §35)。高股息案例在前端与 spec 中显式设置 defer_buy=True。
"""
top_n: int = Field(default=30, ge=1, le=1000, description="n:候选池大小")
hold_top_x: int | None = Field(
default=None, ge=1, le=1000, description="x:实际持仓数;None → = top_n"
)
allow_substitute: bool = Field(
default=True,
description="True(默认,历史语义):从 n 名之外替补补足;False:不引入计划外标的",
)
defer_buy: bool = Field(
default=False,
description="True:买不进(涨停/停牌)时顺延到之后首个可成交日的收盘买入",
)
@model_validator(mode="after")
def _check_x_le_n(self) -> SelectionSpec:
if self.hold_top_x is not None and self.hold_top_x > self.top_n:
raise ValueError(
f"hold_top_x(持仓 x={self.hold_top_x})不能大于 top_n(候选池 n={self.top_n})"
)
if self.allow_substitute and self.defer_buy:
raise ValueError(
"allow_substitute=True(往下替补)与 defer_buy=True(顺延买入)语义互斥,只能选一个"
)
return self
@property
def x(self) -> int:
"""实际持仓目标数(未显式给 x 时等于 n)。"""
return self.hold_top_x if self.hold_top_x is not None else self.top_n
class PortfolioSpec(BaseModel):
"""组合构建(v2 §16)。MVP:等权;单股/行业上限等约束字段预留,
未建模约束在回测结果 unimplemented 中如实标注(禁止假装支持)。
"""
weighting: str = Field(default="equal", pattern="^(equal)$")
max_position_pct: float | None = Field(
default=None, gt=0, le=1, description="单股最大权重(预留,未建模)"
)
max_industry_weight_pct: float | None = Field(
default=None, gt=0, le=1, description="行业最大权重(预留,未建模)"
)
class CostSpec(BaseModel):
"""交易成本模型。
买:commission(≥ min_commission)+ slippage
卖:commission(≥ min_commission)+ stamp_tax + slippage
`min_commission` 为**单笔最低佣金**(A 股常见 5 元)。默认 0.0 = 不启用,
以保持既有回测数值不变(AGENT.md §35);高股息等实盘贴近场景建议显式设 5.0。
注意:最低佣金对**小额单**影响显著,x 越多、单笔越小,成本占比越高。
"""
commission_rate: float = Field(default=0.0003, ge=0, le=0.01)
stamp_tax_rate: float = Field(default=0.0005, ge=0, le=0.01)
slippage_rate: float = Field(default=0.001, ge=0, le=0.05)
min_commission: float = Field(
default=0.0, ge=0, le=100.0, description="单笔最低佣金(元);0 = 不启用"
)
benchmark: str = Field(default="000300.SH", description="对照基准指数代码")
class ResearchSpec(BaseModel):
"""一次研究的完整描述。type 决定执行路径。"""
type: str = Field(default="backtest", pattern="^(factor_test|backtest)$")
universe: UniverseSpec = UniverseSpec()
price_adjustment: str = Field(
default="none", pattern="^(none|qfq|hfq)$",
description=(
"研究行情口径:none 不复权(默认)/ qfq 前复权 / hfq 后复权。"
"qfq/hfq 基于 adjust_factor 折算(v3 §20.5);结果与 config_snapshot 中显式记录。"
),
)
factors: list[FactorSpec] = Field(min_length=1)
conditions: list[ConditionSpec] = Field(
default_factory=list,
description=(
"选股过滤条件(AND,可选):universe 之后、因子排序之前执行。"
"字段域同 SelectionQuery.conditions(static.* / 行情列 / 已注册因子 / "
"fundamental.*),回测与 /api/selections 共用同一求值器(v2 §25 一致性)。"
),
)
selection: SelectionSpec = SelectionSpec()
rebalance: str = Field(default="monthly", pattern="^(weekly|monthly)$")
selection_interval_months: int | None = Field(
default=None, ge=1, le=60,
description=(
"m:择股间隔(月)。None → 每次调仓都重新择股(等价于 rebalance 频率)。"
"择股日 = 起始月锚定,月序号 % m == 0 的月份的首个交易日。"
),
)
rebalance_interval_months: int | None = Field(
default=None, ge=1, le=60,
description=(
"y:调仓间隔(月)。None → 等于 selection_interval_months(未给则按 rebalance 频率)。"
"y < m 时池子在下一次择股前保持不变(结果中会标注池子陈旧)。"
),
)
period: tuple[date, date]
costs: CostSpec = CostSpec()
portfolio: PortfolioSpec = PortfolioSpec()
initial_capital: float = Field(default=1_000_000.0, gt=0)
@field_validator("period")
@classmethod
def _period_ordered(cls, period: tuple[date, date]) -> tuple[date, date]:
if period[0] >= period[1]:
raise ValueError("period 必须满足 start < end")
return period
@model_validator(mode="after")
def _no_duplicate_factors(self) -> ResearchSpec:
names = [f.name for f in self.factors]
if len(set(names)) != len(names):
raise ValueError("factors 存在重复因子名")
return self
@model_validator(mode="after")
def _check_intervals(self) -> ResearchSpec:
m = self.selection_interval_months
y = self.rebalance_interval_months
# 未给 m 却给了 y:语义不完整(y 无锚点可依)→ 明确拒绝而非猜
if m is None and y is not None and y != 1:
raise ValueError(
"只给了 rebalance_interval_months(y) 而没给 selection_interval_months(m):"
"请同时给出 m,否则无法确定择股日集合"
)
if m is not None and y is not None and y < m and y != 1:
# 允许但不静默:池子会在多个调仓日复用(陈旧),交由结果 unimplemented 标注
return self
return self
@property
def effective_selection_months(self) -> int | None:
"""实际择股间隔(月);None 表示「每次调仓都择股」。"""
return self.selection_interval_months
@property
def effective_rebalance_months(self) -> int | None:
"""实际调仓间隔(月);None 表示按 rebalance 频率(周/月)。"""
if self.rebalance_interval_months is not None:
return self.rebalance_interval_months
return self.selection_interval_months
# ---------- 回测结果 ----------
class CurvePoint(BaseModel):
date: date
value: float
class MonthlyReturn(BaseModel):
year: int
month: int
return_pct: float # 百分数,如 3.2 表示 +3.2%
class YearlyReturn(BaseModel):
year: int
return_pct: float
class BacktestSummary(BaseModel):
start: date
end: date
initial_capital: float
final_equity: float
total_return_pct: float
annual_return_pct: float
sharpe: float
max_drawdown_pct: float
volatility_pct: float
win_rate_pct: float
total_trades: int
avg_turnover_pct: float
benchmark_return_pct: float | None = None
class TradeReason(BaseModel):
"""一次交易意图 / 成交的**结构化理由**:用当时的真实数字解释「为什么买 / 为什么卖」。
为什么不让前端自己推:界面上出现的每个数字(排名、综合分、因子值、持有天数)
都必须来自引擎当时的计算,否则就是「看着像真的」。理由因此分三层:
- `code`:机器可判定的原因分类(封闭取值,见 `quant/trade_reasons.py`)。
前端据此筛选 / 上色,不去解析文案;
- `text`:给人读的一句话(自带关键数字,可单独展示);
- `data`:当时真实数值(`rank` / `total` / `score` / `top_n` / `hold_days` /
`factors`(各因子当时的原始值)/ `budget` …)。前端只展示,不推算。
"""
code: str
text: str
data: dict = Field(default_factory=dict)
class Trade(BaseModel):
entry_date: date
exit_date: date
symbol: str
name: str | None = Field(default=None, description="股票名称(展示用)")
entry_price: float
exit_price: float
return_pct: float
entry_reason: TradeReason | None = Field(
default=None, description="买入理由(建仓当日引擎给出的结构化理由)"
)
exit_reason: TradeReason | None = Field(
default=None, description="卖出理由(了结当日引擎给出的结构化理由)"
)
class Position(BaseModel):
date: date
symbol: str
name: str | None = Field(default=None, description="股票名称(展示用)")
weight: float
class RankedPick(BaseModel):
"""调仓日选股意图候选(与 select(as_of) 同源;v3 §22.3 selection_history)。"""
date: date
symbol: str
name: str | None = Field(default=None, description="股票名称(展示用)")
rank: int
score: float
class ActionRecord(BaseModel):
"""一次交易意图(Signal)及其成交结果(Fill)—— v3 §20.3 Signal↔Fill 区分。
signal=BUY/SELL(策略意图);filled=是否实际成交;reject_reason 给出未成交原因
(涨停/跌停/无价/现金不足等)。fills = [a for a in signal_history if a.filled]。
`reason` 是**数据化**的为什么:`reject_reason` 只说「没成交」(执行层),
`reason` 同时覆盖成交与未成交(策略层 + 执行层),并带上当时的排名 / 综合分 /
各因子原始值,前端「买卖说明」直接用,不再二次推断。
`name` 为展示增强字段:由服务层按股票池统一回填(未命中则为 None),
引擎自身不感知名称 —— 引擎只处理 symbol,保持纯行情计算职责。
"""
date: date
symbol: str
name: str | None = Field(default=None, description="股票名称(展示用)")
signal: str = Field(pattern="^(BUY|SELL)$")
filled: bool
reject_reason: str | None = None
price: float | None = Field(default=None, description="成交价(fill)或意图参考价")
reason: TradeReason | None = Field(
default=None, description="结构化理由(成交与未成交都有;旧归档为 null)"
)
class SymbolCurve(BaseModel):
"""个股收益率趋势曲线 + 该股买卖点标注(回测结果可视化用)。
`points[].value` 语义:该股**持仓期间**的累计收益率(%,以建仓日收盘为 0% 基准,
按日复利)。只在该股被持有的交易日落点(未持有期间不落点,以压缩结果体积);
建仓当日会补一个基准点,保证买卖点标注总能在曲线上取到数值。多段持仓以累计值
连乘衔接,读图时以 marks 中的 BUY/SELL 区分各段持仓区间。
`marks` 为该股实际成交(BUY/SELL fill)的日期与价格,与 `signal_history`
中 filled=True 的记录一致(v3 §20.3 的成交口径)。
"""
symbol: str
name: str | None = Field(default=None, description="股票名称(展示用)")
points: list[CurvePoint] = Field(default_factory=list)
marks: list[ActionRecord] = Field(default_factory=list)
final_return_pct: float = Field(
default=0.0, description="该股持仓期累计收益率(%,多段持仓连乘)"
)
class FactorCurve(BaseModel):
"""单个因子在回测期内的时间序列(**持仓组合加权平均原始值**)。
口径必须写死,否则读图会读反:
- 值为该因子在**当日持仓股票**上的权重加权平均(权重 = 该股当日市值 / 组合权益),
是**原始值**:不做 z-score、不按方向取负 —— 图上看到的就是因子本身;
- 空仓日不落点(不插值、不用 0 假填充),曲线中间会出现空档;
- `direction` 一并归档:低为好的因子,曲线升高不等于「更好」;
- `unit` 是代码注册表里的事实(`%` / `倍数` / `小数`),用于坐标轴与提示文案。
"""
name: str
label: str
direction: str = "higher_is_better"
unit: str | None = None
points: list[CurvePoint] = Field(default_factory=list)
class BacktestResult(BaseModel):
"""标准化回测结果(ARCHITECTURE §14)。前端只依赖该结构。"""
summary: BacktestSummary
equity_curve: list[CurvePoint]
drawdown: list[CurvePoint]
monthly_returns: list[MonthlyReturn]
yearly_returns: list[YearlyReturn]
positions: list[Position]
trades: list[Trade]
selection_history: list[RankedPick] = Field(
default_factory=list, description="各调仓日选股意图候选(同 select(as_of))"
)
signal_history: list[ActionRecord] = Field(
default_factory=list, description="交易意图与是否成交(v3 §20.3)"
)
fills: list[ActionRecord] = Field(
default_factory=list, description="实际成交(signal_history 中 filled=True 的子集)"
)
symbol_curves: list[SymbolCurve] = Field(
default_factory=list,
description="个股收益率曲线 + 买卖点标注(按期末收益绝对值降序,体积可控)",
)
factor_curves: list[FactorCurve] = Field(
default_factory=list,
description=(
"策略用到的每个因子的时间序列(持仓加权平均原始值):"
"用来解释「买卖依据的那个因子在各时点是什么水平」"
),
)
turnover_pct: float
unimplemented: list[str] = Field(
default_factory=list,
description="本结果中未建模的约束(AGENT §24:必须显式标注,禁止假装支持)",
)
config_snapshot: dict = Field(default_factory=dict, description="复现用完整配置快照")
archive_meta: dict = Field(
default_factory=dict,
description=(
"归档元数据(由 experiment_archive 在落库时写入):curves_stored / "
"curves_total / truncated / budget_chars / budget_bytes / result_chars / "
"result_bytes。用于说明归档是否因体积预算被裁剪(AGENT §24 不静默)"
),
)
SymbolCurve.model_rebuild()
# ---------- 因子测试结果 ----------
class QuantileReturn(BaseModel):
"""分层收益:按因子值升序分 N 层后各层等权组合的区间收益。"""
quantile: int
return_pct: float
class FactorTestReport(BaseModel):
factor_name: str
ic_mean: float
icir: float
rank_ic_mean: float
positive_ratio_pct: float
quantile_returns: list[QuantileReturn]
spread_quantile: int | None = Field(
default=None, description="分层价差 = 最高层收益 - 最低层收益(若多头/空头语义适用)"
)
sample_days: int
unimplemented: list[str] = Field(default_factory=list)
config_snapshot: dict = Field(default_factory=dict)
# ---------- 因子相关性 / 暴露分析(C1,v3 §12) ----------
class FactorCorrelationReport(BaseModel):
"""多因子两两相关(横截面相关逐日均值;v3 §12 冗余剔除前置)。"""
factors: list[str]
corr_matrix: dict[str, dict[str, float]] = Field(
default_factory=dict, description="{f1: {f2: spearman 相关系数}}(对角线=1)"
)
sample_days: int = 0
sample_min_symbols: int = 0
unimplemented: list[str] = Field(default_factory=list)
config_snapshot: dict = Field(default_factory=dict)
# ---------- 异步 Job 与 Experiment(Phase 4) ----------
class JobStatus(str):
"""统一状态机(AGENT.md §20):queued→running→(success|failed|cancelled)。"""
QUEUED = "queued"
RUNNING = "running"
SUCCESS = "success"
FAILED = "failed"
CANCELLED = "cancelled"
class JobRecord(BaseModel):
"""一次异步研究任务。spec/result 以 JSON 文本存储(保持 Schema 演进自由)。"""
id: str
kind: str # factor_test | backtest
status: str = JobStatus.QUEUED
stage: str | None = None
spec_json: str
error: str | None = None
result_json: str | None = None
experiment_id: str | None = None
created_at: datetime | None = None
started_at: datetime | None = None
finished_at: datetime | None = None
class ExperimentRecord(BaseModel):
"""一次研究的可复现存档(AGENT.md §21)。"""
id: str
kind: str # factor_test | backtest
spec_json: str
result_json: str
summary_text: str | None = None # 便于列表展示的摘要(如 total_return_pct)
code_version: str | None = None # git commit / 代码指纹
data_version: str | None = None
job_id: str | None = None
created_at: datetime | None = None
class ExperimentSummary(BaseModel):
"""归档列表项(**不含 result_json**)。
列表接口一次可能返回上百条归档,而 `result_json` 是 MEDIUMTEXT(完整存档后
单条可达数 MB):为避免把上百 MB 拉进内存,仓储的列表查询只取元数据列,
`result_bytes` 由 SQL 的字符长度函数(MySQL CHAR_LENGTH / SQLite length)
在库侧算出,不取回大字段本身。
"""
id: str
kind: str
spec_json: str
summary_text: str | None = None
code_version: str | None = None
data_version: str | None = None
job_id: str | None = None
created_at: datetime | None = None
result_bytes: int = 0 # 归档 JSON 的字符数(SQL 侧计算,不拉大字段)