Walk-Forward Optimization: Bài Kiểm Tra Chiến Lược Trung Thực Duy Nhất
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

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ì

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)

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ổ:
Cửa sổ 1: Train [2024-01] → Test [2024-04]
Cửa sổ 2: Train [2024-01..04] → Test [2024-07] (train đang tăng trưởng)
Cửa sổ 3: Train [2024-01..07] → Test [2024-10]
Cửa sổ 4: Train [2024-01..10] → Test [2025-01]
Cửa sổ 5: 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:
Cửa sổ 1: Train [2024-01..06] → Test [2024-07..09]
Cửa sổ 2: Train [2024-04..09] → Test [2024-10..12]
Cửa sổ 3: Train [2024-07..12] → Test [2025-01..03]
Cửa sổ 4: Train [2024-10..2025-03] → Test [2025-04..06]
Cửa sổ 5: 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)

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 nhóm, trong đó 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):
Với : 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

Độ 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:
Đố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 chồng lấp:
Train [01..06] → Test [07..09]
Train [07..12] → Test [01..03]
Chồng lấp 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

Walk-Forward Efficiency Ratio (WFER)
Chỉ số WFO chính — tỷ lệ lợi nhuận OOS so với 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:
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

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ử

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ố

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ố:
Nếu mỗi trong số 21 tham số nhận ít nhất 10 giá trị:
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 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):
Ở và tổ hợp đã thử:
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:
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 giao dịch OOS. Tỷ lệ — chấp nhận được, nhưng chỉ khi WFER > 0.5.
Tích Hợp WFO Với 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:
-
WFER < 0.5 = overfitting. Nếu OOS PnL nhỏ hơn một nửa IS — các tham số được khớp.
-
Độ ổ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.
-
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.
-
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.
-
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
- 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
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.}
}
Tác Giả
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.