Backtest bằng Python nên bắt đầu từ dữ liệu giá và tín hiệu đơn giản như thế nào?
Backtest bằng Python
Khi mới tìm hiểu Quant Trading, nhiều người muốn bắt đầu bằng Machine Learning, tối ưu danh mục hoặc một chiến lược có hàng chục điều kiện. Cách làm này thường khiến chúng ta dành nhiều thời gian sửa code nhưng lại chưa hiểu rõ backtest đang mô phỏng điều gì.
Một điểm bắt đầu hợp lý hơn là chọn một cổ phiếu, dữ liệu giá ngày và một tín hiệu đơn giản. Mục tiêu đầu tiên không phải tìm ra chiến lược sinh lời vượt trội, mà là xây đúng quy trình: lấy dữ liệu, tạo tín hiệu, tránh dùng thông tin tương lai, tính chi phí và đánh giá kết quả.
Bài viết này sử dụng cổ phiếu FPT và chiến lược đường trung bình MA20–MA50 để minh họa. Đây là mô hình đủ đơn giản để nhìn thấy toàn bộ logic, nhưng vẫn chứa hầu hết những vấn đề quan trọng của một backtest thực tế.
Phần 1. Bắt đầu từ dữ liệu giá sạch và một câu hỏi rõ ràng
Trước khi viết code, nên xác định chính xác câu hỏi mà backtest cần trả lời. Trong ví dụ này, câu hỏi là:
Khi giá trung bình 20 phiên của FPT nằm trên giá trung bình 50 phiên, việc nắm giữ cổ phiếu có tạo kết quả tốt hơn so với mua và giữ hay không?
Câu hỏi này đã xác định được tài sản, loại dữ liệu, điều kiện vào thị trường và phương pháp so sánh. Chúng ta chưa thêm stop-loss, thanh khoản, RSI hay tối ưu tham số. Mỗi yếu tố bổ sung quá sớm đều làm tăng nguy cơ tạo ra một chiến lược đẹp trên dữ liệu cũ nhưng khó giải thích và khó kiểm tra.
Dữ liệu đầu tiên cần chuẩn bị là OHLCV theo ngày, gồm giá mở cửa, cao nhất, thấp nhất, đóng cửa và khối lượng. Với thị trường Việt Nam, thư viện vnstock giúp tải dữ liệu về dưới dạng Pandas DataFrame. Mã dưới đây sử dụng giao diện của Vnstock 4.x; phiên bản hiện hành trên PyPI yêu cầu Python từ 3.10 trở lên.

Trên Google Colab hoặc Jupyter Notebook, trước tiên cài các thư viện:
%pip install -q -U vnstock pandas numpy matplotlibSau đó tải dữ liệu FPT:
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
from vnstock import Market
SYMBOL = "FPT"
START_DATE = "2020-01-01"
END_DATE = "2026-07-20"
market = Market()
# Vnstock 4.x: chọn nhóm dữ liệu cổ phiếu rồi lấy OHLCV
df = market.equity(symbol=SYMBOL).ohlcv(
start=START_DATE,
end=END_DATE,
resolution="1D"
)
print(df.head())
print(df.columns)Thông thường dữ liệu trả về có các cột:
time, open, high, low, close, volumeTuy nhiên, không nên lấy dữ liệu xong rồi lập tức tính tín hiệu. Bước kiểm tra dữ liệu khá đơn giản nhưng rất quan trọng: ngày giao dịch phải được sắp đúng thứ tự, không có ngày trùng, giá phải chuyển sang dạng số và các quan sát thiếu cần được xử lý rõ ràng.
required_columns = [
"time", "open", "high", "low", "close", "volume"
]
missing_columns = set(required_columns) - set(df.columns)
if missing_columns:
raise ValueError(
f"Dữ liệu thiếu các cột: {sorted(missing_columns)}"
)
df = df[required_columns].copy()
# Chuẩn hóa thời gian
df["time"] = pd.to_datetime(df["time"], errors="coerce")
# Chuẩn hóa các cột số
price_columns = ["open", "high", "low", "close", "volume"]
for column in price_columns:
df[column] = pd.to_numeric(df[column], errors="coerce")
# Sắp xếp, loại ngày trùng và đặt ngày làm index
df = (
df.dropna(subset=["time", "open", "close"])
.sort_values("time")
.drop_duplicates(subset="time", keep="last")
.set_index("time")
)
# Loại các quan sát không hợp lệ
df = df[
(df["open"] > 0)
& (df["high"] > 0)
& (df["low"] > 0)
& (df["close"] > 0)
& (df["volume"] >= 0)
].copy()
print(df.info())
print(df.tail())Nên nhìn nhanh biểu đồ giá và khối lượng trước khi tiếp tục. Một mức giá nhảy bất thường, nhiều ngày bị thiếu hoặc khối lượng bằng không trong thời gian dài có thể là dấu hiệu cần kiểm tra lại dữ liệu.
fig, ax = plt.subplots(figsize=(12, 5))
ax.plot(df.index, df["close"], label=f"Giá đóng cửa {SYMBOL}")
ax.set_title(f"Lịch sử giá {SYMBOL}")
ax.set_xlabel("Thời gian")
ax.set_ylabel("Giá")
ax.legend()
ax.grid(alpha=0.3)
plt.show()Một vấn đề cần đặc biệt lưu ý là điều chỉnh giá do chia cổ tức, thưởng cổ phiếu, chia tách hoặc phát hành thêm. Nếu chuỗi giá không được điều chỉnh phù hợp, một sự kiện doanh nghiệp có thể bị hiểu nhầm thành cú giảm giá lớn và tạo tín hiệu sai. Vì vậy, trước khi sử dụng kết quả nghiêm túc, cần kiểm tra cách nguồn dữ liệu xử lý corporate actions.
Bản thân Vnstock cũng lưu ý dữ liệu được trích xuất từ các nguồn công khai có thể không đầy đủ, không liên tục hoặc có sai lệch; công cụ phù hợp cho học tập và nghiên cứu cá nhân hơn là đưa thẳng vào giao dịch thực tế khi chưa kiểm chứng.
Phần 2. Tạo tín hiệu đơn giản nhưng phải tránh nhìn trước tương lai
Sau khi có dữ liệu, chúng ta xây hai đường trung bình:
- MA20: giá đóng cửa trung bình của 20 phiên gần nhất.
- MA50: giá đóng cửa trung bình của 50 phiên gần nhất.
Quy tắc chiến lược rất đơn giản:
MA20 cao hơn MA50 thì nắm giữ cổ phiếu. MA20 thấp hơn hoặc bằng MA50 thì giữ tiền mặt.
Đây là chiến lược theo xu hướng. Ý tưởng phía sau là xu hướng ngắn hạn mạnh hơn xu hướng trung hạn có thể phản ánh trạng thái giá đang tích cực. Điều đó không có nghĩa MA20–MA50 luôn có khả năng dự báo. Mục đích ở đây là dùng một tín hiệu dễ hiểu để học cách xây backtest đúng.
Pandas cung cấp phương thức rolling() để tính toán trên cửa sổ dữ liệu trượt. Với cửa sổ 20, mỗi giá trị MA20 được tính từ tối đa 20 quan sát gần nhất; những phiên đầu tiên chưa đủ dữ liệu sẽ nhận giá trị thiếu.
FAST_MA = 20
SLOW_MA = 50
df["ma20"] = df["close"].rolling(
window=FAST_MA,
min_periods=FAST_MA
).mean()
df["ma50"] = df["close"].rolling(
window=SLOW_MA,
min_periods=SLOW_MA
).mean()
# Tín hiệu được xác định sau khi phiên giao dịch kết thúc
df["signal"] = (
df["ma20"] > df["ma50"]
).astype(int)Đến đây xuất hiện lỗi phổ biến nhất của người mới backtest: lấy tín hiệu của hôm nay rồi áp dụng ngay vào lợi suất của chính hôm nay.
Ví dụ, MA20 của ngày 20/7 chỉ được tính đầy đủ sau khi đã biết giá đóng cửa ngày 20/7. Nếu chiến lược lại giả định đã nắm giữ từ đầu ngày 20/7, mô hình đã sử dụng thông tin tương lai. Đây là look-ahead bias.
Trong ví dụ này, tín hiệu được tính sau giá đóng cửa và vị thế chỉ bắt đầu từ giá mở cửa phiên tiếp theo. Vì thế, cần dịch tín hiệu đi một phiên bằng shift(1). Phương thức shift() cho phép di chuyển dữ liệu theo số kỳ được chỉ định.
# Tín hiệu của ngày hôm trước quyết định vị thế tại
# giá mở cửa ngày hôm sau
df["position"] = df["signal"].shift(1).fillna(0)Tiếp theo, chúng ta tính lợi suất từ giá mở cửa hôm nay đến giá mở cửa phiên kế tiếp. Cách này phù hợp với giả định tín hiệu được xác nhận sau khi đóng cửa và lệnh được thực hiện ở đầu phiên sau.
# Lợi suất từ giá mở cửa hôm nay tới giá mở cửa ngày kế tiếp
df["asset_return"] = (
df["open"].shift(-1) / df["open"] - 1
)Khi position = 1, danh mục nhận lợi suất của cổ phiếu. Khi position = 0, danh mục đứng ngoài và lợi suất được giả định bằng 0. Đây là mô hình đơn giản nên chưa tính lãi tiền gửi đối với phần tiền mặt.
Nếu bỏ qua chi phí, lợi suất chiến lược được tính như sau:
df["gross_strategy_return"] = (
df["position"] * df["asset_return"]
)Nhưng một backtest không tính phí thường đẹp hơn đáng kể so với khả năng triển khai thực tế. Mỗi lần chuyển từ tiền mặt sang cổ phiếu hoặc từ cổ phiếu về tiền mặt đều phát sinh phí, thuế, chênh lệch mua bán và trượt giá.
Để minh họa, giả định tổng chi phí mỗi lần thay đổi vị thế là 0,15%. Đây chỉ là một tham số mẫu, không phải mức chi phí cố định áp dụng cho mọi tài khoản.
COST_PER_TRADE = 0.0015 # 0,15% cho mỗi lần mua hoặc bán
# Turnover bằng 1 khi chuyển 0 -> 1 hoặc 1 -> 0
df["turnover"] = (
df["position"]
.diff()
.abs()
.fillna(df["position"].abs())
)
df["strategy_return"] = (
df["gross_strategy_return"]
- df["turnover"] * COST_PER_TRADE
)
# Mua và giữ để làm benchmark
df["buy_hold_return"] = df["asset_return"]
# Phiên cuối chưa có giá mở cửa của ngày kế tiếp
bt = df.dropna(subset=["asset_return"]).copy()Bây giờ tạo đường tăng trưởng của một đồng vốn ban đầu:
bt["strategy_equity"] = (
1 + bt["strategy_return"]
).cumprod()
bt["buy_hold_equity"] = (
1 + bt["buy_hold_return"]
).cumprod()Có thể vẽ tín hiệu mua bán để kiểm tra bằng mắt. Việc này giúp phát hiện những lỗi rất cơ bản như mua trước khi đường trung bình giao cắt, vị thế bị đảo chiều sai hoặc chiến lược sử dụng dữ liệu tương lai.
position_change = bt["position"].diff()
buy_points = bt[position_change == 1]
sell_points = bt[position_change == -1]
fig, ax = plt.subplots(figsize=(14, 7))
ax.plot(
bt.index,
bt["close"],
label="Giá đóng cửa",
linewidth=1.2
)
ax.plot(
bt.index,
bt["ma20"],
label="MA20",
linewidth=1
)
ax.plot(
bt.index,
bt["ma50"],
label="MA50",
linewidth=1
)
ax.scatter(
buy_points.index,
buy_points["open"],
marker="^",
s=70,
label="Mua"
)
ax.scatter(
sell_points.index,
sell_points["open"],
marker="v",
s=70,
label="Bán"
)
ax.set_title(f"Tín hiệu MA20–MA50 trên {SYMBOL}")
ax.set_xlabel("Thời gian")
ax.set_ylabel("Giá")
ax.legend()
ax.grid(alpha=0.3)
plt.show()Tiếp tục vẽ đường tăng trưởng vốn:
fig, ax = plt.subplots(figsize=(12, 5))
ax.plot(
bt.index,
bt["strategy_equity"],
label="Chiến lược MA20–MA50"
)
ax.plot(
bt.index,
bt["buy_hold_equity"],
label="Mua và giữ"
)
ax.set_title("So sánh tăng trưởng một đồng vốn")
ax.set_xlabel("Thời gian")
ax.set_ylabel("Giá trị danh mục")
ax.legend()
ax.grid(alpha=0.3)
plt.show()Đến đây, chúng ta đã có một backtest tối giản nhưng tương đối rõ ràng: tín hiệu dùng dữ liệu đóng cửa, giao dịch ở phiên kế tiếp, có benchmark và có chi phí. Nó chưa đủ để kết luận chiến lược đáng đầu tư, nhưng đã đủ để kiểm tra logic ban đầu.
Phần 3. Đánh giá kết quả và tránh biến backtest thành một biểu đồ đẹp
Nhìn đường tăng trưởng vốn chỉ cho biết chiến lược đã đi như thế nào trong giai đoạn đã chọn. Muốn đánh giá đầy đủ hơn, cần xem ít nhất tổng lợi nhuận, CAGR, biến động, Sharpe Ratio và maximum drawdown.
Tổng lợi nhuận cho biết một đồng vốn đã tăng bao nhiêu. CAGR quy đổi kết quả thành tốc độ tăng trưởng bình quân năm. Biến động đo mức dao động của lợi suất. Sharpe Ratio đơn giản trong ví dụ này đo lợi nhuận trung bình so với biến động, với giả định lãi suất phi rủi ro bằng 0. Maximum drawdown cho biết mức giảm sâu nhất của danh mục tính từ một đỉnh trước đó.
TRADING_DAYS = 252
def performance_stats(returns: pd.Series) -> dict:
returns = returns.dropna()
if returns.empty:
raise ValueError("Chuỗi lợi suất không có dữ liệu.")
wealth = (1 + returns).cumprod()
years = len(returns) / TRADING_DAYS
total_return = wealth.iloc[-1] - 1
cagr = (
wealth.iloc[-1] ** (1 / years) - 1
if years > 0 and wealth.iloc[-1] > 0
else np.nan
)
annual_volatility = (
returns.std(ddof=1) * np.sqrt(TRADING_DAYS)
)
sharpe = (
returns.mean()
/ returns.std(ddof=1)
* np.sqrt(TRADING_DAYS)
if returns.std(ddof=1) > 0
else np.nan
)
running_peak = wealth.cummax()
drawdown = wealth / running_peak - 1
max_drawdown = drawdown.min()
return {
"Tổng lợi nhuận (%)": total_return * 100,
"CAGR (%)": cagr * 100,
"Biến động năm (%)": annual_volatility * 100,
"Sharpe đơn giản": sharpe,
"Max drawdown (%)": max_drawdown * 100,
}
results = pd.DataFrame({
"MA20–MA50": performance_stats(
bt["strategy_return"]
),
"Mua và giữ": performance_stats(
bt["buy_hold_return"]
),
}).T
results["Số lần mua"] = [
int((position_change == 1).sum()),
1
]
print(results.round(2))Để xem drawdown theo thời gian:
strategy_peak = bt["strategy_equity"].cummax()
bt["strategy_drawdown"] = (
bt["strategy_equity"] / strategy_peak - 1
)
fig, ax = plt.subplots(figsize=(12, 4))
ax.fill_between(
bt.index,
bt["strategy_drawdown"],
0,
alpha=0.4
)
ax.set_title("Drawdown của chiến lược MA20–MA50")
ax.set_xlabel("Thời gian")
ax.set_ylabel("Drawdown")
ax.grid(alpha=0.3)
plt.show()Khi đọc kết quả, không nên chỉ hỏi chiến lược có lợi nhuận cao hơn mua và giữ hay không. Một chiến lược có CAGR thấp hơn vẫn có thể hữu ích nếu drawdown nhỏ hơn đáng kể và giúp nhà đầu tư duy trì kỷ luật. Ngược lại, lợi nhuận cao hơn một chút nhưng phải giao dịch quá nhiều hoặc chịu drawdown lớn chưa chắc tạo ra giá trị thực tế.
Kết quả trên một cổ phiếu cũng chưa chứng minh tín hiệu có chất lượng. FPT có thể vô tình phù hợp với chiến lược xu hướng trong giai đoạn nghiên cứu. Để giảm sự phụ thuộc vào một trường hợp riêng lẻ, bước tiếp theo là thử cùng một quy tắc trên nhiều mã có thanh khoản tốt và trên các giai đoạn thị trường khác nhau.
Một kiểm tra đơn giản khác là chia dữ liệu thành hai phần. Giai đoạn đầu dùng để xây dựng ý tưởng, giai đoạn sau dùng để kiểm tra mà không thay đổi quy tắc.
SPLIT_DATE = "2024-01-01"
in_sample = bt[bt.index < SPLIT_DATE]
out_of_sample = bt[bt.index >= SPLIT_DATE]
comparison = pd.DataFrame({
"Trong mẫu": performance_stats(
in_sample["strategy_return"]
),
"Ngoài mẫu": performance_stats(
out_of_sample["strategy_return"]
),
}).T
print(comparison.round(2))Nếu chiến lược rất tốt trong giai đoạn đầu nhưng mất hoàn toàn hiệu quả ở phần ngoài mẫu, có thể tín hiệu chỉ phù hợp với một trạng thái thị trường cụ thể. Điều đó không nhất thiết khiến chiến lược vô dụng, nhưng cần hiểu nó hoạt động trong điều kiện nào.
Tham số cũng cần được kiểm tra theo vùng thay vì chỉ chọn tổ hợp đẹp nhất. Chẳng hạn, MA20–MA50 có kết quả tốt nhưng MA19–MA49 và MA21–MA51 đều rất tệ thì nên nghi ngờ. Một cấu trúc thị trường có thật thường không biến mất hoàn toàn chỉ vì thay đổi một hoặc hai phiên trong tham số.
Sau bước này, backtest có thể được cải thiện dần bằng cách thêm điều kiện thanh khoản, giới hạn tỷ trọng, stop-loss, mức trượt giá thay đổi theo khối lượng và benchmark phù hợp hơn. Nhưng chỉ nên thêm từng thành phần, sau đó kiểm tra xem kết quả cải thiện vì có logic kinh tế hay chỉ vì mô hình đã được điều chỉnh quá sát với quá khứ.
Điểm quan trọng nhất là backtest không có nhiệm vụ chứng minh một chiến lược chắc chắn kiếm tiền. Nó giúp chúng ta loại bỏ những ý tưởng không hợp lý, hiểu cách tín hiệu phản ứng trong nhiều tình huống và ước lượng mức rủi ro có thể gặp phải.
Một quy trình tốt cho người mới có thể gói gọn như sau:
Lấy dữ liệu sạch → xây tín hiệu dễ giải thích → trì hoãn lệnh để tránh look-ahead → tính chi phí → so với benchmark → kiểm tra ngoài mẫu.
Khi đã làm chắc quy trình này, việc chuyển sang nhiều cổ phiếu, factor investing, regime detection hay Machine Learning sẽ dễ hơn rất nhiều. Khi đó, Python không chỉ giúp tạo một biểu đồ backtest đẹp, mà trở thành công cụ kiểm tra xem một ý tưởng giao dịch có thật sự đứng vững trước dữ liệu hay không.
