"""回测引擎 — 共享数据加载 + 撮合 + 统计计算。 纯 Polars/NumPy 实现,不依赖 pandas/vectorbt。 """ from __future__ import annotations import hashlib import logging 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.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 fees_pct: float = 0.0002 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" 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 @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 @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 # ================================================================ # 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 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 def get_or_compute( self, symbols: list[str] | None, start: date, end: date, columns: list[str] | None, compute_fn, ) -> pl.DataFrame: key = self._make_key(symbols, start, end, columns) now = time.monotonic() if key in self._cache: entry = self._cache[key] if now - entry.ts < self._ttl: self._cache.move_to_end(key) return entry.df del self._cache[key] df = compute_fn(symbols, start, end, columns) self._cache[key] = _CacheEntry(df=df, ts=now) if len(self._cache) > self._max_size: self._cache.popitem(last=False) return df def invalidate(self) -> None: self._cache.clear() @staticmethod def _make_key(symbols: list[str] | None, start: date, end: date, columns: list[str] | None) -> 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"{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, ) -> pl.DataFrame: """加载 enriched 数据面板,带缓存。""" return self._cache.get_or_compute(symbols, start, end, columns, self._load_panel_inner) def _load_panel_inner( self, symbols: list[str] | None, start: date, end: date, columns: list[str] | None = None, ) -> pl.DataFrame: t0 = time.perf_counter() # 近期区间优先复用 repository 的预计算 enriched 历史缓存,避免重复 scan_parquet + compute_all。 try: if 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) enriched_glob = str(self.repo.store.data_dir / "kline_daily_enriched" / "**" / "*.parquet") try: lf = pl.scan_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 instruments = self.repo.get_instruments() 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, ) -> 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.fees_pct * 2 + config.slippage_bps / 10000.0 * 2 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, ) -> 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 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.fees_pct + config.slippage_bps / 10000.0 sell_cost_pct = config.fees_pct + config.slippage_bps / 10000.0 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)): risk_lines.append((entry_price * (1 + peak_profit - 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 exit_price = float(exit_price_override) if exit_price_override is not None else 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)), )) 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 = 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_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) 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, ) -> 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.fees_pct + config.slippage_bps / 10000.0 sell_cost_pct = config.fees_pct + config.slippage_bps / 10000.0 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] = [] 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) exit_price = float(exit_price_override) if exit_price_override is not None else 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)), )) 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)): take_profit_line = entry_price * (1 + peak_profit - 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 = 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_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 _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 # Calmar calmar = annual_return / abs(max_dd) if abs(max_dd) > 0.001 else 0.0 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), "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), } @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 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), "return_distribution": dist, "execution": execution_stats, } 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 pnls = np.array([t.pnl_pct 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), "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), "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") )