mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 22:34:18 +08:00
- 新增 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 (北京时间)
287 lines
12 KiB
Python
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
|