Chạy backtest và đọc kết quả
Mục lục
Nội dung trang này giữ nguyên từ bản v0.5.0, chưa viết riêng cho free: ví dụ chạy được nếu tên hàm không đổi. Bản gốc: /docs/vnstock_quant/backtest/chay-backtest.
Tính năng trên trang này đang trong giai đoạn phát triển thử nghiệm và chưa ổn định. Bạn dùng được để tìm hiểu và chạy thử, nhưng tên hàm hay công cụ, tham số và cấu trúc kết quả có thể còn đổi giữa các bản phát hành, cách tính và cách xử lý cũng có thể còn được hiệu chỉnh.
Hãy đối chiếu kết quả với một cách tính độc lập trước khi đưa vào quy trình nghiên cứu, và ghim phiên bản thư viện trong dự án để mã đang chạy không tự đổi theo bản mới.
run
bt.run(
data,
signals=None,
*,
entries=None, exits=None, short_entries=None, short_exits=None, tags=None,
rule_dsl=None, feature_columns=None,
entry_limit=None, entry_stop=None, exit_stop=None, exit_tags=None,
symbol="SYMBOL",
price_unit="auto",
benchmark_returns=None, rf_annual=0.0,
**config,
) -> BacktestResultTruyền đúng một trong ba dạng tín hiệu: signals, entries/exits, hoặc rule_dsl (xem Dữ liệu và tín hiệu). Số phần tử của tín hiệu phải bằng số phiên. entry_limit, entry_stop, exit_stop, exit_tags cũng là mảng theo phiên, cùng độ dài; mục "Lệnh giới hạn, lệnh dừng và điểm thoát" ở dưới giải thích từng tham số.
| Tham số | Mặc định | Ý nghĩa |
|---|---|---|
symbol | "SYMBOL" | Tên mã, ghi vào kết quả |
feature_columns | None | dict tên cột → danh sách số, dùng được như cột trong rule_dsl |
price_unit | "auto" | "auto", "vnd", "thousand_vnd". Xem Đơn vị giá |
benchmark_returns | None | Lợi suất theo phiên của chỉ số so sánh, để tính các thước đo tương đối |
rf_annual | 0.0 | Lãi suất phi rủi ro theo năm, dùng cho Sharpe, Sortino |
Tham số cấu hình (**config)
| Tham số | Mặc định | Ý nghĩa |
|---|---|---|
init_cash | 100_000_000 | Vốn ban đầu, theo đồng |
profile | "vn_hose" | Hồ sơ thị trường: lô, bước giá, biên độ, chu kỳ thanh toán, thuế bán |
profile_overrides | None | dict ghi đè từng trường của hồ sơ |
fee | 0.0015 | Phí mỗi chiều. Một số (cả hai chiều), tuple (mua, bán), hoặc dict mô hình phí. Thuế bán lấy theo hồ sơ, không nằm trong fee |
broker | None | Mã biểu phí trong bt.list_brokers(), thay cho fee |
execution | "vn_realistic" | Chế độ khớp lệnh. Xem Chế độ khớp lệnh và đối chứng |
execution_timing | theo chế độ | "next_open", "next_close", "same_close" |
same_bar_priority | theo chế độ | "stop_first", "target_first", "ohlc_path". Xem Chế độ khớp lệnh và đối chứng |
use_unsettled_proceeds | False | Cho phép dùng tiền bán chưa về tài khoản để mua (ứng trước tiền bán) |
stop_loss_pct | None | Cắt lỗ theo tỷ lệ so với giá vào, ví dụ 0.07 là 7% |
take_profit_pct | None | Chốt lời theo tỷ lệ |
trailing_stop_pct | None | Cắt lỗ đuổi theo đỉnh, theo tỷ lệ |
trail_activation_pct | None | Cắt lỗ đuổi chỉ bật sau khi lãi đạt tỷ lệ này |
breakeven_trigger_pct | None | Lãi đạt tỷ lệ này thì dời điểm dừng về giá hoà vốn (đã tính phí hai chiều và thuế bán) |
take_profit_levels | None | Chốt lời từng phần, ví dụ [{"pct": 0.1, "qty_pct": 0.5}, {"pct": 0.2, "qty_pct": 0.5}] |
max_holding_bars | None | Bán ở giá mở cửa sau khi giữ đủ số phiên này |
pyramiding | 1 | Số lô tối đa của một vị thế cùng mã |
order_valid_bars | 1 | Số phiên một lệnh giới hạn/dừng còn hiệu lực trước khi hết hạn |
account_guard | None | Ngừng mở vị thế mới khi sụt giảm hoặc chuỗi lệnh lỗ vượt ngưỡng |
slippage_pct | 0.0 | Trượt giá bất lợi mỗi lần khớp, theo tỷ lệ |
slippage_ticks | 0.0 | Trượt giá bất lợi thêm, theo số bước giá của hồ sơ |
fee_per_contract | None | Phí cố định mỗi đơn vị (mỗi hợp đồng VN30F) mỗi chiều, cộng thêm vào phí theo tỷ lệ |
prices_adjusted | True | Dữ liệu là giá đã điều chỉnh. Muốn khai corporate_actions thì dùng giá gốc và đặt False |
corporate_actions | None | Cổ tức tiền, cổ phiếu thưởng, quyền mua. Xem Luật thị trường, phí và sự kiện |
shock_events | None | Kịch bản sốc do bạn tự khai |
Truyền một tham số không có trong bảng là lỗi QUANT_INVALID_PARAMETER.
Khối lượng mỗi lệnh
Chọn tối đa một cách. Không chọn thì mỗi lệnh dùng toàn bộ tiền mặt đang có.
| Tham số | Ý nghĩa |
|---|---|
position_size_pct | Tỷ lệ tiền mặt đang có, ví dụ 0.5 là một nửa |
size_equity_pct | Tỷ lệ tổng tài sản |
size_value | Số tiền cố định mỗi lệnh |
size_qty | Số cổ phiếu cố định mỗi lệnh |
risk_per_trade_pct | Tỷ lệ tài sản sẵn sàng mất nếu giá chạm điểm dừng: khối lượng = `vốn × risk_per_trade_pct / |
Chọn thêm max_position_pct để giới hạn giá trị một vị thế theo tỷ lệ tài sản, áp dụng cùng với cách tính khối lượng ở trên.
Khối lượng được làm tròn xuống theo lô của hồ sơ (100 cổ phiếu với cổ phiếu Việt Nam). Ở chế độ mặc định, thư viện chừa đủ tiền cho phí; không đủ tiền cho một lô thì lệnh bị ghi vào rejected_orders với mã INSUFFICIENT_CASH.
Lệnh giới hạn, lệnh dừng và điểm thoát
Mặc định, lệnh vào khớp ở giá mở cửa phiên sau tín hiệu (lệnh thị trường). Bốn cột theo phiên dưới đây cho vào lệnh có điều kiện và dừng lỗ đổi theo thời gian, đều là mảng cùng độ dài với dữ liệu, phần tử None/null nghĩa là không đặt ở phiên đó.
Lệnh vào có điều kiện
res = bt.run(
df, sigs,
entry_limit=[None, 58_000.0, None, ...], # chỉ mua ở phiên 1, với giá giới hạn 58.000 đ
order_valid_bars=3, # lệnh chờ tối đa 3 phiên rồi hết hạn
)entry_limit: giá giới hạn. Lệnh khớp ở giá giới hạn hoặc giá mở cửa tốt hơn, nhưng chỉ khi giá đã đi qua mức đặt một số bước giá (limit_fill_tickscủa hồ sơ, mặc định 1 bước cho cổ phiếu Việt Nam vàvn_futures, 0 cho các hồ sơ khác) — chạm đúng mức chưa chắc đã tới lượt khớp trong hàng đợi thật.entry_stop: giá dừng (mua khi giá vượt lên, kiểu lệnh breakout). Khớp tại mức dừng, hoặc tại giá mở cửa nếu phiên mở cửa đã vượt qua mức đó.- Đặt cả hai cùng phiên thành lệnh dừng-giới hạn: lệnh giới hạn chỉ kích hoạt sau khi giá chạm mức dừng; nếu giá đã vượt qua giới hạn ngay lúc đó, lệnh dừng-giới hạn chuyển thành lệnh giới hạn thường từ phiên sau.
- Lệnh chờ tính từ phiên sau tín hiệu, tối đa
order_valid_barsphiên (mặc định 1, tức chỉ xét đúng phiên kế tiếp). Giá đặt ngoài biên độ của phiên thì chờ ở giá trần/sàn, không bị bỏ ngay. - Một tín hiệu thoát cùng chiều ở phiên sau hủy lệnh chờ chưa khớp.
Dừng lỗ, chốt lời và chốt lời từng phần
res = bt.run(
df, sigs,
stop_loss_pct=0.07,
trailing_stop_pct=0.10, trail_activation_pct=0.05, # cắt lỗ đuổi 10%, chỉ bật khi đã lãi 5%
breakeven_trigger_pct=0.08, # lãi 8% thì dời điểm dừng về giá hoà vốn
take_profit_levels=[{"pct": 0.10, "qty_pct": 0.5}, {"pct": 0.20, "qty_pct": 0.5}],
max_holding_bars=40,
)- Mức dừng ràng buộc là mức chặt nhất trong các mức đang bật:
stop_loss_pct, cắt lỗ đuổi (khi đã kích hoạt), hoà vốn (khi đã kích hoạt), và cộtexit_stopnếu có. exit_stop: mức dừng lỗ tự tính (ví dụ theo ATR, theo đáy swing), biết tại giá đóng cửa phiênt, có hiệu lực từ phiênt+1. Cách này tránh nhìn trước tương lai: mức dừng của phiênt+1không được tính từ giá cao/thấp của chính phiênt+1.breakeven_trigger_pct: giá hoà vốn tính cả phí mua, phí bán và thuế bán 0,1%, không phải đúng giá vào — dời về đúng giá vào vẫn còn lỗ phí và thuế.take_profit_levels: mỗi mức bánqty_pctkhối lượng đã mua ban đầu khi lãi đạtpct, làm tròn theo lô; các mức phải xếp theopcttăng dần và tổngqty_pctkhông vượt 1. Mức cuối của một kế hoạch đủ 100% bán hết phần còn lại. Mỗi mức khớp tạo một lệnh riêng,exit_reasondạngtake_profit_1,take_profit_2, ...max_holding_bars: thoát ở giá mở cửa ngay sau khi giữ đủ số phiên này, không cần chờ tín hiệu hay chạm mức dừng.same_bar_priority(xem Chế độ khớp lệnh) quyết định thứ tự khi một phiên chạm cả mức dừng và mức chốt lời.
Nhiều lô và pyramiding
Mặc định mỗi mã chỉ giữ một lô. Đặt pyramiding lớn hơn 1 để tín hiệu vào cùng chiều, khi đang giữ vị thế, mở thêm một lô mới thay vì bị bỏ qua:
# entries có hai điểm mua (ví dụ bar 10 và bar 40); tags đặt "lan_1"/"lan_2" tương ứng
res = bt.run(df, entries=entries, exits=exits, tags=tags, pyramiding=2)
# exits ở bar 60 chỉ thoát lô "lan_1", lô còn lại tiếp tục giữ
exit_tags = [None] * len(df)
exit_tags[60] = "lan_1"
res = bt.run(df, entries=entries, exits=exits, tags=tags, exit_tags=exit_tags, pyramiding=2)- Mỗi lô giữ giá vào, phí, khối lượng và lịch thanh toán T+2.5 riêng. Mức dừng lỗ, chốt lời và giá trị tài khoản tính theo giá vốn bình quân của các lô đang mở, giống cách công ty chứng khoán hiển thị.
- Lệnh thoát bán theo thứ tự vào trước ra trước (FIFO): mỗi lô bán ra một dòng riêng trong
trades, không gộp chung. tagsgắn nhãn cho từng lệnh vào;exit_tagscho tín hiệu thoát chỉ bán các lô mang đúng nhãn đó (ví dụ "chỉ thoát lô mua lần hai"), bỏ trống thì thoát theo FIFO trên mọi lô.open_positionscó một dòng cho mỗi lô còn mở, không gộp theo mã.
Chặn rủi ro cấp tài khoản
res = bt.run(
df, sigs,
account_guard={"max_drawdown_pct": 0.2, "max_loss_streak": 5, "close_positions": False},
)
print(res.summary()["halted_on"])account_guard xét ở cuối mỗi phiên, trên tài sản tính theo giá đóng cửa và trên các lệnh đã đóng tới phiên đó:
max_drawdown_pct: dừng khi tài sản sụt quá tỷ lệ này so với đỉnh.max_loss_streak: dừng khi có đủ số lệnh lỗ liên tiếp.- Khi kích hoạt: hủy mọi lệnh đang chờ, từ chối (
ACCOUNT_HALTED) mọi lệnh vào sau thời điểm đó, và nếuclose_positions=Truethì xếp lệnh bán hết vị thế đang mở ở giá mở cửa phiên sau (vẫn chịu khóa sàn và chờ T+2.5 như lệnh thường). - Phiên kích hoạt ghi ở
res.summary()["halted_on"](Nonenếu chưa từng kích hoạt).
Đọc kết quả
run trả một BacktestResult:
| Thuộc tính / hàm | Nội dung |
|---|---|
summary() / tear_sheet | Bảng tổng hợp: lợi nhuận, rủi ro, thống kê lệnh, bộ đếm lệnh bị chặn, cảnh báo |
trades / trades_df() | Danh sách lệnh đã đóng, một dòng cho mỗi lô bán ra |
rejected_orders | Lệnh không khớp được và lý do |
open_positions | Vị thế còn mở ở phiên cuối, một dòng cho mỗi lô |
equity_curve / equity_df() | Trạng thái tài khoản từng phiên |
daily_returns | Lợi suất theo phiên, dùng cho Monte Carlo và Deflated Sharpe |
final_equity | Tổng tài sản cuối kỳ |
warnings | Cảnh báo về giả định: đơn vị giá, khớp ở giá đóng cửa, phiên giao dịch ngoài biên độ tính từ giá tham chiếu, vị thế còn mở ở phiên cuối |
Tear sheet
| Nhóm | Trường |
|---|---|
| Lợi nhuận | final_equity, net_profit, total_return_pct, cagr_pct, buy_hold_return_pct (mua-giữ chính mã đó, trước phí và cổ tức; None với danh mục) |
| Rủi ro | annual_volatility_pct, sharpe_ratio, sortino_ratio, calmar_ratio, omega_ratio, max_drawdown_pct, intrabar_max_drawdown_pct, max_runup_pct, max_drawdown_duration_bars, max_drawdown_recovery_bars, ulcer_index, recovery_factor |
| Lệnh | total_trades, winning_trades, losing_trades, win_rate_pct, gross_profit, gross_loss, profit_factor, payoff_ratio, expectancy, sqn, avg_holding_bars, exposure_pct, largest_win, largest_loss, max_consecutive_wins, max_consecutive_losses, avg_bars_winning, avg_bars_losing, avg_mfe_pct, avg_mae_pct |
| Chi phí | total_commission, total_tax, total_dividends |
| Ma sát thị trường | limit_up_blocked_buys, limit_down_blocked_sells, t2_blocked_sells, insufficient_cash_entries |
| Khác | open_positions (số lô còn mở), open_unrealized_pnl, halted_on, rebalance_count, warnings, quant_risk_metrics |
quant_risk_metrics là các thước đo rủi ro tính bằng vnstock_quant: skew, kurtosis, var_historical, var_normal, var_cornish_fisher, cvar_historical, cvar_normal, downside_deviation, stability, limit_hit_ratio, zero_return_ratio và các thước đo ở trên dưới dạng số thập phân.
intrabar_max_drawdown_pct tính sụt giảm theo giá thấp nhất trong phiên, nên luôn sâu hơn hoặc bằng max_drawdown_pct (tính theo giá đóng cửa). max_runup_pct là mức tăng lớn nhất của tài sản (theo giá đóng cửa) từ một đáy trước đó. max_drawdown_duration_bars là số phiên dài nhất tài sản nằm dưới đỉnh cũ; max_drawdown_recovery_bars là số phiên từ đáy sâu nhất quay lại đỉnh cũ (None nếu chưa hồi phục).
total_trades, win_rate_pct, profit_factor và các thống kê lệnh khác chỉ tính trên lệnh đã đóng. open_unrealized_pnl (lãi/lỗ của các lô còn mở, tính theo giá đóng cửa cuối) vẫn nằm trong net_profit và đường tài sản, nhưng không vào thống kê lệnh. Một vị thế đang lỗ nặng mà chưa có tín hiệu thoát sẽ không hiện trong total_trades — đọc cả open_positions trước khi kết luận từ thống kê lệnh. Còn vị thế mở ở phiên cuối thì res.warnings có một dòng nhắc.
Ví dụ tear sheet (chạy ngày 15/10/2026)
Ví dụ ở trang Giới thiệu, dữ liệu FPT từ 04/01/2021 tới 30/09/2026 (1.429 phiên, giá đã điều chỉnh), biểu phí ssi. Đây là kết quả chạy thử để minh hoạ cấu trúc đầu ra, không phải dự báo.
total_return_pct 6.85
max_drawdown_pct -7.15
total_trades 2
win_rate_pct 50.0
total_commission 571657.5
total_tax 194360.0
exposure_pct 4.97
warnings ['Giá có trung vị 61.43 nên được hiểu là nghìn đồng ...']Lệnh đã đóng
Mỗi phần tử của trades là một lô đã bán: symbol, side (long/short), entry_date, entry_price, exit_date, exit_price, quantity, gross_pnl, entry_fee, exit_fee, exit_tax, funding_cost, net_pnl, return_pct, holding_bars, holding_days, exit_reason, tag, mfe_pct, mae_pct.
mfe_pct/mae_pct (biên độ thuận lợi/bất lợi, maximum favourable/adverse excursion) là mức lãi/lỗ tốt nhất và xấu nhất mà giá đạt được trong lúc giữ lô, tính theo % giá vào — kể cả khi lệnh cuối cùng thoát ở mức khác.
exit_reason:
| Giá trị | Ý nghĩa |
|---|---|
signal_exit | Thoát theo tín hiệu |
stop_loss, take_profit, trailing_stop | Chạm điểm dừng |
breakeven_stop | Chạm điểm dừng đã dời về giá hoà vốn (breakeven_trigger_pct) |
dynamic_stop | Chạm mức dừng tự tính ở cột exit_stop |
take_profit_1, take_profit_2, ... | Chạm một mức trong take_profit_levels (đánh số theo thứ tự khai) |
time_exit | Thoát vì giữ đủ max_holding_bars phiên |
rebalance | Bán bớt để tái cân bằng về tỷ trọng mục tiêu (xem Danh mục, tối ưu và chống quá khớp) |
account_halt | Bán hết vì account_guard đã kích hoạt (close_positions=True) |
..._delayed_by_floor_lock | Lệnh thoát bị hoãn vì phiên khóa sàn, khớp ở phiên đầu tiên bán được |
..._delayed_by_settlement | Lệnh thoát bị hoãn vì cổ phiếu chưa về tài khoản (T+2.5) |
Giá trong lệnh tính theo đồng sau khi quy đổi đơn vị.
Vị thế còn mở
Mỗi phần tử của open_positions là một lô chưa bán hết: symbol, side, entry_bar, entry_date, entry_price, quantity, mark_price, mark_date (giá và ngày đóng cửa phiên cuối, dùng để đánh giá), holding_bars, unrealized_pnl (đã trừ phí mua, chưa trừ phí bán và thuế bán), return_pct.
table = res.trades_df(include_open=True) # nối open_positions vào bảng lệnh, cột status="open"/"closed"trades_df(include_open=True) thêm các lô còn mở vào cuối bảng, đánh giá ở giá đóng cửa phiên cuối (exit_reason="open"), kèm cột status. Thống kê trong summary() luôn chỉ tính lệnh đã đóng, không đổi theo tham số này.
Lệnh bị chặn
Mỗi phần tử của rejected_orders: symbol, date, side, reason_code, message.
reason_code | Ý nghĩa |
|---|---|
LIMIT_UP_LOCKED | Lệnh vào vị thế mua rơi vào phiên khóa trần, không có người bán; lệnh bị bỏ, không chuyển sang phiên sau |
LIMIT_DOWN_LOCKED | Lệnh bán rơi vào phiên khóa sàn, không có người mua; lệnh được giữ lại cho phiên sau |
T2_SETTLEMENT_LOCKED | Cổ phiếu mua chưa về tài khoản; lệnh được giữ lại tới phiên bán được |
SHARES_NOT_LISTED | Cổ phiếu thưởng hoặc cổ phiếu mua theo quyền chưa tới ngày niêm yết; lệnh chờ tới ngày đó, không tính vào t2_blocked_sells |
INSUFFICIENT_CASH | Không đủ tiền cho một lô sau phí |
NO_STOP_FOR_RISK_SIZING | Tính khối lượng theo risk_per_trade_pct nhưng không có stop_loss_pct hay exit_stop nằm phía lỗ của giá vào |
RIGHTS_CASH_LIMITED | Thực hiện quyền mua nhưng tiền mặt không đủ mua hết số quyền được hưởng; mua được bao nhiêu hay bấy nhiêu |
ACCOUNT_HALTED | account_guard đã kích hoạt; lệnh vào bị bỏ |
Biểu đồ
res.plot(output_path="fpt.png") # ảnh tĩnh: nến, điểm vào/ra, đường vốn, sụt giảm
html = res.plot_html(output_html="fpt.html") # trang HTML tương tác (tuỳ chọn)plot() cần vnstock_chart (pip install --extra-index-url https://vnstocks.com/api/packages "vnstock-quant[chart]") và chỉ có cho backtest một mã. Ảnh tĩnh phù hợp khi trợ lý AI cần đọc lại kết quả, hoặc khi bạn chèn vào báo cáo.
