Vnstock Logo

Chạy backtest và đọc kết quả

Mở rộng

Mục lục

Tính năng đang thử nghiệm

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

Python
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,
) -> BacktestResult

Truyề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_columnsNonedict 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_returnsNoneLợi suất theo phiên của chỉ số so sánh, để tính các thước đo tương đối
rf_annual0.0Lã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_cash100_000_000Vố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_overridesNonedict ghi đè từng trường của hồ sơ
fee0.0015Phí 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
brokerNoneMã 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_timingtheo chế độ"next_open", "next_close", "same_close"
same_bar_prioritytheo chế độ"stop_first", "target_first", "ohlc_path". Xem Chế độ khớp lệnh và đối chứng
use_unsettled_proceedsFalseCho 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_pctNoneCắt lỗ theo tỷ lệ so với giá vào, ví dụ 0.07 là 7%
take_profit_pctNoneChốt lời theo tỷ lệ
trailing_stop_pctNoneCắt lỗ đuổi theo đỉnh, theo tỷ lệ
trail_activation_pctNoneCắt lỗ đuổi chỉ bật sau khi lãi đạt tỷ lệ này
breakeven_trigger_pctNoneLã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_levelsNoneChố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_barsNoneBán ở giá mở cửa sau khi giữ đủ số phiên này
pyramiding1Số lô tối đa của một vị thế cùng mã
order_valid_bars1Số 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_guardNoneNgừng mở vị thế mới khi sụt giảm hoặc chuỗi lệnh lỗ vượt ngưỡng
slippage_pct0.0Trượt giá bất lợi mỗi lần khớp, theo tỷ lệ
slippage_ticks0.0Trượt giá bất lợi thêm, theo số bước giá của hồ sơ
fee_per_contractNonePhí 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_adjustedTrueDữ liệu là giá đã điều chỉnh. Muốn khai corporate_actions thì dùng giá gốc và đặt False
corporate_actionsNoneCổ tức tiền, cổ phiếu thưởng, quyền mua. Xem Luật thị trường, phí và sự kiện
shock_eventsNoneKị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_pctTỷ lệ tiền mặt đang có, ví dụ 0.5 là một nửa
size_equity_pctTỷ lệ tổng tài sản
size_valueSố tiền cố định mỗi lệnh
size_qtySố cổ phiếu cố định mỗi lệnh
risk_per_trade_pctTỷ 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

Python
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_ticks củ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_bars phiê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

Python
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ột exit_stop nế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ên t, có hiệu lực từ phiên t+1. Cách này tránh nhìn trước tương lai: mức dừng của phiên t+1 không được tính từ giá cao/thấp của chính phiên t+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án qty_pct khối lượng đã mua ban đầu khi lãi đạt pct, làm tròn theo lô; các mức phải xếp theo pct tăng dần và tổng qty_pct khô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_reason dạng take_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:

Python
# 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.
  • tags gắn nhãn cho từng lệnh vào; exit_tags cho 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_positions có 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

Python
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ếu close_positions=True thì 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"] (None nếu chưa từng kích hoạt).

Đọc kết quả

run trả một BacktestResult:

Thuộc tính / hàmNội dung
summary() / tear_sheetBả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_ordersLệnh không khớp được và lý do
open_positionsVị 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_returnsLợi suất theo phiên, dùng cho Monte Carlo và Deflated Sharpe
final_equityTổng tài sản cuối kỳ
warningsCả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ómTrường
Lợi nhuậnfinal_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 roannual_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ệnhtotal_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ườnglimit_up_blocked_buys, limit_down_blocked_sells, t2_blocked_sells, insufficient_cash_entries
Khácopen_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).

Vị thế còn mở không phải là lệnh

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.

Text
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_exitThoát theo tín hiệu
stop_loss, take_profit, trailing_stopChạm điểm dừng
breakeven_stopChạm điểm dừng đã dời về giá hoà vốn (breakeven_trigger_pct)
dynamic_stopChạ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_exitThoát vì giữ đủ max_holding_bars phiên
rebalanceBá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_haltBán hết vì account_guard đã kích hoạt (close_positions=True)
..._delayed_by_floor_lockLệ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_settlementLệ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.

Python
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_LOCKEDLệ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_LOCKEDLệ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_LOCKEDCổ 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_LISTEDCổ 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_CASHKhông đủ tiền cho một lô sau phí
NO_STOP_FOR_RISK_SIZINGTí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_LIMITEDThự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_HALTEDaccount_guard đã kích hoạt; lệnh vào bị bỏ

Biểu đồ

Python
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.