Modulo 5 — Trading algoritmico · Lezione 22 di 23 · 9 min
Backtest senza illusioni: costi, statistiche, walk-forward e Monte Carlo
Un motore di backtest con costi e ipotesi prudenti, le statistiche con intervalli di confidenza, il walk-forward, la simulazione Monte Carlo e le tecniche per non ingannarsi.
Hai i segnali: ora bisogna misurarli. Un backtest ben fatto è più severo del trader medio: aggiunge costi, assume l'ipotesi più sfavorevole quando non sa e ti restituisce numeri con i margini di errore. In questa lezione trovi il codice completo (lib_backtest.py) e i metodi di controllo che separano un test onesto da una bella illusione.
Il motore di backtest (lib_backtest.py)
import numpy as np
import pandas as pd
def backtest(df, signals, stops, rr=2.0, pip=0.0001, spread_pips=1.0, buffer_pips=2.0, max_bars=48):
"""Long-only. Entra all'apertura della candela successiva al segnale.
Se nella stessa candela sono toccati sia stop sia target, vince lo stop (ipotesi prudente).
Ritorna un DataFrame con un'operazione per riga e l'esito in R, costi inclusi."""
rows = []
idx = df.index
i = 0
n = len(df)
while i < n - 1:
if signals.iloc[i] != 1:
i += 1
continue
entry = df["open"].iloc[i + 1] + spread_pips * pip # compri all'ask
stop = stops.iloc[i] - buffer_pips * pip
risk = entry - stop
if risk <= 0:
i += 1
continue
target = entry + rr * risk
outcome, exit_bar = None, None
for j in range(i + 1, min(i + 1 + max_bars, n)):
hit_stop = df["low"].iloc[j] <= stop
hit_tp = df["high"].iloc[j] >= target
if hit_stop: # prudenza: lo stop vince in caso di dubbio
outcome, exit_bar = -1.0, j
break
if hit_tp:
outcome, exit_bar = rr, j
break
if outcome is None: # uscita a tempo alla chiusura dell'ultima candela
exit_bar = min(i + max_bars, n - 1)
outcome = (df["close"].iloc[exit_bar] - entry) / risk
rows.append({"time": idx[i + 1], "r": outcome, "risk_pips": risk / pip})
i = exit_bar + 1 # una sola posizione alla volta
return pd.DataFrame(rows, columns=["time", "r", "risk_pips"])
def stats(r):
r = np.asarray(r, dtype=float)
if len(r) == 0:
return {}
wins, losses = r[r > 0], r[r <= 0]
equity = np.cumsum(r)
peak = np.maximum.accumulate(equity)
max_dd = (peak - equity).max()
sd = r.std(ddof=1) if len(r) > 1 else float("nan")
se = sd / np.sqrt(len(r)) if len(r) > 1 else float("nan")
streak = cur = 0
for x in r:
cur = cur + 1 if x <= 0 else 0
streak = max(streak, cur)
return {
"trades": len(r),
"win_rate": len(wins) / len(r),
"avg_win_R": wins.mean() if len(wins) else 0.0,
"avg_loss_R": losses.mean() if len(losses) else 0.0,
"expectancy_R": r.mean(),
"ci95_R": (r.mean() - 1.96 * se, r.mean() + 1.96 * se),
"profit_factor": wins.sum() / abs(losses.sum()) if losses.sum() != 0 else float("inf"),
"max_drawdown_R": max_dd,
"max_losing_streak": streak,
}
def monte_carlo(r, n_sims=5000, seed=42):
"""Rimescola l'ordine dei risultati in R e misura il drawdown massimo di ogni sequenza."""
rng = np.random.default_rng(seed)
r = np.asarray(r, dtype=float)
dds = np.empty(n_sims)
finals = np.empty(n_sims)
for k in range(n_sims):
seq = rng.permutation(r)
eq = np.cumsum(seq)
dds[k] = (np.maximum.accumulate(eq) - eq).max()
finals[k] = eq[-1]
return {
"dd_median": np.median(dds),
"dd_p90": np.percentile(dds, 90),
"dd_p95": np.percentile(dds, 95),
"final_p5": np.percentile(finals, 5),
"final_median": np.median(finals),
}
def walk_forward(df, run, train_bars, test_bars):
"""Scorre finestre consecutive: 'run(df_finestra)' deve ritornare i risultati in R della finestra."""
out = []
start = 0
while start + train_bars + test_bars <= len(df):
test = df.iloc[start + train_bars : start + train_bars + test_bars]
out.append(np.asarray(run(test), dtype=float))
start += test_bars
return outScelte di progetto (e perché)
- Ingresso all'apertura della candela successiva al segnale, pagando lo spread (compri all'ask).
- Stop = minimo dello sweep meno un margine, il rischio è la distanza ingresso-stop; il target è rr volte il rischio.
- Stop e target nella stessa candela → vince lo stop: ipotesi prudente.
- Uscita a tempo (max_bars): se dopo un numero massimo di candele non è successo nulla, si chiude alla chiusura.
- Una posizione alla volta: evita sovrapposizioni e rischi combinati non voluti.
- Risultati in R, costi inclusi: il costo dello spread è già dentro l'ingresso.
Un'esecuzione tipica
Con df di candele M5 e i segnali della lezione precedente:
from lib_signals import bullish_signals
from lib_backtest import backtest, stats, monte_carlo
sig, stops = bullish_signals(df)
trades = backtest(df, sig, stops, rr=2.0, spread_pips=1.0, buffer_pips=2.0)
s = stats(trades["r"])
print(s["trades"], s["win_rate"], s["expectancy_R"], s["ci95_R"], s["max_drawdown_R"])
print(monte_carlo(trades["r"], n_sims=5000))Il risultato è un dizionario con numero di operazioni, win rate, aspettativa, intervallo di confidenza al 95%, drawdown massimo e serie negativa massima. Se l'intervallo include lo zero, non hai evidenza di un vantaggio.
Leggere le statistiche
La funzione stats calcola ciò che hai visto nel modulo 4: win rate, R medio di vincite e perdite, aspettativa, intervallo di confidenza (E ± 1,96 × SE), profit factor, drawdown massimo e serie di perdite consecutive. Cosa guardare per prima cosa:
- Numero di operazioni: se sono meno di 100, tutto il resto è poco affidabile.
- Aspettativa e intervallo: l'intervallo esclude lo zero?
- Drawdown massimo e Monte Carlo (p95): il rischio reale è sopportabile?
- Sensibilità ai costi: ricalcola con spread doppio: l'aspettativa resta positiva?
I nemici del backtest
| Nemico | Cosa succede | Rimedio |
|---|---|---|
| Look-ahead | Il segnale usa informazioni future | Test dello "spostamento del futuro", shift, candele chiuse |
| Overfitting | Provi 200 combinazioni e tieni la migliore | Pochi parametri, out-of-sample, walk-forward |
| Costi sottostimati | Spread, commissioni, slippage ignorati | Testa con costi doppi; ingresso alla candela successiva |
| Qualità dei dati | Buchi, orari sfalsati, duplicati | Controlli sui dati; stessa fonte per tutto |
| Campione piccolo | Poche operazioni | Serve un campione ampio; guarda l'intervallo |
| Sopravvivenza | Testi solo la coppia/periodo che funziona | Più coppie e periodi |
| Ottimismo nell'esecuzione | Assumi fill perfetti | Stop prudente, slippage, ritardo |
Out-of-sample e walk-forward
Out-of-sample semplice: sviluppi le regole sul primo 60% dei dati e le verifichi, senza modifiche, sul restante 40%.
Walk-forward: scorri finestre consecutive nel tempo: usi una finestra per "allenare" (definire i parametri) e la successiva per testare, poi fai avanzare tutto. Simula come useresti davvero il sistema: sempre con dati che non ha mai visto.
La funzione walk_forward restituisce i risultati in R di ciascuna finestra di test. Poi li concateni e calcoli le statistiche complessive:
from lib_backtest import walk_forward
import numpy as np
def run(df_window):
sig, stops = bullish_signals(df_window)
return backtest(df_window, sig, stops)["r"]
# 3 mesi di M5 = circa 25.000 candele; test su 1 mese alla volta
results = walk_forward(df, run, train_bars=3 * 22 * 288, test_bars=1 * 22 * 288)
all_r = np.concatenate(results)
print(stats(all_r))Se le regole sono fisse (senza ottimizzazione), il walk-forward mostra semplicemente la stabilità nel tempo: il sistema guadagna in modo simile in ogni finestra o dipende da un solo periodo fortunato?
Sensibilità dei parametri
Cambia un parametro alla volta in un piccolo intervallo (per esempio displacement 1,2 / 1,5 / 1,8; RR 1,5 / 2,0 / 3,0) e confronta l'aspettativa:
import itertools
for disp, rr in itertools.product([1.2, 1.5, 1.8], [1.5, 2.0, 3.0]):
sig, stops = bullish_signals(df, disp=disp)
tr = backtest(df, sig, stops, rr=rr)
if len(tr) > 0:
print(disp, rr, len(tr), round(stats(tr["r"])["expectancy_R"], 3))Cosa cercare: un altopiano, cioè valori vicini con risultati simili. Se solo una combinazione "brilla" e le vicine crollano, è un adattamento al rumore (un "picco isolato").
Il caso di controllo (in codice)
Confronta il tuo modello con un controllo semplice, stessa gestione: per esempio segnali casuali nella killzone con la stessa frequenza:
import numpy as np
import pandas as pd
from lib_structure import atr
rng = np.random.default_rng(0)
def random_signals(df, n_signals, atr_mult=1.5):
a = atr(df, 14)
idx = rng.choice(len(df) - 50, size=n_signals, replace=False)
sig = pd.Series(0, index=df.index)
stops = pd.Series(np.nan, index=df.index)
for i in idx:
sig.iloc[i] = 1
stops.iloc[i] = df["close"].iloc[i] - atr_mult * a.iloc[i]
return sig, stopsRipeti l'estrazione casuale molte volte (per esempio 200) e ottieni la distribuzione dell'aspettativa "per caso". Il tuo modello ha un vantaggio se la sua aspettativa sta chiaramente oltre quella distribuzione (per esempio sopra il 95° percentile).
Monte Carlo: cosa dice
La funzione monte_carlo rimescola l'ordine dei risultati e ti restituisce: drawdown mediano, drawdown al 90° e 95° percentile e il capitale finale al 5° percentile e mediano. Interpretazione:
- dd_p95 è il drawdown che supererai solo nel 5% dei casi: usalo per scegliere il rischio per operazione.
- final_p5 è il risultato "sfortunato ma plausibile" a fine campione.
Cosa NON fa questo codice
- Non gestisce swap, commissioni per lotto e slippage variabile.
- Non ottimizza la size (è in R).
- Non considera notizie e gap in modo speciale.
- È long-only e a una posizione alla volta.
Sono limiti voluti per tenere il codice leggibile. Prima di fidarti di un risultato, estendi il motore (per esempio con spread variabile per ora) e ripeti i test.
In sintesi
- Un backtest onesto include costi, ipotesi prudenti (stop prima del target) e ingresso alla candela successiva.
- Le statistiche vanno lette con l'intervallo di confidenza: se include lo zero, non c'è evidenza.
- Valida con out-of-sample, walk-forward, sensibilità dei parametri (cerca un altopiano) e un controllo casuale.
- Il Monte Carlo ti dà il drawdown realistico da usare per scegliere il rischio.
- Ricorda i limiti del modello e amplialo prima di fidarti.
Esercizio pratico
- Esegui il backtest sul tuo dataset e calcola le statistiche con l'intervallo di confidenza.
- Ricalcola con spread doppio e con ingresso ritardato di una candela: cosa cambia?
- Esegui la griglia di sensibilità e il controllo casuale (200 estrazioni). Il tuo modello supera il 95° percentile del caso?
Metti alla prova quello che hai imparato
1. Perché in caso di dubbio vince lo stop nel backtest?
2. Cosa cerchi nella sensibilità dei parametri?
3. A cosa serve dd_p95 del Monte Carlo?
4. Come si usa il caso di controllo casuale?
Hai una domanda o vuoi condividere il tuo esercizio?
Scrivi nella community, oppure entra nella sala segnali gratuita su Telegram.
Contenuto educativo, non è consulenza finanziaria. Il trading comporta rischi. ICT è un termine riferito ai materiali di Michael Huddleston: questo corso è indipendente e non affiliato.