Files
tick-stock-panel/backend/app/backtest/minute_replay.py
T
shy3130 0559632de6 feat(backtest): 分钟策略回测 v1 — 逐交易日回放信号分钟收盘入场
- 新增 MinuteSignalReplayer: 枚举区间内分钟分区日, 逐日组装 StrategyDataContext
  (timeframe=1m, 日线窗口严格止于 T-1) 并复用 strategy_engine.run, 与实盘选股同路径
- 缺分区日显式跳过并记入 skipped_days, 不回退最近分区
- 涨停拒买: 信号分钟收盘 >= 当日涨停价(T-1 raw_close + 板块幅度)剔除并计数
- MarketMatrix 新增 entry_price 覆盖矩阵, 引擎在有限值处优先于 open/close 惯例
- repository.list_minute_dates 按目录名枚举分钟分区日, 零 parquet 扫描
- strategy.run 增加 minute_filter 分支: 入场 delay 0/离场沿用日K matcher,
  trades.entry_date 补全为 YYYY-MM-DD HH:MM (北京时间)
2026-08-30 19:05:16 +08:00

287 lines
12 KiB
Python

"""分钟策略回测回放器 — 逐交易日回放 filter_minute_history 产生入场信号。
与实盘选股 (ScreenerService 1m context) 走同一条 StrategyEngine.run 执行路径,
消除回测/实盘偏差。语义铁律:
- 分钟侧: 传入当日全量分钟分区, 策略函数自身因果 (第 m 根只用 <=m 的K线);
- 日线侧: T 日的日线条件窗口只含 T-1 及更早的完成态日K — 与实盘盘中行为一致
(当日成形K不进窗口), 杜绝未来函数;
- 按交易日精确对日: 缺分钟分区的日子显式跳过, 不做"回退最近分区" (那是实盘语义)。
"""
from __future__ import annotations
import time
from collections.abc import Callable
from dataclasses import dataclass, field
from datetime import date, timedelta
import polars as pl
from app.price_limits import is_risk_warning_name, price_limit_pct
from app.strategy.engine import StrategyDataContext, StrategyDef, StrategyEngine
# 日线面板列: 基础行情 + 涨停/炸板信号 (策略日线窗口契约) + 基础过滤/展示列。
# raw_close 用于涨停价计算 (分钟价是未复权真实价, 涨停规则定义在原始价上)。
MINUTE_DAILY_PANEL_COLUMNS = frozenset({
"open", "high", "low", "close", "volume", "amount",
"raw_close", "raw_high", "raw_low",
"turnover_rate",
"signal_limit_up", "signal_limit_down", "signal_broken_limit_up",
})
MINUTE_INSTRUMENT_COLUMNS = frozenset({"name", "total_shares", "float_shares"})
def minute_replay_feature_plan(daily_bars: int):
"""分钟回测的日线面板加载计划。
execution_backend 用 polars_expr 走"按需计算信号"路径: enriched 分区只落
基础列, 涨停/炸板信号由 load_panel_for_backtest 的 compute_limit_signals
按 signal_columns 需求现算 (matrix_native 路径会跳过通用信号计算)。
"""
# 函数级导入规避与 strategy.py 的循环依赖 (strategy 顶层导入本模块)。
from app.backtest.strategy import ResolvedFeaturePlan
return ResolvedFeaturePlan(
base_columns=MINUTE_DAILY_PANEL_COLUMNS,
intermediate_columns=frozenset(),
indicator_columns=frozenset(),
signal_columns=frozenset({
"signal_limit_up", "signal_limit_down", "signal_broken_limit_up",
}),
matrix_columns=frozenset(),
instrument_columns=MINUTE_INSTRUMENT_COLUMNS,
warmup_bars=max(daily_bars, 1),
full_feature_fallback=False,
execution_backend="polars_expr",
)
def minute_panel_start(start: date, daily_bars: int) -> date:
"""日线面板加载起点: 覆盖首个回测日的 daily_bars 交易日窗口。
N 个交易日约需 N*2 自然日 (周末/节假日), 再留 warmup 余量。
"""
calendar_days = max(daily_bars, 1) * 2 + 30
return start - timedelta(days=calendar_days)
def _trigger_hhmm(value) -> str:
"""从 last_datetime 提取北京时间 "HH:MM" 触发分钟。
分区 datetime 为 UTC 存储 (tz-aware 或 naive-UTC), 统一折算到北京时区。
"""
from app.market_time import CN_TZ
if hasattr(value, "astimezone"):
if value.tzinfo is None:
from datetime import timezone
value = value.replace(tzinfo=timezone.utc)
return value.astimezone(CN_TZ).strftime("%H:%M")
text = str(value or "")
if len(text) >= 16 and text[13] == ":":
return text[11:16]
return text[-5:] if text else ""
def _scalar_limit_up_price(prev_close: float, limit_pct: float) -> float:
"""与 polars_limit_price 同口径的标量涨停价 (整数分半进位)。"""
cents = int(prev_close * 100 + 0.5)
numerator = round((1 + limit_pct) * 100)
return ((cents * numerator + 50) // 100) / 100
@dataclass
class MinuteReplayHit:
"""一个盘中入场信号: 触发分钟收盘买入。"""
trade_date: date
symbol: str
# 已按当日 复权close/原始close 比例折算到复权价系的入场价, 与日线出场价同尺度。
entry_price: float
trigger_time: str # "HH:MM" — 触发分钟K的时间戳
score: float = 0.0
@dataclass
class MinuteReplayResult:
hits: list[MinuteReplayHit] = field(default_factory=list)
skipped_days: list[date] = field(default_factory=list)
replayed_days: int = 0
strategy_matches: int = 0
buy_limit_up: int = 0
elapsed_ms: float = 0.0
class MinuteSignalReplayer:
"""逐交易日回放分钟策略, 产出与实盘选股同源的入场命中。"""
def __init__(self, engine, strategy_engine: StrategyEngine) -> None:
# engine: BacktestEngine — 只用其 repo (分钟分区读取)。
self.engine = engine
self.strategy_engine = strategy_engine
def replay(
self,
strategy: StrategyDef,
*,
panel: pl.DataFrame,
start: date,
end: date,
params: dict,
overrides: dict,
pool: list[str] | None = None,
symbols: list[str] | None = None,
progress_cb: Callable[[dict], None] | None = None,
cancel_event=None,
) -> MinuteReplayResult:
t0 = time.perf_counter()
result = MinuteReplayResult()
repo = self.engine.repo
if panel.is_empty():
return result
universe = symbols if symbols else panel.get_column("symbol").unique().to_list()
daily_bars = int(strategy.minute_daily_bars or 0)
# 面板交易日序列 (升序) — 日线窗口切片与缺分区日判定的基准。
panel_dates = panel.get_column("date").unique().sort().to_list()
date_to_window: dict[date, tuple[date, date]] = {}
for i, day in enumerate(panel_dates):
window_start = panel_dates[max(0, i - daily_bars)] if daily_bars > 0 else day
date_to_window[day] = (window_start, day)
# 逐分区日回放: 只回放 [start, end] 内有分钟分区的交易日。
minute_days = repo.list_minute_dates(start, end, "stock")
minute_day_set = set(minute_days)
replay_days = [day for day in panel_dates if start <= day <= end]
result.skipped_days = [day for day in replay_days if day not in minute_day_set]
total = len(minute_days)
# 逐标的的 T-1 原始收盘/复权收盘查表 (涨停价与复权折算用)。
prev_raw_close: dict[str, float] = {}
prev_name: dict[str, str] = {}
adj_factor: dict[str, float] = {}
for i, day in enumerate(minute_days):
if cancel_event is not None and cancel_event.is_set():
break
if progress_cb is not None:
progress_cb({
"day": i + 1,
"total": max(total, 1),
"date": str(day),
})
history = repo.get_minute_by_dates(universe, [day], "stock")
if history.is_empty():
result.skipped_days.append(day)
continue
# 日线窗口: 截至 T-1 的完成态日K (index of last panel date < day)。
prior = [d for d in panel_dates if d < day]
if not prior:
# 面板起点之前的分区日 (窗口数据不足), 策略按数据不足自然不命中。
daily_history = pl.DataFrame()
current = pl.DataFrame()
else:
last_prior = prior[-1]
window_start, _ = date_to_window[last_prior]
daily_history = panel.filter(
(pl.col("date") >= window_start) & (pl.col("date") <= last_prior)
) if daily_bars > 0 else pl.DataFrame()
current = panel.filter(pl.col("date") == last_prior)
# T-1 收盘/名称 + T 日复权因子查表。
_refresh_day_lookups(prev_raw_close, prev_name, current, prior)
day_rows = panel.filter(pl.col("date") == day).select(
"symbol", "close", "raw_close",
)
adj_factor.clear()
adj_factor.update(_adj_factors(day_rows))
context = StrategyDataContext(
asset_type="stock",
timeframe="1m",
as_of=day,
current=current if not current.is_empty() else None,
history=history,
daily_history=daily_history if not daily_history.is_empty() else None,
)
try:
run_result = self.strategy_engine.run(
strategy.meta.get("id", ""),
context,
pool,
params,
overrides,
)
except ValueError:
# 单日执行失败 (如窗口缺列) 记为跳过, 不中断整个回放。
result.skipped_days.append(day)
continue
result.replayed_days += 1
result.strategy_matches += len(run_result.rows)
for row in run_result.rows:
symbol = row.get("symbol")
close = row.get("close")
if not symbol or close is None or float(close) <= 0:
continue
raw_close = float(close)
name = prev_name.get(str(symbol), "")
prev = prev_raw_close.get(str(symbol))
# 涨停拒买: 触发分钟收盘已达当日涨停价 (按 T-1 原始收盘 + 板块规则)。
if prev is not None and prev > 0:
limit_up = _scalar_limit_up_price(
prev, price_limit_pct(str(symbol), day, is_risk_warning=is_risk_warning_name(name)),
)
if raw_close >= limit_up - 1e-9:
result.buy_limit_up += 1
continue
trigger = row.get("last_datetime")
trigger_time = _trigger_hhmm(trigger)
result.hits.append(MinuteReplayHit(
trade_date=day,
symbol=str(symbol),
entry_price=raw_close * adj_factor.get(str(symbol), 1.0),
trigger_time=trigger_time,
score=float(run_result.scores.get(str(symbol), 0.0) or 0.0),
))
result.elapsed_ms = round((time.perf_counter() - t0) * 1000, 1)
return result
def _refresh_day_lookups(
prev_raw_close: dict[str, float],
prev_name: dict[str, str],
prior_snapshot: pl.DataFrame,
prior: list[date],
) -> None:
"""从 T-1 快照刷新逐标的原始收盘与名称查表 (涨停价/ST 判定用)。"""
if prior_snapshot.is_empty():
return
frame = prior_snapshot
if "raw_close" not in frame.columns:
frame = frame.with_columns(pl.col("close").alias("raw_close"))
if "name" not in frame.columns:
frame = frame.with_columns(pl.lit("").alias("name"))
prev_raw_close.clear()
prev_name.clear()
for symbol, raw_close, name in frame.select("symbol", "raw_close", "name").iter_rows():
prev_raw_close[str(symbol)] = float(raw_close) if raw_close is not None else 0.0
prev_name[str(symbol)] = str(name or "")
def _adj_factors(day_rows: pl.DataFrame) -> dict[str, float]:
"""T 日 复权close/原始close 比例: 把分钟原始价折算到复权价系。"""
factors: dict[str, float] = {}
if day_rows.is_empty() or "raw_close" not in day_rows.columns:
return factors
for symbol, close, raw_close in day_rows.select("symbol", "close", "raw_close").iter_rows():
if close and raw_close and float(raw_close) > 0:
factors[str(symbol)] = float(close) / float(raw_close)
return factors