feat(backtest): M9-2 回测补 selection_history/signal_history/fills(Signal↔Fill 区分)

- BacktestResult 新增:RankedPick(调仓意图,与 select(as_of) 同源排序)、
  ActionRecord(BUY/SELL 意图 + filled + reject_reason/price)字段 selection_history /
  signal_history / fills(fills=signal_history 中 filled 子集)(v3 §20.3/§22.3)
- TopKBacktestRunner:调仓记录卖出/买入逐动作与是否成交;涨停/停牌导致的
  「BUY 信号未成交」保留原因;意图 picks 与执行 targets 分离(不因涨停悄悄改选股视图)
- ChartService.backtest_stock_chart 改用 history 生成三类标记(selection/signal/fill),
  未成交意图在图上可见(v3 §20.4)
- tests/test_backtest_history.py:意图=select 一致、fills 推导、涨停拒绝可见(构造 +10%
  涨停日)、序列化 roundtrip;相关回归(quant/consistency/charts)全过;全量 pytest 通过
This commit is contained in:
Simon
2026-09-09 07:12:36 +08:00
parent 995ed08548
commit bfeac7aa4c
4 changed files with 280 additions and 34 deletions
@@ -21,7 +21,7 @@ from app.domain.entities.chart import (
VolumePoint,
)
from app.domain.entities.market import AdjustFactor, DailyBar, Stock
from app.domain.entities.research import BacktestResult, Trade
from app.domain.entities.research import BacktestResult
from app.domain.repositories.market import (
AdjustFactorRepository,
DailyBarRepository,
@@ -132,39 +132,50 @@ class ChartService:
end: date,
adjust: str = "none",
) -> ChartResult:
"""回测个股视图:K 线 + 该股实际成交 fills(v3 §20.3 Signal↔Fill 展示)。"""
"""回测个股视图:K 线 + 选股意图/未成交信号/实际成交三类标记(v3 §20.3)。"""
basis = (result.config_snapshot or {}).get("price_adjustment", "none")
markers = _trades_to_markers(result.trades, symbol, basis)
markers = _result_to_markers(result, symbol)
return self.stock_chart(symbol, start, end, adjust, execution_price_basis=basis,
extra_markers=markers)
def _trades_to_markers(trades: list[Trade], symbol: str, basis: str) -> list[EventMarker]:
def _result_to_markers(result: BacktestResult, symbol: str) -> list[EventMarker]:
"""由回测 history 生成个股标记:fills(成交)/ signals(未成交意图)/ selections(选股)。"""
markers: list[EventMarker] = []
for t in trades:
if t.symbol != symbol:
# 实际成交(fills)与未成交信号(signal_history 中 filled=False)
for a in result.signal_history:
if a.symbol != symbol:
continue
if a.filled:
kind = "fill_buy" if a.signal == "BUY" else "fill_sell"
text = [f"{'买入' if a.signal=='BUY' else '卖出'} @ {a.price:.2f}(basis={_basis_of(result)})"]
markers.append(
EventMarker(time=a.date, kind=kind, symbol=symbol, price=a.price, text=text)
)
else:
kind = "signal_buy" if a.signal == "BUY" else "signal_sell"
text = [a.reject_reason or f"{a.signal} 未成交"]
markers.append(EventMarker(time=a.date, kind=kind, symbol=symbol, price=a.price, text=text))
# 选股意图(selection_history 中该 symbol 的命中)
for pk in result.selection_history:
if pk.symbol != symbol:
continue
markers.append(
EventMarker(
time=t.entry_date,
kind="fill_buy",
time=pk.date,
kind="selection",
symbol=symbol,
price=t.entry_price,
text=[f"买入 @ {t.entry_price:.2f}(basis={basis})"],
)
)
markers.append(
EventMarker(
time=t.exit_date,
kind="fill_sell",
symbol=symbol,
price=t.exit_price,
text=[f"卖出 @ {t.exit_price:.2f},收益 {t.return_pct:.2f}%(basis={basis})"],
score=pk.score,
text=[f"选股意图 rank #{pk.rank}"],
)
)
return markers
def _basis_of(result: BacktestResult) -> str:
return (result.config_snapshot or {}).get("price_adjustment", "none")
def _convert_markers(markers: list[EventMarker], mult: dict[date, float]) -> list[EventMarker]:
"""显示口径与执行价 basis 不一致时,把 marker 价格折算到 K 线坐标系(v3 §20.5)。"""
out: list[EventMarker] = []