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:
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user