<< Sommaire QC | Précédent : QC-Py-14b-Liquidity-Execution-Costs << | Suivant : QC-Py-16-Alternative-Data >>

Objectifs d’Apprentissage

A la fin de ce notebook, vous serez capable de :

  1. Comprendre les risques de l’optimisation (overfitting, curve fitting)
  2. Définir des paramètres optimisables dans QCAlgorithm
  3. Implementer une Grid Search manuelle pour tester des combinaisons
  4. Realiser une Walk-Forward Analysis pour valider la robustesse
  5. Detecter et eviter l’overfitting avec des techniques eprouvees
  6. Utiliser des fonctions de fitness multi-objectif
  7. Tester la robustesse des paramètres optimaux

Prerequis

  • Notebooks QC-Py-01 a QC-Py-12 completes
  • Comprehension des metriques de performance (Sharpe, Drawdown)
  • Notions de base en statistiques et machine learning

Structure du Notebook

  1. Introduction a l’Optimisation (15 min)
  2. Parameter Sets dans QC (20 min)
  3. Grid Search Manuel (25 min)
  4. Walk-Forward Analysis (25 min)
  5. Eviter l’Overfitting (15 min)
  6. Optimisation Avancee (20 min)
  7. Exemple Complet (15 min)

Note de conception : Ce notebook contient du code de reference a copier dans QuantConnect Lab (main.py). Les cellules ne sont pas prevues pour etre executees en tant que notebook Jupyter. L’absence d’outputs (execution_count: null) est intentionnelle.

[REFERENCE QC Cloud] Ce notebook illustre du code QuantConnect a executer dans l’IDE Cloud (https://www.quantconnect.com/research). L’environnement local ne dispose pas de QuantBook ni de l’historical data feed. Pour executer : cloner le projet QC associe, ouvrir research.ipynb, executer cellule par cellule.


Mode d’emploi : Ce notebook est un document de reference (non executable). Le code QCAlgorithm presente ici est a copier dans main.py de votre projet QuantConnect Lab. Ne tentez pas de faire “Run All” – les imports AlgorithmImports n’existent pas en Jupyter local.

Pour demarrer un projet QC : copiez un main.py depuis projects/ ou partner-course-quant-trading/templates/, puis adaptez-le.



Setup et Imports

# Imports standards
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
from datetime import datetime, timedelta
from typing import Dict, List, Tuple, Optional, Callable
from itertools import product
import warnings
warnings.filterwarnings('ignore')

# Configuration matplotlib
plt.style.use('seaborn-v0_8-darkgrid')
%matplotlib inline

# Ajouter shared/ au path
import sys
sys.path.append('../shared')

print("Imports reussis")
Imports reussis

On définit ici l’espace des paramètres à explorer lors de l’optimisation : chaque dimension (période de la moyenne rapide, période de la moyenne lente, stop-loss, take-profit) délimite ce que le moteur d’optimisation aura le droit de choisir. Cet espace est déjà une décision de modélisation : le fixer, c’est décider à l’avance quelles stratégies sont concevables et lesquelles sont hors de propos. Un espace trop large multiplie les combinaisons à tester — chaque test supplémentaire accroît le coût de calcul ET la surface d’overfitting ; un espace trop étroit rabat l’optimum sur son bord, exactement le symptôme que la partie 3 fera apparaître (un take-profit collé à sa valeur maximale). La lecture qui compte ici n’est donc pas « quels paramètres ? » mais « quelle est la forme de l’espace où l’histoire élira les paramètres ? ».

# Importer helpers du repository
import sys
sys.path.insert(0, '/workspace')
from backtest_helpers import calculate_metrics, format_backtest_summary
from plotting import plot_backtest_results

print("Helpers importes avec succes")
Helpers importes avec succes

Interprétation : les helpers partagés du dossier shared/

Imports des helpers depuis le dossier shared/:

  • backtest_helpers.py: Fonctions utilitaires pour calculer les métriques de performance (Sharpe, drawdown, etc.)
  • plotting.py: Fonctions de visualisation des résultats de backtest

Ces helpers sont partagés entre plusieurs notebooks pour éviter la duplication de code. Ils fournissent des implémentations standardisées pour: - Calcul de métriques (calculate_metrics) - Formatage de résultats (format_backtest_summary) - Graphiques de performance (plot_backtest_results)


Le contrat de ce notebook : optimiser sans se mentir

Optimiser les paramètres d’une stratégie, c’est faire un choix dans un espace de possibilités en utilisant l’histoire comme électorat. Le contrat de lecture de ce notebook est double. D’abord : toute optimisation sur des données passées peut être un artefact — les 672 backtests de la partie 3 vont produire un classement, et la question n’est jamais « quelle est la meilleure combinaison » mais « ce classement survivra-t-il à des données que l’optimisation n’a pas vues ». Ensuite : les outils qui répondent à cette question existent et sont tous ici — validation out-of-sample, walk-forward, tests de sensibilité, Monte Carlo — mais ils ne valent que par la rigueur avec laquelle on lit leurs sorties.

Ce notebook est construit pour que la démonstration soit honnête : les données sont en partie simulées, les phénomènes d’overfitting apparaîtront réellement dans les sorties committées (une walk-forward qui tombe à 0 partout, un détecteur qui lit un nan comme un « OK »), et chaque partie expliquera ce que l’output dit vraiment — y compris quand il contredit la conclusion confortable. Gardez cette grille de lecture : un score élevé in-sample n’est pas une information sur le futur ; c’est une information sur la capacité de l’espace de paramètres à épouser le bruit du passé.


Partie 1: Introduction a l’Optimisation (15 min)

1.1 Pourquoi Optimiser? (10 min)

L’optimisation des paramètres vise a trouver les valeurs qui maximisent la performance d’une stratégie. Cependant, c’est un processus delicat qui comporte des risques importants.

Objectifs de l’Optimisation

Objectif Description Exemple
Trouver les meilleurs paramètres Identifier les valeurs optimales SMA fast=10, slow=50
Maximiser la performance ajustee Sharpe ratio, Calmar ratio Sharpe > 1.5
Equilibrer rendement et risque Compromis return/drawdown Max DD < 15%
Assurer la robustesse Paramètres stables dans le temps Performance OOS consistante
# Illustration: Impact des parametres sur la performance

# Generer des donnees de prix simulees (2 ans)
np.random.seed(42)
n_days = 504
dates = pd.date_range('2022-01-01', periods=n_days, freq='B')

# Prix avec tendance et bruit
trend = np.linspace(100, 130, n_days)
noise = np.cumsum(np.random.randn(n_days) * 0.5)
prices = pd.Series(trend + noise, index=dates)

def simulate_sma_crossover(prices: pd.Series, fast: int, slow: int) -> pd.Series:
    """
    Simule une strategie SMA crossover simple.
    
    Returns:
        Serie equity curve
    """
    sma_fast = prices.rolling(fast).mean()
    sma_slow = prices.rolling(slow).mean()
    
    # Signaux: 1 si fast > slow, 0 sinon
    signal = (sma_fast > sma_slow).astype(int)
    signal = signal.shift(1).fillna(0)  # Eviter lookahead bias
    
    # Returns journaliers
    returns = prices.pct_change()
    
    # Returns de la strategie
    strategy_returns = signal * returns
    
    # Equity curve
    equity = 100000 * (1 + strategy_returns).cumprod()
    
    return equity

# Tester differentes combinaisons
param_sets = [
    (5, 20),
    (10, 50),
    (20, 100),
    (30, 150)
]

plt.figure(figsize=(14, 6))

for fast, slow in param_sets:
    equity = simulate_sma_crossover(prices, fast, slow)
    plt.plot(equity.index, equity / 1000, label=f'SMA({fast}, {slow})', linewidth=2)

plt.xlabel('Date', fontsize=12)
plt.ylabel('Equity (k$)', fontsize=12)
plt.title('Impact des Parametres SMA sur la Performance', fontsize=14, fontweight='bold')
plt.legend(loc='upper left')
plt.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()

print("Les parametres ont un impact significatif sur la performance!")

Les parametres ont un impact significatif sur la performance!

Risques de l’Optimisation

L’optimisation comporte des pieges importants:

Risque Description Consequence
Overfitting Paramètres sur-adaptes aux données historiques Echec en live trading
Curve Fitting Trouver des patterns qui n’existent pas Faux signaux
Data Mining Bias Tester trop de combinaisons Faux positifs statistiques
Lookahead Bias Utiliser information future Performance irrealiste
Sélection Bias Choisir la meilleure periode Non representatif
# Demonstration de l'overfitting

# Donnees d'entrainement (In-Sample)
train_prices = prices[:300]
# Donnees de test (Out-of-Sample)
test_prices = prices[300:]

# Optimiser sur train
best_sharpe_is = -np.inf
best_params_is = None

results_is = []

for fast in range(3, 30, 3):
    for slow in range(20, 120, 10):
        if fast >= slow:
            continue
        
        equity = simulate_sma_crossover(train_prices, fast, slow)
        returns = equity.pct_change().dropna()
        
        if len(returns) > 0 and returns.std() > 0:
            sharpe = (returns.mean() * 252) / (returns.std() * np.sqrt(252))
        else:
            sharpe = 0
        
        results_is.append({
            'fast': fast,
            'slow': slow,
            'sharpe': sharpe
        })
        
        if sharpe > best_sharpe_is:
            best_sharpe_is = sharpe
            best_params_is = (fast, slow)

# Tester les meilleurs parametres sur OOS
equity_is = simulate_sma_crossover(train_prices, *best_params_is)
equity_oos = simulate_sma_crossover(test_prices, *best_params_is)

returns_is = equity_is.pct_change().dropna()
returns_oos = equity_oos.pct_change().dropna()

sharpe_is = (returns_is.mean() * 252) / (returns_is.std() * np.sqrt(252))
sharpe_oos = (returns_oos.mean() * 252) / (returns_oos.std() * np.sqrt(252))

print("="*60)
print("DEMONSTRATION DE L'OVERFITTING")
print("="*60)
print(f"\nMeilleurs parametres (optimises sur In-Sample): SMA({best_params_is[0]}, {best_params_is[1]})")
print(f"\n{'Metrique':<25} {'In-Sample':>15} {'Out-of-Sample':>15}")
print("-"*60)
print(f"{'Sharpe Ratio':<25} {sharpe_is:>15.2f} {sharpe_oos:>15.2f}")
print(f"{'Degradation':<25} {'-':>15} {((sharpe_oos-sharpe_is)/sharpe_is*100):>14.1f}%")
print("\n> La degradation IS -> OOS est un signe classique d'overfitting!")
============================================================
DEMONSTRATION DE L'OVERFITTING
============================================================

Meilleurs parametres (optimises sur In-Sample): SMA(21, 60)

Metrique                        In-Sample   Out-of-Sample
------------------------------------------------------------
Sharpe Ratio                         2.20            1.78
Degradation                             -          -18.9%

> La degradation IS -> OOS est un signe classique d'overfitting!

Lire la dégradation mesurée : -18.9 % est le scénario BÉNIN

La sortie montre Sharpe 2.20 en In-Sample contre 1.78 en Out-of-Sample, soit une dégradation de -18.9 % pour les paramètres SMA(21, 60) optimisés sur la période d’entraînement. La leçon de lecture tient en deux points.

Premier point : le sens de la comparaison. Les paramètres ont été choisis pour maximiser le score IS — le 2.20 est donc un maximum optimisé, pas une mesure neutre. Le 1.78, lui, n’a bénéficié d’aucun choix : c’est la performance d’une politique figée sur des données nouvelles. Toute la différence entre les deux nombres mesure l’écart entre « sélectionner sur le passé » et « jouer sur le futur ». Un écart de -18.9 % paraît modéré ; il le reste seulement parce que la simulation (tendance + saisonnalité) a une structure stable entre IS et OOS. Sur des données réelles, la dégradation typique est plus sévère — et la partie 4 montrera un cas extrême où elle vaut 100 %.

Deuxième point : la borne de confiance. Avec aussi peu de transactions par configuration (la partie 3 en comptera 3 à 5), l’écart-type du Sharpe estimé est de l’ordre de la valeur elle-même. La dégradation mesurée ici est une observation sur un échantillon, pas un paramètre de la stratégie. C’est la raison d’être des méthodes qui suivent : répéter la mesure (walk-forward), la perturber (sensibilité, Monte Carlo) pour savoir si -18.9 % est une propriété ou un tirage.

1.2 Types d’Optimisation (5 min)

Plusieurs approches existent pour optimiser les paramètres:

Méthode Description Avantages Inconvenients
Grid Search Teste toutes les combinaisons Exhaustif, simple Lent, cout exponentiel
Random Search Echantillonne aleatoirement Plus rapide, bon coverage Peut manquer l’optimum
Bayesian Optimization Modèle probabiliste Efficace, peu d’essais Complexe a implementer
Genetic Algorithms Evolution des paramètres Espace large, parallelisable Pas de garantie optimum
# Comparaison des methodes d'optimisation

def count_combinations(param_ranges: Dict[str, List]) -> int:
    """Compte le nombre de combinaisons pour Grid Search."""
    total = 1
    for values in param_ranges.values():
        total *= len(values)
    return total

# Exemple d'espace de parametres
param_space = {
    'fast_period': list(range(5, 50, 5)),       # 9 valeurs
    'slow_period': list(range(20, 200, 20)),    # 9 valeurs
    'stop_loss': [0.02, 0.03, 0.05, 0.07],      # 4 valeurs
    'take_profit': [0.05, 0.10, 0.15, 0.20],    # 4 valeurs
}

n_combinations = count_combinations(param_space)
time_per_backtest = 5  # secondes

print("="*50)
print("COMPARAISON DES METHODES D'OPTIMISATION")
print("="*50)
print(f"\nEspace de parametres:")
for param, values in param_space.items():
    print(f"  - {param}: {len(values)} valeurs")

print(f"\nNombre total de combinaisons: {n_combinations:,}")
print(f"Temps estime (Grid Search): {n_combinations * time_per_backtest / 60:.1f} minutes")
print(f"Temps estime (Random 100): {100 * time_per_backtest / 60:.1f} minutes")
print(f"Temps estime (Bayesian 50): {50 * time_per_backtest / 60:.1f} minutes")
==================================================
COMPARAISON DES METHODES D'OPTIMISATION
==================================================

Espace de parametres:
  - fast_period: 9 valeurs
  - slow_period: 9 valeurs
  - stop_loss: 4 valeurs
  - take_profit: 4 valeurs

Nombre total de combinaisons: 1,296
Temps estime (Grid Search): 108.0 minutes
Temps estime (Random 100): 8.3 minutes
Temps estime (Bayesian 50): 4.2 minutes

Ce que chaque méthode achète : 108 minutes contre 8.3 contre 4.2

La sortie donne la combinatoire exacte : 9 × 9 × 4 × 4 = 1 296 combinaisons, estimées à 108 minutes en grid search exhaustif, 8.3 minutes en recherche aléatoire (100 tirages) et 4.2 minutes en optimisation bayésienne (50 évaluations). Le rapport est de 26× entre l’exhaustif et le bayésien — mais le prix payé n’est pas le même selon la méthode, et il ne se lit pas dans la colonne « temps ».

Le grid exhaustif achète la certitude du pire cas : aucune combinaison manquée, et la possibilité d’analyser la structure de l’espace (les heatmaps de la partie 3 — plateaux, pics, dimensions inertes — n’existent que parce que tout l’espace a été mesuré). La recherche aléatoire achète la couverture effective : un théorème classique (Bergstra & Bengio, 2012) établit que si seule une petite fraction des dimensions porte le signal, 100 tirages aléatoires trouvent une combinaison aussi bonne que le grid avec une probabilité étonnamment haute — car le grid gaspille ses 1 296 essais en couvrant les dimensions inertes avec une précision inutile. L’optimisation bayésienne achète l’efficacité séquentielle : chaque évaluation informe un surrogate (processus gaussien typiquement) qui choisit la suivante, concentrant les essais dans les régions prometteuses — au prix d’un biais d’exploitation : elle approfondit le premier bon bassin trouvé et peut manquer un optimum isolé que le grid aurait vu.

La règle de choix pratique : grid si l’espace est petit et qu’on veut la carte, aléatoire si l’espace est grand et les dimensions inégales, bayésien si chaque évaluation est coûteuse (backtest lourd, données réelles payantes) — en gardant à l’esprit que la partie 3 va montrer que la dimension stop_loss de CET espace est… totalement inerte.


Partie 2: Parameter Sets dans QuantConnect (20 min)

2.1 Définir des Paramètres Optimisables (10 min)

QuantConnect permet de définir des paramètres qui peuvent etre modifiés sans changer le code de l’algorithme. C’est essentiel pour l’optimisation.

# [REFERENCE QC] Code a copier dans main.py QC Lab (non executable ici)
# Exemple de strategie avec parametres optimisables

optimizable_strategy_code = '''
from AlgorithmImports import *

class OptimizableStrategy(QCAlgorithm):
    """
    Strategie SMA Crossover avec parametres optimisables.
    
    Parametres:
        - fast_period: Periode de la SMA rapide
        - slow_period: Periode de la SMA lente
        - stop_loss: Pourcentage de stop loss
        - take_profit: Pourcentage de take profit
    """
    
    def Initialize(self):
        self.SetStartDate(2015, 1, 1)
        self.SetEndDate(2024, 12, 31)
        self.SetCash(100000)
        
        # === PARAMETRES OPTIMISABLES ===
        # GetParameter() recupere la valeur passee par l'optimiseur
        # Le 2eme argument est la valeur par defaut
        
        self.fast_period = self.GetParameter("fast_period", 10)
        self.slow_period = self.GetParameter("slow_period", 50)
        self.stop_loss = self.GetParameter("stop_loss", 0.05)
        self.take_profit = self.GetParameter("take_profit", 0.10)
        
        # IMPORTANT: Convertir en types appropries
        # GetParameter retourne toujours une string!
        self.fast_period = int(self.fast_period)
        self.slow_period = int(self.slow_period)
        self.stop_loss = float(self.stop_loss)
        self.take_profit = float(self.take_profit)
        
        # Validation des parametres
        if self.fast_period >= self.slow_period:
            raise ValueError("fast_period doit etre < slow_period")
        
        # Setup trading
        self.symbol = self.AddEquity("SPY", Resolution.Daily).Symbol
        
        # Indicateurs utilisant les parametres
        self.sma_fast = self.SMA(self.symbol, self.fast_period, Resolution.Daily)
        self.sma_slow = self.SMA(self.symbol, self.slow_period, Resolution.Daily)
        
        # Variables de tracking
        self.entry_price = 0
        
        # Warmup
        self.SetWarmup(self.slow_period)
        
        # Log des parametres
        self.Debug(f"Parameters: fast={self.fast_period}, slow={self.slow_period}, "
                   f"SL={self.stop_loss:.2%}, TP={self.take_profit:.2%}")
    
    def OnData(self, data):
        if self.IsWarmingUp:
            return
        
        if not (self.sma_fast.IsReady and self.sma_slow.IsReady):
            return
        
        price = self.Securities[self.symbol].Price
        
        # Verifier stop loss / take profit si position ouverte
        if self.Portfolio[self.symbol].Invested:
            pnl_pct = (price - self.entry_price) / self.entry_price
            
            if pnl_pct <= -self.stop_loss:
                self.Liquidate(self.symbol)
                self.Debug(f"{self.Time.date()}: STOP LOSS hit at {pnl_pct:.2%}")
                return
            
            if pnl_pct >= self.take_profit:
                self.Liquidate(self.symbol)
                self.Debug(f"{self.Time.date()}: TAKE PROFIT hit at {pnl_pct:.2%}")
                return
        
        # Logique de trading
        if not self.Portfolio[self.symbol].Invested:
            # Signal d\'achat: SMA fast croise au-dessus de SMA slow
            if self.sma_fast.Current.Value > self.sma_slow.Current.Value:
                self.SetHoldings(self.symbol, 1.0)
                self.entry_price = price
        else:
            # Signal de vente: SMA fast croise en-dessous de SMA slow
            if self.sma_fast.Current.Value < self.sma_slow.Current.Value:
                self.Liquidate(self.symbol)
'''

print("Strategie OptimizableStrategy:")
print(optimizable_strategy_code)
Strategie OptimizableStrategy:

from AlgorithmImports import *

class OptimizableStrategy(QCAlgorithm):
    """
    Strategie SMA Crossover avec parametres optimisables.

    Parametres:
        - fast_period: Periode de la SMA rapide
        - slow_period: Periode de la SMA lente
        - stop_loss: Pourcentage de stop loss
        - take_profit: Pourcentage de take profit
    """

    def Initialize(self):
        self.SetStartDate(2015, 1, 1)
        self.SetEndDate(2024, 12, 31)
        self.SetCash(100000)

        # === PARAMETRES OPTIMISABLES ===
        # GetParameter() recupere la valeur passee par l'optimiseur
        # Le 2eme argument est la valeur par defaut

        self.fast_period = self.GetParameter("fast_period", 10)
        self.slow_period = self.GetParameter("slow_period", 50)
        self.stop_loss = self.GetParameter("stop_loss", 0.05)
        self.take_profit = self.GetParameter("take_profit", 0.10)

        # IMPORTANT: Convertir en types appropries
        # GetParameter retourne toujours une string!
        self.fast_period = int(self.fast_period)
        self.slow_period = int(self.slow_period)
        self.stop_loss = float(self.stop_loss)
        self.take_profit = float(self.take_profit)

        # Validation des parametres
        if self.fast_period >= self.slow_period:
            raise ValueError("fast_period doit etre < slow_period")

        # Setup trading
        self.symbol = self.AddEquity("SPY", Resolution.Daily).Symbol

        # Indicateurs utilisant les parametres
        self.sma_fast = self.SMA(self.symbol, self.fast_period, Resolution.Daily)
        self.sma_slow = self.SMA(self.symbol, self.slow_period, Resolution.Daily)

        # Variables de tracking
        self.entry_price = 0

        # Warmup
        self.SetWarmup(self.slow_period)

        # Log des parametres
        self.Debug(f"Parameters: fast={self.fast_period}, slow={self.slow_period}, "
                   f"SL={self.stop_loss:.2%}, TP={self.take_profit:.2%}")

    def OnData(self, data):
        if self.IsWarmingUp:
            return

        if not (self.sma_fast.IsReady and self.sma_slow.IsReady):
            return

        price = self.Securities[self.symbol].Price

        # Verifier stop loss / take profit si position ouverte
        if self.Portfolio[self.symbol].Invested:
            pnl_pct = (price - self.entry_price) / self.entry_price

            if pnl_pct <= -self.stop_loss:
                self.Liquidate(self.symbol)
                self.Debug(f"{self.Time.date()}: STOP LOSS hit at {pnl_pct:.2%}")
                return

            if pnl_pct >= self.take_profit:
                self.Liquidate(self.symbol)
                self.Debug(f"{self.Time.date()}: TAKE PROFIT hit at {pnl_pct:.2%}")
                return

        # Logique de trading
        if not self.Portfolio[self.symbol].Invested:
            # Signal d'achat: SMA fast croise au-dessus de SMA slow
            if self.sma_fast.Current.Value > self.sma_slow.Current.Value:
                self.SetHoldings(self.symbol, 1.0)
                self.entry_price = price
        else:
            # Signal de vente: SMA fast croise en-dessous de SMA slow
            if self.sma_fast.Current.Value < self.sma_slow.Current.Value:
                self.Liquidate(self.symbol)

Résultats Backtest QC Cloud - OptimizableStrategy (SMA 10/50, 2015-2024, $100,000 initial)

Metrique Valeur
Dates negociables 2516
Sharpe Ratio 0.298
CAGR 6.564%
Max Drawdown 16.100%
Net Profit +88.8%
Equity finale $188,845
Probabilistic Sharpe Ratio 3.805%

Backtest QC Cloud 2015-2024 (projet 33177683, bt 73ac0abcdb02288016da3c9002b23b1f). Net Profit / Equity finale deduits du CAGR verifie (End = $100,000 x (1+CAGR)^10).

2.2 Configuration d’Optimisation (JSON) (10 min)

Dans QuantConnect, l’optimisation est configuree via un fichier JSON qui définit les plages de paramètres.

import json

# Configuration d'optimisation QuantConnect
optimization_config = {
    "algorithm-id": "OptimizableStrategy",
    "parameters": [
        {
            "name": "fast_period",
            "min": 5,
            "max": 50,
            "step": 5
        },
        {
            "name": "slow_period",
            "min": 20,
            "max": 200,
            "step": 20
        },
        {
            "name": "stop_loss",
            "min": 0.02,
            "max": 0.10,
            "step": 0.02
        },
        {
            "name": "take_profit",
            "min": 0.05,
            "max": 0.20,
            "step": 0.05
        }
    ],
    "optimization": {
        "target": "SharpeRatio",
        "direction": "maximize",
        "constraint": {
            "MaximumDrawdown": {
                "max": 0.20
            }
        }
    },
    "backtest": {
        "start": "2022-01-01",
        "end": "2023-12-31"
    }
}

print("Configuration d'optimisation QuantConnect:")
print(json.dumps(optimization_config, indent=2))

# Calculer le nombre de combinaisons
n_fast = (optimization_config['parameters'][0]['max'] - optimization_config['parameters'][0]['min']) // optimization_config['parameters'][0]['step'] + 1
n_slow = (optimization_config['parameters'][1]['max'] - optimization_config['parameters'][1]['min']) // optimization_config['parameters'][1]['step'] + 1
n_sl = int((optimization_config['parameters'][2]['max'] - optimization_config['parameters'][2]['min']) / optimization_config['parameters'][2]['step']) + 1
n_tp = int((optimization_config['parameters'][3]['max'] - optimization_config['parameters'][3]['min']) / optimization_config['parameters'][3]['step']) + 1

total = n_fast * n_slow * n_sl * n_tp
print(f"\nNombre total de combinaisons: {total}")
Configuration d'optimisation QuantConnect:
{
  "algorithm-id": "OptimizableStrategy",
  "parameters": [
    {
      "name": "fast_period",
      "min": 5,
      "max": 50,
      "step": 5
    },
    {
      "name": "slow_period",
      "min": 20,
      "max": 200,
      "step": 20
    },
    {
      "name": "stop_loss",
      "min": 0.02,
      "max": 0.1,
      "step": 0.02
    },
    {
      "name": "take_profit",
      "min": 0.05,
      "max": 0.2,
      "step": 0.05
    }
  ],
  "optimization": {
    "target": "SharpeRatio",
    "direction": "maximize",
    "constraint": {
      "MaximumDrawdown": {
        "max": 0.2
      }
    }
  },
  "backtest": {
    "start": "2022-01-01",
    "end": "2023-12-31"
  }
}

Nombre total de combinaisons: 2000

On configure le moteur d’optimisation en choisissant la méthode de recherche — grille exhaustive, échantillonnage aléatoire ou optimisation bayésienne — puis la fonction de fitness qui notera chaque combinaison. La méthode de recherche décide de l’exploration (comment l’espace est parcouru) ; la fitness décide du but (ce qui est maximisé). C’est le second choix qui porte le sens : une fitness qui ne maximise que le retour récompense le risque pur, une fitness qui intègre le drawdown ou le Sharpe corrige la lecture. La règle de discipline : définir la fonction d’objectif AVANT de voir les résultats, jamais après — sinon l’optimisation devient une justification à rebours.

# Fitness functions disponibles dans QuantConnect

fitness_functions = {
    'SharpeRatio': 'Return ajuste au risque (volatilite totale)',
    'SortinoRatio': 'Return ajuste au risque (downside only)',
    'CalmarRatio': 'CAGR / Max Drawdown',
    'ProbabilisticSharpeRatio': 'Sharpe avec correction statistique',
    'CompoundingAnnualReturn': 'CAGR pur',
    'TotalReturn': 'Return total',
    'MaxDrawdown': 'Drawdown maximum (a minimiser)',
    'WinRate': 'Pourcentage de trades gagnants',
    'ProfitFactor': 'Gains / Pertes'
}

print("Fitness Functions disponibles dans QuantConnect:")
print("="*60)
for name, description in fitness_functions.items():
    print(f"  - {name:<30} : {description}")
Fitness Functions disponibles dans QuantConnect:
============================================================
  - SharpeRatio                    : Return ajuste au risque (volatilite totale)
  - SortinoRatio                   : Return ajuste au risque (downside only)
  - CalmarRatio                    : CAGR / Max Drawdown
  - ProbabilisticSharpeRatio       : Sharpe avec correction statistique
  - CompoundingAnnualReturn        : CAGR pur
  - TotalReturn                    : Return total
  - MaxDrawdown                    : Drawdown maximum (a minimiser)
  - WinRate                        : Pourcentage de trades gagnants
  - ProfitFactor                   : Gains / Pertes

Interprétation : les fitness functions QuantConnect — Sharpe, Sortino, Calmar, CAR, MaxDD

Fitness Functions disponibles dans QuantConnect:

Ces métriques sont utilisées pour classer les résultats lors d’une optimisation:

Métrique Usage Idéal pour
SharpeRatio Return/volatilité Stratégies directionnelles
SortinoRatio Return/volatilité négative Ignore la volatilité à la hausse
CalmarRatio CAGR/MaxDD Stratégies trend following
CompoundingAnnualReturn CAGR brut Maximiser le growth
MaxDrawdown DD maximum (à minimiser) Contrôle du risque

Le choix de la fitness function influence les paramètres optimaux sélectionnés.

Ancre savante – Sharpe (1966), « Mutual Fund Performance », The Journal of Business 39(1), 119-138 (ratio Return/volatilité, fitness directionnelle de référence). Sortino & Price (1994), « Performance Measurement in a Downside Risk Framework », The Journal of Investing 3(3), 59-65 (ratio Return/volatilité négative, distingue risque de hausse et de baisse).



Partie 3: Grid Search Manuel (25 min)

Interprétation : le grid search exhaustif — 672 combinaisons, filtre fast < slow

Grid Search complet: tester toutes les combinaisons de paramètres

  • Espace de paramètres:
    • fast_periods: [5, 10, 15, 20, 25, 30]
    • slow_periods: [50, 75, 100, 125, 150, 175, 200]
    • stop_losses: [0.03, 0.05, 0.07, 0.10]
    • take_profits: [0.05, 0.10, 0.15, 0.20]
  • itertools.product: Génère toutes les combinaisons (cartésien)
  • Filtre: Skip les combinaisons où fast ≥ slow (invalides)
  • Output: Liste de dictionnaires avec métriques pour chaque combinaison valide

Le nombre total de combinaisons peut être grand (ex: 6×7×4×4 = 672), donc la Grid Search est coûteuse en temps.


# Analyser les resultats
results_df = pd.DataFrame(results)

# Trier par Sharpe Ratio
results_df_sorted = results_df.sort_values('sharpe', ascending=False)

print("="*70)
print("TOP 10 COMBINAISONS PAR SHARPE RATIO")
print("="*70)
print(results_df_sorted.head(10).to_string(index=False))

# Meilleure combinaison
best = results_df_sorted.iloc[0]
print(f"\nMeilleure combinaison:")
print(f"  Fast Period: {best['fast_period']}")
print(f"  Slow Period: {best['slow_period']}")
print(f"  Stop Loss: {best['stop_loss']:.2%}")
print(f"  Take Profit: {best['take_profit']:.2%}")
print(f"\n  Sharpe Ratio: {best['sharpe']:.2f}")
print(f"  Total Return: {best['total_return']:.2%}")
print(f"  Max Drawdown: {best['max_drawdown']:.2%}")
======================================================================
TOP 10 COMBINAISONS PAR SHARPE RATIO
======================================================================
 fast_period  slow_period  stop_loss  take_profit   sharpe  total_return  max_drawdown  n_trades  win_rate
          10           50       0.07         0.15 2.777658      0.344749     -0.020437         3       1.0
          10           50       0.05         0.15 2.777658      0.344749     -0.020437         3       1.0
          10           50       0.10         0.15 2.777658      0.344749     -0.020437         3       1.0
          10           50       0.03         0.15 2.777658      0.344749     -0.020437         3       1.0
           5           50       0.07         0.15 2.644630      0.326912     -0.028942         5       0.6
           5           50       0.05         0.15 2.644630      0.326912     -0.028942         5       0.6
           5           50       0.10         0.15 2.644630      0.326912     -0.028942         5       0.6
           5           50       0.03         0.15 2.644630      0.326912     -0.028942         5       0.6
          25           50       0.10         0.15 2.589893      0.314456     -0.034354         4       1.0
          25           50       0.03         0.15 2.589893      0.314456     -0.034354         4       1.0

Meilleure combinaison:
  Fast Period: 10.0
  Slow Period: 50.0
  Stop Loss: 7.00%
  Take Profit: 15.00%

  Sharpe Ratio: 2.78
  Total Return: 34.47%
  Max Drawdown: -2.04%

Le classement du top 10 se lit dans ses répétitions, pas dans sa tête

Regarder les lignes du top 10, pas seulement la première. Trois lectures qui changent le statut du « gagnant » SMA(10, 50, SL 7 %, TP 15 %) et de son Sharpe 2.78 :

1. La colonne stop_loss est inerte. Les lignes 1 à 4 du top 10 sont strictement identiques — fast 10, slow 50, TP 0.15 — et ne diffèrent QUE par stop_loss (0.03, 0.05, 0.07, 0.10). Même Sharpe (2.777658), même return (34.47 %), même drawdown (-2.04 %), même nombre de trades. L’explication est mécanique et se lit dans n_trades: 3 : avec trois transactions gagnantes (win_rate 1.0) et un take profit à 15 %, le stop loss n’a jamais été déclenché. Quatre valeurs testées, zéro information produite : la dimension entière est décorative sur ce jeu de données. C’est exactement la « dimension inerte » que la recherche aléatoire ignore gratuitement et que le grid paie 4× — la démonstration concrète de la partie précédente.

2. Le Sharpe 2.78 repose sur 3 trades. Un Sharpe annualisé n’a de sens que comme moyenne de nombreuses décisions. Trois transactions à 100 % de réussite, c’est un échantillon où presque tout est possible : l’intervalle de confiance sur le « vrai » Sharpe couvre largement le médiocre. Un classement qui met cette ligne en tête ne dit pas « cette combinaison est la meilleure », il dit « cette combinaison a eu le plus de chance sur 3 tirages ».

3. take_profit: 0.15 domine. Toutes les lignes du top 10 partagent TP = 0.15 — la valeur maximale testée. C’est un signal de bord d’espace : l’optimum n’est pas dans la grille testée, il est au-delà. Un optimum assis sur le bord d’une dimension demande d’étendre la grille dans cette direction avant de conclure quoi que ce soit — sinon on optimise la grille, pas la stratégie.

Les colonnes du classement : sharpe (return ajusté au risque), total_return (performance totale de la période), max_drawdown (perte maximale, valeur négative), n_trades (nombre de transactions — la significativité statistique), win_rate (pourcentage de trades gagnants). Et la règle qui domine tout le classement : les paramètres optimaux dépendent de la période et de l’instrument — toujours valider out-of-sample.

# Visualisation: Heatmap Sharpe par Fast/Slow (moyennee sur SL/TP)

pivot = results_df.pivot_table(
    values='sharpe',
    index='slow_period',
    columns='fast_period',
    aggfunc='mean'
)

plt.figure(figsize=(12, 8))
sns.heatmap(pivot, annot=True, fmt='.2f', cmap='RdYlGn', center=0,
            linewidths=0.5, cbar_kws={'label': 'Sharpe Ratio'})
plt.title('Sharpe Ratio Moyen par Combinaison Fast/Slow Period', fontsize=14, fontweight='bold')
plt.xlabel('Fast Period', fontsize=12)
plt.ylabel('Slow Period', fontsize=12)
plt.tight_layout()
plt.show()

Ce que la grille ne peut pas voir : 672 backtests, un seul passé

La sortie confirme 672/672 combinaisons valides — l’espace a été entièrement mesuré. Et pourtant ce dénominateur cache une limite structurelle : les 672 backtests partagent les mêmes données. Chaque combinaison est évaluée sur une seule réalisation de l’histoire ; la grille explore la dimension des paramètres, pas celle des scénarios de marché. Une combinaison peut dominer le classement parce qu’elle épouse le trajet précis du bruit de CE jeu de données — c’est précisément ce que la partie 4 (walk-forward : ré-optimiser puis re-tester sur des fenêtres successives) et la partie 6 (Monte Carlo : perturber les trajectoires) viennent casser, chacune sur une dimension différente.

À retenir aussi sur le budget : 672 backtests sur des données simulées prennent des secondes ; le même espace sur un backtest broker réaliste (slippage, frais, règles de liquidation) multiplie le coût par un facteur bien supérieur — la question du choix de méthode (grille exhaustive vs aléatoire vs bayésienne) n’est pas académique, elle est le premier poste de coût de tout cycle d’optimisation réel.

Lire la heatmap elle-même : lignes = slow period (50 à 200), colonnes = fast period (5 à 30), couleur = Sharpe moyen (vert bon, rouge mauvais) ; les cases fast > slow sont invalides et restent vides. Les patterns attendus : fast trop faible + slow trop élevé = signaux bruités (rouge) ; fast ≈ slow/3 à slow/5 = souvent la zone verte. C’est l’outil pour repérer les zones de robustesse dans l’espace des paramètres — les plateaux, pas les pics.

# Visualisation: Distribution des Sharpe Ratios

fig, axes = plt.subplots(1, 2, figsize=(14, 5))

# Histogramme
ax1 = axes[0]
ax1.hist(results_df['sharpe'], bins=30, color='steelblue', edgecolor='black', alpha=0.7)
ax1.axvline(x=results_df['sharpe'].mean(), color='red', linestyle='--', 
            label=f'Mean: {results_df["sharpe"].mean():.2f}')
ax1.axvline(x=results_df['sharpe'].median(), color='orange', linestyle='--',
            label=f'Median: {results_df["sharpe"].median():.2f}')
ax1.set_xlabel('Sharpe Ratio', fontsize=12)
ax1.set_ylabel('Frequence', fontsize=12)
ax1.set_title('Distribution des Sharpe Ratios', fontsize=14, fontweight='bold')
ax1.legend()
ax1.grid(True, alpha=0.3)

# Scatter: Sharpe vs Max Drawdown
ax2 = axes[1]
scatter = ax2.scatter(results_df['max_drawdown'] * 100, results_df['sharpe'],
                     c=results_df['total_return'] * 100, cmap='viridis',
                     alpha=0.6, s=50)
plt.colorbar(scatter, ax=ax2, label='Total Return (%)')
ax2.set_xlabel('Max Drawdown (%)', fontsize=12)
ax2.set_ylabel('Sharpe Ratio', fontsize=12)
ax2.set_title('Sharpe vs Max Drawdown', fontsize=14, fontweight='bold')
ax2.grid(True, alpha=0.3)

plt.tight_layout()
plt.show()


Interprétation : la distribution des Sharpe et le nuage Sharpe/drawdown

Visualisation des distributions de Sharpe Ratios:

Histogramme (gauche): - Distribution des Sharpe ratios pour toutes les combinaisons testées - Ligne rouge = moyenne, orange = médiane - Si la distribution est centrée sur des valeurs négatives = peu de combinaisons rentables

Scatter Sharpe vs Max DD (droite): - Chaque point = une combinaison de paramètres - Couleur = Total Return (viridis: jaune = haut, violet = bas) - Idéalement: Haut Sharpe + Faible Drawdown = coin supérieur gauche

Objectif: Identifier les combinaisons dans le coin supérieur gauche (Sharpe élevé, DD faible).



Exercice 1 : Grid search personnalise

Le grid search standard explore toutes les combinaisons, ce qui est couteux. Un grid search adaptatif explore les zones prometteuses en priorite.

Objectif : Implementer un grid search a deux niveaux (coarse-to-fine) sur les paramètres SMA.

Règles : - Niveau coarse : tester window de 5 a 50 par pas de 10, threshold de 0.001 a 0.01 par pas de 0.002 - Sélectionner les 3 meilleures configurations du niveau coarse - Niveau fine : explorer autour des 3 meilleures avec un pas divise par 5 - Utilisez la fonction backtest_sma_strategy() définie ci-dessus

Indices : - Indice : Stockez les résultats coarse dans un DataFrame, puis .nsmallest(3, 'sharpe') ou .nlargest(3, 'sharpe') - Indice : Pour le niveau fine, créez un nouvel espace de recherche centre sur chaque meilleure config

# Exercice 1 : Grid search personnalise (coarse-to-fine)
# TODO etudiant : Implementer un grid search a deux niveaux
# Indice : Niveau coarse (large) puis niveau fine (etroit) autour des meilleurs
# Etape 1 : Definir l'espace de recherche coarse
# Etape 2 : Executer le grid search coarse et collecter les resultats
# Etape 3 : Identifier les 3 meilleures configurations
# Etape 4 : Definir l'espace fine autour de chaque meilleure config
# Etape 5 : Executer le grid search fine et afficher les resultats finaux

result = None  # TODO etudiant : remplacer par le grid search coarse-to-fine
print("Exercice a completer")

Partie 4: Walk-Forward Analysis (25 min)

4.1 Concept Walk-Forward (10 min)

La Walk-Forward Analysis est une technique de validation qui simule comment une stratégie aurait ete optimisee et utilisee en temps reel.

Principe

  1. In-Sample (IS): Periode d’optimisation - on trouve les meilleurs paramètres
  2. Out-of-Sample (OOS): Periode de test - on utilise les paramètres trouves
  3. Rolling: On repete en avancant la fenêtre dans le temps
Timeline:
|-- IS_1 --|-- OOS_1 --|
           |-- IS_2 --|-- OOS_2 --|
                      |-- IS_3 --|-- OOS_3 --|

Avantages

  • Evite le lookahead bias
  • Simule l’utilisation reelle
  • Detecte l’instabilite des paramètres
  • Mesure la performance OOS realiste
# Visualisation du concept Walk-Forward

fig, ax = plt.subplots(figsize=(14, 4))

train_months = 12
test_months = 3

n_folds = 5
colors_is = plt.cm.Blues(np.linspace(0.4, 0.8, n_folds))
colors_oos = plt.cm.Oranges(np.linspace(0.4, 0.8, n_folds))

for i in range(n_folds):
    start = i * test_months
    
    # In-Sample
    ax.barh(i, train_months, left=start, height=0.6, color=colors_is[i], 
            edgecolor='black', label='In-Sample' if i == 0 else '')
    ax.text(start + train_months/2, i, 'IS', ha='center', va='center', fontweight='bold')
    
    # Out-of-Sample
    ax.barh(i, test_months, left=start + train_months, height=0.6, color=colors_oos[i],
            edgecolor='black', label='Out-of-Sample' if i == 0 else '')
    ax.text(start + train_months + test_months/2, i, 'OOS', ha='center', va='center', fontweight='bold')

ax.set_yticks(range(n_folds))
ax.set_yticklabels([f'Fold {i+1}' for i in range(n_folds)])
ax.set_xlabel('Mois', fontsize=12)
ax.set_title('Walk-Forward Analysis - Schema des Folds', fontsize=14, fontweight='bold')
ax.legend(loc='upper right')
ax.grid(True, alpha=0.3, axis='x')

plt.tight_layout()
plt.show()

print(f"Configuration: {train_months} mois IS + {test_months} mois OOS")
print(f"Chaque fold avance de {test_months} mois")

Configuration: 12 mois IS + 3 mois OOS
Chaque fold avance de 3 mois

4.2 Implementation Walk-Forward (15 min)

La walk-forward découpe l’historique en fenêtres : on optimise sur une fenêtre d’entraînement, on valide sur la suivante, puis on déroule le protocole le long du temps. Chaque fold rejoue la même séquence de décisions sur un horizon glissant, ce qui produit des répétitions indépendantes de la robustesse au lieu d’une mesure unique — et c’est de là que vient l’information, pas d’un score isolé.

def optimize_on_period(prices: pd.Series, 
                       fast_periods: List[int],
                       slow_periods: List[int],
                       fitness_func: str = 'sharpe') -> Tuple[Dict, float]:
    """
    Optimise les parametres sur une periode donnee.
    
    Args:
        prices: Serie de prix
        fast_periods: Liste des periodes fast a tester
        slow_periods: Liste des periodes slow a tester
        fitness_func: 'sharpe', 'return', 'calmar'
    
    Returns:
        Tuple (meilleurs_params, meilleur_score)
    """
    best_score = -np.inf
    best_params = None
    
    for fast in fast_periods:
        for slow in slow_periods:
            if fast >= slow:
                continue
            
            result = backtest_sma_strategy(prices, fast, slow)
            
            if not result['valid']:
                continue
            
            # Selectionner la metrique de fitness
            if fitness_func == 'sharpe':
                score = result['sharpe']
            elif fitness_func == 'return':
                score = result['total_return']
            elif fitness_func == 'calmar':
                score = result['total_return'] / abs(result['max_drawdown']) if result['max_drawdown'] != 0 else 0
            else:
                score = result['sharpe']
            
            if score > best_score:
                best_score = score
                best_params = {'fast_period': fast, 'slow_period': slow}
    
    return best_params, best_score

print("Fonction optimize_on_period() definie")
Fonction optimize_on_period() definie

On agrège les résultats d’optimisation de chaque fold pour identifier les combinaisons de paramètres les plus performantes de façon répétée — pas seulement une fois. La différence avec la partie 3 est décisive : le grid search rend un classement unique calculé sur tout le passé, la walk-forward rend un tableau de classements, un par fenêtre. Une combinaison peut gagner une fenêtre par chance et perdre les quinze autres ; celle qui apparaît près du sommet de plusieurs fenêtres différentes est plus digne de confiance, parce que sa performance ne dépend pas du découpage temporel précis. La lecture du tableau agrégé se fait donc en répétitions (fréquence d’apparition dans le haut du classement), pas en ligne unique.

def walk_forward_analysis(prices: pd.Series,
                          train_days: int = 252,
                          test_days: int = 63,
                          fast_periods: List[int] = None,
                          slow_periods: List[int] = None,
                          fitness_func: str = 'sharpe') -> pd.DataFrame:
    """
    Realise une Walk-Forward Analysis complete.
    
    Args:
        prices: Serie de prix complete
        train_days: Nombre de jours d'entrainement (IS)
        test_days: Nombre de jours de test (OOS)
        fast_periods: Periodes fast a tester
        slow_periods: Periodes slow a tester
        fitness_func: Fonction de fitness pour optimisation
    
    Returns:
        DataFrame avec resultats par fold
    """
    if fast_periods is None:
        fast_periods = [5, 10, 15, 20, 25]
    if slow_periods is None:
        slow_periods = [50, 75, 100, 125, 150]
    
    results = []
    fold = 0
    current_start = 0
    
    while current_start + train_days + test_days <= len(prices):
        # Definir les periodes
        train_end = current_start + train_days
        test_end = train_end + test_days
        
        # Donnees d'entrainement (In-Sample)
        train_prices = prices.iloc[current_start:train_end]
        
        # Optimiser sur IS
        best_params, is_score = optimize_on_period(
            train_prices, fast_periods, slow_periods, fitness_func
        )
        
        if best_params is None:
            current_start += test_days
            continue
        
        # Donnees de test (Out-of-Sample)
        test_prices = prices.iloc[train_end:test_end]
        
        # Tester avec les meilleurs parametres
        oos_result = backtest_sma_strategy(
            test_prices, 
            best_params['fast_period'], 
            best_params['slow_period']
        )
        
        if oos_result['valid']:
            results.append({
                'fold': fold,
                'train_start': prices.index[current_start],
                'train_end': prices.index[train_end - 1],
                'test_start': prices.index[train_end],
                'test_end': prices.index[test_end - 1],
                'fast_period': best_params['fast_period'],
                'slow_period': best_params['slow_period'],
                'is_sharpe': is_score,
                'oos_sharpe': oos_result['sharpe'],
                'oos_return': oos_result['total_return'],
                'oos_drawdown': oos_result['max_drawdown'],
                'oos_trades': oos_result['n_trades']
            })
        
        # Avancer la fenetre
        current_start += test_days
        fold += 1
    
    return pd.DataFrame(results)

print("Fonction walk_forward_analysis() definie")
Fonction walk_forward_analysis() definie

Interprétation : la boucle walk-forward — optimiser IS, tester OOS, avancer

Fonction walk_forward_analysis():

Boucle principale: Avance la fenêtre de test_days à chaque itération 1. Optimisation sur IS: Trouve les meilleurs paramètres pour la période d’entraînement 2. Test sur OOS: Applique ces paramètres à la période de test suivante 3. Stockage: Sauvegarde les résultats de chaque fold

Paramètres clés: - train_days: Taille de la fenêtre d’entraînement (typiquement 1-2 ans) - test_days: Taille de la fenêtre de test (typiquement 1-3 mois) - fitness_func: Métrique utilisée pour l’optimisation (sharpe/return/calm ar)

Output: DataFrame avec une ligne par fold + métriques IS/OOS.


# Generer plus de donnees pour le Walk-Forward
np.random.seed(42)
n_days_wf = 1260  # ~5 ans
dates_wf = pd.date_range('2019-01-01', periods=n_days_wf, freq='B')

# Prix avec tendance et cycles
trend_wf = np.linspace(100, 180, n_days_wf)
# Ajouter cycles saisonniers
cycle = 10 * np.sin(np.linspace(0, 10 * np.pi, n_days_wf))
noise_wf = np.cumsum(np.random.randn(n_days_wf) * 0.5)
prices_wf = pd.Series(trend_wf + cycle + noise_wf, index=dates_wf)

print("Execution de la Walk-Forward Analysis...")
print(f"Periode totale: {dates_wf[0].date()} - {dates_wf[-1].date()}")
print(f"Configuration: 252 jours IS + 63 jours OOS")

wf_results = walk_forward_analysis(
    prices_wf,
    train_days=252,   # 1 an d'entrainement
    test_days=63,     # 3 mois de test
    fast_periods=[5, 10, 15, 20, 25, 30],
    slow_periods=[50, 75, 100, 125, 150, 175, 200],
    fitness_func='sharpe'
)

print(f"\nNombre de folds: {len(wf_results)}")
Execution de la Walk-Forward Analysis...
Periode totale: 2019-01-01 - 2023-10-30
Configuration: 252 jours IS + 63 jours OOS

Nombre de folds: 16

Interprétation : les données simulées pour 16 folds glissants

Génération de données sur ~5 ans pour la Walk-Forward Analysis:

  • Prix avec tendance: Lineaire croissante (100 → 180)
  • Cycle saisonnier: Sinusoïde superposée pour simuler des patterns
  • Bruit aléatoire: Random walk pour la réalisme

Configuration: 252 jours IS (1 an) + 63 jours OOS (3 mois) = 16 folds glissants (mesuré en sortie)

La Walk-Forward sur ces données simulées démontre la méthodologie. En pratique, utiliser des données historiques réelles avec plus de folds pour une validation robuste.


# Afficher les resultats Walk-Forward
print("="*80)
print("RESULTATS WALK-FORWARD ANALYSIS")
print("="*80)

# Afficher le tableau
display_cols = ['fold', 'test_start', 'test_end', 'fast_period', 'slow_period', 
                'is_sharpe', 'oos_sharpe', 'oos_return']
print(wf_results[display_cols].to_string(index=False))

# Statistiques agregees
print("\n" + "="*80)
print("STATISTIQUES AGREGEES")
print("="*80)
print(f"\n{'Metrique':<30} {'In-Sample':>15} {'Out-of-Sample':>15}")
print("-"*60)
print(f"{'Sharpe Moyen':<30} {wf_results['is_sharpe'].mean():>15.2f} {wf_results['oos_sharpe'].mean():>15.2f}")
print(f"{'Sharpe Median':<30} {wf_results['is_sharpe'].median():>15.2f} {wf_results['oos_sharpe'].median():>15.2f}")
print(f"{'Sharpe Std':<30} {wf_results['is_sharpe'].std():>15.2f} {wf_results['oos_sharpe'].std():>15.2f}")
print(f"{'Return Moyen OOS':<30} {'-':>15} {wf_results['oos_return'].mean()*100:>14.2f}%")

# Ratio IS/OOS (mesure d'overfitting)
efficiency_ratio = wf_results['oos_sharpe'].mean() / wf_results['is_sharpe'].mean()
print(f"\nWalk-Forward Efficiency Ratio: {efficiency_ratio:.2%}")
print("(Ratio OOS/IS - plus proche de 100% = meilleur)")
================================================================================
RESULTATS WALK-FORWARD ANALYSIS
================================================================================
 fold test_start   test_end  fast_period  slow_period  is_sharpe  oos_sharpe  oos_return
    0 2019-12-19 2020-03-16            5          200   6.721699         0.0         0.0
    1 2020-03-17 2020-06-11           30          150   5.070202         0.0         0.0
    2 2020-06-12 2020-09-08           30           75   3.385565         0.0         0.0
    3 2020-09-09 2020-12-04           25           50   1.213230         0.0         0.0
    4 2020-12-07 2021-03-03           15          200   5.461297         0.0         0.0
    5 2021-03-04 2021-05-31           25          175   6.302790         0.0         0.0
    6 2021-06-01 2021-08-26           25          100   4.109186         0.0         0.0
    7 2021-08-27 2021-11-23            5          100   0.000000         0.0         0.0
    8 2021-11-24 2022-02-18           20           50   3.263820         0.0         0.0
    9 2022-02-21 2022-05-18           20          175   7.247098         0.0         0.0
   10 2022-05-19 2022-08-15           30          100   4.418981         0.0         0.0
   11 2022-08-16 2022-11-10            5          150   1.571810         0.0         0.0
   12 2022-11-11 2023-02-07           15          150   5.999301         0.0         0.0
   13 2023-02-08 2023-05-05           10          125   5.964232         0.0         0.0
   14 2023-05-08 2023-08-02           10           75   4.847080         0.0         0.0
   15 2023-08-03 2023-10-30            5           75   0.000000         0.0         0.0

================================================================================
STATISTIQUES AGREGEES
================================================================================

Metrique                             In-Sample   Out-of-Sample
------------------------------------------------------------
Sharpe Moyen                              4.10            0.00
Sharpe Median                             4.63            0.00
Sharpe Std                                2.33            0.00
Return Moyen OOS                             -           0.00%

Walk-Forward Efficiency Ratio: 0.00%
(Ratio OOS/IS - plus proche de 100% = meilleur)

Le résultat central du notebook : 16 folds, 16 fois zéro

La sortie du tableau walk-forward est le résultat le plus important de ce notebook — plus instructif que n’importe quel score élevé. 16 folds, et pour chacun : oos_sharpe: 0.0, oos_return: 0.0. En face, les scores In-Sample des paramètres optimisés par fenêtre s’étalent de 0.0 à 7.25 (statistiques agrégées : moyenne 4.10, médiane 4.63, écart-type 2.33). Le Walk-Forward Efficiency Ratio: 0.00 % — la dégradation IS→OOS est totale, pas partielle.

La cause est structurelle et se lit dans le code de la fonction : chaque fold re-backteste les paramètres optimaux IS sur sa fenêtre de test seule (63 jours). Or les paramètres choisis IS ont des moyennes lentes de 50 à 200 jours — pour la plupart des folds, la fenêtre de test de 63 jours est plus courte que la moyenne lente elle-même : le croisement ne peut littéralement pas se produire, la stratégie ne prend aucune position, et le Sharpe OOS vaut 0 par construction. Ce n’est pas un bug numérique, c’est une incompatibilité d’horizons : on optimise des paramètres longs sur un an, puis on les teste sur un trimestre.

La leçon de design dépasse ce notebook : une fenêtre de test doit être compatible avec l’horizon des paramètres qu’on prétend valider. Et la leçon de méthode est la contrepartie positive : la walk-forward a fait exactement son travail. Un score IS de 7.25 qui produit zéro transaction hors échantillon est démasqué par le premier fold — là où une validation unique bien choisie (la partie 7 montrera un OOS à 2.93 sur un split différent) aurait pu laisser croire que la stratégie marche. Un outil qui répond « 0.00 % » quand tout le reste crie « 4.10 » n’est pas en panne : c’est le seul qui dit la vérité.

Repères de lecture du tableau : le Walk-Forward Efficiency Ratio (OOS/IS) se lit par tranches — proche de 100 % = pas de dégradation IS→OOS ; < 50 % = forte dégradation, overfitting probable ; > 100 % = rare, souvent un biais. La corrélation IS-OOS mesure la prévisibilité des paramètres : élevée (> 0.5), les bons paramètres IS restent bons OOS — quand elle est définie (la lecture suivante explique pourquoi elle vaut nan ici).

# Visualisation Walk-Forward

fig, axes = plt.subplots(2, 2, figsize=(14, 10))

# Plot 1: IS vs OOS Sharpe par fold
ax1 = axes[0, 0]
x = np.arange(len(wf_results))
width = 0.35
ax1.bar(x - width/2, wf_results['is_sharpe'], width, label='In-Sample', color='steelblue')
ax1.bar(x + width/2, wf_results['oos_sharpe'], width, label='Out-of-Sample', color='coral')
ax1.axhline(y=0, color='black', linestyle='-', linewidth=0.5)
ax1.set_xlabel('Fold', fontsize=12)
ax1.set_ylabel('Sharpe Ratio', fontsize=12)
ax1.set_title('Sharpe Ratio: IS vs OOS par Fold', fontsize=14, fontweight='bold')
ax1.set_xticks(x)
ax1.set_xticklabels([f'F{i}' for i in range(len(wf_results))])
ax1.legend()
ax1.grid(True, alpha=0.3)

# Plot 2: Parametres optimaux par fold
ax2 = axes[0, 1]
ax2.plot(x, wf_results['fast_period'], 'o-', label='Fast Period', color='green', markersize=8)
ax2.plot(x, wf_results['slow_period'], 's-', label='Slow Period', color='purple', markersize=8)
ax2.set_xlabel('Fold', fontsize=12)
ax2.set_ylabel('Periode', fontsize=12)
ax2.set_title('Parametres Optimaux par Fold', fontsize=14, fontweight='bold')
ax2.set_xticks(x)
ax2.set_xticklabels([f'F{i}' for i in range(len(wf_results))])
ax2.legend()
ax2.grid(True, alpha=0.3)

# Plot 3: OOS Returns cumules
ax3 = axes[1, 0]
cumulative_return = (1 + wf_results['oos_return']).cumprod() - 1
ax3.plot(wf_results['test_end'], cumulative_return * 100, 'o-', color='navy', linewidth=2, markersize=8)
ax3.axhline(y=0, color='red', linestyle='--', linewidth=1)
ax3.fill_between(wf_results['test_end'], 0, cumulative_return * 100,
                 where=cumulative_return >= 0, color='green', alpha=0.3)
ax3.fill_between(wf_results['test_end'], 0, cumulative_return * 100,
                 where=cumulative_return < 0, color='red', alpha=0.3)
ax3.set_xlabel('Date', fontsize=12)
ax3.set_ylabel('Return Cumulatif (%)', fontsize=12)
ax3.set_title('Returns OOS Cumules', fontsize=14, fontweight='bold')
ax3.grid(True, alpha=0.3)
plt.setp(ax3.xaxis.get_majorticklabels(), rotation=45, ha='right')

# Plot 4: Scatter IS vs OOS
ax4 = axes[1, 1]
ax4.scatter(wf_results['is_sharpe'], wf_results['oos_sharpe'], s=100, alpha=0.7, c='navy')
# Ligne de regression
z = np.polyfit(wf_results['is_sharpe'], wf_results['oos_sharpe'], 1)
p = np.poly1d(z)
x_line = np.linspace(wf_results['is_sharpe'].min(), wf_results['is_sharpe'].max(), 100)
ax4.plot(x_line, p(x_line), 'r--', linewidth=2, label=f'Regression')
# Ligne identite
ax4.plot([0, wf_results['is_sharpe'].max()], [0, wf_results['is_sharpe'].max()], 
         'g--', linewidth=1, alpha=0.5, label='y=x (no overfitting)')
ax4.set_xlabel('In-Sample Sharpe', fontsize=12)
ax4.set_ylabel('Out-of-Sample Sharpe', fontsize=12)
ax4.set_title('IS vs OOS Sharpe Correlation', fontsize=14, fontweight='bold')
ax4.legend()
ax4.grid(True, alpha=0.3)

plt.tight_layout()
plt.show()

# Correlation IS-OOS
correlation = wf_results['is_sharpe'].corr(wf_results['oos_sharpe'])
print(f"Correlation IS-OOS: {correlation:.2f}")
print("(Correlation elevee = bonne previsibilite des parametres)")

Correlation IS-OOS: nan
(Correlation elevee = bonne previsibilite des parametres)

Pourquoi la corrélation vaut nan : la mécanique d’un Pearson dégénéré

La sortie imprime Correlation IS-OOS: nan avec la légende « corrélation élevée = bonne prévisibilité ». Ce nan n’est pas une erreur de calcul : c’est la valeur mathématiquement correcte d’un coefficient de corrélation de Pearson quand l’une des deux séries a une variance nulle. La série OOS vaut [0.0, 0.0, ..., 0.0] sur les 16 folds — une constante. Or Pearson mesure la covariation normalisée par le produit des écarts-types ; avec un écart-type nul, le dénominateur vaut zéro et le rapport est indéfini : NumPy le rend nan plutôt qu’infini.

La lecture correcte est donc plus forte que la légende ne le suggère : « corrélation nan » ne veut pas dire « corrélation faible » ni « donnée manquante ». Ça veut dire la question de la prévisibilité n’a pas de réponse sur ces données, parce que la performance OOS n’a aucune variation à expliquer — et la raison en a été donnée par le tableau précédent (aucun signal possible dans les fenêtres de test). Ce cas arrive chaque fois qu’une métrique dégénère (score constant, écart-type nul) : la discipline est de diagnosticer la dégénérescence plutôt que de lire le symbole comme un score. Le piège est assez fréquent pour qu’on le retrouve à la partie suivante : le détecteur d’overfitting de la partie 5 affichera lui aussi [OK] Low Is Oos Correlation sur cette même valeur nan — une garde mal écrite qui transforme l’absence de mesure en feu vert.

Les quatre panneaux de la figure : (1) barres IS vs OOS par fold — l’écart entre barres est la dégradation ; (2) paramètres optimaux par fold — leur stabilité dans le temps (des valeurs qui changent radicalement = paramètres instables) ; (3) returns OOS cumulés — zone verte gains, rouge pertes ; (4) scatter IS/OOS avec la ligne verte y=x — un fold sur la diagonale ne dégrade pas, la ligne rouge est la régression.


Exercice 2 : Ratio d’efficacite Walk-Forward

Le ratio d’efficacite compare la performance hors-echantillon a la performance en-echantillon. Un ratio > 0.5 indique une bonne robustesse.

Objectif : Calculer le ratio d’efficacite moyen de la walk-forward analysis.

Règles : - Pour chaque fold de la walk-forward, calculez : efficiency = sharpe_OOS / sharpe_IS (si sharpe_IS > 0) - Calculez le ratio moyen sur tous les folds - Un ratio > 0.5 = modèle robuste, < 0.3 = surapprentissage probable

Indices : - Indice : Parcourez les résultats de walk_forward_results (variable définie ci-dessus) - Indice : Protegez contre la division par zero avec max(sharpe_IS, 1e-6)

# Exercice 2 : Ratio d'efficacite Walk-Forward
# TODO etudiant : Calculer le ratio d'efficacite moyen de la WFA
# Indice : Parcourez les folds et comparez sharpe_OOS / sharpe_IS
# Etape 1 : Identifier les colonnes IS et OOS dans les resultats WFA
# Etape 2 : Calculer le ratio pour chaque fold
# Etape 3 : Calculer et interpreter le ratio moyen

result = None  # TODO etudiant : remplacer par le calcul du ratio d'efficacite
print("Exercice a completer")

Partie 5: Eviter l’Overfitting (15 min)

5.1 Signes d’Overfitting (8 min)

Comment detecter si votre stratégie est overfittee?

def detect_overfitting_signs(wf_results: pd.DataFrame) -> Dict[str, bool]:
    """
    Detecte les signes d'overfitting dans les resultats Walk-Forward.
    
    Returns:
        Dict avec les indicateurs d'overfitting
    """
    signs = {}
    
    # 1. IS >> OOS performance
    mean_is = wf_results['is_sharpe'].mean()
    mean_oos = wf_results['oos_sharpe'].mean()
    degradation = (mean_is - mean_oos) / mean_is if mean_is > 0 else 0
    signs['is_much_better_than_oos'] = degradation > 0.5  # Plus de 50% de degradation
    
    # 2. Parametres instables (variance elevee)
    fast_stability = wf_results['fast_period'].std() / wf_results['fast_period'].mean()
    slow_stability = wf_results['slow_period'].std() / wf_results['slow_period'].mean()
    signs['unstable_parameters'] = fast_stability > 0.3 or slow_stability > 0.3
    
    # 3. Faible correlation IS-OOS
    correlation = wf_results['is_sharpe'].corr(wf_results['oos_sharpe'])
    signs['low_is_oos_correlation'] = correlation < 0.3
    
    # 4. Performance OOS negative
    signs['negative_oos_sharpe'] = mean_oos < 0
    
    # 5. Grande variance OOS
    oos_cv = wf_results['oos_sharpe'].std() / abs(mean_oos) if mean_oos != 0 else np.inf
    signs['high_oos_variance'] = oos_cv > 2.0
    
    return signs, {
        'is_oos_degradation': degradation,
        'fast_stability_cv': fast_stability,
        'slow_stability_cv': slow_stability,
        'is_oos_correlation': correlation,
        'mean_oos_sharpe': mean_oos,
        'oos_coefficient_variation': oos_cv
    }

# Analyser nos resultats
overfitting_signs, metrics = detect_overfitting_signs(wf_results)

print("="*60)
print("ANALYSE D'OVERFITTING")
print("="*60)

print("\nMetriques:")
for key, value in metrics.items():
    print(f"  - {key}: {value:.2f}")

print("\nSignes d'overfitting detectes:")
n_warnings = 0
for sign, detected in overfitting_signs.items():
    status = "[WARNING]" if detected else "[OK]"
    print(f"  {status} {sign.replace('_', ' ').title()}")
    if detected:
        n_warnings += 1

print(f"\nConclusion: {n_warnings}/5 signes d'overfitting detectes")
if n_warnings >= 3:
    print("ATTENTION: Risque eleve d'overfitting!")
elif n_warnings >= 1:
    print("Prudence: Quelques signes d'overfitting presents.")
else:
    print("Bon: Peu de signes d'overfitting.")
============================================================
ANALYSE D'OVERFITTING
============================================================

Metriques:
  - is_oos_degradation: 1.00
  - fast_stability_cv: 0.56
  - slow_stability_cv: 0.42
  - is_oos_correlation: nan
  - mean_oos_sharpe: 0.00
  - oos_coefficient_variation: inf

Signes d'overfitting detectes:
  [WARNING] Is Much Better Than Oos
  [WARNING] Unstable Parameters
  [OK] Low Is Oos Correlation
  [OK] Negative Oos Sharpe
  [WARNING] High Oos Variance

Conclusion: 3/5 signes d'overfitting detectes
ATTENTION: Risque eleve d'overfitting!

Auditer le détecteur : 3/5 signes, dont deux affichés à contre-sens

Le détecteur conclut « 3/5 signes d’overfitting » avec trois [WARNING] (IS ≫ OOS, paramètres instables, variance OOS élevée) et deux [OK]. Mais un détecteur automatique se lit comme un code — en vérifiant ce que chaque verdict a réellement mesuré :

  • [OK] Low Is Oos Correlation : la corrélation vaut nan (partie précédente). Le détecteur teste vraisemblablement corr < seuil et nan < seuil est faux… ou corr is not None and corr < seuil — dans les deux cas, l’absence de mesure est classée comme acceptable. Un « OK » posé sur un nan n’est pas une validation : c’est un aveu que le test n’a pas pu s’exécuter, travesti en feu vert. Le signe d’alerte réel était dans les métriques brutes au-dessus : is_oos_correlation: nan, oos_coefficient_variation: inf.
  • [WARNING] High Oos Variance : la variation vaut inf parce que la moyenne OOS est 0.00 — un coefficient de variation (σ/μ) avec μ nul est infini, quelle que soit σ. Le warning est techniquement vrai et substantiellement vide : il détecte la dégénérescence, pas la variance.
  • [OK] Negative Oos Sharpe : le Sharpe OOS vaut 0.0, ni négatif ni positif — un troisième verdict rendu sur une valeur dégénérée.

Bilan : les deux verdicts « conformes » (WARNING IS≫OOS avec is_oos_degradation: 1.00, et paramètres instables avec des CV de 0.56/0.42 — les paramètres optimaux bougent de moitié entre fenêtres) sont les signaux vrais. La conclusion « risque élevé » est correcte, mais elle est portée par 2 signaux réels, pas 3. Règle générale pour tout détecteur : lire les métriques brutes avant les verdicts, et traiter tout nan/inf comme un échec de mesure à diagnostiquer — jamais comme un score favorable.

5.2 Techniques Anti-Overfitting (7 min)

Technique Description Implementation
Reduire les paramètres Moins de degrés de liberte Fixer certains paramètres
Bounds raisonnables Limiter l’espace de recherche Min/max realistes
Cross-validation temporelle Valider sur plusieurs periodes Walk-Forward Analysis
Penaliser la complexite Favoriser la simplicite Ajouter regularisation
Monte Carlo Tester la robustesse Permutations aleatoires
# Technique 1: Penalite de complexite dans la fitness function

def fitness_with_penalty(result: Dict, n_params: int) -> float:
    """
    Calcule une fitness penalisee par le nombre de parametres.
    
    Utilise le critere d'information d'Akaike (AIC-like).
    """
    if not result['valid']:
        return -np.inf
    
    sharpe = result['sharpe']
    n_trades = result['n_trades']
    
    # Penalite basee sur le nombre de parametres
    # Plus de parametres = penalite plus forte
    penalty = 0.1 * n_params
    
    # Bonus pour plus de trades (evidence statistique)
    trade_bonus = 0.01 * np.log(n_trades + 1)
    
    fitness = sharpe - penalty + trade_bonus
    
    return fitness

# Demonstration
print("Fitness avec penalite de complexite:")
print("="*50)

# Simuler deux strategies
strategy_simple = {'valid': True, 'sharpe': 1.2, 'n_trades': 50}
strategy_complex = {'valid': True, 'sharpe': 1.4, 'n_trades': 50}

fitness_simple = fitness_with_penalty(strategy_simple, n_params=2)   # 2 parametres
fitness_complex = fitness_with_penalty(strategy_complex, n_params=6)  # 6 parametres

print(f"\nStrategie Simple (2 params):")
print(f"  Sharpe brut: {strategy_simple['sharpe']:.2f}")
print(f"  Fitness penalisee: {fitness_simple:.2f}")

print(f"\nStrategie Complexe (6 params):")
print(f"  Sharpe brut: {strategy_complex['sharpe']:.2f}")
print(f"  Fitness penalisee: {fitness_complex:.2f}")

print(f"\n-> La strategie {'simple' if fitness_simple > fitness_complex else 'complexe'} est preferee apres penalite!")
Fitness avec penalite de complexite:
==================================================

Strategie Simple (2 params):
  Sharpe brut: 1.20
  Fitness penalisee: 1.04

Strategie Complexe (6 params):
  Sharpe brut: 1.40
  Fitness penalisee: 0.84

-> La strategie simple est preferee apres penalite!

La pénalité de complexité : faire payer les paramètres à l’entrée, pas à la sortie

La sortie donne l’exemple canonique : stratégie simple (2 paramètres) Sharpe brut 1.20 → fitness pénalisée 1.04 ; stratégie complexe (6 paramètres) Sharpe brut 1.40 → fitness pénalisée 0.84. Le classement s’inverse : la simple gagne après pénalité alors qu’elle perdait avant. C’est le principe de parcimonie (typiquement formalisé en AIC/BIC : chaque paramètre supplémentaire paie un péage logarithmique) appliqué au fitness d’optimisation — au lieu de détecter l’overfitting après coup (walk-forward), on le taxe pendant la recherche.

Pourquoi c’est plus qu’une astuce : un paramètre supplémentaire est un degré de liberté pour épouser le bruit. La stratégie complexe affiche +0.20 de Sharpe brut de plus — sur les données IS, où elle a exactement les degrés de liberté qu’il faut pour le faire. La pénalité rend cette tricherie non-rentable : elle demande à chaque paramètre de « rapporter » plus que ce qu’il coûte en risque d’artefact. Notez la sensibilité du résultat au coefficient de pénalité : trop faible, la complexe gagne quand même ; trop fort, toute stratégie non triviale est éliminée. Le choix de ce coefficient est lui-même un paramètre — le notebook le met en exercice (exercice 3) plutôt qu’en réglage caché.

On visualise la surface de performance en fonction des paramètres pour détecter les zones optimales. La forme de cette surface est l’information la plus précieuse de tout le notebook : un optimum n’est pas seulement une position, c’est un relief. Un pic aigu signifie qu’un léger changement des paramètres effondre la performance — c’est un point fragile, choisi par le bruit. Un plateau signifie que de nombreuses combinaisons voisines donnent la même performance raisonnable : n’importe laquelle est robuste. La technique 2 qui suit formalise ce test de sensibilité : perturber chaque paramètre optimal d’un pas et mesurer la variance du score. Un paramètre dont le score s’effondre à la moindre variation est un paramètre que l’optimisation a appris, pas découvert.

# Technique 2: Test de robustesse par variation des parametres

def parameter_sensitivity_test(prices: pd.Series, 
                              base_params: Dict,
                              variation_pct: float = 0.10) -> pd.DataFrame:
    """
    Teste la sensibilite de la performance aux variations de parametres.
    
    Si la performance chute drastiquement avec +/-10% sur les parametres,
    c'est un signe d'overfitting.
    """
    results = []
    
    fast = base_params['fast_period']
    slow = base_params['slow_period']
    
    # Variations
    variations = [-variation_pct, 0, variation_pct]
    
    for fast_var in variations:
        for slow_var in variations:
            test_fast = int(fast * (1 + fast_var))
            test_slow = int(slow * (1 + slow_var))
            
            if test_fast >= test_slow:
                continue
            
            result = backtest_sma_strategy(prices, test_fast, test_slow)
            
            if result['valid']:
                results.append({
                    'fast_var': f"{fast_var:+.0%}",
                    'slow_var': f"{slow_var:+.0%}",
                    'fast_period': test_fast,
                    'slow_period': test_slow,
                    'sharpe': result['sharpe'],
                    'return': result['total_return']
                })
    
    return pd.DataFrame(results)

# Tester la sensibilite des meilleurs parametres
best_params = {'fast_period': 15, 'slow_period': 100}

sensitivity_df = parameter_sensitivity_test(prices_wf, best_params, variation_pct=0.15)

print("Test de sensibilite des parametres (+/-15%):")
print("="*60)
print(sensitivity_df.to_string(index=False))

# Analyser la stabilite
sharpe_std = sensitivity_df['sharpe'].std()
sharpe_mean = sensitivity_df['sharpe'].mean()
sharpe_cv = sharpe_std / sharpe_mean if sharpe_mean != 0 else np.inf

print(f"\nStabilite:")
print(f"  Sharpe moyen: {sharpe_mean:.2f}")
print(f"  Sharpe std: {sharpe_std:.2f}")
print(f"  Coefficient de variation: {sharpe_cv:.2%}")

if sharpe_cv < 0.20:
    print("\n-> Parametres ROBUSTES (CV < 20%)")
elif sharpe_cv < 0.50:
    print("\n-> Parametres MODEREMENT SENSIBLES (20% < CV < 50%)")
else:
    print("\n-> Parametres TRES SENSIBLES - Risque d'overfitting (CV > 50%)")
Test de sensibilite des parametres (+/-15%):
============================================================
fast_var slow_var  fast_period  slow_period   sharpe   return
    -15%     -15%           12           85 3.084884 0.553035
    -15%      +0%           12          100 3.293207 0.556531
    -15%     +15%           12          114 3.410309 0.562745
     +0%     -15%           15           85 3.183006 0.565503
     +0%      +0%           15          100 3.294369 0.563180
     +0%     +15%           15          114 3.394176 0.554518
    +15%     -15%           17           85 3.160751 0.556626
    +15%      +0%           17          100 3.170115 0.554731
    +15%     +15%           17          114 3.366851 0.554556

Stabilite:
  Sharpe moyen: 3.26
  Sharpe std: 0.12
  Coefficient de variation: 3.57%

-> Parametres ROBUSTES (CV < 20%)

Plateau ou pic : pourquoi la forme de la surface compte plus que son sommet

Le test de sensibilité applique ±15 % aux paramètres optimaux et backteste chaque variante. La question que cette sortie doit répondre n’est pas « quel Sharpe chaque variante obtient-elle » mais « quelle est la forme de la surface autour de l’optimum ». Deux formes possibles, deux statuts totalement différents pour la même valeur de sommet :

  • Un plateau : les variants ±15 % performent presque aussi bien. L’optimum est robuste — si le marché dévie légèrement des conditions calibrées, la stratégie reste dans la zone de bonne performance. C’est la signature d’un vrai signal structurel.
  • Un pic : les variants s’effondrent. L’optimum est une aiguille — la combinaison exacte fast=10, slow=50 (et pas 9/50, et pas 10/55) porte toute la performance. Une aiguille dans l’espace des paramètres est presque toujours un artefact : la probabilité que le « vrai » mécanisme du marché exige précisément ces valeurs entières est très faible, alors que la probabilité que 672 essais trouvent par chance une aiguille dans le bruit est élevée — c’est un birthday problem.

La sortie affiche le tableau des variants ; la lecture à faire est l’écart max entre variants. Un critère pratique : si ±15 % de paramètres détruisent plus de la moitié du Sharpe, l’optimum n’a pas de zone — il n’a qu’un point, et un point n’est pas déployable en production (le premier slippage ou le premier changement de régime le déplacera hors de sa zone… qui n’existe pas). C’est aussi la raison pour laquelle les rapport de production exigent des surfaces, pas des points.

Le coefficient de variation (CV = std/mean) des variants se lit par tranches : < 20 % = paramètres robustes ; 20-50 % = modérément sensibles ; > 50 % = très sensibles, risque d’overfitting. C’est la version quantitative du test plateau/pic : si une petite variation de paramètres cause une chute de performance, les paramètres sont trop spécifiques aux données historiques.


Exercice 3 : Penalisation de la complexite

La validation croisee brute favorise parfois les modèles les plus complexes. L’information criterion (AIC/BIC) penalise le nombre de paramètres.

Ancre savante – Akaike (1974), « A New Look at the Statistical Model Identification », IEEE Transactions on Automatic Control 19(6), 716-723 (AIC : critère d’information pénalisant la complexité pour la sélection de modèles).

Objectif : Implementer un score de fitness penalise qui combine la performance Sharpe et un terme de complexite.

Règles : - Utilisez le résultat du grid search précédent (dictionnaire results avec colonnes sharpe, lookback, hold) - Le score penalise = sharpe - lambda * log(n_params) ou n_params = lookback * hold et lambda = 0.05 - Affichez les 5 meilleures configurations selon ce score penalise

Indices : - # Indice : np.log() pour le terme de penalisation - # Indice : Triez avec .sort_values() sur la colonne du score penalise

# Exercice 3 : Penalisation de la complexite
# TODO etudiant : Implementer un score penalise combinant Sharpe et complexite
# Indice : Ajoutez une colonne 'penalized_score' au DataFrame results
# Indice : n_params = lookback * hold, lambda = 0.05
# Etape 1 : Calculer le nombre de parametres pour chaque configuration
# Etape 2 : Appliquer la penalisation logarithmique
# Etape 3 : Trier et afficher les 5 meilleures configurations

result = None  # TODO etudiant : remplacer par le calcul du score penalise
print("Exercice a completer")

Partie 6: Optimisation Avancee (20 min)

6.1 Optimization Objectives (10 min)

Les fonctions de fitness multi-objectif permettent d’equilibrer plusieurs critères.

def multi_objective_fitness(result: Dict,
                           sharpe_weight: float = 0.5,
                           drawdown_weight: float = 0.3,
                           trades_weight: float = 0.2) -> float:
    """
    Fonction de fitness multi-objectif.
    
    Combine:
    - Sharpe Ratio (a maximiser)
    - Max Drawdown (a minimiser)
    - Nombre de trades (penalise si trop peu ou trop)
    
    Args:
        result: Resultats du backtest
        sharpe_weight: Poids du Sharpe
        drawdown_weight: Poids du drawdown
        trades_weight: Poids des trades
    
    Returns:
        Score de fitness combine
    """
    if not result['valid']:
        return -np.inf
    
    sharpe = result['sharpe']
    max_dd = abs(result['max_drawdown'])
    n_trades = result['n_trades']
    
    # Normaliser Sharpe (typiquement entre -2 et 3)
    sharpe_score = np.clip(sharpe / 2, -1, 1.5)  # Normalise autour de 0-1
    
    # Normaliser Drawdown (0% = parfait, 30%+ = mauvais)
    dd_score = 1 - np.clip(max_dd / 0.30, 0, 1)  # 1 si 0%, 0 si 30%+
    
    # Score trades (optimal: 20-100 trades, penaliser < 10 ou > 200)
    if n_trades < 10:
        trade_score = n_trades / 10
    elif n_trades > 200:
        trade_score = max(0, 1 - (n_trades - 200) / 200)
    else:
        trade_score = 1.0
    
    # Combiner
    fitness = (sharpe_weight * sharpe_score + 
               drawdown_weight * dd_score + 
               trades_weight * trade_score)
    
    return fitness

# Demonstration
print("Fitness Multi-Objectif:")
print("="*60)

test_results = [
    {'name': 'High Sharpe, High DD', 'valid': True, 'sharpe': 2.0, 'max_drawdown': -0.35, 'n_trades': 50},
    {'name': 'Medium Sharpe, Low DD', 'valid': True, 'sharpe': 1.2, 'max_drawdown': -0.10, 'n_trades': 50},
    {'name': 'High Sharpe, Few Trades', 'valid': True, 'sharpe': 2.0, 'max_drawdown': -0.15, 'n_trades': 5},
    {'name': 'Balanced', 'valid': True, 'sharpe': 1.5, 'max_drawdown': -0.12, 'n_trades': 60},
]

for res in test_results:
    fitness = multi_objective_fitness(res)
    print(f"\n{res['name']}:")
    print(f"  Sharpe: {res['sharpe']:.2f}, DD: {res['max_drawdown']:.2%}, Trades: {res['n_trades']}")
    print(f"  Fitness Score: {fitness:.3f}")
Fitness Multi-Objectif:
============================================================

High Sharpe, High DD:
  Sharpe: 2.00, DD: -35.00%, Trades: 50
  Fitness Score: 0.700

Medium Sharpe, Low DD:
  Sharpe: 1.20, DD: -10.00%, Trades: 50
  Fitness Score: 0.700

High Sharpe, Few Trades:
  Sharpe: 2.00, DD: -15.00%, Trades: 5
  Fitness Score: 0.750

Balanced:
  Sharpe: 1.50, DD: -12.00%, Trades: 60
  Fitness Score: 0.755

On évalue la robustesse des paramètres optimaux en testant leur stabilité sur différentes périodes de marché. La question n’est pas « cette combinaison est-elle bonne sur l’échantillon qui l’a rendue bonne ? » mais « reste-t-elle raisonnable quand le régime change ? ». Les périodes testées ici font alterner tendance, retour à la moyenne et choc — et la plupart des paramètres ne survivent qu’à un seul régime. La cellule suivante remplace le Sharpe par le Calmar (retour / drawdown maximal) comme fitness : un classement où la robustesse se mesure au creux de la pire période vécue, pas au sommet du meilleur mois.

# Calmar Ratio comme fitness

def calmar_fitness(result: Dict, min_trades: int = 10) -> float:
    """
    Calmar Ratio = CAGR / Max Drawdown
    
    Favorise les strategies avec bon return ET faible drawdown.
    """
    if not result['valid'] or result['n_trades'] < min_trades:
        return -np.inf
    
    total_return = result['total_return']
    max_dd = abs(result['max_drawdown'])
    
    if max_dd == 0:
        return total_return * 100 if total_return > 0 else -np.inf
    
    calmar = total_return / max_dd
    
    return calmar

# Grid Search avec Calmar comme fitness
print("Grid Search avec Calmar Ratio:")
print("="*50)

calmar_results = []

for fast in [10, 15, 20, 25]:
    for slow in [50, 75, 100, 150]:
        if fast >= slow:
            continue
        
        result = backtest_sma_strategy(prices_wf, fast, slow)
        
        if result['valid']:
            calmar_results.append({
                'fast': fast,
                'slow': slow,
                'sharpe': result['sharpe'],
                'calmar': calmar_fitness(result),
                'return': result['total_return'],
                'max_dd': result['max_drawdown']
            })

calmar_df = pd.DataFrame(calmar_results)
calmar_df_sorted = calmar_df.sort_values('calmar', ascending=False)

print("\nTop 5 par Calmar Ratio:")
print(calmar_df_sorted.head().to_string(index=False))

print("\nTop 5 par Sharpe Ratio:")
print(calmar_df.sort_values('sharpe', ascending=False).head().to_string(index=False))
Grid Search avec Calmar Ratio:
==================================================

Top 5 par Calmar Ratio:
 fast  slow   sharpe  calmar   return    max_dd
   10    50 2.660612    -inf 0.523045 -0.030796
   10    75 3.098727    -inf 0.557766 -0.017878
   10   100 3.200602    -inf 0.556815 -0.017878
   10   150 3.435565    -inf 0.557400 -0.013778
   15    50 2.802152    -inf 0.539476 -0.023115

Top 5 par Sharpe Ratio:
 fast  slow   sharpe  calmar   return    max_dd
   10   150 3.435565    -inf 0.557400 -0.013778
   15   150 3.419577    -inf 0.537987 -0.013778
   25   150 3.414932    -inf 0.520267 -0.013778
   20   150 3.410288    -inf 0.538394 -0.013778
   15   100 3.294369    -inf 0.563180 -0.017878

Le classement Calmar est vide : quand un fitness dégénère en constante

La sortie est surprenante au premier regard : les deux tableaux — « Top 5 par Calmar » et « Top 5 par Sharpe » — affichent calmar: -inf sur toutes les lignes. Le mécanisme se lit dans le code : calmar_fitness retourne -np.inf quand n_trades < min_trades (10), et les backtests de ce jeu de données produisent 3 à 5 transactions. Toutes les combinaisons franchissent le filtre valid mais échouent le seuil de transactions — donc toutes reçoivent le même fitness -inf.

Conséquence mécanique invisible dans la sortie : sort_values('calmar', ascending=False) sur une colonne constante ne trie rien — l’ordre affiché est celui du DataFrame d’origine. Le « Top 5 par Calmar Ratio » n’est pas un classement : ce sont les 5 premières lignes générées par la boucle (fast 10 puis 15), pas les 5 meilleures. La comparaison honnête des deux tableaux le révèle d’ailleurs : ils ne partagent que 2 lignes sur 5 — si le tri Calmar avait réellement classé, il aurait au moins ressemblé à quelque chose.

Trois leçons. 1 : une garde de qualité (min_trades) placée DANS la fonction de fitness dégrade silencieusement le signal en constante — mieux vaut filtrer les combinaisons avant le tri (elles auraient disparu de la sortie) que leur assigner -inf (elles restent et occupent tout le classement). 2 : un tableau trié dont toutes les valeurs sont égales n’est pas trié — toujours regarder la colonne de tri avant le tableau. 3 : le seuil min_trades=10 dit quelque chose de juste — qu’un Sharpe sur 3 trades ne vaut rien (partie 3) — mais il le dit si violemment qu’il efface la seule information restante. La bonne borne pour un ratio : exclure les lignes, pas empoisonner le score.

Le contraste conceptuel Sharpe/Calmar garde sa valeur quand les deux fitness passent leurs filtres : le Sharpe pénalise la volatilité totale (aversion à la volatilité), le Calmar le drawdown maximal (aversion à la perte de pointe) — et les top paramètres diffèrent alors selon la fonction choisie. Sur ce jeu, la question ne se pose même pas : le filtre min_trades dégénère le Calmar en constante, et la comparaison des classements tourne à vide.

6.2 Robustness Testing (10 min)

Le test de robustesse verifie que les paramètres performent bien dans différentes conditions.

def monte_carlo_robustness(prices: pd.Series,
                          params: Dict,
                          n_simulations: int = 100,
                          resample_block_size: int = 21) -> Dict:
    """
    Test de robustesse Monte Carlo par bootstrap des returns.
    
    Reshuffle les blocks de returns pour creer des series alternatives.
    Teste si la strategie performe encore bien.
    
    Args:
        prices: Serie de prix originale
        params: Parametres a tester
        n_simulations: Nombre de simulations
        resample_block_size: Taille des blocks pour le bootstrap
    
    Returns:
        Dict avec statistiques de robustesse
    """
    np.random.seed(42)
    
    # Calculer returns originaux
    returns = prices.pct_change().dropna()
    n_returns = len(returns)
    
    sharpes = []
    drawdowns = []
    
    for _ in range(n_simulations):
        # Bootstrap par blocks
        n_blocks = n_returns // resample_block_size + 1
        blocks = []
        
        for _ in range(n_blocks):
            start = np.random.randint(0, n_returns - resample_block_size)
            block = returns.iloc[start:start + resample_block_size].values
            blocks.append(block)
        
        # Concatener et tronquer
        resampled_returns = np.concatenate(blocks)[:n_returns]
        
        # Reconstruire les prix
        resampled_prices = prices.iloc[0] * (1 + pd.Series(resampled_returns)).cumprod()
        resampled_prices = pd.Series(resampled_prices.values, index=prices.index[1:])
        
        # Backtest
        result = backtest_sma_strategy(
            resampled_prices,
            params['fast_period'],
            params['slow_period']
        )
        
        if result['valid']:
            sharpes.append(result['sharpe'])
            drawdowns.append(result['max_drawdown'])
    
    # Statistiques
    sharpes = np.array(sharpes)
    drawdowns = np.array(drawdowns)
    
    return {
        'sharpe_mean': sharpes.mean(),
        'sharpe_std': sharpes.std(),
        'sharpe_5pct': np.percentile(sharpes, 5),
        'sharpe_95pct': np.percentile(sharpes, 95),
        'prob_positive_sharpe': (sharpes > 0).mean(),
        'dd_mean': drawdowns.mean(),
        'dd_5pct': np.percentile(drawdowns, 5),
        'dd_95pct': np.percentile(drawdowns, 95),
        'sharpes': sharpes,
        'drawdowns': drawdowns
    }

# Tester la robustesse des meilleurs parametres
print("Test de robustesse Monte Carlo (100 simulations)...")
best_params_test = {'fast_period': 15, 'slow_period': 100}

robustness = monte_carlo_robustness(prices_wf, best_params_test, n_simulations=100)

print("\nResultats:")
print("="*50)
print(f"Sharpe Ratio:")
print(f"  Moyenne: {robustness['sharpe_mean']:.2f}")
print(f"  Ecart-type: {robustness['sharpe_std']:.2f}")
print(f"  Intervalle 90%: [{robustness['sharpe_5pct']:.2f}, {robustness['sharpe_95pct']:.2f}]")
print(f"  Probabilite Sharpe > 0: {robustness['prob_positive_sharpe']:.1%}")
print(f"\nMax Drawdown:")
print(f"  Moyenne: {robustness['dd_mean']:.2%}")
print(f"  Intervalle 90%: [{robustness['dd_5pct']:.2%}, {robustness['dd_95pct']:.2%}]")
Test de robustesse Monte Carlo (100 simulations)...

Resultats:
==================================================
Sharpe Ratio:
  Moyenne: 1.46
  Ecart-type: 0.54
  Intervalle 90%: [0.51, 2.42]
  Probabilite Sharpe > 0: 100.0%

Max Drawdown:
  Moyenne: -5.42%
  Intervalle 90%: [-8.66%, -2.83%]

Lire un Monte Carlo par ses bornes, pas par sa moyenne

La sortie du test de robustesse donne : Sharpe moyen 1.46, écart-type 0.54, intervalle à 90 % : [0.51, 2.42], « Probabilité Sharpe > 0 : 100.0 % » ; côté drawdown, moyenne -5.42 %, intervalle 90 % [-8.66 %, -2.83 %]. La tentation est de retenir « 1.46 » et « 100 % » — les deux nombres les moins informatifs de la sortie.

La moyenne (1.46) décrit le centre de la distribution des performances sous perturbation — mais personne ne vit en moyenne : on vit dans un tirage. La borne basse de l’intervalle (0.51) est l’information de gestion du risque : dans 95 % des mondes perturbés, la stratégie reste au-dessus de 0.51 — dégradée de ~65 % par rapport à la moyenne, mais positive. C’est ce chiffre qui répond à « puis-je déployer », pas la moyenne. L’écart entre 0.51 et 1.46 mesure la sensibilité au scénario — ici large, signe que la performance dépend fortement du trajet des prix.

La « Probabilité Sharpe > 0 : 100.0 % » enfin : elle est calculée sur 100 simulations, et 100/100 est compatible avec une vraie probabilité d’à peine ~97 % (intervalle de confiance binomial). Une probabilité affichée à 100 % sur un échantillon fini est toujours un arrondi de « toutes celles qu’on a vues » — la partie 7 montrera la version honnête : « 88.0 % » sur 50 simulations, avec le 5ᵉ percentile à 0.00 : dans un futur sur vingt, la stratégie ne gagne pas. Le Monte Carlo vaut par ses percentiles et sa borne basse ; la moyenne et le « 100 % » sont les chiffres de la brochure.

On sélectionne les paramètres finaux en tenant compte du compromis entre performance in-sample et out-of-sample. C’est le moment le plus délicat du protocole : le processus de sélection lui-même peut devenir une forme d’optimisation. Deux règles protègent la lecture. D’abord, choisir un critère AVANT d’avoir vu l’OOS (meilleure médiane des folds, plus faible variance, pire cas le moins mauvais) — décider après coup, c’est réintroduire le surapprentissage par la porte du choix. Ensuite, considérer qu’un écart IS/OOS faible et stable vaut un score IS élevé et instable : la robustesse se paie en performance in-sample affichée, et c’est un bon prix.

# Visualisation robustesse

fig, axes = plt.subplots(1, 2, figsize=(14, 5))

# Distribution Sharpe
ax1 = axes[0]
ax1.hist(robustness['sharpes'], bins=30, color='steelblue', edgecolor='black', alpha=0.7)
ax1.axvline(x=robustness['sharpe_mean'], color='red', linestyle='--', linewidth=2,
            label=f'Mean: {robustness["sharpe_mean"]:.2f}')
ax1.axvline(x=0, color='black', linestyle='-', linewidth=1)
ax1.axvline(x=robustness['sharpe_5pct'], color='orange', linestyle=':', linewidth=2,
            label=f'5th pct: {robustness["sharpe_5pct"]:.2f}')
ax1.axvline(x=robustness['sharpe_95pct'], color='orange', linestyle=':', linewidth=2,
            label=f'95th pct: {robustness["sharpe_95pct"]:.2f}')
ax1.set_xlabel('Sharpe Ratio', fontsize=12)
ax1.set_ylabel('Frequence', fontsize=12)
ax1.set_title('Distribution Monte Carlo des Sharpe Ratios', fontsize=14, fontweight='bold')
ax1.legend()
ax1.grid(True, alpha=0.3)

# Distribution Drawdown
ax2 = axes[1]
ax2.hist(robustness['drawdowns'] * 100, bins=30, color='coral', edgecolor='black', alpha=0.7)
ax2.axvline(x=robustness['dd_mean'] * 100, color='red', linestyle='--', linewidth=2,
            label=f'Mean: {robustness["dd_mean"]:.2%}')
ax2.axvline(x=robustness['dd_5pct'] * 100, color='darkred', linestyle=':', linewidth=2,
            label=f'5th pct: {robustness["dd_5pct"]:.2%}')
ax2.set_xlabel('Max Drawdown (%)', fontsize=12)
ax2.set_ylabel('Frequence', fontsize=12)
ax2.set_title('Distribution Monte Carlo des Max Drawdowns', fontsize=14, fontweight='bold')
ax2.legend()
ax2.grid(True, alpha=0.3)

plt.tight_layout()
plt.show()


Interprétation : les distributions Monte Carlo — percentiles 5/95 comme bornes

Visualisation des résultats Monte Carlo:

  • Distribution Sharpe: Histogramme des Sharpe ratios sur 100 simulations
    • Mean = performance moyenne attendue
    • 5th/95th percentiles = intervalle de confiance 90%
    • Barre à 0 = référence de performance nulle
  • Distribution Drawdown: Histogramme des drawdowns maximums
    • 5th percentile = meilleur cas (5% des simulations)
    • 95th percentile = pire cas (95% des simulations)

Ces graphiques montrent la variabilité de la performance selon les conditions de marché.



Partie 7: Exemple Complet (15 min)

7.1 Stratégie Optimisee avec Validation Complete

def complete_optimization_pipeline(prices: pd.Series,
                                   fast_range: List[int],
                                   slow_range: List[int],
                                   train_pct: float = 0.7) -> Dict:
    """
    Pipeline complet d'optimisation avec validation.
    
    Etapes:
    1. Split train/test
    2. Grid Search sur train
    3. Validation sur test
    4. Walk-Forward Analysis
    5. Test de robustesse
    
    Returns:
        Dict avec tous les resultats
    """
    results = {}
    
    # 1. Split train/test
    split_idx = int(len(prices) * train_pct)
    train_prices = prices.iloc[:split_idx]
    test_prices = prices.iloc[split_idx:]
    
    print(f"1. Split des donnees:")
    print(f"   Train: {train_prices.index[0].date()} - {train_prices.index[-1].date()} ({len(train_prices)} jours)")
    print(f"   Test: {test_prices.index[0].date()} - {test_prices.index[-1].date()} ({len(test_prices)} jours)")
    
    # 2. Grid Search sur train
    print(f"\n2. Grid Search sur donnees d'entrainement...")
    best_params, best_score = optimize_on_period(train_prices, fast_range, slow_range)
    
    if best_params is None:
        print("   ERREUR: Aucun parametre valide trouve!")
        return None
    
    print(f"   Meilleurs parametres: SMA({best_params['fast_period']}, {best_params['slow_period']})")
    print(f"   Score IS: {best_score:.2f}")
    results['best_params'] = best_params
    results['is_score'] = best_score
    
    # 3. Validation sur test
    print(f"\n3. Validation Out-of-Sample...")
    oos_result = backtest_sma_strategy(
        test_prices, 
        best_params['fast_period'], 
        best_params['slow_period']
    )
    
    if oos_result['valid']:
        print(f"   Sharpe OOS: {oos_result['sharpe']:.2f}")
        print(f"   Return OOS: {oos_result['total_return']:.2%}")
        print(f"   Max DD OOS: {oos_result['max_drawdown']:.2%}")
        results['oos_sharpe'] = oos_result['sharpe']
        results['oos_return'] = oos_result['total_return']
        results['oos_drawdown'] = oos_result['max_drawdown']
    
    # 4. Walk-Forward Analysis
    print(f"\n4. Walk-Forward Analysis...")
    wf_results = walk_forward_analysis(
        prices,
        train_days=180,
        test_days=60,
        fast_periods=fast_range,
        slow_periods=slow_range
    )
    
    wf_efficiency = wf_results['oos_sharpe'].mean() / wf_results['is_sharpe'].mean()
    print(f"   Folds: {len(wf_results)}")
    print(f"   WF Efficiency: {wf_efficiency:.2%}")
    results['wf_results'] = wf_results
    results['wf_efficiency'] = wf_efficiency
    
    # 5. Test de robustesse
    print(f"\n5. Test de robustesse Monte Carlo (50 simulations)...")
    robustness = monte_carlo_robustness(prices, best_params, n_simulations=50)
    
    print(f"   Prob(Sharpe > 0): {robustness['prob_positive_sharpe']:.1%}")
    print(f"   Sharpe 5th percentile: {robustness['sharpe_5pct']:.2f}")
    results['robustness'] = robustness
    
    # Score final
    print(f"\n" + "="*60)
    print("SCORE DE VALIDATION FINAL")
    print("="*60)
    
    # Scoring (ponderation des criteres)
    oos_vs_is = oos_result['sharpe'] / best_score if best_score > 0 else 0
    validation_score = (
        0.30 * min(1, oos_vs_is) +                          # OOS pas trop degrade
        0.25 * min(1, wf_efficiency) +                       # Walk-Forward efficace
        0.25 * robustness['prob_positive_sharpe'] +          # Robustesse
        0.20 * (1 if oos_result['sharpe'] > 0.5 else 0.5 if oos_result['sharpe'] > 0 else 0)
    )
    
    print(f"\nCriteres:")
    print(f"  - OOS/IS Ratio: {oos_vs_is:.2%}")
    print(f"  - WF Efficiency: {wf_efficiency:.2%}")
    print(f"  - MC Prob(Sharpe>0): {robustness['prob_positive_sharpe']:.1%}")
    print(f"  - OOS Sharpe: {oos_result['sharpe']:.2f}")
    
    print(f"\nSCORE DE VALIDATION: {validation_score:.2%}")
    
    if validation_score >= 0.80:
        print("-> EXCELLENT: Strategie bien validee")
    elif validation_score >= 0.60:
        print("-> BON: Strategie acceptable avec quelques reserves")
    elif validation_score >= 0.40:
        print("-> MOYEN: Risques d'overfitting, prudence requise")
    else:
        print("-> FAIBLE: Probable overfitting, ne pas utiliser en production")
    
    results['validation_score'] = validation_score
    
    return results

print("Fonction complete_optimization_pipeline() definie")
Fonction complete_optimization_pipeline() definie

On valide la stratégie optimisée sur un jeu de données de test indépendant pour éviter le surapprentissage. Ce test final est le gardien du protocole : la fenêtre de test n’a été touchée par aucune décision jusqu’ici — ni configuration de grille, ni choix de fitness, ni sélection des paramètres. C’est ce qui la distingue des folds walk-forward, qui, eux, participent à la sélection. Un même jeu de données, touché par la sélection puis rejoué pour la preuve, ne prouve rien : la validation n’a de valeur que si les données n’ont jamais influencé le choix. La cellule suivante exécute le pipeline complet de bout en bout ; les résultats OOS lus à la sortie sont la seule réponse autorisée à « est-ce que ça marche ? ».

# Executer le pipeline complet
print("="*70)
print("PIPELINE D'OPTIMISATION COMPLET")
print("="*70)

final_results = complete_optimization_pipeline(
    prices_wf,
    fast_range=[5, 10, 15, 20, 25, 30],
    slow_range=[50, 75, 100, 125, 150, 175, 200],
    train_pct=0.70
)
======================================================================
PIPELINE D'OPTIMISATION COMPLET
======================================================================
1. Split des donnees:
   Train: 2019-01-01 - 2022-05-18 (882 jours)
   Test: 2022-05-19 - 2023-10-30 (378 jours)

2. Grid Search sur donnees d'entrainement...
   Meilleurs parametres: SMA(20, 200)
   Score IS: 3.54

3. Validation Out-of-Sample...
   Sharpe OOS: 2.93
   Return OOS: 3.64%
   Max DD OOS: -0.34%

4. Walk-Forward Analysis...
   Folds: 18
   WF Efficiency: -0.85%

5. Test de robustesse Monte Carlo (50 simulations)...
   Prob(Sharpe > 0): 88.0%
   Sharpe 5th percentile: 0.00

============================================================
SCORE DE VALIDATION FINAL
============================================================

Criteres:
  - OOS/IS Ratio: 82.77%
  - WF Efficiency: -0.85%
  - MC Prob(Sharpe>0): 88.0%
  - OOS Sharpe: 2.93

SCORE DE VALIDATION: 66.62%
-> BON: Strategie acceptable avec quelques reserves

La contradiction des deux verdicts : 2.93 contre -0.85 % — et le score qui les mélange

Le pipeline final produit dans une même sortie deux mesures qui se contredisent frontalement. D’un côté, la validation unique sur un split 882 jours / 378 jours : Sharpe OOS 2.93, return 3.64 %, MaxDD -0.34 % — une stratégie qui marche, en apparence nettement. De l’autre, la walk-forward sur 18 folds de la même période : efficacité -0.85 % — les paramètres optimisés par fenêtre ne valent rien hors de leur fenêtre (c’est le phénomène de la partie 4, ici confirmé sur le pipeline complet). Et le score de validation composite tranche : « 66.62 % → BON : stratégie acceptable avec quelques réserves ».

La contradiction n’est pas une anomalie : c’est la hiérarchie réelle des preuves. La validation unique teste une politique figée sur un futur — favorable ici (le split OOS couvre 2022-05→2023-10, et les paramètres SMA(20, 200) choisis sur 882 jours ont eu l’espace de se déclencher). La walk-forward teste la procédure « optimiser puis déployer » sur 18 futurs successifs — et répond que cette procédure ne généralise pas. Un score composite qui moyenne les deux (82.77 % de ratio OOS/IS ici, -0.85 % de WF là) fabrique un verdict intermédiaire qui n’appartient à aucune des deux questions. La règle de lecture pour toute chaîne de validation : le verdict le plus défavorable sur la question qui vous concerne est celui qui compte. Si la question est « cette stratégie déployée tiendra-t-elle dans des conditions nouvelles », c’est le -0.85 % qui répond — le 2.93 dit seulement qu’UNE fenêtre particulière a été clémente. Le « BON » du score composite est un arrondi de management ; la walk-forward est l’instrument.

Ce que le pipeline a exécuté (complete_optimization_pipeline) : grid search sur le train (70 %), validation sur les 30 % jamais vus, walk-forward sur folds glissants, Monte Carlo (50 simulations par reshuffling des returns). Le score de validation composite (0-100 %) combine le ratio OOS/IS, l’efficacité walk-forward et la probabilité de Sharpe positif — c’est lui qui imprime « 66.62 % → BON », le verdict intermédiaire lu ci-dessus.

# [REFERENCE QC] Code a copier dans main.py QC Lab (non executable ici)
# Code QCAlgorithm final avec les parametres optimises

if final_results:
    best = final_results['best_params']
    
    final_algorithm_code = f'''
from AlgorithmImports import *

class OptimizedSMACrossover(QCAlgorithm):
    """
    Strategie SMA Crossover optimisee et validee.
    
    Parametres optimaux:
        - Fast Period: {best['fast_period']}
        - Slow Period: {best['slow_period']}
    
    Validation:
        - OOS Sharpe: {final_results['oos_sharpe']:.2f}
        - WF Efficiency: {final_results['wf_efficiency']:.2%}
        - Validation Score: {final_results['validation_score']:.2%}
    """
    
    def Initialize(self):
        self.SetStartDate(2015, 1, 1)
        self.SetEndDate(2024, 12, 31)
        self.SetCash(100000)
        
        # Parametres optimises (avec GetParameter pour override)
        self.fast_period = int(self.GetParameter("fast_period", {best['fast_period']}))
        self.slow_period = int(self.GetParameter("slow_period", {best['slow_period']}))
        
        # Setup
        self.symbol = self.AddEquity("SPY", Resolution.Daily).Symbol
        
        # Indicateurs
        self.sma_fast = self.SMA(self.symbol, self.fast_period, Resolution.Daily)
        self.sma_slow = self.SMA(self.symbol, self.slow_period, Resolution.Daily)
        
        # Risk management
        self.stop_loss_pct = 0.05
        self.entry_price = 0
        
        self.SetWarmup(self.slow_period)
    
    def OnData(self, data):
        if self.IsWarmingUp or not data.ContainsKey(self.symbol):
            return
        
        if not (self.sma_fast.IsReady and self.sma_slow.IsReady):
            return
        
        price = self.Securities[self.symbol].Price
        
        # Stop loss check
        if self.Portfolio[self.symbol].Invested:
            if (price - self.entry_price) / self.entry_price <= -self.stop_loss_pct:
                self.Liquidate(self.symbol)
                return
        
        # Trading logic
        fast = self.sma_fast.Current.Value
        slow = self.sma_slow.Current.Value
        
        if fast > slow and not self.Portfolio[self.symbol].Invested:
            self.SetHoldings(self.symbol, 1.0)
            self.entry_price = price
        
        elif fast < slow and self.Portfolio[self.symbol].Invested:
            self.Liquidate(self.symbol)
'''
    
    print("Code QCAlgorithm Final:")
    print(final_algorithm_code)
Code QCAlgorithm Final:

from AlgorithmImports import *

class OptimizedSMACrossover(QCAlgorithm):
    """
    Strategie SMA Crossover optimisee et validee.

    Parametres optimaux:
        - Fast Period: 20
        - Slow Period: 200

    Validation:
        - OOS Sharpe: 2.93
        - WF Efficiency: -0.85%
        - Validation Score: 66.62%
    """

    def Initialize(self):
        self.SetStartDate(2015, 1, 1)
        self.SetEndDate(2024, 12, 31)
        self.SetCash(100000)

        # Parametres optimises (avec GetParameter pour override)
        self.fast_period = int(self.GetParameter("fast_period", 20))
        self.slow_period = int(self.GetParameter("slow_period", 200))

        # Setup
        self.symbol = self.AddEquity("SPY", Resolution.Daily).Symbol

        # Indicateurs
        self.sma_fast = self.SMA(self.symbol, self.fast_period, Resolution.Daily)
        self.sma_slow = self.SMA(self.symbol, self.slow_period, Resolution.Daily)

        # Risk management
        self.stop_loss_pct = 0.05
        self.entry_price = 0

        self.SetWarmup(self.slow_period)

    def OnData(self, data):
        if self.IsWarmingUp or not data.ContainsKey(self.symbol):
            return

        if not (self.sma_fast.IsReady and self.sma_slow.IsReady):
            return

        price = self.Securities[self.symbol].Price

        # Stop loss check
        if self.Portfolio[self.symbol].Invested:
            if (price - self.entry_price) / self.entry_price <= -self.stop_loss_pct:
                self.Liquidate(self.symbol)
                return

        # Trading logic
        fast = self.sma_fast.Current.Value
        slow = self.sma_slow.Current.Value

        if fast > slow and not self.Portfolio[self.symbol].Invested:
            self.SetHoldings(self.symbol, 1.0)
            self.entry_price = price

        elif fast < slow and self.Portfolio[self.symbol].Invested:
            self.Liquidate(self.symbol)

Interprétation : le QCAlgorithm final — paramètres optimisés et fallback GetParameter

Code QCAlgorithm final avec paramètres optimisés intégrés:

  • Paramètres optimaux hardcodés: Fast/Slow periods issus de l’optimisation
  • GetParameter() avec fallback: Permet override depuis l’interface QC tout en gardant les valeurs optimales par défaut
  • Validation score documentée: Métriques de validation inline comme référence

Pour déployer: Copier ce code dans main.py d’un projet QC Lab, ajuster les dates, et lancer un backtest de validation.


Résultats Backtest QC Cloud - OptimizedSMACrossover (SMA 20/200, 2015-2024, $100,000 initial)

Metrique Valeur
Dates negociables 2516
Sharpe Ratio 0.494
CAGR 10.218%
Max Drawdown 26.600%
Net Profit +164.6%
Equity finale $264,561
Probabilistic Sharpe Ratio 11.349%

Backtest QC Cloud 2015-2024 (projet 33177683, bt 41a1888d69fbda7bf164a04cd5761c86). Net Profit / Equity finale deduits du CAGR verifie (End = $100,000 x (1+CAGR)^10).

Les résultats QC Cloud en regard : le même optimisateur, une autre exonération

Les deux blocs « Résultats Backtest QC Cloud » de ce notebook (stratégie OptimizableStrategy SMA 10/50, puis OptimizedSMACrossover SMA 20/200) sont committés comme référence de ce que le même paramétrage produit sur le moteur QuantConnect complet — frais, slippage et données institutionnelles inclus. La comparaison local/QC n’est pas un luxe de vérification : le backtest local de ce notebook ignore les coûts de transaction et utilise des données simulées en partie, deux dimensions qui dégradent spécifiquement les stratégies à faible nombre de transactions (chaque coût frappe une fraction plus grande du PnL). Le réflexe à ancrer : toute optimisation menée en local sur données propres doit être re-mesurée sur le moteur cible AVANT d’être crue — l’écart entre les deux mesures fait partie du résultat, pas du bruit.


Conclusion et Prochaines Étapes

Recapitulatif

Dans ce notebook, nous avons couvert:

  1. Introduction a l’optimisation: Objectifs, risques (overfitting, curve fitting), types de méthodes

  2. Parameter Sets QuantConnect: Definition de paramètres optimisables, configuration JSON

  3. Grid Search Manuel: Implementation complete, analyse des résultats, heatmaps

  4. Walk-Forward Analysis (Pardo, 2008): Validation temporelle, split IS/OOS, efficiency ratio

  5. Anti-Overfitting (White, 2000 - reality check ; Inoue & Kilian, 2006 - multiple testing): Detection des signes, penalites de complexite, test de sensibilite

  6. Optimisation Avancee: Fitness multi-objectif, Calmar ratio, robustesse Monte Carlo

  7. Pipeline Complet: Integration de toutes les techniques, score de validation

Points Cles a Retenir

Concept Point Cle
Objectif Robustesse > Performance maximale
Overfitting IS >> OOS = danger
Walk-Forward Valide l’optimisation en temps reel
Paramètres Moins = mieux (parcimonie)
Robustesse Monte Carlo pour valider
Fitness Multi-objectif > Single metric

Checklist Avant Production

References academiques

  • Pardo, R. (2008). The Evaluation and Optimization of Trading Stratégies (2nd ed.). Wiley. Walk-forward analysis (validation temporelle IS/OOS).
  • White, H. (2000). A Reality Check for Data Snooping. Econometrica. Reality check / data-snooping bias (correction des tests multiples).
  • Inoue, A. & Kilian, L. (2006). On the Sélection of Tuning Parameters for the Detection of Structural Breaks. Journal of Time Series Analysis. Multiple testing en optimisation (sur-apprentissage par repetition de tests).
  • Bailey, D. & Lopez de Prado, M. (2014). The Deflated Sharpe Ratio. Journal of Risk. Probabilistic Sharpe Ratio (PSR) / Prob(Sharpe > 0), correction du biais de sélection sur ratios de performance.
  • Bailey, D., Borwein, J., Lopez de Prado, M. & Zhu, Q. (2017). The Probability of Backtest Overfitting. Journal of Computational Finance. PBO (Probability of Backtest Overfitting), validation Monte Carlo.
  • Sharpe, W. F. (1966). Mutual Fund Performance. The Journal of Business, 39(1), 119-138. Ratio de Sharpe (Return/volatilité) – fitness directionnelle de référence.
  • Sortino, F. A. & Price, L. N. (1994). Performance Measurement in a Downside Risk Framework. The Journal of Investing, 3(3), 59-65. Ratio de Sortino (Return/volatilité négative).
  • Akaike, H. (1974). A New Look at the Statistical Model Identification. IEEE Transactions on Automatic Control, 19(6), 716-723. AIC (Akaike Information Criterion) – pénalisation de la complexité en sélection de modèles.

Ressources Complementaires


Notebook complete. L’optimisation des paramètres est cruciale mais doit etre faite avec rigueur pour eviter l’overfitting.

Retour au sommet