Walk-Forward Optimization: L'Unico Test Onesto per una Strategia
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

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

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)

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)

Un metodo avanzato proposto da Marcos Lopez de Prado. I dati vengono divisi in gruppi, da cui 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):
Con : 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

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

Walk-Forward Efficiency Ratio (WFER)
La metrica chiave della WFO — il rapporto tra i rendimenti OOS e 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:
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

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

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

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:
Se ognuno dei 21 parametri assume almeno 10 valori:
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 combinazioni di parametri, la probabilità di una falsa "scoperta" (trovare un buon risultato per caso):
Con e combinazioni provate:
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:
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 trade OOS. Il rapporto — accettabile, ma solo se WFER > 0.5.
Integrazione della WFO con 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:
-
WFER < 0.5 = overfitting. Se il PnL out-of-sample è inferiore alla metà di quello in-sample — i parametri sono adattati.
-
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.
-
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.
-
Più parametri — requisiti più severi. 21 parametri richiedono almeno 210 trade OOS e 6+ finestre WFO. Senza questo, il risultato non può essere verificato.
-
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
- Pardo, R. — The Evaluation and Optimization of Trading Strategies (Wiley)
- Lopez de Prado, M. — Advances in Financial Machine Learning, Chapter 12: Backtesting
- Bailey, D.H. et al. — The Probability of Backtest Overfitting
- Lopez de Prado, M. — The Combinatorial Purged Cross-Validation (CPCV)
- Aronson, D.R. — Evidence-Based Technical Analysis
- Optuna: A Next-generation Hyperparameter Optimization Framework
- Kevin Davey — Building Winning Algorithmic Trading Systems: Walk-Forward Analysis
- White, H. — A Reality Check for Data Snooping (2000)
- Harvey, C.R. & Liu, Y. — Backtesting (2015)
- 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.}
}
Autori
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.