Research QuantBook: Covered Call Strategy

Objectif

Analyser la stratégie Covered Call sur SPY qui génère du revenu par la vente de calls couverts.

Stratégie

  • Underlying: SPY (S&P 500 ETF)
  • Option: Vente de calls OTM (Delta ~0.20)
  • VIX Filter: 15 < VIX < 35 (éviter volatilité extrême)
  • Expiry: 20-45 jours, roll à 10 jours avant expiration
  • Profit Target: 50% du premium collecté
  • Defensive Close: Si SPY chute de -3% en un jour

Performance de référence

Sharpe 0.791, CAGR 15.9%, MaxDD 7.5% (2023-2024)

Hypothèses à tester

  1. Delta cible: 0.15, 0.20, 0.25
  2. VIX range: (12/30), (15/35), (18/40)
  3. Profit target: 40%, 50%, 60%

Prérequis

  • Environnement Lean Research
  • Données SPY + VIX
  • Durée estimée: ~10 minutes

Note : Les options complètes ne sont pas disponibles dans QuantBook. Ce notebook utilise une approximation du premium basée sur le VIX et le delta.

# Setup QuantBook
from AlgorithmImports import *
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from scipy.stats import norm
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 SPY et calcule la volatilité réalisée (proxy du VIX) pour la période 2018-2026.

# SPY underlying
spy = qb.add_equity("SPY", Resolution.DAILY)

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

spy_history = qb.history(spy.symbol, start, end, Resolution.DAILY)

print(f"Données SPY: {len(spy_history)} lignes")
Données SPY: 2011 lignes

Traitement des données SPY et calcul de la volatilité réalisée (rolling 20 jours, annualisée) comme proxy du VIX pour la stratégie OptionsIncome.

# Préparer les données
spy_close = spy_history['close'].droplevel(0)

# Volatilité réalisée annualisée (proxy VIX, en %)
spy_returns = spy_close.pct_change()
realized_vol = spy_returns.rolling(20).std() * np.sqrt(252) * 100

data = pd.DataFrame({
    'SPY': spy_close,
    'VIX': realized_vol
}).dropna()

print(f"Période: {data.index[0].date()} à {data.index[-1].date()}")
print(f"Données: {len(data)} jours de trading")
print(f"\nStatistiques SPY:")
print(f"  Prix initial: ${data['SPY'].iloc[0]:.2f}")
print(f"  Prix final: ${data['SPY'].iloc[-1]:.2f}")
print(f"  Return total: {(data['SPY'].iloc[-1]/data['SPY'].iloc[0] - 1):.1%}")
print(f"\nVolatilité réalisée (proxy VIX):")
print(f"  Moyenne: {data['VIX'].mean():.1f}")
print(f"  Min: {data['VIX'].min():.1f}")
print(f"  Max: {data['VIX'].max():.1f}")
Période: 2018-01-31 à 2025-12-31
Données: 1991 jours de trading

Statistiques SPY:
  Prix initial: $265.64
  Prix final: $396.33
  Return total: 49.2%

Volatilité réalisée (proxy VIX):
  Moyenne: 7.2
  Min: 0.0
  Max: 93.2

2. Approximation du premium d’options

Comme les options complètes ne sont pas disponibles dans QuantBook, on utilise une approximation basée sur le VIX et le delta.

def estimate_call_premium(spy_price, vix, days_to_expiry, delta_target=0.20):
    """
    Estime le premium d'un call OTM sur SPY.
    
    Approximation basée sur:
    - VIX comme proxy de la volatilité implicite
    - Delta target pour le strike OTM
    - Black-Scholes simplifié
    """
    # Volatilité annualisée (VIX en %)
    vol = vix / 100.0
    
    # Temps en années
    t = days_to_expiry / 365.0
    
    # Strike OTM approximatif (delta ~0.20 → strike ~1.05 * spot)
    # Plus delta est élevé, plus le strike est proche
    otm_pct = 0.05 + (0.25 - delta_target) * 0.1
    strike = spy_price * (1 + otm_pct)
    
    # Black-Scholes simplifié pour call OTM
    # d1 = (ln(S/K) + (r + 0.5*σ²)t) / (σ√t)
    r = 0.05  # Taux sans risque approximatif
    d1 = (np.log(spy_price / strike) + (r + 0.5 * vol**2) * t) / (vol * np.sqrt(t))
    
    # Premium approximatif (sans dividendes)
    premium = spy_price * norm.cdf(d1) - strike * np.exp(-r * t) * norm.cdf(d1 - vol * np.sqrt(t))
    
    return max(0, premium)

# Test de la fonction d'estimation
test_price = 450.0
test_vix = 20.0
test_dte = 30

premium = estimate_call_premium(test_price, test_vix, test_dte)
print(f"Premium estimé pour SPY=${test_price}, VIX={test_vix}, DTE={test_dte}: ${premium:.2f}")
print(f"Premium en % du sous-jacent: {(premium/test_price)*100:.1f}%")
Premium estimé pour SPY=$450.0, VIX=20.0, DTE=30: $2.85
Premium en % du sous-jacent: 0.6%

Interprétation: Premium d’options

  • VIX élevé → Premium plus élevé (avantageux pour le vendeur)
  • Delta cible → Détermine le strike OTM (0.20 ≈ 5% OTM)
  • DTE → Plus de temps = plus de valeur temps

L’approximation Black-Scholes donne une estimation raisonnable du premium pour la recherche.

3. Backtest Covered Call

Simulation de la stratégie avec: - 100 shares SPY (couverture pour 1 contrat) - Vente de call mensuel (~30 DTE) - Roll à 10 DTE - VIX filter: 15-35 - Profit target: 50% - Defensive close: -3% daily drop

def backtest_covered_call(data, 
                         delta_target=0.20,
                         vix_min=15, vix_max=35,
                         profit_target=0.50,
                         defensive_drop=0.03,
                         days_to_roll=10,
                         target_dte=30):
    """
    Backtest Covered Call sur SPY.
    
    Retourne les métriques de performance.
    """
    portfolio_values = [1.0]
    cash = 0.0
    shares = 100
    
    # Position state
    short_call_active = False
    call_entry_premium = 0.0
    call_entry_date = None
    call_dte = target_dte
    
    # Stats
    trades_count = 0
    profit_closes = 0
    defensive_closes = 0
    expirations = 0
    premium_collected = 0
    
    warmup = 50
    
    for i in range(warmup, len(data)):
        spy_price = data['SPY'].iloc[i]
        vix = data['VIX'].iloc[i]
        prev_spy = data['SPY'].iloc[i-1]
        
        # Daily return SPY
        spy_daily_return = (spy_price - prev_spy) / prev_spy
        
        # Valeur du portefeuille (shares + cash - short call)
        port_value = shares * spy_price / 100.0 + cash
        
        if short_call_active:
            # Estimer la valeur actuelle du call
            current_dte = max(1, call_dte - 1)
            current_premium = estimate_call_premium(spy_price, vix, current_dte, delta_target)
            port_value -= current_premium  # Short position
            
            # Check defensive close
            if spy_daily_return < -defensive_drop:
                # Buy back at current premium
                pnl = call_entry_premium - current_premium
                cash += pnl
                short_call_active = False
                defensive_closes += 1
            # Check profit target
            elif current_premium <= call_entry_premium * (1 - profit_target):
                pnl = call_entry_premium - current_premium
                cash += pnl
                short_call_active = False
                profit_closes += 1
            # Check roll/expiration
            elif current_dte <= days_to_roll:
                # Expire/roll: buy back at current premium
                pnl = call_entry_premium - current_premium
                cash += pnl
                short_call_active = False
                expirations += 1
        
        # Sell new call si position vide et VIX OK
        if not short_call_active and vix_min <= vix <= vix_max:
            new_premium = estimate_call_premium(spy_price, vix, target_dte, delta_target)
            if new_premium > 0.50:  # Minimum premium
                short_call_active = True
                call_entry_premium = new_premium
                call_dte = target_dte
                cash -= new_premium  # Receive premium (negative for short)
                premium_collected += new_premium
                trades_count += 1
        
        portfolio_values.append(port_value)
    
    # Métriques
    returns = np.diff(portfolio_values) / np.array(portfolio_values[:-1])
    cum_returns = pd.Series(portfolio_values[1:], index=data.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],
        'trades': trades_count,
        'profit_closes': profit_closes,
        'defensive_closes': defensive_closes,
        'expirations': expirations,
        'premium_collected': premium_collected
    }

print("Fonction de backtest définie.")
Fonction de backtest définie.

4. Test du Delta cible

# Test différents delta targets
deltas = [0.15, 0.20, 0.25]

print(f"{'Delta':<8} {'Sharpe':>8} {'CAGR':>8} {'MaxDD':>8} {'Trades':>8}")
print("-" * 48)

delta_results = {}
for delta in deltas:
    r = backtest_covered_call(data, delta_target=delta)
    delta_results[f"{delta:.2f}"] = r
    print(f"{delta:.2f}{'':<6} {r['sharpe']:>8.3f} {r['cagr']:>7.1%} {r['max_dd']:>7.1%} {r['trades']:>8}")

best_delta = max(delta_results.items(), key=lambda x: x[1]['sharpe'])
print(f"\nMeilleur Delta: {best_delta[0]} (Sharpe={best_delta[1]['sharpe']:.3f})")
Delta      Sharpe     CAGR    MaxDD   Trades
------------------------------------------------
0.15          0.012  115.4%  -37.4%       19
0.20          0.012  115.2%  -37.8%       19
0.25          0.012  115.0%  -38.3%       18

Meilleur Delta: 0.15 (Sharpe=0.012)

5. Test du filtre VIX

# Test différentes plages VIX
vix_ranges = [
    ((12, 30), "VIX12/30"),
    ((15, 35), "VIX15/35"),
    ((18, 40), "VIX18/40"),
]

print(f"{'VIX Range':<12} {'Sharpe':>8} {'CAGR':>8} {'MaxDD':>8} {'Trades':>8}")
print("-" * 52)

vix_results = {}
for (vmin, vmax), name in vix_ranges:
    r = backtest_covered_call(data, vix_min=vmin, vix_max=vmax)
    vix_results[name] = r
    print(f"{name:<12} {r['sharpe']:>8.3f} {r['cagr']:>7.1%} {r['max_dd']:>7.1%} {r['trades']:>8}")

best_vix = max(vix_results.items(), key=lambda x: x[1]['sharpe'])
print(f"\nMeilleure plage VIX: {best_vix[0]} (Sharpe={best_vix[1]['sharpe']:.3f})")
VIX Range      Sharpe     CAGR    MaxDD   Trades
----------------------------------------------------
VIX12/30        0.012  115.1%  -38.0%       23
VIX15/35        0.012  115.2%  -37.8%       19
VIX18/40        0.012  114.7%  -40.0%       15

Meilleure plage VIX: VIX15/35 (Sharpe=0.012)

6. Test du Profit Target

# Test différents profit targets
profit_targets = [0.40, 0.50, 0.60]

print(f"{'Profit Target':<14} {'Sharpe':>8} {'CAGR':>8} {'MaxDD':>8} {'Profit Closes':>14}")
print("-" * 62)

profit_results = {}
for pt in profit_targets:
    r = backtest_covered_call(data, profit_target=pt)
    profit_results[f"{pt:.0%}"] = r
    print(f"{pt:.0%}{'':<12} {r['sharpe']:>8.3f} {r['cagr']:>7.1%} {r['max_dd']:>7.1%} {r['profit_closes']:>14}")

best_profit = max(profit_results.items(), key=lambda x: x[1]['sharpe'])
print(f"\nMeilleur Profit Target: {best_profit[0]} (Sharpe={best_profit[1]['sharpe']:.3f})")
Profit Target    Sharpe     CAGR    MaxDD  Profit Closes
--------------------------------------------------------------
40%                0.012  115.0%  -38.0%             13
50%                0.012  115.2%  -37.8%             12
60%                0.012  115.4%  -37.7%             11

Meilleur Profit Target: 60% (Sharpe=0.012)

7. Comparaison avec SPY B&H

# Covered Call avec paramètres optimaux
cc_result = backtest_covered_call(data)

# SPY B&H
spy_values = data['SPY'].iloc[50:] / data['SPY'].iloc[50]

# 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"{'Covered Call':<20} {cc_result['cagr']:>9.1%} {cc_result['sharpe']:>10.3f} {cc_result['max_dd']:>9.1%}")
print(f"{'SPY B&H':<20} {spy_cagr:>9.1%} {spy_sharpe:>10.3f} {spy_dd:>9.1%}")

print(f"\n=== Statistiques Covered Call ===")
print(f"Trades totaux: {cc_result['trades']}")
print(f"Closes profit: {cc_result['profit_closes']}")
print(f"Closes défensifs: {cc_result['defensive_closes']}")
print(f"Expirations/Rolls: {cc_result['expirations']}")
print(f"Premium total collecté: ${cc_result['premium_collected']:.2f}")
=== Comparaison vs SPY B&H ===
Stratégie                  CAGR     Sharpe      MaxDD
-----------------------------------------------------
Covered Call            115.2%      0.012    -37.8%
SPY B&H                   6.1%      0.223    -33.7%

=== Statistiques Covered Call ===
Trades totaux: 19
Closes profit: 12
Closes défensifs: 7
Expirations/Rolls: 0
Premium total collecté: $35.61

8. Visualisation des résultats

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

# Gauche: Delta comparison
ax = axes[0]
for name, r in delta_results.items():
    ax.plot(r['cum'].values, label=f"Delta {name} (S={r['sharpe']:.2f})", linewidth=1.5)
ax.plot(spy_values.values, label='SPY B&H', linestyle='--', alpha=0.5)
ax.set_title('Delta Cible Optimal', fontsize=12, fontweight='bold')
ax.set_ylabel('Valeur du portefeuille')
ax.legend(fontsize=8)
ax.grid(True, alpha=0.3)

# Droite: VIX range comparison
ax = axes[1]
for name, r in vix_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('Filtre VIX', 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('covered_call_analysis.png', dpi=150, bbox_inches='tight')
plt.show()
print("Graphique sauvegardé.")

Graphique sauvegardé.

9. Conclusions et recommandations

Résumé

Métrique Meilleure config
Delta (à remplir)
VIX Range (à remplir)
Profit Target (à remplir)
Sharpe (à remplir)
CAGR (à remplir)

Verdict

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

Points forts Covered Call

  • Revenu régulier: Premiums collectés à chaque vente
  • Protection partielle: Premium réduit le coût de base
  • VIX filter: Évite les périodes de volatilité extrême
  • Defensive close: Protège contre les chutes brutales

Limitations

  • Plafonnement de gain: Le gain max est le premium + (strike - entry)
  • Risque de baisse: 100 shares SPY exposés à la baisse
  • Complexité: Gestion des rolls et des early closes

Prochaines étapes

  1. Déployer sur QC cloud avec les paramètres optimaux
  2. Tester sur d’autres sous-jacents (QQQ, IWM)
  3. Optimiser le defensive close (ATR-based au lieu de fixe -3%)
  4. Tester stratégie wheel (put + covered call combinés)
Retour au sommet