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] = []
|
||||
|
||||
Reference in New Issue
Block a user