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