mirror of
https://ghfast.top/https://github.com/aeroxw/easy-tdx.git
synced 2026-09-12 18:04:16 +08:00
- 成交价精简为 next_open/next_close,删除 this_close/worst/best 三种 非真实模式(this_close 有未来函数偏差,worst/best 为压力测试边界) - 初始资金默认统一为 1,000,000(原 10万/20万) - 新增 presets.py:18 策略各配 1-2 参数的预设寻优网格(笛卡尔积≤200) - registry.to_schema() 返回 preset_grid 字段供前端自动填充 - 新增 POST /backtest/optimize-all/run/async:逐策略预设网格寻优 + 全局排名(OptimizeAllResult:ranking/best/per_strategy/total_grid_points) - 新增 optimize-all 端到端单测 2 例(838 单测全绿)
518 lines
18 KiB
Python
518 lines
18 KiB
Python
"""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,
|
||
):
|
||
"""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).
|
||
"""
|
||
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
|
||
|
||
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)
|
||
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
|
||
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",
|
||
"rejected",
|
||
]
|
||
)
|
||
|
||
data = [
|
||
{
|
||
"datetime": t.datetime,
|
||
"direction": t.direction,
|
||
"size": t.size,
|
||
"price": t.price,
|
||
"commission": t.commission,
|
||
"slippage": t.slippage,
|
||
"pnl": t.pnl,
|
||
"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={},
|
||
)
|