backtesting-frameworks

Par wshobson · agents

Construisez des systèmes de backtesting robustes pour les stratégies de trading, avec une gestion appropriée du biais de look-ahead, du biais de survivant et des coûts de transaction. À utiliser lors du développement d'algorithmes de trading, de la validation de stratégies ou de la construction d'une infrastructure de backtesting.

npx skills add https://github.com/wshobson/agents --skill backtesting-frameworks

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

Skills similaires