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:
@@ -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] = []
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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