Objectif
Analyser la stratégie Adaptive Asset Allocation (AAA) qui combine: - Ranking momentum : Sélection des top N assets par performance passée - Optimisation min-variance : Allocation qui minimise la variance du portefeuille
Référence
Butler, Philbrick, Gordillo - “Adaptive Asset Allocation: A Primer” (SSRN 2328254)
Stratégie
Univers : 10 ETFs (SPY, EFA, EEM, VNQ, GLD, DBC, TLT, IEF, TIP, HYG)
Momentum : 6 mois (126 jours)
Top N : 4 meilleurs assets
Volatilité : 60 jours pour covariance stable
Corrélation : 126 jours
Rebalancement : Mensuel
Hypothèses à tester
Top N optimal: 3, 4, 5, 6 assets
Période momentum: 3, 6, 12 mois
Fenêtre volatilité: 20, 60, 126 jours
Prérequis
Environnement Lean Research
Durée estimée: ~15 minutes
# Setup QuantBook
from AlgorithmImports import *
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import warnings
warnings.filterwarnings('ignore' )
plt.style.use('seaborn-v0_8-darkgrid' )
plt.rcParams['figure.figsize' ] = (14 , 5 )
qb = QuantBook()
print ("QuantBook initialisé." )
1. Chargement des données
On charge les 10 ETFs de l’univers AAA.
# Univers AAA: large-cap equities (Docker data availability)
tickers = ["SPY" , "QQQ" , "IWM" , "AAPL" , "MSFT" ,
"GOOGL" , "AMZN" , "NVDA" , "EFA" , "AGG" ]
symbols = {}
for ticker in tickers:
equity = qb.add_equity(ticker, Resolution.DAILY)
equity.set_data_normalization_mode(DataNormalizationMode.ADJUSTED)
symbols[ticker] = equity.symbol
# Charger l'historique (2008-2026 pour inclure une crise complete)
start = datetime(2008 , 1 , 1 )
end = datetime(2026 , 1 , 1 )
history = qb.history(list (symbols.values()), start, end, Resolution.DAILY)
print (f"Donnees chargees: { len (history)} lignes" )
Donnees chargees: 21833 lignes
Pivot de la série ‘close’ en DataFrame large, avec remapping des colonnes Symbol → ticker pour AdaptiveAssetAllocation.
# Pivoter les données
closes = history['close' ].unstack(level= 0 )
symbol_to_ticker = {str (v): k for k, v in symbols.items()}
closes.columns = [symbol_to_ticker.get(str (c), str (c)) for c in closes.columns]
closes = closes.dropna()
print (f"Période: { closes. index[0 ]. date()} à { closes. index[- 1 ]. date()} " )
print (f"Données: { len (closes)} jours de trading" )
print (f"ETFs disponibles: { list (closes.columns)} " )
Période: 2011-03-23 à 2025-12-31
Données: 3717 jours de trading
ETFs disponibles: ['AAPL', 'GOOGL', 'IWM', 'QQQ', 'SPY']
2. Fonctions AAA: Momentum + Min-Variance
def compute_momentum_scores(closes, lookback= 126 ):
"""Calcule les scores de momentum (return sur lookback)."""
momentum = closes.iloc[- 1 ] / closes.iloc[- lookback] - 1
return momentum
def compute_min_variance_weights(returns, vol_window= 60 , corr_window= 126 ):
"""
Calcule les poids minimum-variance.
Formule: w = inv(cov) @ 1 / (1' @ inv(cov) @ 1)
où cov = diag(vol) @ corr @ diag(vol)
"""
# Volatilité sur vol_window
vol_returns = returns.iloc[- vol_window:]
vols = vol_returns.std()
# Corrélation sur corr_window
corr_returns = returns.iloc[- corr_window:]
corr = corr_returns.corr()
# Matrice de covariance: cov = diag(vol) @ corr @ diag(vol)
n = len (vols)
vol_diag = np.diag(vols.values)
cov_matrix = vol_diag @ corr.values @ vol_diag
# Minimum variance
try :
cov_inv = np.linalg.inv(cov_matrix)
ones = np.ones(n)
raw_weights = cov_inv @ ones
weights = raw_weights / raw_weights.sum ()
# Long-only: clip négatifs, renormaliser
weights = np.maximum(weights, 0 )
if weights.sum () > 0 :
weights = weights / weights.sum ()
else :
weights = np.ones(n) / n
except np.linalg.LinAlgError:
weights = np.ones(n) / n
return pd.Series(weights, index= vols.index)
print ("Fonctions AAA définies." )
Interprétation: AAA
Momentum : Sélectionne les top N assets par performance passée
Min-Variance : Alloue pour minimiser la variance du portefeuille
Long-only : Pas de vente à découvert, poids ≥ 0
La combinaison momentum + min-variance capture à la fois le rendement (trend) et réduit le risque.
3. Backtest AAA
def backtest_aaa(closes, top_n= 4 , mom_lookback= 126 , vol_window= 60 , corr_window= 126 ):
"""Backtest Adaptive Asset Allocation."""
returns_df = closes.pct_change().dropna()
portfolio_values = [1.0 ]
warmup = max (mom_lookback, corr_window) + 30
current_weights = {}
last_rebalance_idx = warmup - 1
for i in range (warmup, len (closes)):
current_date = closes.index[i]
# Rebalancement mensuel
if current_date.to_period('M' ) != closes.index[last_rebalance_idx].to_period('M' ):
last_rebalance_idx = i
# Step 1: Momentum ranking
hist_closes = closes.iloc[:i]
if len (hist_closes) >= mom_lookback:
momentum = hist_closes.iloc[- 1 ] / hist_closes.iloc[- mom_lookback] - 1
top_assets = momentum.nlargest(top_n).index.tolist()
if len (top_assets) >= top_n:
# Step 2: Min-variance weights
top_returns = returns_df.iloc[:i][top_assets].dropna()
if len (top_returns) >= corr_window:
weights = compute_min_variance_weights(top_returns, vol_window, corr_window)
current_weights = {asset: weights[asset] for asset in top_assets if asset in weights.index}
# Calcul du return (toujours, meme si pas de rebalancement)
port_return = 0.0
for asset, weight in current_weights.items():
if asset in returns_df.columns and i < len (returns_df):
port_return += weight * returns_df[asset].iloc[i]
portfolio_values.append(portfolio_values[- 1 ] * (1 + port_return))
# Metriques
returns = np.diff(portfolio_values) / np.array(portfolio_values[:- 1 ])
cum_returns = pd.Series(portfolio_values[1 :], index= closes.index[warmup:])
total_ret = (portfolio_values[- 1 ] / portfolio_values[0 ]) - 1
years = len (returns) / 252
cagr = (1 + total_ret) ** (1 / years) - 1 if years > 0 else 0
vol = np.std(returns) * np.sqrt(252 ) if len (returns) > 1 else 0
sharpe = (cagr - 0.03 ) / vol if vol > 0.001 else 0
running_max = cum_returns.expanding().max ()
drawdown = (cum_returns - running_max) / running_max
max_dd = drawdown.min ()
return {
'cum' : cum_returns,
'sharpe' : sharpe,
'cagr' : cagr,
'max_dd' : max_dd,
'vol' : vol,
'final_value' : portfolio_values[- 1 ]
}
print ("Fonction de backtest AAA définie." )
Fonction de backtest AAA définie.
4. Test du paramètre Top N
# Test différentes valeurs de Top N
top_n_values = [3 , 4 , 5 , 6 ]
print (f" { 'Top N' :<8} { 'Sharpe' :>8} { 'CAGR' :>8} { 'MaxDD' :>8} { 'Vol' :>8} " )
print ("-" * 45 )
top_n_results = {}
for n in top_n_values:
r = backtest_aaa(closes, top_n= n)
top_n_results[n] = r
print (f" { n:<8} { r['sharpe' ]:>8.3f} { r['cagr' ]:>7.1%} { r['max_dd' ]:>7.1%} { r['vol' ]:>7.1%} " )
best_top_n = max (top_n_results.items(), key= lambda x: x[1 ]['sharpe' ])
print (f" \n Meilleur Top N: { best_top_n[0 ]} (Sharpe= { best_top_n[1 ]['sharpe' ]:.3f} )" )
Top N Sharpe CAGR MaxDD Vol
---------------------------------------------
3 0.690 13.1% -28.6% 14.6%
4 0.595 11.3% -33.6% 13.9%
5 0.575 10.8% -34.9% 13.6%
6 0.000 0.0% 0.0% 0.0%
Meilleur Top N: 3 (Sharpe=0.690)
5. Test de la période Momentum
# Test différentes périodes de momentum
mom_lookbacks = [(63 , "3 mois" ), (126 , "6 mois" ), (252 , "12 mois" )]
print (f" { 'Période Momentum' :<16} { 'Sharpe' :>8} { 'CAGR' :>8} { 'MaxDD' :>8} " )
print ("-" * 43 )
mom_results = {}
for lookback, name in mom_lookbacks:
r = backtest_aaa(closes, mom_lookback= lookback)
mom_results[name] = r
print (f" { name:<16} { r['sharpe' ]:>8.3f} { r['cagr' ]:>7.1%} { r['max_dd' ]:>7.1%} " )
best_mom = max (mom_results.items(), key= lambda x: x[1 ]['sharpe' ])
print (f" \n Meilleure période Momentum: { best_mom[0 ]} (Sharpe= { best_mom[1 ]['sharpe' ]:.3f} )" )
Période Momentum Sharpe CAGR MaxDD
-------------------------------------------
3 mois 0.632 11.7% -33.6%
6 mois 0.595 11.3% -33.6%
12 mois 0.491 9.9% -33.6%
Meilleure période Momentum: 3 mois (Sharpe=0.632)
6. Test de la fenêtre Volatilité
# Test différentes fenêtres de volatilité
vol_windows = [20 , 60 , 126 ]
print (f" { 'Fenêtre Vol' :<12} { 'Sharpe' :>8} { 'CAGR' :>8} { 'MaxDD' :>8} " )
print ("-" * 39 )
vol_results = {}
for vol_w in vol_windows:
r = backtest_aaa(closes, vol_window= vol_w)
vol_results[vol_w] = r
print (f" { vol_w:<12} { r['sharpe' ]:>8.3f} { r['cagr' ]:>7.1%} { r['max_dd' ]:>7.1%} " )
best_vol = max (vol_results.items(), key= lambda x: x[1 ]['sharpe' ])
print (f" \n Meilleure fenêtre Vol: { best_vol[0 ]} jours (Sharpe= { best_vol[1 ]['sharpe' ]:.3f} )" )
Fenêtre Vol Sharpe CAGR MaxDD
---------------------------------------
20 0.610 11.6% -33.5%
60 0.595 11.3% -33.6%
126 0.597 11.3% -33.7%
Meilleure fenêtre Vol: 20 jours (Sharpe=0.610)
7. Comparaison avec benchmarks
# AAA avec paramètres optimaux
aaa_result = backtest_aaa(closes, top_n= 4 , mom_lookback= 126 , vol_window= 60 )
# Benchmarks (using available tickers)
spy_values = closes['SPY' ].iloc[252 :] / closes['SPY' ].iloc[252 ]
qqq_values = closes['QQQ' ].iloc[252 :] / closes['QQQ' ].iloc[252 ]
print ("=== Comparaison vs Benchmarks ===" )
print (f" { 'Strategie' :<20} { 'CAGR' :>10} { 'Sharpe' :>10} { 'MaxDD' :>10} " )
print ("-" * 53 )
print (f" { 'AAA (Top 4)' :<20} { aaa_result['cagr' ]:>9.1%} { aaa_result['sharpe' ]:>10.3f} { aaa_result['max_dd' ]:>9.1%} " )
# SPY metrics
spy_ret = closes['SPY' ].pct_change().iloc[252 :]
spy_cagr = (spy_values.iloc[- 1 ] ** (252 / len (spy_values))) - 1
spy_vol = spy_ret.std() * np.sqrt(252 )
spy_sharpe = (spy_cagr - 0.03 ) / spy_vol
spy_dd = (spy_values / spy_values.cummax() - 1 ).min ()
print (f" { 'SPY B&H' :<20} { spy_cagr:>9.1%} { spy_sharpe:>10.3f} { spy_dd:>9.1%} " )
# QQQ metrics
qqq_ret = closes['QQQ' ].pct_change().iloc[252 :]
qqq_cagr = (qqq_values.iloc[- 1 ] ** (252 / len (qqq_values))) - 1
qqq_vol = qqq_ret.std() * np.sqrt(252 )
qqq_sharpe = (qqq_cagr - 0.03 ) / qqq_vol
qqq_dd = (qqq_values / qqq_values.cummax() - 1 ).min ()
print (f" { 'QQQ B&H' :<20} { qqq_cagr:>9.1%} { qqq_sharpe:>10.3f} { qqq_dd:>9.1%} " )
# Equal-weight all available ETFs
available_cols = closes.columns.tolist()
ew_values = closes[available_cols].iloc[252 :].mean(axis= 1 ) / closes[available_cols].iloc[252 :].mean(axis= 1 ).iloc[0 ]
ew_cagr = (ew_values.iloc[- 1 ] ** (252 / len (ew_values))) - 1
ew_ret = ew_values.pct_change().dropna()
ew_vol = ew_ret.std() * np.sqrt(252 )
ew_sharpe = (ew_cagr - 0.03 ) / ew_vol
ew_dd = (ew_values / ew_values.cummax() - 1 ).min ()
print (f" { 'Equal-Weight All' :<20} { ew_cagr:>9.1%} { ew_sharpe:>10.3f} { ew_dd:>9.1%} " )
=== Comparaison vs Benchmarks ===
Strategie CAGR Sharpe MaxDD
-----------------------------------------------------
AAA (Top 4) 11.3% 0.595 -33.6%
SPY B&H 9.3% 0.470 -33.7%
QQQ B&H 12.8% 0.618 -28.6%
Equal-Weight All 12.9% 0.575 -31.7%
8. Visualisation des résultats
fig, axes = plt.subplots(1 , 2 , figsize= (16 , 5 ))
# Gauche: Top N comparison
ax = axes[0 ]
for n, r in top_n_results.items():
ax.plot(r['cum' ].values, label= f"Top { n} (S= { r['sharpe' ]:.2f} )" , linewidth= 1.5 )
ax.plot(spy_values.values, label= 'SPY B&H' , linestyle= '--' , alpha= 0.5 )
ax.set_title('Top N Optimization' , fontsize= 12 , fontweight= 'bold' )
ax.set_ylabel('Valeur du portefeuille' )
ax.legend(fontsize= 8 )
ax.grid(True , alpha= 0.3 )
# Droite: Momentum period comparison
ax = axes[1 ]
for name, r in mom_results.items():
ax.plot(r['cum' ].values, label= f" { name} (S= { r['sharpe' ]:.2f} )" , linewidth= 1.5 )
ax.plot(spy_values.values, label= 'SPY B&H' , linestyle= '--' , alpha= 0.5 )
ax.set_title('Momentum Period' , fontsize= 12 , fontweight= 'bold' )
ax.set_ylabel('Valeur du portefeuille' )
ax.legend(fontsize= 8 )
ax.grid(True , alpha= 0.3 )
plt.tight_layout()
plt.savefig('aaa_analysis.png' , dpi= 150 , bbox_inches= 'tight' )
plt.show()
print ("Graphique sauvegardé." )
9. Conclusions et recommandations
Résumé
Top N
(à remplir)
Période Momentum
(à remplir)
Fenêtre Vol
(à remplir)
Sharpe
(à remplir)
CAGR
(à remplir)
Verdict
Si Sharpe > 0.6: Déployer avec les paramètres optimaux
Points forts AAA
Diversification : 10 ETFs multi-asset classes
Adaptatif : Momentum + min-variance s’adaptent aux conditions de marché
Robustesse : Fonctionne sur différents régimes (bull, bear, crises)
Prochaines étapes
Déployer AAA sur QC cloud
Tester avec d’autres univers (secteurs, géographique)
Combiner avec d’autres stratégies dans un composite
Retour au sommet