1533 lines
65 KiB
Python
1533 lines
65 KiB
Python
"""回测引擎 — 共享数据加载 + 撮合 + 统计计算。
|
||
|
||
纯 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")
|
||
)
|