Frameworks de Backtesting
Construisez des systèmes de backtesting robustes, prêts pour la production, qui évitent les pièges courants et produisent des estimations fiables de la performance des stratégies.
Quand utiliser cette compétence
- Développer des backtests de stratégies de trading
- Construire une infrastructure de backtesting
- Valider la performance des stratégies
- Éviter les biais courants du backtesting
- Implémenter une analyse walk-forward
- Comparer les alternatives de stratégies
Concepts fondamentaux
1. Biais du backtesting
| Biais | Description | Atténuation |
|---|---|---|
| Look-ahead | Utiliser des informations futures | Données point-in-time |
| Survivorship | Tester uniquement sur les survivants | Utiliser les titres radiés |
| Overfitting | Curve-fitting sur l'historique | Test hors échantillon |
| Sélection | Cherry-picking de stratégies | Pré-enregistrement |
| Transaction | Ignorer les coûts de trading | Modèles de coûts réalistes |
2. Structure appropriée du backtest
Données historiques
│
▼
┌─────────────────────────────────────────┐
│ Ensemble d'entraînement │
│ (Développement et optimisation) │
└─────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────┐
│ Ensemble de validation │
│ (Sélection de paramètres, pas de fuite)│
└─────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────┐
│ Ensemble de test │
│ (Évaluation de performance finale) │
└─────────────────────────────────────────┘
3. Analyse Walk-Forward
Fenêtre 1: [Train──────][Test]
Fenêtre 2: [Train──────][Test]
Fenêtre 3: [Train──────][Test]
Fenêtre 4: [Train──────][Test]
─────▶ Temps
Motifs d'implémentation
Motif 1 : Backtester piloté par événements
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from datetime import datetime
from decimal import Decimal
from enum import Enum
from typing import Dict, List, Optional
import pandas as pd
import numpy as np
class OrderSide(Enum):
BUY = "buy"
SELL = "sell"
class OrderType(Enum):
MARKET = "market"
LIMIT = "limit"
STOP = "stop"
@dataclass
class Order:
symbol: str
side: OrderSide
quantity: Decimal
order_type: OrderType
limit_price: Optional[Decimal] = None
stop_price: Optional[Decimal] = None
timestamp: Optional[datetime] = None
@dataclass
class Fill:
order: Order
fill_price: Decimal
fill_quantity: Decimal
commission: Decimal
slippage: Decimal
timestamp: datetime
@dataclass
class Position:
symbol: str
quantity: Decimal = Decimal("0")
avg_cost: Decimal = Decimal("0")
realized_pnl: Decimal = Decimal("0")
def update(self, fill: Fill) -> None:
if fill.order.side == OrderSide.BUY:
new_quantity = self.quantity + fill.fill_quantity
if new_quantity != 0:
self.avg_cost = (
(self.quantity * self.avg_cost + fill.fill_quantity * fill.fill_price)
/ new_quantity
)
self.quantity = new_quantity
else:
self.realized_pnl += fill.fill_quantity * (fill.fill_price - self.avg_cost)
self.quantity -= fill.fill_quantity
@dataclass
class Portfolio:
cash: Decimal
positions: Dict[str, Position] = field(default_factory=dict)
def get_position(self, symbol: str) -> Position:
if symbol not in self.positions:
self.positions[symbol] = Position(symbol=symbol)
return self.positions[symbol]
def process_fill(self, fill: Fill) -> None:
position = self.get_position(fill.order.symbol)
position.update(fill)
if fill.order.side == OrderSide.BUY:
self.cash -= fill.fill_price * fill.fill_quantity + fill.commission
else:
self.cash += fill.fill_price * fill.fill_quantity - fill.commission
def get_equity(self, prices: Dict[str, Decimal]) -> Decimal:
equity = self.cash
for symbol, position in self.positions.items():
if position.quantity != 0 and symbol in prices:
equity += position.quantity * prices[symbol]
return equity
class Strategy(ABC):
@abstractmethod
def on_bar(self, timestamp: datetime, data: pd.DataFrame) -> List[Order]:
pass
@abstractmethod
def on_fill(self, fill: Fill) -> None:
pass
class ExecutionModel(ABC):
@abstractmethod
def execute(self, order: Order, bar: pd.Series) -> Optional[Fill]:
pass
class SimpleExecutionModel(ExecutionModel):
def __init__(self, slippage_bps: float = 10, commission_per_share: float = 0.01):
self.slippage_bps = slippage_bps
self.commission_per_share = commission_per_share
def execute(self, order: Order, bar: pd.Series) -> Optional[Fill]:
if order.order_type == OrderType.MARKET:
base_price = Decimal(str(bar["open"]))
# Appliquer le slippage
slippage_mult = 1 + (self.slippage_bps / 10000)
if order.side == OrderSide.BUY:
fill_price = base_price * Decimal(str(slippage_mult))
else:
fill_price = base_price / Decimal(str(slippage_mult))
commission = order.quantity * Decimal(str(self.commission_per_share))
slippage = abs(fill_price - base_price) * order.quantity
return Fill(
order=order,
fill_price=fill_price,
fill_quantity=order.quantity,
commission=commission,
slippage=slippage,
timestamp=bar.name
)
return None
class Backtester:
def __init__(
self,
strategy: Strategy,
execution_model: ExecutionModel,
initial_capital: Decimal = Decimal("100000")
):
self.strategy = strategy
self.execution_model = execution_model
self.portfolio = Portfolio(cash=initial_capital)
self.equity_curve: List[tuple] = []
self.trades: List[Fill] = []
def run(self, data: pd.DataFrame) -> pd.DataFrame:
"""Exécuter le backtest sur des données OHLCV avec DatetimeIndex."""
pending_orders: List[Order] = []
for timestamp, bar in data.iterrows():
# Exécuter les ordres en attente aux prix du jour
for order in pending_orders:
fill = self.execution_model.execute(order, bar)
if fill:
self.portfolio.process_fill(fill)
self.strategy.on_fill(fill)
self.trades.append(fill)
pending_orders.clear()
# Obtenir les prix actuels pour le calcul de l'équité
prices = {data.index.name or "default": Decimal(str(bar["close"]))}
equity = self.portfolio.get_equity(prices)
self.equity_curve.append((timestamp, float(equity)))
# Générer de nouveaux ordres pour la barre suivante
new_orders = self.strategy.on_bar(timestamp, data.loc[:timestamp])
pending_orders.extend(new_orders)
return self._create_results()
def _create_results(self) -> pd.DataFrame:
equity_df = pd.DataFrame(self.equity_curve, columns=["timestamp", "equity"])
equity_df.set_index("timestamp", inplace=True)
equity_df["returns"] = equity_df["equity"].pct_change()
return equity_df
Motif 2 : Backtester vectorisé (rapide)
import pandas as pd
import numpy as np
from typing import Callable, Dict, Any
class VectorizedBacktester:
"""Backtester vectorisé rapide pour les stratégies simples."""
def __init__(
self,
initial_capital: float = 100000,
commission: float = 0.001, # 0,1%
slippage: float = 0.0005 # 0,05%
):
self.initial_capital = initial_capital
self.commission = commission
self.slippage = slippage
def run(
self,
prices: pd.DataFrame,
signal_func: Callable[[pd.DataFrame], pd.Series]
) -> Dict[str, Any]:
"""
Exécuter le backtest avec une fonction de signal.
Args:
prices: DataFrame avec la colonne 'close'
signal_func: Fonction retournant des signaux de position (-1, 0, 1)
Returns:
Dictionnaire avec les résultats
"""
# Générer les signaux (décalés pour éviter le look-ahead)
signals = signal_func(prices).shift(1).fillna(0)
# Calculer les rendements
returns = prices["close"].pct_change()
# Calculer les rendements de la stratégie avec coûts
position_changes = signals.diff().abs()
trading_costs = position_changes * (self.commission + self.slippage)
strategy_returns = signals * returns - trading_costs
# Construire la courbe d'équité
equity = (1 + strategy_returns).cumprod() * self.initial_capital
# Calculer les métriques
results = {
"equity": equity,
"returns": strategy_returns,
"signals": signals,
"metrics": self._calculate_metrics(strategy_returns, equity)
}
return results
def _calculate_metrics(
self,
returns: pd.Series,
equity: pd.Series
) -> Dict[str, float]:
"""Calculer les métriques de performance."""
total_return = (equity.iloc[-1] / self.initial_capital) - 1
annual_return = (1 + total_return) ** (252 / len(returns)) - 1
annual_vol = returns.std() * np.sqrt(252)
sharpe = annual_return / annual_vol if annual_vol > 0 else 0
# Drawdown
rolling_max = equity.cummax()
drawdown = (equity - rolling_max) / rolling_max
max_drawdown = drawdown.min()
# Taux de réussite
winning_days = (returns > 0).sum()
total_days = (returns != 0).sum()
win_rate = winning_days / total_days if total_days > 0 else 0
return {
"total_return": total_return,
"annual_return": annual_return,
"annual_volatility": annual_vol,
"sharpe_ratio": sharpe,
"max_drawdown": max_drawdown,
"win_rate": win_rate,
"num_trades": int((returns != 0).sum())
}
# Exemple d'utilisation
def momentum_signal(prices: pd.DataFrame, lookback: int = 20) -> pd.Series:
"""Stratégie de momentum simple : long quand prix > SMA, sinon plat."""
sma = prices["close"].rolling(lookback).mean()
return (prices["close"] > sma).astype(int)
# Exécuter le backtest
# backtester = VectorizedBacktester()
# results = backtester.run(price_data, lambda p: momentum_signal(p, 50))
Motif 3 : Optimisation Walk-Forward
from typing import Callable, Dict, List, Tuple, Any
import pandas as pd
import numpy as np
from itertools import product
class WalkForwardOptimizer:
"""Analyse walk-forward avec fenêtres ancrées ou roulantes."""
def __init__(
self,
train_period: int,
test_period: int,
anchored: bool = False,
n_splits: int = None
):
"""
Args:
train_period: Nombre de barres dans la fenêtre d'entraînement
test_period: Nombre de barres dans la fenêtre de test
anchored: Si True, l'entraînement commence toujours depuis le début
n_splits: Nombre de divisions train/test (auto-calculé si None)
"""
self.train_period = train_period
self.test_period = test_period
self.anchored = anchored
self.n_splits = n_splits
def generate_splits(
self,
data: pd.DataFrame
) -> List[Tuple[pd.DataFrame, pd.DataFrame]]:
"""Générer les divisions train/test."""
splits = []
n = len(data)
if self.n_splits:
step = (n - self.train_period) // self.n_splits
else:
step = self.test_period
start = 0
while start + self.train_period + self.test_period <= n:
if self.anchored:
train_start = 0
else:
train_start = start
train_end = start + self.train_period
test_end = min(train_end + self.test_period, n)
train_data = data.iloc[train_start:train_end]
test_data = data.iloc[train_end:test_end]
splits.append((train_data, test_data))
start += step
return splits
def optimize(
self,
data: pd.DataFrame,
strategy_func: Callable,
param_grid: Dict[str, List],
metric: str = "sharpe_ratio"
) -> Dict[str, Any]:
"""
Exécuter l'optimisation walk-forward.
Args:
data: Ensemble de données complet
strategy_func: Fonction(data, **params) -> dict de résultats
param_grid: Combinaisons de paramètres à tester
metric: Métrique à optimiser
Returns:
Résultats combinés de toutes les périodes de test
"""
splits = self.generate_splits(data)
all_results = []
optimal_params_history = []
for i, (train_data, test_data) in enumerate(splits):
# Optimiser sur les données d'entraînement
best_params, best_metric = self._grid_search(
train_data, strategy_func, param_grid, metric
)
optimal_params_history.append(best_params)
# Tester avec les paramètres optimaux
test_results = strategy_func(test_data, **best_params)
test_results["split"] = i
test_results["params"] = best_params
all_results.append(test_results)
print(f"Split {i+1}/{len(splits)}: "
f"Meilleur {metric}={best_metric:.4f}, params={best_params}")
return {
"split_results": all_results,
"param_history": optimal_params_history,
"combined_equity": self._combine_equity_curves(all_results)
}
def _grid_search(
self,
data: pd.DataFrame,
strategy_func: Callable,
param_grid: Dict[str, List],
metric: str
) -> Tuple[Dict, float]:
"""Recherche en grille pour les meilleurs paramètres."""
best_params = None
best_metric = -np.inf
# Générer toutes les combinaisons de paramètres
param_names = list(param_grid.keys())
param_values = list(param_grid.values())
for values in product(*param_values):
params = dict(zip(param_names, values))
results = strategy_func(data, **params)
if results["metrics"][metric] > best_metric:
best_metric = results["metrics"][metric]
best_params = params
return best_params, best_metric
def _combine_equity_curves(
self,
results: List[Dict]
) -> pd.Series:
"""Combiner les courbes d'équité de toutes les périodes de test."""
combined = pd.concat([r["equity"] for r in results])
return combined
Motif 4 : Analyse Monte Carlo
import numpy as np
import pandas as pd
from typing import Dict, List
class MonteCarloAnalyzer:
"""Simulation Monte Carlo pour la robustesse de la stratégie."""
def __init__(self, n_simulations: int = 1000, confidence: float = 0.95):
self.n_simulations = n_simulations
self.confidence = confidence
def bootstrap_returns(
self,
returns: pd.Series,
n_periods: int = None
) -> np.ndarray:
"""
Simulation bootstrap par rééchantillonnage des rendements.
Args:
returns: Série de rendements historiques
n_periods: Longueur de chaque simulation (par défaut : même que l'entrée)
Returns:
Array de forme (n_simulations, n_periods)
"""
if n_periods is None:
n_periods = len(returns)
simulations = np.zeros((self.n_simulations, n_periods))
for i in range(self.n_simulations):
# Rééchantillonner avec remise
simulated_returns = np.random.choice(
returns.values,
size=n_periods,
replace=True
)
simulations[i] = simulated_returns
return simulations
def analyze_drawdowns(
self,
returns: pd.Series
) -> Dict[str, float]:
"""Analyser la distribution des drawdowns via simulation."""
simulations = self.bootstrap_returns(returns)
max_drawdowns = []
for sim_returns in simulations:
equity = (1 + sim_returns).cumprod()
rolling_max = np.maximum.accumulate(equity)
drawdowns = (equity - rolling_max) / rolling_max
max_drawdowns.append(drawdowns.min())
max_drawdowns = np.array(max_drawdowns)
return {
"expected_max_dd": np.mean(max_drawdowns),
"median_max_dd": np.median(max_drawdowns),
f"worst_{int(self.confidence*100)}pct": np.percentile(
max_drawdowns, (1 - self.confidence) * 100
),
"worst_case": max_drawdowns.min()
}
def probability_of_loss(
self,
returns: pd.Series,
holding_periods: List[int] = [21, 63, 126, 252]
) -> Dict[int, float]:
"""Calculer la probabilité de perte sur diverses périodes de détention."""
results = {}
for period in holding_periods:
if period > len(returns):
continue
simulations = self.bootstrap_returns(returns, period)
total_returns = (1 + simulations).prod(axis=1) - 1
prob_loss = (total_returns < 0).mean()
results[period] = prob_loss
return results
def confidence_interval(
self,
returns: pd.Series,
periods: int = 252
) -> Dict[str, float]:
"""Calculer l'intervalle de confiance pour les rendements futurs."""
simulations = self.bootstrap_returns(returns, periods)
total_returns = (1 + simulations).prod(axis=1) - 1
lower = (1 - self.confidence) / 2
upper = 1 - lower
return {
"expected": total_returns.mean(),
"lower_bound": np.percentile(total_returns, lower * 100),
"upper_bound": np.percentile(total_returns, upper * 100),
"std": total_returns.std()
}
Métriques de performance
def calculate_metrics(returns: pd.Series, rf_rate: float = 0.02) -> Dict[str, float]:
"""Calculer les métriques de performance complètes."""
# Facteur d'annualisation (en supposant des rendements quotidiens)
ann_factor = 252
# Métriques de base
total_return = (1 + returns).prod() - 1
annual_return = (1 + total_return) ** (ann_factor / len(returns)) - 1
annual_vol = returns.std() * np.sqrt(ann_factor)
# Rendements ajustés au risque
sharpe = (annual_return - rf_rate) / annual_vol if annual_vol > 0 else 0
# Sortino (déviation baissière)
downside_returns = returns[returns < 0]
downside_vol = downside_returns.std() * np.sqrt(ann_factor)
sortino = (annual_return - rf_rate) / downside_vol if downside_vol > 0 else 0
# Ratio de Calmar
equity = (1 + returns).cumprod()
rolling_max = equity.cummax()
drawdowns = (equity - rolling_max) / rolling_max
max_drawdown = drawdowns.min()
calmar = annual_return / abs(max_drawdown) if max_drawdown != 0 else 0
# Taux de réussite et profit factor
wins = returns[returns > 0]
losses = returns[returns < 0]
win_rate = len(wins) / len(returns[returns != 0]) if len(returns[returns != 0]) > 0 else 0
profit_factor = wins.sum() / abs(losses.sum()) if losses.sum() != 0 else np.inf
return {
"total_return": total_return,
"annual_return": annual_return,
"annual_volatility": annual_vol,
"sharpe_ratio": sharpe,
"sortino_ratio": sortino,
"calmar_ratio": calmar,
"max_drawdown": max_drawdown,
"win_rate": win_rate,
"profit_factor": profit_factor,
"num_trades": int((returns != 0).sum())
}
Bonnes pratiques
À faire
- Utiliser les données point-in-time - Éviter le biais de look-ahead
- Inclure les coûts de transaction - Estimations réalistes
- Tester hors échantillon - Toujours réserver des données
- Utiliser walk-forward - Pas seulement train/test
- Analyse Monte Carlo - Comprendre l'incertitude
À ne pas faire
- Ne pas overfitter - Limiter les paramètres
- Ne pas ignorer le survivorship - Inclure les titres radiés
- Ne pas utiliser les données ajustées sans discernement - Comprendre les ajustements
- Ne pas optimiser sur l'historique complet - Réserver l'ensemble de test
- Ne pas ignorer la capacité - L'impact de marché compte