<< Sommaire QC | Précédent : QC-Py-17-Sentiment-Analysis << | Suivant : QC-Py-19-ML-Supervised-Classification >>

QC-Py-18 - Feature Engineering pour Machine Learning Trading

Construire des features predictives pour les modèles ML Duree: 90 minutes | Niveau: Intermediaire-Avance | Python + QuantConnect


Objectifs d’Apprentissage

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

  1. Comprendre l’importance du Feature Engineering en trading quantitatif
  2. Créer des features techniques basees sur les prix et volumes
  3. Construire des features basees sur indicateurs (RSI, MACD, Bollinger…)
  4. Extraire des features fondamentales depuis QuantConnect
  5. Maitriser le labeling pour classification et regression
  6. Appliquer des techniques de feature sélection et importance
  7. Implementer un preprocessing pipeline robuste
  8. Construire un pipeline complet de preparation des données ML

Prerequisites

  • Notebooks QC-Py-01 a 15 completes
  • Comprehension des indicateurs techniques (QC-Py-11)
  • Notions de base en Machine Learning (classification, regression)
  • Familiarite avec pandas, numpy, sklearn

Structure du Notebook

  1. Introduction au Feature Engineering (15 min)
  2. Features Techniques: Price-Based (10 min)
  3. Features Techniques: Indicator-Based (15 min)
  4. Features Fondamentales (20 min)
  5. Labeling pour Classification et Regression (20 min)
  6. Feature Sélection et Importance (20 min)
  7. Preprocessing et Normalization (15 min)
  8. Pipeline Complet de Feature Engineering (20 min)


Mode d’emploi : Ce notebook est entierement executable en Jupyter local. Il utilise pandas, matplotlib et sklearn pour l’analyse de données financieres. Les résultats peuvent ensuite etre integres dans un projet QuantConnect (voir fin du notebook).


Partie 1 : Introduction au Feature Engineering (15 min)

Pourquoi le Feature Engineering est-il crucial?

En Machine Learning, la qualite des predictions depend directement de la qualite des features. C’est le principe “Garbage In, Garbage Out” :

Données Brutes     Features Bien          Modèle ML        Predictions
(OHLCV)      -->   Construites      -->   (RF, XGB)   -->  Profitables
                        |
                        v
               Signal + Information

Features vs Données Brutes

Données Brutes Features Derivees
Prix de cloture Rendements sur N periodes
Volume Ratio volume/moyenne
High/Low Range (High-Low)/Close
Historique Momentum, Volatilite, Tendance

Importance pour l’Interpretabilite

Des features bien construites permettent :

  1. Comprendre ce que le modèle apprend
  2. Debugger les predictions erronees
  3. Communiquer la logique aux stakeholders
  4. Verifier que le modèle n’utilise pas de lookahead bias

Feature Sélection vs Feature Extraction

Approche Description Techniques
Sélection Choisir les meilleures features existantes Correlation, Importance, RFE
Extraction Créer de nouvelles features combinees PCA, Auto-encoders

Catégories de Features en Trading

Features
   |
   +-- Techniques
   |      +-- Price-based (returns, volatility, range)
   |      +-- Indicator-based (RSI, MACD, BB)
   |      +-- Volume-based (OBV, volume ratio)
   |
   +-- Fondamentales
   |      +-- Valuation (P/E, P/B, P/S)
   |      +-- Profitability (ROE, ROA, Margin)
   |      +-- Growth (Revenue, EPS)
   |
   +-- Alternatives
          +-- Sentiment (news, social)
          +-- Macro (rates, indices)
# Imports necessaires
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
from datetime import datetime, timedelta
import warnings
warnings.filterwarnings('ignore')

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

# Import bibliotheque partagee
import sys
sys.path.insert(0, '../shared')

try:
    from features import calculate_returns, add_technical_features, create_labels, walk_forward_split
    print("Import depuis shared/features.py reussi")
except ImportError as e:
    print(f"Note: {e}")
    print("Les fonctions seront definies dans ce notebook.")

print("\nImports reussis!")
print("Ce notebook couvre le Feature Engineering pour ML Trading.")
Import depuis shared/features.py reussi

Imports reussis!
Ce notebook couvre le Feature Engineering pour ML Trading.

Imports et Configuration

Ce notebook utilise les bibliothèques standards de data science pour le Feature Engineering.

Bibliothèques importées :

Bibliothèque Usage
numpy Calculs numériques, fonctions mathématiques
pandas Manipulation de données temporelles
matplotlib Visualisation des données
seaborn Heatmaps, visualisations avancées
sklearn Modèles ML, preprocessing, feature sélection

Tentative d’import depuis shared/features.py : - Ce fichier contiendrait des fonctions réutilisables pour d’autres notebooks - Si l’import échoue, les fonctions sont définies localement dans le notebook

Configuration matplotlib : - seaborn-v0_8-darkgrid : Style avec grille pour meilleure lisibilité - %matplotlib inline : Affichage des graphiques dans le notebook

Warnings désactivés : - warnings.filterwarnings('ignore') : Évite les messages sklearn futurs - En production, on laisserait les warnings pour détecter les problèmes

Note : Dans un environnement QuantConnect, l’import depuis shared ne fonctionnera pas car le fichier n’existe pas sur le serveur QC. Le code est conçu pour fonctionner en Jupyter local.


On construit ici les features techniques et fondamentales qui serviront d’entrées au modèle de machine learning.

# Creer des donnees de demonstration (simulees)
# En production, ces donnees viendraient de QuantConnect

def generate_sample_data(n_days=500, seed=42):
    """
    Genere des donnees OHLCV simulees pour demonstration.
    
    Parameters:
    -----------
    n_days : int
        Nombre de jours de donnees
    seed : int
        Graine aleatoire pour reproductibilite
    
    Returns:
    --------
    pd.DataFrame
        DataFrame avec colonnes OHLCV
    """
    np.random.seed(seed)
    
    # Dates
    dates = pd.date_range(start='2022-01-01', periods=n_days, freq='B')  # Business days
    
    # Prix de depart
    start_price = 100.0
    
    # Generer rendements avec tendance et volatilite variables
    daily_drift = 0.0003  # ~7.5% annuel
    daily_volatility = 0.015  # ~24% annuel
    
    returns = np.random.normal(daily_drift, daily_volatility, n_days)
    
    # Ajouter des regimes (periodes de haute/basse volatilite)
    regime = np.sin(np.linspace(0, 4*np.pi, n_days)) * 0.005
    returns = returns + regime
    
    # Calculer les prix de cloture
    close = start_price * np.exp(np.cumsum(returns))
    
    # Generer OHLV a partir de close
    intraday_vol = 0.01
    high = close * (1 + np.abs(np.random.normal(0, intraday_vol, n_days)))
    low = close * (1 - np.abs(np.random.normal(0, intraday_vol, n_days)))
    open_price = close * (1 + np.random.normal(0, intraday_vol/2, n_days))
    
    # Volume (correle negativement avec les rendements)
    base_volume = 1_000_000
    volume = base_volume * (1 + np.random.exponential(0.5, n_days))
    volume = volume * (1 + np.abs(returns) * 10)  # Volume augmente avec volatilite
    
    # Creer DataFrame
    df = pd.DataFrame({
        'open': open_price,
        'high': high,
        'low': low,
        'close': close,
        'volume': volume.astype(int)
    }, index=dates)
    
    return df

# Generer donnees
df = generate_sample_data(n_days=500)

print("Donnees de demonstration generees:")
print(f"  Periode: {df.index[0].date()} a {df.index[-1].date()}")
print(f"  Nombre de jours: {len(df)}")
print(f"\nApercu:")
print(df.head(10))
Donnees de demonstration generees:
  Periode: 2022-01-03 a 2023-12-01
  Nombre de jours: 500

Apercu:
                  open        high         low       close   volume
2022-01-03  101.170291  101.711467   99.367839  100.778083  1359135
2022-01-04  100.334851  102.533236   99.681837  100.612131  1051188
2022-01-05  101.234591  103.072092  101.589828  101.650442  1339176
2022-01-06  104.068137  104.655775  103.396627  104.069893  1307894
2022-01-07  103.699983  104.463589  103.063624  103.788299  1875211
2022-01-10  103.285850  104.024716  103.113104  103.520441  2539229
2022-01-11  106.483048  106.742178  105.163646  106.113567  1294226
2022-01-12  107.981880  108.397072  106.785944  107.468554  2875613
2022-01-13  106.900514  106.905128  105.731800  106.853281  1448316
2022-01-14  108.676888  108.776337  107.302497  107.879909  1290669

Interprétation : données OHLCV simulées — propriétés statistiques et limites

Ce code génère des données OHLCV simulées qui imitent le comportement réel du marché. Bien que simulées, elles respectent les propriétés statistiques des prix financiers.

Processus de génération :

  1. Rendements : Modélisés comme processus gaussien avec drift et volatilité
    • daily_drift = 0.0003 → ~7.5% annuel (tendance haussière modeste)
    • daily_volatility = 0.015 → ~24% annuel (volatilité typique SPY)
  2. Régimes de volatilité : Ajout d’une composante sinusoïdale
    • Simule des périodes de haute/basse volatilité
    • Les marchés alternent entre régimes calmes et turbulents
  3. OHLC à partir du Close :
    • high/low : Ajout de volatilité intraday (±1%)
    • open : Prix d’ouverture avec gap overnight
    • volume : Corrélé avec la volatilité absolue

Propriétés réalistes : - Non-stationnarité : Les prix suivent une marche aléatoire (random walk) - Hétéroscédasticité : La volatilité varie dans le temps - Leverage effect : Volume augmente avec la volatilité

Limitations des données simulées : - Pas de sauts intraday (gap risk) - Pas de news/events - Distribution gaussienne (pas de fat tails)

En production avec QuantConnect :

# Obtenir les vraies données
history = self.history([symbol], 500, Resolution.DAILY)

Les données simulées ici sont uniquement pour démonstration pédagogique.


Ancre savante – Fama, E.F. (1965), The Behavior of Stock-Market Prices, The Journal of Business 38(1):34-105. (Formalisation de l’hypothese de marche aleatoire / random walk des prix, fondee sur l’efficience informationnelle ; justifie la non-stationnarite des prix et le passage aux rendements stationnaires.)

# Visualisation des donnees brutes
fig, axes = plt.subplots(2, 1, figsize=(14, 8), sharex=True)

# Prix
axes[0].plot(df.index, df['close'], label='Close', color='blue', linewidth=1)
axes[0].fill_between(df.index, df['low'], df['high'], alpha=0.2, color='blue', label='High-Low Range')
axes[0].set_ylabel('Prix ($)')
axes[0].set_title('Donnees OHLCV Brutes')
axes[0].legend()

# Volume
axes[1].bar(df.index, df['volume'], color='gray', alpha=0.6)
axes[1].set_ylabel('Volume')
axes[1].set_xlabel('Date')

plt.tight_layout()
plt.show()

print("\nStatistiques descriptives:")
print(df.describe())


Statistiques descriptives:
             open        high         low       close        volume
count  500.000000  500.000000  500.000000  500.000000  5.000000e+02
mean   131.161950  132.164234  130.077469  131.140417  1.677910e+06
std     25.959278   26.172548   25.740602   25.950453  5.703340e+05
min     95.765436   97.413864   96.143424   96.443361  1.023761e+06
25%    110.157934  110.935491  109.259541  110.115911  1.281939e+06
50%    124.326370  125.020585  122.964958  124.302612  1.486600e+06
75%    149.890601  152.993057  148.961770  150.002372  1.893244e+06
max    192.806954  194.475557  193.266042  194.443623  3.960890e+06

Partie 2 : Features Techniques - Price-Based (10 min)

Features derivees du prix

Les features basees sur le prix capturent :

Feature Description Formule
Rendements Variation relative du prix (P_t - P_{t-n}) / P_{t-n}
Volatilite Ecart-type des rendements std(returns, window)
Range Amplitude intraday (High - Low) / Close
Distance from extremes Position vs high/low recents (High_max - Close) / Close

Pourquoi les rendements plutot que les prix?

  1. Stationnarite : Les prix sont non-stationnaires, les rendements le sont (approximativement)
  2. Comparabilite : Permet de comparer des actifs a prix différents
  3. Interpretabilite : Un rendement de 2% est directement comprehensible
def calculate_price_features(df, return_periods=[1, 5, 10, 20], volatility_window=20):
    """
    Calcule les features basees sur le prix.
    
    Parameters:
    -----------
    df : pd.DataFrame
        DataFrame avec colonnes OHLCV
    return_periods : list
        Periodes pour calculer les rendements (ex: [1, 5, 20] jours)
    volatility_window : int
        Fenetre pour la volatilite
    
    Returns:
    --------
    pd.DataFrame
        DataFrame avec features ajoutees
    """
    result = df.copy()
    
    # === RETURNS ===
    # Rendements sur differentes periodes
    for period in return_periods:
        result[f'return_{period}d'] = result['close'].pct_change(period)
    
    # Log-returns (plus adaptes pour les modeles)
    result['log_return_1d'] = np.log(result['close'] / result['close'].shift(1))
    
    # === VOLATILITY ===
    # Volatilite historique (ecart-type des rendements)
    result[f'volatility_{volatility_window}d'] = result['return_1d'].rolling(volatility_window).std()
    
    # Volatilite annualisee
    result[f'volatility_annualized'] = result[f'volatility_{volatility_window}d'] * np.sqrt(252)
    
    # === RANGE (Amplitude) ===
    # Range intraday normalise
    result['range'] = (result['high'] - result['low']) / result['close']
    
    # True Range (inclut les gaps overnight)
    result['true_range'] = np.maximum(
        result['high'] - result['low'],
        np.maximum(
            np.abs(result['high'] - result['close'].shift(1)),
            np.abs(result['low'] - result['close'].shift(1))
        )
    ) / result['close']
    
    # Average True Range normalise
    result['atr_norm'] = result['true_range'].rolling(14).mean()
    
    # === DISTANCE FROM EXTREMES ===
    # Distance par rapport au plus haut des 20 derniers jours
    result['dist_from_high_20d'] = (result['high'].rolling(20).max() - result['close']) / result['close']
    
    # Distance par rapport au plus bas des 20 derniers jours
    result['dist_from_low_20d'] = (result['close'] - result['low'].rolling(20).min()) / result['close']
    
    # Position dans le range 20 jours (0 = au plus bas, 1 = au plus haut)
    high_20d = result['high'].rolling(20).max()
    low_20d = result['low'].rolling(20).min()
    result['position_in_range_20d'] = (result['close'] - low_20d) / (high_20d - low_20d)
    
    # === PRICE MOMENTUM ===
    # Ratio prix actuel / prix moyen
    result['price_to_sma_20'] = result['close'] / result['close'].rolling(20).mean()
    result['price_to_sma_50'] = result['close'] / result['close'].rolling(50).mean()
    
    return result

# Appliquer
df_features = calculate_price_features(df)

print("Features Price-Based ajoutees:")
price_features = [col for col in df_features.columns if col not in ['open', 'high', 'low', 'close', 'volume']]
for f in price_features:
    print(f"  - {f}")

print(f"\nNombre de features: {len(price_features)}")
Features Price-Based ajoutees:
  - return_1d
  - return_5d
  - return_10d
  - return_20d
  - log_return_1d
  - volatility_20d
  - volatility_annualized
  - range
  - true_range
  - atr_norm
  - dist_from_high_20d
  - dist_from_low_20d
  - position_in_range_20d
  - price_to_sma_20
  - price_to_sma_50

Nombre de features: 15

Interprétation : les catégories de features price-based

Ce code calcule les features basées sur les prix, qui sont les plus fondamentales pour toute stratégie ML de trading.

Catégories de features price-based :

Catégorie Features Ce que ça capture
Rendements return_1d, return_5d, return_10d, return_20d Performance sur différentes périodes
Volatilité volatility_20d, volatility_annualized Risque historique
Range range, true_range, atr_norm Amplitude des mouvements
Position dist_from_high_20d, dist_from_low_20d Position vs extrêmes récents
Tendance position_in_range_20d, price_to_sma_* Momentum relatif

Rendements multi-périodes : - return_1d : Court terme, bruit dominant - return_5d : Semaine, équilibre bruit/signal - return_20d : Mois, tendance plus claire - Combiner plusieurs périodes permet au ML de capturer différents horizons

Volatilité annualisée : - Formule : std(returns) * sqrt(252) - Permet de comparer différentes périodes - Utilisée pour le risk management

True Range (TR) : - Amplitude réelle incluant les gaps overnight - max(H-L, |H-C_prev|, |L-C_prev|) - Plus robuste que le simple High-Low

Position in Range : - 0 = au plus bas des 20 derniers jours - 1 = au plus haut des 20 derniers jours - 0.5 = au milieu - Intérêt : Identifie les conditions de survente/surachat sans RSI

Pourquoi utiliser les log-returns : - Symétriques : +10% et -10% ont la même amplitude - Additifs dans le temps : log_return(T) = sum(log_returns) - Plus stables pour les modèles ML


# Visualisation des features price-based
fig, axes = plt.subplots(3, 1, figsize=(14, 10), sharex=True)

# Rendements multi-periodes
ax1 = axes[0]
for period in [1, 5, 20]:
    ax1.plot(df_features.index, df_features[f'return_{period}d'] * 100, 
             label=f'Return {period}D', alpha=0.7)
ax1.axhline(y=0, color='black', linestyle='--', linewidth=0.5)
ax1.set_ylabel('Rendement (%)')
ax1.set_title('Rendements sur differentes periodes')
ax1.legend()

# Volatilite
ax2 = axes[1]
ax2.plot(df_features.index, df_features['volatility_annualized'] * 100, 
         color='red', label='Volatilite Annualisee')
ax2.fill_between(df_features.index, 0, df_features['volatility_annualized'] * 100, 
                 alpha=0.3, color='red')
ax2.set_ylabel('Volatilite (%)')
ax2.set_title('Volatilite Historique (rolling 20D, annualisee)')
ax2.legend()

# Position dans le range
ax3 = axes[2]
ax3.plot(df_features.index, df_features['position_in_range_20d'], color='purple')
ax3.axhline(y=0.5, color='black', linestyle='--', linewidth=0.5)
ax3.axhline(y=0.2, color='green', linestyle=':', linewidth=1, label='Zone Oversold')
ax3.axhline(y=0.8, color='red', linestyle=':', linewidth=1, label='Zone Overbought')
ax3.fill_between(df_features.index, 0, 0.2, alpha=0.1, color='green')
ax3.fill_between(df_features.index, 0.8, 1, alpha=0.1, color='red')
ax3.set_ylabel('Position (0-1)')
ax3.set_xlabel('Date')
ax3.set_title('Position dans le Range 20D (0=Low, 1=High)')
ax3.legend()

plt.tight_layout()
plt.show()


Interprétation : les features price-based en trois graphiques

Ce code visualise les features price-based calculées précédemment pour comprendre leur comportement temporel.

Graphique 1 - Rendements multi-périodes : - Return 1D : Très volatile, beaucoup de bruit - Return 5D : Plus lissé, capture les tendances hebdomadaires - Return 20D : Encore plus lissé, tendances mensuelles - Les rendements oscillent autour de 0 (marche aléatoire)

Graphique 2 - Volatilité annualisée : - Mesure le risque historique sur 20 jours - Annualisation : std * sqrt(252) pour comparaison - Les périodes de haute volatilité sont visibles (pics rouges) - La volatilité n’est pas constante (hétéroscédasticité)

Graphique 3 - Position dans le Range 20D : - 0 : Au plus bas des 20 derniers jours - 1 : Au plus haut des 20 derniers jours - 0.5 : Au milieu - Zones vertes (< 0.2) : Potentiellement survendu - Zones rouges (> 0.8) : Potentiellement suracheté

Insights visuels : - Les rendements courts sont dominés par le bruit - La volatilité varie dans le temps (regimes de marché) - La position dans le range peut identifier les extrêmes

Pourquoi ces visualisations : - Vérifier que les calculs sont corrects - Comprendre la distribution des features - Identifier les anomalies ou outliers



Exercice 1 : Features de momentum personnalisees

Le momentum classique compare le prix actuel au prix N jours plus tot. Des variantes plus sophistiquees combinent plusieurs horizons.

Objectif : Implementer un score de momentum multi-horizon.

Règles : - Calculez les retours sur 5, 10, 21, 63 et 252 jours ouvrables - Score de momentum = 0.1*ret_5d + 0.15*ret_10d + 0.25*ret_21d + 0.3*ret_63d + 0.2*ret_252d - Ajoutez une mesure de consistence : fraction des 5 retours positifs - Affichez les 10 actifs avec le meilleur score momentum

Indices : - # Indice : df.pct_change(N) pour le retour sur N periodes - # Indice : La consistence = (returns > 0).sum() / len(returns) sur les 5 horizons

# Exercice 1 : Momentum multi-horizon
# TODO etudiant : Implementer un score momentum combineant 5 horizons
# Indice : pct_change() pour chaque horizon, puis somme ponderee
# Etape 1 : Calculer les retours sur 5/10/21/63/252 jours
# Etape 2 : Appliquer les poids et calculer le score
# Etape 3 : Calculer la consistence (fraction de retours positifs)
# Etape 4 : Afficher le top 10 momentum

result = None  # TODO etudiant : remplacer par le score momentum multi-horizon
print("Exercice a completer")
Exercice a completer

Partie 3 : Features Techniques - Indicator-Based (15 min)

Indicateurs techniques comme features

Les indicateurs techniques populaires capturent différents aspects du marche :

Indicateur Type Ce qu’il capture
RSI Momentum Conditions surachat/survente
MACD Trend/Momentum Direction et force de la tendance
Bollinger Bands Volatilite Position relative et volatilite
Moving Averages Trend Tendance lissee
ADX Trend Strength Force de la tendance (pas direction)

Normalisation des indicateurs

Pour le ML, il est important de normaliser :

  • RSI : Déjà normalise [0, 100]
  • MACD : Diviser par le prix ou utiliser le ratio
  • BB : Utiliser %B (position normalisee)

Ancre savante – Wilder, J.W. (1978), New Concepts in Technical Trading Systems, Trend Research, Greensboro, NC. ISBN 978-0-89459-027-6. (Origine du RSI (Relative Strength Index) et de l’ATR (Average True Range), deux indicateurs de momentum et de volatilite les plus utilises.)

def calculate_indicator_features(df):
    """
    Calcule les features basees sur indicateurs techniques.
    
    Parameters:
    -----------
    df : pd.DataFrame
        DataFrame avec colonnes OHLCV
    
    Returns:
    --------
    pd.DataFrame
        DataFrame avec features indicateurs ajoutees
    """
    result = df.copy()
    close = result['close']
    high = result['high']
    low = result['low']
    
    # === MOVING AVERAGES ===
    result['sma_20'] = close.rolling(20).mean()
    result['sma_50'] = close.rolling(50).mean()
    result['ema_12'] = close.ewm(span=12, adjust=False).mean()
    result['ema_26'] = close.ewm(span=26, adjust=False).mean()
    
    # Ratios de MA (features normalisees)
    result['ma_ratio_20_50'] = result['sma_20'] / result['sma_50']
    result['price_to_ema_12'] = close / result['ema_12']
    
    # Cross signals (1 si fast > slow, 0 sinon)
    result['ma_cross_20_50'] = (result['sma_20'] > result['sma_50']).astype(int)
    
    # === RSI (Relative Strength Index) ===
    delta = close.diff()
    gain = delta.clip(lower=0)
    loss = (-delta).clip(lower=0)
    
    avg_gain = gain.rolling(14).mean()
    avg_loss = loss.rolling(14).mean()
    
    rs = avg_gain / avg_loss
    result['rsi_14'] = 100 - (100 / (1 + rs))
    
    # RSI normalise [-1, 1] pour ML
    result['rsi_normalized'] = (result['rsi_14'] - 50) / 50
    
    # === MACD (Moving Average Convergence Divergence) ===
    result['macd'] = result['ema_12'] - result['ema_26']
    result['macd_signal'] = result['macd'].ewm(span=9, adjust=False).mean()
    result['macd_hist'] = result['macd'] - result['macd_signal']
    
    # MACD normalise par prix (pour comparabilite)
    result['macd_norm'] = result['macd'] / close
    result['macd_hist_norm'] = result['macd_hist'] / close
    
    # === BOLLINGER BANDS ===
    bb_period = 20
    bb_std = 2
    
    bb_middle = close.rolling(bb_period).mean()
    bb_std_val = close.rolling(bb_period).std()
    bb_upper = bb_middle + (bb_std * bb_std_val)
    bb_lower = bb_middle - (bb_std * bb_std_val)
    
    # %B: Position dans les bandes (0 = lower, 1 = upper)
    result['bb_percent_b'] = (close - bb_lower) / (bb_upper - bb_lower)
    
    # Bandwidth: Largeur des bandes (mesure de volatilite)
    result['bb_bandwidth'] = (bb_upper - bb_lower) / bb_middle
    
    # Distance au middle band
    result['bb_dist_to_middle'] = (close - bb_middle) / (bb_std_val * bb_std)
    
    # === STOCHASTIC OSCILLATOR ===
    stoch_period = 14
    stoch_smooth = 3
    
    lowest_low = low.rolling(stoch_period).min()
    highest_high = high.rolling(stoch_period).max()
    
    # %K: Position relative dans le range
    result['stoch_k'] = 100 * (close - lowest_low) / (highest_high - lowest_low)
    # %D: Signal line (moyenne de %K)
    result['stoch_d'] = result['stoch_k'].rolling(stoch_smooth).mean()
    
    # Stochastic normalise [-1, 1]
    result['stoch_normalized'] = (result['stoch_k'] - 50) / 50
    
    # === ADX (Average Directional Index) ===
    # Mesure la FORCE de la tendance (pas la direction)
    adx_period = 14
    
    # Directional Movement
    plus_dm = high.diff()
    minus_dm = -low.diff()
    
    plus_dm = plus_dm.where((plus_dm > minus_dm) & (plus_dm > 0), 0)
    minus_dm = minus_dm.where((minus_dm > plus_dm) & (minus_dm > 0), 0)
    
    # True Range pour ADX
    tr = np.maximum(
        high - low,
        np.maximum(
            np.abs(high - close.shift(1)),
            np.abs(low - close.shift(1))
        )
    )
    
    # Smoothed
    atr = tr.rolling(adx_period).mean()
    plus_di = 100 * (plus_dm.rolling(adx_period).mean() / atr)
    minus_di = 100 * (minus_dm.rolling(adx_period).mean() / atr)
    
    # ADX
    dx = 100 * np.abs(plus_di - minus_di) / (plus_di + minus_di)
    result['adx'] = dx.rolling(adx_period).mean()
    
    # DI difference (direction)
    result['di_diff'] = plus_di - minus_di
    
    # === CCI (Commodity Channel Index) ===
    cci_period = 20
    typical_price = (high + low + close) / 3
    sma_tp = typical_price.rolling(cci_period).mean()
    mean_deviation = (typical_price - sma_tp).abs().rolling(cci_period).mean()
    result['cci'] = (typical_price - sma_tp) / (0.015 * mean_deviation)
    
    # CCI normalise (typiquement entre -200 et +200)
    result['cci_normalized'] = result['cci'] / 200
    result['cci_normalized'] = result['cci_normalized'].clip(-1, 1)
    
    return result

# Appliquer
df_features = calculate_indicator_features(df_features)

print("Features Indicator-Based ajoutees:")
indicator_features = ['sma_20', 'sma_50', 'ma_ratio_20_50', 'rsi_14', 'rsi_normalized',
                      'macd', 'macd_hist', 'macd_norm', 'bb_percent_b', 'bb_bandwidth',
                      'stoch_k', 'stoch_normalized', 'adx', 'di_diff', 'cci_normalized']
for f in indicator_features:
    if f in df_features.columns:
        print(f"  - {f}")

print(f"\nTotal features: {len([c for c in df_features.columns if c not in ['open', 'high', 'low', 'close', 'volume']])}")
Features Indicator-Based ajoutees:
  - sma_20
  - sma_50
  - ma_ratio_20_50
  - rsi_14
  - rsi_normalized
  - macd
  - macd_hist
  - macd_norm
  - bb_percent_b
  - bb_bandwidth
  - stoch_k
  - stoch_normalized
  - adx
  - di_diff
  - cci_normalized

Total features: 39

Interprétation : la famille d’indicateurs calculés et leurs normalisations

Ce code calcule les indicateurs techniques les plus utilisés en trading quantitatif. Ces indicateurs capturent différents aspects du comportement des prix.

Indicateurs calculés :

Groupe Indicateurs Capture
Tendance SMA, EMA, MA Cross Direction de la tendance
Momentum RSI, Stochastic, MACD Vitesse/force du mouvement
Volatilité Bollinger Bands, ATR Amplitude des variations
Force tendance ADX, DI Intensité de la tendance (non-directionnel)
Surachat/survente CCI Positions extrêmes

Normalisation pour ML : Plusieurs indicateurs sont normalisés pour être comparables : - rsi_normalized : Échelle [-1, 1] au lieu de [0, 100] - macd_norm : Divisé par le prix pour être relatif - bb_percent_b : Position dans les bandes [0, 1] - cci_normalized : CCI/200, borné à [-1, 1]

Indicateurs clés pour ML : - RSI : Fonctionne bien pour prédire les retournements - MACD histogram : Capture les changements de momentum - %B Bollinger : Meilleur que les prix bruts pour ML - ADX : Prédit si la tendance va persister

Pourquoi calculer tant d’indicateurs : - Chaque indicateur capture une information différente - Le ML peut sélectionner ceux qui sont pertinents - La redondance est gérée plus tard par feature sélection


Ancre savante – Jegadeesh, N. & Titman, S. (1993), Returns to Buying Winners and Selling Losers: Implications for Stock Market Efficiency, The Journal of Finance 48(1):65-91. DOI 10.1111/j.1540-6261.1993.tb04702.x. (Anomalie de momentum : les titres gagnants surperforment a court terme, fonde le momentum comme feature predictive.)

# Visualisation des features indicator-based
fig, axes = plt.subplots(4, 1, figsize=(14, 12), sharex=True)

# Prix avec MAs
ax1 = axes[0]
ax1.plot(df_features.index, df_features['close'], label='Close', linewidth=1)
ax1.plot(df_features.index, df_features['sma_20'], label='SMA 20', linewidth=1)
ax1.plot(df_features.index, df_features['sma_50'], label='SMA 50', linewidth=1)
ax1.set_ylabel('Prix ($)')
ax1.set_title('Prix et Moving Averages')
ax1.legend()

# RSI
ax2 = axes[1]
ax2.plot(df_features.index, df_features['rsi_14'], color='purple')
ax2.axhline(y=70, color='red', linestyle='--', label='Overbought (70)')
ax2.axhline(y=30, color='green', linestyle='--', label='Oversold (30)')
ax2.axhline(y=50, color='gray', linestyle=':')
ax2.fill_between(df_features.index, 70, 100, alpha=0.1, color='red')
ax2.fill_between(df_features.index, 0, 30, alpha=0.1, color='green')
ax2.set_ylabel('RSI')
ax2.set_ylim(0, 100)
ax2.set_title('RSI (14)')
ax2.legend()

# MACD
ax3 = axes[2]
ax3.plot(df_features.index, df_features['macd'], label='MACD', color='blue')
ax3.plot(df_features.index, df_features['macd_signal'], label='Signal', color='orange')
colors = ['green' if x > 0 else 'red' for x in df_features['macd_hist']]
ax3.bar(df_features.index, df_features['macd_hist'], color=colors, alpha=0.5, label='Histogram')
ax3.axhline(y=0, color='black', linestyle='-', linewidth=0.5)
ax3.set_ylabel('MACD')
ax3.set_title('MACD (12, 26, 9)')
ax3.legend()

# Bollinger %B
ax4 = axes[3]
ax4.plot(df_features.index, df_features['bb_percent_b'], color='teal')
ax4.axhline(y=1, color='red', linestyle='--', label='Upper Band')
ax4.axhline(y=0, color='green', linestyle='--', label='Lower Band')
ax4.axhline(y=0.5, color='gray', linestyle=':')
ax4.fill_between(df_features.index, 1, df_features['bb_percent_b'].max(), 
                 where=df_features['bb_percent_b'] > 1, alpha=0.2, color='red')
ax4.fill_between(df_features.index, df_features['bb_percent_b'].min(), 0, 
                 where=df_features['bb_percent_b'] < 0, alpha=0.2, color='green')
ax4.set_ylabel('%B')
ax4.set_xlabel('Date')
ax4.set_title('Bollinger Bands %B (position normalisee)')
ax4.legend()

plt.tight_layout()
plt.show()


Interprétation : prix, RSI, MACD et Bollinger en quatre graphiques

Ce code visualise les indicateurs techniques calculés par la fonction ci-dessus pour aider à comprendre leur comportement.

Graphique 1 - Prix et Moving Averages : - SMA 20 : Moyenne mobile simple à 20 jours (court terme) - SMA 50 : Moyenne mobile simple à 50 jours (moyen terme) - Quand SMA 20 > SMA 50 : Tendance haussière - Quand SMA 20 < SMA 50 : Tendance baissière

Graphique 2 - RSI (14) : - Zone > 70 : Surachat (possibilité de retournement baissier) - Zone < 30 : Survente (possibilité de retournement haussier) - 50 : Point neutre - Le RSI oscille entre 0 et 100 naturellement

Graphique 3 - MACD : - Ligne MACD (bleue) : EMA 12 - EMA 26 - Ligne Signal (orange) : EMA 9 du MACD - Histogramme : MACD - Signal (vert = positif, rouge = négatif) - Signal d’achat : MACD croise au-dessus du Signal - Signal de vente : MACD croise en-dessous du Signal

Graphique 4 - Bollinger %B : - > 1 : Prix au-dessus de la bande supérieure (surachat) - < 0 : Prix en-dessous de la bande inférieure (survente) - 0.5 : Prix au milieu des bandes - Les zones colorées montrent les extrêmes

Utilité de ces visualisations : - Comprendre le comportement des indicateurs - Identifier les conditions de marché extrêmes - Vérifier que les calculs sont corrects



Partie 4 : Features Fondamentales (20 min)

Données fondamentales dans QuantConnect

QuantConnect fournit des données fondamentales via MorningstarData. Ces features capturent la qualite et la valorisation des entreprises.

Catégories de features fondamentales

Catégorie Features Description
Valuation P/E, P/B, P/S, EV/EBITDA L’action est-elle chere ou bon marche?
Profitability ROE, ROA, Margin L’entreprise est-elle rentable?
Growth Revenue Growth, EPS Growth L’entreprise croit-elle?
Debt D/E, Interest Coverage Le bilan est-il sain?
Size Market Cap, Enterprise Value Quelle est la taille?

Utilisation dans QuantConnect

# Exemple de code QuantConnect pour features fondamentales
# A copier dans l'IDE QuantConnect

qc_fundamental_code = '''
def calculate_fundamental_features(self, fundamentals):
    """
    Extrait les features fondamentales depuis QuantConnect.
    
    Parameters:
    -----------
    fundamentals : QuantConnect.Data.Fundamental.Fundamental
        Objet fondamental pour un actif
    
    Returns:
    --------
    dict
        Dictionnaire de features
    """
    features = {}
    
    # === VALUATION RATIOS ===
    if fundamentals.ValuationRatios is not None:
        vr = fundamentals.ValuationRatios
        
        # Price to Earnings
        features['pe_ratio'] = vr.PERatio if hasattr(vr, 'PERatio') else np.nan
        
        # Price to Book
        features['pb_ratio'] = vr.PBRatio if hasattr(vr, 'PBRatio') else np.nan
        
        # Price to Sales
        features['ps_ratio'] = vr.PSRatio if hasattr(vr, 'PSRatio') else np.nan
        
        # Price to Cash Flow
        features['pcf_ratio'] = vr.PCFRatio if hasattr(vr, 'PCFRatio') else np.nan
        
        # Dividend Yield
        features['dividend_yield'] = vr.DividendYield if hasattr(vr, 'DividendYield') else 0
        
        # Earnings Yield (inverse of P/E)
        features['earnings_yield'] = vr.EarningYield if hasattr(vr, 'EarningYield') else np.nan
    
    # === PROFITABILITY ===
    if fundamentals.OperationRatios is not None:
        op = fundamentals.OperationRatios
        
        # Return on Equity
        if hasattr(op, 'ROE') and op.ROE is not None:
            features['roe'] = op.ROE.Value if hasattr(op.ROE, 'Value') else op.ROE
        else:
            features['roe'] = np.nan
        
        # Return on Assets
        if hasattr(op, 'ROA') and op.ROA is not None:
            features['roa'] = op.ROA.Value if hasattr(op.ROA, 'Value') else op.ROA
        else:
            features['roa'] = np.nan
        
        # Return on Invested Capital
        if hasattr(op, 'ROIC') and op.ROIC is not None:
            features['roic'] = op.ROIC.Value if hasattr(op.ROIC, 'Value') else op.ROIC
        else:
            features['roic'] = np.nan
        
        # Profit Margins
        if hasattr(op, 'NetMargin') and op.NetMargin is not None:
            features['net_margin'] = op.NetMargin.Value if hasattr(op.NetMargin, 'Value') else op.NetMargin
        else:
            features['net_margin'] = np.nan
        
        if hasattr(op, 'GrossMargin') and op.GrossMargin is not None:
            features['gross_margin'] = op.GrossMargin.Value if hasattr(op.GrossMargin, 'Value') else op.GrossMargin
        else:
            features['gross_margin'] = np.nan
        
        # Asset Turnover
        if hasattr(op, 'AssetsTurnover') and op.AssetsTurnover is not None:
            features['asset_turnover'] = op.AssetsTurnover.Value if hasattr(op.AssetsTurnover, 'Value') else op.AssetsTurnover
        else:
            features['asset_turnover'] = np.nan
    
    # === GROWTH ===
    if fundamentals.OperationRatios is not None:
        op = fundamentals.OperationRatios
        
        # Revenue Growth
        if hasattr(op, 'RevenueGrowth') and op.RevenueGrowth is not None:
            if hasattr(op.RevenueGrowth, 'ThreeMonths'):
                features['revenue_growth_3m'] = op.RevenueGrowth.ThreeMonths
            if hasattr(op.RevenueGrowth, 'OneYear'):
                features['revenue_growth_1y'] = op.RevenueGrowth.OneYear
    
    if fundamentals.EarningRatios is not None:
        er = fundamentals.EarningRatios
        
        # EPS Growth
        if hasattr(er, 'DilutedEPSGrowth') and er.DilutedEPSGrowth is not None:
            features['eps_growth'] = er.DilutedEPSGrowth
    
    # === DEBT / LEVERAGE ===
    if fundamentals.OperationRatios is not None:
        op = fundamentals.OperationRatios
        
        # Debt to Equity
        if hasattr(op, 'DebttoEquityRatio') and op.DebttoEquityRatio is not None:
            features['debt_to_equity'] = op.DebttoEquityRatio
        else:
            features['debt_to_equity'] = np.nan
        
        # Current Ratio
        if hasattr(op, 'CurrentRatio') and op.CurrentRatio is not None:
            features['current_ratio'] = op.CurrentRatio.Value if hasattr(op.CurrentRatio, 'Value') else op.CurrentRatio
        else:
            features['current_ratio'] = np.nan
    
    # === SIZE ===
    # Market Cap
    if hasattr(fundamentals, 'MarketCap'):
        features['market_cap'] = fundamentals.MarketCap
        features['log_market_cap'] = np.log(fundamentals.MarketCap) if fundamentals.MarketCap > 0 else np.nan
    
    return features
'''

print("Code QuantConnect pour features fondamentales:")
print(qc_fundamental_code)
Code QuantConnect pour features fondamentales:

def calculate_fundamental_features(self, fundamentals):
    """
    Extrait les features fondamentales depuis QuantConnect.

    Parameters:
    -----------
    fundamentals : QuantConnect.Data.Fundamental.Fundamental
        Objet fondamental pour un actif

    Returns:
    --------
    dict
        Dictionnaire de features
    """
    features = {}

    # === VALUATION RATIOS ===
    if fundamentals.ValuationRatios is not None:
        vr = fundamentals.ValuationRatios

        # Price to Earnings
        features['pe_ratio'] = vr.PERatio if hasattr(vr, 'PERatio') else np.nan

        # Price to Book
        features['pb_ratio'] = vr.PBRatio if hasattr(vr, 'PBRatio') else np.nan

        # Price to Sales
        features['ps_ratio'] = vr.PSRatio if hasattr(vr, 'PSRatio') else np.nan

        # Price to Cash Flow
        features['pcf_ratio'] = vr.PCFRatio if hasattr(vr, 'PCFRatio') else np.nan

        # Dividend Yield
        features['dividend_yield'] = vr.DividendYield if hasattr(vr, 'DividendYield') else 0

        # Earnings Yield (inverse of P/E)
        features['earnings_yield'] = vr.EarningYield if hasattr(vr, 'EarningYield') else np.nan

    # === PROFITABILITY ===
    if fundamentals.OperationRatios is not None:
        op = fundamentals.OperationRatios

        # Return on Equity
        if hasattr(op, 'ROE') and op.ROE is not None:
            features['roe'] = op.ROE.Value if hasattr(op.ROE, 'Value') else op.ROE
        else:
            features['roe'] = np.nan

        # Return on Assets
        if hasattr(op, 'ROA') and op.ROA is not None:
            features['roa'] = op.ROA.Value if hasattr(op.ROA, 'Value') else op.ROA
        else:
            features['roa'] = np.nan

        # Return on Invested Capital
        if hasattr(op, 'ROIC') and op.ROIC is not None:
            features['roic'] = op.ROIC.Value if hasattr(op.ROIC, 'Value') else op.ROIC
        else:
            features['roic'] = np.nan

        # Profit Margins
        if hasattr(op, 'NetMargin') and op.NetMargin is not None:
            features['net_margin'] = op.NetMargin.Value if hasattr(op.NetMargin, 'Value') else op.NetMargin
        else:
            features['net_margin'] = np.nan

        if hasattr(op, 'GrossMargin') and op.GrossMargin is not None:
            features['gross_margin'] = op.GrossMargin.Value if hasattr(op.GrossMargin, 'Value') else op.GrossMargin
        else:
            features['gross_margin'] = np.nan

        # Asset Turnover
        if hasattr(op, 'AssetsTurnover') and op.AssetsTurnover is not None:
            features['asset_turnover'] = op.AssetsTurnover.Value if hasattr(op.AssetsTurnover, 'Value') else op.AssetsTurnover
        else:
            features['asset_turnover'] = np.nan

    # === GROWTH ===
    if fundamentals.OperationRatios is not None:
        op = fundamentals.OperationRatios

        # Revenue Growth
        if hasattr(op, 'RevenueGrowth') and op.RevenueGrowth is not None:
            if hasattr(op.RevenueGrowth, 'ThreeMonths'):
                features['revenue_growth_3m'] = op.RevenueGrowth.ThreeMonths
            if hasattr(op.RevenueGrowth, 'OneYear'):
                features['revenue_growth_1y'] = op.RevenueGrowth.OneYear

    if fundamentals.EarningRatios is not None:
        er = fundamentals.EarningRatios

        # EPS Growth
        if hasattr(er, 'DilutedEPSGrowth') and er.DilutedEPSGrowth is not None:
            features['eps_growth'] = er.DilutedEPSGrowth

    # === DEBT / LEVERAGE ===
    if fundamentals.OperationRatios is not None:
        op = fundamentals.OperationRatios

        # Debt to Equity
        if hasattr(op, 'DebttoEquityRatio') and op.DebttoEquityRatio is not None:
            features['debt_to_equity'] = op.DebttoEquityRatio
        else:
            features['debt_to_equity'] = np.nan

        # Current Ratio
        if hasattr(op, 'CurrentRatio') and op.CurrentRatio is not None:
            features['current_ratio'] = op.CurrentRatio.Value if hasattr(op.CurrentRatio, 'Value') else op.CurrentRatio
        else:
            features['current_ratio'] = np.nan

    # === SIZE ===
    # Market Cap
    if hasattr(fundamentals, 'MarketCap'):
        features['market_cap'] = fundamentals.MarketCap
        features['log_market_cap'] = np.log(fundamentals.MarketCap) if fundamentals.MarketCap > 0 else np.nan

    return features

Interprétation : l’accès aux données fondamentales QuantConnect

Ce code montre comment accéder aux données fondamentales QuantConnect dans un algorithme réel. Les features fondamentales capturent la santé financière et la valorisation de l’entreprise.

Accès aux données fondamentales en QC :

# Dans l'IDE QuantConnect
fundamentals = self.securities[symbol].fundamentals

Propriétés clés accédées :

Propriété QC Feature Description
ValuationRatios.PERatio pe_ratio Price/Earnings
ValuationRatios.PBRatio pb_ratio Price/Book
OperationRatios.ROE roe Return on Equity
OperationRatios.NetMargin net_margin Marge nette
MarketCap market_cap Capitalisation boursière

Gestion des valeurs manquantes : - Toutes les features ne sont pas disponibles pour tous les actifs - Utiliser hasattr() et vérifier None avant l’accès - Remplacer par np.nan si indisponible

Patterns d’accès : 1. Vérifier si l’objet existe (if fundamentals.X is not None) 2. Accéder à la valeur (feature = fundamentals.X.Y) 3. Certains ratios ont une propriété .Value pour les types décimaux

Note importante : Ce code est pour l’IDE QuantConnect. Dans le notebook Jupyter local, nous simulons ces données car l’accès direct aux fondamentales QC nécessite une connexion live.


# Simuler des donnees fondamentales pour demonstration

def generate_fundamental_data(n_stocks=50, seed=42):
    """
    Genere des donnees fondamentales simulees.
    
    Returns:
    --------
    pd.DataFrame
        DataFrame avec features fondamentales
    """
    np.random.seed(seed)
    
    # Tickers simulees
    tickers = [f'STOCK_{i:03d}' for i in range(n_stocks)]
    
    # Generer features avec distributions realistes
    data = {
        'ticker': tickers,
        
        # Valuation (log-normal)
        'pe_ratio': np.exp(np.random.normal(2.7, 0.6, n_stocks)),  # Mean ~15
        'pb_ratio': np.exp(np.random.normal(0.7, 0.5, n_stocks)),  # Mean ~2
        'ps_ratio': np.exp(np.random.normal(0.5, 0.7, n_stocks)),  # Mean ~1.6
        'dividend_yield': np.abs(np.random.normal(0.02, 0.015, n_stocks)),
        
        # Profitability (normal avec bounds)
        'roe': np.clip(np.random.normal(0.15, 0.1, n_stocks), -0.3, 0.5),
        'roa': np.clip(np.random.normal(0.08, 0.06, n_stocks), -0.2, 0.3),
        'net_margin': np.clip(np.random.normal(0.10, 0.08, n_stocks), -0.2, 0.4),
        'gross_margin': np.clip(np.random.normal(0.35, 0.15, n_stocks), 0.1, 0.8),
        
        # Growth (normal)
        'revenue_growth': np.random.normal(0.08, 0.15, n_stocks),
        'eps_growth': np.random.normal(0.10, 0.25, n_stocks),
        
        # Debt (positive)
        'debt_to_equity': np.abs(np.random.normal(0.8, 0.5, n_stocks)),
        'current_ratio': np.abs(np.random.normal(1.5, 0.5, n_stocks)),
        
        # Size (log-normal)
        'market_cap': np.exp(np.random.normal(23, 2, n_stocks)),  # En dollars
    }
    
    df = pd.DataFrame(data)
    df['log_market_cap'] = np.log(df['market_cap'])
    
    # Ajouter earnings_yield (inverse de P/E)
    df['earnings_yield'] = 1 / df['pe_ratio']
    
    return df

# Generer
df_fundamentals = generate_fundamental_data(n_stocks=50)

print("Donnees fondamentales simulees:")
print(f"  Nombre d'actions: {len(df_fundamentals)}")
print(f"\nApercu:")
print(df_fundamentals.head(10))
Donnees fondamentales simulees:
  Nombre d'actions: 50

Apercu:
      ticker   pe_ratio  pb_ratio  ps_ratio  dividend_yield       roe  \
0  STOCK_000  20.045977  2.367991  0.612161        0.023757  0.185779   
1  STOCK_001  13.695145  1.661065  1.228198        0.025197  0.206078   
2  STOCK_002  21.946620  1.435537  1.297060        0.009800  0.258305   
3  STOCK_003  37.107456  2.734198  0.940264        0.023484  0.255380   
4  STOCK_004  12.929454  3.371979  1.472704        0.024396  0.012233   
5  STOCK_005  12.929582  3.207976  2.187667        0.009285  0.056217   
6  STOCK_006  38.379662  1.323648  6.173896        0.047987  0.201504   
7  STOCK_007  23.581461  1.725288  1.863031        0.027107  0.201379   
8  STOCK_008  11.226930  2.376507  1.974441        0.002130  0.201505   
9  STOCK_009  20.605047  3.279768  1.565003        0.029848  0.500000   

        roa  net_margin  gross_margin  revenue_growth  eps_growth  \
0  0.004347    0.033680      0.396636       -0.159164    0.084330   
1  0.135072    0.055186      0.571303       -0.009906    0.338786   
2  0.207329    0.159783      0.478649        0.080787   -0.146432   
3  0.141948    0.148830      0.326009        0.087047    0.226012   
4 -0.011162    0.098328      0.347148        0.012490   -0.032564   
5  0.050946    0.109386      0.199621        0.173427   -0.098218   
6  0.156015    0.202213      0.347223       -0.080143    0.073242   
7  0.037540    0.052674      0.306701        0.058643   -0.158811   
8  0.106629    0.143768      0.398408        0.098044   -0.038412   
9  0.126478    0.083825      0.225915        0.157166   -0.199469   

   debt_to_equity  current_ratio    market_cap  log_market_cap  earnings_yield  
0        1.263089       1.543295  4.428790e+10       24.513977        0.049885  
1        1.754708       1.422161  1.540957e+09       21.155669        0.073019  
2        0.100716       2.083891  5.547575e+10       24.739212        0.045565  
3        1.081485       1.627210  1.466443e+11       25.711276        0.026949  
4        0.474679       1.668801  2.227810e+10       23.826870        0.077343  
5        0.556437       1.294062  4.158505e+11       26.753592        0.077342  
6        0.503803       1.256197  2.073329e+09       21.452422        0.026055  
7        0.368005       1.283721  8.084995e+08       20.510691        0.042406  
8        0.824261       1.697226  2.778410e+08       19.442560        0.089072  
9        0.384525       1.289508  1.941872e+11       25.992089        0.048532  

Interprétation : données fondamentales simulées — distributions et relations attendues

Ce code simule des données fondamentales pour 50 actions : depuis un notebook Jupyter local, les données réelles ne sont pas accessibles — en production elles proviendraient de QuantConnect via MorningstarData. La fonction generate_fundamental_data() utilise pour chaque type de ratio une distribution statistique appropriée.

Catégories de features fondamentales :

Catégorie Features simulées Distribution
Valuation P/E, P/B, P/S, Dividend Yield Log-normale (valeurs positives)
Profitabilité ROE, ROA, Net Margin, Gross Margin Normale avec bounds
Growth Revenue Growth, EPS Growth Normale
Debt D/E, Current Ratio Normale absolue
Size Market Cap, Log Market Cap Log-normale

Distributions choisies :

Feature Distribution Pourquoi
P/E, P/B, P/S Log-normale Toujours positifs, queue à droite (certaines actions très chères)
ROE, ROA Normale clipée Peuvent être négatives mais bornées (~-30% à +50%)
Margins Normale clipée Bornées par la réalité économique
Growth Normale Peuvent être négatives (croissance négative)
D/E Normale absolue Le ratio est toujours positif
Market Cap Log-normale Très forte dispersion (small caps à mega caps)

Paramètres réalistes : - P/E moyen : exp(2.7) ≈ 15 (marché US typique) - ROE moyen : 15% (sain pour une entreprise) - Net Margin moyen : 10% (typique) - Revenue Growth std : 15% (volatilité des taux de croissance)

Log Market Cap : - log(market_cap) au lieu de market_cap brut - Distribution plus symétrique et normale, meilleure pour les modèles ML (évite les outliers extrêmes, échelle comparable) - Interprétation : “taille relative” plutôt que valeur absolue

Earnings Yield : - Calculé comme 1 / P/E - C’est le rendement inversé : combien l’entreprise gagne par rapport à son prix - Utile pour comparer avec les taux d’intérêt

Relations attendues entre features : - Actions à faible P/E (value) ont souvent un ROE plus faible - High growth (Revenue, EPS) corrélé avec un P/E plus élevé - Debt-to-Equity élevé peut indiquer plus de risque


# Visualisation des features fondamentales
fig, axes = plt.subplots(2, 3, figsize=(15, 10))

# P/E Ratio distribution
axes[0, 0].hist(df_fundamentals['pe_ratio'], bins=20, color='steelblue', edgecolor='white')
axes[0, 0].axvline(df_fundamentals['pe_ratio'].median(), color='red', linestyle='--', label=f"Median: {df_fundamentals['pe_ratio'].median():.1f}")
axes[0, 0].set_xlabel('P/E Ratio')
axes[0, 0].set_title('Distribution du P/E Ratio')
axes[0, 0].legend()

# ROE distribution
axes[0, 1].hist(df_fundamentals['roe'] * 100, bins=20, color='forestgreen', edgecolor='white')
axes[0, 1].axvline(df_fundamentals['roe'].median() * 100, color='red', linestyle='--', label=f"Median: {df_fundamentals['roe'].median()*100:.1f}%")
axes[0, 1].set_xlabel('ROE (%)')
axes[0, 1].set_title('Distribution du ROE')
axes[0, 1].legend()

# Debt to Equity
axes[0, 2].hist(df_fundamentals['debt_to_equity'], bins=20, color='coral', edgecolor='white')
axes[0, 2].axvline(1.0, color='black', linestyle='--', label='D/E = 1.0')
axes[0, 2].set_xlabel('Debt/Equity')
axes[0, 2].set_title('Distribution du Debt/Equity')
axes[0, 2].legend()

# Scatter P/E vs ROE (Value vs Quality)
scatter = axes[1, 0].scatter(df_fundamentals['pe_ratio'], df_fundamentals['roe'] * 100,
                              c=df_fundamentals['log_market_cap'], cmap='viridis', alpha=0.6)
axes[1, 0].set_xlabel('P/E Ratio')
axes[1, 0].set_ylabel('ROE (%)')
axes[1, 0].set_title('P/E vs ROE (couleur = Market Cap)')
plt.colorbar(scatter, ax=axes[1, 0], label='Log Market Cap')

# Growth vs Margin
scatter2 = axes[1, 1].scatter(df_fundamentals['revenue_growth'] * 100, df_fundamentals['net_margin'] * 100,
                               c=df_fundamentals['pe_ratio'], cmap='coolwarm', alpha=0.6)
axes[1, 1].set_xlabel('Revenue Growth (%)')
axes[1, 1].set_ylabel('Net Margin (%)')
axes[1, 1].set_title('Growth vs Margin (couleur = P/E)')
axes[1, 1].axvline(0, color='gray', linestyle='--', alpha=0.5)
axes[1, 1].axhline(0, color='gray', linestyle='--', alpha=0.5)
plt.colorbar(scatter2, ax=axes[1, 1], label='P/E Ratio')

# Correlation heatmap
fundamental_cols = ['pe_ratio', 'pb_ratio', 'roe', 'roa', 'net_margin', 
                    'revenue_growth', 'debt_to_equity', 'log_market_cap']
corr = df_fundamentals[fundamental_cols].corr()
sns.heatmap(corr, annot=True, fmt='.2f', cmap='RdBu_r', center=0, ax=axes[1, 2])
axes[1, 2].set_title('Correlation entre Features')

plt.tight_layout()
plt.show()


Exercice 2 : Features fondamentales personnalisees

Les fundamentals Morningstar offrent des dizaines de ratios. Un feature engineering avance combine plusieurs ratios en un score composite.

Objectif : Créer un score de sante financiere composite a partir de 5 ratios fondamentaux.

Règles : - Calculez 5 ratios : P/E inverse, ROE, Debt/Equity inverse, Current ratio, FCF Yield - Normalisez chaque ratio entre 0 et 1 (min-max sur l’univers) - Score composite = moyenne ponderee (P/E: 20%, ROE: 30%, D/E: 15%, Current: 15%, FCF: 20%) - Classez les actifs par score composite et affichez le top 10

Indices : - # Indice : sklearn.preprocessing.MinMaxScaler pour la normalisation - # Indice : Les poids du score composite sont dans l’enonce ci-dessus

# Exercice 2 : Score de sante financiere composite
# TODO etudiant : Combiner 5 ratios fondamentaux en un score unique
# Indice : MinMaxScaler + moyenne ponderee avec les poids donnes
# Etape 1 : Calculer les 5 ratios pour chaque actif
# Etape 2 : Normaliser entre 0 et 1
# Etape 3 : Appliquer les poids et calculer le score composite
# Etape 4 : Classer et afficher le top 10

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

Partie 5 : Labeling pour Classification et Regression (20 min)

Qu’est-ce que le labeling?

Le labeling est le processus de creation des targets (y) a partir des données de prix futurs. C’est une étape critique car elle définit ce que le modèle va apprendre a predire.

Types de labels

Type Description Utilisation
Binary Up (1) / Down (0) Classification simple
Ternary Up (1) / Flat (0) / Down (-1) Classification avec neutre
Multi-class Quantiles (Q1, Q2, Q3, Q4) Classification fine
Regression Rendement continu Prediction du rendement exact

Horizon de prediction

t=0 (maintenant)                    t=horizon (futur)
    |                                    |
    v                                    v
[Features calculees]  ---> [Modèle] ---> [Label = future return]

Attention au lookahead bias! Le label utilise des données futures, donc ne jamais l’inclure dans les features.

def create_classification_labels(df, horizon=5, threshold=0.0):
    """
    Cree des labels pour classification.
    
    Parameters:
    -----------
    df : pd.DataFrame
        DataFrame avec colonne 'close'
    horizon : int
        Nombre de jours pour le rendement futur
    threshold : float
        Seuil pour separer up/down (0 = simple direction)
    
    Returns:
    --------
    pd.DataFrame
        DataFrame avec labels ajoutes
    """
    result = df.copy()
    
    # Rendement futur sur l'horizon
    result['future_return'] = result['close'].shift(-horizon) / result['close'] - 1
    
    # === BINARY LABEL ===
    # 1 si rendement > threshold, 0 sinon
    result['label_binary'] = (result['future_return'] > threshold).astype(int)
    
    # === TERNARY LABEL ===
    # 1 (up), 0 (flat), -1 (down)
    result['label_ternary'] = 0
    result.loc[result['future_return'] > threshold, 'label_ternary'] = 1
    result.loc[result['future_return'] < -threshold, 'label_ternary'] = -1
    
    # === QUANTILE LABEL ===
    # Diviser en quartiles (0, 1, 2, 3)
    result['label_quantile'] = pd.qcut(
        result['future_return'].dropna(), 
        q=4, 
        labels=[0, 1, 2, 3],
        duplicates='drop'
    ).reindex(result.index)
    
    return result


def create_regression_labels(df, horizon=5):
    """
    Cree des labels pour regression.
    
    Parameters:
    -----------
    df : pd.DataFrame
        DataFrame avec colonne 'close'
    horizon : int
        Nombre de jours pour la prediction
    
    Returns:
    --------
    pd.DataFrame
        DataFrame avec labels regression ajoutes
    """
    result = df.copy()
    
    # Rendement futur (target principal)
    result['target_return'] = result['close'].shift(-horizon) / result['close'] - 1
    
    # Log-return (souvent mieux pour ML)
    result['target_log_return'] = np.log(result['close'].shift(-horizon) / result['close'])
    
    # Volatilite future (pour prediction de risque)
    result['target_volatility'] = result['return_1d'].shift(-horizon).rolling(horizon).std()
    
    # Sharpe-like ratio (rendement / volatilite)
    result['target_sharpe'] = result['target_return'] / (result['target_volatility'] + 1e-8)
    
    return result

# Appliquer les deux types de labeling
df_labeled = create_classification_labels(df_features, horizon=5, threshold=0.01)
df_labeled = create_regression_labels(df_labeled, horizon=5)

print("Labels crees:")
print("\nClassification:")
print(f"  - label_binary: {df_labeled['label_binary'].value_counts().to_dict()}")
print(f"  - label_ternary: {df_labeled['label_ternary'].value_counts().to_dict()}")
print("\nRegression:")
print(f"  - target_return: mean={df_labeled['target_return'].mean():.4f}, std={df_labeled['target_return'].std():.4f}")
Labels crees:

Classification:
  - label_binary: {0: 286, 1: 214}
  - label_ternary: {1: 214, -1: 182, 0: 104}

Regression:
  - target_return: mean=0.0027, std=0.0359

Interprétation : les labels de classification et de régression créés

Ce code crée les labels (targets) pour les modèles ML. Le labeling transforme les données futures en valeurs que le modèle peut apprendre à prédire.

Labels de classification créés :

Type Description Distribution typique
label_binary 1 si future_return > 0, 0 sinon Équilibré ~50/50
label_ternary 1 (up), 0 (flat), -1 (down) 3 classes selon threshold
label_quantile Quartiles Q1, Q2, Q3, Q4 4 classes égales

Labels de régression créés :

Target Description Utilisation
target_return Rendement futur brut Régression standard
target_log_return Log-rendement futur Plus stable pour ML
target_volatility Volatilité future Prédiction de risque
target_sharpe Ratio rendement/volatilité Score ajusté au risque

Horizon de 5 jours choisi ici : - Court terme : évite le “regime change” pendant la période - Suffisamment long : capture les mouvements significatifs - Trade-off : Horizon trop court = bruit, trop long = signal dégradé

Threshold de 1% pour ternary : - Élimine les mouvements insignifiants (< 1%) - Crée une classe “flat” pour les périodes d’incertitude - Réduit le bruit de classification

Distribution des labels : - Si label_binary est très déséquilibré (ex: 90% Up), le modèle aura du biais - Le seuil (threshold) permet d’ajuster l’équilibre des classes - Pour un marché haussier, les labels Up seront naturellement plus fréquents


Ancre savante – Sharpe, W.F. (1966), Mutual Fund Performance, The Journal of Business 39(1):119-138. DOI 10.1086/294846. (Ratio de Sharpe / reward-to-variability : rendement ajuste au risque, utilise ici comme cible (target_sharpe) du feature engineering.)

# Visualisation des labels
fig, axes = plt.subplots(2, 2, figsize=(14, 10))

# Distribution du rendement futur
ax1 = axes[0, 0]
ax1.hist(df_labeled['future_return'].dropna() * 100, bins=50, color='steelblue', edgecolor='white')
ax1.axvline(0, color='red', linestyle='--', label='Zero')
ax1.axvline(1, color='green', linestyle=':', label='+1% threshold')
ax1.axvline(-1, color='orange', linestyle=':', label='-1% threshold')
ax1.set_xlabel('Future Return (%)')
ax1.set_ylabel('Frequency')
ax1.set_title(f'Distribution du Rendement Futur ({5}D)')
ax1.legend()

# Distribution des labels binaires
ax2 = axes[0, 1]
counts = df_labeled['label_binary'].value_counts().sort_index()
bars = ax2.bar(['Down (0)', 'Up (1)'], counts.values, color=['red', 'green'])
ax2.set_ylabel('Count')
ax2.set_title('Distribution des Labels Binaires')
for bar, count in zip(bars, counts.values):
    ax2.text(bar.get_x() + bar.get_width()/2, bar.get_height() + 5, 
             f'{count}\n({count/counts.sum()*100:.1f}%)', ha='center')

# Distribution des labels ternaires
ax3 = axes[1, 0]
counts_ternary = df_labeled['label_ternary'].value_counts().sort_index()
bars = ax3.bar(['Down (-1)', 'Flat (0)', 'Up (1)'], counts_ternary.values, 
               color=['red', 'gray', 'green'])
ax3.set_ylabel('Count')
ax3.set_title('Distribution des Labels Ternaires (threshold=1%)')
for bar, count in zip(bars, counts_ternary.values):
    ax3.text(bar.get_x() + bar.get_width()/2, bar.get_height() + 5, 
             f'{count}\n({count/counts_ternary.sum()*100:.1f}%)', ha='center')

# Rendement futur vs indicateurs
ax4 = axes[1, 1]
valid_data = df_labeled.dropna(subset=['future_return', 'rsi_14'])
scatter = ax4.scatter(valid_data['rsi_14'], valid_data['future_return'] * 100,
                      c=valid_data['label_binary'], cmap='RdYlGn', alpha=0.5)
ax4.axhline(0, color='black', linestyle='-', linewidth=0.5)
ax4.axvline(30, color='blue', linestyle='--', alpha=0.5, label='RSI Oversold')
ax4.axvline(70, color='blue', linestyle='--', alpha=0.5, label='RSI Overbought')
ax4.set_xlabel('RSI (14)')
ax4.set_ylabel('Future Return (%)')
ax4.set_title('RSI vs Rendement Futur')
ax4.legend()

plt.tight_layout()
plt.show()

# Methodes de labeling avancees

def create_triple_barrier_labels(df, horizon=5, profit_take=0.02, stop_loss=0.02):
    """
    Methode Triple Barrier de Lopez de Prado.
    
    Le label est determine par quelle barriere est touchee en premier:
    - Upper barrier (profit take): label = 1
    - Lower barrier (stop loss): label = -1
    - Time barrier (horizon): label = sign(return)
    
    Parameters:
    -----------
    df : pd.DataFrame
        DataFrame avec colonnes OHLCV
    horizon : int
        Nombre de jours maximum
    profit_take : float
        Seuil de profit (ex: 0.02 = 2%)
    stop_loss : float
        Seuil de perte (ex: 0.02 = 2%)
    
    Returns:
    --------
    pd.Series
        Labels (-1, 0, 1)
    """
    labels = pd.Series(index=df.index, dtype=float)
    
    for i in range(len(df) - horizon):
        entry_price = df['close'].iloc[i]
        upper_barrier = entry_price * (1 + profit_take)
        lower_barrier = entry_price * (1 - stop_loss)
        
        # Regarder les jours suivants
        for j in range(1, horizon + 1):
            if i + j >= len(df):
                break
            
            high = df['high'].iloc[i + j]
            low = df['low'].iloc[i + j]
            close = df['close'].iloc[i + j]
            
            # Upper barrier touchee?
            if high >= upper_barrier:
                labels.iloc[i] = 1
                break
            
            # Lower barrier touchee?
            if low <= lower_barrier:
                labels.iloc[i] = -1
                break
            
            # Time barrier (dernier jour)
            if j == horizon:
                ret = (close - entry_price) / entry_price
                if abs(ret) < 0.005:  # < 0.5% = flat
                    labels.iloc[i] = 0
                else:
                    labels.iloc[i] = 1 if ret > 0 else -1
    
    return labels

# Appliquer Triple Barrier
df_labeled['label_triple_barrier'] = create_triple_barrier_labels(
    df_labeled, horizon=5, profit_take=0.02, stop_loss=0.02
)

print("Triple Barrier Labels:")
print(df_labeled['label_triple_barrier'].value_counts().sort_index())

print("\nAvantages du Triple Barrier:")
print("  - Plus realiste (simule take profit / stop loss)")
print("  - Capture le timing du mouvement")
print("  - Moins de labels neutres")
Triple Barrier Labels:
label_triple_barrier
-1.0    233
 0.0      8
 1.0    254
Name: count, dtype: int64

Avantages du Triple Barrier:
  - Plus realiste (simule take profit / stop loss)
  - Capture le timing du mouvement
  - Moins de labels neutres

Interprétation : la méthode Triple Barrier de Lopez de Prado

Ce code implémente la méthode Triple Barrier de Lopez de Prado, une technique de labeling avancée qui simule la réalité du trading avec stop-loss et take-profit.

Concept du Triple Barrier :

      Upper Barrier (+2%)
             ↑
             |
    ←------- | -------→  Time
             |
             ↓
      Lower Barrier (-2%)

Logique : 1. Upper Barrier (Take Profit) : Si le prix monte de +2%, label = +1 2. Lower Barrier (Stop Loss) : Si le prix baisse de -2%, label = -1 3. Time Barrier : Si horizon atteint sans toucher les barrières, label = sign(return)

Avantages vs labeling simple :

Aspect Labeling Simple Triple Barrier
Réalisme Théorique Simule stop-loss / take-profit
Timing Ignore quand le mouvement se produit Capture le timing exact
False positives Peut labeler “Up” un mouvement qui a d’abord chossé Plus robuste
Complexité Simple Plus complexe à calculer

Paramètres : - profit_take=0.02 : 2% de gain - stop_loss=0.02 : 2% de perte - horizon=5 : 5 jours maximum

Distribution typique des labels : - Moins de labels “flat” (0) car le time barrier est rare - Plus de labels extrêmes (+1/-1) car les barrières sont souvent touchées

Note : Cette méthode est plus adaptée au trading réel car elle reflète la façon dont les trades sont réellement gérés.



Partie 6 : Feature Sélection et Importance (20 min)

Pourquoi sélectionner les features?

Raison Explication
Overfitting Trop de features = modèle memorise le bruit
Curse of dimensionality Performance degrade avec dimensions
Interpretabilite Moins de features = plus comprehensible
Vitesse Moins de features = entrainement plus rapide

Techniques de sélection

Feature Sélection
       |
       +-- Filter Methods (rapide, indépendant du modèle)
       |      +-- Correlation avec target
       |      +-- Variance threshold
       |      +-- Mutual Information
       |
       +-- Wrapper Methods (lent, modèle-dependant)
       |      +-- Forward sélection
       |      +-- Backward elimination
       |      +-- RFE (Récursive Feature Elimination)
       |
       +-- Embedded Methods (integre dans le modèle)
              +-- L1 Regularization (Lasso)
              +-- Tree-based importance

Ancres savantes – Pearson, K. (1901), On Lines and Planes of Closest Fit to Systems of Points in Space, The London, Edinburgh, and Dublin Philosophical Magazine and Journal of Science 2(11):559-572. DOI 10.1080/14786440109462720 (origine de l’ACP / PCA, reduction de dimensionnalite) ; Guyon, I., Weston, J., Barnhill, S. & Vapnik, V. (2002), Gene Sélection for Cancer Classification using Support Vector Machines, Machine Learning 46(1-3):389-422. DOI 10.1023/A:1012487302797 (Récursive Feature Elimination / RFE, méthode wrapper de sélection de features).)

from sklearn.ensemble import RandomForestClassifier
from sklearn.feature_selection import mutual_info_classif, SelectKBest

def calculate_feature_importance(X, y, method='random_forest'):
    """
    Calcule l'importance des features.
    
    Parameters:
    -----------
    X : pd.DataFrame
        Features
    y : pd.Series
        Labels
    method : str
        'random_forest', 'mutual_info', ou 'correlation'
    
    Returns:
    --------
    pd.DataFrame
        DataFrame avec features et leur importance
    """
    if method == 'random_forest':
        # Tree-based importance
        model = RandomForestClassifier(n_estimators=100, max_depth=10, random_state=42, n_jobs=-1)
        model.fit(X, y)
        importance = model.feature_importances_
        
    elif method == 'mutual_info':
        # Information mutuelle (capture non-linearites)
        importance = mutual_info_classif(X, y, random_state=42)
        
    elif method == 'correlation':
        # Correlation absolue avec target
        importance = X.apply(lambda col: abs(col.corr(y))).values
    
    else:
        raise ValueError(f"Unknown method: {method}")
    
    importance_df = pd.DataFrame({
        'feature': X.columns,
        'importance': importance
    }).sort_values('importance', ascending=False)
    
    # Normaliser (0-1)
    importance_df['importance_normalized'] = (
        importance_df['importance'] / importance_df['importance'].max()
    )
    
    return importance_df.reset_index(drop=True)

# Preparer les donnees pour feature selection
feature_cols = [col for col in df_labeled.columns 
                if col not in ['open', 'high', 'low', 'close', 'volume',
                               'future_return', 'label_binary', 'label_ternary',
                               'label_quantile', 'target_return', 'target_log_return',
                               'target_volatility', 'target_sharpe', 'label_triple_barrier']]

# Supprimer les lignes avec NaN
df_clean = df_labeled.dropna(subset=feature_cols + ['label_binary'])

X = df_clean[feature_cols]
y = df_clean['label_binary']

print(f"Nombre de features: {len(feature_cols)}")
print(f"Nombre d'echantillons: {len(df_clean)}")

# Calculer importance avec Random Forest
importance_rf = calculate_feature_importance(X, y, method='random_forest')

print("\nTop 15 Features (Random Forest Importance):")
print(importance_rf.head(15).to_string(index=False))
Nombre de features: 39
Nombre d'echantillons: 451

Top 15 Features (Random Forest Importance):
              feature  importance  importance_normalized
            macd_norm    0.061092               1.000000
               sma_50    0.057970               0.948901
      price_to_sma_50    0.049636               0.812481
                 macd    0.045790               0.749532
               ema_12    0.045658               0.747365
               ema_26    0.045547               0.745554
               sma_20    0.039392               0.644803
       volatility_20d    0.039276               0.642893
volatility_annualized    0.036526               0.597883
         bb_bandwidth    0.035192               0.576059
            macd_hist    0.033460               0.547703
          macd_signal    0.033406               0.546824
       ma_ratio_20_50    0.032513               0.532198
           return_20d    0.025565               0.418461
                  adx    0.025513               0.417619
def remove_correlated_features(df, threshold=0.95):
    """
    Supprime les features fortement correlees.
    
    Parameters:
    -----------
    df : pd.DataFrame
        DataFrame de features
    threshold : float
        Seuil de correlation (ex: 0.95 = 95%)
    
    Returns:
    --------
    tuple : (DataFrame filtre, liste des features supprimees)
    """
    # Matrice de correlation
    corr_matrix = df.corr().abs()
    
    # Triangle superieur (eviter doublons)
    upper = corr_matrix.where(
        np.triu(np.ones(corr_matrix.shape), k=1).astype(bool)
    )
    
    # Features a supprimer (correlation > threshold)
    to_drop = [col for col in upper.columns if any(upper[col] > threshold)]
    
    # Supprimer
    df_filtered = df.drop(columns=to_drop)
    
    return df_filtered, to_drop

# Appliquer
X_filtered, dropped_features = remove_correlated_features(X, threshold=0.90)

print(f"Features supprimees (correlation > 90%):")
for f in dropped_features:
    print(f"  - {f}")

print(f"\nFeatures restantes: {len(X_filtered.columns)} (vs {len(X.columns)} avant)")
Features supprimees (correlation > 90%):
  - log_return_1d
  - volatility_annualized
  - dist_from_low_20d
  - price_to_sma_20
  - price_to_sma_50
  - sma_50
  - ema_12
  - ema_26
  - ma_ratio_20_50
  - price_to_ema_12
  - rsi_14
  - rsi_normalized
  - macd
  - macd_signal
  - macd_norm
  - macd_hist_norm
  - bb_percent_b
  - bb_dist_to_middle
  - stoch_k
  - stoch_d
  - stoch_normalized
  - di_diff
  - cci
  - cci_normalized

Features restantes: 15 (vs 39 avant)

Interprétation : la suppression des features fortement corrélées

Ce code supprime les features fortement corrélées pour éviter la redondance dans le dataset.

Fonction remove_correlated_features() :

  1. Calcule la matrice de corrélation absolue
  2. Examine le triangle supérieur uniquement (évite les doublons)
  3. Identifie les paires avec corrélation > seuil (90% ici)
  4. Supprime une des deux features de chaque paire corrélée

Pourquoi supprimer les features corrélées ?

Problème Explication
Redondance Deux features corrélées apportent la même information
Overfitting Le modèle peut donner trop de poids à la même information
Instabilité Petits changements peuvent affecter plusieurs features corrélés
Interprétabilité Difficile de comprendre quel feature est vraiment important

Seuil de 90% : - Corrélation très élevée → presque dupliquée - On pourrait utiliser 95% pour être plus conservateur - Ou 80% pour être plus agressif

Exemple de features corrélées : - sma_20 et ema_12 : Mesures de tendance à court terme - return_5d et return_10d : Rendements à différentes périodes - volatility_20d et atr_norm : Mesures de volatilité

Stratégie de suppression : - La fonction supprime la seconde feature de chaque paire - L’ordre des colonnes détermine laquelle est gardée - En production, on pourrait prioriser les features plus “explicables”


Élimination récursive des variables (RFE)

La suppression par corrélation ci-dessus ne traite que les redondances deux à deux. Une feature peut être inutile sans être corrélée à aucune autre — la corrélation ne la verra pas. L’élimination récursive des variables (Recursive Feature Elimination, RFE) attaque le problème par le modèle lui-même : entraîner, retirer la feature la moins importante, recommencer.

Étape Ce qui se passe
1 Le modèle est entraîné sur toutes les features restantes
2 La feature de plus faible importance est retirée
3 Retour à l’étape 1, tant qu’il reste plus de features que la cible

RFE appartient aux wrapper methods (arbre de la Partie 6) : le classement dépend du modèle choisi, et son coût est celui de plusieurs entraînements successifs.

Ancres savantes — Guyon, I., Weston, J., Barnhill, S. & Vapnik, V. (2002), Gene selection for cancer classification using support vector machines, Machine Learning 46(1):389-422 (introduction de RFE, sélection par retrait récursif autour d’un SVM) ; Breiman, L. (2001), Random Forests, Machine Learning 45(1):5-32 (importance par diminution d’impureté, utilisée ici comme critère de retrait).

from sklearn.feature_selection import RFE
from sklearn.ensemble import RandomForestClassifier

# RFE sur les features deja filtrees par correlation (cellule precedente)
rfe_estimator = RandomForestClassifier(n_estimators=100, max_depth=10, random_state=42, n_jobs=-1)
rfe = RFE(estimator=rfe_estimator, n_features_to_select=5, step=1)
rfe.fit(X_filtered, y)

rfe_table = pd.DataFrame({
    'feature': X_filtered.columns,
    'rang': rfe.ranking_,
    'retenue': rfe.support_,
}).sort_values(['retenue', 'rang'], ascending=[False, True])

print("Classement RFE (rang 1 = retenue) :")
print(rfe_table.to_string(index=False))
print(f"\n{int(rfe.support_.sum())} features retenues sur {X_filtered.shape[1]} apres filtrage par correlation")
Classement RFE (rang 1 = retenue) :
              feature  rang  retenue
           return_20d     1     True
       volatility_20d     1     True
               sma_20     1     True
            macd_hist     1     True
         bb_bandwidth     1     True
   dist_from_high_20d     2    False
                  adx     3    False
             atr_norm     4    False
           return_10d     5    False
position_in_range_20d     6    False
            return_5d     7    False
            return_1d     8    False
                range     9    False
           true_range    10    False
       ma_cross_20_50    11    False

5 features retenues sur 15 apres filtrage par correlation

Interprétation : ce que RFE retient, et pourquoi le nombre compte

RFE ne dit pas « ces features sont bonnes » : il dit « avec ce modèle et cette cible, ce sous-ensemble est celui que le retrait progressif n’a pas réussi à casser ». Trois points de lecture :

Observation Lecture
Le classement est modèle-dépendant Un Random Forest et une régression logistique ne retiennent pas les mêmes variables : RFE mesure l’utilité pour ce modèle, pas une vérité intrinsèque du signal
Le nombre cible est un choix, pas un résultat n_features_to_select=5 est fixé à la main. En production, il se choisit par validation croisée : tester plusieurs valeurs (3, 5, 8…) et retenir celle qui maximise le score CV — la même grille que celle de l’Exercice 2 du notebook QC-Py-20 pour le alpha
Le coût grandit avec les features Chaque retrait ré-entraîne le modèle : step=1 sur 30 features coûte ~30 entraînements. step>1 en retire plusieurs par tour, moins précis mais plus rapide

RFE contre corrélation — les deux étapes sont complémentaires. La corrélation retire les features redondantes entre elles ; RFE retire celles qui n’apportent rien à la prédiction, même uniques. L’ordre compte : on filtre d’abord (moins de features = RFE plus rapide), on élimine ensuite.

# Visualisation de l'importance des features
fig, axes = plt.subplots(1, 2, figsize=(14, 8))

# Barplot importance
ax1 = axes[0]
top_features = importance_rf.head(20)
colors = plt.cm.viridis(np.linspace(0, 1, len(top_features)))
bars = ax1.barh(range(len(top_features)), top_features['importance_normalized'], color=colors)
ax1.set_yticks(range(len(top_features)))
ax1.set_yticklabels(top_features['feature'])
ax1.invert_yaxis()
ax1.set_xlabel('Importance Normalisee')
ax1.set_title('Top 20 Features par Importance (Random Forest)')

# Heatmap correlation des top features
ax2 = axes[1]
top_feature_names = importance_rf.head(10)['feature'].tolist()
corr_top = X[top_feature_names].corr()
sns.heatmap(corr_top, annot=True, fmt='.2f', cmap='RdBu_r', center=0, ax=ax2)
ax2.set_title('Correlation entre Top 10 Features')

plt.tight_layout()
plt.show()


Interprétation : importance des features et corrélations résiduelles

Ce code visualise l’importance des features et les corrélations entre elles après la suppression des features redondants.

Graphique gauche - Barplot d’importance : - Montre les 20 features les plus importantes selon Random Forest - L’importance est normalisée (0-1) pour comparabilité - Les features avec l’importance la plus élevée sont les plus prédictives

Graphique droit - Heatmap de corrélation : - Montre les corrélations entre les 10 features les plus importants - Rouge = corrélation positive, Bleu = corrélation négative - Les valeurs proches de 0 indiquent des features indépendantes (souhaitable)

Interprétation des résultats :

Observation Signification
Top features = volatilité Le risque est plus prédictif que le rendement
Corrélation faible entre top Bon : features indépendantes
MACD et RSI tous deux dans top Capturent des signaux différents

Pourquoi visualiser les corrélations : - Confirme que remove_correlated_features() a fonctionné - Identifie les corrélations résiduelles (subtiles) - Aide à comprendre la structure des données

Remarque : Les features corrélées peuvent toutes avoir une haute importance (ex: SMA_20 et EMA_12). C’est pourquoi la suppression de corrélation est faite avant le calcul d’importance.



Exercice 3 : Sélection de features par mutual information

La mutual information mesure la dépendance non-lineaire entre chaque feature et la cible. Contrairement a la correlation, elle capture les relations non-lineaires.

Objectif : Implementer un selector base sur la mutual information et comparer avec la sélection par correlation.

Règles : - Calculez mutual_info_classif (ou mutual_info_regression) pour chaque feature - Selectionnez les 10 features avec le plus haut score MI - Comparez avec les 10 features les plus correlees (correlation de Pearson) - Affichez un Venn diagram des deux ensembles

Indices : - # Indice : from sklearn.feature_selection import mutual_info_classif - # Indice : from matplotlib_venn import venn2 pour le diagramme de Venn

Ancre savante – Shannon, C.E. (1948), A Mathematical Theory of Communication, Bell System Technical Journal 27(3):379-423. DOI 10.1002/j.1538-7305.1948.tb01338.x. (Théorie de l’information : la mutual information mesure la dépendance (lineaire ET non-lineaire) entre deux variables, capture ce que la correlation de Pearson rate.)

# Exercice 3 : Selection par mutual information
# TODO etudiant : Comparer selection MI vs correlation Pearson
# Indice : mutual_info_classif pour le score, venn2 pour la comparaison
# Etape 1 : Calculer les scores MI pour chaque feature
# Etape 2 : Calculer les correlations Pearson absolues
# Etape 3 : Identifier les top-10 de chaque methode
# Etape 4 : Afficher le Venn diagram des deux ensembles

result = None  # TODO etudiant : remplacer par la comparaison MI vs Pearson
print("Exercice a completer")
Exercice a completer

Partie 7 : Preprocessing et Normalization (15 min)

Pourquoi normaliser?

Raison Explication
Echelle Features avec grandes valeurs dominent
Gradient Convergence plus rapide (neural networks)
Distance K-NN, SVM sensibles a l’echelle
Regularisation L1/L2 penalisent differemment selon echelle

Méthodes de normalisation

Méthode Formule Quand utiliser
StandardScaler (x - mean) / std Distribution ~normale
MinMaxScaler (x - min) / (max - min) Range [0, 1] souhaite
RobustScaler (x - median) / IQR Données avec outliers

Rolling Normalization pour eviter lookahead

En trading, on ne peut pas utiliser mean/std globaux (lookahead bias). On utilise une normalisation roulante :

# WRONG (lookahead bias)
scaler.fit(all_data)

# CORRECT (rolling)
rolling_mean = data.rolling(window).mean()
rolling_std = data.rolling(window).std()
normalized = (data - rolling_mean) / rolling_std

Détection des valeurs aberrantes par écart interquartile (IQR)

Avant de normaliser, il faut regarder ce qu’on normalise. Une valeur extrême — un prix aberrant, un volume multiplié par cent — déforme la moyenne et l’écart-type dont dépend StandardScaler, et peut déplacer tout un lot d’observations. La méthode de l’écart interquartile est la plus robuste des trois classiques (z-score, boîte à moustaches, IQR) parce qu’elle ne suppose aucune forme de distribution : elle n’utilise que des quantiles.

Grandeur Définition
Q1 25ᵉ centile (premier quartile)
Q3 75ᵉ centile (troisième quartile)
IQR Q3 - Q1 — la dispersion du « corps » de la distribution
Bornes [Q1 - 1.5 x IQR, Q3 + 1.5 x IQR] — hors de ces bornes, la valeur est aberrante

Le facteur 1.5 est une convention (Tukey) qui couvre environ 99,3 % d’une loi normale ; un facteur 3.0 définit les aberrations extrêmes.

def detect_outliers_iqr(series, factor=1.5):
    """Masque booleen des valeurs hors [Q1 - factor*IQR, Q3 + factor*IQR]."""
    q1 = series.quantile(0.25)
    q3 = series.quantile(0.75)
    iqr = q3 - q1
    lower = q1 - factor * iqr
    upper = q3 + factor * iqr
    return (series < lower) | (series > upper), lower, upper


# Diagnostic sur TOUTES les features numeriques, triees par proportion d'aberrants
rows = []
for col in X.columns:
    s = pd.to_numeric(df_clean[col], errors='coerce').dropna()
    if s.empty:
        continue
    mask, lower, upper = detect_outliers_iqr(s)
    rows.append({
        'feature': col,
        'n_aberrants': int(mask.sum()),
        'pct': round(100 * mask.mean(), 2),
        'borne_basse': round(float(lower), 4),
        'borne_haute': round(float(upper), 4),
    })

outlier_df = pd.DataFrame(rows).sort_values('pct', ascending=False)
print("Valeurs aberrantes (facteur 1.5) — 10 features les plus touchees :")
print(outlier_df.head(10).to_string(index=False))
print(f"\n{len(outlier_df)} features examinees, {int((outlier_df['n_aberrants'] > 0).sum())} portent au moins une valeur aberrante")
Valeurs aberrantes (facteur 1.5) — 10 features les plus touchees :
              feature  n_aberrants  pct  borne_basse  borne_haute
         bb_bandwidth           21 4.66      -0.0031       0.2332
   dist_from_high_20d           19 4.21      -0.0907       0.1926
                  adx           14 3.10     -15.6934      76.4732
                range            5 1.11      -0.0111       0.0413
           true_range            4 0.89      -0.0071       0.0512
        log_return_1d            3 0.67      -0.0407       0.0406
            return_1d            3 0.67      -0.0407       0.0407
             atr_norm            1 0.22       0.0153       0.0297
volatility_annualized            0 0.00       0.1037       0.3615
           return_20d            0 0.00      -0.2275       0.2532

39 features examinees, 8 portent au moins une valeur aberrante

Interprétation : ce que l’IQR signale, et ce qu’il faut en faire

Observation mesurée Lecture
Les features de dispersion arrivent en tête (bb_bandwidth ~4,7 %, dist_from_high_20d ~4,2 %, adx ~3,1 %) Ce sont des rapports construits sur le prix : un régime de volatilité atypique les déplace d’un coup, alors que le prix lui-même reste dans sa plage
Les rendements bruts portent peu d’aberrants (return_1d ~0,7 %) Leurs queues sont épaisses mais continues : l’IQR à 1,5 ne trouve pas de « trou » les séparant du corps de la distribution
39 features examinées, 8 en portent au moins un L’aberration n’est pas diffuse : elle se concentre sur une poignée de mesures dérivées, ce qui indique où regarder avant de normaliser

Trois traitements possibles, à choisir selon la nature de l’extrême :

Traitement Effet Quand
Retirer Perte d’information Erreur de données avérée
Écrêter (capping) Ramène la valeur aux bornes, garde la ligne Extrême plausible mais extrême
Transformer log ou rang Distributions à queue lourde (rendements)

Ce que fait ce notebook : aucune suppression automatique. L’IQR sert ici d’instrument de diagnostic — il quantifie ce que la normalisation devra encaisser. La Partie 7 normalise ensuite en z-score roulant (fenêtre glissante) : elle centre et met à l’échelle localement, mais n’est pas robuste aux valeurs extrêmes — le RobustScaler, que le tableau de la Partie 7 réserve à ce rôle, y reste un choix ouvert plutôt qu’une décision déployée. La règle est la même que pour la sélection de features : on mesure d’abord, on décide ensuite.

from sklearn.preprocessing import StandardScaler, MinMaxScaler, RobustScaler

def preprocess_features_sklearn(X_train, X_test, method='standard'):
    """
    Preprocessing avec scikit-learn (pour split train/test).
    
    Parameters:
    -----------
    X_train : pd.DataFrame
        Features d'entrainement
    X_test : pd.DataFrame
        Features de test
    method : str
        'standard', 'minmax', ou 'robust'
    
    Returns:
    --------
    tuple : (X_train_scaled, X_test_scaled, scaler)
    """
    if method == 'standard':
        scaler = StandardScaler()
    elif method == 'minmax':
        scaler = MinMaxScaler()
    elif method == 'robust':
        scaler = RobustScaler()
    else:
        raise ValueError(f"Unknown method: {method}")
    
    # Fit sur train, transform sur les deux
    X_train_scaled = pd.DataFrame(
        scaler.fit_transform(X_train),
        columns=X_train.columns,
        index=X_train.index
    )
    
    X_test_scaled = pd.DataFrame(
        scaler.transform(X_test),
        columns=X_test.columns,
        index=X_test.index
    )
    
    return X_train_scaled, X_test_scaled, scaler


def rolling_normalize(df, window=252):
    """
    Normalisation roulante pour eviter lookahead bias.
    
    Parameters:
    -----------
    df : pd.DataFrame
        Features a normaliser
    window : int
        Fenetre pour calcul mean/std (ex: 252 = 1 an)
    
    Returns:
    --------
    pd.DataFrame
        Features normalisees (Z-score roulant)
    """
    result = pd.DataFrame(index=df.index)
    
    for col in df.columns:
        rolling_mean = df[col].rolling(window).mean()
        rolling_std = df[col].rolling(window).std()
        
        # Z-score avec rolling stats
        result[col] = (df[col] - rolling_mean) / (rolling_std + 1e-8)
        
        # Clip outliers extremes
        result[col] = result[col].clip(-3, 3)
    
    return result


def percentile_rank(df, window=252):
    """
    Rang percentile roulant (robuste aux outliers).
    
    Parameters:
    -----------
    df : pd.DataFrame
        Features
    window : int
        Fenetre
    
    Returns:
    --------
    pd.DataFrame
        Rangs percentiles [0, 1]
    """
    def rolling_percentile(series, window):
        def rank_pct(x):
            return pd.Series(x).rank(pct=True).iloc[-1]
        return series.rolling(window).apply(rank_pct, raw=True)
    
    result = pd.DataFrame(index=df.index)
    for col in df.columns:
        result[col] = rolling_percentile(df[col], window)
    
    return result

# Demonstration
print("Methodes de normalisation:")
print("\n1. StandardScaler (sklearn):")
print("   - Fit sur train, transform sur test")
print("   - Resultat: mean=0, std=1")

print("\n2. Rolling Normalize:")
print("   - Pas de lookahead bias")
print("   - Utilise mean/std des N derniers jours")

print("\n3. Percentile Rank:")
print("   - Robuste aux outliers")
print("   - Resultat: [0, 1] = percentile actuel")
Methodes de normalisation:

1. StandardScaler (sklearn):
   - Fit sur train, transform sur test
   - Resultat: mean=0, std=1

2. Rolling Normalize:
   - Pas de lookahead bias
   - Utilise mean/std des N derniers jours

3. Percentile Rank:
   - Robuste aux outliers
   - Resultat: [0, 1] = percentile actuel

On sélectionne et normalise les features les plus informatives en appliquant des méthodes de réduction de dimensionnalité.

# Appliquer rolling normalization
X_rolling_norm = rolling_normalize(X_filtered, window=60)

# Visualiser l'effet
fig, axes = plt.subplots(2, 2, figsize=(14, 10))

# Choose a feature that survived the correlation filter
feature_example = X_filtered.columns[0] if len(X_filtered.columns) > 0 else None

if feature_example:
    # Avant normalisation
    ax1 = axes[0, 0]
    ax1.plot(X_filtered.index, X_filtered[feature_example])
    ax1.set_title(f'{feature_example} - Avant Normalisation')
    ax1.set_ylabel('Valeur brute')
    
    ax2 = axes[0, 1]
    ax2.hist(X_filtered[feature_example].dropna(), bins=50, color='steelblue', edgecolor='white')
    ax2.set_title(f'{feature_example} - Distribution (brute)')

    # Apres normalisation
    if feature_example in X_rolling_norm.columns:
        ax3 = axes[1, 0]
        ax3.plot(X_rolling_norm.index, X_rolling_norm[feature_example])
        ax3.axhline(0, color='red', linestyle='--', alpha=0.5)
        ax3.axhline(2, color='orange', linestyle=':', alpha=0.5)
        ax3.axhline(-2, color='orange', linestyle=':', alpha=0.5)
        ax3.set_title(f'{feature_example} - Apres Rolling Normalization')
        ax3.set_ylabel('Z-score')
        ax3.set_xlabel('Date')
        
        ax4 = axes[1, 1]
        ax4.hist(X_rolling_norm[feature_example].dropna(), bins=50, color='forestgreen', edgecolor='white')
        ax4.axvline(0, color='red', linestyle='--')
        ax4.set_title(f'{feature_example} - Distribution (normalisee)')
        ax4.set_xlabel('Z-score')

    plt.tight_layout()
    plt.show()

    print("Statistiques avant/apres:")
    print(f"  Avant: mean={X_filtered[feature_example].mean():.2f}, std={X_filtered[feature_example].std():.2f}")
    print(f"  Apres: mean={X_rolling_norm[feature_example].mean():.2f}, std={X_rolling_norm[feature_example].std():.2f}")
else:
    print("Aucune feature disponible apres filtrage.")

Statistiques avant/apres:
  Avant: mean=0.00, std=0.02
  Apres: mean=-0.04, std=1.00

Interprétation : l’effet de la normalisation roulante sur les features

Cette visualisation montre l’effet de la normalisation roulante (rolling normalization) sur les features.

Avant normalisation (graphiques du haut) : - Le RSI brut a une distribution concentrée entre 30 et 70 (valeurs typiques) - La série temporelle montre des variations dans l’échelle native de l’indicateur [0, 100] - Différents indicateurs ont des échelles différentes (RSI: 0-100, MACD: ± prix, etc.)

Après normalisation (graphiques du bas) : - Le Z-score rolling centerne les données autour de 0 - L’écart-type est approximativement 1 (c’est le but de la normalisation) - Les valeurs extrêmes (> 2 ou < -2) sont rares et indiquent des conditions extrêmes - La distribution devient plus “gaussienne” (courbe en cloche)

Pourquoi la rolling normalization est critique en trading :

Méthode Avantage Inconvénient
StandardScaler global Simple Lookahead bias! - utilise moyenne de tout le dataset
Rolling Z-score Pas de lookahead Fenêtre à choisir (trop courte = bruit, trop longue = lag)
Percentile rank Robuste aux outliers Computationnellement plus lourd

Fenêtre de 60 jours choisie ici : - Environ 3 mois de données boursières (60 jours ouvrés ≈ 3 mois) - Suffisamment longue pour stabiliser les statistiques - Assez courte pour s’adapter aux changements de régime

Impact sur les modèles ML : - Les algorithmes basés sur la distance (KNN, SVM) fonctionnent beaucoup mieux - Les réseaux de neurones convergent plus vite - Les features deviennent comparables entre elles



Partie 8 : Pipeline Complet de Feature Engineering (20 min)

Architecture du pipeline

flowchart TD
    D["Données OHLCV"] --> F1["1. Price Features<br/>(returns, volatility, range)"]
    F1 --> F2["2. Indicator Feat.<br/>(RSI, MACD, BB, ADX)"]
    F2 --> F3["3. Create Labels<br/>(future returns, classification)"]
    F3 --> F4["4. Train/Test Split<br/>(temporal, walk-forward)"]
    F4 --> F5["5. Normalization<br/>(rolling z-score)"]
    F5 --> F6["6. Feature Select.<br/>(importance, correlation)"]
    F6 --> OUT["X_train, X_test, y_train, y_test<br/>(Pret pour ML)"]
class FeatureEngineeringPipeline:
    """
    Pipeline complet de Feature Engineering pour ML Trading.
    
    Etapes:
    1. Calcul des features techniques
    2. Creation des labels
    3. Train/Test split temporel
    4. Normalisation
    5. Selection des features
    
    Usage:
        pipeline = FeatureEngineeringPipeline(horizon=5, train_ratio=0.7)
        X_train, X_test, y_train, y_test = pipeline.fit_transform(df_ohlcv)
    """
    
    def __init__(self, 
                 horizon=5,
                 train_ratio=0.7,
                 label_type='binary',
                 label_threshold=0.0,
                 norm_window=60,
                 n_features=20,
                 correlation_threshold=0.90):
        """
        Parameters:
        -----------
        horizon : int
            Horizon de prediction (jours)
        train_ratio : float
            Ratio train/test
        label_type : str
            'binary', 'ternary', ou 'regression'
        label_threshold : float
            Seuil pour labels (classification)
        norm_window : int
            Fenetre pour rolling normalization
        n_features : int
            Nombre de features a garder
        correlation_threshold : float
            Seuil pour supprimer features correlees
        """
        self.horizon = horizon
        self.train_ratio = train_ratio
        self.label_type = label_type
        self.label_threshold = label_threshold
        self.norm_window = norm_window
        self.n_features = n_features
        self.correlation_threshold = correlation_threshold
        
        # Stockage apres fit
        self.feature_names_ = None
        self.selected_features_ = None
        self.feature_importance_ = None
        self.scaler_ = None
    
    def _calculate_features(self, df):
        """Calcule toutes les features techniques."""
        result = df.copy()
        
        # Price features
        result = calculate_price_features(result)
        
        # Indicator features
        result = calculate_indicator_features(result)
        
        return result
    
    def _create_labels(self, df):
        """Cree les labels selon le type."""
        # Rendement futur
        df['future_return'] = df['close'].shift(-self.horizon) / df['close'] - 1
        
        if self.label_type == 'binary':
            df['label'] = (df['future_return'] > self.label_threshold).astype(int)
        elif self.label_type == 'ternary':
            df['label'] = 0
            df.loc[df['future_return'] > self.label_threshold, 'label'] = 1
            df.loc[df['future_return'] < -self.label_threshold, 'label'] = -1
        elif self.label_type == 'regression':
            df['label'] = df['future_return']
        
        return df
    
    def _temporal_split(self, df):
        """Split temporel train/test."""
        split_idx = int(len(df) * self.train_ratio)
        train = df.iloc[:split_idx]
        test = df.iloc[split_idx:]
        return train, test
    
    def _normalize(self, X_train, X_test):
        """Normalise les features."""
        self.scaler_ = StandardScaler()
        
        X_train_norm = pd.DataFrame(
            self.scaler_.fit_transform(X_train),
            columns=X_train.columns,
            index=X_train.index
        )
        
        X_test_norm = pd.DataFrame(
            self.scaler_.transform(X_test),
            columns=X_test.columns,
            index=X_test.index
        )
        
        return X_train_norm, X_test_norm
    
    def _select_features(self, X_train, y_train):
        """Selectionne les meilleures features."""
        # 1. Supprimer features tres correlees
        X_filtered, dropped = remove_correlated_features(X_train, self.correlation_threshold)
        
        # 2. Calculer importance
        self.feature_importance_ = calculate_feature_importance(
            X_filtered, y_train, method='random_forest'
        )
        
        # 3. Garder top N features
        self.selected_features_ = self.feature_importance_.head(self.n_features)['feature'].tolist()
        
        return self.selected_features_
    
    def fit_transform(self, df):
        """
        Execute le pipeline complet.
        
        Parameters:
        -----------
        df : pd.DataFrame
            Donnees OHLCV brutes
        
        Returns:
        --------
        tuple : (X_train, X_test, y_train, y_test)
        """
        print("="*60)
        print("FEATURE ENGINEERING PIPELINE")
        print("="*60)
        
        # 1. Calculer features
        print("\n[1/5] Calcul des features techniques...")
        df_features = self._calculate_features(df)
        
        # 2. Creer labels
        print(f"[2/5] Creation des labels ({self.label_type}, horizon={self.horizon}D)...")
        df_labeled = self._create_labels(df_features)
        
        # Identifier colonnes features vs non-features
        non_feature_cols = ['open', 'high', 'low', 'close', 'volume', 
                           'future_return', 'label']
        self.feature_names_ = [c for c in df_labeled.columns if c not in non_feature_cols]
        print(f"    {len(self.feature_names_)} features calculees")
        
        # Supprimer NaN
        df_clean = df_labeled.dropna()
        print(f"    {len(df_clean)} echantillons valides (apres drop NaN)")
        
        # 3. Split temporel
        print(f"[3/5] Split temporel (train={self.train_ratio*100:.0f}%)...")
        train_df, test_df = self._temporal_split(df_clean)
        
        X_train = train_df[self.feature_names_]
        y_train = train_df['label']
        X_test = test_df[self.feature_names_]
        y_test = test_df['label']
        
        print(f"    Train: {len(X_train)} samples ({train_df.index[0].date()} - {train_df.index[-1].date()})")
        print(f"    Test:  {len(X_test)} samples ({test_df.index[0].date()} - {test_df.index[-1].date()})")
        
        # 4. Selection features
        print(f"[4/5] Selection des {self.n_features} meilleures features...")
        selected = self._select_features(X_train, y_train)
        
        X_train = X_train[selected]
        X_test = X_test[selected]
        
        # 5. Normalisation
        print("[5/5] Normalisation (StandardScaler)...")
        X_train, X_test = self._normalize(X_train, X_test)
        
        print("\n" + "="*60)
        print("PIPELINE COMPLETE")
        print("="*60)
        print(f"\nResultat:")
        print(f"  X_train: {X_train.shape}")
        print(f"  X_test:  {X_test.shape}")
        print(f"  y_train: {len(y_train)} (distribution: {dict(y_train.value_counts())})")
        print(f"  y_test:  {len(y_test)} (distribution: {dict(y_test.value_counts())})")
        
        print(f"\nTop 10 Features selectionnees:")
        for i, feat in enumerate(selected[:10]):
            imp = self.feature_importance_[self.feature_importance_['feature'] == feat]['importance'].values[0]
            print(f"  {i+1}. {feat} (importance: {imp:.4f})")
        
        return X_train, X_test, y_train, y_test
    
    def transform(self, df_new):
        """
        Transforme de nouvelles donnees (utilise les parametres du fit).
        
        Parameters:
        -----------
        df_new : pd.DataFrame
            Nouvelles donnees OHLCV
        
        Returns:
        --------
        pd.DataFrame
            Features transformees
        """
        if self.selected_features_ is None:
            raise ValueError("Pipeline not fitted. Call fit_transform first.")
        
        # Calculer features
        df_features = self._calculate_features(df_new)
        df_clean = df_features.dropna()
        
        # Selectionner et normaliser
        X = df_clean[self.selected_features_]
        X_norm = pd.DataFrame(
            self.scaler_.transform(X),
            columns=X.columns,
            index=X.index
        )
        
        return X_norm

# Verification : la classe est bien definie et expose les methodes attendues
_pipeline_methods = [m for m in dir(FeatureEngineeringPipeline) if not m.startswith('_')]
print(f"FeatureEngineeringPipeline definie avec {len(_pipeline_methods)} methodes publiques :")
for m in _pipeline_methods:
    print(f"  - {m}")
FeatureEngineeringPipeline definie avec 2 methodes publiques :
  - fit_transform
  - transform

Interprétation : la classe FeatureEngineeringPipeline et son architecture

Ce code définit une classe FeatureEngineeringPipeline qui encapsule tout le processus de préparation des données pour le ML en trading.

Design Pattern : Cette classe implémente le pattern Transformer de scikit-learn : - fit_transform() : Calcule les paramètres sur train, transforme train et test - transform() : Applique les transformations apprises à de nouvelles données

Architecture du pipeline :

OHLCV → Features → Labels → Split → Sélection → Normalization → X, y

Méthodes clés :

Méthode Rôle
_calculate_features() Applique calculate_price_features() et calculate_indicator_features()
_create_labels() Crée les labels selon le type (binary/ternary/regression)
_temporal_split() Split temporel (pas random!) pour éviter lookahead bias
_normalize() StandardScaler entraîné sur train uniquement
_select_features() Supprime corrélations + Random Forest importance

Attributs stockés après fit : - selected_features_ : Liste des features retenues - scaler_ : StandardScaler entraîné - feature_importance_ : DataFrame avec importance de chaque feature

Pourquoi une classe : 1. Réutilisabilité : Même pipeline sur différents datasets 2. Reproductibilité : Paramètres stockés et sauvegardables 3. Production : Facile à déployer (joblib, pickle)

Usage typique :

# Entraînement
pipeline = FeatureEngineeringPipeline(horizon=5)
X_train, X_test, y_train, y_test = pipeline.fit_transform(df)

# Sauvegarder
joblib.dump(pipeline, 'pipeline.pkl')

# Production / nouvelles données
pipeline = joblib.load('pipeline.pkl')
X_new = pipeline.transform(nouvelles_données)

# Executer le pipeline complet
pipeline = FeatureEngineeringPipeline(
    horizon=5,                    # Predire direction sur 5 jours
    train_ratio=0.7,              # 70% train, 30% test
    label_type='binary',          # Classification binaire
    label_threshold=0.0,          # Seuil = 0 (up/down simple)
    n_features=15,                # Garder 15 features
    correlation_threshold=0.90    # Supprimer si correlation > 90%
)

# Transformer les donnees
X_train, X_test, y_train, y_test = pipeline.fit_transform(df)
============================================================
FEATURE ENGINEERING PIPELINE
============================================================

[1/5] Calcul des features techniques...
[2/5] Creation des labels (binary, horizon=5D)...
    39 features calculees
    446 echantillons valides (apres drop NaN)
[3/5] Split temporel (train=70%)...
    Train: 312 samples (2022-03-11 - 2023-05-22)
    Test:  134 samples (2023-05-23 - 2023-11-24)
[4/5] Selection des 15 meilleures features...
[5/5] Normalisation (StandardScaler)...

============================================================
PIPELINE COMPLETE
============================================================

Resultat:
  X_train: (312, 14)
  X_test:  (134, 14)
  y_train: 312 (distribution: {1: np.int64(177), 0: np.int64(135)})
  y_test:  134 (distribution: {0: np.int64(82), 1: np.int64(52)})

Top 10 Features selectionnees:
  1. sma_20 (importance: 0.1199)
  2. volatility_20d (importance: 0.0988)
  3. return_20d (importance: 0.0911)
  4. bb_bandwidth (importance: 0.0897)
  5. macd_hist (importance: 0.0821)
  6. dist_from_high_20d (importance: 0.0809)
  7. return_5d (importance: 0.0757)
  8. adx (importance: 0.0734)
  9. return_10d (importance: 0.0700)
  10. atr_norm (importance: 0.0646)

Interprétation : l’exécution du pipeline — étapes, dimensions et features retenues

Ce code exécute le pipeline complet qui a été défini ci-dessus. Le pipeline transforme les données OHLCV brutes en features prêtes pour le ML, et produit les datasets d’entraînement et de test.

Étapes exécutées par le pipeline :

  1. Calcul des features : ~40 features techniques (prix + indicateurs)
  2. Création des labels : Classification binaire (Up/Down) avec horizon 5 jours
  3. Split temporel : 70% train (période ancienne), 30% test (période récente) — en finance le split doit TOUJOURS être temporel pour éviter le lookahead bias
  4. Feature sélection :
    • Suppression des features avec corrélation > 90%
    • Random Forest pour calculer l’importance
    • Sélection des meilleures features — 15 demandées, 14 retenues après suppression des corrélées
  5. Normalisation : StandardScaler (mean=0, std=1)

Dimensions des datasets (sortie de l’exécution ci-dessus) :

X_train: (312, 14)  - 312 échantillons, 14 features
X_test:  (134, 14)  - 134 échantillons, 14 features
y_train: 312       - Labels (0 ou 1)
y_test:  134       - Labels (0 ou 1)

Top features typiques (pour données de marché) : - volatility_* : La volatilité est souvent le meilleur prédicteur de direction - rsi_* : Conditions de surachat/survente (momentum normalisé) - return_*d : Momentum récent - range / atr_* : Volatilité intraday

Paramètres clés du pipeline : - horizon=5 : Prédire la direction sur 5 jours ouvrés - n_features=15 : Demander les 15 features les plus importantes - correlation_threshold=0.90 : Éliminer la redondance

Pipeline sérialisé : L’objet pipeline contient maintenant tous les paramètres appris : - selected_features_ : Les 14 features retenues - scaler_ : Le StandardScaler entraîné sur train - feature_importance_ : L’importance de chaque feature

Pour utiliser ce pipeline sur de nouvelles données :

# Charger (si sauvegardé)
pipeline = joblib.load('feature_pipeline.pkl')
# Transformer
X_new = pipeline.transform(nouvelles_données_ohlcv)

# Verification rapide avec un modele ML simple
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import classification_report, accuracy_score

# Entrainer un Random Forest
print("\nValidation avec Random Forest...")
model = RandomForestClassifier(n_estimators=100, max_depth=5, random_state=42)
model.fit(X_train, y_train)

# Predictions
y_train_pred = model.predict(X_train)
y_test_pred = model.predict(X_test)

print(f"\nAccuracy Train: {accuracy_score(y_train, y_train_pred):.4f}")
print(f"Accuracy Test:  {accuracy_score(y_test, y_test_pred):.4f}")

print("\nClassification Report (Test):")
print(classification_report(y_test, y_test_pred, target_names=['Down', 'Up']))

Validation avec Random Forest...

Accuracy Train: 0.9295
Accuracy Test:  0.5597

Classification Report (Test):
              precision    recall  f1-score   support

        Down       0.63      0.70      0.66        82
          Up       0.42      0.35      0.38        52

    accuracy                           0.56       134
   macro avg       0.52      0.52      0.52       134
weighted avg       0.55      0.56      0.55       134

Interprétation : la vérification rapide par Random Forest

Ce résultat final montre que notre pipeline de feature engineering est fonctionnel et prêt pour être utilisé avec les modèles ML plus avancés des notebooks suivants.

Résultats du Random Forest : - Accuracy Train : 0.9295 - Le modèle capture des patterns d’entraînement - Accuracy Test : 0.5597 - Performance modeste mais supérieure au hasard (50%) - Sur-apprentissage marqué : L’écart train/test (0.9295 vs 0.5597) montre une mémorisation de l’entraînement

Note importante : Ces performances proviennent de données simulées. En production avec de vraies données de marché, les résultats seraient différents - potentiellement meilleurs ou pires selon la qualité des features et l’efficience du marché.

Pipeline complet : 1. Features techniques calculées (prix + indicateurs) 2. Labels créés (classification binaire) 3. Split temporel respecté (70/30) 4. 14 features sélectionnées par importance (15 demandées) 5. Normalisation StandardScaler appliquée

Le fichier feature_pipeline.pkl sauvegardé contient : - Les paramètres du pipeline (horizon, threshold, etc.) - Les features sélectionnées - Le scaler entraîné - L’importance des features

Pour de nouvelles données, il suffira d’appeler pipeline.transform(nouvelles_données) pour obtenir des features prêtes à prédire.


# Exporter les donnees pour les notebooks ML suivants
print("\nPipeline pret pour export:")
print("\nDonnees disponibles:")
print(f"  - X_train: DataFrame {X_train.shape}")
print(f"  - X_test:  DataFrame {X_test.shape}")
print(f"  - y_train: Series {len(y_train)}")
print(f"  - y_test:  Series {len(y_test)}")
print(f"  - pipeline: FeatureEngineeringPipeline (pour transform nouvelles donnees)")

print("\nFeatures selectionnees:")
print(f"  {pipeline.selected_features_}")

# Code pour sauvegarder (optionnel)
save_code = '''
# Sauvegarder les donnees preparees
import joblib

# Sauvegarder le pipeline
joblib.dump(pipeline, 'feature_pipeline.pkl')

# Sauvegarder les donnees
X_train.to_parquet('X_train.parquet')
X_test.to_parquet('X_test.parquet')
y_train.to_frame().to_parquet('y_train.parquet')
y_test.to_frame().to_parquet('y_test.parquet')

# Charger plus tard
pipeline = joblib.load('feature_pipeline.pkl')
X_train = pd.read_parquet('X_train.parquet')
'''

print("\nCode pour sauvegarder/charger:")
print(save_code)

Pipeline pret pour export:

Donnees disponibles:
  - X_train: DataFrame (312, 14)
  - X_test:  DataFrame (134, 14)
  - y_train: Series 312
  - y_test:  Series 134
  - pipeline: FeatureEngineeringPipeline (pour transform nouvelles donnees)

Features selectionnees:
  ['sma_20', 'volatility_20d', 'return_20d', 'bb_bandwidth', 'macd_hist', 'dist_from_high_20d', 'return_5d', 'adx', 'return_10d', 'atr_norm', 'return_1d', 'range', 'true_range', 'ma_cross_20_50']

Code pour sauvegarder/charger:

# Sauvegarder les donnees preparees
import joblib

# Sauvegarder le pipeline
joblib.dump(pipeline, 'feature_pipeline.pkl')

# Sauvegarder les donnees
X_train.to_parquet('X_train.parquet')
X_test.to_parquet('X_test.parquet')
y_train.to_frame().to_parquet('y_train.parquet')
y_test.to_frame().to_parquet('y_test.parquet')

# Charger plus tard
pipeline = joblib.load('feature_pipeline.pkl')
X_train = pd.read_parquet('X_train.parquet')

Interprétation : l’export joblib/Parquet pour les notebooks suivants

Ce code final prépare l’export des données pour les notebooks suivants de la série ML. Le pipeline est maintenant complet et sérialisable.

Composants exportés :

Composant Description Utilisation
X_train Features normalisées (train) Entraînement des modèles
X_test Features normalisées (test) Validation des modèles
y_train / y_test Labels Targets pour ML
pipeline Pipeline sérialisé Transformer nouvelles données

Sauvegarde avec joblib : - Le fichier .pkl contient l’objet pipeline complet - Inclut le scaler entraîné, les features sélectionnées, l’importance - Permet de reproduire exactement les mêmes transformations sur de nouvelles données

Format Parquet pour les données : - Plus efficace que CSV (taille, vitesse) - Préserve les types de données pandas - Compatible avec la plupart des outils data science

Workflow recommandé pour les prochains notebooks :

# Charger
pipeline = joblib.load('feature_pipeline.pkl')
X_train = pd.read_parquet('X_train.parquet')
y_train = pd.read_parquet('y_train.parquet')

# Entraîner modèle
from sklearn.ensemble import RandomForestClassifier
model = RandomForestClassifier()
model.fit(X_train, y_train)

# Sauvegarder modèle
joblib.dump(model, 'ml_model.pkl')


Conclusion et Prochaines Étapes

Recapitulatif

Dans ce notebook, nous avons couvert :

  1. Introduction au Feature Engineering :
    • Importance du “Garbage In, Garbage Out”
    • Catégories de features (techniques, fondamentales, alternatives)
  2. Features Techniques (Price-Based) :
    • Rendements multi-periodes
    • Volatilite historique
    • Range et distance from extremes
  3. Features Techniques (Indicator-Based) :
    • RSI (Wilder, 1978), MACD, Bollinger Bands (Bollinger, 2002)
    • ADX, Stochastic, CCI
    • Normalisation des indicateurs pour ML
  4. Features Fondamentales :
    • Valuation (P/E, P/B, P/S)
    • Profitability (ROE, ROA, Margins)
    • Growth et Debt ratios
  5. Labeling :
    • Classification (binary, ternary, quantile)
    • Regression (future returns)
    • Triple Barrier method (Lopez de Prado, 2018)
  6. Feature Sélection :
    • Random Forest importance (Breiman, 2001)
    • Correlation filtering
    • Mutual information (Shannon, 1948 ; Cover & Thomas, 1991)
  7. Preprocessing :
    • StandardScaler, RobustScaler
    • Rolling normalization (anti-lookahead)
  8. Pipeline Complet :
    • Classe FeatureEngineeringPipeline
    • Train/Test split temporel
    • Export pour ML

Points Cles a Retenir

Concept Point Cle
Lookahead Bias Ne jamais utiliser de données futures dans les features
Normalisation Rolling stats pour eviter biais, pas mean/std global
Sélection Supprimer features correlees avant importance
Labeling Horizon et threshold impactent fortement les résultats
Split Toujours temporel en finance (pas random)

Limitations

  • Les features techniques seules sont souvent insuffisantes
  • Le marche evolue, les features perdent leur pouvoir predictif (regime change)
  • Overfitting facile avec trop de features
  • Les données simulees ne capturent pas toute la complexite reelle

Prochaines Étapes

Notebook Contenu
QC-Py-19 Classification Models (Random Forest, XGBoost)
QC-Py-20 Regression Models et prediction de rendements
QC-Py-21 Deep Learning (LSTM, Transformers)
QC-Py-22 Reinforcement Learning pour trading

References academiques

  • Wilder, J. W. (1978). New Concepts in Technical Trading Systems. Trend Research. RSI, ADX.
  • Bollinger, J. (2002). Bollinger on Bollinger Bands. McGraw-Hill. Bollinger Bands.
  • Appel, G. (2005). Technical Analysis: Power Tools for Active Investors. MACD.
  • Breiman, L. (2001). Random Forests. Machine Learning. Feature importance (permutation / Gini).
  • Lopez de Prado, M. (2018). Advances in Financial Machine Learning. Wiley. Triple Barrier method, meta-labeling.
  • Shannon, C. E. (1948). A Mathematical Theory of Communication. Bell System Technical Journal. Information entropy, mutual information.
  • Cover, T. M. & Thomas, J. A. (1991). Éléments of Information Theory. Wiley. Mutual information (feature sélection).
  • Guyon, I. & Elisseeff, A. (2003). An Introduction to Variable and Feature Sélection. JMLR. RFE / sélection de features.

Ressources Complementaires


Notebook complete. Pret pour QC-Py-19 (Classification Models).

References academiques

  • Fama, E.F. (1965). The Behavior of Stock-Market Prices. The Journal of Business 38(1):34-105.
  • Guyon, I., Weston, J., Barnhill, S. & Vapnik, V. (2002). Gene Sélection for Cancer Classification using Support Vector Machines. Machine Learning 46(1-3):389-422. DOI 10.1023/A:1012487302797.
  • Jegadeesh, N. & Titman, S. (1993). Returns to Buying Winners and Selling Losers: Implications for Stock Market Efficiency. The Journal of Finance 48(1):65-91. DOI 10.1111/j.1540-6261.1993.tb04702.x.
  • Pearson, K. (1901). On Lines and Planes of Closest Fit to Systems of Points in Space. Philosophical Magazine 2(11):559-572. DOI 10.1080/14786440109462720.
  • Shannon, C.E. (1948). A Mathematical Theory of Communication. Bell System Technical Journal 27(3):379-423. DOI 10.1002/j.1538-7305.1948.tb01338.x.
  • Sharpe, W.F. (1966). Mutual Fund Performance. The Journal of Business 39(1):119-138. DOI 10.1086/294846.
  • Wilder, J.W. (1978). New Concepts in Technical Trading Systems. Trend Research, Greensboro, NC. ISBN 978-0-89459-027-6.
Retour au sommet