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` 逐条一致 —— 理由与曲线
  是纯新增字段,成交行为零变化。
This commit is contained in:
Simon
2026-10-01 18:08:42 +08:00
parent 633176a3d1
commit 7e369d9680
5 changed files with 767 additions and 33 deletions
+14 -4
View File
@@ -10,9 +10,10 @@ 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, score_panel_for_factors
from app.quant.selection import condition_needed_columns
# LocalEngine 路径恒需 close(TopK 收盘撮合 / 前瞻收益)
_CLOSE = {"close"}
@@ -73,7 +74,16 @@ class LocalEngine:
def run_backtest(
self, daily: pd.DataFrame, spec: ResearchSpec, eligibility_fn=None
) -> BacktestResult:
# 评分面板与选股共用同一构建(v2 §25:回测与当前选股同引擎)
score = score_panel_for_factors(daily, spec.factors)
# 因子面板**只算一次**:复合分(选股)与原始值(买卖理由 / 因子曲线)同源。
# 若先 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).run()
return TopKBacktestRunner(
spec, score, close, eligibility_fn=eligibility_fn, factor_panels=factor_panels
).run()