Niveau : Intermediaire | Duree estimee : 25 min | Kernel : Python 3
Objectifs
Comprendre le concept de parite de risque (Risk Parity / Risk Budgeting)
Implementer la ponderation par volatilite inverse (Inverse Volatility Weighting)
Gerer le rebalancement mensuel avec surveillance de derive
Deployer sur QC Cloud et comparer avec un portefeuille equally-weighted
Partie 1 : Théorie du Risk Parity
Le Risk Parity est une approche d’allocation developpee par Bridgewater Associates (Ray Dalio, 1996). Au lieu d’allouer le capital equitablement (1/N), on alloue le risque equitablement entre les classes d’actifs.
Pourquoi 1/N echoue : avec des capitaux egaux, l’actif le plus volatil domine le risque du portefeuille. Un portefeuille 20/20/20/20/20 ou DBC volatilise a 22,1% annuels contribue pres du double de risque de TLT qui volatilise a 12,4% — le capital est egal, le risque ne l’est pas. Le Risk Parity inverse la logique : on fixe la contribution au risque, et on laisse les poids s’en deduire.
Principe : Inverse Volatility Weighting
Pour N actifs, le poids de l’actif i est :
w_i = (1/sigma_i) / sum(1/sigma_j)
Ou sigma_i est la volatilite realisee de l’actif i sur une fenêtre glissante.
Cette formule est l’approximation la plus simple du Risk Parity : elle equalise les contributions au risque en supposant les correlations nulles ou uniformes. La Partie 4 montrera l’ecart que cette hypothese introduit, et la methode ERC qui la corrige — la demo ci-dessous en pose deja la base mesurable.
# Demonstration : calcul de poids Risk Parityimport numpy as npassets = {"SPY": 16.5, "EFA": 18.2, "GLD": 15.8, "DBC": 22.1, "TLT": 12.4}n =len(assets)equal_weights = {k: 1/n for k in assets}inv_vols = {k: 1/v for k, v in assets.items()}total =sum(inv_vols.values())rp_weights = {k: v/total for k, v in inv_vols.items()}print("Allocation : Equal-Weight vs Risk Parity")print("-"*45)for asset in assets: ew = equal_weights[asset] rp = rp_weights[asset]print(f" {asset:5s} : {ew:5.1%}{rp:5.1%}")print()ew_risk = {k: equal_weights[k] * assets[k] for k in assets}rp_risk = {k: rp_weights[k] * assets[k] for k in assets}print("Contribution au risque (%):")print("-"*45)for asset in assets:print(f" {asset:5s} : {ew_risk[asset]:5.1f}% {rp_risk[asset]:5.1f}%")
La sortie chiffre exactement le mecanisme annonce en Partie 1. Cote allocation, l’ecart au 1/N reste modere (TLT passe de 20,0% a 26,5%, DBC recule de 20,0% a 14,8%) — ce sont des poids, on s’y attendait peu differents. Cote contribution au risque, la transformation est nette : en Equal-Weight, les contributions sont dispersees de 2,5% (TLT) a 4,4% (DBC) — DBC pese 1,8x le risque de TLT pour le meme capital. En Risk Parity, les cinq contributions convergent a 3,3% : chaque actif participe pour exactement un cinquieme du risque total. C’est la signature d’une allocation par le risque, pas par le capital.
Paramètres de la stratégie QC
ETF
Classe
Rôle dans le portefeuille
SPY
Actions US
Growth engine
EFA
Actions internationales
Diversification geographique
GLD
Or
Couverture inflation
DBC
Matieres premières
Exposition reelle
TLT
Obligations long terme
Safe haven / stabilisateur
Rebalancement mensuel avec trigger de derive a 5% (intra-mensuel).
# Code source de l'algorithme - pret pour deploiement QC Cloudqc_code ='''# region importsfrom AlgorithmImports import *# endregionclass RiskParity(QCAlgorithm): """ Risk Parity Strategy (Bridgewater-style simplifie). Edge: Equaliser la contribution au risque de chaque classe d'actifs plutot que la contribution en capital. Reference: Asness/Frazzini 2012 "Leverage Aversion and Risk Parity" Universe: SPY, EFA, GLD, DBC, TLT (5 classes d'actifs diversifiees) Signal: Poids = 1/volatilite(60j), normalises pour sommer a 1.0 Rebalancement: Mensuel + trigger 5% de derive Periode: 2015-2026 """ # Parametres configurables TICKERS = ["SPY", "EFA", "GLD", "DBC", "TLT"] VOL_LOOKBACK = 60 # Jours pour la volatilite realisee REBALANCE_DAY = 1 # Jour du mois pour rebalancement mensuel DRIFT_THRESHOLD = 0.05 # Seuil de derive pour rebalancement intra-mensuel WARMUP_DAYS = 70 # Jours de warmup pour avoir 60j de donnees def initialize(self): self.set_start_date(2015, 1, 1) self.set_end_date(2026, 6, 30) # fenetre figee (reproductibilite, fix D2 #10850) self.set_cash(100000) # Ajouter les ETFs en resolution quotidienne (suffisant pour mensuel) self.symbols = {} for ticker in self.TICKERS: symbol = self.add_equity(ticker, Resolution.DAILY).symbol self.symbols[ticker] = symbol # Indicateurs de volatilite (ecart-type 60j des rendements log) self.std_indicators = {} for ticker, symbol in self.symbols.items(): # STD sur les log-returns: utiliser StandardDeviation sur les prix # On calculera manuellement via RollingWindow self.std_indicators[ticker] = self.STD(symbol, self.VOL_LOOKBACK, Resolution.DAILY) # Poids cibles actuels self.target_weights = {ticker: 1.0 / len(self.TICKERS) for ticker in self.TICKERS} # Warmup pour remplir les indicateurs self.set_warm_up(self.WARMUP_DAYS) # Scheduler: rebalancement mensuel le 1er jour de trading du mois self.schedule.on( self.date_rules.month_start("SPY"), self.time_rules.after_market_open("SPY", 30), self.rebalance ) self.log("RiskParity initialized: 5 ETFs, 60-day vol, monthly rebalance") def on_data(self, data: Slice): """Verifier la derive des poids intra-mensuelle.""" if self.is_warming_up: return if not self._indicators_ready(): return # Verifier si les poids actuels ont derive au-dela du seuil if self._check_drift(): self.log(f"Drift threshold exceeded, rebalancing intra-month") self._execute_rebalance() def rebalance(self): """Rebalancement mensuel principal.""" if self.is_warming_up: return if not self._indicators_ready(): self.log("Indicators not ready, skipping rebalance") return self._compute_target_weights() self._execute_rebalance() def _indicators_ready(self) -> bool: """Verifie que tous les indicateurs sont prets.""" return all(ind.is_ready for ind in self.std_indicators.values()) def _compute_target_weights(self): """Calcule les poids inversement proportionnels a la volatilite.""" # Recuperer la volatilite de chaque actif (STD des prix) # La STD des prix n'est pas la bonne mesure - on veut la vol des rendements # On approxime: vol_rendements ~= STD(prix) / prix_moyen # Mais QuantConnect STD donne directement la STD des valeurs brutes # Pour avoir la vol des rendements: utiliser le ratio STD/prix actuel vols = {} for ticker, symbol in self.symbols.items(): std_val = self.std_indicators[ticker].current.value current_price = self.securities[symbol].price if current_price > 0 and std_val > 0: # Volatilite relative (rendements) vols[ticker] = std_val / current_price else: vols[ticker] = 0.01 # Valeur par defaut si donnee manquante # Poids = 1/vol (inverse volatility weighting) inv_vols = {} for ticker, vol in vols.items(): if vol > 0: inv_vols[ticker] = 1.0 / vol else: inv_vols[ticker] = 0.0 total_inv_vol = sum(inv_vols.values()) if total_inv_vol > 0: self.target_weights = { ticker: inv_vol / total_inv_vol for ticker, inv_vol in inv_vols.items() } else: # Fallback: poids egaux n = len(self.TICKERS) self.target_weights = {ticker: 1.0 / n for ticker in self.TICKERS} # Log les poids calcules weight_str = ", ".join( f"{t}:{w:.1%}" for t, w in sorted(self.target_weights.items()) ) self.log(f"Target weights: {weight_str}") def _check_drift(self) -> bool: """Verifie si un actif a derive au-dela du seuil par rapport a son poids cible.""" if not self.portfolio.invested: return False total_value = self.portfolio.total_portfolio_value if total_value <= 0: return False for ticker, symbol in self.symbols.items(): target = self.target_weights.get(ticker, 0.0) holding = self.portfolio[symbol] current_weight = holding.holdings_value / total_value if abs(current_weight - target) > self.DRIFT_THRESHOLD: return True return False def _execute_rebalance(self): """Execute le rebalancement vers les poids cibles.""" for ticker, symbol in self.symbols.items(): weight = self.target_weights.get(ticker, 0.0) self.set_holdings(symbol, weight) self.log( f"Rebalanced | " f"Portfolio: ${self.portfolio.total_portfolio_value:,.0f}" )'''print(f"Code source QC Cloud charge : RiskParity, {len(qc_code)} caracteres, "f"{len(qc_code.splitlines())} lignes, {len(['SPY','EFA','GLD','DBC','TLT'])} ETFs")
NOTE — approximation de volatilité : STD(60) s’applique aux prix bruts, pas aux rendements. La volatilité des rendements est approximée par std_prix / prix_actuel (cf. commentaires dans le code de l’algorithme). La mesure correcte serait la STD des log-rendements ; l’approximation est retenue pour sa simplicité avec l’indicateur QC natif.
Partie 2 : Deploiement via MCP QuantConnect
La cellule suivante ne s’execute pas comme une strategie : elle definit le code source complet dans une chaine qc_code et en verifie le chargement (sortie : « RiskParity, 6026 caracteres, 5 ETFs »). L’entrainement n’a rien a voir ici — c’est le backtest de la strategie complete qui se produit sur QuantConnect Cloud, via le serveur MCP dedie :
Projet QC : le source est charge dans un projet QuantConnect ;
Compilation Cloud (create_compile) : QC compile dans son propre environnement — indicateurs natifs STD, donnees ajustees ;
Backtest (create_backtest puis read_backtest) : execution sur les donnees institutionnelles QC (dividendes, splits) — les metriques commentees en Partie 3 proviennent de cette execution Cloud.
La ligne de partage de la serie Cloud-native s’applique : le notebook enseigne et verifie les briques localement (poids, derive, volatilite), le Cloud execute la strategie entiere sur l’historique reel.
Exercice 1 : Detection de derive des poids
Le Risk Parity rebalance mensuellement mais verifie intra-mensuellement si les poids ont derive au-dela d’un seuil de 5%. Si oui, un rebalancement d’urgence est declenche.
Objectif : Implementez check_drift(target_weights, current_weights, threshold=0.05) qui detecte si un actif a derive au-dela du seuil.
Indices : - Indice : Le poids actuel = (quantite * prix_actuel) / valeur_portefeuille. - Indice : Si |poids_actuel - poids_cible| > seuil, il y a derive.
Methode : un seul passage sur les cinq actifs suffit — la derive est le max des ecarts absolus, compare au seuil. Les valeurs de test sont deja posees dans la cellule suivante : SPY a 0.24 pour une cible de 0.20 (derive de 4 points), les quatre autres restent proches de leurs cibles — la reponse attendue se joue sur la frontiere du seuil.
# Exercice 1 : Detection de derive des poids# TODO etudiant : implementer check_drift(target, current, threshold=0.05) -> bool# Indice : derive si |poids_actuel - poids_cible| > threshold pour au moins un actifdef check_drift(target_weights, current_weights, threshold=0.05):# TODO etudiant : comparer chaque poids actuel au poids ciblereturnNone# TODO etudiant : retourner True si derive detecteetarget = {"SPY": 0.20, "EFA": 0.20, "GLD": 0.20, "DBC": 0.20, "TLT": 0.20}current = {"SPY": 0.24, "EFA": 0.18, "GLD": 0.19, "DBC": 0.21, "TLT": 0.18}result_drift =None# TODO etudiant : appeler check_driftprint("Exercice a completer : detection de derive")
Exercice a completer : detection de derive
Exercice 2 : Calcul de la volatilite realisee
La volatilite realisee sur 60 jours est l’indicateur cle du Risk Parity. Elle est calculee comme l’ecart-type des rendements quotidiens sur une fenêtre glissante, annualise par multiplication par sqrt(252).
Objectif : Implementez realized_vol(returns, window=60) qui retourne la volatilite annuelle realisee. Testez avec des rendements simules et comparez différentes fenêtres (21, 60, 126 jours).
Indices : - Indice : Vol_annualisee = std(rendements) * sqrt(252). - Indice : Une fenêtre plus courte reactit plus vite mais est plus bruitee.
Ce qu’on attend d’observer : les trois fenetres (21, 60, 126 jours) renvoient des volatilites du meme ordre — le processus simulate est stationnaire — mais avec une dispersion decroissante quand la fenetre s’allonge : 21 jours reactit a chaque accident du generateur, 126 jours les lisse tous. Le choix 60 jours de l’algorithme est un compromis, pas un optimum.
# Exercice 2 : Calcul de la volatilite realisee# TODO etudiant : implementer realized_vol(returns, window=60) -> float# Indice : vol_annualisee = std(rendements_fenetre) * sqrt(252)import numpy as npdef realized_vol(returns, window=60):# TODO etudiant : prendre les derniers window rendements# TODO etudiant : calculer ecart-type et annualiserreturnNone# TODO etudiant : retourner la vol annualiseenp.random.seed(42)sim_returns = np.random.normal(0.0003, 0.01, 500)result_vol =None# TODO etudiant : comparer window=21, 60, 126print("Exercice a completer : volatilite realisee")
Exercice a completer : volatilite realisee
Partie 3 : Résultats et interpretation
Comparaison avec les benchmarks
Stratégie
Sharpe
CAGR
MaxDD
60/40 (SPY/TLT)
~0.6
~8%
~25%
Risk Parity (sans leverage)
~0.7
~7%
~18%
Risk Parity (1.5x leverage)
~0.75
~10%
~25%
Comment lire ces chiffres
Risk Parity sans leverage (Sharpe ~0.7, CAGR ~7%) : mieux ajuste du risque que le 60/40 (Sharpe ~0.6) mais rendement plus faible — sans levier, une allocation en grande partie obligations/produits defensifs ne peut pas egaler la course d’un portefeuille a 60% d’actions.
Le leverage 1.5x restaure le rendement (~10%) au prix du MaxDD (~25%) : c’est le contrat du Risk Parity reel — emprunter pour ramener le portefeuille defensive a l’agressivite du 60/40, en esperant une meilleure trajectoire ajustee du risque. Le tableau montre la limite du pari : la drawdown remonte au niveau du 60/40.
MaxDD ~18% sans leverage : la ligne ou le Risk Parity gagne nettement (-7 points vs 60/40) — la diversification du risque paie surtout dans les stress, ou les correlations montent et le 60/40 se comporte comme un seul grand bloc d’actions.
Points d’attention
Sans leverage, le Risk Parity a un rendement inferieur au 60/40
La derive des poids necessite une surveillance (trigger 5%)
Les correlations entre classes d’actifs changent en periode de stress
La fenêtre de volatilite (60j) est un paramètre sensible
Exercice 3 : Simulation du rebalancement Risk Parity
Un portefeuille Risk Parity rebalance mensuellement vers les poids cibles. Entre deux rebalancements, les prix bougent et les poids derivent.
Objectif : Simulez un portefeuille de 5 ETFs sur 12 mois. Recalculez les poids Risk Parity chaque mois en utilisant la volatilite realisee glissante.
Indices : - Indice : Chaque mois, calculez la vol sur 60j, puis les poids 1/vol. - Indice : Les poids changeront legerement chaque mois car la vol change.
Ce qu’on attend d’observer : des poids qui varient de quelques points de mois en mois — la volatilite realisee glissante est elle-meme bruitee — mais sans changement de regime : TLT reste surpondere, DBC sous-pondere, dans un corridor etroit autour des cibles de la Partie 1. Si un mois s’ecarte fortement, cherchez l’accident dans la serie de rendements simulee, pas dans la formule.
# Exercice 3 : Simulation du rebalancement Risk Parity# TODO etudiant : simuler 12 mois de rebalancement Risk Parity# Indice : chaque mois, recalculer vol(60j) puis poids 1/volimport numpy as npdef simulate_rp_rebalance(returns_dict, n_months=12, vol_window=60):# returns_dict : {ticker: array de rendements quotidiens}# TODO etudiant : boucler sur les mois# TODO etudiant : recalculer poids Risk Parity chaque moisreturnNone# TODO etudiant : retourner historique des poidsresult_rp_sim =None# TODO etudiant : lancer la simulationprint("Exercice a completer : simulation rebalancement Risk Parity")
Exercice a completer : simulation rebalancement Risk Parity
Partie 4 : Au-dela de l’Inverse-Vol — ERC et PSR
La méthode Inverse Volatility Weighting (1/σ) que nous avons implementee est souvent appelee “Risk Parity” dans la pratique. Mais strictement parlant, ce n’est qu’une approximation qui ignore les correlations entre actifs.
Inverse-Vol vs ERC (Equal Risk Contribution)
Le vrai Risk Parity cherche a equaliser la contribution marginale au risque de chaque actif, en tenant compte de la matrice de covariance complete :
Contribution au risque de l’actif i = w_i × (Σ w)_i / σ_p
ERC : chaque actif contribue exactement 1/N du risque total
Inverse-Vol suppose implicitement que les correlations sont nulles ou uniformes. Quand c’est le cas, IVW ≈ ERC. Mais en pratique, les correlations changent — surtout en periode de stress ou les actifs tendent a corriger ensemble.
Probabilistic Sharpe Ratio (PSR)
Le PSR (Bailey & Lopez de Prado, 2014) repond a : “Ce Sharpe ratio est-il statistiquement significatif, ou juste du bruit ?”
Il ajuste le Sharpe observe pour : - La longueur de la periode de test (plus c’est court, plus c’est bruite) - L’asymetrie (skewness) des rendements - L’exces d’aplatissement (kurtosis)
Un PSR > 50% signifie que le Sharpe est plus probablement reel que du au hasard. C’est un garde-fou essentiel contre l’overfitting.
# Demo : Inverse-Vol vs ERC + calcul du PSRimport numpy as npfrom scipy.stats import normnp.random.seed(42)# Parametres realistes pour 5 ETFs (volatilites annuelles en %)vols = np.array([16.5, 18.2, 15.8, 22.1, 12.4]) # SPY, EFA, GLD, DBC, TLTnames = ["SPY", "EFA", "GLD", "DBC", "TLT"]n_assets =len(vols)# Matrice de correlation (simplifiee mais realiste)# Actions correlées, or/obligations anti-corréléescorr = np.array([ [1.00, 0.85, 0.05, 0.40, -0.30], # SPY [0.85, 1.00, 0.10, 0.35, -0.25], # EFA [0.05, 0.10, 1.00, 0.20, 0.15], # GLD [0.40, 0.35, 0.20, 1.00, -0.10], # DBC [-0.30,-0.25, 0.15,-0.10, 1.00], # TLT])# Matrice de covariance annualiseecov_matrix = np.outer(vols/100, vols/100) * corr# --- Methode 1 : Inverse-Vol Weighting (notre implementation QC) ---inv_vols =1.0/ (vols /100)ivw_weights = inv_vols / inv_vols.sum()# --- Methode 2 : ERC (iteratif, descente de gradient) ---def compute_erc_weights(cov, max_iter=100, tol=1e-8):"""Trouve les poids ERC par minimisation iterative.""" n = cov.shape[0] w = np.ones(n) / n # initialisation equal-weightfor _ inrange(max_iter): port_var = w @ cov @ w mrc = cov @ w / np.sqrt(port_var) # marginal risk contribution rc = w * mrc # risk contribution target_rc = rc.sum() / n # egal pour tous# Ajustement simple grad = rc - target_rc w_new = w -0.1* grad / np.sum(np.abs(grad)) w_new = np.maximum(w_new, 0.001) w_new = w_new / w_new.sum()if np.max(np.abs(w_new - w)) < tol:break w = w_newreturn werc_weights = compute_erc_weights(cov_matrix)# Comparaisonprint("="*65)print("Comparaison : Inverse-Vol vs ERC (Equal Risk Contribution)")print("="*65)print(f"{'ETF':>5s}{'Vol%':>6s}{'IVW':>7s}{'ERC':>7s}{'Delta':>7s}")print("-"*45)for i, name inenumerate(names): delta = erc_weights[i] - ivw_weights[i]print(f" {name:>4s}{vols[i]:5.1f}% {ivw_weights[i]:6.1%}{erc_weights[i]:6.1%}{delta:+6.1%}")# Verification : contribution au risqueport_var_ivw = ivw_weights @ cov_matrix @ ivw_weightsport_var_erc = erc_weights @ cov_matrix @ erc_weightsrc_ivw = ivw_weights * (cov_matrix @ ivw_weights) / port_var_ivwrc_erc = erc_weights * (cov_matrix @ erc_weights) / port_var_ercprint(f"\nVolatilite portefeuille : IVW = {np.sqrt(port_var_ivw)*100:.1f}% | ERC = {np.sqrt(port_var_erc)*100:.1f}%")print(f"Ecart max poids IVW vs ERC : {np.max(np.abs(ivw_weights - erc_weights)):.1%}")print(f"\nContribution risque IVW : {[f'{rc:.1%}'for rc in rc_ivw/rc_ivw.sum()]}")print(f"Contribution risque ERC : {[f'{rc:.1%}'for rc in rc_erc/rc_erc.sum()]}")# --- Calcul du PSR ---# Simulons des rendements Risk Parity sur 5 ans (1260 jours)days =1260port_vol = np.sqrt(port_var_ivw)daily_returns = np.random.normal(0.0001, port_vol/np.sqrt(252), days)sharpe = np.mean(daily_returns) / np.std(daily_returns) * np.sqrt(252)# Formule PSR (Bailey & Lopez de Prado 2014)skew =float(np.mean(((daily_returns - daily_returns.mean()) / daily_returns.std())**3))kurt =float(np.mean(((daily_returns - daily_returns.mean()) / daily_returns.std())**4))n =len(daily_returns)threshold =0.0# Sharpe minimum acceptablepsr_numerator = (sharpe - threshold) * np.sqrt(n -1)psr_denominator = np.sqrt(1- skew * sharpe + (kurt -1) /4* sharpe**2)psr = norm.cdf(psr_numerator / psr_denominator) if psr_denominator >0else0.5print(f"\n--- Probabilistic Sharpe Ratio (PSR) ---")print(f"Sharpe observe : {sharpe:.3f}")print(f"Skewness : {skew:.2f} | Kurtosis : {kurt:.2f}")print(f"Observations : {n} jours ({n/252:.1f} ans)")print(f"PSR : {psr:.1%}{'(significatif)'if psr >0.5else'(non significatif)'}")print(f"Reference : Bailey & Lopez de Prado (2014)")
Interprétation du résultat : La comparaison montre la différence concrète entre Inverse-Vol (pondère seulement les volatilités individuelles) et ERC (égalise les contributions au risque, corrélations comprises). En ERC, chaque ETF contribue pour ~20 % du risque (17.6 % à 23.8 %), alors qu’en Inverse-Vol TLT ne contribue qu’à 6.3 % malgré 26.5 % de poids.
Pourquoi TLT monte à 37.9 % en ERC (+11.4 pts) : sa corrélation négative avec les actions (SPY : -0.30) réduit sa contribution marginale au risque total — l’algorithme peut donc lui donner plus de poids sans dégrader le risque. C’est l’avantage clé de l’ERC : il récompense la diversification, pas seulement la faible volatilité. Résultat : une volatilité portefeuille réduite (8.3 % vs 9.2 %).
PSR = 100 % : le Sharpe observé (0.888) reste significatif au seuil de Bailey & Lopez de Prado après ajustement pour le moment 3/4 (skewness 0.08, kurtosis 3.02 — quasi gaussien) sur 1260 observations. La performance n’est donc pas un artefact de queues épaisses sur ce jeu de données.
Conclusion
Le Risk Parity est une stratégie fondamentale en gestion quantitative : - Principe : allouer le risque, pas le capital - Implementation : poids proportionnels a 1/volatilite - Avantage : meilleure diversification du risque entre classes d’actifs - Limite : sans leverage, rendement inferieur au 60/40 - Depassement : l’ERC (Partie 4) corrige l’hypothese de correlations nulles de l’Inverse-Vol — TLT monte a 37,9% dans la demo des que sa correlation negative avec les actions est prise en compte
Comment lire ces chiffres