← Quay lại danh sách bài viết
March 15, 2026
5 phút đọc

Walk-Forward Optimization: Bài Kiểm Tra Chiến Lược Trung Thực Duy Nhất

#algotrading
#backtest
#walk-forward
#overfitting
#validation
#optimization

Bạn đã tối ưu hóa một chiến lược. 12 tham số phân tách, 9 meta-tham số — tổng cộng 21. Backtest trong 25 tháng trên một cặp duy nhất cho thấy PnL +3342% tại MaxLev. Đường equity tăng liên tục gần như không có drawdown. Sharpe trên 3. Mọi thứ trông hoàn hảo.

Bạn khởi động bot. Hai tuần sau, chiến lược mất 18% vốn. Một tháng sau — 34%. Các tham số đã "hoạt động" trên dữ liệu lịch sử hóa ra chỉ khớp với một chuỗi sự kiện thị trường cụ thể. Bạn không tìm ra được quy luật — bạn đã ghi nhớ nhiễu.

Đây là overfitting kinh điển. Và cách duy nhất có hệ thống để phát hiện nó trước khi đưa vào sản xuất là Walk-Forward Optimization (WFO).

Bẫy Phân Chia Train/Test Một Lần

Minh họa bẫy phân chia train/test

Phương pháp tiêu chuẩn: chia dữ liệu thành 70% train và 30% test. Tối ưu hóa trên train, kiểm tra trên test. Nếu kết quả dương — khởi động.

Vấn đề: đây là một bài kiểm tra trên một lần phân chia. Kết quả phụ thuộc vào chỗ bạn vẽ ranh giới. Dịch ranh giới một tháng — và OOS PnL có thể thay đổi từ +40% xuống -15%.

Dữ liệu: |===== Train (70%) =====|== Test (30%) ==|
Phân chia 1: |===2024-01..2025-09====|==2025-10..26-01==|  → OOS PnL: +38%
Phân chia 2: |===2024-01..2025-06====|==2025-07..26-01==|  → OOS PnL: -12%
Phân chia 3: |===2024-04..2025-12====|==2026-01..26-04==|  → OOS PnL: +7%

Ba lần phân chia khác nhau — ba kết luận khác nhau. Tin vào cái nào? Không cái nào. Một lần phân chia train/test là ước lượng đơn điểm tương tự mà các vấn đề của nó đã được mô tả trong Monte Carlo Bootstrap. Bạn cần không phải một lần kiểm tra, mà là một chuỗi có hệ thống các lần kiểm tra trên các đoạn dữ liệu liên tiếp.

Đây chính xác là lý do Walk-Forward Optimization tồn tại.

Walk-Forward Optimization Là Gì

Sơ đồ các cửa sổ trượt walk-forward

WFO là thủ tục tối ưu hóa và xác minh chiến lược tuần tự trên các cửa sổ dữ liệu trượt (hoặc mở rộng). Ý tưởng: mô phỏng quá trình giao dịch thực tế, nơi bạn định kỳ tối ưu hóa lại tham số trên dữ liệu có sẵn rồi giao dịch cho đến lần tối ưu hóa tiếp theo.

Mỗi "cửa sổ" gồm hai phần:

  • In-Sample (IS) — giai đoạn mà trên đó các tham số được tối ưu hóa
  • Out-of-Sample (OOS) — giai đoạn mà trên đó các tham số tìm được được kiểm tra không khớp

Thuộc tính then chốt: các giai đoạn OOS không chồng lên nhau và cùng nhau bao phủ một phần đáng kể dữ liệu. Đường equity kết quả được xây dựng chỉ từ các đoạn OOS — đây là đánh giá trung thực của chiến lược.

Anchored WFO (Cửa Sổ Mở Rộng)

Minh họa cửa sổ mở rộng Anchored WFO

Trong anchored WFO, điểm bắt đầu của giai đoạn train được cố định, và phần cuối của nó mở rộng theo mỗi cửa sổ:

Ca s1: Train [2024-01]Test [2024-04]
Ca s2: Train [2024-01..04]Test [2024-07]    (train đang tăng trưởng)
Ca s3: Train [2024-01..07]Test [2024-10]
Ca s4: Train [2024-01..10]Test [2025-01]
Ca s5: Train [2024-01..2025-01]Test [2025-04]

Ưu điểm:

  • Mỗi giai đoạn train tiếp theo chứa nhiều dữ liệu hơn — tối ưu hóa ổn định hơn
  • Các quy luật cũ không bị mất — chúng luôn nằm trong tập train
  • Dễ thực hiện hơn

Nhược điểm:

  • Dữ liệu cũ có thể "pha loãng" các quy luật hiện tại
  • Nếu thị trường đã thay đổi về cấu trúc — dữ liệu cũ gây hại
  • Giai đoạn train tăng trưởng vô hạn, tăng thời gian tối ưu hóa

Rolling WFO (Cửa Sổ Trượt)

Trong rolling WFO, giai đoạn train có độ dài cố định "trượt" qua dữ liệu:

Ca s1: Train [2024-01..06]Test [2024-07..09]
Ca s2: Train [2024-04..09]Test [2024-10..12]
Ca s3: Train [2024-07..12]Test [2025-01..03]
Ca s4: Train [2024-10..2025-03]Test [2025-04..06]
Ca s5: Train [2025-01..06]Test [2025-07..09]

Ưu điểm:

  • Thích nghi với chế độ thị trường hiện tại
  • Thời gian tối ưu hóa không đổi
  • Dữ liệu cũ, không còn phù hợp không ảnh hưởng đến kết quả

Nhược điểm:

  • Ít dữ liệu hơn cho training — phương sai cao hơn của các tham số tối ưu
  • Nhạy cảm với lựa chọn độ dài cửa sổ
  • Có thể "quên" các sự kiện hiếm nhưng quan trọng (flash crashes)

Combinatorial Purged Cross-Validation (CPCV)

Minh họa combinatorial purged cross-validation

Một phương pháp tiên tiến được đề xuất bởi Marcos Lopez de Prado. Dữ liệu được chia thành NN nhóm, trong đó kk nhóm được chọn để kiểm tra. Điểm khác biệt chính so với cross-validation tiêu chuẩn là purging (loại bỏ dữ liệu tại ranh giới train/test) và embargo (khoảng trống bổ sung để ngăn rò rỉ dữ liệu):

Soˆˊ lượng tổ hợp=(Nk)\text{Số lượng tổ hợp} = \binom{N}{k}

Với N=10,k=2N = 10, k = 2: 45 tổ hợp train/test. Mỗi tổ hợp tạo ra một kết quả OOS, và ước lượng cuối cùng là trung bình của tất cả các tổ hợp.

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 tốt hơn rolling WFO khi dữ liệu khan hiếm, nhưng tốn kém hơn về mặt tính toán. Đối với chiến lược có 21 tham số và 25 tháng dữ liệu, chúng tôi khuyến nghị bắt đầu với rolling WFO và sử dụng CPCV như một kiểm tra bổ sung.

Các Tham Số Chính của WFO

Minh họa các tham số chính của WFO

Độ Dài Giai Đoạn Train

Train quá ngắn — không đủ dữ liệu để tối ưu hóa đáng tin cậy. Train quá dài — dữ liệu cũ pha loãng các quy luật hiện tại.

Nguyên tắc tính nhanh: train phải chứa ít nhất 200-300 giao dịch. Nếu chiến lược thực hiện 2 giao dịch mỗi ngày:

Tmin=300 giao dịch2 giao dịch/ngaˋy=150 ngaˋy5 thaˊngT_{min} = \frac{300\ \text{giao dịch}}{2\ \text{giao dịch/ngày}} = 150\ \text{ngày} \approx 5\ \text{tháng}

Đối với crypto với các chuyển đổi chế độ, chúng tôi khuyến nghị không quá 6-12 tháng cho cửa sổ trượt.

Độ Dài Giai Đoạn Test

Giai đoạn test phải đủ để đánh giá có ý nghĩa thống kê, nhưng không quá dài — nếu không các tham số có thời gian suy giảm.

Quy tắc: test = 20-33% của train. Nếu train = 6 tháng, test = 1,5-2 tháng.

Độ Chồng Lấp

Trong rolling WFO, các cửa sổ có thể chồng lên nhau. Độ chồng lấp tăng số lượng điểm dữ liệu OOS nhưng tạo ra tương quan giữa các ước lượng:

Không chng lp:
  Train [01..06]Test [07..09]
  Train [07..12]Test [01..03]

Chng lp 50%:
  Train [01..06]Test [07..09]
  Train [04..09]Test [10..12]
  Train [07..12]Test [01..03]

Khuyến nghị: 50% chồng lấp trên giai đoạn train — cân bằng tốt giữa số lượng cửa sổ và độc lập của các ước lượng.

Tần Suất Tối Ưu Hóa Lại

Xác định mức độ thường xuyên bạn tính lại tham số. Trong thị trường crypto, tần suất tối ưu là mỗi 1-3 tháng. Tối ưu hóa lại thường xuyên hơn làm tăng nguy cơ overfitting với nhiễu; ít thường xuyên hơn — nguy cơ tham số bị lỗi thời.

Walk-Forward Efficiency Ratio và Tốc Độ Suy Giảm

Minh họa walk-forward efficiency ratio và suy giảm

Walk-Forward Efficiency Ratio (WFER)

Chỉ số WFO chính — tỷ lệ lợi nhuận OOS so với IS:

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

Giải thích:

WFER Giải thích
> 0.8 Độ bền xuất sắc. Tham số chuyển sang dữ liệu mới.
0.5 — 0.8 Độ bền chấp nhận được. Chiến lược hoạt động nhưng có suy giảm.
0.3 — 0.5 Trường hợp ranh giới. Overfitting một phần có khả năng xảy ra.
< 0.3 Overfitting. Tham số được khớp với dữ liệu IS.
< 0 Chiến lược không có lợi nhuận OOS. Overfitting hoàn toàn hoặc lỗi logic.

Nếu WFER < 0.5 — chiến lược nhiều khả năng bị overfit. Đây là bộ lọc chính của chúng tôi.

Tốc Độ Suy Giảm

Cho thấy các tham số tối ưu mất hiệu quả nhanh như thế nào theo thời gian:

Toˆˊc độ suy giảm=d(OOS PnL)dt\text{Tốc độ suy giảm} = \frac{d(\text{OOS PnL})}{dt}

Trong thực tế: chia giai đoạn test thành các khoảng con và theo dõi động lực 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

Nếu tốc độ suy giảm âm mạnh — tham số bị lỗi thời nhanh, và bạn cần tối ưu hóa lại thường xuyên hơn hoặc giai đoạn train ngắn hơn.

Triển Khai Pipeline WFO Đầy Đủ Trong Python

Minh họa kiến trúc 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

Ví Dụ Sử Dụng

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}")

Giải Thích Kết Quả: Khi Nào Tin Tưởng, Khi Nào Từ Chối

Chiến Lược Đã Vượt Qua WFO

Nếu WFER >= 0.5 ở tất cả các cửa sổ, OOS PnL dương và ổn định:

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

Dấu hiệu tốt:

  • WFER ổn định qua các cửa sổ (không có biến động mạnh)
  • Tham số tương đồng giữa các cửa sổ (fast = 10-15, slow = 50-60)
  • OOS PnL dương ở hầu hết các cửa sổ
  • Tốc độ suy giảm gần bằng không

Chiến Lược Thất Bại 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

Dấu hiệu overfitting:

  • IS PnL cao, OOS PnL thấp/âm — overfitting kinh điển
  • Tham số thay đổi đáng kể giữa các cửa sổ — không có điểm tối ưu ổn định
  • WFER < 0.3 ở hầu hết các cửa sổ — tham số không chuyển giao được
  • Tốc độ suy giảm âm mạnh — suy giảm nhanh

Thêm về phân tích độ ổn định tham số — trong bài viết Phân tích Plateau. Nếu điểm tối ưu "nhọn" (giảm mạnh với các thay đổi tham số nhỏ) — đây là tín hiệu overfitting bổ sung.

Đặc Thù WFO Đối Với Tiền Điện Tử

Minh họa đặc thù WFO tiền điện tử

Tiền điện tử tạo ra các vấn đề độc đáo cho WFO mà không tồn tại ở các thị trường truyền thống.

Chuyển Đổi Chế Độ

Thị trường crypto chuyển đổi giữa các chế độ hoàn toàn khác nhau: xu hướng tăng, xu hướng giảm, đi ngang với biến động cao/thấp. Các tham số tối ưu trong một chế độ có thể không có lợi nhuận trong chế độ khác.

Giải pháp: sử dụng rolling WFO (không phải anchored) với cửa sổ 4-6 tháng. Điều này cho phép "quên" các chế độ cũ. Ngoài ra — phân cụm dữ liệu theo biến động và chạy WFO riêng cho mỗi cụm.

Lịch Sử Ngắn

Hầu hết altcoin có ít hơn 3 năm lịch sử giao dịch. Với train = 6 tháng và test = 2 tháng, bạn sẽ chỉ có 4-5 cửa sổ — một ước lượng yếu về mặt thống kê.

Giải pháp: sử dụng CPCV thay thế hoặc bổ sung cho rolling WFO. CPCV tạo ra nhiều tổ hợp hơn từ cùng một dữ liệu. Với 10 nhóm và k=2: 45 tổ hợp thay vì 4-5 cửa sổ.

Thay Đổi Thanh Khoản Cấu Trúc

Thanh khoản cặp crypto không ổn định: một cặp có thể có thanh khoản trong 6 tháng, sau đó khối lượng giảm 10 lần. Các tham số được tối ưu hóa trên thị trường có thanh khoản không hoạt động trên thị trường không có thanh khoản.

Giải pháp: thêm bộ lọc thanh khoản vào pipeline WFO. Loại trừ các cửa sổ mà khối lượng giao dịch trung bình hàng ngày dưới ngưỡng. Xác minh rằng thanh khoản trong giai đoạn test tương đương với giai đoạn train.

Tác Động Của Funding Rate

Đối với các chiến lược futures có đòn bẩy, funding rate có thể thay đổi cơ bản kết quả OOS. Một chiến lược cho thấy +5% OOS trong 2 tháng, nhưng ở đòn bẩy 10x, funding ăn mất 3,6%.

Phân tích chi tiết về tác động của funding — trong bài viết Funding rates tiêu diệt đòn bẩy của bạn. Hãy chắc chắn tính đến chi phí funding khi đánh giá OOS PnL trong WFO.

Chiến Lược Đa Tham Số: Tại Sao WFO Là Quan Trọng Với 12+ Tham Số

Lời nguyền chiều trong tối ưu hóa đa tham số

Một chiến lược với 21 tham số (12 phân tách + 9 meta) trên 25 tháng dữ liệu từ một cặp duy nhất là một mô hình với không gian tìm kiếm khổng lồ.

Lời Nguyền Chiều

Số lượng tổ hợp tham số tăng theo hàm mũ với số lượng tham số:

Tổ hợp=i=1nPi\text{Tổ hợp} = \prod_{i=1}^{n} |P_i|

Nếu mỗi trong số 21 tham số nhận ít nhất 10 giá trị:

1021=10 sextillion tổ hợp10^{21} = 10\ \text{sextillion tổ hợp}

Ngay cả với tối ưu hóa Bayesian (chi tiết trong Coordinate Descent vs Bayesian), bạn khám phá một phần nhỏ không đáng kể của không gian. Xác suất rằng điểm tối ưu tìm được là một hiện vật nhiễu hơn là một quy luật thực sự tăng theo số lượng tham số.

Công Thức Bonferroni Cho Nhiều So Sánh

Nếu bạn kiểm tra MM tổ hợp tham số, xác suất "phát hiện" sai (tìm thấy kết quả tốt ngẫu nhiên):

P(phaˊt hiện sai)=1(1α)M1eαMP(\text{phát hiện sai}) = 1 - (1 - \alpha)^M \approx 1 - e^{-\alpha M}

α=0.05\alpha = 0.05M=10000M = 10000 tổ hợp đã thử:

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

Bạn chắc chắn sẽ tìm thấy các tham số "hoạt động" — thực ra chỉ khớp với nhiễu. Không có WFO, bạn không có cách nào để phân biệt lợi thế thực sự với hiện vật thống kê.

Quy Tắc: Số Điểm Dữ Liệu OOS So Với Số Tham Số

Một nguyên tắc tính nhanh để tin tưởng kết quả WFO:

Giao dịch OOSTham soˆˊ>10\frac{\text{Giao dịch OOS}}{\text{Tham số}} > 10

Với 21 tham số, bạn cần ít nhất 210 giao dịch OOS. Nếu WFO của bạn tạo ra ít hơn — kết quả không thể tin cậy.

Chiến lược với +3342% PnL@ML: 21 tham số, 25 tháng dữ liệu. Giả sử 5 cửa sổ OOS 60 ngày, 2 giao dịch/ngày — tổng cộng 5×60×2=6005 \times 60 \times 2 = 600 giao dịch OOS. Tỷ lệ 600/21=28.6600/21 = 28.6 — chấp nhận được, nhưng chỉ khi WFER > 0.5.

Tích Hợp WFO Với Optuna

Tối ưu hóa Bayesian với tích hợp Optuna

Trong mỗi cửa sổ WFO, bạn cần tối ưu hóa tham số. Với 21 tham số, grid search là không thể, coordinate descent không hiệu quả. Lựa chọn tối ưu là tối ưu hóa Bayesian qua 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()

Quan trọng: bên trong WFO, tối ưu hóa Sharpe, không phải PnL. Tối ưu hóa PnL tìm các tham số tối đa hóa lợi nhuận trên một chuỗi giao dịch cụ thể. Tối ưu hóa Sharpe tìm các tham số với tỷ lệ lợi nhuận-rủi ro tốt nhất — chúng mạnh mẽ hơn trên OOS.

So sánh chi tiết Optuna TPE với coordinate descent — trong bài viết Coordinate Descent vs Bayesian.

Trực Quan Hóa Kết Quả 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()

Khuyến Nghị Thực Tế

Danh Sách Kiểm Tra Trước Khi Khởi Động Chiến Lược Vào Sản Xuất

1. Chạy WFO (rolling + anchored)

So sánh kết quả của cả hai chế độ. Nếu rolling WFO thất bại nhưng anchored vượt qua — chiến lược nhiều khả năng chỉ hoạt động trên dữ liệu đầu.

2. Kiểm tra WFER cho mỗi cửa sổ

Không chỉ WFER tổng hợp, mà từng cửa sổ riêng lẻ. Nếu 2 trong số 6 cửa sổ có WFER < 0 — đó là vấn đề, ngay cả khi tổng hợp > 0.5.

3. So sánh tham số giữa các cửa sổ

Nếu các tham số tối ưu "nhảy" từ cửa sổ này sang cửa sổ khác — không có lợi thế ổn định. Sử dụng Phân tích Plateau để xác minh độ ổn định của điểm tối ưu.

4. Kiểm tra tốc độ suy giảm

Tốc độ suy giảm âm mạnh = tham số mất hiệu quả nhanh. Bạn cần tối ưu hóa lại thường xuyên hơn hoặc cải tổ chiến lược.

5. Áp dụng Monte Carlo bootstrap cho kết quả OOS

OOS PnL tổng hợp cũng là ước lượng đơn điểm. Áp dụng Monte Carlo bootstrap cho mảng lợi nhuận OOS để có được khoảng tin cậy.

6. Tính đến chi phí

OOS PnL phải bao gồm hoa hồng, slippage và funding rate. OOS PnL đẹp mà không có chi phí là ảo tưởng. Chi tiết hơn — Funding rates tiêu diệt đòn bẩy của bạn.

Yêu Cầu Dữ Liệu Tối Thiểu

Số tham số Min giao dịch OOS Min cửa sổ WFO Min dữ liệu (2 giao dịch/ngày)
2-5 50 3 ~6 tháng
6-10 100 4 ~12 tháng
11-15 150 5 ~18 tháng
16-21 210 6 ~24 tháng
22+ 300+ 8+ ~36+ tháng

Chiến Lược Với 21 Tham Số Và 25 Tháng Dữ Liệu

Hãy trở lại câu hỏi từ đầu bài: 21 tham số được tối ưu hóa trên 25 tháng dữ liệu từ một cặp duy nhất. PnL@ML = +3342%. Làm thế nào để xác minh?

Bước 1. Rolling WFO: train = 8 tháng, test = 2 tháng, step = 2 tháng. Chúng ta nhận được ~8 cửa sổ.

Bước 2. Anchored WFO: train đầu tiên = 8 tháng, test = 2 tháng. Chúng ta nhận được ~8 cửa sổ.

Bước 3. CPCV: 10 nhóm ~2,5 tháng, k = 2. Chúng ta nhận được 45 tổ hợp.

Bước 4. Đối với mỗi phương pháp, xác minh:

  • WFER >= 0.5?
  • Tham số ổn định giữa các cửa sổ?
  • Tốc độ suy giảm chấp nhận được?
  • Giao dịch OOS / Tham số >= 10?

Bước 5. Monte Carlo bootstrap trên lợi nhuận OOS tổng hợp. PnL phần trăm thứ 5 > 0?

Nếu bất kỳ bài kiểm tra nào trong số này thất bại — chiến lược với +3342% nhiều khả năng bị overfit. 21 tham số trên 25 tháng của một cặp duy nhất — đây là tỷ lệ tham số-dữ liệu cực kỳ cao. Không vượt qua WFO, không thể có sự tin tưởng.

Chúng tôi cũng khuyến nghị kiểm tra hiệu quả chiến lược tính đến PnL theo thời gian hoạt động — điều này sẽ tiết lộ phần nào của +3342% là do thời gian trong vị thế so với lợi thế thực sự.

Kết Luận

Walk-Forward Optimization không phải là tùy chọn — đó là sự cần thiết. Đây là phương pháp duy nhất có hệ thống xác minh khả năng chuyển giao tham số sang dữ liệu mới. Một lần phân chia train/test là một canh bạc. Backtest đầy đủ trên tất cả dữ liệu là tự lừa dối.

Điểm chính:

  1. WFER < 0.5 = overfitting. Nếu OOS PnL nhỏ hơn một nửa IS — các tham số được khớp.

  2. Độ ổn định tham số quan trọng hơn mức tối đa. Các tham số mang lại +15% trong mỗi cửa sổ tốt hơn các tham số mang lại +40% trong một cửa sổ và -10% trong cửa sổ khác.

  3. Rolling WFO cho crypto. Chuyển đổi chế độ làm cho anchored WFO kém đáng tin cậy hơn. Cửa sổ trượt 4-6 tháng là sự cân bằng tối ưu.

  4. Nhiều tham số hơn — yêu cầu nghiêm ngặt hơn. 21 tham số yêu cầu ít nhất 210 giao dịch OOS và 6+ cửa sổ WFO. Không có điều này, kết quả không thể xác minh.

  5. WFO + Monte Carlo bootstrap + Phân tích Plateau — ba lớp bảo vệ overfitting. Mỗi lớp bắt được những gì các lớp khác bỏ lỡ.

Một chiến lược vượt qua WFO với WFER > 0.5 ở tất cả các cửa sổ, tham số ổn định và phần trăm thứ 5 bootstrap dương — đó là chiến lược bạn có thể tin tưởng với tiền thật. Tất cả mọi thứ khác là curve fitting với đường equity đẹp.


Liên Kết Hữu Ích

  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

Trích Dẫn

@article{soloviov2026walkforwardoptimization,
  author = {Soloviov, Eugen},
  title = {Walk-Forward Optimization: Bài Kiểm Tra Chiến Lược Trung Thực Duy Nhất},
  year = {2026},
  url = {https://marketmaker.cc/vi/blog/post/walk-forward-optimization},
  version = {0.1.0},
  description = {Tại sao một lần phân chia train/test không bảo vệ được khỏi overfitting, cách walk-forward optimization kiểm tra tính bền vững của tham số một cách có hệ thống, và tại sao chiến lược với +3342\% PnL@ML trên 21 tham số là một quả bom hẹn giờ nếu thiếu WFO.}
}
Tuyên bố miễn trừ trách nhiệm: Thông tin được cung cấp trong bài viết này chỉ nhằm mục đích giáo dục và thông tin, không cấu thành lời khuyên về tài chính, đầu tư hoặc giao dịch. Giao dịch tiền mã hóa tiềm ẩn rủi ro thua lỗ đáng kể.

Tác Giả

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

Đi Trước Thị Trường

Đăng ký nhận bản tin của chúng tôi để có những thông tin chuyên sâu độc quyền về AI trading, phân tích thị trường và các cập nhật nền tảng.

Chúng tôi tôn trọng quyền riêng tư của bạn. Hủy đăng ký bất kỳ lúc nào.