Backtest walk-forward — cách kiểm tra chiến lược quant không bị overfit (Python từng bước, áp dụng VN-Index)
Walk-forward backtest là chuẩn vàng của quant trading: tách dữ liệu IS/OOS, optimize rolling, đo Sharpe degradation. Hướng dẫn Python từng bước trên VN-Index, 5 sai lầm thường gặp khi retail tự backtest.
Một câu chuyện quen thuộc với 90% retail trader Việt Nam: bạn dành 3 tuần code một chiến lược trên VN-Index, backtest trên dữ liệu 5 năm đẹp như mơ — Sharpe 2.4, max drawdown 8%, win-rate 64%. Bạn deploy live với 200 triệu, sau 3 tháng tài khoản còn 142 triệu. Vấn đề không phải ở chiến lược — vấn đề ở cách bạn backtest.
Đây là biểu hiện kinh điển của overfit: chiến lược hoạt động tốt trên dữ liệu quá khứ vì các tham số đã được “vẽ” để vừa khít với nhiễu, chứ không phản ánh quy luật thực sự của thị trường. Trong 7 nguyên tắc giao dịch định lượng đã trình bày, walk-forward backtest là nguyên tắc #3 — và là rào chắn quan trọng nhất giữa một backtest đẹp và một strategy thật sự sống được trên live.
Bài viết này hướng dẫn từng bước cách thiết kế walk-forward backtest chuẩn, code Python để chạy trên VN-Index, và 5 sai lầm phổ biến khiến retail vẫn bị overfit ngay cả khi nghĩ rằng đã làm walk-forward.
Tóm tắt nhanh
- Walk-forward backtest = chia dữ liệu thành nhiều cửa sổ rolling (in-sample optimize → out-of-sample test → trượt tới), mô phỏng cách bạn thực sự sẽ deploy strategy
- Overfit = chiến lược fit vào nhiễu thay vì tín hiệu; biểu hiện: IS Sharpe cao, OOS Sharpe rớt > 50%
- Quy trình 3 bước: split 70/30 → rolling optimize 12 tháng → forward test 3 tháng, đo Walk-Forward Efficiency (WFE)
- Tiêu chí pass: WFE > 50%, OOS Sharpe ≥ 0.7 × IS Sharpe, profit factor OOS > 1.3
- 5 sai lầm phổ biến: peeking future data, optimize quá nhiều tham số, không tính phí giao dịch, chọn period ngắn, chỉ test trên 1 ticker
1. Walk-forward backtest là gì? — định nghĩa thực chiến
Walk-forward backtest là kỹ thuật kiểm tra chiến lược giao dịch bằng cách mô phỏng đúng quá trình bạn sẽ vận hành nó trong tương lai: tại mỗi thời điểm trong quá khứ, bạn chỉ được dùng dữ liệu trước thời điểm đó để tối ưu tham số, sau đó test kết quả trên một khoảng thời gian sau (mà strategy chưa từng “thấy”). Trượt cửa sổ tiến về tương lai, lặp lại.
Khác biệt cơ bản với backtest tĩnh (in-sample fitting):
Sharpe 2.4 trên giấy · live thua · không phát hiện overfit
Sharpe IS 2.4 · OOS 1.6 · WFE 67% → strategy sống được
Có 2 biến thể chính:
- Anchored walk-forward: cửa sổ training mở rộng dần (cố định điểm đầu, kéo dài điểm cuối). Phù hợp khi bạn tin “nhiều data hơn = tốt hơn” — ví dụ chiến lược macro dài hạn.
- Rolling walk-forward: cửa sổ training cố định kích thước, trượt theo thời gian. Phù hợp khi thị trường có regime shift — ví dụ chiến lược đoản hạn trên VN30.
Với TTCK Việt Nam (chỉ có 14 năm dữ liệu sạch sau 2010), rolling 24 tháng training + 6 tháng test là điểm cân bằng tốt giữa độ ổn định và khả năng thích nghi.
2. Vì sao 95% backtest tự làm bị overfit?
Câu trả lời ngắn: bạn dùng cùng dữ liệu để vừa thiết kế vừa kiểm tra chiến lược. Khi bạn thử 50 tham số khác nhau trên cùng 5 năm dữ liệu rồi chọn cái Sharpe cao nhất, bạn không tìm ra strategy tốt — bạn chỉ tìm ra bộ tham số khớp may mắn nhất với nhiễu của 5 năm đó.
Hiện tượng này có tên trong thống kê: data snooping bias. Mức nghiêm trọng tỷ lệ thuận với:
| Yếu tố | Tăng overfit risk |
|---|---|
| Số tham số tự do | Nhiều hơn 3-4 tham số → rủi ro tăng theo cấp số nhân |
| Số chiến lược đã thử | Try 100 chiến lược trên cùng dataset → 5 cái sẽ “đẹp” thuần do may rủi (p < 0.05) |
| Độ dài dữ liệu | Ít hơn 200 trade → confidence interval rất rộng |
| Tính độc lập của trade | Trade sử dụng overlap dữ liệu → effective N nhỏ hơn nhiều so với N quan sát |
Khi bạn deploy lên live, cấu trúc nhiễu của tương lai khác với quá khứ — và “sự khớp may mắn” biến mất. Đó là khoảnh khắc đau đớn mà rất nhiều retail trader gọi là “thị trường đã thay đổi” — thực ra thị trường không đổi, chỉ là nhiễu không lặp lại.
Walk-forward giải quyết vấn đề này bằng cách buộc bạn test trên dữ liệu chưa thấy ở mỗi bước — đó là điều kiện gần nhất với việc deploy thật.
3. Quy trình walk-forward 3 bước — chuẩn dùng được cho VN-Index
Bước 1: Tách dữ liệu
Với 14 năm dữ liệu VN-Index (2010-01 → 2024-12), tách:
- In-sample (IS): 2010-01 → 2020-12 (11 năm) — dùng để tối ưu tham số
- Out-of-sample (OOS): 2021-01 → 2024-12 (4 năm) — không bao giờ chạm vào trong giai đoạn dev
- Live forward: từ 2025-01 trở đi — paper trade thật trước khi rút real money
Nguyên tắc bất di bất dịch: OOS data phải bị “đóng băng” cho tới khi bạn quyết định deploy. Một khi đã peek OOS để cải tiến strategy, OOS đó đã chết — bạn cần tìm tập OOS mới hoặc đợi thời gian trôi.
Bước 2: Rolling optimization trên IS
Trên 11 năm IS, chạy cửa sổ rolling:
- Training window: 24 tháng → grid search tham số → chọn bộ Sharpe cao nhất
- Forward window: 6 tháng tiếp theo → áp tham số đã chọn, ghi nhận P&L, Sharpe, MaxDD
- Trượt forward 6 tháng → lặp lại
11 năm IS sẽ tạo ra ~18 cửa sổ. Mỗi cửa sổ cho 1 cặp (IS Sharpe optimized, OOS Sharpe forward). Đây là tập dữ liệu chân thực nhất về hành vi của strategy.
Bước 3: Đánh giá ổn định + chỉ số quyết định
Tính 3 metric chốt:
- Walk-Forward Efficiency (WFE) = trung bình OOS Sharpe / trung bình IS Sharpe. Chuẩn pass: WFE ≥ 0.5 (mất tối đa 50% Sharpe khi ra OOS).
- OOS hit rate: số cửa sổ có OOS Sharpe > 0 / tổng số cửa sổ. Chuẩn pass: ≥ 65%.
- Profit factor OOS: tổng lợi nhuận OOS / tổng thua lỗ OOS. Chuẩn pass: ≥ 1.3.
Strategy thoả cả 3 điều kiện trên IS rolling → mới có quyền deploy lên 4 năm OOS đóng băng. Sống tiếp ở OOS đóng băng → mới được lên live paper.
4. Code Python — implement walk-forward đơn giản
Skeleton code dưới đây cài walk-forward cho một chiến lược MA crossover trên VN-Index. Yêu cầu: pandas, numpy, dữ liệu OHLC daily.
import numpy as np
import pandas as pd
from itertools import product
def ma_crossover_signal(df: pd.DataFrame, fast: int, slow: int) -> pd.Series:
fast_ma = df['close'].rolling(fast).mean()
slow_ma = df['close'].rolling(slow).mean()
return (fast_ma > slow_ma).astype(int).shift(1).fillna(0) # shift để tránh look-ahead
def backtest(df: pd.DataFrame, signal: pd.Series, fee_bps: float = 15) -> dict:
ret = df['close'].pct_change().fillna(0)
pos_change = signal.diff().abs().fillna(signal.iloc[0])
cost = pos_change * (fee_bps / 10000)
strat_ret = signal * ret - cost
sharpe = (strat_ret.mean() / strat_ret.std()) * np.sqrt(252) if strat_ret.std() > 0 else 0
cum = (1 + strat_ret).cumprod()
mdd = (cum / cum.cummax() - 1).min()
return {'sharpe': sharpe, 'mdd': mdd, 'final': cum.iloc[-1] - 1}
def optimize(df: pd.DataFrame, fast_range, slow_range) -> tuple:
best = (None, -np.inf)
for f, s in product(fast_range, slow_range):
if f >= s:
continue
sig = ma_crossover_signal(df, f, s)
sharpe = backtest(df, sig)['sharpe']
if sharpe > best[1]:
best = ((f, s), sharpe)
return best # ((fast, slow), sharpe)
def walk_forward(df: pd.DataFrame, train_months: int = 24, test_months: int = 6):
df = df.copy()
df.index = pd.to_datetime(df.index)
results = []
start = df.index[0]
while True:
train_end = start + pd.DateOffset(months=train_months)
test_end = train_end + pd.DateOffset(months=test_months)
if test_end > df.index[-1]:
break
train = df[(df.index >= start) & (df.index < train_end)]
test = df[(df.index >= train_end) & (df.index < test_end)]
params, is_sharpe = optimize(train, range(5, 31, 5), range(20, 121, 10))
oos = backtest(test, ma_crossover_signal(test, *params))
results.append({
'train_start': start, 'train_end': train_end,
'params': params, 'is_sharpe': is_sharpe,
'oos_sharpe': oos['sharpe'], 'oos_mdd': oos['mdd'],
})
start = start + pd.DateOffset(months=test_months) # rolling step
return pd.DataFrame(results)
# Sử dụng
# df = pd.read_parquet('vnindex_d1.parquet') # cột: open, high, low, close, volume
# wf = walk_forward(df)
# wfe = wf['oos_sharpe'].mean() / wf['is_sharpe'].mean()
# print(f'WFE = {wfe:.2%}, OOS hit-rate = {(wf.oos_sharpe > 0).mean():.0%}')
3 chi tiết kỹ thuật quan trọng trong skeleton trên (đừng bỏ qua):
shift(1)trong tín hiệu — bắt buộc, để tránh look-ahead bias (dùng giá đóng cửa hôm nay quyết định cho lệnh hôm nay).fee_bps=15— phí giao dịch HOSE thực tế ~0.15% mỗi vòng (mua + bán) chưa kể slippage. Bỏ phí ra → Sharpe ảo.if f >= s: continue— chặn tham số không hợp lý, tránh polluting search space.
5. Đọc kết quả: IS vs OOS Sharpe degradation
Sau khi chạy walk_forward(), bạn sẽ có DataFrame ~18 hàng. Đọc theo logic này:
| Pattern | Ý nghĩa | Hành động |
|---|---|---|
| IS Sharpe 2.0+ · OOS Sharpe 1.5+ · WFE > 75% | Edge mạnh, ổn định | Deploy OOS đóng băng |
| IS Sharpe 2.0+ · OOS Sharpe 0.8 · WFE 40% | Có edge nhưng bị overfit nặng | Giảm số tham số, lặp lại |
| IS Sharpe 2.0+ · OOS Sharpe -0.2 · WFE âm | Pure overfit, không có edge | Bỏ chiến lược, đừng tiếc |
| IS Sharpe 0.7 · OOS Sharpe 0.6 · WFE 86% | Edge yếu nhưng thật | Deploy với position size nhỏ, tổ hợp với strategy khác |
Tham khảo thêm cách tính và so sánh Sharpe ratio cùng Sortino để chọn risk-adjusted metric phù hợp với phân phối lợi nhuận của strategy.
Một quan sát thực chiến: với MA crossover trên VN-Index 2010-2020, WFE thường rơi vào khoảng 35-55%, nghĩa là chiến lược này có edge biên giới — sống được nhưng không phải mỏ vàng. Để có WFE > 70%, bạn thường phải kết hợp regime filter (ví dụ chỉ trade khi VN-Index trên MA200) hoặc lọc bằng mean-reversion Bollinger Band.
6. Áp dụng walk-forward vào TTCK Việt Nam — 3 lưu ý đặc thù
Lưu ý 1: Giai đoạn 2010-2014 hành xử rất khác phần còn lại
Volatility VN-Index 2010-2014 cao hơn 2015-2024 trung bình ~40%, do thị trường còn non + cú sốc bất động sản 2011-2012. Nếu rolling window của bạn rơi đúng vào đoạn này, kết quả có thể bị skew lớn. Cách xử lý: hoặc (a) starting period từ 2015 nếu strategy đoản hạn, hoặc (b) chấp nhận có 1-2 cửa sổ “ngoại lai” và xử lý bằng median thay vì mean khi tính WFE.
Lưu ý 2: T+2.5 settlement = trade thực tế chậm hơn signal
Tín hiệu vào lệnh hôm T tại giá đóng cửa, nhưng tiền về sau bán phải chờ T+2.5 mới giao dịch tiếp được. Trong code skeleton trên chưa mô hình hoá điều này — bạn cần thêm constraint: nếu vừa thoát lệnh thì 2 ngày sau mới được vào lệnh mới. Bỏ qua T+2.5 → Sharpe ảo trên backtest, live sẽ thua phí cơ hội.
Lưu ý 3: Chỉ số có “free lunch” giả từ vốn hoá lệch
VN-Index trọng số vốn hoá → 5 mã (VIC, VCB, VHM, BID, GAS) chiếm gần 40% rổ. Một strategy backtest tốt trên VN-Index có thể chỉ là đang cược vào VIC + VCB, không phải có edge thật. Robust hơn: backtest trên VN30 equal-weight hoặc trên rổ 10 mã thanh khoản top, lấy median Sharpe.
7. 5 sai lầm phổ biến khi walk-forward (kể cả khi đã làm đúng quy trình)
Sai lầm 1: Peeking future data qua chuẩn hóa
Nếu bạn z-score chuẩn hoá feature dùng df['feature'].mean() trên toàn bộ data trước khi split, bạn đã vô tình rò rỉ thông tin tương lai vào training set. Đúng: tính mean/std chỉ trên train window, áp lên test window.
Sai lầm 2: Tối ưu hơn 4 tham số đồng thời
Mỗi tham số tự do thêm vào → search space tăng theo hàm mũ → khả năng tìm ra “may mắn ngẫu nhiên” tăng theo. Quy tắc thực dụng: với 14 năm data VN-Index (~3500 bar daily), giới hạn tối đa 3-4 tham số có ý nghĩa kinh tế, không phải nhồi 8 tham số rồi mong WFE đẹp.
Sai lầm 3: Bỏ qua phí + slippage
VN: phí HOSE/HNX ~0.15%/lượt + thuế bán 0.1% + spread bid-ask trung bình 0.05-0.20% với mã ngoài VN30. Chiến lược active 100 trade/năm, không tính phí có thể “ăn gian” Sharpe lên +0.5. Quy tắc: luôn backtest with fees first, never strip them out.
Sai lầm 4: Test period quá ngắn so với holding period
Nếu chiến lược của bạn có holding period 30 ngày, test window 3 tháng chỉ chứa ~3 trade độc lập → ước lượng Sharpe gần như vô nghĩa. Quy tắc: test window ≥ 20 × holding period để có ít nhất 20 trade độc lập.
Sai lầm 5: Cherry-picking universe
Backtest trên 100 mã, chọn 10 mã Sharpe cao nhất, gọi đó là “strategy” — đây là cross-sectional overfit. Đúng: định nghĩa universe theo rule trước khi xem kết quả (ví dụ “top 30 mã thanh khoản”), giữ nguyên rule đó qua mọi cửa sổ walk-forward.
Tổng kết — và lộ trình tiếp theo
Walk-forward backtest không làm chiến lược của bạn tốt hơn — nó giúp bạn không phá sản vì ảo tưởng vào một backtest đẹp. Đây là khác biệt giữa một quant trader chuyên nghiệp (biết trước maximum drawdown thật của hệ thống) và một retail tự backtest (sốc khi DD live vượt 2x DD backtest).
Lộ trình áp dụng walk-forward cho strategy đầu tiên của bạn:
- Tuần 1-2: Thu thập 14 năm OHLC VN-Index + 30 mã VN30 từ data cleaned source. Verify không có gap lớn, không có corporate action errors.
- Tuần 3: Code 1 strategy đơn giản (MA crossover, RSI, mean reversion). Backtest tĩnh trên IS để có baseline.
- Tuần 4: Implement
walk_forward()như skeleton trên. Chạy. Đọc WFE. Nếu < 30% → bỏ strategy. Nếu > 50% → tiếp. - Tuần 5-6: Test trên OOS đóng băng 4 năm. Nếu OOS Sharpe ≥ 0.7 × IS rolling Sharpe → đạt.
- Tuần 7-12: Paper trade thật 3 tháng với data live. Đo “live vs OOS gap”. Nếu gap < 30% → deploy real money với 25% capital, scale up sau 3 tháng nữa.
Toàn bộ quy trình tham chiếu trong tổng quan Quant Trading Việt Nam 2026. Walk-forward chỉ là một mảnh trong bức tranh — kèm với position sizing, drawdown plan và disciplined execution thì mới đủ tạo ra đường cong P&L bền vững.
FAQ
Walk-forward có thay thế được Monte Carlo simulation không?
Không. Walk-forward kiểm tra độ vững của tham số qua thời gian thật; Monte Carlo kiểm tra biến thiên của P&L do thứ tự trade ngẫu nhiên. Hai cái bổ sung cho nhau — pro luôn dùng cả hai trước khi deploy.
Có cần walk-forward cho chiến lược buy-and-hold không?
Có nếu strategy có rule chuyển đổi (ví dụ “rebalance khi VN-Index dưới MA200” → đó cũng là tham số có thể overfit). Không nếu thật sự pure buy-and-hold không có rule — nhưng lúc đó chỉ cần phân tích Sharpe lịch sử là đủ.
Tôi mới học Python, có thể skip walk-forward và dùng tool có sẵn?
Có. vectorbt, backtrader, quantstats đều hỗ trợ walk-forward. Tuy nhiên trước khi dùng tool, phải hiểu nguyên lý — nếu không bạn sẽ rơi vào sai lầm 1-5 ở trên mà không biết mình đang làm sai.
WFE bao nhiêu là chấp nhận được trên VN-Index?
Thực tế với data VN: WFE 50-70% là tốt, > 70% là rất tốt (có thể dấu hiệu strategy đơn giản, ít tham số). WFE < 30% gần như chắc chắn là overfit, kể cả khi IS Sharpe đẹp.
Bài liên quan
- Giao dịch định lượng vs đầu tư cảm tính — 7 nguyên tắc
- Quant Trading Việt Nam 2026 — tổng quan
- Sharpe ratio là gì — quant trading
- Mean reversion Bollinger Band trên TTCK VN
- Maximum drawdown là gì — quant trading
Miễn trừ trách nhiệm: Nội dung mang tính phân tích định lượng, không phải khuyến nghị đầu tư. Nhà đầu tư tự chịu trách nhiệm với quyết định của mình.
Áp dụng vào tài khoản thật?
Mở tài khoản chứng khoán qua mã giới thiệu — nhận tư vấn 1-1, DIAMOND signal VN30 miễn phí 6 tháng, ưu đãi phí giao dịch.
CTCK VPS Securities
- Mã IB: 9836 (mở online 15 phút)
- Phí 0.15% · margin 13%/năm
- + DIAMOND signal VN30 — 6 tháng
- + Tư vấn cơ cấu danh mục 1-1
Gói VIP / DIAMOND
- Tín hiệu VN30 + Midcap hằng phiên
- Backtest 5-15 năm minh bạch
- Báo cáo NAV hằng tháng
- Workshop định lượng hằng tháng
⚠️ Giao dịch chứng khoán có rủi ro mất vốn. Chỉ đầu tư số tiền bạn có thể chịu mất. P.Thai Capital không khuyến nghị mua/bán cụ thể và không bảo lãnh lợi nhuận.
Lý thuyết bài này có thể test trên dữ liệu của bạn:
P.Thai Capital