fx_pairs = ['EURUSD', 'AUDUSD', 'USDJPY', 'USDCAD', 'GBPUSD', 'NZDUSD']symbols = {}for pair in fx_pairs:try: symbols[pair] = qb.add_forex(pair, Resolution.DAILY).symbolexcept:print(f"Warning: {pair} non disponible")start = datetime(2010, 1, 1)end = datetime(2026, 1, 1)history = qb.history(list(symbols.values()), start, end, Resolution.DAILY)closes = history['close'].unstack(level=0)stm = {str(v): k for k, v in symbols.items()}closes.columns = [stm.get(str(c), str(c)) for c in closes.columns]closes = closes.dropna()# QC History retourne les bars daily a 16:00 (cloture marche), pas a minuit.# resample('MS') etiquette les bacs a minuit -> le test d'appartenance mensuel# (date in closes.index) ne matcherait jamais l'index daily -> on normalise a minuit.closes.index = closes.index.normalize()print(f"Periode: {closes.index[0].date()} a {closes.index[-1].date()}")print(f"Donnees: {len(closes)} jours de trading")print(f"Paires: {list(closes.columns)}")returns_df = closes.pct_change()
Periode: 2010-01-03 a 2025-12-31
Donnees: 4996 jours de trading
Paires: ['AUDUSD', 'EURUSD', 'GBPUSD', 'NZDUSD', 'USDCAD', 'USDJPY']
Note sur les paires USD/XXX :
Pour USDJPY et USDCAD, un prix qui monte = USD fort = devise locale faible. Pour mesurer le momentum de la devise (pas du USD), on inverse le return.
# Statistiques par paireprint(f"{'Paire':<10}{'Rend. Ann.':>12}{'Volatilite':>12}{'Sharpe':>8}")print("-"*45)for pair in closes.columns: ret = (closes[pair].iloc[-1] / closes[pair].iloc[0]) ** (252/len(closes)) -1 vol = returns_df[pair].std() * np.sqrt(252) sharpe = ret / vol if vol >0else0print(f"{pair:<10}{ret:>11.1%}{vol:>11.1%}{sharpe:>7.2f}")
def fx_momentum_score(closes, pair, lookback_short=21, lookback_long=126, w_short=0.7, w_long=0.3, risk_adjusted=True, skip_month=True):"""Compute composite FX momentum score.""" prices = closes[pair]# Invert USD/XXX pairs (USD strength = negative momentum for the currency) invert = pair.startswith('USD')if skip_month:# Skip last 21 days (Jegadeesh) ret_long = prices.shift(21) / prices.shift(lookback_long) -1else: ret_long = prices / prices.shift(lookback_long) -1 ret_short = prices / prices.shift(lookback_short) -1if invert: ret_long =-ret_long ret_short =-ret_short# Composite score score = w_short * ret_short + w_long * ret_longif risk_adjusted: vol = returns_df[pair].rolling(63).std() * np.sqrt(252) score = score / vol.clip(lower=0.01)return scoredef backtest_fx_momentum(closes, universe, top_n=2, lookback_short=21, lookback_long=126, risk_adjusted=True, skip_month=True):"""Backtest FX momentum strategy.""" returns_df = closes.pct_change()# Compute scores for all pairs scores = pd.DataFrame(index=closes.index)for pair in universe:if pair in closes.columns: scores[pair] = fx_momentum_score( closes, pair, lookback_short, lookback_long, risk_adjusted=risk_adjusted, skip_month=skip_month )# Monthly rebalance monthly = closes.resample('MS').first().index start_idx =max(lookback_long +21, 63) +1 port_ret = pd.Series(0.0, index=closes.index) current_pos = []for date in monthly:if date notin closes.index:continueif date < closes.index[start_idx]:continue s = scores.loc[date].dropna()iflen(s) ==0:continue# Top-N with positive IR top = s.nlargest(top_n) current_pos = [p for p in top.index if top[p] >0]# Actually compute returns with proper rebalancing positions = {}for date in monthly:if date notin closes.index or date < closes.index[start_idx]:continue s = scores.loc[date].dropna()iflen(s) ==0: positions[date] = []continue top = s.nlargest(top_n) positions[date] = [p for p in top.index if top[p] >0] current_pos = [] weight =0.5# 50% per positionfor i inrange(start_idx, len(closes)): date = closes.index[i]# Check if rebalance dayif date in positions: current_pos = positions[date]iflen(current_pos) >0: w =1.0/max(len(current_pos), 1)for pair in current_pos:if pair in returns_df.columns:# For USD/XXX pairs, we need to invert r = returns_df[pair].iloc[i]if pair.startswith('USD'): r =-r port_ret.iloc[i] += w * r port_ret = port_ret[port_ret.index >= closes.index[start_idx]] vals = (1+ port_ret).cumprod() total = vals.iloc[-1] -1 years =len(port_ret) /252 cagr = (1+ total) ** (1/ years) -1if years >0else0 vol = port_ret.std() * np.sqrt(252) sharpe = (cagr -0.03) / vol if vol >0.001else0 max_dd = ((vals - vals.expanding().max()) / vals.expanding().max()).min()return {'sharpe': sharpe, 'cagr': cagr, 'max_dd': max_dd, 'vol': vol, 'cum': vals}print("Fonctions definies.")
Fonctions definies.
3. Hypothese 1: Pure vs risk-adjusted momentum
Règle #1 du backlog attendait « risk-adjusted momentum > raw momentum ». La mesure FX dit l’inverse (cf. Verdict H1 ci-dessous).
base_universe = ['EURUSD', 'AUDUSD', 'USDJPY', 'USDCAD']print(f"{'Momentum Type':<25}{'Sharpe':>8}{'CAGR':>8}{'MaxDD':>8}")print("-"*50)results_mom = {}for name, ra in [('Pure momentum', False), ('Risk-adjusted (actuel)', True)]: r = backtest_fx_momentum(closes, base_universe, risk_adjusted=ra) results_mom[name] = rprint(f"{name:<25}{r['sharpe']:>8.3f}{r['cagr']:>7.1%}{r['max_dd']:>7.1%}")
Momentum Type Sharpe CAGR MaxDD
--------------------------------------------------
Pure momentum -0.719 -1.2% -33.2%
Risk-adjusted (actuel) -0.744 -1.3% -35.7%
Verdict H1
research.ipynb etait inconclusif sur FX. La re-exec tranche : le risk-adjustment nuit sur FX G10 — Pure momentum Sharpe -0.719 (CAGR -1.2%, MaxDD -33.2%) surperforme le risk-adjusted -0.744 (-1.3%, -35.7%). Ecart avec la Règle #1 documente au tableau §9.
4. Hypothese 2: Skip-month effect
print(f"{'Skip-Month':<25}{'Sharpe':>8}{'CAGR':>8}{'MaxDD':>8}")print("-"*50)results_skip = {}for name, skip in [('Sans skip (J-126 a J-0)', False), ('Avec skip (J-126 a J-21)', True)]: r = backtest_fx_momentum(closes, base_universe, skip_month=skip) results_skip[name] = rprint(f"{name:<25}{r['sharpe']:>8.3f}{r['cagr']:>7.1%}{r['max_dd']:>7.1%}")
Skip-Month Sharpe CAGR MaxDD
--------------------------------------------------
Sans skip (J-126 a J-0) -0.767 -1.5% -37.6%
Avec skip (J-126 a J-21) -0.744 -1.3% -35.7%
Verdict H2
Règle #2 du backlog: skip-month confirme pour equities (Jegadeesh 1990). Sur FX, la mesure donne un effet faible et en demi-teinte : Avec skip Sharpe -0.744 (CAGR -1.3%, MaxDD -35.7%) vs Sans skip -0.767 (-1.5%, -37.6%). Le skip attenu legerement la perte (Δ +0.023 Sharpe) mais les deux configurations restent negatives — l’effet skip ne sauve pas la strategie, il la rend un peu moins mauvaise.
126d (6 mois) est le lookback long actuel. FX momentum est typiquement plus court que equity momentum (1-3 mois vs 12 mois).
6. Hypothese 4: Top-N positions
print(f"{'Top-N':<15}{'Sharpe':>8}{'CAGR':>8}{'MaxDD':>8}")print("-"*40)results_topn = {}for n in [1, 2, 3]: r = backtest_fx_momentum(closes, base_universe, top_n=n) name =f'Top-{n}' results_topn[name] = rprint(f"{name:<15}{r['sharpe']:>8.3f}{r['cagr']:>7.1%}{r['max_dd']:>7.1%}")
Verdict : NO BEATS — aucune configuration ne produit un Sharpe positif. Données FX issues de yfinance (backend LEAN, cf. corps de PR) ; pas de cross-check yfinance independant possible (yfinance est la source).
Problème structurel
FX momentum G10 genere ~0.7-1.5% CAGR absolu, mais le taux sans risque est de 2.5%-5.5% sur 2015-2026. Le Sharpe negatif est donc structurel: le momentum FX ne compense pas le cout d’opportunite du cash.
Menkhoff et al. (2012) documentent l’affaiblissement du FX momentum post-2008, lie a l’intervention massive des banques centrales.