← Kembali ke artikel
March 15, 2026
5 menit baca

Walk-Forward Optimization: Satu-Satunya Uji Strategi yang Jujur

#algotrading
#backtest
#walk-forward
#overfitting
#validasi
#optimisasi

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

Visualisasi jebakan pemisahan train/test

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

Diagram rolling windows walk-forward

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)

Visualisasi expanding window anchored WFO

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)

Visualisasi combinatorial purged cross-validation

Metode lanjutan yang diusulkan oleh Marcos Lopez de Prado. Data dibagi menjadi NN kelompok, di mana kk 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):

Jumlah kombinasi=(Nk)\text{Jumlah kombinasi} = \binom{N}{k}

Dengan N=10,k=2N = 10, k = 2: 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

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

Tmin=300 transaksi2 transaksi/hari=150 hari5 bulanT_{min} = \frac{300\ \text{transaksi}}{2\ \text{transaksi/hari}} = 150\ \text{hari} \approx 5\ \text{bulan}

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

Visualisasi walk-forward efficiency ratio dan degradasi

Walk-Forward Efficiency Ratio (WFER)

Metrik kunci WFO — rasio return OOS terhadap return IS:

WFER=PnLOOSPnLIS\text{WFER} = \frac{\text{PnL}_{OOS}}{\text{PnL}_{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:

Degradation rate=d(OOS PnL)dt\text{Degradation rate} = \frac{d(\text{OOS PnL})}{dt}

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

Visualisasi arsitektur 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:
    """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

Visualisasi spesifikasi WFO 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

Kutukan dimensionalitas dalam optimisasi multi-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:

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

Jika masing-masing dari 21 parameter mengambil setidaknya 10 nilai:

1021=10 sextillion kombinasi10^{21} = 10\ \text{sextillion kombinasi}

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 MM kombinasi parameter, probabilitas "penemuan" palsu (menemukan hasil yang bagus secara kebetulan):

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

Pada α=0,05\alpha = 0,05 dan M=10000M = 10000 kombinasi yang dicoba:

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

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:

Transaksi OOSParameter>10\frac{\text{Transaksi OOS}}{\text{Parameter}} > 10

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 5×60×2=6005 \times 60 \times 2 = 600 transaksi OOS. Rasio 600/21=28,6600/21 = 28,6 — dapat diterima, tetapi hanya jika WFER > 0,5.

Mengintegrasikan WFO dengan Optuna

Integrasi optimisasi Bayesian 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:

  1. WFER < 0,5 = overfitting. Jika OOS PnL kurang dari setengah IS — parameternya disesuaikan.

  2. 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.

  3. Rolling WFO untuk crypto. Pergantian rezim membuat anchored WFO kurang andal. Rolling window 4-6 bulan adalah keseimbangan yang optimal.

  4. Lebih banyak parameter — persyaratan lebih ketat. 21 parameter membutuhkan setidaknya 210 transaksi OOS dan 6+ jendela WFO. Tanpa ini, hasilnya tidak dapat diverifikasi.

  5. 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

  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

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.}
}
Penafian: Informasi yang disediakan dalam artikel ini hanya untuk tujuan edukasi dan informasi serta tidak merupakan nasihat keuangan, investasi, atau trading. Trading mata uang kripto mengandung risiko kerugian yang signifikan.

Penulis

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

Selangkah Lebih Maju dari Pasar

Berlangganan newsletter kami untuk wawasan AI trading eksklusif, analisis pasar, dan pembaruan platform.

Kami menghormati privasi Anda. Berhenti berlangganan kapan saja.