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 out

Scelte 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:

  1. Numero di operazioni: se sono meno di 100, tutto il resto è poco affidabile.
  2. Aspettativa e intervallo: l'intervallo esclude lo zero?
  3. Drawdown massimo e Monte Carlo (p95): il rischio reale è sopportabile?
  4. Sensibilità ai costi: ricalcola con spread doppio: l'aspettativa resta positiva?

I nemici del backtest

NemicoCosa succedeRimedio
Look-aheadIl segnale usa informazioni futureTest dello "spostamento del futuro", shift, candele chiuse
OverfittingProvi 200 combinazioni e tieni la migliorePochi parametri, out-of-sample, walk-forward
Costi sottostimatiSpread, commissioni, slippage ignoratiTesta con costi doppi; ingresso alla candela successiva
Qualità dei datiBuchi, orari sfalsati, duplicatiControlli sui dati; stessa fonte per tutto
Campione piccoloPoche operazioniServe un campione ampio; guarda l'intervallo
SopravvivenzaTesti solo la coppia/periodo che funzionaPiù coppie e periodi
Ottimismo nell'esecuzioneAssumi fill perfettiStop 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, stops

Ripeti 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

  1. Esegui il backtest sul tuo dataset e calcola le statistiche con l'intervallo di confidenza.
  2. Ricalcola con spread doppio e con ingresso ritardato di una candela: cosa cambia?
  3. 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.