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
+19 -1
View File
@@ -37,6 +37,11 @@ BUY_SKIP_MIN_COMMISSION = "buy_skip_min_commission"
# 卖出(成交)
SELL_DROP_TOPN = "sell_drop_topn"
SELL_FORCE_TMAX = "sell_force_tmax"
# 卖出(成交):策略在调仓日**全量换仓**(先清仓再建仓),该股当时仍在 TopN 内。
# 为什么单列一个 code:单策略回测(TopK runner)的调仓语义就是「全清再买」,
# 被卖出的股票很可能仍然排在前列 —— 这时说「跌出 TopN」与 data 里的 rank=1 自相矛盾,
# 等于给用户一个假的解释。分开写才是如实描述。
SELL_REBALANCE_FULL = "sell_rebalance_full"
# 卖出(顺延 / 未成交)
SELL_DEFER_TMIN = "sell_defer_tmin"
SELL_DEFER_HALTED = "sell_defer_halted"
@@ -52,6 +57,7 @@ REASON_CODES = frozenset(
BUY_SKIP_MIN_COMMISSION,
SELL_DROP_TOPN,
SELL_FORCE_TMAX,
SELL_REBALANCE_FULL,
SELL_DEFER_TMIN,
SELL_DEFER_HALTED,
SELL_DEFER_LIMIT_DOWN,
@@ -68,6 +74,7 @@ REASON_LABELS: dict[str, str] = {
BUY_SKIP_MIN_COMMISSION: "不足最低佣金",
SELL_DROP_TOPN: "跌出 TopN",
SELL_FORCE_TMAX: "持有超 Tmax",
SELL_REBALANCE_FULL: "调仓换仓卖出",
SELL_DEFER_TMIN: "Tmin 保护暂留",
SELL_DEFER_HALTED: "停牌未卖",
SELL_DEFER_LIMIT_DOWN: "跌停未卖",
@@ -242,13 +249,24 @@ def sell_filled(
return_pct: float | None = None,
not_in_pool: bool = False,
) -> TradeReason:
"""卖出成交的理由(跌出 TopN / 持有超 Tmax),带持有交易日与当时名次。
"""卖出成交的理由(跌出 TopN / 持有超 Tmax / 全量换仓),带持有交易日与当时名次。
`not_in_pool=True` 表示该股已**不在候选池**(被股票池/条件过滤,如转为 ST),
与「在池内但排名掉出去」是两回事,文案与 data 都分开写。
`SELL_REBALANCE_FULL` 用于「策略每次调仓都先全清再建仓」的引擎:该股当时仍在前列,
卖它不是因为掉出 TopN,而是策略本身的调仓方式 —— 不能套用跌出 TopN 的说法。
"""
if code == SELL_FORCE_TMAX:
text = f"持有 {hold_days} 个交易日 > Tmax={tmax},强制了结(与排名无关)"
elif code == SELL_REBALANCE_FULL:
text = (
f"调仓日全量换仓:该策略每次调仓先清仓再按新名单建仓"
f"(该股当时仍在 TopN 内:{_rank_text(rank, total, top_n, score)});"
f"持有 {hold_days} 个交易日"
)
if tmin is not None:
text += f" ≥ Tmin={tmin}"
else:
code = SELL_DROP_TOPN
if not_in_pool: