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
+8 -4
View File
@@ -21,10 +21,10 @@ from pathlib import Path
import pandas as pd
from app.domain.entities.research import BacktestResult, FactorTestReport, ResearchSpec
from app.quant.composite import build_factor_panels_full
from app.quant.engine import QuantEngine
from app.quant.local_engine import (
TopKBacktestRunner,
build_factor_panels,
composite_score,
run_spec_factor_test,
)
@@ -69,8 +69,10 @@ class QlibEngine(QuantEngine):
`eligibility_fn`(选股条件/时点 ST 过滤)必须透传,否则条件与
`exclude_st` 在 Qlib 引擎下会被**静默忽略**(AGENT.md §24 禁止假装支持)。
"""
panels = build_factor_panels(daily, spec.factors)
score = composite_score(panels)
# 与 LocalEngine 同口径:复合分与买卖理由/因子曲线用**同一张**原始因子面板
full = build_factor_panels_full(daily, spec.factors)
score = composite_score([(d.name, p, w, d.direction) for d, p, w in full])
factor_panels = {d.name: (d, p) for d, p, _w in full}
self.qlib_dir.mkdir(parents=True, exist_ok=True)
uri = build_qlib_dataset(daily, self.qlib_dir)
@@ -85,7 +87,9 @@ class QlibEngine(QuantEngine):
)
close = close.sort_index()
result = TopKBacktestRunner(spec, score, close, eligibility_fn=eligibility_fn).run()
result = TopKBacktestRunner(
spec, score, close, eligibility_fn=eligibility_fn, factor_panels=factor_panels
).run()
result.config_snapshot = spec.model_dump(mode="json")
note = _ENGINE_NOTE
result.unimplemented = [note, *result.unimplemented]