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
+153
View File
@@ -0,0 +1,153 @@
"""M9-2 回测历史测试:selection_history(意图=select 同源)、signal_history(BUY/SELL
与成交与否)、fills(Signal↔Fill 区分)——含「涨停导致 BUY 信号未成交」场景。
"""
from __future__ import annotations
from datetime import date
from decimal import Decimal
import pandas as pd
import pytest
from app.application.services.selection_service import SelectionService
from app.domain.entities.market import DailyBar, Stock
from app.domain.entities.research import BacktestResult, ResearchSpec
from app.domain.entities.selection import SelectionQuery
from app.quant.engine import LocalEngine
from conftest_quant import synthetic_daily
_SYMS = ["600000.SH", "600001.SH", "600002.SH", "600003.SH", "600004.SH"]
class _MemStockRepo:
def __init__(self, stocks):
self._stocks = stocks
def list(self):
return self._stocks
def get_by_symbol(self, symbol):
return next((s for s in self._stocks if s.symbol == symbol), None)
class _MemDailyRepo:
def __init__(self, df: pd.DataFrame) -> None:
self._bars = [
DailyBar(
symbol=r.symbol, trade_date=r.trade_date,
open=Decimal(str(r.open)), high=Decimal(str(r.high)),
low=Decimal(str(r.low)), close=Decimal(str(r.close)),
volume=Decimal(str(r.volume)), amount=Decimal(str(r.amount)),
)
for r in df.itertuples()
]
def get_range(self, symbol, start, end):
return [b for b in self._bars if b.symbol == symbol and start <= b.trade_date <= end]
def get_range_many(self, symbols, start, end, adjust="none"):
syms = set(symbols)
return [b for b in self._bars if b.symbol in syms and start <= b.trade_date <= end]
def _stocks() -> list[Stock]:
return [
Stock(symbol=s, name=f"测试{i}", list_date=date(1999, 1, 1)) for i, s in enumerate(_SYMS)
]
def _spec(**kw) -> ResearchSpec:
base = dict(
type="backtest",
universe={"exclude_st": False, "min_listing_days": 0, "symbols": _SYMS},
factors=[{"name": "momentum_60", "weight": 1.0}],
selection={"top_n": 2},
rebalance="monthly",
period=(date(2024, 5, 1), date(2024, 12, 31)),
)
base.update(kw)
return ResearchSpec(**base)
@pytest.fixture()
def daily_df() -> pd.DataFrame:
return synthetic_daily({s: 0.006 - 0.0015 * i for i, s in enumerate(_SYMS)}, n=320)
class TestBacktestHistory:
def test_selection_history_matches_select(self, daily_df) -> None:
spec = _spec()
result = LocalEngine().run_backtest(daily_df, spec)
svc = SelectionService(_MemStockRepo(_stocks()), _MemDailyRepo(daily_df))
rebal_dates = sorted({p.date for p in result.positions}) # 有持仓的调仓日近似
assert result.selection_history
hist_by_date: dict[date, list] = {}
for pick in result.selection_history:
hist_by_date.setdefault(pick.date, []).append(pick)
sample = [d for d in hist_by_date if d in rebal_dates][:3]
for d in sample:
res = svc.select(
SelectionQuery(
universe=spec.universe,
factors=[{"name": "momentum_60", "weight": 1.0}],
top_n=2,
as_of=d,
)
)
picked = {c.symbol: c.rank for c in res.candidates}
hist = {p.symbol: p.rank for p in hist_by_date[d]}
assert hist == picked, f"as_of={d}:意图 {hist} ≠ select {picked}"
def test_signal_and_fills_consistency(self, daily_df) -> None:
result = LocalEngine().run_backtest(daily_df, _spec())
fills = result.fills
assert fills == [a for a in result.signal_history if a.filled]
# 每个 SELL fill 对应一笔 Trade 平仓;每个 BUY fill 在交易中体现
assert all(a.filled for a in fills)
buy_fills = [a for a in fills if a.signal == "BUY"]
assert len(buy_fills) > 0
# fills 价格与滑点一致(buy price >= close)
assert all(a.price is not None for a in fills)
def test_limit_up_buy_rejected_visible(self) -> None:
"""把最高动量股在某调仓日设为涨停:意图仍在 selection_history,
但 signal_history 记录 BUY 未成交(涨停),fills/持仓无该笔。"""
df = synthetic_daily({s: 0.006 - 0.0015 * i for i, s in enumerate(_SYMS)}, n=320)
# 找首个 >= 2024-05-01 的交易日作为首个调仓日
dates = sorted(pd.to_datetime(df["trade_date"].unique()))
d0 = next(x for x in dates if x.date() >= date(2024, 5, 1))
top = _SYMS[0] # 最高漂移股
prev_date = dates[dates.index(d0) - 1]
prev_close = float(
df[(df["symbol"] == top) & (df["trade_date"] == prev_date.date())]["close"].iloc[0]
)
# 把 d0 该股 close 抬高到 +10%(近似涨停)
mask = (df["symbol"] == top) & (df["trade_date"] == d0.date())
df.loc[mask, "close"] = prev_close * 1.10
df.loc[mask, "high"] = prev_close * 1.10
spec = _spec()
result = LocalEngine().run_backtest(df, spec)
picks_at_d0 = [p for p in result.selection_history if p.date == d0.date()]
assert any(p.symbol == top for p in picks_at_d0), "涨停股仍应在选股意图中"
# 涨停买入被拒
rejects = [
a for a in result.signal_history
if a.date == d0.date() and a.symbol == top and a.signal == "BUY" and not a.filled
]
assert rejects and any("涨停" in (r.reject_reason or "") for r in rejects)
# 该日无该股成交
assert not any(
a.date == d0.date() and a.symbol == top and a.signal == "BUY" and a.filled
for a in result.fills
)
def test_result_serializes_history(self, daily_df) -> None:
result = LocalEngine().run_backtest(daily_df, _spec())
js = result.model_dump(mode="json")
assert "selection_history" in js and "signal_history" in js and "fills" in js
BacktestResult.model_validate(js) # roundtrip