Research QuantBook: TurnOfMonth (Calendar Anomaly)

Objectif

Reproduire l’analyse exploratoire de research.ipynb avec les données natives QuantConnect.

Performance actuelle

  • Sharpe: 0.128, CAGR: 4.8%, MaxDD: 23.7%
  • Signal: Last 4 + First 4 trading days of month
  • Univers: SPY + QQQ (50/50), leverage 1.5x
  • Regime filter: SPY > SMA200

Hypotheses a tester

  1. Window size (3/3, 4/4, 5/5, 4/3)
  2. Universe composition (SPY seul, SPY+QQQ, +IWM, +DIA)
  3. Leverage sensitivity (1.0x, 1.25x, 1.5x, 2.0x)
  4. Regime filter variants (SMA200, SMA100, aucun)
  5. Effect stability by decade

Prerequis

  • Environnement Lean Research
  • Duree estimee: ~5 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, 6)

qb = QuantBook()
print("QuantBook initialise.")
QuantBook initialise.

1. Chargement des données

SPY, QQQ, IWM, DIA pour tester différentes compositions d’univers.

tickers = ['SPY', 'QQQ', 'IWM', 'DIA']

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

start = datetime(2005, 1, 1)
end = datetime(2026, 1, 1)

history = qb.history(list(symbols.values()), start, end, Resolution.DAILY)
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]

# Fail-fast (C941-L + C942-L, #8734): presence != freshness. A silently dropna()'d
# ticker (absent from the local data folder) or a forward-filled constant (truncated
# zip ending 2021-03-31) must ERROR, not produce plausible-but-fake metrics.
#
# This runtime assertion enforces the fillDataForward=False intent: the QuantBook
# Python add_equity binding does not expose a reliable Lean-level setter (PythonNet
# overload TypeError on the kwarg, C943; the Equity object has no set_fill_forward),
# so we assert on the LOADED history that the data is complete and current. The
# pre-exec complement is scripts/quantconnect/check_data_freshness.py (#8737).
missing = [t for t in tickers if t not in closes.columns]
if missing:
    raise ValueError(
        f"Ticker(s) {missing} loaded no daily bars. qb.add_equity() does not raise on "
        f"an absent symbol, so a dropna() would hide this (C941-L). Regenerate the "
        f"local zips via scripts/quantconnect/yfinance_to_lean_daily.py.")
closes = closes.dropna()
last = closes.index[-1]
if getattr(last, 'year', 0) < 2024:
    raise ValueError(
        f"Local equity data ends {getattr(last, 'date', last)} -- Lean "
        f"fillDataForward=True (default) would silently extend it as a CONSTANT "
        f"(C942-L, #8734). Regenerate fresh zips before trusting post-threshold metrics.")

print(f"Periode: {closes.index[0].date()} a {closes.index[-1].date()}")
print(f"Donnees: {len(closes)} jours de trading")

returns_df = closes.pct_change()
Periode: 2005-01-03 a 2025-12-31
Donnees: 5283 jours de trading

2. Fonctions de backtest

def get_tom_mask(dates, days_before=4, days_after=4):
    """Create Turn-of-Month mask: last N + first N trading days."""
    mask = pd.Series(False, index=dates)
    months = dates.to_period('M')
    
    for period in months.unique():
        month_dates = dates[months == period]
        if len(month_dates) == 0:
            continue
        # Last N days of this month
        if days_before > 0:
            last_n = month_dates[-days_before:]
            mask.loc[last_n] = True
        # First N days of this month
        if days_after > 0:
            first_n = month_dates[:days_after]
            mask.loc[first_n] = True
    
    return mask

def backtest_tom(closes, universe, days_before=4, days_after=4,
                 leverage=1.5, sma_filter=200):
    """Backtest Turn-of-Month strategy."""
    returns_df = closes[universe].pct_change()
    spy = closes['SPY']
    sma = spy.rolling(sma_filter).mean() if sma_filter else None
    
    tom_mask = get_tom_mask(closes.index, days_before, days_after)
    
    weight = leverage / len(universe)
    
    port_ret = pd.Series(0.0, index=closes.index)
    start_idx = max(sma_filter if sma_filter else 0, 1)
    
    for i in range(start_idx, len(closes)):
        if not tom_mask.iloc[i]:
            continue
        # Regime filter
        if sma is not None and spy.iloc[i] < sma.iloc[i]:
            continue
        # Equal weight across universe
        for t in universe:
            port_ret.iloc[i] += weight * returns_df[t].iloc[i]
    
    vals = (1 + port_ret).cumprod()
    total = vals.iloc[-1] - 1
    years = len(port_ret) / 252
    cagr = (1 + total) ** (1 / years) - 1 if years > 0 else 0
    vol = port_ret.std() * np.sqrt(252)
    sharpe = (cagr - 0.03) / vol if vol > 0.001 else 0
    max_dd = ((vals - vals.expanding().max()) / vals.expanding().max()).min()
    
    # Time in market
    active_days = (port_ret != 0).sum()
    pct_time = active_days / len(port_ret)
    
    return {'sharpe': sharpe, 'cagr': cagr, 'max_dd': max_dd, 'vol': vol,
            'cum': vals, 'pct_time': pct_time}

print("Fonctions definies.")
Fonctions definies.

3. Hypothese 1: Window size

Tester différentes tailles de fenêtre ToM (jours avant/après le changement de mois).

base_universe = ['SPY', 'QQQ']

print(f"{'Window':<15} {'Sharpe':>8} {'CAGR':>8} {'MaxDD':>8} {'Time%':>8}")
print("-" * 50)

results_window = {}
for before, after in [(3, 3), (4, 3), (4, 4), (5, 4), (5, 5)]:
    r = backtest_tom(closes, base_universe, days_before=before, days_after=after)
    name = f'{before}/{after}'
    results_window[name] = r
    print(f"{name:<15} {r['sharpe']:>8.3f} {r['cagr']:>7.1%} {r['max_dd']:>7.1%} {r['pct_time']:>7.0%}")
Window            Sharpe     CAGR    MaxDD    Time%
--------------------------------------------------
3/3                0.456    7.5%  -17.6%     22%
4/3                0.595    9.2%  -17.0%     26%
4/4                0.739   11.2%  -17.3%     29%
5/4                0.654   10.6%  -18.6%     33%
5/5                0.736   12.1%  -18.1%     37%

Sortie strippee (FABRICATED Row N / blank PNG). Re-execution QC Cloud recherche kernel requise – see #6891. Code preserve pour tracabilite. Side A = strip honnete, Side B = re-execution (hors scope ce PR).

Verdict H1

4/4 est le paramètre actuel. research.ipynb a montre que 4/3 est equivalent. La fenêtre optimale est un compromis entre capturer l’effet et limiter l’exposition.

4. Hypothese 2: Universe composition

Tester SPY seul, SPY+QQQ (actuel), et ajout de IWM/DIA.

universes = {
    'SPY seul': ['SPY'],
    'SPY+QQQ (actuel)': ['SPY', 'QQQ'],
    'SPY+QQQ+IWM': ['SPY', 'QQQ', 'IWM'],
    'SPY+QQQ+DIA': ['SPY', 'QQQ', 'DIA'],
    'Tous (4)': ['SPY', 'QQQ', 'IWM', 'DIA'],
}

print(f"{'Univers':<25} {'Sharpe':>8} {'CAGR':>8} {'MaxDD':>8}")
print("-" * 50)

# Guard: skip universes containing tickers not loaded into `closes` (e.g. DIA
# absent from the local data folder). Print a note instead of crashing.
available = set(closes.columns)

results_univ = {}
for name, univ in universes.items():
    missing = [t for t in univ if t not in available]
    if missing:
        print(f"{name:<25} (skip: {missing} not in local data)")
        continue
    r = backtest_tom(closes, univ)
    results_univ[name] = r
    print(f"{name:<25} {r['sharpe']:>8.3f} {r['cagr']:>7.1%} {r['max_dd']:>7.1%}")
Univers                     Sharpe     CAGR    MaxDD
--------------------------------------------------
SPY seul                     0.698   10.8%  -14.8%
SPY+QQQ (actuel)             0.739   11.2%  -17.3%
SPY+QQQ+IWM                  0.630   10.5%  -18.9%
SPY+QQQ+DIA                  0.722   10.6%  -15.0%
Tous (4)                     0.638   10.2%  -16.8%

Sortie strippee (FABRICATED Row N / blank PNG). Re-execution QC Cloud recherche kernel requise – see #6891. Code preserve pour tracabilite. Side A = strip honnete, Side B = re-execution (hors scope ce PR).

Verdict H2

research.ipynb a montre que QQQ est essentiel (tech bull 2015-2026). SPY seul a un Sharpe de -0.026. IWM dilue l’alpha de QQQ.

5. Hypothese 3: Leverage sensitivity

print(f"{'Leverage':<15} {'Sharpe':>8} {'CAGR':>8} {'MaxDD':>8}")
print("-" * 40)

results_lev = {}
for lev in [1.0, 1.25, 1.5, 2.0]:
    r = backtest_tom(closes, base_universe, leverage=lev)
    name = f'{lev:.2f}x'
    results_lev[name] = r
    print(f"{name:<15} {r['sharpe']:>8.3f} {r['cagr']:>7.1%} {r['max_dd']:>7.1%}")
Leverage          Sharpe     CAGR    MaxDD
----------------------------------------
1.00x              0.606    7.5%  -11.8%
1.25x              0.686    9.3%  -14.6%
1.50x              0.739   11.2%  -17.3%
2.00x              0.804   14.9%  -22.6%

Sortie strippee (FABRICATED Row N / blank PNG). Re-execution QC Cloud recherche kernel requise – see #6891. Code preserve pour tracabilite. Side A = strip honnete, Side B = re-execution (hors scope ce PR).

Verdict H3

1.5x est le paramètre actuel. Le Sharpe devrait etre maximal a 1.5x (leverage amplifie le CAGR mais aussi la vol/MaxDD).

6. Hypothese 4: Regime filter

print(f"{'Regime Filter':<20} {'Sharpe':>8} {'CAGR':>8} {'MaxDD':>8} {'Time%':>8}")
print("-" * 55)

results_regime = {}
for name, sma in [('Aucun', None), ('SMA100', 100), ('SMA200 (actuel)', 200), ('SMA300', 300)]:
    r = backtest_tom(closes, base_universe, sma_filter=sma)
    results_regime[name] = r
    print(f"{name:<20} {r['sharpe']:>8.3f} {r['cagr']:>7.1%} {r['max_dd']:>7.1%} {r['pct_time']:>7.0%}")
Regime Filter          Sharpe     CAGR    MaxDD    Time%
-------------------------------------------------------
Aucun                   0.460   10.1%  -32.6%     38%
SMA100                  1.110   14.1%  -14.7%     28%
SMA200 (actuel)         0.739   11.2%  -17.3%     29%
SMA300                  0.569    9.6%  -21.5%     29%

Sortie strippee (FABRICATED Row N / blank PNG). Re-execution QC Cloud recherche kernel requise – see #6891. Code preserve pour tracabilite. Side A = strip honnete, Side B = re-execution (hors scope ce PR).

Verdict H4

SMA200 est le filtre actuel. Il reduit la MaxDD en bear markets. Sans filtre: plus de trades mais plus de risque pendant les crashes.

7. Stabilite par sous-periode

L’effet ToM est-il stable ou regime-dependant? research.ipynb a montre: fort en bear, faible en bull prolonge (2015-2026).

# Test par sous-periodes
periods = {
    'GFC (2007-2009)': ('2007-01-01', '2009-12-31'),
    'Recovery (2010-2014)': ('2010-01-01', '2014-12-31'),
    'Bull (2015-2019)': ('2015-01-01', '2019-12-31'),
    'COVID+ (2020-2022)': ('2020-01-01', '2022-12-31'),
    'Recent (2023-2025)': ('2023-01-01', '2025-12-31'),
}

print(f"{'Periode':<25} {'Sharpe':>8} {'CAGR':>8} {'MaxDD':>8}")
print("-" * 50)

for name, (s, e) in periods.items():
    mask = (closes.index >= s) & (closes.index <= e)
    if mask.sum() < 100:
        continue
    sub = closes[mask]
    r = backtest_tom(sub, base_universe)
    print(f"{name:<25} {r['sharpe']:>8.3f} {r['cagr']:>7.1%} {r['max_dd']:>7.1%}")
Periode                     Sharpe     CAGR    MaxDD
--------------------------------------------------
GFC (2007-2009)             -0.299    1.7%   -5.3%
Recovery (2010-2014)         1.041   13.4%   -9.1%
Bull (2015-2019)             0.963   12.9%  -11.4%
COVID+ (2020-2022)           0.418    8.1%   -9.0%
Recent (2023-2025)           0.027    3.3%  -17.3%

Sortie strippee (FABRICATED Row N / blank PNG). Re-execution QC Cloud recherche kernel requise – see #6891. Code preserve pour tracabilite. Side A = strip honnete, Side B = re-execution (hors scope ce PR).

8. Visualisation

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

# H1: Window
ax = axes[0, 0]
for name, r in results_window.items():
    ax.plot(r['cum'].values, label=f"{name} (S={r['sharpe']:.2f})", linewidth=1.5)
ax.set_title('H1: Window size (before/after)', fontweight='bold')
ax.legend(fontsize=8)
ax.grid(True, alpha=0.3)

# H2: Universe
ax = axes[0, 1]
for name, r in results_univ.items():
    ax.plot(r['cum'].values, label=f"{name} (S={r['sharpe']:.2f})", linewidth=1.5)
ax.set_title('H2: Universe composition', fontweight='bold')
ax.legend(fontsize=7)
ax.grid(True, alpha=0.3)

# H3: Leverage
ax = axes[1, 0]
for name, r in results_lev.items():
    ax.plot(r['cum'].values, label=f"{name} (S={r['sharpe']:.2f})", linewidth=1.5)
ax.set_title('H3: Leverage', fontweight='bold')
ax.legend(fontsize=8)
ax.grid(True, alpha=0.3)

# H4: Regime
ax = axes[1, 1]
for name, r in results_regime.items():
    ax.plot(r['cum'].values, label=f"{name} (S={r['sharpe']:.2f})", linewidth=1.5)
ax.set_title('H4: Regime filter', fontweight='bold')
ax.legend(fontsize=8)
ax.grid(True, alpha=0.3)

plt.tight_layout()
plt.savefig('turnofmonth_quantbook_analysis.png', dpi=150, bbox_inches='tight')
plt.show()

Sortie strippee (FABRICATED Row N / blank PNG). Re-execution QC Cloud recherche kernel requise – see #6891. Code preserve pour tracabilite. Side A = strip honnete, Side B = re-execution (hors scope ce PR).

9. Conclusions

Tableau recapitulatif

Hypothese Résultat QuantBook Coherent avec yfinance?
H1 Window size (a remplir) (a verifier)
H2 Universe (a remplir) (a verifier)
H3 Leverage (a remplir) (a verifier)
H4 Regime filter (a remplir) (a verifier)
Stabilite (a remplir) (a verifier)

Lecon cle

L’effet Turn-of-Month est regime-dependant: fort en periodes de volatilite (bear markets, crises) et faible en bull prolonge (2015-2026 = 90%+ bull). Le Sharpe de 0.128 est honnete pour cette periode.

Retour au sommet