Walk-Forward Optimization: Satu-Satunya Uji Strategi yang Jujur
Anda mengoptimalkan sebuah strategi. 12 parameter separasi, 9 meta-parameter — 21 total. Backtest selama 25 bulan pada satu pasang aset menunjukkan PnL +3342% pada MaxLev. Kurva ekuitas naik hampir tanpa drawdown. Sharpe di atas 3. Semuanya tampak sempurna.
Anda meluncurkan bot. Dua minggu kemudian, strategi kehilangan 18% modal. Sebulan kemudian — 34%. Parameter yang "berhasil" pada data historis ternyata disesuaikan dengan urutan peristiwa pasar tertentu. Anda tidak menemukan pola — Anda menghafal noise.
Ini adalah overfitting klasik. Dan satu-satunya cara sistematis untuk mendeteksinya sebelum masuk ke produksi adalah Walk-Forward Optimization (WFO).
Jebakan Pemisahan Train/Test Tunggal

Pendekatan standar: bagi data menjadi 70% train dan 30% test. Optimalkan pada train, verifikasi pada test. Jika hasilnya positif — luncurkan.
Masalahnya: ini adalah satu pengujian pada satu pemisahan. Hasilnya bergantung pada di mana Anda menarik batas. Geser batas sebulan — dan PnL out-of-sample bisa berubah dari +40% menjadi -15%.
Data: |===== 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%
Tiga pemisahan berbeda — tiga kesimpulan berbeda. Mana yang harus dipercaya? Tidak ada. Pemisahan train/test tunggal sama seperti estimasi titik tunggal yang masalah-masalahnya sudah kita bahas di Monte Carlo Bootstrap. Anda butuh bukan satu pemeriksaan, melainkan serangkaian pemeriksaan sistematis pada segmen data yang berurutan.
Inilah tepatnya mengapa Walk-Forward Optimization ada.
Apa Itu Walk-Forward Optimization

WFO adalah prosedur optimisasi dan verifikasi strategi secara berurutan pada jendela data yang meluncur (atau berkembang). Idenya: mensimulasikan proses trading nyata di mana Anda secara berkala mengoptimalkan ulang parameter pada data yang tersedia, lalu berdagang hingga optimisasi berikutnya.
Setiap "jendela" terdiri dari dua bagian:
- In-Sample (IS) — periode di mana parameter dioptimalkan
- Out-of-Sample (OOS) — periode di mana parameter yang ditemukan diuji tanpa penyesuaian
Sifat kunci: periode OOS tidak tumpang tindih dan secara kolektif mencakup bagian data yang signifikan. Kurva ekuitas yang dihasilkan dibangun hanya dari segmen OOS — ini adalah evaluasi strategi yang jujur.
Anchored WFO (Expanding Window)

Dalam anchored WFO, awal periode train bersifat tetap, dan ujungnya berkembang dengan setiap jendela:
Window 1: Train [2024-01] → Test [2024-04]
Window 2: Train [2024-01..04] → Test [2024-07] (train yang terus tumbuh)
Window 3: Train [2024-01..07] → Test [2024-10]
Window 4: Train [2024-01..10] → Test [2025-01]
Window 5: Train [2024-01..2025-01] → Test [2025-04]
Keunggulan:
- Setiap periode train berikutnya mengandung lebih banyak data — optimisasi lebih stabil
- Pola awal tidak hilang — selalu ada dalam set pelatihan
- Lebih mudah diimplementasikan
Kelemahan:
- Data lama mungkin "mengencerkan" pola terkini
- Jika pasar telah berubah secara struktural — data lama bersifat merugikan
- Periode train tumbuh tanpa batas, meningkatkan waktu optimisasi
Rolling WFO (Sliding Window)
Dalam rolling WFO, periode train dengan panjang tetap "meluncur" melintasi data:
Window 1: Train [2024-01..06] → Test [2024-07..09]
Window 2: Train [2024-04..09] → Test [2024-10..12]
Window 3: Train [2024-07..12] → Test [2025-01..03]
Window 4: Train [2024-10..2025-03] → Test [2025-04..06]
Window 5: Train [2025-01..06] → Test [2025-07..09]
Keunggulan:
- Beradaptasi dengan rezim pasar terkini
- Waktu optimisasi konstan
- Data lama yang tidak relevan tidak mempengaruhi hasil
Kelemahan:
- Data pelatihan lebih sedikit — varians parameter optimal lebih tinggi
- Sensitif terhadap pemilihan panjang jendela
- Mungkin "melupakan" peristiwa langka namun penting (flash crash)
Combinatorial Purged Cross-Validation (CPCV)

Metode lanjutan yang diusulkan oleh Marcos Lopez de Prado. Data dibagi menjadi kelompok, di mana dipilih untuk pengujian. Perbedaan kunci dari cross-validation standar adalah purging (menghapus data di batas train/test) dan embargo (celah tambahan untuk mencegah kebocoran data):
Dengan : 45 kombinasi train/test. Setiap kombinasi menghasilkan hasil OOS, dan estimasi akhir adalah rata-rata dari semua kombinasi.
from itertools import combinations
import numpy as np
def cpcv_splits(n_groups: int, k_test: int, purge_pct: float = 0.01):
"""
Generate CPCV splits with purging.
Args:
n_groups: number of groups
k_test: number of test groups in each split
purge_pct: fraction of data for purging (at the train/test boundary)
"""
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):
"""Groups at the train/test boundary for 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 lebih baik daripada rolling WFO ketika data langka, namun secara komputasi lebih mahal. Untuk strategi dengan 21 parameter dan 25 bulan data, kami merekomendasikan memulai dengan rolling WFO dan menggunakan CPCV sebagai pemeriksaan tambahan.
Parameter Kunci WFO

Panjang Periode Train
Terlalu pendek — data tidak cukup untuk optimisasi yang andal. Terlalu panjang — data lama mengencerkan pola terkini.
Aturan praktis: train harus mengandung setidaknya 200-300 transaksi. Jika strategi melakukan 2 transaksi per hari:
Untuk crypto dengan pergantian rezimnya, kami merekomendasikan tidak lebih dari 6-12 bulan untuk rolling window.
Panjang Periode Test
Periode test harus cukup untuk evaluasi yang signifikan secara statistik, tetapi tidak terlalu panjang — jika tidak, parameter sempat terdegradasi.
Aturan: test = 20-33% dari train. Jika train = 6 bulan, test = 1,5-2 bulan.
Overlap
Dalam rolling WFO, jendela dapat saling tumpang tindih. Overlap meningkatkan jumlah titik data OOS tetapi memperkenalkan korelasi antar estimasi:
Tanpa overlap:
Train [01..06] → Test [07..09]
Train [07..12] → Test [01..03]
Dengan overlap 50%:
Train [01..06] → Test [07..09]
Train [04..09] → Test [10..12]
Train [07..12] → Test [01..03]
Rekomendasi: overlap 50% pada periode train — keseimbangan yang baik antara jumlah jendela dan independensi estimasi.
Frekuensi Re-optimisasi
Menentukan seberapa sering Anda menghitung ulang parameter. Di pasar crypto, frekuensi optimal adalah setiap 1-3 bulan. Re-optimisasi lebih sering meningkatkan risiko overfitting pada noise; lebih jarang — risiko kedaluwarsa parameter.
Walk-Forward Efficiency Ratio dan Degradation Rate

Walk-Forward Efficiency Ratio (WFER)
Metrik kunci WFO — rasio return OOS terhadap return IS:
Interpretasi:
| WFER | Interpretasi |
|---|---|
| > 0,8 | Ketahanan sangat baik. Parameter berpindah ke data baru. |
| 0,5 — 0,8 | Ketahanan dapat diterima. Strategi berhasil tetapi dengan degradasi. |
| 0,3 — 0,5 | Kasus batas. Overfitting sebagian kemungkinan terjadi. |
| < 0,3 | Overfitting. Parameter disesuaikan dengan data IS. |
| < 0 | Strategi tidak menguntungkan OOS. Overfitting total atau kesalahan logika. |
Jika WFER < 0,5 — strategi kemungkinan besar overfit. Ini adalah filter utama kami.
Degradation Rate
Menunjukkan seberapa cepat parameter optimal kehilangan efektivitasnya seiring waktu:
Dalam praktik: bagi periode test menjadi sub-interval dan lacak dinamika PnL:
def degradation_rate(oos_returns: np.ndarray, n_subperiods: int = 4) -> float:
"""
Estimate parameter degradation rate.
Splits the OOS period into sub-intervals and computes the slope
of linear regression of PnL against sub-interval number.
Returns:
slope: negative = degradation, positive = improvement
"""
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
Jika degradation rate sangat negatif — parameter menjadi basi dengan cepat, dan Anda membutuhkan re-optimisasi lebih sering atau periode train yang lebih pendek.
Implementasi Pipeline WFO Lengkap dalam 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:
"""A single walk-forward window."""
window_id: int
train_start: int # train start index
train_end: int # train end index (exclusive)
test_start: int # test start index
test_end: int # test end index (exclusive)
best_params: dict = field(default_factory=dict)
is_pnl: float = 0.0 # in-sample PnL
oos_pnl: float = 0.0 # out-of-sample PnL
oos_returns: np.ndarray = field(default_factory=lambda: np.array([]))
wfer: float = 0.0 # walk-forward efficiency ratio
@dataclass
class WFOResult:
"""Result of the entire 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 # whether the strategy passed the filter
class WalkForwardOptimizer:
"""
Walk-Forward Optimization pipeline.
Supports anchored (expanding) and rolling (sliding) modes.
"""
def __init__(
self,
data: np.ndarray,
optimize_fn: Callable,
evaluate_fn: Callable,
mode: str = "rolling", # "rolling" or "anchored"
train_size: int = 180, # days
test_size: int = 60, # days
step_size: int = 60, # window step size, days
min_trades: int = 30, # min number of trades in OOS
wfer_threshold: float = 0.5, # WFER threshold for accept/reject
):
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]:
"""Generate walk-forward windows."""
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:
"""Run the full WFO pipeline."""
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:
"""Slope of OOS PnL across window numbers."""
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
Contoh Penggunaan
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):
"""
Optimize strategy on train data.
Returns (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):
"""
Evaluate strategy on test data with fixed parameters.
Returns (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):
"""Simple MA crossover strategy."""
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 months
test_size=60, # 2 months
step_size=60, # step = test
)
result = wfo.run()
print(f"Windows: {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"Degradation: {result.degradation_rate:.5f}")
print(f"Passed: {result.passed}")
for w in result.windows:
print(f" Window {w.window_id}: IS={w.is_pnl:.4f} OOS={w.oos_pnl:.4f} "
f"WFER={w.wfer:.2f} params={w.best_params}")
Menginterpretasikan Hasil: Kapan Percaya, Kapan Menolak
Strategi Lulus WFO
Jika WFER >= 0,5 di semua jendela, OOS PnL positif dan stabil:
Window 0: IS=0.0812 OOS=0.0531 WFER=0.65 params={'fast': 10, 'slow': 50}
Window 1: IS=0.0744 OOS=0.0489 WFER=0.66 params={'fast': 10, 'slow': 50}
Window 2: IS=0.0698 OOS=0.0401 WFER=0.57 params={'fast': 15, 'slow': 50}
Window 3: IS=0.0823 OOS=0.0512 WFER=0.62 params={'fast': 10, 'slow': 60}
Window 4: IS=0.0756 OOS=0.0478 WFER=0.63 params={'fast': 10, 'slow': 50}
→ Aggregate WFER: 0.63, all windows > 0.5, parameters are stable
Tanda-tanda positif:
- WFER stabil di semua jendela (tidak ada lonjakan tajam)
- Parameter serupa antar jendela (fast = 10-15, slow = 50-60)
- OOS PnL positif di sebagian besar jendela
- Degradation rate mendekati nol
Strategi Gagal WFO
Window 0: IS=0.2341 OOS=-0.0312 WFER=-0.13 params={'fast': 5, 'slow': 95}
Window 1: IS=0.1987 OOS=0.0089 WFER=0.04 params={'fast': 25, 'slow': 30}
Window 2: IS=0.2156 OOS=-0.0567 WFER=-0.26 params={'fast': 10, 'slow': 90}
Window 3: IS=0.1834 OOS=0.0234 WFER=0.13 params={'fast': 20, 'slow': 40}
→ Aggregate WFER: -0.07, IS is high, OOS is near zero → overfitting
Tanda-tanda overfitting:
- IS PnL tinggi, OOS PnL rendah/negatif — overfitting klasik
- Parameter bervariasi signifikan antar jendela — tidak ada optimum yang stabil
- WFER < 0,3 di sebagian besar jendela — parameter tidak berpindah
- Degradation rate sangat negatif — degradasi cepat
Lebih lanjut tentang analisis stabilitas parameter — dalam artikel Analisis Plateau. Jika optimum "tajam" (turun curam dengan perubahan parameter kecil) — ini adalah sinyal overfitting tambahan.
Spesifikasi WFO untuk Cryptocurrency

Cryptocurrency menciptakan masalah unik untuk WFO yang tidak ada di pasar tradisional.
Pergantian Rezim
Pasar crypto beralih antara rezim yang sangat berbeda: tren naik, tren turun, sideways dengan volatilitas tinggi/rendah. Parameter yang optimal di satu rezim bisa tidak menguntungkan di rezim lain.
Solusi: gunakan rolling WFO (bukan anchored) dengan jendela 4-6 bulan. Ini memungkinkan "melupakan" rezim lama. Selain itu — kelompokkan data berdasarkan volatilitas dan jalankan WFO secara terpisah untuk setiap kelompok.
Sejarah yang Pendek
Sebagian besar altcoin memiliki riwayat perdagangan kurang dari 3 tahun. Dengan train = 6 bulan dan test = 2 bulan, Anda hanya akan mendapatkan 4-5 jendela — estimasi yang lemah secara statistik.
Solusi: gunakan CPCV sebagai pengganti atau tambahan rolling WFO. CPCV menghasilkan lebih banyak kombinasi dari data yang sama. Untuk 10 kelompok dan k=2: 45 kombinasi dibandingkan 4-5 jendela.
Perubahan Likuiditas Struktural
Likuiditas pasangan crypto bersifat non-stasioner: sebuah pasangan bisa likuid selama 6 bulan, lalu volumenya turun 10 kali lipat. Parameter yang dioptimalkan pada pasar likuid tidak bekerja pada pasar illikuid.
Solusi: tambahkan filter likuiditas ke pipeline WFO. Kecualikan jendela di mana rata-rata volume harian di bawah ambang batas. Verifikasi bahwa likuiditas dalam periode test sebanding dengan periode train.
Dampak Funding Rate
Untuk strategi futures berleverage, funding rate dapat secara fundamental mengubah hasil OOS. Strategi menunjukkan +5% OOS selama 2 bulan, tetapi pada leverage 10x, funding memakan 3,6%.
Analisis terperinci tentang dampak funding — dalam artikel kami Funding rates kill your leverage. Pastikan untuk memperhitungkan biaya funding saat mengevaluasi OOS PnL dalam WFO.
Strategi Multi-Parameter: Mengapa WFO Kritis dengan 12+ Parameter

Strategi dengan 21 parameter (12 separasi + 9 meta) pada 25 bulan data dari satu pasang aset adalah model dengan ruang pencarian yang sangat besar.
Kutukan Dimensionalitas
Jumlah kombinasi parameter tumbuh secara eksponensial dengan jumlah parameter:
Jika masing-masing dari 21 parameter mengambil setidaknya 10 nilai:
Bahkan dengan optimisasi Bayesian (rincian dalam Coordinate Descent vs Bayesian), Anda menjelajahi sebagian kecil dari ruang tersebut. Probabilitas bahwa optimum yang ditemukan adalah artefak noise daripada pola nyata tumbuh seiring jumlah parameter.
Formula Bonferroni untuk Perbandingan Berganda
Jika Anda menguji kombinasi parameter, probabilitas "penemuan" palsu (menemukan hasil yang bagus secara kebetulan):
Pada dan kombinasi yang dicoba:
Anda dijamin menemukan parameter yang "berhasil" — yang sebenarnya disesuaikan dengan noise. Tanpa WFO, Anda tidak punya cara untuk membedakan edge nyata dari artefak statistik.
Aturan: Jumlah Titik Data OOS vs Jumlah Parameter
Aturan praktis untuk mempercayai hasil WFO:
Untuk 21 parameter, Anda membutuhkan setidaknya 210 transaksi OOS. Jika WFO Anda menghasilkan lebih sedikit — hasilnya tidak dapat dipercaya.
Strategi dengan +3342% PnL@ML: 21 parameter, 25 bulan data. Misalkan 5 jendela OOS selama 60 hari, 2 transaksi/hari — total transaksi OOS. Rasio — dapat diterima, tetapi hanya jika WFER > 0,5.
Mengintegrasikan WFO dengan Optuna

Dalam setiap jendela WFO, Anda perlu mengoptimalkan parameter. Untuk 21 parameter, pencarian grid tidak mungkin, coordinate descent tidak efisien. Pilihan optimal adalah optimisasi Bayesian melalui Optuna.
import optuna
from optuna.samplers import TPESampler
def optuna_optimize(train_data: np.ndarray, n_trials: int = 500) -> tuple:
"""
Optimize strategy parameters using Optuna.
Used inside each WFO window.
"""
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 # invalid combination
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 instead of grid search
evaluate_fn=my_evaluate,
mode="rolling",
train_size=180,
test_size=60,
step_size=60,
)
result = wfo.run()
Penting: di dalam WFO, optimalkan Sharpe, bukan PnL. Optimisasi PnL menemukan parameter yang memaksimalkan keuntungan pada urutan transaksi tertentu. Optimisasi Sharpe menemukan parameter dengan rasio return-to-risk terbaik — mereka lebih tangguh secara OOS.
Perbandingan terperinci Optuna TPE dengan coordinate descent — dalam artikel Coordinate Descent vs Bayesian.
Memvisualisasikan Hasil WFO
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
def plot_wfo_results(result: WFOResult, data: np.ndarray):
"""Visualize Walk-Forward Optimization results."""
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'OOS Equity Curve (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='Threshold (0.5)')
ax.axhline(0, color='gray', linestyle='-', alpha=0.3)
ax.set_title('Walk-Forward Efficiency Ratio by Window')
ax.set_xlabel('Window')
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('In-Sample vs Out-of-Sample PnL')
ax.set_xlabel('Window')
ax.set_ylabel('PnL')
ax.legend()
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig('wfo_results.png', dpi=150)
plt.show()
Rekomendasi Praktis
Daftar Periksa Sebelum Meluncurkan Strategi ke Produksi
1. Jalankan WFO (rolling + anchored)
Bandingkan hasil kedua mode. Jika rolling WFO gagal tetapi anchored lulus — kemungkinan besar strategi hanya bekerja pada data awal.
2. Periksa WFER untuk setiap jendela
Bukan hanya aggregate WFER, tetapi setiap jendela secara individual. Jika 2 dari 6 jendela memiliki WFER < 0 — itu adalah masalah, bahkan jika aggregate > 0,5.
3. Bandingkan parameter antar jendela
Jika parameter optimal "melompat" dari jendela ke jendela — tidak ada edge yang stabil. Gunakan Analisis Plateau untuk memverifikasi stabilitas optimum.
4. Periksa degradation rate
Degradation rate yang sangat negatif = parameter kehilangan efektivitas dengan cepat. Anda membutuhkan re-optimisasi lebih sering atau perombakan strategi.
5. Terapkan Monte Carlo bootstrap pada hasil OOS
Aggregate OOS PnL juga merupakan estimasi titik tunggal. Terapkan Monte Carlo bootstrap pada larik return OOS untuk mendapatkan interval kepercayaan.
6. Perhitungkan biaya
OOS PnL harus mencakup komisi, slippage, dan funding rate. OOS PnL yang bagus tanpa biaya adalah ilusi. Detail lebih lanjut — Funding rates kill your leverage.
Persyaratan Data Minimum
| Jumlah parameter | Min transaksi OOS | Min jendela WFO | Min data (2 transaksi/hari) |
|---|---|---|---|
| 2-5 | 50 | 3 | ~6 bulan |
| 6-10 | 100 | 4 | ~12 bulan |
| 11-15 | 150 | 5 | ~18 bulan |
| 16-21 | 210 | 6 | ~24 bulan |
| 22+ | 300+ | 8+ | ~36+ bulan |
Strategi dengan 21 Parameter dan 25 Bulan Data
Mari kembali ke pertanyaan dari awal artikel: 21 parameter yang dioptimalkan pada 25 bulan data dari satu pasang aset. PnL@ML = +3342%. Bagaimana cara memvalidasinya?
Langkah 1. Rolling WFO: train = 8 bulan, test = 2 bulan, step = 2 bulan. Kita mendapat ~8 jendela.
Langkah 2. Anchored WFO: train pertama = 8 bulan, test = 2 bulan. Kita mendapat ~8 jendela.
Langkah 3. CPCV: 10 kelompok ~2,5 bulan, k = 2. Kita mendapat 45 kombinasi.
Langkah 4. Untuk setiap metode, verifikasi:
- WFER >= 0,5?
- Parameter stabil antar jendela?
- Degradation rate dapat diterima?
- Transaksi OOS / Parameter >= 10?
Langkah 5. Monte Carlo bootstrap pada aggregate return OOS. PnL persentil ke-5 > 0?
Jika salah satu dari pengujian ini gagal — strategi dengan +3342% kemungkinan besar overfit. 21 parameter pada 25 bulan dari satu pasang aset — ini adalah rasio parameter-terhadap-data yang sangat tinggi. Tanpa lulus WFO, tidak ada kepercayaan.
Kami juga merekomendasikan memeriksa efisiensi strategi dengan memperhitungkan PnL berdasarkan waktu aktif — ini akan mengungkapkan berapa bagian dari +3342% yang disebabkan oleh waktu dalam posisi versus edge nyata.
Kesimpulan
Walk-Forward Optimization bukan opsional — ini adalah keharusan. Ini adalah satu-satunya metode yang secara sistematis memverifikasi kemampuan transfer parameter ke data baru. Pemisahan train/test tunggal adalah lotere. Backtest penuh pada semua data adalah penipuan diri sendiri.
Poin-poin kunci:
-
WFER < 0,5 = overfitting. Jika OOS PnL kurang dari setengah IS — parameternya disesuaikan.
-
Stabilitas parameter lebih penting daripada maksimum. Parameter yang menghasilkan +15% di setiap jendela lebih baik daripada parameter yang menghasilkan +40% di satu jendela dan -10% di jendela lain.
-
Rolling WFO untuk crypto. Pergantian rezim membuat anchored WFO kurang andal. Rolling window 4-6 bulan adalah keseimbangan yang optimal.
-
Lebih banyak parameter — persyaratan lebih ketat. 21 parameter membutuhkan setidaknya 210 transaksi OOS dan 6+ jendela WFO. Tanpa ini, hasilnya tidak dapat diverifikasi.
-
WFO + Monte Carlo bootstrap + Analisis Plateau — tiga lapisan perlindungan overfitting. Setiap lapisan menangkap apa yang terlewat oleh yang lain.
Strategi yang lulus WFO dengan WFER > 0,5 di semua jendela, parameter stabil, dan bootstrap persentil ke-5 yang positif — itulah strategi yang dapat Anda percayai dengan uang nyata. Semua yang lainnya adalah curve fitting dengan kurva ekuitas yang indah.
Tautan Berguna
- 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
Kutipan
@article{soloviov2026walkforwardoptimization,
author = {Soloviov, Eugen},
title = {Walk-Forward Optimization: Satu-Satunya Uji Strategi yang Jujur},
year = {2026},
url = {https://marketmaker.cc/id/blog/post/walk-forward-optimization},
version = {0.1.0},
description = {Mengapa pemisahan train/test tunggal tidak melindungi dari overfitting, bagaimana walk-forward optimization secara sistematis memverifikasi ketahanan parameter, dan mengapa strategi dengan +3342\% PnL@ML pada 21 parameter adalah bom waktu tanpa WFO.}
}
Penulis
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.