- 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 通过
154 lines
6.0 KiB
Python
154 lines
6.0 KiB
Python
"""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
|