mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 21:24:16 +08:00
原问题: 多个信号在 _build_signal_mask 里 OR 成一个 mask 后, 只记录 exit_reason="信号", 具体哪个信号触发的信息丢失。回测里买入/卖出原因 只能看到笼统的「信号」, 不知道是 MA金叉还是 MACD死叉。 实现: - 后端 TradeRecord 加 entry_signal_id/exit_signal_id 字段 - 新增 _resolve_signal_id() 在触发行回查 panel 列, 找出为 True 的具体信号 - 三个 simulate 方法加 entry_signal_ids/exit_signal_ids 参数 - strategy.py 把信号列表传给 engine, _trade_to_dict 透传新字段 - 前端 useSignalNames() hook 合并内置 (BUILTIN_SIGNAL_DEFINITIONS) + 自定义 信号名称; TradeLegCell/ExitReasonBadge 显示具体信号名 - 信号名称前端解析 (复用 cnSignal), 后端只返回 signal_id, 不读磁盘 效果: 买入腿显示「MA5上穿MA20」, 卖出腿显示「MACD死叉」, 原因列从 通用「信号」变成具体信号名。止损/止盈/到期等非信号退出不受影响。 验证: 191 passed, 前端类型检查无错误
1928 lines
85 KiB
Python
1928 lines
85 KiB
Python
"""回测引擎 — 共享数据加载 + 撮合 + 统计计算。
|
||
|
||
纯 Polars/NumPy 实现,不依赖 pandas/vectorbt。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import hashlib
|
||
import logging
|
||
import threading
|
||
import time
|
||
from collections import OrderedDict
|
||
from dataclasses import dataclass
|
||
from datetime import date
|
||
from typing import Callable
|
||
|
||
logger = logging.getLogger(__name__)
|
||
from typing import Literal
|
||
|
||
import numpy as np
|
||
import polars as pl
|
||
|
||
from app.parquet import scan_enriched_parquet
|
||
from app.tickflow.repository import KlineRepository
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
# ================================================================
|
||
# 数据结构
|
||
# ================================================================
|
||
|
||
@dataclass
|
||
class MatcherConfig:
|
||
# matching 为向后兼容入口: 仅传 matching 时, entry_fill/exit_fill 都取 matching 的值。
|
||
# 显式传入 entry_fill/exit_fill 时以二者为准 (允许建仓/清仓口径不同)。
|
||
matching: Literal["close_t", "open_t+1"] = "close_t"
|
||
entry_fill: Literal["close_t", "open_t+1"] | None = None
|
||
exit_fill: Literal["close_t", "open_t+1"] | None = None
|
||
# 成本模型: 优先使用拆分口径 (佣金双边 + 印花税仅卖出 + 滑点双边)。
|
||
# 未设 commission_pct 时回退到 fees_pct 作为双边佣金 (向后兼容, 无印花税)。
|
||
fees_pct: float = 0.0002
|
||
commission_pct: float | None = None
|
||
stamp_tax_pct: float | None = None
|
||
slippage_bps: float = 5.0
|
||
stop_loss_pct: float | None = None
|
||
take_profit_pct: float | None = None
|
||
trailing_stop_pct: float | None = None
|
||
trailing_take_profit_activate_pct: float | None = None
|
||
trailing_take_profit_drawdown_pct: float | None = None
|
||
max_hold_days: int | None = None
|
||
max_positions: int = 10
|
||
max_exposure_pct: float = 1.0
|
||
score_min: float | None = None
|
||
score_max: float | None = None
|
||
initial_capital: float = 1_000_000.0
|
||
position_sizing: Literal["equal", "score_weight"] = "equal"
|
||
# 分钟K精确成交: 开启后, 信号触发日的成交价用当日分钟K优化
|
||
# (有参考线→穿越价, 无参考线→VWAP)。数据缺失时降级为日K口径。
|
||
minute_fill: bool = False
|
||
|
||
def __post_init__(self) -> None:
|
||
# 解析最终口径: 优先 entry_fill/exit_fill, 否则回退到 matching (向后兼容)。
|
||
if self.entry_fill is None:
|
||
self.entry_fill = self.matching
|
||
if self.exit_fill is None:
|
||
self.exit_fill = self.matching
|
||
|
||
def _commission_pct(self) -> float:
|
||
# commission_pct 显式给出时优先, 否则回退 fees_pct (向后兼容双边佣金)。
|
||
return self.commission_pct if self.commission_pct is not None else self.fees_pct
|
||
|
||
def buy_cost_pct(self) -> float:
|
||
# 买入腿: 佣金 + 滑点。
|
||
return self._commission_pct() + self.slippage_bps / 10000.0
|
||
|
||
def sell_cost_pct(self) -> float:
|
||
# 卖出腿: 佣金 + 印花税 + 滑点。印花税未设时为 0 (向后兼容)。
|
||
stamp = self.stamp_tax_pct if self.stamp_tax_pct is not None else 0.0
|
||
return self._commission_pct() + stamp + self.slippage_bps / 10000.0
|
||
|
||
|
||
@dataclass
|
||
class TradeRecord:
|
||
symbol: str
|
||
entry_date: date
|
||
exit_date: date
|
||
entry_price: float
|
||
exit_price: float
|
||
pnl_pct: float
|
||
duration: int
|
||
exit_reason: str # "signal" | "stop_loss" | "take_profit" | "trailing_stop" | "trailing_take_profit" | "max_hold" | "end"
|
||
# 退出优先级 (高→低): pending_exit(历史挂单) > 风控(止损/移动止损/移动止盈) > signal(卖点) > max_hold(到期) > end
|
||
name: str = ""
|
||
shares: float = 0.0
|
||
lots: float = 0.0
|
||
position_pct: float = 0.0
|
||
entry_value: float = 0.0
|
||
exit_value: float = 0.0
|
||
pnl_amount: float = 0.0
|
||
entry_score: float | None = None
|
||
entry_signal_date: date | str | None = None
|
||
exit_signal_date: date | str | None = None
|
||
blocked_exit_days: int = 0
|
||
# 触发买入/卖出的具体信号列名 (如 signal_ma_golden_5_20 / csg_xxx);
|
||
# 仅当该腿由信号触发时填充, 止损/止盈/到期等非信号退出时 exit_signal_id 为 None。
|
||
entry_signal_id: str | None = None
|
||
exit_signal_id: str | None = None
|
||
|
||
|
||
@dataclass
|
||
class SimResult:
|
||
equity_curve: list[dict] # [{date, value}]
|
||
drawdown_curve: list[dict] # [{date, value}]
|
||
trades: list[TradeRecord]
|
||
per_symbol_stats: list[dict]
|
||
stats: dict
|
||
|
||
|
||
def _resolve_signal_id(panel: pl.DataFrame, idx: int, signal_ids: list[str] | None) -> str | None:
|
||
"""在触发行 idx 上, 从候选信号里找出 panel 列为 True 的那个, 返回其列名。
|
||
|
||
多个信号同时为 True 时返回第一个匹配的 (信号 OR 关系, 回测只记录其一即可)。
|
||
signal_ids 元素可能带 signal_/csg_ 前缀, 也可能是裸名 (如 "ma_golden_5_20")。
|
||
"""
|
||
if not signal_ids:
|
||
return None
|
||
for sid in signal_ids:
|
||
col = sid if (sid.startswith("signal_") or sid.startswith("csg_")) else f"signal_{sid}"
|
||
if col not in panel.columns:
|
||
continue
|
||
try:
|
||
if bool(panel[col][idx]):
|
||
return col
|
||
except (IndexError, TypeError):
|
||
continue
|
||
return None
|
||
|
||
|
||
# ================================================================
|
||
# PanelCache — 避免重复 scan_parquet + compute_all
|
||
# ================================================================
|
||
|
||
class _CacheEntry:
|
||
__slots__ = ("df", "ts")
|
||
|
||
def __init__(self, df: pl.DataFrame, ts: float):
|
||
self.df = df
|
||
self.ts = ts
|
||
|
||
|
||
class _InFlight:
|
||
"""同 key 正在计算的占位: leader 算完通过 done 唤醒所有跟随者复用结果。"""
|
||
|
||
__slots__ = ("done", "df", "error")
|
||
|
||
def __init__(self) -> None:
|
||
self.done = threading.Event()
|
||
self.df: pl.DataFrame | None = None
|
||
self.error: BaseException | None = None
|
||
|
||
|
||
class PanelCache:
|
||
"""LRU + TTL 数据面板缓存。"""
|
||
|
||
def __init__(self, max_size: int = 2, ttl_seconds: int = 180):
|
||
self._cache: OrderedDict[str, _CacheEntry] = OrderedDict()
|
||
self._max_size = max_size
|
||
self._ttl = ttl_seconds
|
||
# 跨请求单例, SSE 回测在各自 daemon 线程并发访问 OrderedDict。
|
||
# 无锁的 move_to_end/del/popitem check-then-act 会抛 "OrderedDict mutated"。
|
||
# 用实例锁守护所有 OrderedDict 变更; compute_fn (重扫盘) 放锁外避免串行化。
|
||
self._lock = threading.Lock()
|
||
# single-flight: 同 key 只让一个线程 compute, 其余等其结果复用。
|
||
# 否则优化器等场景下 max_workers 个线程冷启动同时 miss, 会并行加载 N 份同一面板。
|
||
self._inflight: dict[str, _InFlight] = {}
|
||
|
||
def get_or_compute(
|
||
self,
|
||
symbols: list[str] | None,
|
||
start: date,
|
||
end: date,
|
||
columns: list[str] | None,
|
||
compute_fn,
|
||
asset_type: str = "stock",
|
||
) -> pl.DataFrame:
|
||
key = self._make_key(symbols, start, end, columns, asset_type)
|
||
now = time.monotonic()
|
||
|
||
with self._lock:
|
||
entry = self._cache.get(key)
|
||
if entry is not None:
|
||
if now - entry.ts < self._ttl:
|
||
self._cache.move_to_end(key)
|
||
return entry.df
|
||
del self._cache[key] # 过期, 丢弃后重算
|
||
# single-flight: 同 key 若已有线程在算, 登记为跟随者; 否则本线程当 leader。
|
||
flight = self._inflight.get(key)
|
||
leader = flight is None
|
||
if leader:
|
||
flight = _InFlight()
|
||
self._inflight[key] = flight
|
||
|
||
if not leader:
|
||
# 跟随者: 等 leader 算完直接复用, 不重复 compute (消除缓存踩踏)。
|
||
flight.done.wait()
|
||
if flight.error is not None:
|
||
raise flight.error
|
||
return flight.df
|
||
|
||
# leader: compute 放锁外 (不同 key 仍可并发, 保留原设计优点)。
|
||
try:
|
||
df = compute_fn(symbols, start, end, columns, asset_type)
|
||
except BaseException as e:
|
||
# 失败不缓存: 摘除 inflight 让后续线程重试, 并把异常透传给已在等的跟随者。
|
||
with self._lock:
|
||
self._inflight.pop(key, None)
|
||
flight.error = e
|
||
flight.done.set()
|
||
raise
|
||
with self._lock:
|
||
self._cache[key] = _CacheEntry(df=df, ts=now)
|
||
if len(self._cache) > self._max_size:
|
||
self._cache.popitem(last=False)
|
||
self._inflight.pop(key, None)
|
||
flight.df = df
|
||
flight.done.set()
|
||
return df
|
||
|
||
def invalidate(self) -> None:
|
||
with self._lock:
|
||
self._cache.clear()
|
||
|
||
@staticmethod
|
||
def _make_key(symbols: list[str] | None, start: date, end: date, columns: list[str] | None, asset_type: str = "stock") -> str:
|
||
if symbols is None:
|
||
h = "all"
|
||
else:
|
||
h = hashlib.md5(",".join(sorted(symbols)).encode()).hexdigest()[:12]
|
||
cols = "all" if columns is None else hashlib.md5(",".join(sorted(columns)).encode()).hexdigest()[:8]
|
||
return f"{asset_type}:{h}:{start}:{end}:{cols}"
|
||
|
||
|
||
# ================================================================
|
||
# BacktestEngine
|
||
# ================================================================
|
||
|
||
class BacktestEngine:
|
||
"""回测引擎 — 数据加载 + 撮合模拟 + 统计计算。"""
|
||
|
||
def __init__(self, repo: KlineRepository) -> None:
|
||
self.repo = repo
|
||
self._cache = PanelCache()
|
||
|
||
# ── 数据加载 ──────────────────────────────────────
|
||
|
||
def load_panel(
|
||
self,
|
||
symbols: list[str] | None,
|
||
start: date,
|
||
end: date,
|
||
columns: list[str] | None = None,
|
||
asset_type: str = "stock",
|
||
) -> pl.DataFrame:
|
||
"""加载 enriched 数据面板,带缓存。asset_type='etf' 时读 ETF enriched。"""
|
||
return self._cache.get_or_compute(symbols, start, end, columns, self._load_panel_inner, asset_type=asset_type)
|
||
|
||
def _load_panel_inner(
|
||
self,
|
||
symbols: list[str] | None,
|
||
start: date,
|
||
end: date,
|
||
columns: list[str] | None = None,
|
||
asset_type: str = "stock",
|
||
) -> pl.DataFrame:
|
||
t0 = time.perf_counter()
|
||
|
||
# 近期区间优先复用 repository 的预计算 enriched 历史缓存 (仅 stock: 该缓存为股票专用)。
|
||
try:
|
||
if asset_type == "stock" and self.repo is not None and hasattr(self.repo, "get_enriched_range"):
|
||
cached = self.repo.get_enriched_range(start, end, symbols=symbols, columns=columns)
|
||
if cached is not None and not cached.is_empty():
|
||
elapsed = (time.perf_counter() - t0) * 1000
|
||
logger.info("load_panel(cache): %.0fms, %d rows, %d columns", elapsed, len(cached), len(cached.columns))
|
||
return cached
|
||
except Exception as e: # noqa: BLE001
|
||
logger.debug("backtest load panel cache miss: %s", e)
|
||
|
||
from app.tickflow.repository import enriched_dirname
|
||
enriched_glob = str(self.repo.store.data_dir / enriched_dirname(asset_type) / "**" / "*.parquet")
|
||
|
||
try:
|
||
lf = scan_enriched_parquet(enriched_glob)
|
||
if symbols is not None:
|
||
lf = lf.filter(pl.col("symbol").is_in(symbols))
|
||
if columns is not None:
|
||
available = set(lf.collect_schema().names())
|
||
selected = [c for c in columns if c in available]
|
||
if "symbol" not in selected and "symbol" in available:
|
||
selected.insert(0, "symbol")
|
||
if "date" not in selected and "date" in available:
|
||
selected.insert(1, "date")
|
||
lf = lf.select(selected)
|
||
df = (
|
||
lf.filter(
|
||
(pl.col("date") >= start)
|
||
& (pl.col("date") <= end)
|
||
)
|
||
.sort(["symbol", "date"])
|
||
.collect(streaming=True)
|
||
)
|
||
except Exception as e:
|
||
logger.warning("backtest load panel failed: %s", e)
|
||
return pl.DataFrame()
|
||
|
||
if df.is_empty():
|
||
return df
|
||
|
||
if columns is not None:
|
||
elapsed = (time.perf_counter() - t0) * 1000
|
||
logger.info("load_panel: %.0fms, %d rows, %d columns", elapsed, len(df), len(df.columns))
|
||
return df
|
||
|
||
from app.indicators.pipeline import compute_all
|
||
# 按 asset_type 取维表: ETF 回测须用 ETF 维表, 否则名称 JOIN 失败(全 null)、
|
||
# 涨停信号算在错误的 instruments 上。
|
||
instruments = self.repo.get_instruments_asset(asset_type)
|
||
df = compute_all(df, instruments=instruments)
|
||
if not instruments.is_empty() and "name" not in df.columns:
|
||
inst_cols = [c for c in ["symbol", "name"] if c in instruments.columns]
|
||
if len(inst_cols) == 2:
|
||
df = df.join(
|
||
instruments.select(inst_cols).unique(subset=["symbol"]),
|
||
on="symbol",
|
||
how="left",
|
||
)
|
||
|
||
elapsed = (time.perf_counter() - t0) * 1000
|
||
logger.info("load_panel: %.0fms, %d rows", elapsed, len(df))
|
||
return df
|
||
|
||
# ── 撮合模拟 ──────────────────────────────────────
|
||
|
||
def simulate(
|
||
self,
|
||
panel: pl.DataFrame,
|
||
entries: pl.Series | None,
|
||
exits: pl.Series | None,
|
||
config: MatcherConfig,
|
||
entry_signal_ids: list[str] | None = None,
|
||
exit_signal_ids: list[str] | None = None,
|
||
) -> SimResult:
|
||
"""纯 NumPy 撮合模拟 — 逐 symbol 状态机。"""
|
||
if panel.is_empty():
|
||
return self._empty_result()
|
||
|
||
n = len(panel)
|
||
panel_dates = panel["date"].to_numpy()
|
||
panel_symbols = panel["symbol"].to_numpy()
|
||
|
||
# 构建信号数组
|
||
ent = np.zeros(n, dtype=bool)
|
||
ext = np.zeros(n, dtype=bool)
|
||
if entries is not None and len(entries) == n:
|
||
ent = entries.to_numpy().astype(bool)
|
||
if exits is not None and len(exits) == n:
|
||
ext = exits.to_numpy().astype(bool)
|
||
|
||
if not ent.any():
|
||
return self._empty_result()
|
||
|
||
# 成交口径: entry/exit 可分别配置 close_t (信号当日收盘) 或 open_t+1 (次日开盘)。
|
||
# open_t+1 时信号右移 1 天 (用前一根的信号 + 当根的 open 成交)。
|
||
open_prices = panel["open"].to_numpy()
|
||
close_prices = panel["close"].to_numpy()
|
||
|
||
# 同一 symbol 内相邻行掩码, 跨 symbol 边界不允许 shift (避免错配)。
|
||
same_prev_symbol = np.zeros(n, dtype=bool)
|
||
same_prev_symbol[1:] = panel_symbols[1:] == panel_symbols[:-1]
|
||
|
||
entry_prices = open_prices if config.entry_fill == "open_t+1" else close_prices
|
||
exit_prices = open_prices if config.exit_fill == "open_t+1" else close_prices
|
||
|
||
if config.entry_fill == "open_t+1":
|
||
ent_s = np.zeros(n, dtype=bool)
|
||
ent_s[1:] = ent[:-1] & same_prev_symbol
|
||
ent = ent_s
|
||
if config.exit_fill == "open_t+1":
|
||
ext_s = np.zeros(n, dtype=bool)
|
||
ext_s[1:] = ext[:-1] & same_prev_symbol
|
||
ext = ext_s
|
||
|
||
# 逐 symbol 撮合
|
||
trades: list[TradeRecord] = []
|
||
unique_symbols = np.unique(panel_symbols)
|
||
|
||
for sym in unique_symbols:
|
||
mask = panel_symbols == sym
|
||
sym_ent = ent[mask]
|
||
sym_ext = ext[mask]
|
||
sym_entry_prices = entry_prices[mask]
|
||
sym_exit_prices = exit_prices[mask]
|
||
sym_close = close_prices[mask]
|
||
sym_dates = panel_dates[mask]
|
||
|
||
holding = False
|
||
entry_idx = -1
|
||
entry_price = 0.0
|
||
hold_days = 0
|
||
|
||
for i in range(len(sym_ent)):
|
||
if not holding:
|
||
if sym_ent[i]:
|
||
holding = True
|
||
entry_idx = i
|
||
entry_price = float(sym_entry_prices[i])
|
||
hold_days = 0
|
||
else:
|
||
hold_days += 1
|
||
exit_triggered = False
|
||
exit_reason = ""
|
||
|
||
# 止损 — 用当日 close 检测 (优先级最高)
|
||
if config.stop_loss_pct is not None:
|
||
pnl = (float(sym_close[i]) - entry_price) / entry_price
|
||
if pnl <= -abs(config.stop_loss_pct):
|
||
exit_triggered = True
|
||
exit_reason = "stop_loss"
|
||
|
||
# 信号退出 (优先于 max_hold: 卖点信号是策略主动离场)
|
||
if not exit_triggered and sym_ext[i]:
|
||
exit_triggered = True
|
||
exit_reason = "signal"
|
||
|
||
# 最大持仓天数 (兜底: 无信号/未止损时强制平仓)
|
||
if not exit_triggered and config.max_hold_days is not None:
|
||
if hold_days >= config.max_hold_days:
|
||
exit_triggered = True
|
||
exit_reason = "max_hold"
|
||
|
||
if exit_triggered:
|
||
exit_price = float(sym_exit_prices[i])
|
||
pnl_pct = (exit_price - entry_price) / entry_price if entry_price > 0 else 0.0
|
||
fee_cost = config.buy_cost_pct() + config.sell_cost_pct()
|
||
pnl_pct -= fee_cost
|
||
|
||
e_date = sym_dates[entry_idx]
|
||
x_date = sym_dates[i]
|
||
trades.append(TradeRecord(
|
||
symbol=str(sym),
|
||
entry_date=e_date.item() if hasattr(e_date, "item") else e_date,
|
||
exit_date=x_date.item() if hasattr(x_date, "item") else x_date,
|
||
entry_price=round(entry_price, 4),
|
||
exit_price=round(exit_price, 4),
|
||
pnl_pct=round(pnl_pct, 6),
|
||
duration=int(hold_days),
|
||
exit_reason=exit_reason,
|
||
))
|
||
holding = False
|
||
|
||
# 净值曲线: 按出场日期归集收益
|
||
all_dates_sorted = np.sort(np.unique(panel_dates))
|
||
equity_curve, drawdown_curve = self._build_curves(trades, all_dates_sorted, config.initial_capital)
|
||
|
||
# 统计
|
||
date_min = panel_dates.min()
|
||
date_max = panel_dates.max()
|
||
d_min = date_min.item() if hasattr(date_min, "item") else date_min
|
||
d_max = date_max.item() if hasattr(date_max, "item") else date_max
|
||
stats = self._calc_stats(trades, config.initial_capital, d_min, d_max)
|
||
per_symbol = self._calc_per_symbol(trades)
|
||
|
||
return SimResult(
|
||
equity_curve=equity_curve,
|
||
drawdown_curve=drawdown_curve,
|
||
trades=trades,
|
||
per_symbol_stats=per_symbol,
|
||
stats=stats,
|
||
)
|
||
|
||
def simulate_independent_candidates(
|
||
self,
|
||
panel: pl.DataFrame,
|
||
entries: pl.Series | None,
|
||
exits: pl.Series | None,
|
||
config: MatcherConfig,
|
||
progress_cb: "Callable[[dict], None] | None" = None,
|
||
cancel_event: "threading.Event | None" = None,
|
||
entry_signal_ids: list[str] | None = None,
|
||
exit_signal_ids: list[str] | None = None,
|
||
) -> SimResult:
|
||
"""全量候选独立执行:每个买入信号都是独立样本, 不受资金/仓位限制。"""
|
||
if panel.is_empty():
|
||
return self._empty_result()
|
||
|
||
n = len(panel)
|
||
panel_dates = panel["date"].to_numpy()
|
||
panel_symbols = panel["symbol"].to_numpy()
|
||
|
||
ent_raw = np.zeros(n, dtype=bool)
|
||
ext_raw = np.zeros(n, dtype=bool)
|
||
if entries is not None and len(entries) == n:
|
||
ent_raw = entries.to_numpy().astype(bool)
|
||
if exits is not None and len(exits) == n:
|
||
ext_raw = exits.to_numpy().astype(bool)
|
||
n_candidates = int(ent_raw.sum())
|
||
if n_candidates <= 0:
|
||
return self._empty_result()
|
||
|
||
entry_signal_dates = np.array([None] * n, dtype=object)
|
||
exit_signal_dates = np.array([None] * n, dtype=object)
|
||
same_prev_symbol = panel_symbols[1:] == panel_symbols[:-1]
|
||
|
||
# 建仓口径: close_t 用信号日收盘, open_t+1 右移到次日 open 成交。
|
||
ent = np.zeros(n, dtype=bool)
|
||
if config.entry_fill == "open_t+1":
|
||
ent[1:] = ent_raw[:-1] & same_prev_symbol
|
||
for idx in np.flatnonzero(ent):
|
||
entry_signal_dates[idx] = self._date_str(panel_dates[idx - 1])
|
||
else:
|
||
ent = ent_raw
|
||
for idx in np.flatnonzero(ent):
|
||
entry_signal_dates[idx] = self._date_str(panel_dates[idx])
|
||
|
||
# 清仓口径: 独立于建仓, close_t 用信号日收盘, open_t+1 右移到次日 open。
|
||
ext = np.zeros(n, dtype=bool)
|
||
if config.exit_fill == "open_t+1":
|
||
ext[1:] = ext_raw[:-1] & same_prev_symbol
|
||
for idx in np.flatnonzero(ext):
|
||
exit_signal_dates[idx] = self._date_str(panel_dates[idx - 1])
|
||
else:
|
||
ext = ext_raw
|
||
for idx in np.flatnonzero(ext):
|
||
exit_signal_dates[idx] = self._date_str(panel_dates[idx])
|
||
|
||
open_prices = panel["open"].to_numpy()
|
||
high_prices = panel["high"].to_numpy() if "high" in panel.columns else open_prices
|
||
low_prices = panel["low"].to_numpy()
|
||
close_prices = panel["close"].to_numpy()
|
||
# 撮合价: 建仓/清仓各自独立选列。
|
||
entry_prices = open_prices if config.entry_fill == "open_t+1" else close_prices
|
||
exit_prices = open_prices if config.exit_fill == "open_t+1" else close_prices
|
||
|
||
# ── 分钟K精确成交预加载 (同 simulate_portfolio) ──
|
||
minute_cache: dict = {}
|
||
if config.minute_fill:
|
||
_trigger_dates: set[str] = set()
|
||
_trigger_symbols: set[str] = set()
|
||
for _idx in range(n):
|
||
if ent[_idx] or ext[_idx]:
|
||
_trigger_dates.add(self._date_str(panel_dates[_idx]))
|
||
_trigger_symbols.add(str(panel_symbols[_idx]))
|
||
if _trigger_dates and _trigger_symbols:
|
||
_loaded = self._load_minute_for_fills(
|
||
self.repo, list(_trigger_symbols), _trigger_dates, "stock",
|
||
)
|
||
for _key, _marr in _loaded.items():
|
||
if _marr is not None and len(_marr) > 0:
|
||
minute_cache[_key] = _marr
|
||
|
||
def _refill_price(idx: int, side: str, daily_price: float) -> float:
|
||
if not config.minute_fill or not minute_cache:
|
||
return daily_price
|
||
_sym = str(panel_symbols[idx])
|
||
_d = self._date_str(panel_dates[idx])
|
||
_marr = minute_cache.get((_sym, _d))
|
||
if _marr is None:
|
||
return daily_price
|
||
_ref = None
|
||
for _col in ("ma5", "ma10", "ma20"):
|
||
if _col in panel.columns:
|
||
try:
|
||
_fv = float(panel[_col][idx])
|
||
if _fv > 0 and np.isfinite(_fv):
|
||
_ref = _fv
|
||
break
|
||
except (TypeError, ValueError):
|
||
pass
|
||
_precise = self._resolve_minute_fill(_marr, _ref, side)
|
||
return _precise if _precise is not None else daily_price
|
||
|
||
has_volume = "volume" in panel.columns
|
||
volumes = panel["volume"].fill_null(0).to_numpy() if has_volume else np.ones(n, dtype=float)
|
||
names = panel["name"].fill_null("").to_numpy() if "name" in panel.columns else np.array([""] * n)
|
||
scores = panel["score"].fill_null(0).to_numpy() if "score" in panel.columns else np.zeros(n, dtype=float)
|
||
trade_scores = scores.copy()
|
||
# 评分跟随建仓口径 shift (评分在买入日生效)。
|
||
if config.entry_fill == "open_t+1":
|
||
trade_scores[1:] = np.where(panel_symbols[1:] == panel_symbols[:-1], scores[:-1], trade_scores[1:])
|
||
limit_up_flags = (
|
||
panel["signal_limit_up"].fill_null(False).to_numpy().astype(bool)
|
||
if "signal_limit_up" in panel.columns else np.zeros(n, dtype=bool)
|
||
)
|
||
limit_down_flags = (
|
||
panel["signal_limit_down"].fill_null(False).to_numpy().astype(bool)
|
||
if "signal_limit_down" in panel.columns else np.zeros(n, dtype=bool)
|
||
)
|
||
|
||
symbol_rows: dict[str, list[int]] = {}
|
||
row_pos_in_symbol = np.zeros(n, dtype=int)
|
||
for i, sym_value in enumerate(panel_symbols):
|
||
sym = str(sym_value)
|
||
rows = symbol_rows.setdefault(sym, [])
|
||
row_pos_in_symbol[i] = len(rows)
|
||
rows.append(i)
|
||
|
||
buy_cost_pct = config.buy_cost_pct()
|
||
sell_cost_pct = config.sell_cost_pct()
|
||
score_min = getattr(config, "score_min", None)
|
||
score_max = getattr(config, "score_max", None)
|
||
trades: list[TradeRecord] = []
|
||
execution_stats: dict[str, int] = {
|
||
"buy_invalid_price": 0,
|
||
"buy_suspended": 0,
|
||
"buy_limit_up": 0,
|
||
"buy_score_filter": 0,
|
||
"buy_no_next_bar": max(n_candidates - int(ent.sum()), 0),
|
||
"sell_invalid_price": 0,
|
||
"sell_suspended": 0,
|
||
"sell_limit_down": 0,
|
||
"sell_no_future": 0,
|
||
"pending_exit": 0,
|
||
}
|
||
|
||
def _count(key: str) -> None:
|
||
execution_stats[key] = execution_stats.get(key, 0) + 1
|
||
|
||
def _valid_price(value) -> bool:
|
||
try:
|
||
v = float(value)
|
||
except (TypeError, ValueError):
|
||
return False
|
||
return v > 0 and np.isfinite(v)
|
||
|
||
def _is_suspended(idx: int) -> bool:
|
||
o = float(open_prices[idx])
|
||
h = float(high_prices[idx])
|
||
l = float(low_prices[idx])
|
||
c = float(close_prices[idx])
|
||
valid_bar = any(_valid_price(x) for x in (o, h, l, c))
|
||
if not valid_bar:
|
||
return True
|
||
if has_volume and float(volumes[idx] or 0) <= 0:
|
||
same_price = max(o, h, l, c) - min(o, h, l, c) <= max(abs(c) * 1e-4, 0.01)
|
||
if same_price:
|
||
return True
|
||
return False
|
||
|
||
def _is_one_price_limit(idx: int, direction: str) -> bool:
|
||
if _is_suspended(idx):
|
||
return False
|
||
o = float(open_prices[idx])
|
||
h = float(high_prices[idx])
|
||
l = float(low_prices[idx])
|
||
c = float(close_prices[idx])
|
||
if not all(_valid_price(x) for x in (o, h, l, c)):
|
||
return False
|
||
same_price = max(o, h, l, c) - min(o, h, l, c) <= max(abs(c) * 1e-4, 0.01)
|
||
if direction == "up":
|
||
return bool(limit_up_flags[idx]) and same_price
|
||
return bool(limit_down_flags[idx]) and same_price
|
||
|
||
def _can_buy(idx: int) -> tuple[bool, str]:
|
||
if _is_suspended(idx):
|
||
return False, "buy_suspended"
|
||
if not _valid_price(entry_prices[idx]):
|
||
return False, "buy_invalid_price"
|
||
if _is_one_price_limit(idx, "up"):
|
||
return False, "buy_limit_up"
|
||
return True, ""
|
||
|
||
def _can_sell(idx: int, exit_price_override: float | None = None) -> tuple[bool, str]:
|
||
if _is_suspended(idx):
|
||
return False, "sell_suspended"
|
||
exit_price = exit_price_override if exit_price_override is not None else exit_prices[idx]
|
||
if not _valid_price(exit_price):
|
||
return False, "sell_invalid_price"
|
||
if _is_one_price_limit(idx, "down"):
|
||
return False, "sell_limit_down"
|
||
return True, ""
|
||
|
||
def _risk_exit(pos: dict, idx: int) -> tuple[str | None, float | None]:
|
||
if pos.get("pending_exit_reason") or pos.get("entry_idx") == idx:
|
||
return None, None
|
||
entry_price = float(pos["entry_price"])
|
||
if entry_price <= 0:
|
||
return None, None
|
||
open_price = float(open_prices[idx])
|
||
low_price = float(low_prices[idx])
|
||
high_price = float(high_prices[idx])
|
||
peak_price = float(pos.get("max_high", entry_price))
|
||
risk_lines: list[tuple[float, str]] = []
|
||
|
||
if config.stop_loss_pct is not None:
|
||
risk_lines.append((entry_price * (1 - abs(config.stop_loss_pct)), "stop_loss"))
|
||
if config.trailing_stop_pct is not None and peak_price > 0:
|
||
risk_lines.append((peak_price * (1 - abs(config.trailing_stop_pct)), "trailing_stop"))
|
||
|
||
activate_pct = getattr(config, "trailing_take_profit_activate_pct", None)
|
||
drawdown_pct = getattr(config, "trailing_take_profit_drawdown_pct", None)
|
||
if activate_pct is not None and drawdown_pct is not None and peak_price > entry_price:
|
||
peak_profit = peak_price / entry_price - 1
|
||
if peak_profit >= abs(float(activate_pct)):
|
||
# 回撤止盈触发线: 相对峰值价回撤 drawdown 个点 (纯峰值口径)
|
||
risk_lines.append((peak_price * (1 - abs(float(drawdown_pct))), "trailing_take_profit"))
|
||
|
||
risk_lines = [(line, reason) for line, reason in risk_lines if _valid_price(line)]
|
||
# 止损/移损/回撤止盈: 价格跌破风控线触发 (取最高优先级线)
|
||
if risk_lines:
|
||
stop_price, reason = max(risk_lines, key=lambda item: item[0])
|
||
if _valid_price(open_price) and open_price <= stop_price:
|
||
return reason, open_price
|
||
if _valid_price(low_price) and low_price <= stop_price:
|
||
return reason, stop_price
|
||
|
||
# 固定止盈: 价格涨破止盈线触发
|
||
tp_pct = getattr(config, "take_profit_pct", None)
|
||
if tp_pct is not None:
|
||
tp_line = entry_price * (1 + abs(float(tp_pct)))
|
||
if _valid_price(tp_line):
|
||
# 开盘即超过止盈线 → 以开盘价成交; 否则当日触及高点止盈
|
||
if _valid_price(open_price) and open_price >= tp_line:
|
||
return "take_profit", open_price
|
||
if _valid_price(high_price) and high_price >= tp_line:
|
||
return "take_profit", tp_line
|
||
return None, None
|
||
|
||
def _try_close(pos: dict, idx: int, reason: str, signal_date: str, exit_price_override: float | None = None) -> bool:
|
||
ok, block_reason = _can_sell(idx, exit_price_override)
|
||
if not ok:
|
||
if not pos.get("pending_exit_reason"):
|
||
pos["pending_exit_reason"] = reason
|
||
pos["pending_exit_signal_date"] = signal_date
|
||
_count("pending_exit")
|
||
pos["blocked_exit_days"] = int(pos.get("blocked_exit_days", 0)) + 1
|
||
_count(block_reason)
|
||
return False
|
||
|
||
if exit_price_override is not None:
|
||
exit_price = float(exit_price_override)
|
||
else:
|
||
exit_price = _refill_price(idx, "sell", float(exit_prices[idx]))
|
||
shares = 100.0
|
||
entry_value = shares * float(pos["entry_price"]) * (1 + buy_cost_pct)
|
||
exit_value = shares * exit_price * (1 - sell_cost_pct)
|
||
pnl_amount = exit_value - entry_value
|
||
pnl_pct = pnl_amount / entry_value if entry_value > 0 else 0.0
|
||
trades.append(TradeRecord(
|
||
symbol=str(pos["symbol"]),
|
||
name=str(pos.get("name", "")),
|
||
entry_date=pos["entry_date"],
|
||
exit_date=self._date_str(panel_dates[idx]),
|
||
entry_price=round(float(pos["entry_price"]), 4),
|
||
exit_price=round(exit_price, 4),
|
||
pnl_pct=round(float(pnl_pct), 6),
|
||
duration=int(pos["hold_days"]),
|
||
exit_reason=reason,
|
||
shares=shares,
|
||
lots=1.0,
|
||
position_pct=0.0,
|
||
entry_value=round(float(entry_value), 2),
|
||
exit_value=round(float(exit_value), 2),
|
||
pnl_amount=round(float(pnl_amount), 2),
|
||
entry_score=round(float(pos["entry_score"]), 2) if pos.get("entry_score") is not None else None,
|
||
entry_signal_date=pos.get("entry_signal_date"),
|
||
exit_signal_date=signal_date,
|
||
blocked_exit_days=int(pos.get("blocked_exit_days", 0)),
|
||
entry_signal_id=pos.get("entry_signal_id"),
|
||
exit_signal_id=_resolve_signal_id(panel, idx, exit_signal_ids) if reason == "signal" else None,
|
||
))
|
||
return True
|
||
|
||
candidate_indices = np.flatnonzero(ent)
|
||
for seq, entry_idx in enumerate(candidate_indices, start=1):
|
||
if cancel_event is not None and cancel_event.is_set():
|
||
logger.info("全量模拟被用户取消 (第 %d/%d 个候选)", seq, len(candidate_indices))
|
||
break
|
||
if progress_cb is not None and (seq == 1 or seq % 500 == 0):
|
||
try:
|
||
progress_cb({
|
||
"day": seq,
|
||
"total": len(candidate_indices),
|
||
"date": self._date_str(panel_dates[entry_idx]),
|
||
"equity": 0,
|
||
})
|
||
except Exception:
|
||
pass
|
||
|
||
ok, block_reason = _can_buy(entry_idx)
|
||
if not ok:
|
||
_count(block_reason)
|
||
continue
|
||
score = float(trade_scores[entry_idx] or 0.0)
|
||
if score_min is not None and score < score_min:
|
||
_count("buy_score_filter")
|
||
continue
|
||
if score_max is not None and score > score_max:
|
||
_count("buy_score_filter")
|
||
continue
|
||
|
||
sym = str(panel_symbols[entry_idx])
|
||
rows = symbol_rows.get(sym, [])
|
||
start_pos = int(row_pos_in_symbol[entry_idx])
|
||
if start_pos >= len(rows):
|
||
_count("sell_no_future")
|
||
continue
|
||
|
||
entry_price = _refill_price(entry_idx, "buy", float(entry_prices[entry_idx]))
|
||
pos = {
|
||
"symbol": sym,
|
||
"name": str(names[entry_idx] or ""),
|
||
"entry_idx": entry_idx,
|
||
"entry_date": self._date_str(panel_dates[entry_idx]),
|
||
"entry_signal_date": entry_signal_dates[entry_idx] or self._date_str(panel_dates[entry_idx]),
|
||
"entry_signal_id": _resolve_signal_id(panel, entry_idx, entry_signal_ids),
|
||
"entry_price": entry_price,
|
||
"entry_score": score,
|
||
"hold_days": 0,
|
||
"max_high": entry_price,
|
||
"pending_exit_reason": None,
|
||
"pending_exit_signal_date": None,
|
||
"blocked_exit_days": 0,
|
||
}
|
||
hi = float(high_prices[entry_idx])
|
||
if _valid_price(hi):
|
||
pos["max_high"] = max(float(pos["max_high"]), hi)
|
||
|
||
closed = False
|
||
last_idx = entry_idx
|
||
for idx in rows[start_pos + 1:]:
|
||
last_idx = idx
|
||
pos["hold_days"] = int(pos["hold_days"]) + 1
|
||
d_str = self._date_str(panel_dates[idx])
|
||
|
||
def _scheduled_reason() -> tuple[str | None, str]:
|
||
if pos.get("pending_exit_reason"):
|
||
return str(pos["pending_exit_reason"]), str(pos.get("pending_exit_signal_date") or d_str)
|
||
# 卖点信号优先于到期: 策略主动离场先于 max_hold 兜底。
|
||
if ext[idx]:
|
||
return "signal", str(exit_signal_dates[idx] or d_str)
|
||
if config.max_hold_days is not None and pos["hold_days"] >= config.max_hold_days:
|
||
return "max_hold", d_str
|
||
if idx == rows[-1]:
|
||
return "end", d_str
|
||
return None, d_str
|
||
|
||
# 统一退出顺序: 风控(止损/移动止损/止盈)先于计划出场 (signal/max_hold/end)。
|
||
# 无论 entry/exit 口径如何, 风控都是保护性离场, 必须最高优先级。
|
||
reason, override_price = _risk_exit(pos, idx)
|
||
if reason and _try_close(pos, idx, reason, d_str, override_price):
|
||
closed = True
|
||
break
|
||
reason, signal_date = _scheduled_reason()
|
||
if reason and _try_close(pos, idx, reason, signal_date):
|
||
closed = True
|
||
break
|
||
|
||
hi = float(high_prices[idx])
|
||
if _valid_price(hi):
|
||
pos["max_high"] = max(float(pos.get("max_high", entry_price)), hi)
|
||
|
||
if not closed:
|
||
if last_idx == entry_idx:
|
||
_count("sell_no_future")
|
||
elif not pos.get("pending_exit_reason"):
|
||
_try_close(pos, last_idx, "end", self._date_str(panel_dates[last_idx]))
|
||
|
||
return self._calc_independent_candidate_result(trades, n_candidates, execution_stats)
|
||
|
||
# ── 分钟K精确成交 ──────────────────────────────────
|
||
|
||
@staticmethod
|
||
def _resolve_minute_fill(
|
||
minute_arr: np.ndarray,
|
||
ref_price: float | None,
|
||
side: str,
|
||
) -> float | None:
|
||
"""用当日分钟K确定精确成交价。
|
||
|
||
Args:
|
||
minute_arr: float64 2D 数组, 列顺序 = _MINUTE_NUMERIC_COLS
|
||
[open(0), high(1), low(2), close(3), volume(4), amount(5)]
|
||
(缺失的尾部列直接不存在, 用 shape 判断)
|
||
ref_price: 信号参考线价格 (如 MA5 值); None 表示无参考线
|
||
side: "buy" 或 "sell", 决定穿越方向
|
||
|
||
Returns:
|
||
精确成交价, 或 None (降级到日K口径)
|
||
"""
|
||
if minute_arr is None or len(minute_arr) == 0:
|
||
return None
|
||
|
||
ncols = minute_arr.shape[1] if minute_arr.ndim == 2 else 1
|
||
opens = minute_arr[:, 0]
|
||
highs = minute_arr[:, 1] if ncols > 1 else opens
|
||
lows = minute_arr[:, 2] if ncols > 2 else opens
|
||
closes = minute_arr[:, 3] if ncols > 3 else opens
|
||
volumes = minute_arr[:, 4] if ncols > 4 else None
|
||
amounts = minute_arr[:, 5] if ncols > 5 else None
|
||
|
||
# 有参考线 → 穿越价成交 (逻辑同止损: 找价格穿越参考线的时刻)
|
||
if ref_price is not None and ref_price > 0 and np.isfinite(ref_price):
|
||
if side == "sell":
|
||
# 卖出: 价格跌破参考线 → 开盘已低于则按开盘; 否则按参考线 (低点触及)
|
||
if np.isfinite(opens[0]) and opens[0] <= ref_price:
|
||
return float(opens[0])
|
||
if np.any(np.isfinite(lows) & (lows <= ref_price)):
|
||
return float(ref_price)
|
||
else:
|
||
# 买入: 价格涨破参考线 → 开盘已高于则按开盘; 否则按参考线 (高点触及)
|
||
if np.isfinite(opens[0]) and opens[0] >= ref_price:
|
||
return float(opens[0])
|
||
if np.any(np.isfinite(highs) & (highs >= ref_price)):
|
||
return float(ref_price)
|
||
# 参考线存在但当日分钟K未穿越 → 用收盘 (信号确认)
|
||
return float(closes[-1]) if np.isfinite(closes[-1]) else None
|
||
|
||
# 无参考线 → VWAP (成交额/成交量), 退化到收盘价
|
||
if volumes is not None and amounts is not None:
|
||
total_vol = float(np.nansum(volumes))
|
||
total_amt = float(np.nansum(amounts))
|
||
if total_vol > 0 and total_amt > 0:
|
||
return total_amt / total_vol
|
||
|
||
return float(closes[-1]) if np.isfinite(closes[-1]) else None
|
||
|
||
# 分钟K cache 存储的数值列及固定顺序 (_resolve_minute_fill 按此顺序整数索引)。
|
||
_MINUTE_NUMERIC_COLS = ["open", "high", "low", "close", "volume", "amount"]
|
||
|
||
@staticmethod
|
||
def _load_minute_for_fills(
|
||
repo,
|
||
symbols: list[str],
|
||
dates_needed: set,
|
||
asset_type: str,
|
||
) -> dict:
|
||
"""按触发日加载分钟K, 返回 {(symbol, date_str): float64 2D ndarray}。
|
||
|
||
dates_needed: 需要分钟数据的日期集合 (set of date strings "YYYY-MM-DD")
|
||
|
||
按触发日分批读取对应分区文件 (get_minute_by_dates), 而非扫描整个区间
|
||
(get_minute_range)。内存与回测区间长度解耦, 只随触发日数量增长 ——
|
||
触发日稀疏时避免读取区间内大量无关日期导致爆内存。
|
||
|
||
cache 值为 float64 紧凑 2D 数组 (列顺序见 _MINUTE_NUMERIC_COLS), 而非
|
||
完整 DataFrame, 避免每条记录携带 polars 元数据开销导致内存膨胀。
|
||
"""
|
||
if not symbols or not dates_needed:
|
||
return {}
|
||
from datetime import date as _date
|
||
sorted_date_strs = sorted(dates_needed)
|
||
date_objs = [_date.fromisoformat(s) for s in sorted_date_strs]
|
||
|
||
cache: dict = {}
|
||
# 分批读取: 每批 50 个交易日, 处理完拼进 cache, 避免单批过大。
|
||
BATCH = 50
|
||
numeric_cols = BacktestEngine._MINUTE_NUMERIC_COLS
|
||
for i in range(0, len(date_objs), BATCH):
|
||
batch = date_objs[i:i + BATCH]
|
||
try:
|
||
df = repo.get_minute_by_dates(symbols, batch, asset_type=asset_type)
|
||
except Exception as e: # noqa: BLE001
|
||
logger.warning("minute fill data load failed (batch %d-%d): %s", i, i + len(batch), e)
|
||
continue
|
||
if df.is_empty():
|
||
continue
|
||
# 按 (symbol, 日期) 分组, 每组转紧凑 float64 数组存入 cache
|
||
df = df.with_columns(
|
||
pl.col("datetime").dt.strftime("%Y-%m-%d").alias("_d_str")
|
||
)
|
||
for sub in df.partition_by(["symbol", "_d_str"], as_dict=False):
|
||
if sub.is_empty():
|
||
continue
|
||
sym = sub["symbol"][0]
|
||
d_str = sub["_d_str"][0]
|
||
cols = [c for c in numeric_cols if c in sub.columns]
|
||
cache[(sym, d_str)] = sub.select(
|
||
[pl.col(c).cast(pl.Float64) for c in cols]
|
||
).to_numpy()
|
||
return cache
|
||
|
||
def simulate_portfolio(
|
||
self,
|
||
panel: pl.DataFrame,
|
||
entries: pl.Series | None,
|
||
exits: pl.Series | None,
|
||
config: MatcherConfig,
|
||
progress_cb: "Callable[[dict], None] | None" = None,
|
||
cancel_event: "threading.Event | None" = None,
|
||
entry_signal_ids: list[str] | None = None,
|
||
exit_signal_ids: list[str] | None = None,
|
||
) -> SimResult:
|
||
"""账户级组合回测:日线信号 → 成交约束 → 仓位/现金撮合。"""
|
||
if panel.is_empty():
|
||
return self._empty_result()
|
||
|
||
n = len(panel)
|
||
panel_dates = panel["date"].to_numpy()
|
||
panel_symbols = panel["symbol"].to_numpy()
|
||
|
||
ent_raw = np.zeros(n, dtype=bool)
|
||
ext_raw = np.zeros(n, dtype=bool)
|
||
if entries is not None and len(entries) == n:
|
||
ent_raw = entries.to_numpy().astype(bool)
|
||
if exits is not None and len(exits) == n:
|
||
ext_raw = exits.to_numpy().astype(bool)
|
||
if not ent_raw.any():
|
||
return self._empty_result()
|
||
|
||
entry_signal_dates = np.array([None] * n, dtype=object)
|
||
exit_signal_dates = np.array([None] * n, dtype=object)
|
||
same_prev_symbol = panel_symbols[1:] == panel_symbols[:-1]
|
||
|
||
# 建仓口径: close_t 用信号日收盘, open_t+1 右移到次日 open 成交。
|
||
ent = np.zeros(n, dtype=bool)
|
||
if config.entry_fill == "open_t+1":
|
||
ent[1:] = ent_raw[:-1] & same_prev_symbol
|
||
for idx in np.flatnonzero(ent):
|
||
entry_signal_dates[idx] = self._date_str(panel_dates[idx - 1])
|
||
else:
|
||
ent = ent_raw
|
||
for idx in np.flatnonzero(ent):
|
||
entry_signal_dates[idx] = self._date_str(panel_dates[idx])
|
||
|
||
# 清仓口径: 独立于建仓。
|
||
ext = np.zeros(n, dtype=bool)
|
||
if config.exit_fill == "open_t+1":
|
||
ext[1:] = ext_raw[:-1] & same_prev_symbol
|
||
for idx in np.flatnonzero(ext):
|
||
exit_signal_dates[idx] = self._date_str(panel_dates[idx - 1])
|
||
else:
|
||
ext = ext_raw
|
||
for idx in np.flatnonzero(ext):
|
||
exit_signal_dates[idx] = self._date_str(panel_dates[idx])
|
||
|
||
open_prices = panel["open"].to_numpy()
|
||
high_prices = panel["high"].to_numpy() if "high" in panel.columns else open_prices
|
||
low_prices = panel["low"].to_numpy()
|
||
close_prices = panel["close"].to_numpy()
|
||
# 撮合价: 建仓/清仓各自独立选列。
|
||
entry_prices = open_prices if config.entry_fill == "open_t+1" else close_prices
|
||
exit_prices = open_prices if config.exit_fill == "open_t+1" else close_prices
|
||
has_volume = "volume" in panel.columns
|
||
volumes = panel["volume"].fill_null(0).to_numpy() if has_volume else np.ones(n, dtype=float)
|
||
names = (
|
||
panel["name"].fill_null("").to_numpy()
|
||
if "name" in panel.columns else np.array([""] * n)
|
||
)
|
||
scores = (
|
||
panel["score"].fill_null(0).to_numpy()
|
||
if "score" in panel.columns else np.zeros(n, dtype=float)
|
||
)
|
||
trade_scores = scores.copy()
|
||
# 评分跟随建仓口径 shift (评分在买入日生效)。
|
||
if config.entry_fill == "open_t+1":
|
||
trade_scores[1:] = np.where(panel_symbols[1:] == panel_symbols[:-1], scores[:-1], trade_scores[1:])
|
||
limit_up_flags = (
|
||
panel["signal_limit_up"].fill_null(False).to_numpy().astype(bool)
|
||
if "signal_limit_up" in panel.columns else np.zeros(n, dtype=bool)
|
||
)
|
||
limit_down_flags = (
|
||
panel["signal_limit_down"].fill_null(False).to_numpy().astype(bool)
|
||
if "signal_limit_down" in panel.columns else np.zeros(n, dtype=bool)
|
||
)
|
||
|
||
date_to_indices: dict[str, list[int]] = {}
|
||
for i, d in enumerate(panel_dates):
|
||
d_str = self._date_str(d)
|
||
date_to_indices.setdefault(d_str, []).append(i)
|
||
all_dates = sorted(date_to_indices.keys())
|
||
if not all_dates:
|
||
return self._empty_result()
|
||
|
||
buy_cost_pct = config.buy_cost_pct()
|
||
sell_cost_pct = config.sell_cost_pct()
|
||
cash = float(config.initial_capital)
|
||
peak = cash
|
||
max_positions = max(int(config.max_positions), 0)
|
||
max_exposure_pct = min(max(float(getattr(config, "max_exposure_pct", 1.0)), 0.0), 1.0)
|
||
score_min = getattr(config, "score_min", None)
|
||
score_max = getattr(config, "score_max", None)
|
||
positions: dict[str, dict] = {}
|
||
last_close: dict[str, float] = {}
|
||
trades: list[TradeRecord] = []
|
||
|
||
# ── 分钟K精确成交预加载 ──
|
||
# 信号触发日加载分钟K, 成交时用穿越价/VWAP替代收盘价
|
||
minute_cache: dict = {} # {(symbol, date_str): structured ndarray}
|
||
if config.minute_fill:
|
||
trigger_dates: set[str] = set()
|
||
trigger_symbols: set[str] = set()
|
||
for idx in range(n):
|
||
if ent[idx] or ext[idx]:
|
||
trigger_dates.add(self._date_str(panel_dates[idx]))
|
||
trigger_symbols.add(str(panel_symbols[idx]))
|
||
if trigger_dates and trigger_symbols:
|
||
asset_type = "etf" if all(
|
||
str(s).endswith(".SH") and str(s).startswith("5") for s in list(trigger_symbols)[:5]
|
||
) else "stock"
|
||
loaded = self._load_minute_for_fills(
|
||
self.repo, list(trigger_symbols), trigger_dates, asset_type,
|
||
)
|
||
for key, marr in loaded.items():
|
||
if marr is not None and len(marr) > 0:
|
||
minute_cache[key] = marr
|
||
|
||
def _refill_price(idx: int, side: str, daily_price: float) -> float:
|
||
"""分钟K精确成交价; 无数据则降级为 daily_price。"""
|
||
if not config.minute_fill or not minute_cache:
|
||
return daily_price
|
||
sym = str(panel_symbols[idx])
|
||
d_str = self._date_str(panel_dates[idx])
|
||
marr = minute_cache.get((sym, d_str))
|
||
if marr is None:
|
||
return daily_price
|
||
# 参考线: 从 panel 取 ma5/ma10/ma20 作为近似 (均线类信号)
|
||
ref = None
|
||
for col in ("ma5", "ma10", "ma20"):
|
||
if col in panel.columns:
|
||
val = panel[col][idx]
|
||
try:
|
||
fv = float(val)
|
||
if fv > 0 and np.isfinite(fv):
|
||
ref = fv
|
||
break
|
||
except (TypeError, ValueError):
|
||
pass
|
||
precise = self._resolve_minute_fill(marr, ref, side)
|
||
return precise if precise is not None else daily_price
|
||
|
||
equity_curve: list[dict] = []
|
||
drawdown_curve: list[dict] = []
|
||
execution_stats: dict[str, int] = {
|
||
"buy_invalid_price": 0,
|
||
"buy_suspended": 0,
|
||
"buy_limit_up": 0,
|
||
"buy_no_slot": 0,
|
||
"buy_cash": 0,
|
||
"buy_lot_size": 0,
|
||
"buy_same_day_reentry": 0,
|
||
"buy_exposure": 0,
|
||
"buy_score_filter": 0,
|
||
"sell_invalid_price": 0,
|
||
"sell_suspended": 0,
|
||
"sell_limit_down": 0,
|
||
"pending_exit": 0,
|
||
}
|
||
|
||
def _count(key: str) -> None:
|
||
execution_stats[key] = execution_stats.get(key, 0) + 1
|
||
|
||
def _valid_price(value) -> bool:
|
||
try:
|
||
v = float(value)
|
||
except (TypeError, ValueError):
|
||
return False
|
||
return v > 0 and np.isfinite(v)
|
||
|
||
def _market_value() -> float:
|
||
value = 0.0
|
||
for pos in positions.values():
|
||
mark = last_close.get(pos["symbol"], pos["entry_price"])
|
||
value += pos["shares"] * mark
|
||
return value
|
||
|
||
def _is_suspended(idx: int) -> bool:
|
||
o = float(open_prices[idx])
|
||
h = float(high_prices[idx])
|
||
l = float(low_prices[idx])
|
||
c = float(close_prices[idx])
|
||
valid_bar = any(_valid_price(x) for x in (o, h, l, c))
|
||
if not valid_bar:
|
||
return True
|
||
if has_volume and float(volumes[idx] or 0) <= 0:
|
||
same_price = max(o, h, l, c) - min(o, h, l, c) <= max(abs(c) * 1e-4, 0.01)
|
||
if same_price:
|
||
return True
|
||
return False
|
||
|
||
def _is_one_price_limit(idx: int, direction: str) -> bool:
|
||
if _is_suspended(idx):
|
||
return False
|
||
o = float(open_prices[idx])
|
||
h = float(high_prices[idx])
|
||
l = float(low_prices[idx])
|
||
c = float(close_prices[idx])
|
||
if not all(_valid_price(x) for x in (o, h, l, c)):
|
||
return False
|
||
same_price = max(o, h, l, c) - min(o, h, l, c) <= max(abs(c) * 1e-4, 0.01)
|
||
if direction == "up":
|
||
return bool(limit_up_flags[idx]) and same_price
|
||
return bool(limit_down_flags[idx]) and same_price
|
||
|
||
def _can_buy(idx: int) -> tuple[bool, str]:
|
||
if _is_suspended(idx):
|
||
return False, "buy_suspended"
|
||
if not _valid_price(entry_prices[idx]):
|
||
return False, "buy_invalid_price"
|
||
if _is_one_price_limit(idx, "up"):
|
||
return False, "buy_limit_up"
|
||
return True, ""
|
||
|
||
def _can_sell(idx: int, exit_price_override: float | None = None) -> tuple[bool, str]:
|
||
if _is_suspended(idx):
|
||
return False, "sell_suspended"
|
||
exit_price = exit_price_override if exit_price_override is not None else exit_prices[idx]
|
||
if not _valid_price(exit_price):
|
||
return False, "sell_invalid_price"
|
||
if _is_one_price_limit(idx, "down"):
|
||
return False, "sell_limit_down"
|
||
return True, ""
|
||
|
||
def _mark_pending(sym: str, reason: str, signal_date: str) -> None:
|
||
pos = positions[sym]
|
||
if not pos.get("pending_exit_reason"):
|
||
pos["pending_exit_reason"] = reason
|
||
pos["pending_exit_signal_date"] = signal_date
|
||
_count("pending_exit")
|
||
pos["blocked_exit_days"] = int(pos.get("blocked_exit_days", 0)) + 1
|
||
|
||
def _sell(
|
||
sym: str,
|
||
idx: int,
|
||
reason: str,
|
||
signal_date: str,
|
||
sold_today: set[str],
|
||
exit_price_override: float | None = None,
|
||
) -> None:
|
||
nonlocal cash
|
||
pos = positions.pop(sym)
|
||
if exit_price_override is not None:
|
||
exit_price = float(exit_price_override)
|
||
else:
|
||
exit_price = _refill_price(idx, "sell", float(exit_prices[idx]))
|
||
exit_value = pos["shares"] * exit_price * (1 - sell_cost_pct)
|
||
cash += exit_value
|
||
pnl_amount = exit_value - pos["entry_value"]
|
||
pnl_pct = (exit_value - pos["entry_value"]) / pos["entry_value"] if pos["entry_value"] > 0 else 0.0
|
||
sold_today.add(sym)
|
||
trades.append(TradeRecord(
|
||
symbol=sym,
|
||
name=pos.get("name", ""),
|
||
entry_date=pos["entry_date"],
|
||
exit_date=self._date_str(panel_dates[idx]),
|
||
entry_price=round(float(pos["entry_price"]), 4),
|
||
exit_price=round(exit_price, 4),
|
||
pnl_pct=round(float(pnl_pct), 6),
|
||
duration=int(pos["hold_days"]),
|
||
exit_reason=reason,
|
||
shares=round(float(pos["shares"]), 4),
|
||
lots=round(float(pos["lots"]), 2),
|
||
position_pct=round(float(pos.get("position_pct", 0.0)), 6),
|
||
entry_value=round(float(pos["entry_value"]), 2),
|
||
exit_value=round(float(exit_value), 2),
|
||
pnl_amount=round(float(pnl_amount), 2),
|
||
entry_score=round(float(pos["entry_score"]), 2) if pos.get("entry_score") is not None else None,
|
||
entry_signal_date=pos.get("entry_signal_date"),
|
||
exit_signal_date=signal_date,
|
||
blocked_exit_days=int(pos.get("blocked_exit_days", 0)),
|
||
entry_signal_id=pos.get("entry_signal_id"),
|
||
exit_signal_id=_resolve_signal_id(panel, idx, exit_signal_ids) if reason == "signal" else None,
|
||
))
|
||
|
||
def _try_sell(
|
||
sym: str,
|
||
idx: int | None,
|
||
reason: str,
|
||
signal_date: str,
|
||
sold_today: set[str],
|
||
exit_price_override: float | None = None,
|
||
) -> bool:
|
||
if idx is None:
|
||
_mark_pending(sym, reason, signal_date)
|
||
_count("sell_suspended")
|
||
return False
|
||
ok, block_reason = _can_sell(idx, exit_price_override)
|
||
if not ok:
|
||
_mark_pending(sym, reason, signal_date)
|
||
_count(block_reason)
|
||
return False
|
||
_sell(sym, idx, reason, signal_date, sold_today, exit_price_override)
|
||
return True
|
||
|
||
def _process_scheduled_exits(
|
||
d_idx: int,
|
||
d_str: str,
|
||
row_by_symbol: dict[str, int],
|
||
sold_today: set[str],
|
||
) -> None:
|
||
for sym in list(positions.keys()):
|
||
pos = positions.get(sym)
|
||
if pos is None:
|
||
continue
|
||
idx = row_by_symbol.get(sym)
|
||
reason = ""
|
||
signal_date = d_str
|
||
if pos.get("pending_exit_reason"):
|
||
reason = str(pos["pending_exit_reason"])
|
||
signal_date = str(pos.get("pending_exit_signal_date") or d_str)
|
||
# 卖点信号优先于到期: 策略主动离场先于 max_hold 兜底。
|
||
elif idx is not None and ext[idx]:
|
||
reason = "signal"
|
||
signal_date = str(exit_signal_dates[idx] or d_str)
|
||
elif config.max_hold_days is not None and pos["hold_days"] >= config.max_hold_days:
|
||
reason = "max_hold"
|
||
elif d_idx == len(all_dates) - 1:
|
||
reason = "end"
|
||
if reason:
|
||
_try_sell(sym, idx, reason, signal_date, sold_today)
|
||
|
||
def _process_risk_exits(d_str: str, row_by_symbol: dict[str, int], sold_today: set[str]) -> None:
|
||
for sym in list(positions.keys()):
|
||
pos = positions.get(sym)
|
||
if pos is None or pos.get("pending_exit_reason"):
|
||
continue
|
||
if pos.get("entry_date") == d_str:
|
||
continue
|
||
idx = row_by_symbol.get(sym)
|
||
if idx is None or pos["entry_price"] <= 0:
|
||
continue
|
||
open_price = float(open_prices[idx])
|
||
low_price = float(low_prices[idx])
|
||
high_price = float(high_prices[idx])
|
||
entry_price = float(pos["entry_price"])
|
||
peak_price = float(pos.get("max_high", entry_price))
|
||
risk_lines: list[tuple[float, str]] = []
|
||
|
||
if config.stop_loss_pct is not None:
|
||
risk_lines.append((entry_price * (1 - abs(config.stop_loss_pct)), "stop_loss"))
|
||
|
||
if config.trailing_stop_pct is not None and peak_price > 0:
|
||
risk_lines.append((peak_price * (1 - abs(config.trailing_stop_pct)), "trailing_stop"))
|
||
|
||
activate_pct = getattr(config, "trailing_take_profit_activate_pct", None)
|
||
drawdown_pct = getattr(config, "trailing_take_profit_drawdown_pct", None)
|
||
if activate_pct is not None and drawdown_pct is not None and peak_price > entry_price:
|
||
peak_profit = peak_price / entry_price - 1
|
||
if peak_profit >= abs(float(activate_pct)):
|
||
# 回撤止盈触发线: 相对峰值价回撤 drawdown 个点 (纯峰值口径)
|
||
# 启动门槛用成本基准的浮盈率, 触发线用峰值基准, 与 trailing_stop 同口径
|
||
take_profit_line = peak_price * (1 - abs(float(drawdown_pct)))
|
||
risk_lines.append((take_profit_line, "trailing_take_profit"))
|
||
|
||
# 止损/移损/回撤止盈: 价格跌破风控线触发
|
||
risk_lines = [(line, reason) for line, reason in risk_lines if _valid_price(line)]
|
||
if risk_lines:
|
||
stop_price, reason = max(risk_lines, key=lambda item: item[0])
|
||
exit_price_override = None
|
||
if _valid_price(open_price) and open_price <= stop_price:
|
||
exit_price_override = open_price
|
||
elif _valid_price(low_price) and low_price <= stop_price:
|
||
exit_price_override = stop_price
|
||
if exit_price_override is not None:
|
||
_try_sell(sym, idx, reason, d_str, sold_today, exit_price_override)
|
||
continue
|
||
|
||
# 固定止盈: 价格涨破止盈线触发
|
||
tp_pct = getattr(config, "take_profit_pct", None)
|
||
if tp_pct is not None:
|
||
tp_line = entry_price * (1 + abs(float(tp_pct)))
|
||
if _valid_price(tp_line):
|
||
if _valid_price(open_price) and open_price >= tp_line:
|
||
_try_sell(sym, idx, "take_profit", d_str, sold_today, open_price)
|
||
elif _valid_price(high_price) and high_price >= tp_line:
|
||
_try_sell(sym, idx, "take_profit", d_str, sold_today, tp_line)
|
||
|
||
def _process_entries(
|
||
d_str: str,
|
||
idxs: list[int],
|
||
sold_today: set[str],
|
||
) -> None:
|
||
nonlocal cash
|
||
if max_positions <= 0:
|
||
return
|
||
candidates: list[tuple[int, str, float]] = []
|
||
for idx in idxs:
|
||
if not ent[idx]:
|
||
continue
|
||
sym = str(panel_symbols[idx])
|
||
if sym in positions:
|
||
continue
|
||
if sym in sold_today:
|
||
_count("buy_same_day_reentry")
|
||
continue
|
||
ok, block_reason = _can_buy(idx)
|
||
if not ok:
|
||
_count(block_reason)
|
||
continue
|
||
score = float(trade_scores[idx] or 0.0)
|
||
if score_min is not None and score < score_min:
|
||
_count("buy_score_filter")
|
||
continue
|
||
if score_max is not None and score > score_max:
|
||
_count("buy_score_filter")
|
||
continue
|
||
candidates.append((idx, sym, score))
|
||
if not candidates:
|
||
return
|
||
candidates.sort(key=lambda x: x[2], reverse=True)
|
||
|
||
slots = max_positions - len(positions)
|
||
if slots <= 0:
|
||
execution_stats["buy_no_slot"] += len(candidates)
|
||
return
|
||
|
||
selected = candidates[:slots]
|
||
market_value_before = _market_value()
|
||
account_equity_before_buy = cash + market_value_before
|
||
if account_equity_before_buy <= 0 or max_exposure_pct <= 0:
|
||
execution_stats["buy_exposure"] += len(selected)
|
||
return
|
||
target_position_value = account_equity_before_buy * max_exposure_pct / max_positions
|
||
max_exposure_value = account_equity_before_buy * max_exposure_pct
|
||
exposure_capacity = max_exposure_value - market_value_before
|
||
if exposure_capacity <= 0:
|
||
execution_stats["buy_exposure"] += len(selected)
|
||
return
|
||
|
||
weights = np.repeat(1 / len(selected), len(selected))
|
||
if config.position_sizing == "score_weight":
|
||
raw = np.array([max(x[2], 0.0) for x in selected], dtype=float)
|
||
if raw.sum() > 0:
|
||
weights = raw / raw.sum()
|
||
total_budget = min(cash, exposure_capacity, target_position_value * len(selected))
|
||
|
||
for (idx, sym, _score), weight in zip(selected, weights):
|
||
if len(positions) >= max_positions:
|
||
_count("buy_no_slot")
|
||
break
|
||
current_market_value = _market_value()
|
||
current_equity = cash + current_market_value
|
||
current_exposure_capacity = current_equity * max_exposure_pct - current_market_value
|
||
allocation = min(total_budget * float(weight), target_position_value, cash, current_exposure_capacity)
|
||
if allocation <= 0:
|
||
_count("buy_exposure")
|
||
continue
|
||
entry_price = _refill_price(idx, "buy", float(entry_prices[idx]))
|
||
shares = np.floor(allocation / (entry_price * (1 + buy_cost_pct)) / 100) * 100
|
||
entry_value = shares * entry_price * (1 + buy_cost_pct)
|
||
if shares <= 0:
|
||
_count("buy_lot_size")
|
||
continue
|
||
if entry_value > cash + 1e-6:
|
||
_count("buy_cash")
|
||
continue
|
||
if entry_value > current_exposure_capacity + 1e-6:
|
||
_count("buy_exposure")
|
||
continue
|
||
cash -= entry_value
|
||
positions[sym] = {
|
||
"symbol": sym,
|
||
"name": str(names[idx] or ""),
|
||
"entry_date": self._date_str(panel_dates[idx]),
|
||
"entry_signal_date": entry_signal_dates[idx] or self._date_str(panel_dates[idx]),
|
||
"entry_signal_id": _resolve_signal_id(panel, idx, entry_signal_ids),
|
||
"entry_price": entry_price,
|
||
"entry_value": entry_value,
|
||
"shares": shares,
|
||
"lots": shares / 100,
|
||
"position_pct": entry_value / account_equity_before_buy if account_equity_before_buy > 0 else 0.0,
|
||
"entry_score": _score,
|
||
"max_high": entry_price,
|
||
"hold_days": 0,
|
||
"pending_exit_reason": None,
|
||
"pending_exit_signal_date": None,
|
||
"blocked_exit_days": 0,
|
||
}
|
||
|
||
for d_idx, d_str in enumerate(all_dates):
|
||
if d_idx % 20 == 0:
|
||
if cancel_event is not None and cancel_event.is_set():
|
||
logger.info("回测被用户取消 (第 %d/%d 天)", d_idx, len(all_dates))
|
||
break
|
||
if progress_cb is not None:
|
||
try:
|
||
progress_cb({
|
||
"day": d_idx + 1,
|
||
"total": len(all_dates),
|
||
"date": str(d_str)[:10],
|
||
"equity": round(cash + _market_value(), 2),
|
||
})
|
||
except Exception:
|
||
pass
|
||
|
||
idxs = date_to_indices[d_str]
|
||
row_by_symbol = {str(panel_symbols[i]): i for i in idxs}
|
||
sold_today: set[str] = set()
|
||
|
||
for pos in positions.values():
|
||
pos["hold_days"] += 1
|
||
|
||
# 统一执行顺序 (不分口径): 风控(止损/移动止损/止盈) → 计划出场(signal/max_hold/end) → 建仓。
|
||
# 风控是保护性离场, 必须最先; 计划出场次之; 建仓最后 (卖出释放的现金/仓位先用于满足新买)。
|
||
# 当天新建仓不会被风控误杀 (_process_risk_exits 跳过 entry_date == d_str 的仓位)。
|
||
_process_risk_exits(d_str, row_by_symbol, sold_today)
|
||
_process_scheduled_exits(d_idx, d_str, row_by_symbol, sold_today)
|
||
if d_idx < len(all_dates) - 1:
|
||
_process_entries(d_str, idxs, sold_today)
|
||
|
||
for sym, pos in positions.items():
|
||
idx = row_by_symbol.get(sym)
|
||
if idx is not None:
|
||
hi = float(high_prices[idx])
|
||
if _valid_price(hi):
|
||
pos["max_high"] = max(float(pos.get("max_high", pos["entry_price"])), hi)
|
||
|
||
for i in idxs:
|
||
c = float(close_prices[i])
|
||
if c > 0 and np.isfinite(c):
|
||
last_close[str(panel_symbols[i])] = c
|
||
|
||
market_value = _market_value()
|
||
equity = cash + market_value
|
||
peak = max(peak, equity)
|
||
dd = (equity - peak) / peak if peak > 0 else 0.0
|
||
exposure = market_value / equity if equity > 0 else 0.0
|
||
equity_curve.append({
|
||
"date": d_str[:10],
|
||
"value": round(float(equity), 2),
|
||
"cash": round(float(cash), 2),
|
||
"positions": len(positions),
|
||
"exposure": round(float(exposure), 4),
|
||
})
|
||
drawdown_curve.append({"date": d_str[:10], "value": round(float(dd), 4)})
|
||
|
||
stats = self._calc_portfolio_stats(equity_curve, trades, config.initial_capital)
|
||
stats["execution"] = execution_stats
|
||
stats["pending_exit_positions"] = sum(1 for p in positions.values() if p.get("pending_exit_reason"))
|
||
per_symbol = self._calc_per_symbol(trades)
|
||
return SimResult(
|
||
equity_curve=equity_curve,
|
||
drawdown_curve=drawdown_curve,
|
||
trades=trades,
|
||
per_symbol_stats=per_symbol,
|
||
stats=stats,
|
||
)
|
||
|
||
# ── 净值曲线 ──────────────────────────────────────
|
||
|
||
@staticmethod
|
||
def _build_curves(
|
||
trades: list[TradeRecord],
|
||
all_dates: np.ndarray,
|
||
initial_capital: float,
|
||
) -> tuple[list[dict], list[dict]]:
|
||
"""从交易记录构建日频净值曲线和回撤曲线。
|
||
|
||
资金模型: 每笔交易等权分配 (1/N_capital),N_capital = 同时持仓数上限。
|
||
简化版: 按出场日归集所有已平仓交易的平均收益作为当日组合收益。
|
||
"""
|
||
if not trades or len(all_dates) == 0:
|
||
return [], []
|
||
|
||
# 按出场日归集 pnl
|
||
exit_pnl: dict[str, list[float]] = {}
|
||
for t in trades:
|
||
d_str = str(t.exit_date)
|
||
exit_pnl.setdefault(d_str, []).append(t.pnl_pct)
|
||
|
||
equity = initial_capital
|
||
peak = initial_capital
|
||
curve: list[dict] = []
|
||
dd_curve: list[dict] = []
|
||
|
||
for d in all_dates:
|
||
d_str = str(d.item() if hasattr(d, "item") else d)
|
||
pnls = exit_pnl.get(d_str, [])
|
||
# 当日组合收益 = 该日所有出场交易的平均收益
|
||
daily_ret = float(np.mean(pnls)) if pnls else 0.0
|
||
equity *= (1 + daily_ret)
|
||
peak = max(peak, equity)
|
||
dd = (equity - peak) / peak if peak > 0 else 0.0
|
||
curve.append({"date": d_str[:10], "value": round(equity, 2)})
|
||
dd_curve.append({"date": d_str[:10], "value": round(dd, 4)})
|
||
|
||
return curve, dd_curve
|
||
|
||
# ── 统计计算 ──────────────────────────────────────
|
||
|
||
@staticmethod
|
||
def _sortino_ratio(returns: np.ndarray, periods_per_year: int = 252) -> float | None:
|
||
"""Sortino 比率: 用下行偏差 (仅惩罚负收益) 替代总标准差, 年化。
|
||
|
||
下行偏差 = sqrt(mean(min(r, 0)^2)), MAR=0 的目标半方差 (对全部样本求均, 非仅负样本)。
|
||
无下行波动 (无亏损) 时 Sortino 未定义, 返回 None (与 profit_factor 的 None 约定一致,
|
||
不虚报 0 或 inf)。样本不足 (<2) 返回 0.0 (与 sharpe 的退化约定一致)。
|
||
"""
|
||
returns = returns[np.isfinite(returns)] # 剔除 inf/nan, 防止污染均值/序列化出非法 JSON
|
||
if len(returns) < 2:
|
||
return 0.0
|
||
mean = float(np.mean(returns))
|
||
downside = np.minimum(returns, 0.0)
|
||
downside_dev = float(np.sqrt(np.mean(downside ** 2)))
|
||
if downside_dev <= 0:
|
||
return None
|
||
return mean / downside_dev * float(np.sqrt(periods_per_year))
|
||
|
||
@staticmethod
|
||
def _mc_drawdown_percentiles(pnls: np.ndarray, n_sims: int = 1000) -> dict:
|
||
"""自助重抽样交易序列, 估计最大回撤的分布 — 回答"仅因成交顺序运气, 回撤能有多坏"。
|
||
|
||
对每笔收益有放回重抽样 n_sims 次, 各自算最大回撤, 取分位:
|
||
- mc_maxdd_p50: 中位场景最大回撤
|
||
- mc_maxdd_p95: 95% 置信最坏场景 (= 分布 5 分位, 更负)
|
||
|
||
固定种子保证可复现/可测。样本 <3 无统计意义, 返回 None。
|
||
大样本 (如 full 模式数千笔) 时按 2M 单元上限压降模拟次数, 防止瞬时数组 OOM。
|
||
"""
|
||
pnls = pnls[np.isfinite(pnls)] # 剔除 inf/nan, 否则 cumprod 传播 nan 导致分位为 nan
|
||
# 防御: 单笔 pnl <= -100% 时 (1+pnl) <= 0 会让 cumprod 符号翻转/得非正净值, 回撤失真。
|
||
# 回测有止损, 实际不会发生; 兜底 clip 到 -99.99% 保证 (1+pnl) 恒正。
|
||
pnls = np.clip(pnls, -0.9999, None)
|
||
n = len(pnls)
|
||
if n < 3:
|
||
return {"mc_maxdd_p50": None, "mc_maxdd_p95": None}
|
||
# 内存护栏: samples/equity/peak/dd 各占 eff_sims*n*8B, 控总单元 <= 2M (~64MB 峰值)
|
||
eff_sims = min(n_sims, max(200, 2_000_000 // n))
|
||
rng = np.random.default_rng(42)
|
||
samples = rng.choice(pnls, size=(eff_sims, n), replace=True)
|
||
equity = np.cumprod(1.0 + samples, axis=1)
|
||
peak = np.maximum.accumulate(equity, axis=1)
|
||
dd = (equity - peak) / peak
|
||
maxdds = dd.min(axis=1)
|
||
return {
|
||
"mc_maxdd_p50": round(float(np.percentile(maxdds, 50)), 4),
|
||
"mc_maxdd_p95": round(float(np.percentile(maxdds, 5)), 4),
|
||
}
|
||
|
||
@staticmethod
|
||
def _per_trade_block(pnls: np.ndarray, durations: np.ndarray) -> dict:
|
||
"""per-trade 明细字段: best/worst/median_pnl/avg_holding_days。"""
|
||
pnls = pnls[np.isfinite(pnls)] # 剔除 inf/nan, 防 best/worst 出非法值
|
||
durations = durations[np.isfinite(durations)] if len(durations) else durations
|
||
if not len(pnls):
|
||
return {"best": 0.0, "worst": 0.0, "median_pnl": 0.0, "avg_holding_days": 0.0}
|
||
return {
|
||
"best": round(float(np.max(pnls)), 4),
|
||
"worst": round(float(np.min(pnls)), 4),
|
||
"median_pnl": round(float(np.median(pnls)), 4),
|
||
"avg_holding_days": round(float(np.mean(durations)), 1) if len(durations) else 0.0,
|
||
}
|
||
|
||
@staticmethod
|
||
def _calc_stats(
|
||
trades: list[TradeRecord],
|
||
initial_capital: float,
|
||
start: date,
|
||
end: date,
|
||
) -> dict:
|
||
if not trades:
|
||
return {"total_return": 0, "n_trades": 0}
|
||
|
||
pnls = np.array([t.pnl_pct for t in trades])
|
||
n_trades = len(trades)
|
||
|
||
# 从净值曲线推算总收益 (等权组合)
|
||
cumulative = 1.0
|
||
for p in pnls:
|
||
cumulative *= (1 + p)
|
||
# 修正: 等权组合的总收益不等于各笔复乘,用曲线终点更准
|
||
# 但这里作为简化,用各笔复乘作为近似
|
||
total_return = cumulative - 1.0
|
||
|
||
# 年化
|
||
n_days = max((end - start).days, 1)
|
||
years = n_days / 365.25
|
||
if total_return > -1.0 and years > 0:
|
||
annual_return = (1 + total_return) ** (1 / years) - 1
|
||
else:
|
||
annual_return = total_return
|
||
|
||
# 胜率
|
||
wins = pnls[pnls > 0]
|
||
losses = pnls[pnls <= 0]
|
||
win_rate = len(wins) / n_trades
|
||
|
||
# 盈亏比
|
||
avg_win = float(np.mean(wins)) if len(wins) > 0 else 0.0
|
||
avg_loss = abs(float(np.mean(losses))) if len(losses) > 0 else 0.0
|
||
profit_factor = avg_win / avg_loss if avg_loss > 0 else (float("inf") if avg_win > 0 else 0.0)
|
||
|
||
# 最大回撤 — 用交易序列近似
|
||
equity = initial_capital
|
||
peak = initial_capital
|
||
max_dd = 0.0
|
||
for p in pnls:
|
||
equity *= (1 + p)
|
||
peak = max(peak, equity)
|
||
dd = (equity - peak) / peak
|
||
max_dd = min(max_dd, dd)
|
||
|
||
# 夏普 — 用交易收益标准差近似
|
||
sharpe = float(np.mean(pnls) / np.std(pnls)) * np.sqrt(252) if np.std(pnls) > 0 else 0.0
|
||
|
||
# Sortino: 刻意沿用本函数 sharpe 的逐笔收益 x sqrt(252) 基准。逐笔年化非严格正确,
|
||
# 但保证同一函数内 sharpe/sortino 口径一致可比 (内部一致 > 局部绝对)。仅惩罚下行波动。
|
||
sortino = BacktestEngine._sortino_ratio(pnls)
|
||
|
||
# Calmar
|
||
calmar = annual_return / abs(max_dd) if abs(max_dd) > 0.001 else 0.0
|
||
|
||
durations = np.array([t.duration for t in trades], dtype=float)
|
||
return {
|
||
"total_return": round(float(total_return), 4),
|
||
"annual_return": round(float(annual_return), 4),
|
||
"max_drawdown": round(float(max_dd), 4),
|
||
"sharpe": round(float(sharpe), 2),
|
||
"sortino": round(float(sortino), 2) if sortino is not None else None,
|
||
"calmar": round(float(calmar), 2),
|
||
"win_rate": round(float(win_rate), 4),
|
||
"profit_factor": round(float(profit_factor), 2) if np.isfinite(profit_factor) else None,
|
||
"n_trades": n_trades,
|
||
"avg_pnl": round(float(np.mean(pnls)), 4),
|
||
"avg_win": round(avg_win, 4),
|
||
"avg_loss": round(avg_loss, 4),
|
||
**BacktestEngine._per_trade_block(pnls, durations),
|
||
**BacktestEngine._mc_drawdown_percentiles(pnls),
|
||
}
|
||
|
||
@staticmethod
|
||
def _calc_per_symbol(trades: list[TradeRecord]) -> list[dict]:
|
||
if not trades:
|
||
return []
|
||
by_sym: dict[str, dict] = {}
|
||
for t in trades:
|
||
s = t.symbol
|
||
d = by_sym.setdefault(s, {
|
||
"symbol": s, "n_trades": 0, "total_return": 1.0,
|
||
"best": -999.0, "worst": 999.0, "wins": 0, "pnls": [],
|
||
})
|
||
d["n_trades"] += 1
|
||
d["pnls"].append(t.pnl_pct)
|
||
d["total_return"] *= (1 + t.pnl_pct)
|
||
d["best"] = max(d["best"], t.pnl_pct)
|
||
d["worst"] = min(d["worst"], t.pnl_pct)
|
||
if t.pnl_pct > 0:
|
||
d["wins"] += 1
|
||
|
||
result = []
|
||
for d in by_sym.values():
|
||
result.append({
|
||
"symbol": d["symbol"],
|
||
"n_trades": d["n_trades"],
|
||
"total_return": round(d["total_return"] - 1.0, 4),
|
||
"win_rate": round(d["wins"] / d["n_trades"], 4) if d["n_trades"] > 0 else 0.0,
|
||
"best": round(d["best"], 4),
|
||
"worst": round(d["worst"], 4),
|
||
})
|
||
return sorted(result, key=lambda x: x["total_return"], reverse=True)
|
||
|
||
@staticmethod
|
||
def _calc_independent_candidate_result(
|
||
trades: list[TradeRecord],
|
||
n_candidates: int,
|
||
execution_stats: dict[str, int],
|
||
) -> SimResult:
|
||
"""全量独立候选统计:按每个候选样本的实际执行收益聚合。"""
|
||
if not trades:
|
||
return SimResult(
|
||
equity_curve=[],
|
||
drawdown_curve=[],
|
||
trades=[],
|
||
per_symbol_stats=[],
|
||
stats={
|
||
"mode": "full",
|
||
"full_kind": "candidate_execution",
|
||
"error": "no executable trades",
|
||
"n_candidates": int(n_candidates),
|
||
"n_trades": 0,
|
||
"execution": execution_stats,
|
||
},
|
||
)
|
||
|
||
pnls = np.array([t.pnl_pct for t in trades], dtype=float)
|
||
durations = np.array([t.duration for t in trades], dtype=float)
|
||
wins = pnls[pnls > 0]
|
||
losses = pnls[pnls <= 0]
|
||
avg_win = float(np.mean(wins)) if len(wins) else 0.0
|
||
avg_loss = abs(float(np.mean(losses))) if len(losses) else 0.0
|
||
|
||
# 按退出日聚合已实现样本收益, 构造“样本收益曲线”。它不是账户净值。
|
||
daily_returns: dict[str, list[float]] = {}
|
||
for t in trades:
|
||
daily_returns.setdefault(str(t.exit_date)[:10], []).append(float(t.pnl_pct))
|
||
|
||
equity_curve: list[dict] = []
|
||
drawdown_curve: list[dict] = []
|
||
equity = 1.0
|
||
peak = 1.0
|
||
daily_avg: list[float] = []
|
||
for d_str in sorted(daily_returns.keys()):
|
||
values = daily_returns[d_str]
|
||
avg_ret = float(np.mean(values)) if values else 0.0
|
||
daily_avg.append(avg_ret)
|
||
equity *= (1 + avg_ret)
|
||
peak = max(peak, equity)
|
||
dd = (equity - peak) / peak if peak > 0 else 0.0
|
||
equity_curve.append({
|
||
"date": d_str,
|
||
"value": round(float(equity), 4),
|
||
"positions": len(values),
|
||
})
|
||
drawdown_curve.append({"date": d_str, "value": round(float(dd), 4)})
|
||
|
||
values = np.array([r["value"] for r in equity_curve], dtype=float)
|
||
total_return = float(values[-1] - 1.0) if len(values) else 0.0
|
||
peaks = np.maximum.accumulate(values) if len(values) else np.array([])
|
||
drawdowns = values / peaks - 1 if len(values) else np.array([])
|
||
max_drawdown = float(drawdowns.min()) if len(drawdowns) else 0.0
|
||
daily = np.array(daily_avg, dtype=float)
|
||
sharpe = float(np.mean(daily) / np.std(daily) * np.sqrt(252)) if len(daily) > 1 and np.std(daily) > 0 else 0.0
|
||
sortino = BacktestEngine._sortino_ratio(daily)
|
||
|
||
lo, hi, nbins = -0.20, 0.20, 20
|
||
clipped = np.clip(pnls, lo, hi)
|
||
counts, edges = np.histogram(clipped, bins=nbins, range=(lo, hi))
|
||
dist = [
|
||
{
|
||
"range": f"{(edges[i]*100):+.0f}~{(edges[i+1]*100):+.0f}%",
|
||
"count": int(counts[i]),
|
||
"ratio": round(float(counts[i] / pnls.size), 4) if pnls.size else 0.0,
|
||
}
|
||
for i in range(nbins)
|
||
]
|
||
|
||
stats = {
|
||
"mode": "full",
|
||
"full_kind": "candidate_execution",
|
||
"n_candidates": int(n_candidates),
|
||
"n_trades": int(len(trades)),
|
||
"n_days": int(len(daily_returns)),
|
||
"avg_daily_candidates": round(float(len(trades) / max(len(daily_returns), 1)), 1),
|
||
"avg_return": round(float(np.mean(pnls)), 4),
|
||
"median_return": round(float(np.median(pnls)), 4),
|
||
"win_rate": round(float(len(wins) / len(pnls)), 4) if len(pnls) else 0.0,
|
||
"profit_factor": round(float(avg_win / avg_loss), 2) if avg_loss > 0 else None,
|
||
"best": round(float(np.max(pnls)), 4),
|
||
"worst": round(float(np.min(pnls)), 4),
|
||
"avg_duration": round(float(np.mean(durations)), 1) if len(durations) else 0.0,
|
||
"total_return": round(float(total_return), 4),
|
||
"max_drawdown": round(float(max_drawdown), 4),
|
||
"sharpe": round(float(sharpe), 2),
|
||
"sortino": round(float(sortino), 2) if sortino is not None else None,
|
||
"return_distribution": dist,
|
||
"execution": execution_stats,
|
||
**BacktestEngine._mc_drawdown_percentiles(pnls),
|
||
}
|
||
|
||
return SimResult(
|
||
equity_curve=equity_curve,
|
||
drawdown_curve=drawdown_curve,
|
||
trades=trades,
|
||
per_symbol_stats=BacktestEngine._calc_per_symbol(trades),
|
||
stats=stats,
|
||
)
|
||
|
||
@staticmethod
|
||
def _calc_portfolio_stats(
|
||
equity_curve: list[dict],
|
||
trades: list[TradeRecord],
|
||
initial_capital: float,
|
||
) -> dict:
|
||
if not equity_curve:
|
||
return {"total_return": 0, "n_trades": 0}
|
||
final_equity = float(equity_curve[-1]["value"])
|
||
total_return = final_equity / initial_capital - 1 if initial_capital > 0 else 0.0
|
||
values = np.array([float(r["value"]) for r in equity_curve], dtype=float)
|
||
daily = values[1:] / values[:-1] - 1 if len(values) > 1 else np.array([])
|
||
annual_return = (1 + total_return) ** (252 / max(len(equity_curve), 1)) - 1 if total_return > -1 else total_return
|
||
peaks = np.maximum.accumulate(values)
|
||
drawdowns = values / peaks - 1
|
||
max_drawdown = float(drawdowns.min()) if len(drawdowns) else 0.0
|
||
sharpe = float(np.mean(daily) / np.std(daily) * np.sqrt(252)) if len(daily) and np.std(daily) > 0 else 0.0
|
||
sortino = BacktestEngine._sortino_ratio(daily)
|
||
pnls = np.array([t.pnl_pct for t in trades], dtype=float) if trades else np.array([])
|
||
durations = np.array([t.duration for t in trades], dtype=float) if trades else np.array([])
|
||
exposures = np.array([float(r.get("exposure", 0.0)) for r in equity_curve], dtype=float)
|
||
wins = pnls[pnls > 0]
|
||
losses = pnls[pnls <= 0]
|
||
avg_win = float(np.mean(wins)) if len(wins) else 0.0
|
||
avg_loss = abs(float(np.mean(losses))) if len(losses) else 0.0
|
||
return {
|
||
"total_return": round(float(total_return), 4),
|
||
"annual_return": round(float(annual_return), 4),
|
||
"max_drawdown": round(float(max_drawdown), 4),
|
||
"sharpe": round(float(sharpe), 2),
|
||
"sortino": round(float(sortino), 2) if sortino is not None else None,
|
||
"calmar": round(float(annual_return / abs(max_drawdown)), 2) if abs(max_drawdown) > 0.001 else 0.0,
|
||
"win_rate": round(float(len(wins) / len(pnls)), 4) if len(pnls) else 0.0,
|
||
"profit_factor": round(float(avg_win / avg_loss), 2) if avg_loss > 0 else None,
|
||
"n_trades": len(trades),
|
||
"avg_pnl": round(float(np.mean(pnls)), 4) if len(pnls) else 0.0,
|
||
"avg_win": round(avg_win, 4),
|
||
"avg_loss": round(avg_loss, 4),
|
||
**BacktestEngine._per_trade_block(pnls, durations),
|
||
**BacktestEngine._mc_drawdown_percentiles(pnls),
|
||
"final_equity": round(final_equity, 2),
|
||
"initial_capital": round(float(initial_capital), 2),
|
||
"avg_exposure": round(float(np.mean(exposures)), 4) if len(exposures) else 0.0,
|
||
"max_exposure": round(float(np.max(exposures)), 4) if len(exposures) else 0.0,
|
||
}
|
||
|
||
@staticmethod
|
||
def _date_str(value) -> str:
|
||
value = value.item() if hasattr(value, "item") else value
|
||
return str(value)[:10]
|
||
|
||
@staticmethod
|
||
def _empty_result() -> SimResult:
|
||
return SimResult(
|
||
equity_curve=[], drawdown_curve=[], trades=[],
|
||
per_symbol_stats=[], stats={"error": "no data or no signals"},
|
||
)
|
||
|
||
# ── 截面工具 (因子回测用) ─────────────────────────
|
||
|
||
@staticmethod
|
||
def cross_section_rank(panel: pl.DataFrame, col: str) -> pl.DataFrame:
|
||
return panel.with_columns(
|
||
pl.col(col).rank(method="random").over("date").alias(f"{col}_rank")
|
||
)
|
||
|
||
@staticmethod
|
||
def cross_section_qcut(panel: pl.DataFrame, col: str, n_groups: int) -> pl.DataFrame:
|
||
return panel.with_columns(
|
||
pl.col(col).qcut(n_groups, labels=[f"Q{i+1}" for i in range(n_groups)])
|
||
.over("date").alias("_group")
|
||
)
|