Files
qlib/backend/app/quant/engine.py
T
Simon 7e369d9680 feat(quant): 单策略引擎也给出买卖理由与因子曲线(口径与组合引擎一致)
「因子组合」/`POST /api/research/backtests` 走的是 `LocalEngine/TopKBacktestRunner`,
上一版只把理由接进了组合引擎,同一件事在两个引擎上就会有两种说法。这次补齐:

- `engine.py` / `qlib_adapter/engine.py`:因子面板**只算一次**
  (`build_factor_panels_full`)→ 复合分与「理由里引用的因子原始值」同源同张面板;
  复合分口径逐字未变(与 `selection.score_panel_for_factors` 相同)。
- `local_engine.py`:调仓日保留完整排名与合格集,各站点写入结构化理由 ——
  买入(按名次建仓 / 顺延成交 / 涨停 / 停牌 / 现金不足 / 不足最低佣金)、
  卖出(全量换仓 / 跌出 TopN / 不在候选池 / 停牌顺延 / 跌停顺延);
  `Trade.entry_reason/exit_reason` 两端齐全;每个交易日记录持仓市值,
  结果填 `factor_curves`(持仓市值加权平均的因子原始值,空仓日不落点)。
- 新增 `SELL_REBALANCE_FULL`(「调仓换仓卖出」):单策略调仓是「先全清再建仓」,
  被卖出的股票**可能仍排在 TopN 内**(如 rank=1),这时写「跌出 TopN」就是假解释;
  按事实分 code(仍在 TopN 内 → 全量换仓;否则 → 跌出 TopN / 不在候选池)。
- 顺延成交不拿挂单日的旧名次冒充当日名次(rank/total/score=None,因子值/成交价/预算
  取成交当日真实值);「候选池不足」的提示记录保持 reason=None(词表里没有对应语义,
  硬套就是编理由)。

验证:
- 新增 `tests/test_local_engine_reasons.py` 14 条:理由数字对回面板、涨停比值对回行情与
  板块规则、停牌/跌停/现金不足/最低佣金、顺延成交、全量换仓 vs 不在池两个分支、
  Trade 两端理由、因子曲线市值加权(手算加权值断言 + 等权平均对不上)、空仓不落点。
  后端 524 条全过(510 + 14),ruff clean。
- 强回归:用改前引擎并排跑 9 个场景,`signal_history`(日期/方向/成交/原因文案/价格)、
  `trades`、`positions`、`summary`、净值/回撤、`unimplemented` 逐条一致 —— 理由与曲线
  是纯新增字段,成交行为零变化。
2026-10-01 18:08:42 +08:00

90 lines
3.8 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.
"""研究引擎抽象与默认实现(业务层依赖本接口,可替换引擎)。
切换引擎(如未来在支持平台启用 Qlib)只需注入不同实现 —— 业务代码不变。
"""
from __future__ import annotations
from typing import Protocol
import pandas as pd
from app.domain.entities.research import BacktestResult, FactorTestReport, ResearchSpec
from app.quant.composite import build_factor_panels_full, composite_score
from app.quant.factors import FactorError, get_factor
from app.quant.local_engine import TopKBacktestRunner, run_spec_factor_test
from app.quant.selection import condition_needed_columns
# LocalEngine 路径恒需 close(TopK 收盘撮合 / 前瞻收益)
_CLOSE = {"close"}
def factor_required_columns(spec: ResearchSpec) -> set[str]:
"""spec 因子计算 + 选股条件 + 回测撮合所需的行情数值列(含 close)。"""
needed = set(_CLOSE)
for fs in spec.factors:
try:
defn, _fn = get_factor(fs.name)
except FactorError:
continue # 未知因子由执行期统一报错
needed.update(defn.requires)
if spec.conditions:
# 条件字段同样决定装配列(dv_ratio / volume / ma60 依赖列等);
# 与选股路径共用 condition_needed_columns(v2 §25 一致性)
needed.update(condition_needed_columns(spec))
return needed
class QuantEngine(Protocol):
"""研究引擎端口:因子面板构建 / 因子测试 / 回测。"""
name: str
def required_columns(self, spec: ResearchSpec) -> set[str]:
"""执行该 spec 所需的行情数值列(数据装配按此裁剪,控制内存)。"""
def run_factor_test(
self, daily: pd.DataFrame, spec: ResearchSpec, horizon_days: int = 21
) -> FactorTestReport: ...
def run_backtest(
self, daily: pd.DataFrame, spec: ResearchSpec, eligibility_fn=None
) -> BacktestResult:
"""eligibility_fn(as_of: date) -> set[symbol] | None:选股条件过滤(可选)。
None 表示不过滤;由业务层注入(复用 selection.eligible_symbols),
保证「历史某日 Selection == 回测当日 Selection」(v3 §28)。
"""
class LocalEngine:
"""默认引擎:纯 pandas 实现(无 Qlib 依赖),见 local_engine.py 的纪律说明。"""
name = "local"
def required_columns(self, spec: ResearchSpec) -> set[str]:
return factor_required_columns(spec)
def run_factor_test(
self, daily: pd.DataFrame, spec: ResearchSpec, horizon_days: int = 21
) -> FactorTestReport:
report, _panels = run_spec_factor_test(daily, spec, horizon_days)
return report
def run_backtest(
self, daily: pd.DataFrame, spec: ResearchSpec, eligibility_fn=None
) -> BacktestResult:
# 因子面板**只算一次**:复合分(选股)与原始值(买卖理由 / 因子曲线)同源。
# 若先 score_panel_for_factors 再单独算一遍原始面板,同一份行情会被算两遍,
# 且两次结果理论上可能分叉 —— 打分用的面板与理由里引用的面板必须是同一张。
# 复合分构建口径不变(与 selection.score_panel_for_factors 同为 z-score 加权和,
# v2 §25:回测与当前选股同引擎)。
panels = build_factor_panels_full(daily, spec.factors) # 未知因子在此抛 FactorError
score = composite_score([(d.name, p, w, d.direction) for d, p, w in panels])
# 同名因子只留一份(spec 已禁止重复因子名,这里再兜一层)
factor_panels = {d.name: (d, p) for d, p, _w in panels}
close = daily.pivot(index="trade_date", columns="symbol", values="close").sort_index()
return TopKBacktestRunner(
spec, score, close, eligibility_fn=eligibility_fn, factor_panels=factor_panels
).run()