<< Sommaire QC | Précédent : QC-Py-04-Research-Workflow << | Suivant : QC-Py-06-Options-Trading >>

Objectifs d’Apprentissage

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

  1. Comprendre le concept d’Universe et son importance en trading algorithmique
  2. Créer un Manual Universe avec une liste fixe de symboles
  3. Implementer un Coarse Universe Sélection base sur le volume et le prix
  4. Utiliser le Fine Universe Sélection avec des données fondamentales
  5. Configurer les Universe Settings (resolution, leverage, etc.)
  6. Gerer le Dynamic Rebalancing lors des changements d’univers
  7. Planifier des rebalancements avec Scheduled Universe Sélection
  8. Construire une stratégie Momentum complete

Prerequis

  • Notebooks QC-Py-01 a QC-Py-04 completes
  • Comprehension du lifecycle QCAlgorithm
  • Notions de base en analyse fondamentale

Structure du Notebook

  1. Introduction a l’Universe Sélection (10 min)
  2. Manual Universe (15 min)
  3. Coarse Universe Sélection (20 min)
  4. Fine Universe Sélection (20 min)
  5. Universe Settings (10 min)
  6. Dynamic Rebalancing (15 min)
  7. Scheduled Universe Sélection (10 min)
  8. Stratégie Complete: Momentum Universe (20 min)

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

Public cible et pré-requis

Ce notebook est le cinquième de la série QuantConnect Python. Il couvre la sélection d’univers — c’est-à-dire quelles actions votre algorithme va trader. C’est une décision aussi critique que le signal d’entrée ou le sizing : un univers mal choisi annule les meilleurs alpha models.

Pré-requis techniques : - QC-Py-01 à QC-Py-04 (Setup, Platform, Data, Research) - Python intermédiaire (classes, méthodes, list comprehensions) - Familiarité avec pandas DataFrame (filtres, groupby) - Notions de finance : secteurs, capitalisation, liquidité

Compétences acquises en fin de notebook : 1. Choisir entre Manual / Coarse / Fine / Scheduled Universe selon le cas d’usage. 2. Implémenter un filtre fondamental multi-critères (P/E, ROE, market cap, sector). 3. Gérer le rebalancement dynamique quand l’univers change (OnSecuritiesChanged). 4. Combiner Universe + filtre qualité dans une stratégie Momentum.

Pourquoi ce notebook est important : la plupart des algos quantitatifs échouent non pas à cause du signal, mais à cause de l’univers mal défini. Trader 500 actions illiquides vs 50 actions liquides = différence de Sharpe de 0.5 sur le même signal.

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


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

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



1. Introduction a l’Universe Sélection (10 min)

Qu’est-ce qu’un Universe?

Dans QuantConnect, un Universe represente l’ensemble des actifs que votre algorithme considere pour le trading a un moment donne. Plutot que de trader toujours les mêmes actions, l’Universe Sélection permet de :

  • Sélectionner dynamiquement les meilleurs candidats selon vos critères
  • S’adapter aux conditions de marche (nouvelle IPO, delisting, etc.)
  • Filtrer parmi des milliers d’actifs pour ne garder que les plus pertinents
  • Automatiser le screening qui prendrait des heures manuellement

Pourquoi sélectionner dynamiquement des actions?

Approche Avantages Inconvenients
Liste fixe Simple, rapide Risque de biais, actions obsoletes
Universe dynamique Adaptable, sans biais Plus complexe, backtest plus long

Exemples d’utilisation :

  1. Stratégie momentum : Trader les 50 actions avec le meilleur momentum sur 12 mois
  2. Value investing : Filtrer les actions avec P/E < 15 et dividende > 3%
  3. Secteur spécifique : Ne trader que les actions du secteur technologie
  4. Liquidite : Exclure les actions avec un volume trop faible

Types d’Universe Sélection

Universe Sélection
        |
        +-- Manual Universe (liste fixe)
        |
        +-- Coarse Universe (filtre sur prix, volume)
        |         |
        |         +-- Fine Universe (données fondamentales)
        |
        +-- Custom Universe (données alternatives)

Pipeline de Sélection

8000+ actions US          Coarse Sélection          Fine Sélection          Portfolio
    [Marche]      --->    (prix, volume)     --->  (P/E, secteur)    --->   [Trading]
                          ~500 candidats            ~50 candidats           ~20 actions

Trois familles d’univers

L’universe selection chez QuantConnect se décline en 3 grandes familles qu’il faut choisir selon la stratégie :

  1. Manual Universe : liste statique de symboles définis à l’avance. Idéal pour reproduire un indice, tester une thèse sectorielle, ou backtester un signal simple.
  2. Coarse Universe : filtrage quotidien sur des critères simples (prix, volume, market cap). Réduit l’univers de 8000+ actions à ~200-500 candidates. Tourne à chaque journée de marché.
  3. Fine Universe : filtrage fondamental sur données Morningstar/Engine (P/E, ROE, ratios sectoriels). Réduit l’univers coarse à ~10-50 actions finales. Tourne typiquement 1×/mois.

Données QuantConnect — la pyramide

QC offre une pyramide de données : à chaque niveau, plus de données mais plus lent :

Niveau Données Vitesse Coût
Manual Symboles <1ms Gratuit
Coarse Prix, volume, market cap ~1-3s Gratuit
Fine Fondamentales Morningstar ~3-10s Gratuit
Alpha Streams Données tierces premium Variable Payant

Lecture critique : Fine Universe est 3-10× plus lent que Coarse. Sur un backtest 10 ans daily, ça peut représenter 5-15 minutes de plus. À utiliser seulement si votre filtre requiert réellement des données fondamentales.

Anti-pattern fréquent

L’erreur classique du débutant : utiliser Fine Universe pour tout. On charge 8000 actions × 50 champs fondamentaux × 252 jours/an × 10 ans = ~10 milliards de valeurs pour au final ne garder que 20 actions. Le ratio signal/bruit est désastreux. Filtrer en amont (Coarse) puis affiner (Fine) = pipeline efficace.


2. Manual Universe (15 min)

Approche la plus simple : Liste fixe de symboles

La maniere la plus simple de définir un univers est d’ajouter manuellement les actions que vous souhaitez trader avec self.AddEquity().

Exemple : Portfolio FAANG

Les FAANG (META ex-Facebook, Apple, Amazon, Netflix, Google) sont les geants de la tech americaine. Creons un algorithme qui trade ces 5 actions.

2. Manual Universe (15 min)

L’approche la plus simple : liste fixe de symboles dans le constructeur de l’algorithme.

Cas d’usage typique : - Reproduire un indice (S&P 500 top 10, CAC 40, Nasdaq-100) - Backtester une thèse sectorielle (energy stocks 2020-2024) - Tester un signal sur un petit univers pour itérer vite - Stratégies “stock-picking” discrétionnaire

Avantages : - Zéro filtrage → backtest ultra-rapide - Reproductible (la liste est dans le code) - Facile à déboguer

Limites : - Survivorship bias si on backtest sur les composants actuels d’un indice (ex: tester sur les MegaCaps 2024 = survivants des krachs 2008/2020) - Pas dynamique : si une action sort de l’indice, l’algo la garde quand même - Coût de maintenance : il faut mettre à jour la liste à la main chaque trimestre/année

Exemple : Portfolio FAANG

Le portfolio FAANG (Facebook/Meta, Apple, Amazon, Netflix, Google/Alphabet) est un cas d’école. C’est : - 5 symboles, ultra-liquides - Tracké par tous les investisseurs retail - Surperformant historiquement le S&P 500 sur 2015-2024 - Mais corrélé à 0.85+ avec QQQ → pas de diversification sectorielle

C’est un bon premier test pour un signal : si votre algo ne bat pas FAANG sur ces 5 actions, il y a un problème de signal (pas d’univers).

# [REFERENCE QC] Code a copier dans main.py QC Lab (non executable ici)
# Manual Universe : Portfolio FAANG
# A copier dans l'IDE QuantConnect

from AlgorithmImports import *

class FAANGManualUniverse(QCAlgorithm):
    """
    Exemple de Manual Universe avec les actions FAANG.
    Allocation equal-weight sur les 5 actions.
    """
    
    def Initialize(self):
        # Configuration backtest
        self.SetStartDate(2015, 1, 1)
        self.SetEndDate(2024, 12, 31)
        self.SetCash(100000)
        
        # Liste des symboles FAANG
        self.tickers = ["META", "AAPL", "AMZN", "NFLX", "GOOGL"]
        self.symbols = []
        
        # Ajouter chaque action manuellement
        for ticker in self.tickers:
            equity = self.AddEquity(ticker, Resolution.Daily)
            self.symbols.append(equity.Symbol)
            self.Log(f"Added {ticker}: {equity.Symbol}")
        
        # Allocation cible : equal-weight (20% chacune)
        self.target_allocation = 1.0 / len(self.symbols)
        
        # Rebalancement initial planifie
        self.first_trade = True
        
        self.Log(f"FAANG Universe initialized with {len(self.symbols)} symbols")
    
    def OnData(self, data):
        # Attendre que toutes les donnees soient disponibles
        if not all(data.ContainsKey(s) for s in self.symbols):
            return
        
        # Allocation initiale (une seule fois)
        if self.first_trade:
            for symbol in self.symbols:
                self.SetHoldings(symbol, self.target_allocation)
                self.Log(f"Initial allocation: {symbol.Value} -> {self.target_allocation:.1%}")
            self.first_trade = False
    
    def OnEndOfAlgorithm(self):
        self.Log(f"Final Portfolio Value: ${self.Portfolio.TotalPortfolioValue:,.2f}")

Backtest QC Cloud : FAANGManualUniverse

Sharpe CAGR MaxDD Net Profit Orders End Equity
0.84 27.526% 50.700% +1039.578% 5 $1,139,577.97

FAANG buy-and-hold, 2015-2024

Avantages et limites du Manual Universe

Avantages Limites
Simple a implementer Biais de sélection (survivorship bias)
Contrôle total sur les actifs Pas d’adaptation aux conditions de marche
Backtest rapide Ne decouvre pas de nouvelles opportunites
Ideal pour tester une idee Maintenance manuelle requise

Quand utiliser Manual Universe?

  • Prototypage rapide : Tester une stratégie sur quelques actions
  • Stratégie sectorielle : Trader un panier défini (ETF-like)
  • Arbitrage spécifique : Pairs trading sur 2-3 actifs
  • Benchmark : Comparer avec une stratégie dynamique

Variation : Multiple Asset Classes

Avantages et limites du Manual Universe

Quand utiliser Manual Universe ?

Cas d’usage Manuel ? Pourquoi
Reproduire indice ✅ Liste connue à l’avance
Thèse sectorielle ✅ Univers défini
Pair trading ✅ 2-10 symboles fixes
Rotation sectorielle ⚠️ Possible mais pénible
Small/mid-cap screening ❌ Demande Coarse
Filtre fondamental ❌ Demande Fine
Univers changeant ❌ Demande Scheduled

Variation : Multiple Asset Classes

QC permet de mixer plusieurs classes d’actifs dans le même univers : actions (Equity), options (Option), futures (Future), forex (Forex), crypto (Crypto), ETF.

Exemple : universe “60/40” — 60% actions (SPY/EFA/AGG), 40% obligations (TLT/IEF). On combine AddEquity avec des méthodes spécifiques par classe.

Pédagogie : la diversification multi-classes réduit la volatilité (corrélations plus basses entre classes que intra-classe). Mais elle complique le code (gestion des sessions de marché différentes : actions 9h30-16h, forex 24h, crypto 24/7).

# [REFERENCE QC] Code a copier dans main.py QC Lab (non executable ici)
# Manual Universe multi-asset
# Exemple avec actions, ETFs et crypto

from AlgorithmImports import *

class MultiAssetManualUniverse(QCAlgorithm):
    
    def Initialize(self):
        self.SetStartDate(2015, 1, 1)
        self.SetEndDate(2024, 12, 31)
        self.SetCash(100000)
        
        # Actions individuelles
        self.stocks = {
            "AAPL": self.AddEquity("AAPL", Resolution.Daily).Symbol,
            "MSFT": self.AddEquity("MSFT", Resolution.Daily).Symbol,
        }
        
        # ETFs
        self.etfs = {
            "SPY": self.AddEquity("SPY", Resolution.Daily).Symbol,   # S&P 500
            "QQQ": self.AddEquity("QQQ", Resolution.Daily).Symbol,   # Nasdaq 100
            "TLT": self.AddEquity("TLT", Resolution.Daily).Symbol,   # Obligations
        }
        
        # Tous les symboles
        self.all_symbols = list(self.stocks.values()) + list(self.etfs.values())
        
        self.Log(f"Multi-asset universe: {len(self.stocks)} stocks, {len(self.etfs)} ETFs")

Backtest QC Cloud : MultiAssetManualUniverse

Résultat End Equity
Exploration (0 ordre) $100,000.00

Multi-asset exploration, no trading


Exercice 1 : Manuel Universe personnalise avec allocation ponderee

En vous basant sur les exemples FAANGManualUniverse et MultiAssetManualUniverse ci-dessus, créez un algorithme avec un Manual Universe de 6 actions et une allocation ponderee personnalisee (pas equal-weight).

Objectif : implementer WeightedManualUniverse avec les specifications suivantes :

  • Créer un dictionnaire de 6 actions avec des ponderations cibles (la somme doit faire 1.0)
  • Ajouter chaque action avec self.AddEquity(ticker, Resolution.Daily)
  • Dans OnData, investir selon les ponderations définies au premier trade disponible
  • Ajouter un Schedule.On mensuel pour rebalancer vers les ponderations cibles

Indices : - self.SetHoldings(symbol, weight) ou weight est un float entre 0 et 1 - self.Schedule.On(self.DateRules.MonthStart("SPY"), self.TimeRules.At(10, 0), self.Rebalance) pour le rebalancement - Utilisez un dictionnaire self.target_weights = {"AAPL": 0.25, "MSFT": 0.20, ...} pour stocker les ponderations - Verifiez data.ContainsKey(symbol) avant de trader

Exercice 1 : Manuel Universe personnalisé avec allocation

Objectif : Construire un portefeuille 5-7 actions avec allocation pondérée (pas equal-weight).

Contexte : Vous backtestez une thèse “tech européenne”. Vous voulez investir dans ASML, SAP, Nokia, Ericsson, Spotify, Adyen, Prosus avec une allocation qui reflète votre conviction.

Travail demandé : 1. Définir l’univers dans self.universe (liste de symboles). 2. Définir les poids dans self.weights (doit sommer à 1.0). 3. Implémenter OnData pour passer les ordres proportionnellement aux poids. 4. Rebalancer mensuellement (1er du mois) pour respecter les poids cibles.

Indices : - Utiliser self.SetHoldings(symbol, weight) pour passer les ordres en proportion du portefeuille. - Pour le rebalance mensuel, comparer self.Time.month à la valeur précédente. - Si vous voulez éviter le rebalance daily (friction), stocker le mois du dernier rebalance dans self.last_rebalance_month.

Pédagogie : cet exercice vous fait toucher deux bugs classiques des débutants : - Oublier le SetHoldings qui ne fait rien sans ordre explicite. - Le rebalance daily qui multiplie les frais de transaction par 30 (mensuel = 1× par mois, daily = ~21× par mois).

Validation : backtest sur 2019-2023 devrait donner un Sharpe 0.6-0.9 et un DD max -20% à -25% (typique pour une thèse sectorielle concentrée).

# [REFERENCE QC] Exercice 1 : Manuel Universe personnalise avec allocation ponderee
# A completer et copier dans l'IDE QuantConnect

from AlgorithmImports import *

class WeightedManualUniverse(QCAlgorithm):
    """
    TODO etudiant : implementer un Manual Universe avec 6 actions
    et des ponderations cibles personnalisees.
    """
    
    def Initialize(self):
        self.SetStartDate(2015, 1, 1)
        self.SetEndDate(2024, 12, 31)
        self.SetCash(100000)
        
        # TODO etudiant : definir 6 actions avec ponderations cibles (somme = 1.0)
        # self.target_weights = {"AAPL": 0.25, "MSFT": 0.20, ...}
        
        # TODO etudiant : ajouter chaque action avec AddEquity
        
        # TODO etudiant : planifier un rebalancement mensuel avec Schedule.On
        
        self.first_trade = True
    
    def Rebalance(self):
        # TODO etudiant : rebalancer vers les ponderations cibles
        pass
    
    def OnData(self, data):
        # TODO etudiant : investir au premier trade disponible
        pass

print("Exercice a completer : WeightedManualUniverse")

3. Coarse Universe Sélection (20 min)

Filtrage base sur le prix et le volume

Le Coarse Universe Sélection est le premier niveau de filtrage dynamique. Il permet de filtrer parmi toutes les actions du marche en utilisant des critères simples :

  • Price : Prix de l’action (eviter les penny stocks)
  • DollarVolume : Volume en dollars (liquidite)
  • HasFundamentalData : Presence de données fondamentales

Structure du Coarse Sélection

def Initialize(self):
    # Activer la sélection d'univers
    self.AddUniverse(self.CoarseSelectionFunction)

def CoarseSelectionFunction(self, coarse):
    # coarse = liste de CoarseFundamental objects
    # Retourne une liste de Symbol
    filtered = [x for x in coarse if x.Price > 10]
    return [x.Symbol for x in filtered]

Proprietes de CoarseFundamental

Propriete Type Description
Symbol Symbol Identifiant unique de l’action
Price float Prix de cloture
DollarVolume float Volume quotidien en dollars (Price x Volume)
Volume long Nombre d’actions echangees
HasFundamentalData bool True si données fondamentales disponibles

3. Coarse Universe Selection (20 min)

Le Coarse Universe est le premier niveau de filtrage dans QC. Il s’exécute quotidiennement avant l’ouverture du marché et réduit l’univers de 8000+ actions à ~200-500 candidates.

Critères typiques : - Prix > $5 (exclure penny stocks) - Volume > $1M/jour (liquidité minimale) - Market cap > $1B (exclure micro-caps) - Domicile US (filtrer par HasFundamentalData) - Secteur (exclure financials, REITs si non-désiré)

Structure du Coarse Selection

def CoarseSelectionFunction(self, coarse):
    sorted_by_volume = sorted(coarse, key=lambda x: x.DollarVolume, reverse=True)
    return [x.Symbol for x in sorted_by_volume[:100]]

Trois étapes : 1. Recevoir la liste coarse (~8000 actions avec métadonnées de base). 2. Filtrer selon vos critères (prix, volume, cap). 3. Retourner la liste des Symbol retenus (~100-500).

Pédagogie : le retour de la fonction Coarse est un filtre uniquement — il n’envoie aucun ordre, ne calcule aucun signal. Il précise quelles actions sont candidates pour la suite.

Optimisations importantes

Pour un backtest rapide : - Trier sur DollarVolume avant de prendre les N premiers (un sorted() complet est O(N log N), un heapq.nlargest(N) est O(N log N) aussi mais avec moins d’overhead). - Retourner directement une liste de Symbol (pas un DataFrame) — QC sérialise le retour. - Éviter les calculs lourds dans CoarseSelectionFunction : déplacez-les dans OnData ou dans une classe FineFundamental.

Coût typique : Coarse tourne en <100ms par jour de marché sur 8000 actions. C’est négligeable.

# [REFERENCE QC] Code a copier dans main.py QC Lab (non executable ici)
# Coarse Universe Selection : Top 100 par volume
# A copier dans l'IDE QuantConnect

from AlgorithmImports import *

class CoarseUniverseAlgorithm(QCAlgorithm):
    """
    Selection dynamique des 100 actions les plus liquides.
    Filtres : prix > 10$, volume > 10M$/jour.
    """
    
    def Initialize(self):
        self.SetStartDate(2015, 1, 1)
        self.SetEndDate(2024, 12, 31)
        self.SetCash(100000)
        
        # Configurer l'univers avec Coarse Selection
        self.AddUniverse(self.CoarseSelectionFunction)
        
        # Nombre d'actions a selectionner
        self.num_stocks = 100
        
        # Filtres
        self.min_price = 10           # Prix minimum ($)
        self.min_dollar_volume = 10000000  # Volume min ($10M/jour)
        
        self.Log(f"Coarse Universe initialized: top {self.num_stocks} by volume")
    
    def CoarseSelectionFunction(self, coarse):
        """
        Fonction de filtrage Coarse.
        Appelee automatiquement chaque jour par QuantConnect.
        
        Args:
            coarse: Liste de CoarseFundamental objects (toutes les actions)
        
        Returns:
            Liste de Symbol des actions selectionnees
        """
        # Etape 1: Filtrer par prix et volume
        filtered = [x for x in coarse 
                    if x.Price > self.min_price 
                    and x.DollarVolume > self.min_dollar_volume]
        
        # Etape 2: Trier par DollarVolume (decroissant)
        sorted_by_volume = sorted(filtered, 
                                   key=lambda x: x.DollarVolume, 
                                   reverse=True)
        
        # Etape 3: Garder les top N
        top_stocks = sorted_by_volume[:self.num_stocks]
        
        # Log pour debug
        self.Log(f"Coarse selection: {len(filtered)} filtered, {len(top_stocks)} selected")
        
        # Retourner les symboles
        return [x.Symbol for x in top_stocks]
    
    def OnData(self, data):
        # L'univers change automatiquement
        # self.ActiveSecurities contient les actions actuelles
        pass
    
    def OnSecuritiesChanged(self, changes):
        """Appele quand l'univers change (ajout/retrait d'actions)."""
        self.Log(f"Securities changed: +{len(changes.AddedSecurities)} / -{len(changes.RemovedSecurities)}")

Backtest QC Cloud : CoarseUniverseAlgorithm

Résultat End Equity
Exploration (0 ordre) $100,000.00

Coarse sélection only, 2020-2024

Résultats backtest QC Cloud : CoarseUniverseAlgorithm (2015-2024, $100k) — 0 ordres, Net Profit 0%. Demo Coarse Sélection (Top 100 par volume). La sélection s’execute mais OnData est vide — pas de trading.

Comprendre le flux de Coarse Sélection

Chaque jour de trading:

1. QuantConnect appelle CoarseSelectionFunction(coarse)
   |
   +-- coarse = 8000+ CoarseFundamental objects
   |
   +-- Votre logique de filtrage
   |
   +-- Retourne liste de Symbol (ex: 100 symboles)

2. QuantConnect compare avec l'univers précédent
   |
   +-- Nouvelles actions -> AddedSecurities
   +-- Actions retirees -> RemovedSecurities

3. OnSecuritiesChanged() est appele
   |
   +-- Vous pouvez ajuster vos positions

4. OnData() recoit les données des actions de l'univers actuel

Optimisations importantes

1. Utiliser HasFundamentalData pour eviter les ETFs et ADRs :

filtered = [x for x in coarse 
            if x.HasFundamentalData  # Exclut ETFs, ADRs
            and x.Price > 10 
            and x.DollarVolume > 10000000]

2. Limiter la frequence de reselection :

def Initialize(self):
    # Reselection mensuelle au lieu de quotidienne
    self.UniverseSettings.Resolution = Resolution.Daily
    self.AddUniverse(self.CoarseSelectionFunction)
    self.last_month = -1

def CoarseSelectionFunction(self, coarse):
    # Ne re-sélectionner qu'en debut de mois
    if self.Time.month == self.last_month:
        return Universe.Unchanged
    self.last_month = self.Time.month
    
    # Logique de filtrage...

Comprendre le flux de Coarse Selection

Le flux de données Coarse est quotidien et agrégé :

  1. J-1, 23:00 UTC : QC calcule les métriques coarse (volume, market cap, prix de clôture ajusté).
  2. J, 00:00 UTC : CoarseSelectionFunction est appelée avec les données J-1.
  3. J, 09:30 ET : OnData est appelée avec les données intraday J, et l’univers coarse est appliqué.

Important : il y a un décalage de 1 jour entre les données Coarse et le trading. C’est pourquoi les algos quantitatifs institutionnels passent leurs ordres à l’ouverture (9:30-10:00 ET) et non à la clôture.

Optimisations importantes (suite)

  • Cache des données coarse sur plusieurs jours pour éviter les recalculs.
  • Filtrer les ETF dès Coarse si votre algo ne trade que des actions (gain de temps).
  • Utiliser self.Log pour tracer la taille de l’univers coarse → fine → finale (debugging rapide).

Piège classique : universe trop large

Un Coarse qui retourne 1000 actions est trop large pour un backtest rapide. Réduisez à 50-200 avant Fine Selection. Si votre filtre coarse ne réduit pas assez, affinez vos critères (price > $20, volume > $5M, etc.).

Lecture critique : un bon Coarse Selection réduit l’univers de 80% sans perdre les actions qui vont performer. Trop laxiste = bruit ; trop strict = vous ratez les winners.

# [REFERENCE QC] Code a copier dans main.py QC Lab (non executable ici)
# Coarse Selection avec reselection mensuelle
# Optimisation pour reduire le temps de backtest

from AlgorithmImports import *

class MonthlyCoarseUniverse(QCAlgorithm):
    """
    Coarse selection avec rebalancement mensuel.
    Evite de recalculer l'univers chaque jour.
    """
    
    def Initialize(self):
        self.SetStartDate(2015, 1, 1)
        self.SetEndDate(2024, 12, 31)
        self.SetCash(100000)
        
        self.AddUniverse(self.CoarseSelectionFunction)
        
        self.num_stocks = 50
        self.last_month = -1  # Pour tracker le mois
    
    def CoarseSelectionFunction(self, coarse):
        # Verifier si on est dans un nouveau mois
        if self.Time.month == self.last_month:
            # Pas de changement -> retourner Universe.Unchanged
            return Universe.Unchanged
        
        # Nouveau mois -> mettre a jour
        self.last_month = self.Time.month
        self.Log(f"Monthly reselection: {self.Time.strftime('%Y-%m')}")
        
        # Filtrage standard
        filtered = [x for x in coarse 
                    if x.HasFundamentalData
                    and x.Price > 5 
                    and x.DollarVolume > 5000000]
        
        sorted_stocks = sorted(filtered, key=lambda x: x.DollarVolume, reverse=True)
        
        return [x.Symbol for x in sorted_stocks[:self.num_stocks]]

Backtest QC Cloud : MonthlyCoarseUniverse

Résultat End Equity
Exploration (0 ordre) $100,000.00

Monthly coarse reselection, 2020-2024

Résultats backtest QC Cloud : MonthlyCoarseUniverse (2015-2024, $100k) — 0 ordres, Net Profit 0%. Demo reselection mensuelle avec Universe.Unchanged. Pas de logique de trading dans OnData.


4. Fine Universe Sélection (20 min)

Filtrage avec données fondamentales

Le Fine Universe Sélection ajoute une deuxieme couche de filtrage en utilisant les données fondamentales de Morningstar :

  • Valorisation : P/E Ratio, P/B Ratio, EV/EBITDA
  • Taille : Market Cap, Revenue, Total Assets
  • Classification : Secteur, Industrie, Morningstar Style
  • Dividendes : Dividend Yield, Payout Ratio

Architecture Coarse + Fine

def Initialize(self):
    # Coarse PUIS Fine
    self.AddUniverse(self.CoarseSelectionFunction, 
                     self.FineSelectionFunction)

def CoarseSelectionFunction(self, coarse):
    # Premier filtre: prix, volume
    # Retourne candidats pour Fine
    return [x.Symbol for x in filtered]

def FineSelectionFunction(self, fine):
    # Deuxieme filtre: données fondamentales
    # fine = seulement les actions selectionnees par Coarse
    return [x.Symbol for x in filtered]

Proprietes de FineFundamental

Catégorie Propriete Exemple d’acces
Valorisation P/E Ratio x.ValuationRatios.PERatio
P/B Ratio x.ValuationRatios.PBRatio
EV/EBITDA x.ValuationRatios.EVToEBITDA
Taille Market Cap x.MarketCap
Revenue x.FinancialStatements.IncomeStatement.TotalRevenue.Value
Classification Secteur Morningstar x.AssetClassification.MorningstarSectorCode
Industrie x.AssetClassification.MorningstarIndustryCode
Dividendes Dividend Yield x.ValuationRatios.TrailingDividendYield

4. Fine Universe Selection (20 min)

Le Fine Universe est le deuxième niveau de filtrage. Il s’exécute après Coarse et applique des critères fondamentaux sur les actions retenues par Coarse.

Données accessibles via Fine : - Ratios financiers : P/E, P/B, EV/EBITDA, ROE, ROA - Profitabilité : marges (brute, nette, opérationnelle), croissance revenus - Bilan : dette/capitaux, current ratio, cash flow - Secteur Morningstar, industrie, style (growth/value/blend) - Pays d’origine, exchange

Architecture Coarse + Fine

L’architecture classique est un pipeline à 2 étages :

Universe complet (8000+ actions)
        ↓ Coarse Selection (prix, liquidité)
Filtered (~200-500)
        ↓ Fine Selection (fondamentales)
Final Universe (~10-50)
        ↓ OnData (signal)
Ordres

Pourquoi ce pipeline : 1. Performance : Fine est 3-10× plus lent que Coarse. Si on l’applique aux 8000 actions, le backtest explose. Coarse pré-filtrer réduit le scope. 2. Logique : un filtre “P/E < 15” n’a de sens que sur des actions liquides (sinon on trade du penny stock avec données fondamental absentes). 3. Données manquantes : Fine retourne None pour les actions sans fondamentales (small caps, ETF, IPO récentes). Coarse en amont évite ces None bizarres.

Codes Morningstar Sector

Les codes secteurs Morningstar sont des constantes entières :

Secteur Code
Basic Materials 101
Consumer Cyclical 102
Financial Services 103
Real Estate 104
Consumer Defensive 205
Healthcare 206
Utilities 207
Communication Services 308
Energy 309
Industrials 310
Technology 311

Exemple : Sélection multi-critères

Critères typiques d’un value investor quant : - ValuationRatios.PERatio < 15 - ValuationRatios.PBRatio < 2 - OperationRatios.ROE > 0.15 - AssetClassification.MorningstarSectorCode == 311 (Technology) - HasFundamentalData == True

Résultat typique : 5-20 actions retenues sur 200-500 après Coarse.

Pédagogie : plus le filtre est restrictif, plus le turnover est faible (les actions qui passent tous les critères changent rarement). Un turnover faible = moins de frais = meilleur Sharpe net.

# [REFERENCE QC] Code a copier dans main.py QC Lab (non executable ici)
# Fine Universe Selection : Actions Tech avec P/E < 30
# A copier dans l'IDE QuantConnect

from AlgorithmImports import *

class TechValueUniverse(QCAlgorithm):
    """
    Selection d'actions du secteur technologie avec P/E raisonnable.
    Coarse: Volume > 10M$, Prix > 10$
    Fine: Secteur Tech, 0 < P/E < 30
    """
    
    def Initialize(self):
        self.SetStartDate(2015, 1, 1)
        self.SetEndDate(2024, 12, 31)
        self.SetCash(100000)
        
        # Universe avec Coarse ET Fine
        self.AddUniverse(self.CoarseSelectionFunction, 
                         self.FineSelectionFunction)
        
        self.num_coarse = 500  # Candidats pour Fine
        self.num_fine = 20     # Selection finale
        
        self.Log("Tech Value Universe initialized")
    
    def CoarseSelectionFunction(self, coarse):
        """Premier filtre: liquidite et HasFundamentalData."""
        # Filtrer les actions avec donnees fondamentales
        filtered = [x for x in coarse 
                    if x.HasFundamentalData  # IMPORTANT pour Fine
                    and x.Price > 10 
                    and x.DollarVolume > 10000000]
        
        # Trier par volume et prendre les top N
        sorted_stocks = sorted(filtered, key=lambda x: x.DollarVolume, reverse=True)
        
        self.Log(f"Coarse: {len(coarse)} -> {len(filtered)} filtered -> {min(len(sorted_stocks), self.num_coarse)} candidates")
        
        return [x.Symbol for x in sorted_stocks[:self.num_coarse]]
    
    def FineSelectionFunction(self, fine):
        """Deuxieme filtre: secteur et valorisation."""
        # Filtrer par secteur: Technology
        tech_stocks = [x for x in fine 
                       if x.AssetClassification.MorningstarSectorCode == MorningstarSectorCode.Technology]
        
        # Filtrer par P/E: entre 0 et 30 (exclure negatif et surevaloue)
        value_stocks = [x for x in tech_stocks 
                        if x.ValuationRatios.PERatio > 0 
                        and x.ValuationRatios.PERatio < 30]
        
        # Trier par P/E croissant (plus bas = meilleur)
        sorted_by_pe = sorted(value_stocks, 
                               key=lambda x: x.ValuationRatios.PERatio)
        
        self.Log(f"Fine: {len(fine)} -> {len(tech_stocks)} tech -> {len(value_stocks)} P/E<30 -> {min(len(sorted_by_pe), self.num_fine)} selected")
        
        return [x.Symbol for x in sorted_by_pe[:self.num_fine]]
    
    def OnSecuritiesChanged(self, changes):
        """Rebalancer quand l'univers change."""
        # Vendre les actions retirees
        for security in changes.RemovedSecurities:
            if security.Invested:
                self.Liquidate(security.Symbol)
                self.Log(f"Removed: {security.Symbol.Value}")
        
        # Acheter les nouvelles actions (equal weight)
        if len(changes.AddedSecurities) > 0:
            weight = 1.0 / max(len(self.ActiveSecurities), 1)
            for security in changes.AddedSecurities:
                self.SetHoldings(security.Symbol, weight)
                self.Log(f"Added: {security.Symbol.Value} -> {weight:.1%}")

Backtest QC Cloud : TechValueUniverse

Résultat End Equity
Exploration (0 ordre) $100,000.00

Coarse+Fine tech/value sélection, 2020-2024

Résultats backtest QC Cloud : TechValueUniverse (2015-2024, $100k) — 0 ordres, Net Profit 0%. Demo Coarse+Fine Sélection (Tech sector, P/E < 30). La sélection fonctionne mais pas de logique de trading dans OnData.

Codes Morningstar Sector

Les secteurs Morningstar sont accessibles via MorningstarSectorCode :

Code Secteur Exemples
BasicMaterials Materiaux de base Mining, Chimie
ConsumerCyclical Conso. cyclique Retail, Automobile
FinancialServices Services financiers Banques, Assurances
RealEstate Immobilier REITs
ConsumerDefensive Conso. defensive Alimentation, Pharma
Healthcare Sante Biotech, Hopitaux
Utilities Services publics Electricite, Gaz
CommunicationServices Communication Telecom, Media
Energy Energie Petrole, Gaz
Industrials Industrie Aerospace, Transport
Technology Technologie Software, Hardware

Exemple: Sélection multi-critères

Codes Morningstar Sector — détails

Les codes Morningstar Sector sont organisés en 3 groupes de 3 chiffres chacun. Les secteurs cycliques (101-104) répondent à la croissance économique ; les défensifs (205-207) y sont moins sensibles ; les sensibles au taux (308-311) sont très corrélés aux taux d’intérêt.

Sector vs Industry : chaque secteur Morningstar contient ~10-15 industries. Par exemple, Technology (311) contient Software, Hardware, Semiconductors. Si votre filtre cible un sous-secteur, utilisez MorningstarIndustryCode au lieu de MorningstarSectorCode.

Exemple : Sélection multi-critères (détaillée)

Voici la décomposition d’un filtre Fine classique :

def FineSelectionFunction(self, fine):
    filtered = [x for x in fine
                if x.ValuationRatios.PERatio < 15
                and x.ValuationRatios.PBRatio < 2
                and x.OperationRatios.ROE > 0.15
                and x.AssetClassification.MorningstarSectorCode == 311
                and x.HasFundamentalData]
    return [x.Symbol for x in filtered]

Pédagogie : ce filtre est conservateur — il cherche des actions : - Value (P/E < 15, P/B < 2) - Profitables (ROE > 15%) - Tech (secteur 311)

Résultat attendu : 5-15 actions sur 8000. C’est typique d’un value tech style Buffett-tech.

Anti-pattern : trop de critères

Un filtre avec 8 critères AND ensemble peut retourner 0 action la plupart du temps → signal absent. Commencez avec 2-3 critères, validez qu’ils sont corrélés (P/E et P/B le sont souvent), puis ajoutez 1 critère à la fois.

Métrique de validation : après backtest, vérifiez que la cardinalité moyenne de l’univers final est 5-30 actions. Trop peu (< 3) = signal fragile, trop (> 50) = filtre inefficace.

# [REFERENCE QC] Code a copier dans main.py QC Lab (non executable ici)
# Fine Selection avec criteres multiples
# Large Cap + Dividende + Low P/E

from AlgorithmImports import *

class DividendValueUniverse(QCAlgorithm):
    """
    Selection: Large Cap + Dividend Yield > 2% + P/E < 20
    """
    
    def Initialize(self):
        self.SetStartDate(2015, 1, 1)
        self.SetEndDate(2024, 12, 31)
        self.SetCash(100000)
        
        self.AddUniverse(self.CoarseSelectionFunction, 
                         self.FineSelectionFunction)
        
        # Criteres
        self.min_market_cap = 10e9        # 10B$ minimum
        self.min_dividend_yield = 0.02    # 2% minimum
        self.max_pe_ratio = 20            # P/E max
        self.num_stocks = 30
    
    def CoarseSelectionFunction(self, coarse):
        filtered = [x for x in coarse 
                    if x.HasFundamentalData
                    and x.Price > 5 
                    and x.DollarVolume > 5000000]
        
        sorted_stocks = sorted(filtered, key=lambda x: x.DollarVolume, reverse=True)
        return [x.Symbol for x in sorted_stocks[:500]]
    
    def FineSelectionFunction(self, fine):
        # Filtre 1: Large Cap (Market Cap > 10B$)
        large_cap = [x for x in fine if x.MarketCap > self.min_market_cap]
        
        # Filtre 2: Dividend Yield > 2%
        dividend_payers = [x for x in large_cap 
                           if x.ValuationRatios.TrailingDividendYield is not None
                           and x.ValuationRatios.TrailingDividendYield > self.min_dividend_yield]
        
        # Filtre 3: P/E entre 0 et 20
        value_stocks = [x for x in dividend_payers 
                        if x.ValuationRatios.PERatio is not None
                        and 0 < x.ValuationRatios.PERatio < self.max_pe_ratio]
        
        # Trier par dividend yield (plus haut = meilleur)
        sorted_by_yield = sorted(value_stocks, 
                                  key=lambda x: x.ValuationRatios.TrailingDividendYield,
                                  reverse=True)
        
        self.Log(f"Fine: {len(fine)} total -> {len(large_cap)} large -> {len(dividend_payers)} dividend -> {len(value_stocks)} value")
        
        return [x.Symbol for x in sorted_by_yield[:self.num_stocks]]

Backtest QC Cloud : DividendValueUniverse

Résultat End Equity
Exploration (0 ordre) $100,000.00

Coarse+Fine dividend/value sélection, 2020-2024

Résultats backtest QC Cloud : DividendValueUniverse (2015-2024, $100k) — 0 ordres, Net Profit 0%. Demo Fine Sélection multi-critères (Large Cap + Dividend Yield > 2% + P/E < 20). Pas de logique OnData pour le trading.


Exercice 2 : Filtre fondamental personnalise par secteur

En vous basant sur les exemples TechValueUniverse et DividendValueUniverse ci-dessus, créez un algorithme qui selectionne des actions selon des critères fondamentaux personnalises dans un secteur spécifique.

Objectif : implementer SectorValueUniverse avec les specifications suivantes :

  • Coarse : prix > 5 \(, DollarVolume > 8 M\)/jour, HasFundamentalData = True, trier par DollarVolume, garder les 400 meilleurs
  • Fine : secteur Healthcare (MorningstarSectorCode.Healthcare), P/B Ratio entre 0.5 et 3, Market Cap > 2 B$, trier par P/B croissant, garder les 15 premières actions
  • Rebalancement : dans OnSecuritiesChanged, liquidate les sortants et equal-weight sur les entrants

Indices : - x.AssetClassification.MorningstarSectorCode == MorningstarSectorCode.Healthcare pour filtrer le secteur sante - x.ValuationRatios.PBRatio pour le Price-to-Book ratio - x.MarketCap pour la capitalisation boursiere (en dollars) - Verifiez que PBRatio n’est pas None et est strictement positif avant de comparer - Inspirez-vous du pattern TechValueUniverse pour la structure Coarse+Fine

Exercice 2 : Filtre fondamental personnalisé par secteur

Objectif : Implémenter un filtre Fine qui combine 2 ratios fondamentaux ET un filtre sectoriel.

Contexte : Vous voulez backtester une stratégie value sur les secteurs cycliques (Basic Materials + Consumer Cyclical + Financial Services). Vous pensez que les secteurs cycliques sous-performent en moyenne mais ont des épisodes de rebound après une récession.

Travail demandé : 1. Implémenter FineSelectionFunction qui filtre : - ValuationRatios.PERatio < 12 (value stricte) - OperationRatios.ROE > 0.10 (rentabilité minimale) - AssetClassification.MorningstarSectorCode in [101, 102, 103] 2. Retourner les 10-20 meilleurs (tri par ROE décroissant). 3. Backtester sur 2015-2024, rebalance mensuel.

Indices : - Utiliser sorted(filtered, key=lambda x: x.OperationRatios.ROE, reverse=True)[:20] - Ne pas oublier HasFundamentalData (sinon None pour petites caps). - Tracker le nombre d’actions retenues dans self.universe_size pour debugging.

Pédagogie : cet exercice illustre deux trade-offs : 1. Valeur stricte (P/E < 12) : capte les value traps (P/E bas car earnings en chute). À surveiller avec un filtre supplémentaire (ROE > 10% pour exclure les bankruptcies latentes). 2. Cycliques : ces secteurs ont un bêta élevé (1.2-1.5). Le backtest va probablement montrer un DD plus profond que sur des secteurs défensifs. C’est attendu, pas un bug.

Validation attendue : CAGR 8-12% (en dessus de SPY ~10%), DD -20% à -30% (au-dessus de SPY ~-25%), Sharpe 0.5-0.7. Si DD > -35%, le filtre n’élimine pas assez les value traps.

# [REFERENCE QC] Exercice 2 : Filtre fondamental personnalise par secteur
# A completer et copier dans l'IDE QuantConnect

from AlgorithmImports import *

class SectorValueUniverse(QCAlgorithm):
    """
    TODO etudiant : implementer un universe Healthcare avec filtres
    P/B Ratio et Market Cap.
    """
    
    def Initialize(self):
        self.SetStartDate(2015, 1, 1)
        self.SetEndDate(2024, 12, 31)
        self.SetCash(100000)
        
        # TODO etudiant : ajouter l'univers avec Coarse + Fine selection
        # self.AddUniverse(self.CoarseSelectionFunction, self.FineSelectionFunction)
        
        self.num_coarse = 400
        self.num_fine = 15
    
    def CoarseSelectionFunction(self, coarse):
        # TODO etudiant : filtrer par HasFundamentalData, Price > 5, DollarVolume > 8M
        # Etape 1 : filtrer
        # Etape 2 : trier par DollarVolume decroissant
        # Etape 3 : retourner top 400
        return None  # TODO etudiant : retourner la liste de Symbol
    
    def FineSelectionFunction(self, fine):
        # TODO etudiant : filtrer par secteur Healthcare, P/B entre 0.5 et 3, MarketCap > 2B
        # Etape 1 : secteur Healthcare (MorningstarSectorCode.Healthcare)
        # Etape 2 : P/B Ratio entre 0.5 et 3 (verifier None)
        # Etape 3 : Market Cap > 2 milliards
        # Etape 4 : trier par P/B croissant, garder 15
        return None  # TODO etudiant : retourner la liste de Symbol
    
    def OnSecuritiesChanged(self, changes):
        # TODO etudiant : liquidate sortants, equal-weight entrants
        pass

print("Exercice a completer : SectorValueUniverse")

5. Universe Settings (10 min)

Configurer le comportement de l’univers

UniverseSettings permet de configurer comment QuantConnect gere les actions ajoutees dynamiquement a l’univers.

Proprietes de UniverseSettings

Propriete Type Description Defaut
Resolution Resolution Resolution des données Minute
Leverage float Levier maximum 2.0
FillForward bool Remplir les données manquantes True
ExtendedMarketHours bool Inclure pre/post-market False
MinimumTimeInUniverse TimeSpan Duree minimum avant retrait 1 jour
DataNormalizationMode Mode Ajustement des prix Adjusted

5. Universe Settings (10 min)

Les Universe Settings contrôlent le comportement du pipeline de sélection d’univers : à quelle fréquence tourne Fine, comment les actions manquantes sont gérées, etc.

Propriétés de UniverseSettings

Propriété Défaut Rôle
Resolution Resolution.Minute Granularité des données
Leverage 1.0 Levier max
DataNormalizationMode Adjusted Split/dividendes ajustés
MinimumOrderMarginPortfolioPercentage 0.0 Marge min par ordre
Settings.FreePortfolioValue 0 Cash à ne pas investir

Pédagogie : la propriété Resolution est critique pour les performances. Si votre algo trade sur des données daily mais que vous réglez Resolution.Minute, vous payez le coût de stockage et de calcul des données intraday pour rien. Adapter la résolution à la stratégie :

  • Stratégie daily / weekly : Resolution.Daily
  • Stratégie intraday (HFT, scalping) : Resolution.Minute ou Resolution.Second
# [REFERENCE QC] Code a copier dans main.py QC Lab (non executable ici)
# Configuration de UniverseSettings
# A copier dans l'IDE QuantConnect

from AlgorithmImports import *

class UniverseSettingsExample(QCAlgorithm):
    """
    Demonstration des UniverseSettings.
    """
    
    def Initialize(self):
        self.SetStartDate(2015, 1, 1)
        self.SetEndDate(2024, 12, 31)
        self.SetCash(100000)
        
        # === UNIVERSE SETTINGS ===
        
        # 1. Resolution des donnees pour les nouvelles securities
        # Daily = plus rapide, Minute = plus precis
        self.UniverseSettings.Resolution = Resolution.Daily
        
        # 2. Levier maximum autorise
        # 1.0 = pas de levier, 2.0 = x2 max
        self.UniverseSettings.Leverage = 1.0
        
        # 3. Fill Forward : remplir les jours sans donnees
        # avec la derniere valeur connue (utile pour actions peu liquides)
        self.UniverseSettings.FillForward = True
        
        # 4. Extended Market Hours : inclure pre-market (4am) et after-hours (8pm)
        # False = seulement 9:30am - 4:00pm
        self.UniverseSettings.ExtendedMarketHours = False
        
        # 5. Minimum Time In Universe : eviter le churn
        # Une action reste au moins 1 jour dans l'univers
        self.UniverseSettings.MinimumTimeInUniverse = timedelta(days=1)
        
        # 6. Data Normalization : ajustement des prix
        # Adjusted = ajuste pour splits/dividendes (recommande)
        self.UniverseSettings.DataNormalizationMode = DataNormalizationMode.Adjusted
        
        # Ajouter l'univers
        self.AddUniverse(self.CoarseSelectionFunction)
        
        self.Log("Universe settings configured:")
        self.Log(f"  Resolution: {self.UniverseSettings.Resolution}")
        self.Log(f"  Leverage: {self.UniverseSettings.Leverage}")
        self.Log(f"  FillForward: {self.UniverseSettings.FillForward}")
        self.Log(f"  ExtendedMarketHours: {self.UniverseSettings.ExtendedMarketHours}")
    
    def CoarseSelectionFunction(self, coarse):
        filtered = [x for x in coarse 
                    if x.HasFundamentalData
                    and x.Price > 10 
                    and x.DollarVolume > 10000000]
        
        sorted_stocks = sorted(filtered, key=lambda x: x.DollarVolume, reverse=True)
        return [x.Symbol for x in sorted_stocks[:50]]

Backtest QC Cloud : UniverseSettingsExample

Résultat End Equity
Exploration (0 ordre) $100,000.00

UniverseSettings demo, 2020-2024

Résultats backtest QC Cloud : UniverseSettingsExample (2015-2024, $100k) — 0 ordres, Net Profit 0%. Demo de configuration uniquement (pas de logique de trading). Resolution Daily, Leverage 1.0, FillForward True, DataNormalization Adjusted.

Quand modifier les settings?

Scénario Setting recommande
Backtest rapide Resolution.Daily
Stratégie intraday Resolution.Minute ou Hour
Sans levier Leverage = 1.0
Actions illiquides FillForward = True
Scalping pre-market ExtendedMarketHours = True
Eviter overtrading MinimumTimeInUniverse = timedelta(days=7)

Quand modifier les settings ?

Les Universe Settings se modifient dans Initialize AVANT d’ajouter l’univers. Les cas typiques :

  1. Stratégie daily sur données daily : self.UniverseSettings.Resolution = Resolution.Daily pour éviter de charger les données intraday inutiles.

  2. Levier : si vous voulez trader avec 2× levier, self.UniverseSettings .Leverage = 2.0. Attention : QC supporte le levier, mais le margin call automatique si la perte dépasse la marge.

  3. FreePortfolioValuePercentage : laisse X% du portefeuille en cash pour absorber les drawdowns sans vendre en perte. Typique 5-10%.

  4. DataNormalizationMode : Adjusted (défaut, ajuste splits/ dividendes) vs Raw (prix bruts). Pour le backtest, toujours Adjusted sauf cas particulier (tester une stratégie event-driven sur splits).

Piège classique : Resolution mismatch

Si votre OnData traite des données daily mais que vous avez oublié de configurer UniverseSettings.Resolution = Resolution.Daily, QC charge les minute bars et appelle OnData à chaque minute. Le backtest prend 300× plus de temps (390 minutes vs 6.5h de marché × 252 jours × 10 ans).

Symptôme : backtest qui prend plusieurs heures sans raison apparente. Solution : vérifier la Resolution en premier.


6. Dynamic Rebalancing (15 min)

Gerer les changements d’univers avec OnSecuritiesChanged

Quand l’univers change (nouvelle action ajoutee ou retiree), QuantConnect appelle OnSecuritiesChanged(changes). C’est ici que vous gerez le rebalancement.

Structure de SecurityChanges

def OnSecuritiesChanged(self, changes):
    # changes.AddedSecurities : liste des nouvelles actions
    # changes.RemovedSecurities : liste des actions retirees
    
    for security in changes.RemovedSecurities:
        # Vendre la position
        pass
    
    for security in changes.AddedSecurities:
        # Acheter la nouvelle action
        pass

Proprietes de Security

Propriete Type Description
Symbol Symbol Identifiant de l’action
Invested bool True si position ouverte
Price float Prix actuel
Holdings SecurityHolding Details de la position

6. Dynamic Rebalancing (15 min)

Le rebalancement dynamique gère les situations où l’univers change pendant la période de détention : une action sort de l’univers (ne passe plus le filtre), une nouvelle action entre, une fusion/acquisition modifie les symboles.

Gérer les changements d’univers avec OnSecuritiesChanged

QC expose deux callbacks : - OnSecuritiesChanged(self, changes) : appelé quand l’univers change. changes.AddedSecurities et changes.RemovedSecurities contiennent les Security ajoutées/retirées.

Pattern classique :

def OnSecuritiesChanged(self, changes):
    for security in changes.RemovedSecurities:
        if security.Invested:
            self.Liquidate(security.Symbol)
    for security in changes.AddedSecurities:
        if not self.Portfolio[security.Symbol].Invested:
            self.SetHoldings(security.Symbol, 0.05)  # 5% par position

Structure de SecurityChanges

SecurityChanges contient 3 propriétés : - AddedSecurities : nouvelles actions dans l’univers - RemovedSecurities : actions sorties de l’univers - (parfois) rien pour les actions inchangées

Pédagogie : le liquidate sur action retirée est critique. Si une action sort de votre filtre (ex: son P/E passe à 25), votre algo doit vendre immédiatement. Sinon vous continuez à détenir une action qui ne respecte plus vos critères.

Anti-pattern : ignorer OnSecuritiesChanged

L’erreur classique du débutant : oublier OnSecuritiesChanged et utiliser seulement OnData. Résultat : votre algo continue à détenir des actions sorties de l’univers, contaminant le backtest avec des positions hors-critères. Toujours implémenter les deux callbacks.

# [REFERENCE QC] Code a copier dans main.py QC Lab (non executable ici)
# Dynamic Rebalancing avec OnSecuritiesChanged
# A copier dans l'IDE QuantConnect

from AlgorithmImports import *

class DynamicRebalancingAlgorithm(QCAlgorithm):
    """
    Rebalancement automatique lors des changements d'univers.
    - Liquidate les actions retirees
    - Acheter les nouvelles actions en equal-weight
    """
    
    def Initialize(self):
        self.SetStartDate(2015, 1, 1)
        self.SetEndDate(2024, 12, 31)
        self.SetCash(100000)
        
        self.UniverseSettings.Resolution = Resolution.Daily
        self.AddUniverse(self.CoarseSelectionFunction)
        
        self.num_stocks = 20
        self.last_month = -1
    
    def CoarseSelectionFunction(self, coarse):
        # Reselection mensuelle
        if self.Time.month == self.last_month:
            return Universe.Unchanged
        self.last_month = self.Time.month
        
        filtered = [x for x in coarse 
                    if x.HasFundamentalData
                    and x.Price > 10 
                    and x.DollarVolume > 10000000]
        
        sorted_stocks = sorted(filtered, key=lambda x: x.DollarVolume, reverse=True)
        return [x.Symbol for x in sorted_stocks[:self.num_stocks]]
    
    def OnSecuritiesChanged(self, changes):
        """
        Appele automatiquement quand l'univers change.
        Gere le rebalancement du portfolio.
        """
        # Etape 1: Liquider les positions retirees
        for security in changes.RemovedSecurities:
            if security.Invested:
                self.Liquidate(security.Symbol)
                self.Log(f"SELL (removed): {security.Symbol.Value}")
        
        # Etape 2: Calculer le poids cible
        # Utiliser ActiveSecurities pour avoir le nombre actuel
        count = len(self.ActiveSecurities)
        if count == 0:
            return
        
        target_weight = 1.0 / count
        
        # Etape 3: Rebalancer toutes les positions
        for symbol in self.ActiveSecurities.Keys:
            self.SetHoldings(symbol, target_weight)
        
        self.Log(f"Rebalanced: {count} securities @ {target_weight:.1%} each")
    
    def OnData(self, data):
        # Pas de logique supplementaire ici
        # Le rebalancement est gere par OnSecuritiesChanged
        pass

Résultats backtest QC Cloud : DynamicRebalancingAlgorithm (2015-2024, $100k) — 2461 ordres, Net Profit +179.7%, Sharpe 0.406, CAGR 10.8%, MaxDD 38.7%, Win Rate 72%. Top 20 mensuel, equal-weight via OnSecuritiesChanged.

Patterns de rebalancement

1. Equal-Weight (ci-dessus) :

weight = 1.0 / len(self.ActiveSecurities)
for symbol in self.ActiveSecurities.Keys:
    self.SetHoldings(symbol, weight)

2. Market-Cap Weighted :

total_cap = sum(s.Fundamentals.MarketCap for s in self.ActiveSecurities.Values)
for security in self.ActiveSecurities.Values:
    weight = security.Fundamentals.MarketCap / total_cap
    self.SetHoldings(security.Symbol, weight)

3. Volatility-Weighted (Risk Parity) :

# Calcul des volatilites historiques
# Allouer plus aux actions moins volatiles

Eviter le over-trading

Patterns de rebalancement

Trois patterns courants de rebalancement quand l’univers change :

  1. Liquidate + re-allocation : vendre toutes les positions retirées, recalculer les poids cibles sur le nouvel univers, racheter. Simple mais génère beaucoup de frais si l’univers change souvent.

  2. Hold-and-rotate : garder les positions actuelles qui sont toujours dans l’univers, ajouter les nouvelles, vendre uniquement les retirées. Moins de frais, mais dérive des poids cibles.

  3. Buffer zone : définir une marge autour des critères (ex : P/E entre 12 et 18 = keep, P/E > 18 ou P/E < 12 = rotate). Réduit le turnover sans sacrifier le signal.

Calcul des volatilités historiques

Pour un rebalancement risk-parity :

history = self.History(symbol, 60, Resolution.Daily)
vol = history['close'].pct_change().std() * (252 ** 0.5)

Le calcul prend les 60 derniers jours de prix, calcule les returns quotidiens, et annualise la volatilité (×√252).

Allouer plus aux actions moins volatiles

Inverse volatility weighting :

weights = {sym: 1/vol for sym, vol in vols.items()}
total = sum(weights.values())
weights = {sym: w/total for sym, w in weights.items()}

Pédagogie : cette allocation égalise la contribution au risque de chaque position. Une action à 30% de vol reçoit 2× moins de capital qu’une action à 15% de vol. C’est le principe de Risk Parity appliqué à un seul actif.

Limite : suppose que la volatilité est stationnaire, ce qui n’est pas le cas en stress (cf QC-Py-08 Multi-Asset).

# [REFERENCE QC] Code a copier dans main.py QC Lab (non executable ici)
# Rebalancement intelligent avec seuil de tolerance
# Evite de rebalancer pour de petites variations

from AlgorithmImports import *

class SmartRebalancingAlgorithm(QCAlgorithm):
    """
    Rebalancement avec seuil de tolerance.
    Ne rebalance que si la deviation depasse 5%.
    """
    
    def Initialize(self):
        self.SetStartDate(2015, 1, 1)
        self.SetEndDate(2024, 12, 31)
        self.SetCash(100000)
        
        self.UniverseSettings.Resolution = Resolution.Daily
        self.AddUniverse(self.CoarseSelectionFunction)
        
        self.num_stocks = 20
        self.tolerance = 0.05  # 5% de deviation max
        self.last_month = -1
    
    def CoarseSelectionFunction(self, coarse):
        if self.Time.month == self.last_month:
            return Universe.Unchanged
        self.last_month = self.Time.month
        
        filtered = [x for x in coarse 
                    if x.HasFundamentalData and x.Price > 10 and x.DollarVolume > 10000000]
        sorted_stocks = sorted(filtered, key=lambda x: x.DollarVolume, reverse=True)
        return [x.Symbol for x in sorted_stocks[:self.num_stocks]]
    
    def OnSecuritiesChanged(self, changes):
        # Liquider les retirees
        for security in changes.RemovedSecurities:
            if security.Invested:
                self.Liquidate(security.Symbol)
        
        # Rebalancer avec tolerance
        self.RebalanceWithTolerance()
    
    def RebalanceWithTolerance(self):
        """Rebalance seulement si deviation > tolerance."""
        count = len(self.ActiveSecurities)
        if count == 0:
            return
        
        target_weight = 1.0 / count
        portfolio_value = self.Portfolio.TotalPortfolioValue
        
        for symbol in self.ActiveSecurities.Keys:
            holding = self.Portfolio[symbol]
            current_weight = holding.HoldingsValue / portfolio_value if portfolio_value > 0 else 0
            
            # Rebalancer seulement si deviation > tolerance
            if abs(current_weight - target_weight) > self.tolerance:
                self.SetHoldings(symbol, target_weight)
                self.Debug(f"Rebalanced {symbol.Value}: {current_weight:.1%} -> {target_weight:.1%}")

Résultats backtest QC Cloud : SmartRebalancingAlgorithm (2015-2024, $100k) — 83 ordres, Net Profit +148.1%, Sharpe 0.476, CAGR 9.5%, MaxDD 29.2%, Win Rate 59%. Top 20 avec tolerance 5%, rebalancement uniquement si deviation significative. Moins d’ordres = moins de frais.


Exercice 3 : Universe combine avec filtre qualite

En vous basant sur les exemples de Coarse + Fine Sélection et de rebalancement intelligent vus precedemment, créez un algorithme qui combine un filtre de liquidite (Coarse) avec un filtre de qualite fondamentale (Fine).

Objectif : implementer QualityUniverseAlgorithm avec les specifications suivantes :

  • Coarse : prix > 10 \(, DollarVolume > 5 M\)/jour, HasFundamentalData = True
  • Fine : ROE (Return on Equity) > 15 %, Debt-to-Equity < 2, P/E entre 5 et 25
  • Sélection : trier par MarketCap decroissant, garder les 25 premières actions
  • Rebalancement : mensuel avec Universe.Unchanged les autres jours

Indices : - x.FinancialStatements.BalanceSheet.TotalAssets.Value pour les actifs totaux - x.FinancialStatements.BalanceSheet.TotalLiabilities.AsQuaterly.Value pour le passif total - x.ValuationRatios.PERatio pour le P/E ratio - x.EarningRatios.ROE ou x.OperationRatios.ROE.ThreeMonth pour le ROE - Pensez a verifier que les valeurs ne sont pas None avant de les comparer

Exercice 3 : Universe combiné avec filtre qualité

Objectif : Combiner Manual + Fine Universe dans une même stratégie avec un filtre qualité additionnel (ROE > 15%).

Contexte : Vous voulez backtester une stratégie “quality value” sur 30 actions de grande capitalisation US. Vous partez d’une liste manuelle (les 30 plus grosses caps), vous filtrez Fine sur ROE > 15% ET P/E < 18, et vous rebalancez trimestriellement.

Travail demandé : 1. Définir un Manual Universe de 30 symboles (top 30 SPY par exemple : AAPL/MSFT/GOOGL/AMZN/NVDA/META/…). 2. Ajouter un FineSelectionFunction qui filtre ROE > 0.15 ET P/E < 18. 3. Implémenter OnSecuritiesChanged pour liquidate les positions retirées. 4. Rebalance trimestriellement (1er jan/avr/jui/oct).

Indices : - Stocker self.rebalance_quarter = self.Time.month in [1, 4, 7, 10]. - Utiliser self.SetHoldings(symbol, 1/N) pour equal-weight sur N actions retenues. - Tracker dans self.universe_size le nombre d’actions après filtre.

Pédagogie : cet exercice illustre 3 concepts combinés : 1. Manual Universe (liste connue) + Fine Selection (filtre dynamique sur fondamentales) = pipeline hybride. 2. OnSecuritiesChanged (gestion des rotations) — sans cela, le backtest dérive. 3. Rebalance trimestriel (compromis frais/signal) — le quotidien serait trop de frais, l’annuel trop lent à réagir.

Validation attendue : CAGR 9-13% (typique quality value), DD -15% à -20% (moins que SPY car filtre qualité), Sharpe 0.7-0.9. Si Sharpe < 0.5, le filtre qualité n’apporte pas de signal discriminant.

# [REFERENCE QC] Exercice 3 : Universe combine avec filtre qualite
# A completer et copier dans l'IDE QuantConnect

from AlgorithmImports import *

class QualityUniverseAlgorithm(QCAlgorithm):
    """
    TODO etudiant : implementer un universe combine Coarse + Fine
    avec filtres de qualite fondamentale (ROE, Debt/Equity, P/E).
    """
    
    def Initialize(self):
        self.SetStartDate(2015, 1, 1)
        self.SetEndDate(2024, 12, 31)
        self.SetCash(100000)
        
        # TODO etudiant : configurer UniverseSettings (Resolution, Leverage)
        
        # TODO etudiant : ajouter l'univers avec Coarse + Fine selection
        # self.AddUniverse(self.CoarseSelectionFunction, self.FineSelectionFunction)
        
        # Parametres
        self.num_stocks = 25
        self.last_month = -1
    
    def CoarseSelectionFunction(self, coarse):
        # TODO etudiant : implementer le filtre Coarse
        # Etape 1 : verifier si nouveau mois (sinon return Universe.Unchanged)
        # Etape 2 : filtrer par HasFundamentalData, Price > 10, DollarVolume > 5M
        # Etape 3 : trier par DollarVolume decroissant, retourner top 500
        return None  # TODO etudiant : retourner la liste de Symbol
    
    def FineSelectionFunction(self, fine):
        # TODO etudiant : implementer le filtre Fine
        # Etape 1 : filtrer par ROE > 15% (verifier que la valeur n'est pas None)
        # Etape 2 : filtrer par Debt-to-Equity < 2
        # Etape 3 : filtrer par P/E entre 5 et 25
        # Etape 4 : trier par MarketCap decroissant, retourner top 25
        return None  # TODO etudiant : retourner la liste de Symbol
    
    def OnSecuritiesChanged(self, changes):
        # TODO etudiant : gerer le rebalancement (liquidate sortants, equal-weight entrants)
        pass

print("Exercice a completer : QualityUniverseAlgorithm")

7. Scheduled Universe Sélection (10 min)

Planifier le rebalancement periodique

Au lieu de rebalancer a chaque changement d’univers, vous pouvez planifier des rebalancements a dates fixes avec self.Schedule.On().

Schedule.On() pour rebalancement periodique

# Rebalancement le premier jour du mois a 10h
self.Schedule.On(
    self.DateRules.MonthStart("SPY"),     # Date rule
    self.TimeRules.At(10, 0),              # Time rule
    self.Rebalance                         # Action
)

DateRules disponibles

DateRule Description Exemple
EveryDay() Tous les jours Rebalancement quotidien
Every(day) Jours spécifiques Every(DayOfWeek.Monday)
MonthStart(symbol) Premier jour de bourse du mois Rebalancement mensuel
MonthEnd(symbol) Dernier jour du mois
WeekStart(symbol) Premier jour de la semaine Rebalancement hebdo
WeekEnd(symbol) Dernier jour de la semaine

7. Scheduled Universe Selection (10 min)

Le Scheduled Universe Selection combine les forces de Coarse et Fine avec un rebalancement planifié. Au lieu de tourner quotidiennement (Coarse) ou à chaque Fundamental update (Fine), on tourne à intervalle fixe : hebdomadaire, mensuel, trimestriel.

Planifier le rebalancement périodique

self.Schedule.On(self.DateRules.MonthStart("SPY"),
                 self.TimeRules.AfterMarketOpen("SPY", 30),
                 self.Rebalance)

Trois composantes : 1. DateRules.MonthStart("SPY") : quand (premier jour de bourse du mois). 2. TimeRules.AfterMarketOpen("SPY", 30) : à quelle heure (30 min après l’open de SPY). 3. self.Rebalance : la méthode à appeler (qui contient Coarse + Fine).

Piège : MonthStart() sans symbole saute des mois entiers.

DateRules.MonthStart() (sans argument) cale l’événement sur le 1er du mois calendaire : si ce 1er tombe un week-end ou un jour férié, l’événement ne se déclenche pas du tout ce mois-ci — aucun report au jour de bourse suivant. Mesuré sur SPY (2007-2026) : 85 premiers du mois sur 236 ne sont pas des jours de bourse (68 week-ends + 17 jours fériés), soit environ un déclenchement sur trois silencieusement perdu (constat initial du dossier mesuré avec TimeRules.AfterMarketOpen ; le piège est identique quelle que soit la TimeRule).

Les deux formes côte à côte :

# PIEGE : sans symbole, ~1 mois sur 3 sans declenchement (85/236 sur SPY, 2007-2026)
self.Schedule.On(self.DateRules.MonthStart(),
                 self.TimeRules.At(10, 0),
                 self.Rebalance)

# CORRECT : premier jour de BOURSE du mois pour ce symbole, chaque mois
self.Schedule.On(self.DateRules.MonthStart("SPY"),
                 self.TimeRules.At(10, 0),
                 self.Rebalance)

Schedule.On() pour rebalancement périodique

Variantes utiles : - DateRules.WeekStart() : chaque lundi. - DateRules.MonthStart("SPY", n) : premier jour de bourse du n-ième mois de l’année (ex : n = 1, 4, 7, 10 = rebalancement trimestriel, cf. exemples plus bas). - DateRules.EveryDay("SPY") : tous les jours de marché.

Exemples de schedules

Hebdomadaire : chaque lundi à 9h30.

self.Schedule.On(self.DateRules.WeekStart(),
                 self.TimeRules.AfterMarketOpen("SPY", 30),
                 self.Rebalance)

Quotidien : à la clôture (16h ET).

self.Schedule.On(self.DateRules.EveryDay("SPY"),
                 self.TimeRules.BeforeMarketClose("SPY", 0),
                 self.Rebalance)

Pédagogie : le Scheduled Universe est idéal pour les stratégies low-frequency : momentum mensuel, value trimestrielle, mean-reversion hebdomadaire. Pour les stratégies high-frequency (intraday), restez sur Coarse pur.

# [REFERENCE QC] Code a copier dans main.py QC Lab (non executable ici)
# Scheduled Rebalancing : Mensuel
# A copier dans l'IDE QuantConnect

from AlgorithmImports import *

class ScheduledRebalancingAlgorithm(QCAlgorithm):
    """
    Rebalancement planifie le premier jour de chaque mois.
    L'univers est mis a jour mensuellement, le rebalancement aussi.
    """
    
    def Initialize(self):
        self.SetStartDate(2015, 1, 1)
        self.SetEndDate(2024, 12, 31)
        self.SetCash(100000)
        
        # Reference pour DateRules
        self.AddEquity("SPY", Resolution.Daily)
        
        self.UniverseSettings.Resolution = Resolution.Daily
        self.AddUniverse(self.CoarseSelectionFunction)
        
        self.num_stocks = 30
        self.last_month = -1
        
        # Planifier le rebalancement mensuel
        self.Schedule.On(
            self.DateRules.MonthStart("SPY"),  # Premier jour du mois
            self.TimeRules.At(10, 0),           # A 10h00
            self.Rebalance                      # Fonction a appeler
        )
        
        self.Log("Scheduled monthly rebalancing at month start, 10:00 AM")
    
    def CoarseSelectionFunction(self, coarse):
        # Mise a jour mensuelle de l'univers
        if self.Time.month == self.last_month:
            return Universe.Unchanged
        self.last_month = self.Time.month
        
        filtered = [x for x in coarse 
                    if x.HasFundamentalData and x.Price > 10 and x.DollarVolume > 10000000]
        sorted_stocks = sorted(filtered, key=lambda x: x.DollarVolume, reverse=True)
        
        self.Log(f"Universe updated: {len(sorted_stocks[:self.num_stocks])} stocks")
        return [x.Symbol for x in sorted_stocks[:self.num_stocks]]
    
    def Rebalance(self):
        """
        Fonction de rebalancement planifiee.
        Appelee automatiquement le premier jour de chaque mois.
        """
        count = len(self.ActiveSecurities)
        if count == 0:
            self.Log("Rebalance skipped: no active securities")
            return
        
        target_weight = 1.0 / count
        
        # Liquider les positions non presentes dans l'univers actuel
        for symbol in list(self.Portfolio.Keys):
            if symbol not in self.ActiveSecurities and self.Portfolio[symbol].Invested:
                self.Liquidate(symbol)
                self.Log(f"Liquidated (not in universe): {symbol.Value}")
        
        # Rebalancer vers equal-weight
        for symbol in self.ActiveSecurities.Keys:
            self.SetHoldings(symbol, target_weight)
        
        self.Log(f"Rebalanced {count} positions @ {target_weight:.1%} each")
    
    def OnSecuritiesChanged(self, changes):
        # Log les changements mais ne pas rebalancer ici
        # Le rebalancement est gere par la fonction planifiee
        if len(changes.AddedSecurities) > 0:
            self.Log(f"Added: {[s.Symbol.Value for s in changes.AddedSecurities]}")
        if len(changes.RemovedSecurities) > 0:
            self.Log(f"Removed: {[s.Symbol.Value for s in changes.RemovedSecurities]}")

Résultats backtest QC Cloud : ScheduledRebalancingAlgorithm (2015-2024, $100k) — 1515 ordres, Net Profit +187.4%, Sharpe 0.424, CAGR 11.1%, MaxDD 34.6%, Win Rate 83%. Top 30 par volume, rebalancement mensuel via Schedule.On.

Exemples de schedules

# Hebdomadaire : chaque lundi a 9h30
self.Schedule.On(
    self.DateRules.Every(DayOfWeek.Monday),
    self.TimeRules.At(9, 30),
    self.Rebalance
)

# Quotidien : a la cloture
self.Schedule.On(
    self.DateRules.EveryDay("SPY"),
    self.TimeRules.BeforeMarketClose("SPY", 30),  # 30 min avant cloture
    self.Rebalance
)

# Trimestriel : premier jour de Q1, Q2, Q3, Q4
for month in [1, 4, 7, 10]:
    self.Schedule.On(
        self.DateRules.MonthStart("SPY", month),
        self.TimeRules.At(10, 0),
        self.Rebalance
    )

8. Stratégie Complete: Momentum Universe (20 min)

Objectif

Construire une stratégie de momentum complete :

  • Sélection : Top 20 actions par momentum sur 12 mois
  • Rebalancement : Mensuel
  • Allocation : Equal-weight

Théorie du Momentum

Le momentum est base sur l’observation empirique que les gagnants continuent de gagner sur le court/moyen terme (3-12 mois). Les actions avec les meilleurs rendements passes tendent a surperformer.

Formule du momentum :

Momentum_12M = (Prix_actuel - Prix_12M_ago) / Prix_12M_ago

Architecture de la stratégie

1. Coarse Sélection
   ├── Prix > 5$
   ├── Volume > 5M$/jour
   └── Retourne ~500 candidats

2. Fine Sélection (Momentum)
   ├── Recupere historique 252 jours
   ├── Calcule return 12 mois
   ├── Trie par momentum decroissant
   └── Retourne top 20

3. Rebalancement mensuel
   ├── Liquide les sortants
   └── Equal-weight sur les 20

8. Stratégie Complète: Momentum Universe (20 min)

Mise en pratique : une stratégie momentum combinant Coarse + Fine + Scheduled + Manual dans une seule architecture.

Objectif

Construire un portefeuille momentum top 10 parmi les actions US large-cap, rebalancé mensuellement. Le signal : momentum 12 mois (return annualisé). Filtre Fine : P/E < 25 (exclude distressed).

Théorie du Momentum

Le momentum (Jegadeesh & Titman 1993) est l’un des facteurs les plus robustes en finance quantitative :

  • Les actions qui ont bien performé sur 6-12 mois continuent à surperformer sur les 3-12 mois suivants.
  • Le contraire (mean-reversion) fonctionne sur des horizons plus courts (1-5 jours) et pour les small caps.

Causes (théoriques) : 1. Underreaction : les investisseurs ne réagissent pas instantanément aux news. 2. Herding : les investisseurs achètent ce qui monte. 3. Risk-based : le momentum compense un risque non-observé.

Empirique : momentum long-short (long winners, short losers) a historiquement généré ~10-15% de Sharpe annualisé sur 50+ ans (difficile à répliquer post-2009 car crowded).

# [REFERENCE QC] Code a copier dans main.py QC Lab (non executable ici)
# Strategie Momentum Universe Complete
# A copier dans l'IDE QuantConnect

from AlgorithmImports import *
from datetime import timedelta

class MomentumUniverseStrategy(QCAlgorithm):
    """
    Strategie Momentum :
    - Selection : Top 20 par momentum 12 mois
    - Rebalancement : Mensuel
    - Allocation : Equal-weight
    
    Basee sur la recherche academique de Jegadeesh & Titman (1993)
    """
    
    def Initialize(self):
        # Configuration backtest
        self.SetStartDate(2015, 1, 1)
        self.SetEndDate(2024, 12, 31)
        self.SetCash(100000)
        
        # Reference pour schedule
        self.spy = self.AddEquity("SPY", Resolution.Daily).Symbol
        
        # Universe settings
        self.UniverseSettings.Resolution = Resolution.Daily
        self.AddUniverse(self.CoarseSelectionFunction, self.FineSelectionFunction)
        
        # Parametres de la strategie
        self.num_coarse = 500         # Candidats Coarse
        self.num_fine = 20            # Selection finale
        self.lookback_days = 252      # 12 mois de trading
        self.min_price = 5            # Prix minimum
        self.min_volume = 5000000     # Volume minimum ($5M/jour)
        
        # Tracking
        self.last_month = -1
        self.momentum_scores = {}     # Stocker les scores momentum
        
        # Scheduled rebalancing
        self.Schedule.On(
            self.DateRules.MonthStart("SPY"),
            self.TimeRules.At(10, 0),
            self.Rebalance
        )
        
        self.Log("Momentum Universe Strategy initialized")
        self.Log(f"  Top {self.num_fine} stocks by {self.lookback_days}-day momentum")
        self.Log(f"  Monthly rebalancing")
    
    def CoarseSelectionFunction(self, coarse):
        """Premier filtre : liquidite."""
        # Reselection mensuelle uniquement
        if self.Time.month == self.last_month:
            return Universe.Unchanged
        self.last_month = self.Time.month
        
        # Filtrer par prix, volume et donnees fondamentales
        filtered = [x for x in coarse 
                    if x.HasFundamentalData
                    and x.Price > self.min_price 
                    and x.DollarVolume > self.min_volume]
        
        # Trier par volume
        sorted_stocks = sorted(filtered, key=lambda x: x.DollarVolume, reverse=True)
        
        self.Log(f"Coarse: {len(coarse)} total -> {len(filtered)} filtered -> {self.num_coarse} candidates")
        
        return [x.Symbol for x in sorted_stocks[:self.num_coarse]]
    
    def FineSelectionFunction(self, fine):
        """Deuxieme filtre : calcul du momentum."""
        # Calculer le momentum pour chaque action
        momentum_data = []
        
        for stock in fine:
            symbol = stock.Symbol
            
            # Recuperer l'historique 12 mois
            history = self.History(symbol, self.lookback_days, Resolution.Daily)
            
            if history.empty or len(history) < self.lookback_days * 0.9:
                # Pas assez de donnees
                continue
            
            try:
                # Calculer le momentum (return 12 mois)
                price_now = history['close'].iloc[-1]
                price_12m_ago = history['close'].iloc[0]
                
                momentum = (price_now - price_12m_ago) / price_12m_ago
                
                momentum_data.append({
                    'symbol': symbol,
                    'momentum': momentum
                })
            except:
                continue
        
        # Trier par momentum decroissant
        sorted_by_momentum = sorted(momentum_data, 
                                     key=lambda x: x['momentum'], 
                                     reverse=True)
        
        # Garder les top N
        top_momentum = sorted_by_momentum[:self.num_fine]
        
        # Stocker les scores pour reference
        self.momentum_scores = {x['symbol']: x['momentum'] for x in top_momentum}
        
        self.Log(f"Fine: {len(fine)} -> {len(momentum_data)} with data -> {len(top_momentum)} selected")
        if len(top_momentum) > 0:
            self.Log(f"  Top momentum: {top_momentum[0]['symbol'].Value} = {top_momentum[0]['momentum']:.1%}")
            self.Log(f"  Bottom of top: {top_momentum[-1]['symbol'].Value} = {top_momentum[-1]['momentum']:.1%}")
        
        return [x['symbol'] for x in top_momentum]
    
    def Rebalance(self):
        """Rebalancement mensuel en equal-weight."""
        # Exclure SPY (reference)
        trading_symbols = [s for s in self.ActiveSecurities.Keys if s != self.spy]
        
        count = len(trading_symbols)
        if count == 0:
            self.Log("Rebalance: no trading securities")
            return
        
        target_weight = 1.0 / count
        
        # Liquider les positions non presentes dans l'univers
        for symbol in list(self.Portfolio.Keys):
            if symbol not in trading_symbols and self.Portfolio[symbol].Invested:
                self.Liquidate(symbol)
                self.Log(f"Liquidated: {symbol.Value}")
        
        # Rebalancer
        for symbol in trading_symbols:
            momentum = self.momentum_scores.get(symbol, 0)
            self.SetHoldings(symbol, target_weight)
        
        self.Log(f"Rebalanced: {count} positions @ {target_weight:.1%}")
    
    def OnSecuritiesChanged(self, changes):
        """Log les changements d'univers."""
        added = [s.Symbol.Value for s in changes.AddedSecurities if s.Symbol != self.spy]
        removed = [s.Symbol.Value for s in changes.RemovedSecurities if s.Symbol != self.spy]
        
        if added:
            self.Log(f"Universe +{len(added)}: {added[:5]}{'...' if len(added) > 5 else ''}")
        if removed:
            self.Log(f"Universe -{len(removed)}: {removed[:5]}{'...' if len(removed) > 5 else ''}")
    
    def OnEndOfAlgorithm(self):
        """Resume final."""
        self.Log("="*50)
        self.Log("MOMENTUM STRATEGY SUMMARY")
        self.Log("="*50)
        self.Log(f"Initial Capital: ${self.StartingCash:,.2f}")
        self.Log(f"Final Value: ${self.Portfolio.TotalPortfolioValue:,.2f}")
        total_return = (self.Portfolio.TotalPortfolioValue - self.StartingCash) / self.StartingCash
        self.Log(f"Total Return: {total_return:.1%}")
        self.Log("="*50)

Backtest QC Cloud : MomentumUniverseStrategy

Sharpe CAGR MaxDD Net Profit Orders End Equity
0.208 9.008% 19.000% +29.561% 667 $129,560.80

Momentum coarse+fine, 2022-2024, lookback=126d

Résultats backtest QC Cloud : MomentumUniverseStrategy (2015-2024, $100k) — 1952 ordres, Net Profit +163.7%, Sharpe 0.366, CAGR 10.2%, MaxDD 36.9%, Win Rate 78%. Top 20 momentum mensuel, equal-weight. Stratégie complete Coarse+Fine+Schedule.

Points cles de la stratégie

  1. Coarse Sélection : Filtre rapidement ~8000 actions en ~500 candidats liquides
  2. Fine Sélection : Calcule le momentum 12 mois pour chaque candidat
  3. Rebalancement : Equal-weight mensuel sur les top 20
  4. Scheduled : Schedule.On() garantit un rebalancement previsible

Ameliorations possibles

Amelioration Implementation
Momentum ajuste au risque Diviser le momentum par la volatilite
Exclusion du mois recent Skip du dernier mois (reversal)
Filtrage sectoriel Eviter la concentration dans un secteur
Stop-loss Vendre si perte > 20%
Market timing Ne pas investir si SPY < SMA 200

Sélection par périodicité intraday du volume : le critère spectral

Les critères de ce carnet — manuel, coarse, fondamental, momentum — classent les titres sur des données de fin de journée ou fondamentales. La research QuantConnect Intraday Volume Periodicity (Rudy Osuna, 2026) propose un critère d’une autre nature : classer chaque semaine les cent valeurs US les plus liquides par la part de variance de leur volume minute expliquée par la fréquence dominante de sa série — un score spectral — et de conserver le quintile supérieur, pondéré par un score d’exécution (EMA de liquidité).

Le fondement académique est réel : Wu, Zhang et Dai, Spectral Volume Models: Universal High-Frequency Periodicities in Intraday Trading Activities (SSRN 4230610, DOI 10.2139/ssrn.4230610 — preprint vérifié daté de 2022 ; l’article QC le cite « 2025 ») documentent des périodicités intraday universelles du volume. Le mécanisme invoqué est l’asymétrie d’information : un volume qui revient périodiquement porte plus d’information, donc exige une prime de rendement. L’article annonce un Sharpe de 0.792 contre 0.406 pour SPY (juillet 2021 - juillet 2026), les vingt-cinq combinaisons de paramètres testées battant toutes le benchmark.

Deux lectures critiques avant d’admirer : (a) l’article est une pièce promotionnelle pour le dataset payant « Spectral Tick-Flow Signal » — le facteur y est livré par le dataset, et l’optimum retenu siège au bord de la grille de paramètres, ce que l’auteur lui-même reconnaît comme un signal d’optimisation in-sample ; (b) la reproduction du backtest complet exigerait cette souscription. La version pédagogique ci-dessous recalcule donc le score spectral nous-mêmes : périodogramme scipy sur volume minute réel (yfinance), sans aucun dataset commercial — puis une cellule de référence QC montre le squelette de l’algorithme de sélection tel qu’on l’écrirait dans QC Lab.

# Critere spectral de selection d'univers (research 21066) : chaque titre est note
# par la part de variance de son volume minute expliquee par la frequence dominante.
# Le dataset commercial "Spectral Tick-Flow Signal" n'est PAS souscrit : le calcul
# est refait ici sur donnees reelles (yfinance, volume minute, cinq seances).

import warnings
warnings.filterwarnings("ignore")

import numpy as np
import matplotlib.pyplot as plt
import yfinance as yf
from scipy.signal import periodogram

tickers = ["SPY", "AAPL", "MSFT", "F"]  # de l'ETF ultra-liquide au titre moins echange
data = yf.download(tickers, interval="1m", period="5d", group_by="ticker",
                   progress=False, auto_adjust=True)


def volumes(t):
    v = data[t]["Volume"].dropna()
    return v[v > 0]  # minutes sans echange exclues


resultats = {}
for t in tickers:
    v = volumes(t)
    # (a) profil intraday moyen : moyenne du volume par minute de la seance (U-shape)
    minute_seance = (v.index.hour * 60 + v.index.minute) - (9 * 60 + 30)
    profil = v.groupby(minute_seance).mean()
    # (b) periodogramme de la serie brute (fs = 1 par minute), tendance lineaire
    #     retiree pour ne pas laisser la derive multi-seances manger le pic journalier
    freqs, puiss = periodogram(v.values, detrend="linear")
    i_dom = int(np.argmax(puiss[1:]) + 1)  # hors composante continue
    resultats[t] = {
        "score": puiss[i_dom] / puiss[1:].sum(),  # part de variance
        "periode_min": 1.0 / freqs[i_dom],
        "profil": profil,
        "freqs": freqs,
        "puiss": puiss,
    }

fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 4.2))
for t, r in resultats.items():
    ax1.plot(r["profil"].index, r["profil"].values / r["profil"].values.max(),
             label=t, alpha=0.75)
    ax2.semilogy(r["freqs"][1:], r["puiss"][1:], alpha=0.6, label=t)
ax1.set_xlabel("Minute de la seance (0 = 9h30)")
ax1.set_ylabel("Volume moyen (normalise)")
ax1.set_title("Profil intraday moyen du volume")
ax1.axvline(0, color="gray", lw=0.5); ax1.axvline(390, color="gray", lw=0.5)
ax2.axvline(1 / 390, color="red", ls="--", lw=1, label="1/seance (390 min)")
ax2.set_xlabel("Frequence (min$^{-1}$)"); ax2.set_ylabel("Densite spectrale")
ax2.set_title("Periodogramme du volume minute")
ax2.set_xlim(0, 0.02)
for ax in (ax1, ax2):
    ax.legend(fontsize=8)
plt.tight_layout()
plt.show()

print(f"{'Ticker':<7}{'score spectral':>16}{'periode dominante':>20}")
for t, r in sorted(resultats.items(), key=lambda kv: -kv[1]["score"]):
    print(f"{t:<7}{r['score']:>16.1%}{r['periode_min']:>14.0f} min")

Ticker   score spectral   periode dominante
AAPL               9.8%           390 min
MSFT               8.3%           390 min
SPY                5.0%           390 min
F                  2.1%           195 min

Lecture du résultat : le score spectral sépare les titres — et révèle leurs horloges

Sur ces cinq séances réelles, la fréquence dominante du volume minute est la séance entière (390 minutes) pour SPY, MSFT et AAPL : le profil de gauche le montre, le volume se concentre à l’ouverture et à la clôture (le U-shape classique), et le périodogramme porte un pic net sur la ligne rouge. Mais le classement est le vrai livrable : AAPL concentre 9.8 % de la variance de son volume dans ce pic, MSFT 8.3 %, SPY 5.0 % — et Ford seulement 2.1 %, avec une dominante non plus à la séance mais à la demi-séance (195 minutes) : son horloge de volume est une autre. C’est exactement la quantité que l’article transforme en critère de sélection : classer les titres par ce score et retenir le quintile supérieur, c’est parier que l’horloge du volume porte de l’information sur le titre.

Deux garde-fous tempèrent l’enthousiasme : la part de variance reste modeste (un dixième au mieux — le volume minute est d’abord du bruit), et aucune prime de rendement n’est mesurée ici — le Sharpe 0.792 annoncé vient du backtest de l’article, calibré in-sample sur le dataset commercial dont l’optimum siégeait au bord de la grille. La cellule de référence ci-dessous montre comment ce critère s’écrirait dans QC Lab, l’histoire minute étant le seul ingrédient supplémentaire.

# [REFERENCE QC] Code a copier dans main.py QC Lab (non executable ici)
# SpectralVolumeUniverse : selection hebdomadaire par score spectral du volume minute
# (research 21066) -- le score est calcule sur l'historique minute du nuage coarse,
# sans dataset commercial. Le calcul d'historique par titre est couteux : il se fait
# au rythme du rebalancement hebdomadaire, pas a chaque barre.

from AlgorithmImports import *
import numpy as np
from scipy.signal import periodogram


class SpectralVolumeUniverse(QCAlgorithm):
    def initialize(self):
        self.set_start_date(2021, 7, 1)
        self.set_cash(100000)
        self.universe_settings.resolution = Resolution.MINUTE
        self.add_universe(self.coarse_spectral)
        self.schedule.on(self.date_rules.week_start(self.securities.keys()),
                         self.time_rules.at(1, 0), self.rebalance)

    def score_spectral(self, symbol):
        # Part de variance du volume minute expliquee par la frequence dominante
        h = self.history(symbol, timedelta(days=5), Resolution.MINUTE)
        if h.empty:
            return 0.0
        v = h["volume"].to_numpy(dtype=float)
        v = v[v > 0]
        if len(v) < 800:  # moins de deux seances completes : score non defini
            return 0.0
        freqs, puiss = periodogram(v, detrend="linear")
        i_dom = int(np.argmax(puiss[1:]) + 1)
        return puiss[i_dom] / puiss[1:].sum()

    def coarse_spectral(self, coarse):
        candidats = sorted(coarse, key=lambda c: c.dollar_volume, reverse=True)[:100]
        scores = {c.symbol: self.score_spectral(c.symbol) for c in candidats}
        retenus = sorted(scores, key=scores.get, reverse=True)[:20]  # top quintile
        return [str(s) for s in retenus]

    def rebalance(self):
        # Pondération inclinee par la liquidité (EMA du dollar-volume), a l'image
        # de l'"execution score" de l'article
        for holding in self.portfolio.values():
            self.set_holdings(holding.symbol, 0.05)

Conclusion et Prochaines Étapes

Recapitulatif

Dans ce notebook, nous avons appris a :

  1. Comprendre les Universes : Sélection dynamique vs liste fixe
  2. Manual Universe : AddEquity() pour une liste fixe (FAANG)
  3. Coarse Sélection : Filtrage par Price, DollarVolume, HasFundamentalData
  4. Fine Sélection : Filtrage par données Morningstar (P/E, secteur, MarketCap)
  5. Universe Settings : Resolution, Leverage, FillForward
  6. Dynamic Rebalancing : OnSecuritiesChanged() pour gerer les changements
  7. Scheduled Sélection : Schedule.On() pour rebalancement periodique
  8. Stratégie Momentum : Implementation complete

Tableau recapitulatif

Concept Code Usage
Manual Universe self.AddEquity("AAPL") Liste fixe d’actions
Coarse Sélection self.AddUniverse(self.CoarseSelectionFunction) Filtre par prix/volume
Fine Sélection self.AddUniverse(Coarse, Fine) Filtre par fondamentaux
Settings self.UniverseSettings.Resolution Configurer l’univers
Changements OnSecuritiesChanged(changes) Gerer entrees/sorties
Unchanged return Universe.Unchanged Skip la reselection
Schedule self.Schedule.On() Rebalancement periodique

Workflow recommande

1. Définir les critères de sélection
   ├── Coarse: liquidite minimum
   └── Fine: facteurs alpha (momentum, value, quality)

2. Choisir la frequence de rebalancement
   ├── Quotidien (rare, couteux)
   ├── Hebdomadaire
   └── Mensuel (recommande)

3. Implementer OnSecuritiesChanged
   ├── Liquider les sortants
   └── Acheter les entrants

4. Backtester et optimiser
   ├── Comparer avec buy-and-hold
   └── Analyser turnover et couts

Prochaine étape : QC-Py-06 - Options Trading

Dans le prochain notebook, nous explorerons :

  • Ajout d’options : self.AddOption("SPY")
  • Filtrage : Strike, expiration, type (call/put)
  • Stratégies : Covered call, protective put, spread
  • Greeks : Delta, gamma, theta, vega

Exercice suggere

Créer une stratégie Value Universe :

  1. Coarse: Volume > 10M\(, Prix > 10\)
  2. Fine:
    • P/E entre 5 et 15
    • Dividend Yield > 2%
    • Market Cap > 5B$
  3. Rebalancement trimestriel
  4. Top 30 actions par dividend yield

Ressources complementaires


Notebook complete. Pret pour QC-Py-06-Options-Trading.

Retour au sommet