Research QuantBook: Multi-Stock EMA Crossover

Objectif

Analyser la stratégie EMA crossover sur un panier d’actions tech.

Stratégie

  • Univers: AAPL, MSFT, GOOGL, AMZN, NVDA
  • Signal: EMA fast > EMA slow (cross haussier)
  • Allocation: Equal-weight des actions en signal
  • Rebalance: Quotidien
  • Max positions: 5 (toutes les actions)

Performance de référence

Sharpe ~1.0-1.3 (2015-2025) - excellente performance sur le panier tech.

Hypothèses à tester

  1. Période EMA: (10/40), (20/50), (20/60)
  2. Allocation: equal-weight vs momentum-weighted
  3. Univers: 5 stocks vs 10 stocks diversifiés

Prérequis

  • Environnement Lean Research
  • Données actions US
  • Durée estimée: ~8 minutes

Note : Cette stratégie diffère de EMA-Cross-Index par la diversification intra-sectorielle (5 actions au lieu de 1 ETF SPY).

# 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é.")
QuantBook initialisé.

1. Chargement des données

On charge les données actions pour la période 2010-2026.

# Univers Tech Stocks
tickers = ["AAPL", "MSFT", "GOOGL", "AMZN", "NVDA"]

symbols = {}
for ticker in tickers:
    symbols[ticker] = qb.add_equity(ticker, Resolution.DAILY).symbol

# Charger l'historique (2010-2026 pour multi-regime)
start = datetime(2010, 1, 1)
end = datetime(2026, 1, 1)

history = qb.history(list(symbols.values()), start, end, Resolution.DAILY)
print(f"Données chargées: {len(history)} lignes")
Données chargées: 8048 lignes

Pivot de la série ‘close’ en DataFrame large, avec remapping des colonnes Symbol → ticker pour EMA-Cross-Stocks.

# 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"Actions: {list(closes.columns)}")
print(f"\nStatistiques des prix finaux:")
for ticker in tickers:
    if ticker in closes.columns:
        ret = (closes[ticker].iloc[-1] / closes[ticker].iloc[0] - 1) * 100
        print(f"  {ticker}: {ret:+.1f}%")
Période: 2010-01-04 à 2025-12-31
Données: 4024 jours de trading
Actions: ['AAPL', 'GOOGL']

Statistiques des prix finaux:
  AAPL: +1753.9%
  GOOGL: +558.2%

2. Calcul des EMA

Calcul des moyennes mobiles exponentielles fast et slow.

def compute_ema(closes, period):
    """Calcule l'EMA pour une période donnée."""
    return closes.ewm(span=period, adjust=False).mean()

# EMA avec paramètres par défaut (20/50)
ema_fast = compute_ema(closes, 20)
ema_slow = compute_ema(closes, 50)

print("EMA Fast (20) - Derniers 5 jours:")
print(ema_fast.iloc[-5:])
print(f"\nEMA Slow (50) - Derniers 5 jours:")
print(ema_slow.iloc[-5:])
EMA Fast (20) - Derniers 5 jours:
                       AAPL    GOOGL
time                                
2025-12-24 13:00:00  122.15  2062.52
2025-12-26 16:00:00  122.15  2062.52
2025-12-29 16:00:00  122.15  2062.52
2025-12-30 16:00:00  122.15  2062.52
2025-12-31 16:00:00  122.15  2062.52

EMA Slow (50) - Derniers 5 jours:
                       AAPL    GOOGL
time                                
2025-12-24 13:00:00  122.15  2062.52
2025-12-26 16:00:00  122.15  2062.52
2025-12-29 16:00:00  122.15  2062.52
2025-12-30 16:00:00  122.15  2062.52
2025-12-31 16:00:00  122.15  2062.52

Interprétation: EMA Crossover

  • EMA fast > EMA slow: Tendance haussière (signal long)
  • EMA fast < EMA slow: Tendance baissière (signal exit)
  • Cross: Changement de tendance potentiel
  • Multi-stock: Diversification réduit le risque spécifique

3. Backtest Multi-Stock EMA

Simulation de la stratégie avec: - EMA 20/50 crossover - Equal-weight allocation - Rebalance quotidien - Max 95% investi

def backtest_ema_cross_stocks(closes, ema_fast, ema_slow, max_invested=0.95):
    """
    Backtest Multi-Stock EMA Crossover.
    
    Retourne les métriques de performance.
    """
    portfolio_values = [1.0]
    current_positions = {}  # ticker -> weight
    
    warmup = max(ema_fast.shape[1], ema_slow.shape[1])  # Wait for slowest EMA
    
    for i in range(warmup, len(closes)):
        # Find stocks with bullish EMA cross
        bullish = []
        for ticker in closes.columns:
            if ema_fast[ticker].iloc[i] > ema_slow[ticker].iloc[i]:
                bullish.append(ticker)
        
        # Equal weight allocation
        target_weight = max_invested / max(len(bullish), 1) if bullish else 0
        current_positions = {ticker: target_weight for ticker in bullish}
        
        # Calculate return
        daily_returns = closes.iloc[i] / closes.iloc[i-1] - 1
        port_return = sum(weight * daily_returns[ticker] 
                        for ticker, weight in current_positions.items())
        portfolio_values.append(portfolio_values[-1] * (1 + port_return))
    
    # Métriques
    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]
    }

result = backtest_ema_cross_stocks(closes, ema_fast, ema_slow)

print(f"Performance Multi-Stock EMA:")
print(f"  Sharpe: {result['sharpe']:.3f}")
print(f"  CAGR:   {result['cagr']:.1%}")
print(f"  Max DD: {result['max_dd']:.1%}")
print(f"  Vol:    {result['vol']:.1%}")
Performance Multi-Stock EMA:
  Sharpe: 0.886
  CAGR:   18.6%
  Max DD: -21.6%
  Vol:    17.6%

4. Test des Périodes EMA

# Test différentes paires EMA
ema_pairs = [
    ((10, 40), "EMA10/40"),
    ((20, 50), "EMA20/50"),
    ((20, 60), "EMA20/60"),
]

print(f"{'Période EMA':<12} {'Sharpe':>8} {'CAGR':>8} {'MaxDD':>8}")
print("-" * 40)

ema_results = {}
for (fast, slow), name in ema_pairs:
    ema_f = compute_ema(closes, fast)
    ema_s = compute_ema(closes, slow)
    r = backtest_ema_cross_stocks(closes, ema_f, ema_s)
    ema_results[name] = r
    print(f"{name:<12} {r['sharpe']:>8.3f} {r['cagr']:>7.1%} {r['max_dd']:>7.1%}")

best_ema = max(ema_results.items(), key=lambda x: x[1]['sharpe'])
print(f"\nMeilleure période EMA: {best_ema[0]} (Sharpe={best_ema[1]['sharpe']:.3f})")
Période EMA    Sharpe     CAGR    MaxDD
----------------------------------------
EMA10/40        1.064   21.1%  -18.3%
EMA20/50        0.886   18.6%  -21.6%
EMA20/60        0.892   19.0%  -19.0%

Meilleure période EMA: EMA10/40 (Sharpe=1.064)

5. Test d’Allocation Momentum-Weighted

def backtest_ema_momentum_weighted(closes, ema_fast, ema_slow, max_invested=0.95):
    """
    Backtest avec allocation momentum-weighted.
    Le poids est proportionnel à la distance entre EMAs.
    """
    portfolio_values = [1.0]
    
    warmup = max(ema_fast.shape[1], ema_slow.shape[1])
    
    for i in range(warmup, len(closes)):
        # Find bullish stocks and compute momentum strength
        bullish_strength = {}
        for ticker in closes.columns:
            fast_val = ema_fast[ticker].iloc[i]
            slow_val = ema_slow[ticker].iloc[i]
            if fast_val > slow_val:
                # Strength = % distance above slow EMA
                strength = (fast_val / slow_val) - 1
                bullish_strength[ticker] = max(0, strength)
        
        if not bullish_strength:
            portfolio_values.append(portfolio_values[-1])
            continue
        
        # Normalize weights
        total_strength = sum(bullish_strength.values())
        weights = {k: (v / total_strength) * max_invested 
                   for k, v in bullish_strength.items()}
        
        # Calculate return
        daily_returns = closes.iloc[i] / closes.iloc[i-1] - 1
        port_return = sum(weights[ticker] * daily_returns[ticker] 
                        for ticker in weights)
        portfolio_values.append(portfolio_values[-1] * (1 + port_return))
    
    # Métriques
    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]
    }

# Comparaison Equal-weight vs Momentum-weighted
ew_result = backtest_ema_cross_stocks(closes, ema_fast, ema_slow)
mw_result = backtest_ema_momentum_weighted(closes, ema_fast, ema_slow)

print(f"{'Allocation':<15} {'Sharpe':>8} {'CAGR':>8} {'MaxDD':>8}")
print("-" * 43)
print(f"{'Equal-weight':<15} {ew_result['sharpe']:>8.3f} {ew_result['cagr']:>7.1%} {ew_result['max_dd']:>7.1%}")
print(f"{'Momentum-weight':<15} {mw_result['sharpe']:>8.3f} {mw_result['cagr']:>7.1%} {mw_result['max_dd']:>7.1%}")
Allocation        Sharpe     CAGR    MaxDD
-------------------------------------------
Equal-weight       0.886   18.6%  -21.6%
Momentum-weight    0.931   19.8%  -21.4%

6. Comparaison avec SPY B&H

# Charger SPY pour comparaison
spy = qb.add_equity("SPY", Resolution.DAILY).symbol
spy_history = qb.history(spy, start, end, Resolution.DAILY)
spy_close = spy_history['close']

# Aligner les dates
warmup = 50
spy_values = spy_close.iloc[warmup:] / spy_close.iloc[warmup]

# Métriques SPY
spy_ret = spy_values.pct_change().dropna()
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("=== Comparaison vs SPY B&H ===")
print(f"{'Stratégie':<20} {'CAGR':>10} {'Sharpe':>10} {'MaxDD':>10}")
print("-" * 53)
print(f"{'Multi-Stock EMA':<20} {result['cagr']:>9.1%} {result['sharpe']:>10.3f} {result['max_dd']:>9.1%}")
print(f"{'SPY B&H':<20} {spy_cagr:>9.1%} {spy_sharpe:>10.3f} {spy_dd:>9.1%}")
=== Comparaison vs SPY B&H ===
Stratégie                  CAGR     Sharpe      MaxDD
-----------------------------------------------------
Multi-Stock EMA          18.6%      0.886    -21.6%
SPY B&H                   9.6%      0.454    -33.7%

7. Visualisation des résultats

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

# Gauche: EMA periods comparison
ax = axes[0]
for name, r in ema_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('Période EMA optimale', fontsize=12, fontweight='bold')
ax.set_ylabel('Valeur du portefeuille')
ax.legend(fontsize=8)
ax.grid(True, alpha=0.3)

# Droite: Equal vs Momentum weighted
ax = axes[1]
ax.plot(ew_result['cum'].values, label=f"Equal-weight (S={ew_result['sharpe']:.2f})", linewidth=1.5)
ax.plot(mw_result['cum'].values, label=f"Momentum-weight (S={mw_result['sharpe']:.2f})", linewidth=1.5)
ax.plot(spy_values.values, label='SPY B&H', linestyle='--', alpha=0.5)
ax.set_title('Méthode d\'allocation', 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('ema_cross_stocks_analysis.png', dpi=150, bbox_inches='tight')
plt.show()
print("Graphique sauvegardé.")

Graphique sauvegardé.

8. Analyse de la Diversification

Calculons la corrélation entre les 5 actions pour comprendre la diversification.

# Matrice de corrélation
returns = closes.pct_change().dropna()
corr_matrix = returns.corr()

print("Matrice de corrélation:")
print(corr_matrix.round(2))

# Corrélation moyenne
avg_corr = corr_matrix.values[np.triu_indices_from(corr_matrix.values, k=1)].mean()
print(f"\nCorrélation moyenne: {avg_corr:.2f}")

if avg_corr > 0.8:
    print("\n⚠️ Corrélation élevée - diversification limitée")
elif avg_corr > 0.5:
    print("\n✓ Corrélation modérée - diversification acceptable")
else:
    print("\n✓ Corrélation faible - bonne diversification")
Matrice de corrélation:
       AAPL  GOOGL
AAPL   1.00   0.53
GOOGL  0.53   1.00

Corrélation moyenne: 0.53

✓ Corrélation modérée - diversification acceptable

9. Conclusions et recommandations

Résumé

Métrique Meilleure config
Période EMA (à remplir)
Allocation (à remplir)
Sharpe (à remplir)
CAGR (à remplir)
Corrélation moyenne (à remplir)

Verdict

Si Sharpe > 0.9: Déployer avec les paramètres optimaux

Points forts Multi-Stock EMA

  • Diversification: 5 actions réduit le risque spécifique
  • Simplicité: Signaux EMA clairs et interprétables
  • Adaptativité: Chaque action a son propre cycle

Limitations

  • Corrélation élevée: Actions tech souvent corrélées
  • Whipsaws: EMA crossover peut générer beaucoup de trades
  • Sector concentration: 100% tech = concentration sectorielle

Prochaines étapes

  1. Déployer sur QC cloud avec les paramètres optimaux
  2. Tester avec un univers multi-sectoriel (tech + finance + healthcare)
  3. Ajouter un filtre de tendance macro (SMA200 du marché)
  4. Optimiser la fréquence de rebalance (hebdomadaire vs quotidien)
Retour au sommet