Tính phí, thuế và trượt giá VN30F1M trong backtest Python: theo điểm và đồng
Duy Nguyen · · Backtest & giao dịch thuật toán
> Tóm tắt nhanh: Mỗi chiều giao dịch VN30F1M chịu phí sở HNX 2.700 đồng, phí môi giới, thuế 0,1% trên giá chuyển nhượng và trượt giá, nên với giả định trong bài chi phí khứ hồi khoảng 0,82 đến 0,97 điểm tùy mức chỉ số. Nên tính chi phí bằng đồng cho từng chiều rồi chia hệ số nhân 100.000 để quy về điểm. Chiến lược EMA 8/21 lãi 348,5 triệu đồng trước chi phí thành lỗ 379,4 triệu đồng.
Một chiến lược VN30F1M lãi trung bình 0,42 điểm mỗi lệnh trước chi phí nghe có vẻ ổn. Mỗi hợp đồng nhân 100.000 đồng cho một điểm, nên 0,42 điểm là 42.000 đồng. Nhưng chỉ riêng phí sở và phí môi giới một chiều đã khoảng 12.700 đồng, chưa tính thuế và trượt giá. Trong bài này, chính chiến lược đó chuyển từ lãi 348,5 triệu đồng mỗi hợp đồng sang lỗ 379,4 triệu đồng khi tính đủ chi phí.
Bài hướng dẫn cách đưa phí, thuế và trượt giá vào backtest VN30F1M bằng Python, quy đổi tất cả về cùng một đơn vị là điểm và đồng trên mỗi hợp đồng, rồi so sánh hai chiến lược trong ngày có tần suất giao dịch khác nhau. Dữ liệu là nến 1 phút VN30F1M liên tục từ 02/01/2019 đến 08/10/2026 (1.934 phiên). Mọi con số trong bài là số do script in ra.
Chi phí của một lệnh VN30F1M gồm những gì
Mỗi lần bạn mở hoặc đóng một hợp đồng (gọi là một chiều), có bốn khoản chi phí:
- Phí giao dịch nộp cho Sở Giao dịch Chứng khoán Hà Nội (HNX), 2.700 đồng mỗi hợp đồng mỗi chiều.
- Phí môi giới của công ty chứng khoán, cũng thu theo hợp đồng, chênh lệch rất nhiều giữa các công ty và các gói phí.
- Thuế thu nhập cá nhân 0,1% trên giá chuyển nhượng, khấu trừ trên mỗi lệnh khớp, cả lệnh mở lẫn lệnh đóng. Với hợp đồng tương lai, giá chuyển nhượng không phải toàn bộ giá trị hợp đồng mà bằng giá nhân hệ số nhân 100.000 nhân tỷ lệ ký quỹ ban đầu (hiện là 17% theo VSDC) rồi chia 2. Vì vậy thuế tăng theo mức giá của chỉ số. Cách tính này và các mức phí được tổng hợp trong bài của DSC và 24hMoney, trong đó cơ sở tính thuế theo từng lệnh áp dụng từ 01/07/2026.
- Trượt giá (slippage), tức phần chênh giữa giá bạn định khớp và giá khớp thật. Khoản này không có trong biểu phí nào nhưng thường là khoản lớn nhất với lệnh thị trường.
Ngoài ra, Tổng công ty Lưu ký và Bù trừ (VSDC) thu phí quản lý vị thế 2.550 đồng mỗi hợp đồng mỗi ngày, chỉ áp dụng khi bạn giữ vị thế qua đêm, nên khoản này quan trọng với chiến lược swing nắm giữ nhiều phiên. Hai chiến lược trong bài đều đóng lệnh trong phiên nên phí này bằng 0, nhưng code vẫn để sẵn tham số để bạn dùng cho chiến lược giữ qua đêm.
Phí môi giới 10.000 đồng mỗi hợp đồng mỗi chiều và trượt giá 0,2 điểm mỗi chiều (hai bước giá 0,1 điểm) trong code là giả định để minh họa. Phí môi giới khác nhau giữa các công ty và các gói, nên trước khi dùng kết quả, hãy thay bằng con số trong biểu phí của công ty chứng khoán bạn đang dùng.
Bước 1: viết hàm chi phí theo đồng và theo điểm
Nguyên tắc quan trọng nhất là tính chi phí cho từng chiều của từng hợp đồng, bằng đồng, rồi mới quy về điểm bằng cách chia cho hệ số nhân 100.000. Làm như vậy thì phí cố định (tính theo hợp đồng) và thuế (tính theo giá) được cộng đúng với nhau, và bạn có thể bật tắt từng khoản để xem khoản nào ảnh hưởng nhiều nhất.
import numpy as np
import pandas as pd
PATH = "vn30f1m_1m_2017-08-10_2026-10-08.parquet"
START, END = "2019-01-01", "2026-10-08"
MULT = 100_000 # 1 điểm = 100.000 đồng / hợp đồng
COSTS = { # kiểm tra lại với biểu phí của công ty bạn
"phi_so_hnx": 2_700, # phí giao dịch HNX, đồng / hợp đồng / chiều
"phi_moi_gioi": 10_000, # giả định, đồng / hợp đồng / chiều, tùy công ty
"phi_qlvt_vsdc": 2_550, # phí quản lý vị thế VSDC, đồng / hợp đồng / đêm giữ qua đêm
"thue_rate": 0.001, # 0,1% trên giá chuyển nhượng
"ty_le_ky_quy": 0.17, # tỷ lệ ký quỹ ban đầu dùng trong cơ sở tính thuế
"truot_gia_diem": 0.2, # trượt giá, điểm / chiều (2 bước giá 0,1)
}
def cost_per_side_vnd(price, c=COSTS, fees=True, tax=True, slip=True):
"""Chi phí MỘT chiều (mở hoặc đóng) của MỘT hợp đồng, tính bằng đồng."""
total = 0.0
if fees:
total += c["phi_so_hnx"] + c["phi_moi_gioi"]
if tax: # giá chuyển nhượng = giá x hệ số nhân x tỷ lệ ký quỹ / 2
total += c["thue_rate"] * price * MULT * c["ty_le_ky_quy"] / 2
if slip:
total += c["truot_gia_diem"] * MULT
return total
for p in [1_000, 1_300, 1_880]:
f = cost_per_side_vnd(p, tax=False, slip=False)
t = cost_per_side_vnd(p, fees=False, slip=False)
s = cost_per_side_vnd(p, fees=False, tax=False)
rt = 2 * (f + t + s)
print(f"Giá {p}: phí {f:,.0f} | thuế {t:,.0f} | trượt {s:,.0f} đồng/chiều"
f" -> khứ hồi {rt:,.0f} đồng = {rt / MULT:.2f} điểm")
Với giả định trên, một chiều có 12.700 đồng phí cố định và 20.000 đồng trượt giá. Thuế thay đổi theo giá: 8.500 đồng khi chỉ số ở 1.000 điểm, 11.050 đồng ở 1.300 điểm và 15.980 đồng ở 1.880 điểm. Cộng cả hai chiều, chi phí khứ hồi (mở rồi đóng một lệnh) là 0,82 điểm, 0,88 điểm và 0,97 điểm tương ứng. Như vậy cùng một chiến lược, khi chỉ số ở vùng giá cao thì mỗi lệnh tốn nhiều hơn. Đây là lý do không nên nhập một con số chi phí cố định theo điểm cho cả giai đoạn backtest dài.
Bước 2: chuẩn bị dữ liệu và hai chiến lược trong ngày
Để thấy chi phí tác động thế nào theo tần suất giao dịch, bài dùng hai chiến lược đơn giản, cả hai đều mở và đóng trong cùng phiên:
- Chiến lược A (đà 30 phút đầu): nếu giá đóng nến 09:29 cao hơn giá mở 09:00 thì mua, thấp hơn thì bán, vào lệnh ở giá mở nến 09:30 và đóng ở giá đóng nến cuối phiên liên tục 14:29. Tối đa một lệnh mỗi phiên.
- Chiến lược B (giao cắt EMA 8/21 trên nến 5 phút): đường EMA nhanh nằm trên EMA chậm thì giữ vị thế mua, nằm dưới thì giữ vị thế bán. Tín hiệu tính trên giá đóng của nến 5 phút và chỉ khớp ở giá mở nến kế tiếp, mọi vị thế đóng ở nến 14:25.
Vì cả hai chiến lược không giữ lệnh qua đêm, lợi nhuận không bị ảnh hưởng bởi ngày roll hợp đồng hay chênh lệch mức giá giữa các nguồn dữ liệu. Code vẫn bỏ ngày 19/10/2018 có giá in sai, bỏ các nến lẻ lúc 11:30, 14:30 và nến ATC để chỉ dùng phiên khớp lệnh liên tục.
d = pd.read_parquet(PATH)
ts = d["time_bar"].dt.tz_convert("Asia/Ho_Chi_Minh").dt.tz_localize(None)
bars = pd.DataFrame({c: d[c].astype(float).to_numpy() for c in ["open", "close"]}, index=ts)
bars = bars[~bars.index.duplicated()].sort_index().loc[START:END]
bars = bars[bars.index.normalize() != pd.Timestamp("2018-10-19")] # ngày giá in sai
m = bars.index.hour * 60 + bars.index.minute
bars = bars[(m < 870) & ~((m >= 690) & (m < 780))] # chỉ phiên liên tục, bỏ ATC
bars["date"] = bars.index.normalize()
bars["minute"] = bars.index.hour * 60 + bars.index.minute
print("Phiên:", bars["date"].nunique(), bars.index[0].date(), "->", bars.index[-1].date())
def strat_first30(b):
"""Theo chiều 09:00-09:29: vào lệnh giá mở nến 09:30, thoát giá đóng nến 14:29."""
o900 = b[b.minute == 540].groupby("date")["open"].first()
c929 = b[b.minute < 570].groupby("date")["close"].last()
entry = b[b.minute == 570].groupby("date")["open"].first()
exitp = b.groupby("date")["close"].last()
t = pd.DataFrame({"side": np.sign(c929 - o900), "entry": entry, "exit": exitp}).dropna()
t = t[t["side"] != 0]
t["gross_pts"] = t["side"] * (t["exit"] - t["entry"])
return t
def strat_ema5m(b, fast=8, slow=21):
"""EMA 8/21 trên nến 5 phút, khớp ở giá mở nến kế tiếp, đóng hết ở nến 14:25."""
trades = []
for day, x in b.groupby("date"):
c5 = x.groupby(x.index.floor("5min")).agg(open=("open", "first"), close=("close", "last"))
if len(c5) < slow + 2:
continue
sig = np.sign(c5["close"].ewm(span=fast).mean() - c5["close"].ewm(span=slow).mean())
pos = sig.shift(1).fillna(0).to_numpy().copy() # tín hiệu nến i, khớp nến i+1
pos[-1] = 0 # nến cuối phiên: đóng vị thế
opens = c5["open"].to_numpy()
cur, entry = 0, 0.0
for i in range(len(pos)):
if pos[i] != cur:
if cur != 0:
trades.append((day, cur, entry, opens[i]))
cur, entry = pos[i], opens[i]
t = pd.DataFrame(trades, columns=["date", "side", "entry", "exit"]).set_index("date")
t["gross_pts"] = t["side"] * (t["exit"] - t["entry"])
return t
Hai chiến lược này được chọn để minh họa chi phí, không phải vì chúng tốt. Tham số EMA 8/21 không được tối ưu, và bài không kiểm định ngoài mẫu.
Bước 3: so sánh kết quả với bốn mức chi phí
Hàm apply_costs tính lãi lỗ ròng của từng lệnh theo bốn kịch bản: chưa trừ gì, chỉ trừ phí, trừ phí và thuế, trừ cả phí, thuế và trượt giá. Nếu bảng lệnh có cột nights (số đêm giữ vị thế), hàm cộng thêm phí quản lý vị thế; với hai chiến lược trong ngày, cột này không có và phí bằng 0. Đoạn cuối thử lại với các mức trượt giá khác nhau, vì đây là giả định khó biết chắc nhất.
SCEN = {"Chưa trừ chi phí": (False, False, False), "Phí": (True, False, False),
"Phí + thuế": (True, True, False), "Phí + thuế + trượt giá": (True, True, True)}
def apply_costs(t, c=COSTS):
nights = t["nights"] if "nights" in t else 0 # số đêm giữ vị thế, 0 nếu trong ngày
out = {}
for name, (f, tx, s) in SCEN.items():
overnight = c["phi_qlvt_vsdc"] * nights if f else 0
cost_pts = (cost_per_side_vnd(t["entry"], c, f, tx, s)
+ cost_per_side_vnd(t["exit"], c, f, tx, s) + overnight) / MULT
out[name] = t["gross_pts"] - cost_pts
return pd.DataFrame(out)
n_days = bars["date"].nunique()
results = {}
for label, t in [("A: đà 30 phút đầu", strat_first30(bars)),
("B: EMA 8/21 nến 5 phút", strat_ema5m(bars))]:
net = apply_costs(t)
results[label] = net
print(f"\n{label}: {len(t):,} lệnh ({len(t) / n_days:.2f} lệnh/phiên), chi phí khứ hồi"
f" trung bình {(net.iloc[:, 0] - net.iloc[:, -1]).mean():.2f} điểm")
for name in SCEN:
s = net[name]
print(f" {name:<24} {s.mean():+.2f} điểm/lệnh | tổng {s.sum():+,.0f} điểm"
f" = {s.sum() * MULT / 1e6:+,.1f} triệu đồng/hợp đồng | thắng {(s > 0).mean():.1%}")
by_year = net.groupby(net.index.year).sum().iloc[:, -1]
print(" Số năm có lãi sau mọi chi phí:", (by_year > 0).sum(), "/", len(by_year))
for slip in [0.0, 0.1, 0.3, 0.5]:
alt = apply_costs(t, dict(COSTS, truot_gia_diem=slip)).iloc[:, -1]
print(f" trượt {slip:.1f} điểm/chiều -> {alt.mean():+.2f} điểm/lệnh,"
f" tổng {alt.sum() * MULT / 1e6:+,.1f} triệu đồng")
Chiến lược A: ít lệnh, chi phí ăn một phần lợi nhuận
Chiến lược A có 1.760 lệnh, trung bình 0,91 lệnh mỗi phiên. Trước chi phí, mỗi lệnh lãi trung bình 1,16 điểm, tổng 2.041 điểm, tương đương 204,1 triệu đồng cho một hợp đồng trong cả giai đoạn. Chi phí khứ hồi trung bình là 0,87 điểm. Sau khi trừ phí, thuế và trượt giá, mỗi lệnh còn 0,28 điểm và tổng lãi còn 50,1 triệu đồng. Tỷ lệ lệnh có lãi giảm từ 53,6% xuống 49,6%, và chỉ 5 trên 8 năm có lãi sau mọi chi phí.

Hình 1: Lãi lỗ cộng dồn của chiến lược A cho một hợp đồng, theo bốn mức chi phí. Trục ngang là thứ tự lệnh, nhãn là tháng/năm.
Đường đã trừ đủ chi phí trên hình 1 có giai đoạn nằm dưới 0 dài nhất kéo dài 336 lệnh liên tiếp, từ 04/04/2024 đến 22/08/2025. Nói cách khác, người chạy chiến lược này từ đầu năm 2019 đã có lúc phải chờ hơn một năm mà tài khoản vẫn chưa về lại mức hòa vốn. Một chiến lược như vậy rất nhạy với giả định trượt giá. Với trượt giá 0,1 điểm mỗi chiều, lãi trung bình mỗi lệnh là 0,48 điểm. Với 0,3 điểm, chỉ còn 0,08 điểm. Với 0,5 điểm, chiến lược lỗ 0,32 điểm mỗi lệnh, tổng lỗ 55,5 triệu đồng.
Chiến lược B: nhiều lệnh, chi phí xóa sạch lợi nhuận
Chiến lược B giao dịch nhiều hơn hẳn: 8.359 lệnh, trung bình 4,32 lệnh mỗi phiên. Lãi trước chi phí là 0,42 điểm mỗi lệnh, tổng 3.485 điểm, tức 348,5 triệu đồng cho một hợp đồng, cao hơn chiến lược A. Nếu chỉ nhìn backtest chưa tính chi phí, bạn sẽ chọn B.

Hình 2: Lãi lỗ cộng dồn của chiến lược B cho một hợp đồng, theo bốn mức chi phí.
Chi phí khứ hồi của B là 0,87 điểm, bằng A, nhưng lớn hơn nhiều so với mức lãi 0,42 điểm mỗi lệnh. Trừ phí, B còn lãi 0,16 điểm mỗi lệnh. Trừ thêm thuế, B lỗ 0,05 điểm mỗi lệnh, tổng lỗ 45,1 triệu đồng. Trừ cả trượt giá, B lỗ 0,45 điểm mỗi lệnh, tổng lỗ 379,4 triệu đồng, và không có năm nào trong 8 năm có lãi. Ngay cả khi giả định khớp lệnh hoàn hảo, không trượt giá, B vẫn lỗ.
Tỷ lệ lệnh có lãi của B thấp, 28,3% trước chi phí, vì đây là chiến lược theo xu hướng: nhiều lệnh nhỏ bị cắt khi tín hiệu đảo chiều, lợi nhuận dồn vào một số ít lệnh lớn. Kiểu chiến lược này chịu chi phí nặng nhất, vì lệnh nào cũng phải trả đủ phí và thuế dù lãi hay lỗ.
Áp dụng vào backtest của bạn
Từ hai ví dụ trên, có vài thói quen nên áp dụng mỗi khi backtest VN30F1M:
- Luôn so sánh lãi trung bình mỗi lệnh (tính bằng điểm) với chi phí khứ hồi. Lợi nhuận mỗi lệnh cần lớn hơn chi phí một khoảng an toàn, vì chi phí thực tế thường cao hơn giả định.
- Tính thuế theo giá của từng lệnh, không dùng một con số cố định. Chỉ số càng cao thì thuế mỗi hợp đồng càng lớn.
- Chạy lại với nhiều mức trượt giá. Nếu chiến lược chỉ có lãi khi trượt giá bằng 0 hoặc 0,1 điểm, đó là chiến lược mong manh, nhất là khi bạn dùng lệnh thị trường vào những lúc biến động mạnh.
- Với chiến lược giữ lệnh qua đêm, thêm cột số đêm nắm giữ để tính phí quản lý vị thế, và nhớ rằng chi phí tăng tuyến tính theo số hợp đồng, còn trượt giá có thể tăng nhanh hơn khi bạn đặt khối lượng lớn.
Cách tính trượt giá cố định theo điểm trong bài là cách đơn giản nhất. Có những mô hình chi tiết hơn, ví dụ trượt giá tăng theo biến động hoặc theo khối lượng lệnh so với khối lượng khớp của nến.
Câu hỏi thường gặp
Phí môi giới 0 đồng thì chiến lược B có lãi không? Đặt phi_moi_gioi bằng 0 trong COSTS rồi chạy lại, B có lãi 0,15 điểm mỗi lệnh nếu không có trượt giá (tổng 122,1 triệu đồng). Thêm trượt giá 0,2 điểm mỗi chiều, B lại lỗ 0,25 điểm mỗi lệnh, tổng lỗ 212,3 triệu đồng. Giảm phí môi giới giúp được một phần nhưng không cứu được chiến lược này.
Tại sao không tính chi phí theo phần trăm như cổ phiếu? Phí sở, phí môi giới và phí quản lý vị thế của phái sinh thu theo số hợp đồng, không theo giá trị. Chỉ thuế phụ thuộc vào giá. Tính theo phần trăm sẽ làm sai chi phí khi chỉ số thay đổi nhiều trong giai đoạn backtest.
Lệnh giới hạn (limit) có tránh được trượt giá không? Lệnh giới hạn giảm trượt giá nhưng đổi lại có rủi ro không được khớp, và những lệnh không khớp thường lại là lệnh có lãi (giá chạy đi luôn). Backtest giả định lệnh giới hạn luôn khớp sẽ đẹp hơn thực tế.
Nếu bạn muốn chạy thử chiến lược của mình với chi phí giao dịch thực tế trên VN30F1M mà không phải tự viết toàn bộ phần mô phỏng, hãy tạo bot và chạy backtest trên nền tảng QuantVN. Để tìm hiểu các mô hình trượt giá chi tiết hơn, xem bài mô hình slippage trong backtest. Nếu mới bắt đầu, bài backtest Python cho người mới là điểm khởi đầu phù hợp.