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] = []
+33
View File
@@ -153,6 +153,30 @@ class Position(BaseModel):
weight: float
class RankedPick(BaseModel):
"""调仓日选股意图候选(与 select(as_of) 同源;v3 §22.3 selection_history)。"""
date: date
symbol: str
rank: int
score: float
class ActionRecord(BaseModel):
"""一次交易意图(Signal)及其成交结果(Fill)—— v3 §20.3 Signal↔Fill 区分。
signal=BUY/SELL(策略意图);filled=是否实际成交;reject_reason 给出未成交原因
(涨停/跌停/无价/现金不足等)。fills = [a for a in signal_history if a.filled]。
"""
date: date
symbol: str
signal: str = Field(pattern="^(BUY|SELL)$")
filled: bool
reject_reason: str | None = None
price: float | None = Field(default=None, description="成交价(fill)或意图参考价")
class BacktestResult(BaseModel):
"""标准化回测结果(ARCHITECTURE §14)。前端只依赖该结构。"""
@@ -163,6 +187,15 @@ class BacktestResult(BaseModel):
yearly_returns: list[YearlyReturn]
positions: list[Position]
trades: list[Trade]
selection_history: list[RankedPick] = Field(
default_factory=list, description="各调仓日选股意图候选(同 select(as_of))"
)
signal_history: list[ActionRecord] = Field(
default_factory=list, description="交易意图与是否成交(v3 §20.3)"
)
fills: list[ActionRecord] = Field(
default_factory=list, description="实际成交(signal_history 中 filled=True 的子集)"
)
turnover_pct: float
unimplemented: list[str] = Field(
default_factory=list,
+64 -15
View File
@@ -16,12 +16,14 @@ from datetime import date
import pandas as pd
from app.domain.entities.research import (
ActionRecord,
BacktestResult,
BacktestSummary,
CurvePoint,
FactorTestReport,
MonthlyReturn,
Position,
RankedPick,
ResearchSpec,
Trade,
YearlyReturn,
@@ -83,6 +85,9 @@ class TopKBacktestRunner:
self.costs = spec.costs
# 上一有效收盘(用于涨跌停与收益结算,处理停牌日)
self.prev_close = self.close.ffill().shift(1)
# M9-2:调仓意图与信号/成交记录(v3 §20.3/§22.3)
self.selection_history: list[RankedPick] = []
self.signal_history: list[ActionRecord] = []
def run(self) -> BacktestResult:
end_date = self.spec.period[1]
@@ -128,23 +133,36 @@ class TopKBacktestRunner:
close_d = self.close.loc[d]
prev_d = self.prev_close.loc[d]
sold_notional = 0.0
day = d.date()
# 1) 卖出:跌停或无价(停牌)持仓保留,其余卖出
# 1) 卖出:逐持仓记录 SELL 意图与实际成交(跌停/无价则保留并说明)
for s in [s for s in shares if shares[s] > 0]:
c, p = close_d[s], prev_d[s]
if _nan(c):
self.signal_history.append(
ActionRecord(date=day, symbol=s, signal="SELL", filled=False,
reject_reason="无行情(停牌),保留持仓")
)
continue # 停牌无价:保留
if not _nan(p) and p > 0 and c / p <= 1.0 - (_limit_up_ratio(s) - 1.0):
self.signal_history.append(
ActionRecord(date=day, symbol=s, signal="SELL", filled=False,
reject_reason="跌停无法卖出,保留到下一调仓")
)
continue # 跌停无法卖出:保留到下一调仓
qty = shares[s]
proceeds = qty * float(c) * (1 - self.costs.slippage_rate)
fee = proceeds * (self.costs.commission_rate + self.costs.stamp_tax_rate)
cash += proceeds - fee
sold_notional += proceeds
self.signal_history.append(
ActionRecord(date=day, symbol=s, signal="SELL", filled=True,
price=float(c))
)
trades.append(
Trade(
entry_date=entry_date[s],
exit_date=d.date(),
exit_date=day,
symbol=s,
entry_price=entry_price[s],
exit_price=float(c),
@@ -155,20 +173,36 @@ class TopKBacktestRunner:
entry_date.pop(s, None)
entry_price.pop(s, None)
# 2) 买入:取得分最高且可买的 TopN(涨停 / 无价剔除)
# 2) 买入:先记录「选股意图」(= select(as_of) 前 top_n,v3 §22.3)
score_d = self.score.loc[d].dropna()
top = score_d.sort_values(ascending=False).index.tolist()
targets: list[str] = []
for s in top:
if len(targets) >= self.spec.selection.top_n:
break
c, p = close_d[s], prev_d[s]
if _nan(c) or _nan(p) or p <= 0:
continue
if c / p >= _limit_up_ratio(s):
continue # 涨停不可追买
targets.append(s)
top_n = self.spec.selection.top_n
picks = top[:top_n]
for rank, sym in enumerate(picks, start=1):
self.selection_history.append(
RankedPick(date=day, symbol=sym, rank=rank,
score=round(float(score_d[sym]), 6))
)
# 执行:顺序寻找可买(涨停/无价剔除;替补仅在意图被拒时进入)
def _buyable(sym) -> tuple[bool, str | None]:
c, p = close_d[sym], prev_d[sym]
if _nan(c) or _nan(p) or p <= 0:
return False, "无行情(停牌),无法买入"
if c / p >= _limit_up_ratio(sym):
return False, "涨停,无法追买"
return True, None
targets: list[str] = []
for sym in top:
if len(targets) >= top_n:
break
ok, _ = _buyable(sym)
if ok:
targets.append(sym)
target_set = set(targets)
# BUY 信号/成交记录:意图入选(filled)或意图被拒(原因);替补成交同样如实记录
if targets:
budget = equal_weight_budget(cash, len(targets))
for s in targets:
@@ -176,10 +210,22 @@ class TopKBacktestRunner:
price_in = c * (1 + self.costs.slippage_rate)
invest = budget * (1 - self.costs.commission_rate)
shares[s] = invest / price_in
entry_date[s] = d.date()
entry_date[s] = day
entry_price[s] = price_in
notional.append(budget)
self.signal_history.append(
ActionRecord(date=day, symbol=s, signal="BUY", filled=True,
price=round(price_in, 4))
)
cash -= budget * len(targets)
for sym in picks:
if sym in target_set:
continue
_ok, reason = _buyable(sym)
self.signal_history.append(
ActionRecord(date=day, symbol=sym, signal="BUY", filled=False,
reject_reason=reason or "资金不足(未成交)")
)
# 3) 记录调仓后仓位
total = cash + sum(
@@ -192,7 +238,7 @@ class TopKBacktestRunner:
if qty > 0 and not _nan(self.close.at[d, s]):
positions.append(
Position(
date=d.date(), symbol=s, weight=float(qty * self.close.at[d, s] / total)
date=day, symbol=s, weight=float(qty * self.close.at[d, s] / total)
)
)
return cash
@@ -263,6 +309,9 @@ class TopKBacktestRunner:
yearly_returns=yearly,
positions=positions,
trades=trades,
selection_history=self.selection_history,
signal_history=self.signal_history,
fills=[a for a in self.signal_history if a.filled],
turnover_pct=round(sum(notional) / max(init, 1) * 100, 2),
unimplemented=list(_DEFAULT_UNIMPLEMENTED) + unimplemented_notes(self.spec.portfolio),
config_snapshot=self.spec.model_dump(mode="json"),