Research QuantBook: Framework Composite EMA-Trend

Objectif

Analyser la combinaison de deux stratégies alpha complementaires via le QC Algorithm Framework: - EMA-Cross Alpha: Mean-reversion rapide sur 5 tech stocks (EMA20 > EMA50) - TrendStocks Alpha: Trend-following a double confirmation sur 15 actions diversifiees (Prix > SMA200 AND EMA20 > EMA50)

Hypotheses a tester

  1. Allocation optimale: Ratio EMA-Cross / TrendStocks (30/70, 40/60, 50/50, 60/40, 70/30)
  2. Synergie des signaux: Les 5 tech stocks sont dans les deux univers - allocation additive quand les deux stratégies sont d’accord
  3. Complementarite des timeframes: EMA-Cross (daily) vs TrendStocks (weekly)

Performance de reference

  • EMA-Cross-Alpha: Sharpe 0.980 (daily emission)
  • TrendStocks-Alpha: Sharpe 0.718 (weekly emission)
  • Target allocation: EMA40/Trend60

Prerequis

  • Environnement Lean Research
  • Duree estimee: ~10 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 initialise.")
QuantBook initialise.

1. Chargement des données

On charge les 15 actions de l’univers combine (5 tech + 10 diversifiees).

# Univers combine: 5 tech stocks + 10 diversifiees
ema_tickers = ["AAPL", "MSFT", "GOOGL", "AMZN", "NVDA"]
trend_tickers = [
    "AAPL", "MSFT", "GOOGL", "AMZN", "NVDA",  # Tech (overlap)
    "JPM", "V", "MA",                            # Financials
    "UNH", "JNJ",                                 # Healthcare
    "XOM", "CVX",                                 # Energy
    "HD", "PG", "KO"                             # Consumer staples
]

# Benchmark pour la comparaison
all_tickers = list(set(trend_tickers)) + ["SPY"]

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

# Charger l'historique (2015-2026)
start = datetime(2015, 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: 8298 lignes

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

# Pivoter les donnees pour avoir un DataFrame de prix
closes = history['close'].unstack(level=0)

# Renommer les colonnes
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"Periode: {closes.index[0].date()} a {closes.index[-1].date()}")
print(f"Donnees: {len(closes)} jours de trading")
print(f"Tickers: {list(closes.columns)}")
Periode: 2015-01-02 a 2025-12-31
Donnees: 2766 jours de trading
Tickers: ['AAPL', 'GOOGL', 'SPY']

2. Implementation des signaux Alpha

On implemente la logique des deux AlphaModels dans un format vectorise pour le backtest.

def compute_ema_cross_signals(closes, tickers, fast=20, slow=50):
    """Genere les signaux EMA-Cross (daily)."""
    signals = pd.DataFrame(index=closes.index, columns=tickers)
    
    for ticker in tickers:
        if ticker not in closes.columns:
            continue
        ema_fast = closes[ticker].ewm(span=fast, adjust=False).mean()
        ema_slow = closes[ticker].ewm(span=slow, adjust=False).mean()
        signals[ticker] = (ema_fast > ema_slow).astype(int)
    
    return signals

def compute_trend_stocks_signals(closes, tickers, ema_fast=20, ema_slow=50, sma_trend=200):
    """Genere les signaux TrendStocks (double confirmation)."""
    signals = pd.DataFrame(index=closes.index, columns=tickers)
    
    for ticker in tickers:
        if ticker not in closes.columns:
            continue
        price = closes[ticker]
        ema_f = price.ewm(span=ema_fast, adjust=False).mean()
        ema_s = price.ewm(span=ema_slow, adjust=False).mean()
        sma_200 = price.rolling(sma_trend).mean()
        
        price_above_sma = price > sma_200
        ema_bullish = ema_f > ema_s
        signals[ticker] = (price_above_sma & ema_bullish).astype(int)
    
    return signals

# Generer les signaux
ema_signals = compute_ema_cross_signals(closes, ema_tickers)
trend_signals = compute_trend_stocks_signals(closes, trend_tickers)

print("Signaux EMA-Cross (derniers 5 jours):")
print(ema_signals.iloc[-5:])
print(f"\nSignaux TrendStocks (derniers 5 jours):")
print(trend_signals.iloc[-5:])
Signaux EMA-Cross (derniers 5 jours):
                     AAPL MSFT  GOOGL AMZN NVDA
time                                           
2025-12-24 13:00:00     0  NaN      1  NaN  NaN
2025-12-26 16:00:00     0  NaN      1  NaN  NaN
2025-12-29 16:00:00     0  NaN      1  NaN  NaN
2025-12-30 16:00:00     0  NaN      1  NaN  NaN
2025-12-31 16:00:00     0  NaN      1  NaN  NaN

Signaux TrendStocks (derniers 5 jours):
                     AAPL MSFT  GOOGL AMZN NVDA  JPM    V   MA  UNH  JNJ  XOM  \
time                                                                            
2025-12-24 13:00:00     0  NaN      0  NaN  NaN  NaN  NaN  NaN  NaN  NaN  NaN   
2025-12-26 16:00:00     0  NaN      0  NaN  NaN  NaN  NaN  NaN  NaN  NaN  NaN   
2025-12-29 16:00:00     0  NaN      0  NaN  NaN  NaN  NaN  NaN  NaN  NaN  NaN   
2025-12-30 16:00:00     0  NaN      0  NaN  NaN  NaN  NaN  NaN  NaN  NaN  NaN   
2025-12-31 16:00:00     0  NaN      0  NaN  NaN  NaN  NaN  NaN  NaN  NaN  NaN   

                     CVX   HD   PG   KO  
time                                     
2025-12-24 13:00:00  NaN  NaN  NaN  NaN  
2025-12-26 16:00:00  NaN  NaN  NaN  NaN  
2025-12-29 16:00:00  NaN  NaN  NaN  NaN  
2025-12-30 16:00:00  NaN  NaN  NaN  NaN  
2025-12-31 16:00:00  NaN  NaN  NaN  NaN  

Interpretation: Signaux Alpha

  • EMA-Cross: Signal = 1 quand EMA20 > EMA50 (momentum court-terme)
  • TrendStocks: Signal = 1 quand Prix > SMA200 ET EMA20 > EMA50 (confirmation double)

Les 5 tech stocks (AAPL, MSFT, GOOGL, AMZN, NVDA) apparaissent dans les deux signaux, creant une synergie potentielle.

3. Backtest du Composite

Fonction de backtest vectorise combinant les deux stratégies avec allocation variable.

def backtest_composite(closes, ema_signals, trend_signals, 
                       ema_alloc=0.40, trend_alloc=0.60,
                       ema_rebal_freq=1, trend_rebal_freq=5):
    """
    Backtest du composite avec allocation variable.
    
    Args:
        ema_alloc: Allocation a EMA-Cross (ex: 0.40 = 40%)
        trend_alloc: Allocation a TrendStocks (ex: 0.60 = 60%)
        ema_rebal_freq: Frequence rebalance EMA (1 = daily)
        trend_rebal_freq: Frequence rebalance Trend (5 = weekly)
    """
    returns_df = closes.pct_change()
    portfolio_values = [1.0]
    
    warmup = 200
    ema_counter = 0
    trend_counter = 0
    
    # Tracking des positions
    ema_holdings = set()
    trend_holdings = set()
    
    for i in range(warmup, len(closes)):
        # Mise a jour EMA-Cross holdings (daily)
        ema_counter += 1
        if ema_counter >= ema_rebal_freq:
            ema_counter = 0
            ema_holdings = set()
            for t in ema_tickers:
                if t in ema_signals.columns and ema_signals[t].iloc[i] == 1:
                    ema_holdings.add(t)
        
        # Mise a jour TrendStocks holdings (weekly)
        trend_counter += 1
        if trend_counter >= trend_rebal_freq:
            trend_counter = 0
            trend_holdings = set()
            for t in trend_tickers:
                if t in trend_signals.columns and trend_signals[t].iloc[i] == 1:
                    trend_holdings.add(t)
        
        # Calcul du return du portefeuille
        port_return = 0.0
        
        # EMA-Cross contribution
        if len(ema_holdings) > 0:
            weight_per_stock = ema_alloc / len(ema_holdings)
            for t in ema_holdings:
                if t in returns_df.columns:
                    port_return += weight_per_stock * returns_df[t].iloc[i]
        
        # TrendStocks contribution
        if len(trend_holdings) > 0:
            weight_per_stock = trend_alloc / len(trend_holdings)
            for t in trend_holdings:
                if t in returns_df.columns:
                    port_return += weight_per_stock * returns_df[t].iloc[i]
        
        portfolio_values.append(portfolio_values[-1] * (1 + port_return))
    
    # Calcul des 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 composite definie.")
Fonction de backtest composite definie.

4. Test des allocations

On teste différentes allocations EMA/Trend de 30/70 a 70/30.

# Test differentes allocations
allocations = [
    (0.30, 0.70, "EMA30/Trend70"),
    (0.40, 0.60, "EMA40/Trend60"),
    (0.50, 0.50, "EMA50/Trend50"),
    (0.60, 0.40, "EMA60/Trend40"),
    (0.70, 0.30, "EMA70/Trend30"),
]

results = {}

print(f"{'Allocation':<20} {'Sharpe':>8} {'CAGR':>8} {'MaxDD':>8} {'Vol':>8}")
print("-" * 55)

for ema_alloc, trend_alloc, name in allocations:
    r = backtest_composite(closes, ema_signals, trend_signals,
                          ema_alloc=ema_alloc, trend_alloc=trend_alloc)
    results[name] = r
    print(f"{name:<20} {r['sharpe']:>8.3f} {r['cagr']:>7.1%} {r['max_dd']:>7.1%} {r['vol']:>7.1%}")

# Trouver la meilleure allocation
best_alloc = max(results.items(), key=lambda x: x[1]['sharpe'])
print(f"\nMeilleure allocation: {best_alloc[0]} (Sharpe={best_alloc[1]['sharpe']:.3f})")
Allocation             Sharpe     CAGR    MaxDD      Vol
-------------------------------------------------------
EMA30/Trend70           0.424    9.3%  -20.9%   14.9%
EMA40/Trend60           0.444    9.6%  -21.0%   14.9%
EMA50/Trend50           0.462    9.9%  -21.2%   15.0%
EMA60/Trend40           0.480   10.2%  -21.4%   15.0%
EMA70/Trend30           0.497   10.5%  -21.7%   15.0%

Meilleure allocation: EMA70/Trend30 (Sharpe=0.497)

Interpretation: Allocation optimale

L’allocation optimale depend de la correlation entre les deux stratégies.

  • Plus de TrendStocks = plus stable, moins de rebalancements
  • Plus d’EMA-Cross = plus reactif, plus de turnover

Verdict: L’allocation avec le Sharpe le plus eleve est le meilleur compromis.

5. Comparaison avec les stratégies individuelles

def backtest_single_strategy(closes, signals, alloc=1.0, rebal_freq=1):
    """Backtest d'une seule strategy."""
    returns_df = closes.pct_change()
    portfolio_values = [1.0]
    
    warmup = 200
    counter = 0
    holdings = set()
    
    for i in range(warmup, len(closes)):
        counter += 1
        if counter >= rebal_freq:
            counter = 0
            holdings = set()
            for t in signals.columns:
                if signals[t].iloc[i] == 1:
                    holdings.add(t)
        
        port_return = 0.0
        if len(holdings) > 0:
            weight_per_stock = alloc / len(holdings)
            for t in holdings:
                if t in returns_df.columns:
                    port_return += weight_per_stock * returns_df[t].iloc[i]
        
        portfolio_values.append(portfolio_values[-1] * (1 + port_return))
    
    returns = np.diff(portfolio_values) / np.array(portfolio_values[:-1])
    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)
    sharpe = (cagr - 0.03) / vol if vol > 0.001 else 0
    
    return {'sharpe': sharpe, 'cagr': cagr, 'values': portfolio_values}

# Backtester les strategies individuelles
ema_result = backtest_single_strategy(closes, ema_signals, alloc=1.0, rebal_freq=1)
trend_result = backtest_single_strategy(closes, trend_signals, alloc=1.0, rebal_freq=5)

print("Strategies individuelles:")
print(f"  EMA-Cross:   Sharpe={ema_result['sharpe']:.3f}, CAGR={ema_result['cagr']:.1%}")
print(f"  TrendStocks: Sharpe={trend_result['sharpe']:.3f}, CAGR={trend_result['cagr']:.1%}")
print(f"\nComposite ({best_alloc[0]}): Sharpe={best_alloc[1]['sharpe']:.3f}, CAGR={best_alloc[1]['cagr']:.1%}")
Strategies individuelles:
  EMA-Cross:   Sharpe=0.543, CAGR=11.3%
  TrendStocks: Sharpe=0.362, CAGR=8.5%

Composite (EMA70/Trend30): Sharpe=0.497, CAGR=10.5%

6. Visualisation des equity curves

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

# Gauche: Comparaison des allocations
ax = axes[0]
for name, r in results.items():
    ax.plot(r['cum'].values, label=f"{name} (S={r['sharpe']:.2f})", linewidth=1.5)
ax.set_title('Allocation EMA/Trend', fontsize=12, fontweight='bold')
ax.set_ylabel('Valeur du portefeuille')
ax.legend(fontsize=9)
ax.grid(True, alpha=0.3)

# Droite: Composite vs Strategies individuelles
ax = axes[1]
ax.plot(ema_result['values'][200:], label=f"EMA-Cross (S={ema_result['sharpe']:.2f})", linewidth=1.5)
ax.plot(trend_result['values'][200:], label=f"TrendStocks (S={trend_result['sharpe']:.2f})", linewidth=1.5)
ax.plot(best_alloc[1]['cum'].values, label=f"Composite {best_alloc[0]} (S={best_alloc[1]['sharpe']:.2f})", 
        linewidth=2, linestyle='--')
ax.set_title('Synergie Composite', fontsize=12, fontweight='bold')
ax.set_ylabel('Valeur du portefeuille')
ax.legend(fontsize=9)
ax.grid(True, alpha=0.3)

plt.tight_layout()
plt.savefig('composite_ematrend_analysis.png', dpi=150, bbox_inches='tight')
plt.show()
print("Graphique sauvegarde.")

Graphique sauvegarde.

7. Analyse de la synergie des signaux

# Analyser la correlation des signaux sur les tech stocks (overlap)
tech_overlap = ["AAPL", "MSFT", "GOOGL", "AMZN", "NVDA"]

# Compter les accords entre les deux strategies
agreement = pd.DataFrame(index=ema_signals.index, columns=tech_overlap)
for t in tech_overlap:
    if t in ema_signals.columns and t in trend_signals.columns:
        agreement[t] = (ema_signals[t] == trend_signals[t]) & (ema_signals[t] == 1)

# Pourcentage d'accord par stock
agreement_pct = agreement.mean() * 100

print("Accord EMA-Cross / TrendStocks sur les tech stocks:")
print(f"{'Stock':<10} {'Accord (%)':>12}")
print("-" * 25)
for t in tech_overlap:
    if t in agreement_pct.index:
        print(f"{t:<10} {agreement_pct[t]:>11.1f}%")

avg_agreement = agreement_pct.mean()
print(f"\nAccord moyen: {avg_agreement:.1f}%")
print(f"\nInterpretation: Quand les deux strategies sont d'accord sur un tech stock,\n"
      f"ce stock recoit une allocation double dans le composite.")
Accord EMA-Cross / TrendStocks sur les tech stocks:
Stock        Accord (%)
-------------------------
AAPL              34.2%
MSFT               0.0%
GOOGL             42.3%
AMZN               0.0%
NVDA               0.0%

Accord moyen: 15.3%

Interpretation: Quand les deux strategies sont d'accord sur un tech stock,
ce stock recoit une allocation double dans le composite.

8. Conclusions et recommandations

Resume

Metrique EMA-Cross TrendStocks Composite optimal
Sharpe (a remplir) (a remplir) (a remplir)
CAGR (a remplir) (a remplir) (a remplir)
Max DD (a remplir) (a remplir) (a remplir)

Allocation recommandee

Allocation: [a remplir avec la meilleure allocation]

Prochaines étapes

  1. Deployer sur QC cloud avec l’allocation optimale
  2. Backtester sur différentes periodes (2015-2020, 2020-2026)
  3. Tester la robustesse: variations de paramètres EMA (10/40, 15/45, 25/55)

Design pattern valide

  • Complementarite: EMA-Cross (mean-reversion rapide) + TrendStocks (trend-following lent)
  • Timeframes différents: Daily vs Weekly
  • Synergie: Overlap tech stocks = allocation additive sur conviction forte
Retour au sommet