Files
qlib/backend/app/domain/entities/research.py
T
Simon 2e90f3eeac feat(backend): 字段库(condition_field)+ 因子参数化(模板/受控参数)+ 单位换算底座
字段库(本次新增的表与接口):
- `condition_field` 表 + `/api/condition-fields`:中文名/说明可编辑、可停用;
  `kind`/单位阶梯/`base_unit` 由代码注册表收敛(改类型 422,伪字段 422,
  越界单位 422),停用的字段不再进条件下拉,但既有策略仍按名字解析。
- 说明书里的数值条件按字段注册表补**基准单位**后缀(字段间比较不加,不猜单位)。

因子参数化(键即身份,冻结口径):
- 模板 + 参数注册表(`quant/factors.py`):`ParamSpec`(类型/范围/枚举/默认值/说明)+
  `FactorTemplate`(公式/依赖列/参数);规范键把**全部**参数写进名字,如
  `momentum(window=90,direction=lower_is_better)`,所以改参数 = 新建一个身份,
  旧因子/既有策略/已归档实验都不变义;`momentum(window=90)`(缺参数)明确拒绝 ——
  缺项要靠模板默认值补齐,而默认值是可改的代码细节,一旦改动会追溯性改义。
- 参数只在受控范围内取值(窗口 2~500、方向二选一),越界/未知模板/多给参数一律 422
  并列出允许范围,不静默截断、不悄悄取默认值;内置实例的启用开关由代码决定(422)。
- `/api/factors` 暴露 `template`/`params`/`param_specs`/`label`/`source`/`enabled`/
  `resolvable`;新增 `/api/factors/templates`、`POST /api/factors`、`PATCH /api/factors`;
  `get_factor = resolve_factor` 兼容全部旧调用点,参数化键也是一等条件字段。
- 迁移链:c5d6(存量策略陈旧说明重算)→ d6e7(condition_field)→ a7c1
  (factor_definition.enabled + name varchar(128))。

测试:新增 test_condition_fields.py / test_factor_params.py;全量 pytest 500 passed。
2026-10-01 16:33:32 +08:00

499 lines
20 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 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
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]。
`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)或意图参考价")
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 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="个股收益率曲线 + 买卖点标注(按期末收益绝对值降序,体积可控)",
)
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 侧计算,不拉大字段)