Files
easy-tdx/src/easy_tdx/backtest/engine.py
T
GitHub 00b8c6374a fix(backtest): v1.20.1 修复回测引擎 3 个 bug(issues #22 #23 #25)
排查发现用户反馈的"回测统计数据缺失/异常"并非服务器连接问题,
而是回测引擎与组合优化器自身的代码缺陷:

#23: 首根 bar 访问 close[-1] 崩溃
  _SeriesAccessor 负向越界改返回 NaN(不抛 IndexError);
  BacktestEngine 新增 warmup_bars 参数跳过指标预热期。

#25-A: FactorWeightedOptimizer 权重坍缩
  n_stocks=2 且得分接近时,减最小值把低分标的权重压到 ~6e-8,
  等于单股满仓、n_stocks 被无视,进而出现持仓1只/-99.98%回撤。
  新增 _apply_weight_floor 权重下限保证入选标的都有实质权重。

#25-B: RebalanceEngine total_trades 统计错误
  total_trades = len(equity_curve)(天数)改为 len(trades_df)(真实笔数)。

#22: 绩效别名键 + 数据异常诊断
  performance dict 新增 sharpe_ratio/start_cash/end_value 别名键;
  资金曲线异常时返回 diagnostic_warning 而非静默全 0,CLI 显示提示。

文档:README 加入回测手册导航;backtest_usage.md 补充 warmup 说明。
测试:新增 6 个回归测试,更新 3 个;932 passed。
2026-07-09 20:16:12 +08:00

533 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""BacktestEngine — orchestrate vectorized execution pipeline.
Coordinates Strategy → OrderSimulator → PortfolioTracker → PerformanceAnalyzer.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
import pandas as pd
from easy_tdx.backtest.orders import OrderSimulator
from easy_tdx.backtest.performance import PerformanceAnalyzer
from easy_tdx.backtest.portfolio import PortfolioTracker
from easy_tdx.backtest.strategy import Strategy
from easy_tdx.backtest.types import BacktestResult, Signal, Trade
if TYPE_CHECKING:
from easy_tdx.backtest.execution import ExecutionModel
from easy_tdx.backtest.slippage import SlippageModel
@dataclass
class _StopCondition:
"""Active stop-loss / take-profit condition tied to an open position.
Attributes:
stop_loss: Price below which a SELL is triggered (None = disabled)
take_profit: Price above which a SELL is triggered (None = disabled)
"""
stop_loss: float | None
take_profit: float | None
class BacktestEngine:
"""Orchestrate backtest execution pipeline.
Pipeline:
1. Signal generation (Strategy)
2. Order simulation (OrderSimulator)
3. Portfolio tracking (PortfolioTracker)
4. Performance analysis (PerformanceAnalyzer)
Example:
>>> engine = BacktestEngine(MyStrategy, cash=100000)
>>> result = engine.run(df)
"""
def __init__(
self,
strategy: type[Strategy] | Strategy,
cash: float = 100000.0,
commission: float = 0.0003,
min_commission: float = 5.0,
stamp_tax: float = 0.001,
slippage: float = 0.0,
execution: str = "next_open",
position_mode: str = "full",
reject_policy: str = "reduce",
benchmark: pd.DataFrame | None = None,
chanlun_level: str | None = None,
slippage_model: SlippageModel | None = None,
execution_model: ExecutionModel | None = None,
warmup_bars: int = 0,
):
"""Initialize engine.
Args:
strategy: Strategy class or instance
cash: Initial cash
commission: Commission rate (e.g., 0.0003 = 0.03%)
min_commission: Minimum commission per trade
stamp_tax: Stamp tax rate (for sells)
slippage: Slippage rate
execution: Execution mode ('next_open', 'next_close')
position_mode: Position mode ('full', 'long_only', 'short_only')
reject_policy: Reject policy ('reduce', 'reject')
benchmark: Benchmark data for performance comparison
chanlun_level: Auto-compute chanlun analysis at this level
(e.g. 'DAILY', '30MIN'). Strategy accesses via self.chanlun.
slippage_model: Pluggable slippage model (overrides flat slippage
when provided).
execution_model: Pluggable execution model (bypasses OrderSimulator
when provided).
warmup_bars: 指标预热 bar 数。前 ``warmup_bars`` 根不调用
``next()``、不产生信号(指标 NaN 期过滤),避免早期数据不足
导致的越界或误信号。默认 0(向后兼容)。
"""
self._strategy_cls = strategy if isinstance(strategy, type) else type(strategy)
self._strategy_instance = strategy if isinstance(strategy, Strategy) else None
self._cash = cash
self._commission = commission
self._min_commission = min_commission
self._stamp_tax = stamp_tax
self._slippage = slippage
self._execution = execution
self._position_mode = position_mode
self._reject_policy = reject_policy
self._benchmark = benchmark
self._chanlun_level = chanlun_level
self._slippage_model = slippage_model
self._execution_model = execution_model
self._warmup_bars = max(int(warmup_bars), 0)
def run(self, df: pd.DataFrame, chanlun_result: Any | None = None) -> BacktestResult:
"""Run backtest.
Args:
df: Price data with OHLCV columns
chanlun_result: Optional chanlun analysis result for strategy.
When provided, takes priority over auto-computed result.
Returns:
BacktestResult with performance, equity_curve, trades, positions, config
"""
if len(df) == 0:
return self._empty_result()
# 兼容真实行情日线数据:get_security_bars 日线返回 date 列,引擎内部
# StrategyDataProxy / PortfolioTracker / _find_bar_index)统一使用
# datetime 列。缺则由 date 派生,避免上层手动重命名。
if "datetime" not in df.columns and "date" in df.columns:
df = df.copy()
df["datetime"] = df["date"]
# Auto-compute chanlun if chanlun_level is set and no manual result
if chanlun_result is None and self._chanlun_level is not None:
from easy_tdx.chanlun.analyser import ChanlunAnalyser
analyser = ChanlunAnalyser(frequency=self._chanlun_level)
chanlun_result = analyser.process_klines(df)
# Step 1: Signal generation
signals = self._generate_signals(df, chanlun_result)
# Step 2: Order simulation
if self._execution_model is not None:
# ExecutionModel path
trades = self._execute_with_model(signals, df)
else:
# OrderSimulator path (default)
simulator = OrderSimulator(
df,
execution=self._execution,
position_mode=self._position_mode,
reject_policy=self._reject_policy,
commission=self._commission,
min_commission=self._min_commission,
stamp_tax=self._stamp_tax,
slippage=self._slippage,
slippage_model=self._slippage_model,
)
trades = simulator.simulate(
signals=signals,
cash=self._cash,
position=0.0,
)
# Step 3: Portfolio tracking
trades = self._compute_pnls(trades)
tracker = PortfolioTracker(df, initial_cash=self._cash)
tracker.apply_trades(trades)
# Step 4: Performance analysis
trades_df = self._trades_to_df(trades)
performance = PerformanceAnalyzer(
tracker.equity_curve,
trades_df,
risk_free_rate=0.03,
).compute()
# Config snapshot
config = {
"cash": self._cash,
"commission": self._commission,
"execution": self._execution,
"position_mode": self._position_mode,
"reject_policy": self._reject_policy,
}
return BacktestResult(
performance=performance,
equity_curve=tracker.equity_curve,
trades=trades_df,
positions=tracker.positions,
config=config,
)
def _execute_with_model(self, signals: list[Signal], df: pd.DataFrame) -> list[Trade]:
"""Use ExecutionModel to process signals."""
assert self._execution_model is not None
all_trades: list[Trade] = []
cash = self._cash
position = 0.0
for signal in signals:
bar_idx = self._find_bar_index(df, signal.datetime)
if bar_idx is None:
continue
sub_trades = self._execution_model.execute(
signal=signal,
df=df,
bar_idx=bar_idx,
cash=cash,
position=position,
position_mode=self._position_mode,
commission=self._commission,
min_commission=self._min_commission,
stamp_tax=self._stamp_tax,
slippage_model=self._slippage_model,
)
for t in sub_trades:
if not t.rejected:
if t.direction == "BUY":
cash -= t.size * t.price + t.commission + t.slippage
position += t.size
else:
cash += t.size * t.price - t.commission - t.slippage
position -= t.size
all_trades.extend(sub_trades)
return all_trades
@staticmethod
def _find_bar_index(df: pd.DataFrame, datetime_val: int) -> int | None:
"""Find bar index by datetime value."""
dt_col = df["datetime"]
try:
idx = (dt_col == datetime_val).idxmax() if (dt_col == datetime_val).any() else None
if idx is not None:
return int(idx)
except (TypeError, ValueError):
pass
if hasattr(dt_col, "dt"):
dt_ints = dt_col.dt.strftime("%Y%m%d").astype(int)
mask = dt_ints == datetime_val
if mask.any():
return int(mask.idxmax())
return None
def _generate_signals(self, df: pd.DataFrame, chanlun_result: Any | None) -> list[Signal]:
"""Generate signals from strategy.
After each bar's signals, update strategy's internal position state so
subsequent bars can make informed decisions (e.g., "don't buy if already
holding"). Uses close price as estimate; actual execution price is
determined later by OrderSimulator.
Args:
df: Price data
chanlun_result: Optional chanlun analysis result
Returns:
List of signals
"""
# Instantiate strategy if needed
strat = (
self._strategy_instance if self._strategy_instance is not None else self._strategy_cls()
)
# Bind data
strat._bind_data(df)
# Inject chanlun result if provided
if chanlun_result is not None:
strat._chanlun_result = chanlun_result
# Initialize position tracking
strat._cash = self._cash
strat._position_size = 0.0
close_arr = df["close"].to_numpy()
# Call init
strat._call_init()
# Generate signals bar by bar
all_signals: list[Signal] = []
active_stops: list[_StopCondition] = []
high_arr = df["high"].to_numpy()
low_arr = df["low"].to_numpy()
for i in range(len(df)):
strat._set_bar_index(i)
# warmup 期(指标 NaN 预热)不调用 next()、不产生信号,但仍推进
# bar_index 与后续 PortfolioTracker 的资金曲线对齐。
if i < self._warmup_bars:
continue
strat._call_next()
bar_signals = strat._clear_signals()
# Check existing SL/TP conditions against current bar's price range
sl_tp_signals = self._check_stop_conditions(
active_stops, high_arr[i], low_arr[i], close_arr[i], i, df
)
# Combine strategy signals with SL/TP signals
combined = bar_signals + sl_tp_signals
# Register new SL/TP from BUY signals (after checking, so they
# activate on the NEXT bar — consistent with next_open execution)
for sig in bar_signals:
if sig.direction == "BUY" and (
sig.stop_loss is not None or sig.take_profit is not None
):
active_stops.append(
_StopCondition(stop_loss=sig.stop_loss, take_profit=sig.take_profit)
)
# Clear conditions when a SELL occurs (strategy or SL/TP triggered)
for sig in combined:
if sig.direction == "SELL" and active_stops:
active_stops.clear()
# Update strategy position state so next bar sees current holdings
self._update_strategy_position(strat, combined, close_arr[i])
all_signals.extend(combined)
return all_signals
def _check_stop_conditions(
self,
active_stops: list[_StopCondition],
bar_high: float,
bar_low: float,
bar_close: float,
bar_index: int,
df: pd.DataFrame,
) -> list[Signal]:
"""Check active SL/TP conditions against current bar's price range.
If triggered, generates a SELL signal at the trigger price and removes
the condition. Stop-loss is checked first (conservative: assume the
worst case for the holder).
Args:
active_stops: List of active stop conditions
bar_high: Current bar's high price
bar_low: Current bar's low price
bar_close: Current bar's close price
bar_index: Current bar index
df: Price DataFrame (for datetime extraction)
Returns:
List of SELL signals triggered by SL/TP conditions
"""
if not active_stops:
return []
signals: list[Signal] = []
remaining: list[_StopCondition] = []
for cond in active_stops:
triggered = False
trigger_price = 0.0
# Check stop-loss first (worst case for holder)
if cond.stop_loss is not None and bar_low <= cond.stop_loss:
triggered = True
trigger_price = cond.stop_loss
# Then check take-profit
elif cond.take_profit is not None and bar_high >= cond.take_profit:
triggered = True
trigger_price = cond.take_profit
if triggered:
# Get datetime for this bar
dt_val = df["datetime"].iloc[bar_index]
if hasattr(dt_val, "strftime"):
dt_int = int(dt_val.strftime("%Y%m%d"))
else:
dt_int = int(dt_val)
signals.append(
Signal(
datetime=dt_int,
direction="SELL",
size=0, # full position close
price=trigger_price,
source="stop", # 标记为止损/止盈触发,延迟到下一根成交
)
)
else:
remaining.append(cond)
active_stops.clear()
active_stops.extend(remaining)
return signals
def _update_strategy_position(
self, strat: Strategy, signals: list[Signal], est_price: float
) -> None:
"""Update strategy's internal position estimate after each bar.
Uses close price as estimate for full-position calculations.
The actual execution price is determined by OrderSimulator later.
Args:
strat: Strategy instance
signals: Signals generated on this bar
est_price: Estimated price (close of current bar)
"""
for sig in signals:
price = sig.price or est_price
if sig.direction == "BUY":
if sig.size == 0:
# Full position: estimate shares (100-lot rounding)
shares = int(strat._cash / (price * (1 + self._commission)) / 100) * 100
if shares > 0:
strat._position_size += shares
strat._cash -= shares * price
else:
strat._position_size += sig.size
strat._cash -= sig.size * price
elif sig.direction == "SELL":
if sig.size == 0:
# Full sell
strat._cash += strat._position_size * price
strat._position_size = 0.0
else:
strat._cash += sig.size * price
strat._position_size = max(0.0, strat._position_size - sig.size)
def _compute_pnls(self, trades: list[Trade]) -> list[Trade]:
"""Compute realized PnL for sell trades.
Args:
trades: List of trades
Returns:
Trades with PnL computed
"""
position_cost = 0.0
position_size = 0.0
for trade in trades:
if not trade.rejected:
if trade.direction == "BUY":
position_cost += trade.size * trade.price + trade.commission
position_size += trade.size
trade.pnl = 0.0
elif trade.direction == "SELL":
if position_size > 0:
avg_cost = position_cost / position_size
trade.pnl = (trade.price - avg_cost) * trade.size - trade.commission
# 记录本次卖出对应的持仓成本基数,用于派生单笔收益率
trade.cost_basis = avg_cost * trade.size
position_cost -= avg_cost * trade.size
position_size -= trade.size
else:
trade.pnl = 0.0
return trades
def _trades_to_df(self, trades: list[Trade]) -> pd.DataFrame:
"""Convert trades to DataFrame.
Args:
trades: List of trades
Returns:
DataFrame with trade data
"""
if not trades:
return pd.DataFrame(
columns=[
"datetime",
"direction",
"size",
"price",
"commission",
"slippage",
"pnl",
"cost_basis",
"rejected",
]
)
data = [
{
"datetime": t.datetime,
"direction": t.direction,
"size": t.size,
"price": t.price,
"commission": t.commission,
"slippage": t.slippage,
"pnl": t.pnl,
"cost_basis": t.cost_basis,
"rejected": t.rejected,
}
for t in trades
]
return pd.DataFrame(data)
def _empty_result(self) -> BacktestResult:
"""Return empty result for empty input.
Returns:
BacktestResult with empty DataFrames
"""
perf = PerformanceAnalyzer(
pd.DataFrame(columns=["total", "drawdown", "drawdown_pct"]),
pd.DataFrame(columns=["direction", "pnl", "rejected"]),
).compute()
return BacktestResult(
performance=perf,
equity_curve=pd.DataFrame(
columns=["datetime", "cash", "position_value", "total", "drawdown", "drawdown_pct"]
),
trades=pd.DataFrame(
columns=[
"datetime",
"direction",
"size",
"price",
"commission",
"slippage",
"pnl",
"rejected",
]
),
positions=pd.DataFrame(
columns=["datetime", "size", "avg_price", "market_value", "unrealized_pnl"]
),
config={},
)