Featured image of post Analyse du risque et backtest d'une stratégie de pairs trading (Partie II)

Analyse du risque et backtest d'une stratégie de pairs trading (Partie II)

J'utilise la simulation KO-PEP pour estimer le risque, puis je compare ses résultats à un backtest historique simple.

Analyse du risque et backtest d’un pairs trade, partie II

La partie I a construit la simulation. Ici, je l’utilise pour estimer le risque implicite du modèle, puis je confronte ce résultat à un backtest historique simple. Le contraste est gênant, mais utile. Une stratégie peut sembler solide dans son modèle et perdre beaucoup sur les prix réalisés.

Analyse du risque par Monte Carlo

Construction du portefeuille

Allocation du capital : $100,000 (9 octobre 2023)

Dimensionnement des positions :

  • Long PEP : 663 actions × $150.67 = $99,892
  • Short KO : 2,008 actions × $49.80 = $99,990
  • Exposition brute : $199,882

Écart initial : $S_0 = \$49.80 - 0.333 \times $150.67 = -$0.38$

Cible de retour à la moyenne. J’utilise des statistiques glissantes sur 60 jours afin que la cible de l’écart reflète le régime courant plutôt que l’historique complet de 20 ans :

  • Moyenne glissante : $\$0.004$ (contre une moyenne historique de $$4.63$)
  • Écart type glissant : $\$0.78$ (contre un écart type historique de $$2.75$)
  • L’écart se négocie loin de sa moyenne de long terme

Value at Risk

La Value at Risk, ou VaR, est le seuil de perte du portefeuille pour un niveau de confiance donné. Je l’estime directement à partir de la distribution de Monte Carlo.

Calcul de la VaR :

# Calculate portfolio value for 60-day horizon
# Portfolio starts at 100_000 dollars and includes cash position
final_portfolio_value = portfolio_values[:, 60]

# VaR at different confidence levels
var_90 = np.percentile(final_portfolio_value, 10)  # 90% confidence
var_95 = np.percentile(final_portfolio_value, 5)  # 95% confidence
var_99 = np.percentile(final_portfolio_value, 1)  # 99% confidence

Résultats de VaR à 60 jours :

Niveau de confianceValeur du portefeuille à la VaRVaR
90%$96,637$3,363
95%$95,541$4,459
99%$93,470$6,530

Garder une définition cohérente de la VaR

Une version antérieure mélangeait la valeur terminale du portefeuille à un percentile et l’ampleur de la perte en dollars. Ces valeurs ne sont pas directement comparables. J’ai retiré le tableau incohérent et gardé un seul ensemble de chiffres de VaR Monte Carlo et d’Expected Shortfall.

Comparaison de VaR

Expected Shortfall

L’Expected Shortfall, ou ES, mesure la perte moyenne parmi les résultats situés au-delà du seuil de VaR.

# ES: average portfolio value in the worst 5% of scenarios
final_portfolio_value = portfolio_values[:, 60]
var_threshold = np.percentile(final_portfolio_value, 5)
cvar_95 = final_portfolio_value[final_portfolio_value <= var_threshold].mean()

ES à 60 jours (95 %) : $94,270 (valeur moyenne du portefeuille dans les 5 % pires cas)

Dans les 5 % pires scénarios, la valeur moyenne du portefeuille tombe à $94,270. Cela représente une perte de $5,730, soit 5,7 % du capital initial. Le portefeuille a environ 200 % d’exposition brute. Cette perte de queue est donc plausible.

Drawdown maximal

Le drawdown maximal est la plus forte baisse entre un pic et un creux de la valeur du portefeuille le long d’une trajectoire simulée.

# Calculate maximum drawdown across all paths
cummax = np.maximum.accumulate(portfolio_values, axis=1)
drawdown = (portfolio_values - cummax) / cummax
max_dd = drawdown.min(axis=1)

# Average and worst-case drawdown
avg_max_drawdown = max_dd.mean() * 100
worst_drawdown = max_dd.min() * 100

Statistiques de drawdown maximal :

  • Drawdown maximal moyen : -7.4%
  • Pire drawdown : -21.0% (99e percentile)
  • Drawdown maximal médian : -7.2%

Distribution de la valeur du portefeuille

Distribution du portefeuille

Le graphique montre la distribution de la valeur du portefeuille à 60 jours sur 1 million de trajectoires simulées :

  • VaR 95 % (5e percentile) : $95,541, donc 5 % des issues finissent en dessous de ce niveau
  • Issue médiane : $100,468, soit environ un gain de 0,5 %
  • 95e percentile : $105,383, soit environ un gain de 5,4 %

L’axe des x représente la valeur finale du portefeuille après 60 jours, à partir de $100,000.


Stratégie de trading à retour à la moyenne

Règles de la stratégie

Je trade l’écart avec une règle simple de retour à la moyenne fondée sur son z-score :

Signaux d’entrée :

  • Long spread (acheter KO, vendre PEP) quand $z < -1.5$ (écart sous sa moyenne)
  • Short spread (vendre KO, acheter PEP) quand $z > 1.5$ (écart au-dessus de sa moyenne)

Signaux de sortie :

  • Fermer la position quand l’écart revient vers sa moyenne ($z$ franchit 0)
  • Stop loss à $z = \pm 2.5$

Calcul du z-score :

$$z = \frac{S_t - \mu_S}{\sigma_S}$$

où $S_t$ est l’écart courant, $\mu_S = 4.63$ est la moyenne de long terme et $\sigma_S = 2.75$ est l’écart type historique.

Implémentation de la stratégie

# Calculate z-scores for all paths
spread_all = ko_prices - beta * pep_prices
z_scores = (spread_all - spread_mean) / spread_std

# Entry conditions
long_entry = z_scores < -1.5  # Spread too low, buy spread
short_entry = z_scores > 1.5  # Spread too high, sell spread

# Exit conditions
exit_signal = np.abs(z_scores) < 0.2  # Near mean
stop_loss = np.abs(z_scores) > 2.5  # Risk management

# Track positions
position = np.zeros_like(z_scores)
pnl_strategy = np.zeros_like(z_scores)

for path in range(n_paths):
    current_position = 0
    entry_spread = 0

    for day in range(1, n_days + 1):
        # Check for entry signals
        if current_position == 0:
            if long_entry[path, day]:
                current_position = 1
                entry_spread = spread_all[path, day]
            elif short_entry[path, day]:
                current_position = -1
                entry_spread = spread_all[path, day]

        # Check for exit signals
        elif exit_signal[path, day] or stop_loss[path, day]:
            pnl_strategy[path, day] = current_position * (
                spread_all[path, day] - entry_spread
            )
            current_position = 0

        # Mark-to-market if still in position
        elif current_position != 0:
            pnl_strategy[path, day] = current_position * (
                spread_all[path, day] - entry_spread
            )

        position[path, day] = current_position

Performance de la stratégie

Résultats à 60 jours (1 M de simulations) :

MesureValeur
Taux de gain70.1%
Gain moyen des gagnants+$5,778
Perte moyenne des perdants-$3,732
Gain maximal+$34,214
Perte maximale-$26,966
Ratio de Sharpe1.05

Calcul du ratio de Sharpe :

# Annualized Sharpe ratio for strategy
strategy_returns = strategy_pnl / initial_capital
mean_return = strategy_returns.mean()
std_return = strategy_returns.std()
sharpe_ratio = (mean_return / std_return) * np.sqrt(252 / 60)

Distribution du P&L de la stratégie

P&L de la stratégie

Le centre de la distribution des profits et pertes est positif :

  • P&L médian : +$3,047 (rendement de 3,0 %)
  • Probabilité de profit : 70.1%
  • Intervalle de confiance à 95 % : environ [-$10k, +$15k]

Exemples de trajectoires de trading

Exemples de trajectoires

Ces cinq trajectoires montrent le comportement de la règle :

  • Trajectoires vertes : trades de retour à la moyenne réussis
  • Trajectoires rouges : positions stoppées ou non rentables
  • Points d’entrée : triangles aux seuils de z-score
  • Points de sortie : cercles aux croisements de la moyenne ou aux stop losses

Backtest historique sur 2 ans

Période de test : octobre 2023 - octobre 2025

Je quitte ensuite le monde simulé et j’applique la règle aux prix réels de KO et PEP sur deux ans, soit 504 jours de bourse.

Paramètres du backtest :

Période des données : 11 octobre 2023 - 9 octobre 2025
Capital initial : $100,000
Règles de la stratégie : les mêmes que dans la partie 1 (seuils de z-score : entrée à ±1.5, sortie à 0, stop loss à ±2.5)

Implémentation

# Load 2 years of historical data
backtest_start = "2023-10-11"
backtest_end = "2025-10-09"

ko_backtest = yf.download(
    "KO", start=backtest_start, end=backtest_end, auto_adjust=False
)["Adj Close"]
pep_backtest = yf.download(
    "PEP", start=backtest_start, end=backtest_end, auto_adjust=False
)["Adj Close"]

# Calculate rolling statistics (60-day lookback)
lookback = 60
spread_backtest = ko_backtest - beta * pep_backtest
rolling_mean = spread_backtest.rolling(window=lookback).mean()
rolling_std = spread_backtest.rolling(window=lookback).std()
z_score_backtest = (spread_backtest - rolling_mean) / rolling_std

Résultats du backtest

Performance sur 2 ans :

MesureValeur
Rendement annualisé-18.91%
Nombre de trades21
Taux de gain28.6%
Drawdown maximal~34%

Ce que le backtest change

Le taux de gain simulé et le résultat historique pointent dans des directions opposées. Cet écart est l’avertissement principal. La dynamique simulée n’a pas reproduit le comportement du marché qui comptait sur cet échantillon.

Ali G ferme le billet :