Một dự án Backtest nên lưu dữ liệu, code và kết quả ra sao?
Dự án Backtest
Với người mới, backtest thường bắt đầu rất đơn giản: tải dữ liệu giá, viết vài dòng code tạo tín hiệu, chạy thử và xem chiến lược lãi hay lỗ. Vấn đề là chỉ sau vài tuần, rất nhiều người rơi vào tình trạng không còn nhớ bộ dữ liệu nào đã dùng, phiên bản code nào tạo ra equity curve đẹp nhất, hay kết quả đó được chạy với tham số nào. Khi đó dự án backtest rất khó kiểm tra lại, khó cải tiến và dễ dẫn tới kết luận sai.
Một dự án backtest tốt không cần phức tạp, nhưng nên được tổ chức sao cho bất kỳ kết quả nào cũng có thể trả lời được ba câu hỏi: dữ liệu nào đã được dùng, logic nào đã tạo ra tín hiệu, và kết quả cụ thể của lần chạy đó là gì.

Phần 1. Dữ liệu nên được lưu tách biệt và có “phiên bản” rõ ràng
Phần đầu tiên của một dự án backtest là dữ liệu. Nếu đang nghiên cứu cổ phiếu Việt Nam, dữ liệu có thể lấy từ Vnstock, API của công ty chứng khoán hoặc một nguồn dữ liệu khác. Điều quan trọng là không nên để code mỗi lần chạy lại tự động tải một bộ dữ liệu khác mà không kiểm soát.
Ví dụ hôm nay chạy backtest với dữ liệu FPT từ 2020 đến 2025. Một tháng sau nhà cung cấp dữ liệu điều chỉnh lại giá cổ tức, stock split hoặc sửa lỗi lịch sử. Nếu code tự tải lại dữ liệu mới, cùng một chiến lược có thể cho kết quả khác mà người nghiên cứu không biết nguyên nhân đến từ đâu.
Cách đơn giản là chia dữ liệu thành hai lớp.
Lớp thứ nhất là raw data, tức dữ liệu nguyên bản vừa tải về. Ví dụ:
data/
├── raw/
│ ├── FPT.csv
│ ├── HPG.csv
│ ├── MBB.csv
│ └── VNINDEX.csvRaw data nên hạn chế chỉnh sửa trực tiếp. Nó giống như “bằng chứng gốc” để sau này có thể kiểm tra lại.
Lớp thứ hai là processed data, tức dữ liệu đã được xử lý để phục vụ model. Ví dụ đã tính return, MA20, RSI, ATR, Relative Strength hay Volume Z-score:
data/
├── processed/
│ ├── features_FPT.csv
│ ├── features_HPG.csv
│ └── universe_features.parquetTại đây có một nguyên tắc rất quan trọng: không nên ghi đè dữ liệu mà không biết thời điểm và logic xử lý.
Ví dụ tốt hơn là lưu:
FPT_2020_2025_downloaded_2026-08-11.csvhoặc ghi metadata:
Source: Vnstock
Ticker: FPT
Start: 2020-01-01
End: 2025-12-31
Downloaded: 2026-08-11
Adjusted Price: YesNghe có vẻ hơi “kỹ thuật”, nhưng đây là thứ cứu rất nhiều thời gian khi backtest bắt đầu lớn.
Người mới cũng nên lưu ý một vấn đề quan trọng khác: dữ liệu cho backtest phải phản ánh thông tin thực sự có sẵn tại thời điểm đó. Ví dụ không nên dùng danh sách VN30 hiện tại để backtest từ năm 2015 rồi giả định tất cả các cổ phiếu đó đều nằm trong VN30 từ đầu. Đây là survivorship bias.
Tương tự, nếu dùng dữ liệu báo cáo tài chính, cần biết ngày công bố thực tế chứ không phải chỉ biết “EPS quý 1”. Nếu EPS quý 1 được công bố vào cuối tháng 4, model không thể sử dụng thông tin đó từ đầu tháng 4.
Vì vậy, dữ liệu không chỉ cần sạch. Nó còn phải point-in-time.
Phần 2. Code nên tách thành từng nhiệm vụ, không viết tất cả vào một notebook duy nhất
Một lỗi phổ biến của người mới là để toàn bộ dự án trong một file:
backtest_final_v2_really_final.ipynbTrong đó vừa tải dữ liệu, vừa tính indicator, vừa tạo signal, vừa chạy backtest, vừa vẽ chart. Lúc đầu rất tiện. Nhưng sau một thời gian, notebook có thể dài hàng nghìn dòng và gần như không thể kiểm tra.
Một cấu trúc đơn giản hơn có thể là:
project/
├── data/
├── src/
│ ├── data_loader.py
│ ├── features.py
│ ├── signals.py
│ ├── backtest.py
│ └── metrics.py
├── notebooks/
│ └── research.ipynb
├── results/
└── config/Mỗi file chỉ làm một nhiệm vụ.
data_loader.py chịu trách nhiệm đọc hoặc tải dữ liệu.
features.py tính RSI, ATR, momentum, Relative Strength hoặc các feature khác.
signals.py chứa logic vào lệnh và thoát lệnh.
backtest.py chạy chiến lược theo thời gian.
metrics.py tính CAGR, Sharpe, Max Drawdown, Win Rate, Profit Factor...
Notebook lúc này chủ yếu dùng để nghiên cứu và trực quan hóa, thay vì chứa toàn bộ engine.
Ví dụ chiến lược breakout có thể được mô tả rất rõ:
signal = (
(close > rolling_high_20)
& (volume_zscore > 1.5)
& (rs20 > 0)
)Sau đó phần execution nằm ở nơi khác:
if signal and position == 0:
buy()
if close < stop_price:
sell()Điểm quan trọng là phải tách signal khỏi execution.
Một strategy có thể phát tín hiệu hôm nay nhưng chỉ khớp lệnh ở giá mở cửa ngày mai. Nếu vô tình dùng giá đóng cửa hôm nay để tạo signal rồi cũng giả định mua tại chính giá đóng cửa hôm nay, backtest có thể xuất hiện look-ahead bias.
Đây là lỗi rất phổ biến.
Một cách tổ chức chuyên nghiệp hơn nữa là đưa tham số vào file config:
config/
└── breakout.yamlVí dụ:
lookback: 20
volume_z_min: 1.5
stop_loss: 0.07
holding_period: 10
transaction_cost: 0.0015Như vậy khi thay đổi tham số, không cần sửa logic code. Quan trọng hơn, mỗi lần chạy có thể lưu lại chính xác config đã dùng.
Nếu có Git, nên dùng Git để lưu version code. Không cần quá phức tạp. Chỉ cần hiểu rằng mỗi khi logic thay đổi đáng kể, tạo một commit.
Ví dụ:
Add transaction cost
Fix look-ahead in breakout signal
Change RS window from 20 to 60Khi một kết quả bất thường xuất hiện, có thể quay lại đúng phiên bản code tạo ra kết quả đó.
Đây chính là reproducibility.
Phần 3. Mỗi lần chạy backtest nên tạo ra một “gói kết quả” riêng
Nhiều người chỉ lưu một biểu đồ equity curve. Đây là chưa đủ.
Một lần backtest nên tạo ra ít nhất ba loại kết quả: summary metrics, trade log và equity curve.
Ví dụ cấu trúc:
results/
└── 2026-08-11_breakout_v03/
├── config.yaml
├── summary.csv
├── trades.csv
├── equity_curve.csv
├── equity_curve.png
└── notes.txtsummary.csv có thể chứa:
CAGR
Sharpe
Max Drawdown
Win Rate
Average Win
Average Loss
Profit Factor
Number of Trades
Turnovertrades.csv nên lưu từng giao dịch:
Ticker
Entry Date
Entry Price
Exit Date
Exit Price
Return
Holding Days
Signal
Exit ReasonTrade log cực kỳ quan trọng vì nó cho phép đi sâu hơn vào model.
Ví dụ chiến lược có Sharpe 1.4. Chỉ nhìn Sharpe thì chưa biết edge đến từ đâu. Nhưng khi có trade log, có thể phát hiện:
70% lợi nhuận đến từ nhóm chứng khoán.
Các lệnh có Volume Z-score > 2 hoạt động tốt hơn.
Breakout trong giai đoạn VN-Index dưới MA200 thường thất bại.
Holding period 5–10 ngày tạo phần lớn lợi nhuận.
Như vậy backtest không còn chỉ là “chiến lược này có lời không”, mà trở thành một dataset để nghiên cứu.
Một file khác nên được lưu là equity curve:
Date | Portfolio Value | Daily Return | DrawdownTừ đó có thể dựng chart, tính rolling Sharpe, drawdown duration hoặc stress period.
Cuối cùng là notes.txt hoặc README nhỏ:
Strategy:
20-day breakout
Universe:
VN100
Entry:
Next-day open
Exit:
10-day hold or 7% stop
Transaction cost:
0.15%
Important:
No short selling
No leverageNgười mới thường nghĩ những thông tin này “nhớ được”. Sau 20 lần thử model, gần như chắc chắn sẽ không nhớ.
Một thói quen rất tốt là đặt Run ID cho mỗi lần chạy.
Ví dụ:
BT_20260811_001
BT_20260811_002
BT_20260811_003Khi đó có thể tạo một bảng tổng hợp:
Run ID | Strategy | Parameters | CAGR | Sharpe | Max DDSau vài tháng, đây sẽ trở thành một “research database” của riêng mình.
Điểm cuối cùng và cũng quan trọng nhất: không xóa những backtest xấu.
Rất nhiều người chỉ giữ kết quả đẹp. Điều này tạo ra một dạng selection bias trong quá trình nghiên cứu. Nếu đã thử 50 chiến lược và chỉ giữ lại chiến lược có Sharpe 2.0, cần nhớ rằng chiến lược đó đã “thắng” trong một cuộc thi với 49 model khác.
Việc lưu toàn bộ experiment giúp biết chính xác đã thử bao nhiêu ý tưởng, tham số nào từng được kiểm tra và model nào thất bại.
Một dự án backtest tốt không nhất thiết phải có kiến trúc phức tạp. Với người mới, chỉ cần giữ được nguyên tắc:
Raw Data → Processed Data → Code → Config → Backtest → Trade Log → Metrics → Chart
