← Torna agli articoli
March 15, 2026
5 min di lettura

Walk-Forward Optimization: L'Unico Test Onesto per una Strategia

#algotrading
#backtest
#walk-forward
#overfitting
#validazione
#ottimizzazione

Hai ottimizzato una strategia. 12 parametri di separazione, 9 meta-parametri — 21 in totale. Un backtest di 25 mesi su una singola coppia mostra PnL +3342% a MaxLev. La curva di equity sale quasi senza drawdown. Sharpe superiore a 3. Tutto sembra perfetto.

Lanci il bot. Due settimane dopo, la strategia perde il 18% del capitale. Un mese dopo — il 34%. I parametri che "funzionavano" sui dati storici si sono rivelati adattati a una specifica sequenza di eventi di mercato. Non hai trovato un pattern — hai memorizzato il rumore.

Questo è il classico overfitting. E l'unico modo sistematico per rilevarlo prima di andare in produzione è la Walk-Forward Optimization (WFO).

La Trappola della Singola Divisione Train/Test

Visualizzazione della trappola della divisione train/test

L'approccio standard: dividere i dati in 70% train e 30% test. Ottimizzare sul train, verificare sul test. Se il risultato è positivo — lanciare.

Il problema: questo è un test su una divisione. Il risultato dipende da dove si traccia il confine. Sposta il confine di un mese — e il PnL out-of-sample può passare da +40% a -15%.

Dati:      |===== Train (70%) =====|== Test (30%) ==|
Split 1: |===2024-01..2025-09====|==2025-10..26-01==|   OOS PnL: +38%
Split 2: |===2024-01..2025-06====|==2025-07..26-01==|   OOS PnL: -12%
Split 3: |===2024-04..2025-12====|==2026-01..26-04==|   OOS PnL: +7%

Tre divisioni diverse — tre conclusioni diverse. Quale fidarsi? Nessuna. Una singola divisione train/test è la stessa stima puntuale i cui problemi abbiamo descritto in Monte Carlo Bootstrap. Non serve un solo controllo, ma una serie sistematica di controlli su segmenti di dati consecutivi.

È esattamente per questo che esiste la Walk-Forward Optimization.

Cos'è la Walk-Forward Optimization

Diagramma delle finestre rolling walk-forward

La WFO è una procedura di ottimizzazione e verifica sequenziale di una strategia su finestre di dati scorrevoli (o in espansione). L'idea: simulare il reale processo di trading in cui si ri-ottimizzano periodicamente i parametri sui dati disponibili e poi si fa trading fino alla prossima ri-ottimizzazione.

Ogni "finestra" è composta da due parti:

  • In-Sample (IS) — il periodo su cui vengono ottimizzati i parametri
  • Out-of-Sample (OOS) — il periodo su cui i parametri trovati vengono testati senza adattamento

La proprietà chiave: i periodi OOS non si sovrappongono e coprono collettivamente una parte significativa dei dati. La curva di equity risultante è costruita solo dai segmenti OOS — questa è la valutazione onesta della strategia.

Anchored WFO (Finestra in Espansione)

Visualizzazione della finestra in espansione Anchored WFO

Nell'Anchored WFO, l'inizio del periodo di train è fisso e la sua fine si espande ad ogni finestra:

Finestra 1: Train [2024-01]         Test [2024-04]
Finestra 2: Train [2024-01..04]     Test [2024-07]    (train in crescita)
Finestra 3: Train [2024-01..07]     Test [2024-10]
Finestra 4: Train [2024-01..10]     Test [2025-01]
Finestra 5: Train [2024-01..2025-01]  Test [2025-04]

Vantaggi:

  • Ogni successivo periodo di train contiene più dati — l'ottimizzazione è più stabile
  • I pattern precoci non vengono persi — sono sempre nel set di training
  • Più semplice da implementare

Svantaggi:

  • I dati vecchi possono "diluire" i pattern attuali
  • Se il mercato è cambiato strutturalmente — i dati vecchi sono dannosi
  • Il periodo di train cresce indefinitamente, aumentando il tempo di ottimizzazione

Rolling WFO (Finestra Scorrevole)

Nel Rolling WFO, un periodo di train di lunghezza fissa "scorre" attraverso i dati:

Finestra 1: Train [2024-01..06]  Test [2024-07..09]
Finestra 2: Train [2024-04..09]  Test [2024-10..12]
Finestra 3: Train [2024-07..12]  Test [2025-01..03]
Finestra 4: Train [2024-10..2025-03]  Test [2025-04..06]
Finestra 5: Train [2025-01..06]  Test [2025-07..09]

Vantaggi:

  • Si adatta al regime di mercato attuale
  • Tempo di ottimizzazione costante
  • I dati vecchi e irrilevanti non influenzano i risultati

Svantaggi:

  • Meno dati per il training — maggiore varianza dei parametri ottimali
  • Sensibile alla scelta della lunghezza della finestra
  • Può "dimenticare" eventi rari ma importanti (flash crash)

Combinatorial Purged Cross-Validation (CPCV)

Visualizzazione della cross-validation purged combinatoriale

Un metodo avanzato proposto da Marcos Lopez de Prado. I dati vengono divisi in NN gruppi, da cui kk vengono selezionati per il test. La differenza chiave rispetto alla cross-validation standard è il purging (rimozione dei dati al confine train/test) e l'embargo (un ulteriore gap per prevenire la fuga di dati):

Numero di combinazioni=(Nk)\text{Numero di combinazioni} = \binom{N}{k}

Con N=10,k=2N = 10, k = 2: 45 combinazioni train/test. Ogni combinazione produce un risultato OOS, e la stima finale è la media di tutte le combinazioni.

from itertools import combinations
import numpy as np

def cpcv_splits(n_groups: int, k_test: int, purge_pct: float = 0.01):
    """
    Genera split CPCV con purging.

    Args:
        n_groups: numero di gruppi
        k_test: numero di gruppi di test in ogni split
        purge_pct: frazione di dati per il purging (al confine train/test)
    """
    groups = list(range(n_groups))
    splits = []

    for test_groups in combinations(groups, k_test):
        train_groups = [g for g in groups if g not in test_groups]
        splits.append({
            "train": train_groups,
            "test": list(test_groups),
            "purge_groups": _get_purge_groups(train_groups, test_groups),
        })

    return splits

def _get_purge_groups(train, test):
    """Gruppi al confine train/test per il purging."""
    purge = set()
    for t in test:
        if t - 1 in train:
            purge.add(t - 1)
        if t + 1 in train:
            purge.add(t + 1)
    return list(purge)

CPCV è migliore del Rolling WFO quando i dati sono scarsi, ma computazionalmente più costoso. Per una strategia con 21 parametri e 25 mesi di dati, consigliamo di iniziare con il Rolling WFO e usare CPCV come controllo aggiuntivo.

Parametri Chiave della WFO

Visualizzazione dei parametri chiave della WFO

Lunghezza del Periodo di Train

Un train troppo corto — dati insufficienti per un'ottimizzazione affidabile. Troppo lungo — i dati vecchi diluiscono i pattern attuali.

Regola empirica: il train deve contenere almeno 200-300 trade. Se la strategia effettua 2 trade al giorno:

Tmin=300 trade2 trade/giorno=150 giorni5 mesiT_{min} = \frac{300\ \text{trade}}{2\ \text{trade/giorno}} = 150\ \text{giorni} \approx 5\ \text{mesi}

Per le crypto con i loro cambi di regime, consigliamo non più di 6-12 mesi per la finestra rolling.

Lunghezza del Periodo di Test

Il periodo di test deve essere sufficiente per una valutazione statisticamente significativa, ma non troppo lungo — altrimenti i parametri hanno il tempo di degradarsi.

Regola: test = 20-33% del train. Se train = 6 mesi, test = 1,5-2 mesi.

Sovrapposizione

Nel Rolling WFO, le finestre possono sovrapporsi. La sovrapposizione aumenta il numero di punti dati OOS ma introduce correlazione tra le stime:

Senza sovrapposizione:
  Train [01..06]Test [07..09]
  Train [07..12]Test [01..03]

Con sovrapposizione del 50%:
  Train [01..06]Test [07..09]
  Train [04..09]Test [10..12]
  Train [07..12]Test [01..03]

Raccomandazione: sovrapposizione del 50% sul periodo di train — un buon equilibrio tra il numero di finestre e l'indipendenza delle stime.

Frequenza di Ri-ottimizzazione

Determina ogni quanto si ricalcolano i parametri. Nel mercato crypto, la frequenza ottimale è ogni 1-3 mesi. Una ri-ottimizzazione più frequente aumenta il rischio di overfitting sul rumore; meno frequente — il rischio di obsolescenza dei parametri.

Walk-Forward Efficiency Ratio e Tasso di Degradazione

Visualizzazione del Walk-Forward Efficiency Ratio e della degradazione

Walk-Forward Efficiency Ratio (WFER)

La metrica chiave della WFO — il rapporto tra i rendimenti OOS e IS:

WFER=PnLOOSPnLIS\text{WFER} = \frac{\text{PnL}_{OOS}}{\text{PnL}_{IS}}

Interpretazione:

WFER Interpretazione
> 0.8 Robustezza eccellente. I parametri si trasferiscono ai nuovi dati.
0.5 — 0.8 Robustezza accettabile. La strategia funziona ma con degradazione.
0.3 — 0.5 Caso limite. Probabile overfitting parziale.
< 0.3 Overfitting. I parametri sono adattati ai dati IS.
< 0 La strategia è non redditizia OOS. Overfitting completo o errore logico.

Se WFER < 0.5 — la strategia è molto probabilmente in overfitting. Questo è il nostro filtro primario.

Tasso di Degradazione

Mostra quanto velocemente i parametri ottimali perdono efficacia nel tempo:

Tasso di degradazione=d(OOS PnL)dt\text{Tasso di degradazione} = \frac{d(\text{OOS PnL})}{dt}

In pratica: dividere il periodo di test in sotto-intervalli e tracciare la dinamica del PnL:

def degradation_rate(oos_returns: np.ndarray, n_subperiods: int = 4) -> float:
    """
    Stima il tasso di degradazione dei parametri.

    Divide il periodo OOS in sotto-intervalli e calcola la pendenza
    della regressione lineare del PnL rispetto al numero del sotto-intervallo.

    Returns:
        slope: negativo = degradazione, positivo = miglioramento
    """
    chunk_size = len(oos_returns) // n_subperiods
    subperiod_pnls = []

    for i in range(n_subperiods):
        start = i * chunk_size
        end = start + chunk_size
        sub_pnl = np.sum(oos_returns[start:end])
        subperiod_pnls.append(sub_pnl)

    x = np.arange(n_subperiods)
    slope = np.polyfit(x, subperiod_pnls, 1)[0]

    return slope

Se il tasso di degradazione è fortemente negativo — i parametri diventano obsoleti rapidamente e occorre una ri-ottimizzazione più frequente o un periodo di train più breve.

Implementazione Completa del Pipeline WFO in Python

Visualizzazione dell'architettura del pipeline WFO

import numpy as np
import pandas as pd
from dataclasses import dataclass, field
from typing import Callable, List, Optional
import warnings

@dataclass
class WFOWindow:
    """Una singola finestra walk-forward."""
    window_id: int
    train_start: int         # indice di inizio del train
    train_end: int           # indice di fine del train (esclusivo)
    test_start: int          # indice di inizio del test
    test_end: int            # indice di fine del test (esclusivo)
    best_params: dict = field(default_factory=dict)
    is_pnl: float = 0.0     # PnL in-sample
    oos_pnl: float = 0.0    # PnL out-of-sample
    oos_returns: np.ndarray = field(default_factory=lambda: np.array([]))
    wfer: float = 0.0       # walk-forward efficiency ratio

@dataclass
class WFOResult:
    """Risultato dell'intera WFO."""
    windows: List[WFOWindow]
    aggregate_oos_pnl: float
    aggregate_is_pnl: float
    wfer: float
    degradation_rate: float
    oos_equity: np.ndarray
    oos_sharpe: float
    oos_max_dd: float
    n_windows: int
    passed: bool             # se la strategia ha superato il filtro

class WalkForwardOptimizer:
    """
    Pipeline di Walk-Forward Optimization.

    Supporta le modalità anchored (espansione) e rolling (scorrevole).
    """

    def __init__(
        self,
        data: np.ndarray,
        optimize_fn: Callable,
        evaluate_fn: Callable,
        mode: str = "rolling",         # "rolling" o "anchored"
        train_size: int = 180,         # giorni
        test_size: int = 60,           # giorni
        step_size: int = 60,           # passo della finestra, giorni
        min_trades: int = 30,          # numero minimo di trade nell'OOS
        wfer_threshold: float = 0.5,   # soglia WFER per accettare/rifiutare
    ):
        self.data = data
        self.optimize_fn = optimize_fn
        self.evaluate_fn = evaluate_fn
        self.mode = mode
        self.train_size = train_size
        self.test_size = test_size
        self.step_size = step_size
        self.min_trades = min_trades
        self.wfer_threshold = wfer_threshold

    def generate_windows(self) -> List[WFOWindow]:
        """Genera le finestre walk-forward."""
        n = len(self.data)
        windows = []
        window_id = 0

        if self.mode == "rolling":
            start = 0
            while start + self.train_size + self.test_size <= n:
                w = WFOWindow(
                    window_id=window_id,
                    train_start=start,
                    train_end=start + self.train_size,
                    test_start=start + self.train_size,
                    test_end=min(start + self.train_size + self.test_size, n),
                )
                windows.append(w)
                start += self.step_size
                window_id += 1

        elif self.mode == "anchored":
            train_end = self.train_size
            while train_end + self.test_size <= n:
                w = WFOWindow(
                    window_id=window_id,
                    train_start=0,
                    train_end=train_end,
                    test_start=train_end,
                    test_end=min(train_end + self.test_size, n),
                )
                windows.append(w)
                train_end += self.step_size
                window_id += 1

        return windows

    def run(self) -> WFOResult:
        """Esegue il pipeline WFO completo."""
        windows = self.generate_windows()
        all_oos_returns = []

        for w in windows:
            train_data = self.data[w.train_start:w.train_end]
            test_data = self.data[w.test_start:w.test_end]

            best_params, is_pnl = self.optimize_fn(train_data)
            w.best_params = best_params
            w.is_pnl = is_pnl

            oos_pnl, oos_returns = self.evaluate_fn(test_data, best_params)
            w.oos_pnl = oos_pnl
            w.oos_returns = oos_returns

            if is_pnl != 0:
                w.wfer = oos_pnl / is_pnl
            else:
                w.wfer = 0.0

            all_oos_returns.extend(oos_returns)

        all_oos = np.array(all_oos_returns)
        oos_equity = np.cumprod(1 + all_oos)
        peak = np.maximum.accumulate(oos_equity)
        max_dd = ((oos_equity - peak) / peak).min()

        aggregate_is = sum(w.is_pnl for w in windows)
        aggregate_oos = sum(w.oos_pnl for w in windows)
        wfer = aggregate_oos / aggregate_is if aggregate_is != 0 else 0

        if np.std(all_oos) > 0:
            oos_sharpe = np.mean(all_oos) / np.std(all_oos) * np.sqrt(252)
        else:
            oos_sharpe = 0

        deg_rate = self._degradation_rate(windows)

        passed = wfer >= self.wfer_threshold and aggregate_oos > 0

        return WFOResult(
            windows=windows,
            aggregate_oos_pnl=aggregate_oos,
            aggregate_is_pnl=aggregate_is,
            wfer=wfer,
            degradation_rate=deg_rate,
            oos_equity=oos_equity,
            oos_sharpe=oos_sharpe,
            oos_max_dd=max_dd,
            n_windows=len(windows),
            passed=passed,
        )

    def _degradation_rate(self, windows: List[WFOWindow]) -> float:
        """Pendenza del PnL OOS attraverso i numeri di finestra."""
        if len(windows) < 3:
            return 0.0
        pnls = [w.oos_pnl for w in windows]
        x = np.arange(len(pnls))
        slope = np.polyfit(x, pnls, 1)[0]
        return slope

Esempio di Utilizzo

import numpy as np

np.random.seed(42)
prices = 100 * np.cumprod(1 + np.random.normal(0.0002, 0.02, 750))

def my_optimize(train_data):
    """
    Ottimizza la strategia sui dati di train.
    Restituisce (best_params, is_pnl).
    """
    best_pnl = -np.inf
    best_params = {}

    for fast in range(5, 30, 5):
        for slow in range(20, 100, 10):
            if fast >= slow:
                continue
            pnl, _ = _run_strategy(train_data, fast, slow)
            if pnl > best_pnl:
                best_pnl = pnl
                best_params = {"fast": fast, "slow": slow}

    return best_params, best_pnl

def my_evaluate(test_data, params):
    """
    Valuta la strategia sui dati di test con parametri fissi.
    Restituisce (oos_pnl, oos_returns).
    """
    pnl, returns = _run_strategy(test_data, params["fast"], params["slow"])
    return pnl, returns

def _run_strategy(data, fast_period, slow_period):
    """Semplice strategia di incrocio MA."""
    fast_ma = pd.Series(data).rolling(fast_period).mean().values
    slow_ma = pd.Series(data).rolling(slow_period).mean().values

    position = 0
    returns = []

    for i in range(slow_period, len(data) - 1):
        if fast_ma[i] > slow_ma[i] and position <= 0:
            position = 1
        elif fast_ma[i] < slow_ma[i] and position >= 0:
            position = -1

        daily_ret = (data[i + 1] - data[i]) / data[i]
        returns.append(position * daily_ret)

    total_pnl = np.sum(returns)
    return total_pnl, np.array(returns)

wfo = WalkForwardOptimizer(
    data=prices,
    optimize_fn=my_optimize,
    evaluate_fn=my_evaluate,
    mode="rolling",
    train_size=180,    # 6 mesi
    test_size=60,      # 2 mesi
    step_size=60,      # passo = test
)

result = wfo.run()

print(f"Finestre: {result.n_windows}")
print(f"OOS PnL: {result.aggregate_oos_pnl:.4f}")
print(f"IS PnL:  {result.aggregate_is_pnl:.4f}")
print(f"WFER:    {result.wfer:.3f}")
print(f"OOS Sharpe: {result.oos_sharpe:.2f}")
print(f"OOS MaxDD:  {result.oos_max_dd:.2%}")
print(f"Degradazione: {result.degradation_rate:.5f}")
print(f"Superato:  {result.passed}")

for w in result.windows:
    print(f"  Finestra {w.window_id}: IS={w.is_pnl:.4f} OOS={w.oos_pnl:.4f} "
          f"WFER={w.wfer:.2f} params={w.best_params}")

Interpretazione dei Risultati: Quando Fidarsi, Quando Rifiutare

Strategia che Supera la WFO

Se WFER >= 0.5 in tutte le finestre, il PnL OOS è positivo e stabile:

Finestra 0: IS=0.0812  OOS=0.0531  WFER=0.65  params={'fast': 10, 'slow': 50}
Finestra 1: IS=0.0744  OOS=0.0489  WFER=0.66  params={'fast': 10, 'slow': 50}
Finestra 2: IS=0.0698  OOS=0.0401  WFER=0.57  params={'fast': 15, 'slow': 50}
Finestra 3: IS=0.0823  OOS=0.0512  WFER=0.62  params={'fast': 10, 'slow': 60}
Finestra 4: IS=0.0756  OOS=0.0478  WFER=0.63  params={'fast': 10, 'slow': 50}
→ WFER aggregato: 0.63, tutte le finestre > 0.5, parametri stabili

Segnali positivi:

  • WFER stabile tra le finestre (nessun salto brusco)
  • Parametri simili tra le finestre (fast = 10-15, slow = 50-60)
  • PnL OOS positivo nella maggior parte delle finestre
  • Tasso di degradazione vicino a zero

Strategia che Fallisce la WFO

Finestra 0: IS=0.2341  OOS=-0.0312  WFER=-0.13  params={'fast': 5, 'slow': 95}
Finestra 1: IS=0.1987  OOS=0.0089   WFER=0.04   params={'fast': 25, 'slow': 30}
Finestra 2: IS=0.2156  OOS=-0.0567  WFER=-0.26  params={'fast': 10, 'slow': 90}
Finestra 3: IS=0.1834  OOS=0.0234   WFER=0.13   params={'fast': 20, 'slow': 40}
→ WFER aggregato: -0.07, IS alto, OOS vicino a zero → overfitting

Segnali di overfitting:

  • IS PnL alto, OOS PnL basso/negativo — overfitting classico
  • Parametri molto variabili tra le finestre — nessun ottimo stabile
  • WFER < 0.3 nella maggior parte delle finestre — i parametri non si trasferiscono
  • Tasso di degradazione fortemente negativo — degradazione rapida

Per ulteriori informazioni sull'analisi della stabilità dei parametri — nell'articolo Analisi del Plateau. Se l'ottimo è "tagliente" (cade bruscamente con piccole variazioni dei parametri) — questo è un ulteriore segnale di overfitting.

Specificità della WFO per le Criptovalute

Visualizzazione delle specificità WFO per le criptovalute

Le criptovalute creano problemi unici per la WFO che non esistono nei mercati tradizionali.

Cambi di Regime

Il mercato crypto passa tra regimi radicalmente diversi: trend rialzista, trend ribassista, laterale con alta/bassa volatilità. I parametri ottimali in un regime possono essere non redditizi in un altro.

Soluzione: usare il Rolling WFO (non anchored) con una finestra di 4-6 mesi. Questo consente di "dimenticare" i vecchi regimi. Inoltre — raggruppare i dati per volatilità ed eseguire la WFO separatamente per ogni cluster.

Breve Storia

La maggior parte degli altcoin ha meno di 3 anni di storia di trading. Con train = 6 mesi e test = 2 mesi, si otterranno solo 4-5 finestre — una stima statisticamente debole.

Soluzione: usare CPCV al posto di o in aggiunta al Rolling WFO. CPCV genera più combinazioni dagli stessi dati. Per 10 gruppi e k=2: 45 combinazioni invece di 4-5 finestre.

Cambiamenti Strutturali della Liquidità

La liquidità delle coppie crypto è non stazionaria: una coppia può essere liquida per 6 mesi, poi i volumi scendono di 10 volte. I parametri ottimizzati su un mercato liquido non funzionano su uno illiquido.

Soluzione: aggiungere un filtro di liquidità al pipeline WFO. Escludere le finestre in cui il volume giornaliero medio è sotto una soglia. Verificare che la liquidità nel periodo di test sia comparabile al periodo di train.

Impatto del Tasso di Finanziamento

Per le strategie futures con leva, i tassi di finanziamento possono cambiare fondamentalmente i risultati OOS. Una strategia mostra +5% OOS in 2 mesi, ma a 10x di leva il finanziamento consuma il 3,6%.

Analisi dettagliata dell'impatto del finanziamento — nel nostro articolo I tassi di finanziamento distruggono la tua leva. Assicurarsi di tenere conto dei costi di finanziamento nella valutazione del PnL OOS nella WFO.

Strategie Multi-Parametro: Perché la WFO è Critica con 12+ Parametri

La maledizione della dimensionalità nell'ottimizzazione multi-parametro

Una strategia con 21 parametri (12 di separazione + 9 meta) su 25 mesi di dati da una singola coppia è un modello con uno spazio di ricerca colossale.

La Maledizione della Dimensionalità

Il numero di combinazioni di parametri cresce esponenzialmente con il numero di parametri:

Combinazioni=i=1nPi\text{Combinazioni} = \prod_{i=1}^{n} |P_i|

Se ognuno dei 21 parametri assume almeno 10 valori:

1021=10 sestilioni di combinazioni10^{21} = 10\ \text{sestilioni di combinazioni}

Anche con l'ottimizzazione bayesiana (dettagli in Coordinate Descent vs Bayesian), si esplora una frazione trascurabile dello spazio. La probabilità che l'ottimo trovato sia un artefatto del rumore piuttosto che un pattern reale cresce con il numero di parametri.

Formula di Bonferroni per Confronti Multipli

Se si testano MM combinazioni di parametri, la probabilità di una falsa "scoperta" (trovare un buon risultato per caso):

P(falsa scoperta)=1(1α)M1eαMP(\text{falsa scoperta}) = 1 - (1 - \alpha)^M \approx 1 - e^{-\alpha M}

Con α=0.05\alpha = 0.05 e M=10000M = 10000 combinazioni provate:

P1e5001.0P \approx 1 - e^{-500} \approx 1.0

Si è garantiti di trovare parametri "funzionanti" — che in realtà sono adattati al rumore. Senza WFO, non c'è modo di distinguere un reale vantaggio da un artefatto statistico.

Regola: Numero di Punti Dati OOS vs Numero di Parametri

Una regola empirica per fidarsi dei risultati WFO:

Trade OOSParametri>10\frac{\text{Trade OOS}}{\text{Parametri}} > 10

Per 21 parametri, servono almeno 210 trade OOS. Se la WFO ne genera di meno — il risultato non può essere considerato affidabile.

La strategia con +3342% PnL@ML: 21 parametri, 25 mesi di dati. Supponiamo 5 finestre OOS di 60 giorni, 2 trade/giorno — totale 5×60×2=6005 \times 60 \times 2 = 600 trade OOS. Il rapporto 600/21=28.6600/21 = 28.6 — accettabile, ma solo se WFER > 0.5.

Integrazione della WFO con Optuna

Ottimizzazione bayesiana con integrazione Optuna

In ogni finestra WFO, è necessario ottimizzare i parametri. Per 21 parametri, la ricerca a griglia è impossibile, la coordinate descent è inefficiente. La scelta ottimale è l'ottimizzazione bayesiana tramite Optuna.

import optuna
from optuna.samplers import TPESampler

def optuna_optimize(train_data: np.ndarray, n_trials: int = 500) -> tuple:
    """
    Ottimizza i parametri della strategia usando Optuna.
    Usato all'interno di ogni finestra WFO.
    """

    def objective(trial):
        fast = trial.suggest_int("fast_period", 3, 50)
        slow = trial.suggest_int("slow_period", 20, 200)
        atr_period = trial.suggest_int("atr_period", 5, 50)
        atr_mult = trial.suggest_float("atr_multiplier", 0.5, 4.0)
        rsi_period = trial.suggest_int("rsi_period", 5, 30)
        rsi_upper = trial.suggest_int("rsi_upper", 60, 85)
        rsi_lower = trial.suggest_int("rsi_lower", 15, 40)
        vol_window = trial.suggest_int("vol_window", 10, 100)
        position_size = trial.suggest_float("position_size", 0.1, 1.0)
        take_profit = trial.suggest_float("take_profit", 0.005, 0.05)
        stop_loss = trial.suggest_float("stop_loss", 0.003, 0.03)
        trailing_pct = trial.suggest_float("trailing_pct", 0.002, 0.02)

        if fast >= slow:
            return -1e6  # combinazione non valida

        params = {
            "fast_period": fast, "slow_period": slow,
            "atr_period": atr_period, "atr_multiplier": atr_mult,
            "rsi_period": rsi_period, "rsi_upper": rsi_upper,
            "rsi_lower": rsi_lower, "vol_window": vol_window,
            "position_size": position_size,
            "take_profit": take_profit, "stop_loss": stop_loss,
            "trailing_pct": trailing_pct,
        }

        pnl, _ = run_strategy(train_data, params)

        _, returns = run_strategy(train_data, params)
        if len(returns) < 30 or np.std(returns) == 0:
            return -1e6
        sharpe = np.mean(returns) / np.std(returns) * np.sqrt(252)
        return sharpe

    optuna.logging.set_verbosity(optuna.logging.WARNING)

    study = optuna.create_study(
        direction="maximize",
        sampler=TPESampler(seed=42),
    )
    study.optimize(objective, n_trials=n_trials, show_progress_bar=False)

    best_params = study.best_params
    best_pnl, _ = run_strategy(train_data, best_params)

    return best_params, best_pnl

wfo = WalkForwardOptimizer(
    data=prices,
    optimize_fn=optuna_optimize,     # Optuna invece della ricerca a griglia
    evaluate_fn=my_evaluate,
    mode="rolling",
    train_size=180,
    test_size=60,
    step_size=60,
)

result = wfo.run()

Importante: all'interno della WFO, ottimizzare Sharpe, non PnL. L'ottimizzazione del PnL trova parametri che massimizzano il profitto su una specifica sequenza di trade. L'ottimizzazione dello Sharpe trova parametri con il miglior rapporto rendimento/rischio — sono più robusti OOS.

Confronto dettagliato di Optuna TPE con la coordinate descent — nell'articolo Coordinate Descent vs Bayesian.

Visualizzazione dei Risultati WFO

import matplotlib.pyplot as plt
import matplotlib.patches as mpatches

def plot_wfo_results(result: WFOResult, data: np.ndarray):
    """Visualizza i risultati della Walk-Forward Optimization."""
    fig, axes = plt.subplots(3, 1, figsize=(16, 14))

    ax = axes[0]
    ax.plot(result.oos_equity, color='#4FC3F7', linewidth=1.5)
    ax.axhline(1.0, color='#FF5252', linestyle='--', alpha=0.5, label='Break-even')
    ax.set_title(f'Curva di Equity OOS (WFER={result.wfer:.2f}, Sharpe={result.oos_sharpe:.2f})')
    ax.set_ylabel('Equity')
    ax.legend()
    ax.grid(True, alpha=0.3)

    ax = axes[1]
    wfers = [w.wfer for w in result.windows]
    colors = ['#69F0AE' if w >= 0.5 else '#FFAB40' if w >= 0.3 else '#FF5252'
              for w in wfers]
    ax.bar(range(len(wfers)), wfers, color=colors, edgecolor='#1A237E', alpha=0.8)
    ax.axhline(0.5, color='#E040FB', linestyle='--', label='Soglia (0.5)')
    ax.axhline(0, color='gray', linestyle='-', alpha=0.3)
    ax.set_title('Walk-Forward Efficiency Ratio per Finestra')
    ax.set_xlabel('Finestra')
    ax.set_ylabel('WFER')
    ax.legend()

    ax = axes[2]
    x = np.arange(len(result.windows))
    width = 0.35
    ax.bar(x - width/2, [w.is_pnl for w in result.windows],
           width, label='IS PnL', color='#7C4DFF', alpha=0.7)
    ax.bar(x + width/2, [w.oos_pnl for w in result.windows],
           width, label='OOS PnL', color='#4FC3F7', alpha=0.7)
    ax.set_title('PnL In-Sample vs Out-of-Sample')
    ax.set_xlabel('Finestra')
    ax.set_ylabel('PnL')
    ax.legend()
    ax.grid(True, alpha=0.3)

    plt.tight_layout()
    plt.savefig('wfo_results.png', dpi=150)
    plt.show()

Raccomandazioni Pratiche

Checklist Prima di Lanciare una Strategia in Produzione

1. Esegui la WFO (rolling + anchored)

Confronta i risultati di entrambe le modalità. Se il Rolling WFO fallisce ma l'Anchored passa — molto probabilmente la strategia funziona solo sui dati iniziali.

2. Verifica il WFER per ogni finestra

Non solo il WFER aggregato, ma ogni finestra singolarmente. Se 2 finestre su 6 hanno WFER < 0 — è un problema, anche se l'aggregato è > 0.5.

3. Confronta i parametri tra le finestre

Se i parametri ottimali "saltano" da una finestra all'altra — non c'è un vantaggio stabile. Usa l'Analisi del Plateau per verificare la stabilità dell'ottimo.

4. Controlla il tasso di degradazione

Un tasso di degradazione fortemente negativo = i parametri perdono efficacia rapidamente. Servono una ri-ottimizzazione più frequente o una revisione della strategia.

5. Applica il Monte Carlo bootstrap ai risultati OOS

Il PnL OOS aggregato è anche una stima puntuale. Applica il Monte Carlo bootstrap all'array dei rendimenti OOS per ottenere intervalli di confidenza.

6. Tieni conto dei costi

Il PnL OOS deve includere commissioni, slippage e tassi di finanziamento. Un bel PnL OOS senza costi è un'illusione. Maggiori dettagli — I tassi di finanziamento distruggono la tua leva.

Requisiti Minimi di Dati

Numero di parametri Trade OOS minimi Finestre WFO minime Dati minimi (2 trade/giorno)
2-5 50 3 ~6 mesi
6-10 100 4 ~12 mesi
11-15 150 5 ~18 mesi
16-21 210 6 ~24 mesi
22+ 300+ 8+ ~36+ mesi

La Strategia con 21 Parametri e 25 Mesi di Dati

Torniamo alla domanda dall'inizio dell'articolo: 21 parametri ottimizzati su 25 mesi di dati da una singola coppia. PnL@ML = +3342%. Come validare?

Passo 1. Rolling WFO: train = 8 mesi, test = 2 mesi, passo = 2 mesi. Si ottengono ~8 finestre.

Passo 2. Anchored WFO: primo train = 8 mesi, test = 2 mesi. Si ottengono ~8 finestre.

Passo 3. CPCV: 10 gruppi di ~2,5 mesi, k = 2. Si ottengono 45 combinazioni.

Passo 4. Per ogni metodo, verificare:

  • WFER >= 0.5?
  • Parametri stabili tra le finestre?
  • Tasso di degradazione accettabile?
  • Trade OOS / Parametri >= 10?

Passo 5. Monte Carlo bootstrap sui rendimenti OOS aggregati. PnL al 5° percentile > 0?

Se uno qualsiasi di questi test fallisce — la strategia con +3342% è molto probabilmente in overfitting. 21 parametri su 25 mesi di una singola coppia — questo è un rapporto parametri/dati estremamente elevato. Senza superare la WFO, non ci può essere fiducia.

Consigliamo inoltre di verificare l'efficienza della strategia tenendo conto del PnL per tempo attivo — questo rivelerà quale parte del +3342% è dovuta al tempo in posizione rispetto al reale vantaggio.

Conclusione

La Walk-Forward Optimization non è opzionale — è una necessità. È l'unico metodo che verifica sistematicamente la trasferibilità dei parametri a nuovi dati. Una singola divisione train/test è una lotteria. Un backtest completo su tutti i dati è un'illusione.

Conclusioni chiave:

  1. WFER < 0.5 = overfitting. Se il PnL out-of-sample è inferiore alla metà di quello in-sample — i parametri sono adattati.

  2. La stabilità dei parametri conta più del massimo. Parametri che rendono +15% in ogni finestra sono meglio di parametri che rendono +40% in una e -10% in un'altra.

  3. Rolling WFO per le crypto. I cambi di regime rendono l'Anchored WFO meno affidabile. Una finestra rolling di 4-6 mesi è l'equilibrio ottimale.

  4. Più parametri — requisiti più severi. 21 parametri richiedono almeno 210 trade OOS e 6+ finestre WFO. Senza questo, il risultato non può essere verificato.

  5. WFO + Monte Carlo bootstrap + Analisi del Plateau — tre livelli di protezione dall'overfitting. Ogni livello cattura ciò che gli altri mancano.

Una strategia che supera la WFO con WFER > 0.5 in tutte le finestre, parametri stabili e un 5° percentile bootstrap positivo — questa è una strategia di cui ci si può fidare con denaro reale. Tutto il resto è curve fitting con una bella curva di equity.


Link Utili

  1. Pardo, R. — The Evaluation and Optimization of Trading Strategies (Wiley)
  2. Lopez de Prado, M. — Advances in Financial Machine Learning, Chapter 12: Backtesting
  3. Bailey, D.H. et al. — The Probability of Backtest Overfitting
  4. Lopez de Prado, M. — The Combinatorial Purged Cross-Validation (CPCV)
  5. Aronson, D.R. — Evidence-Based Technical Analysis
  6. Optuna: A Next-generation Hyperparameter Optimization Framework
  7. Kevin Davey — Building Winning Algorithmic Trading Systems: Walk-Forward Analysis
  8. White, H. — A Reality Check for Data Snooping (2000)
  9. Harvey, C.R. & Liu, Y. — Backtesting (2015)
  10. NumPy — numpy.cumprod

Citazione

@article{soloviov2026walkforwardoptimization,
  author = {Soloviov, Eugen},
  title = {Walk-Forward Optimization: L'Unico Test Onesto per una Strategia},
  year = {2026},
  url = {https://marketmaker.cc/it/blog/post/walk-forward-optimization},
  version = {0.1.0},
  description = {Perché una singola divisione train/test non protegge dall'overfitting, come la walk-forward optimization verifica sistematicamente la robustezza dei parametri, e perché una strategia con +3342\% PnL@ML su 21 parametri è una bomba a orologeria senza WFO.}
}
Disclaimer: le informazioni fornite in questo articolo hanno solo scopo didattico e informativo e non costituiscono consulenza finanziaria, di investimento o di trading. Il trading di criptovalute comporta un rischio significativo di perdita.

Autori

Eugen Soloviov
Eugen Soloviov

Trading-systems engineer

Trading-systems engineer building bots since 2017: cross-exchange arbitrage (connected up to 30 venues), cointegration-based pairs arbitrage across spot and futures, scalping, news and sentiment-driven strategies, trend algorithms, and portfolio management and balancing algorithms. Also builds sub-millisecond order execution, big-data warehouses, backtesting engines, AI agents, and trading interfaces (incl. open-source profitmaker.cc). Stack: JS/TS, Python, Rust/Zig/Go, DevOps, backend, frontend, architecture.

Newsletter

Resta un Passo Avanti al Mercato

Iscriviti alla nostra newsletter per approfondimenti esclusivi sul trading con IA, analisi di mercato e aggiornamenti sulla piattaforma.

Rispettiamo la tua privacy. Annulla l'iscrizione in qualsiasi momento.