Danh mục, tối ưu và chống quá khớp
Mục lục
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.
Các hàm trên trang này mở từ cấp Bronze. deflated_sharpe_ratio mở cho mọi cấp. Xem quy mô mỗi lần chạy ở trang Cấp quyền và xử lý lỗi.
Khi thử nhiều bộ tham số rồi chọn bộ có kết quả cao nhất, bạn cũng chọn cả phần thuận lợi do ngẫu nhiên. Các công cụ dưới đây giúp kiểm tra kết quả ngoài mẫu và đánh giá rủi ro quá khớp.
| Hàm | Làm gì |
|---|---|
run_portfolio | Backtest nhiều mã chung một tài khoản tiền |
sweep | Chạy song song nhiều tổ hợp tham số trên một mã, kèm Deflated Sharpe của bộ tốt nhất |
walk_forward | Chọn bộ tốt nhất trong mẫu, chấm điểm ngoài mẫu, cuộn theo thời gian |
monte_carlo | Lấy mẫu lại lợi suất để ước lượng phân phối tài sản cuối kỳ và sụt giảm |
deflated_sharpe_ratio | Xác suất Sharpe thật sự dương sau khi tính tới số lần thử |
Danh mục nhiều mã
res = bt.run_portfolio(
[
{"symbol": "FPT", "data": df_fpt, "rule_dsl": dsl, "weight": 1.0},
{"symbol": "VNM", "data": df_vnm, "rule_dsl": dsl, "weight": 1.0},
],
profile="vn_hose",
broker="ssi",
position_size_pct=0.5,
)
print(res.summary()["total_return_pct"])- Mỗi mã khai
symbol,datavà một dạng tín hiệu (signals,entries/exits, hoặcrule_dsl).weightmặc định1.0. - Các mã dùng chung một tài khoản tiền: tiền bán của mã này dùng được cho mã khác khi đã về tài khoản.
- Khối lượng mỗi lệnh bằng quy tắc khối lượng (
position_size_pct, ...) nhân với tỷ trọng đã chuẩn hoá của mã. - Mọi mã phải có cùng các ngày giao dịch. Lệch ngày là lỗi
QUANT_INPUT_DATA. run_portfolionhận cùng tham số cấu hình vớirun, kể cảpyramiding, lệnh giới hạn/dừng theo mã (entry_limit,entry_stop,exit_stopkhai trong từng phần tửassets) vàaccount_guard— áp dụng cho cả danh mục. Kết quả làBacktestResult, không có biểu đồ nến.- Sự kiện quyền (
corporate_actions) trong danh mục nhiều mã phải khaisymbolđể áp đúng mã; thiếusymbollà lỗiQUANT_INVALID_PARAMETER.
Tái cân bằng theo tỷ trọng mục tiêu
Thay cho việc tự tính khi nào bán mã này mua mã kia, đặt rebalance để thư viện tự đưa tỷ trọng về mục tiêu theo lịch bạn chọn:
res = bt.run_portfolio(
[
{"symbol": "FPT", "data": df_fpt, "weight": 0.5},
{"symbol": "VNM", "data": df_vnm, "weight": 0.5},
],
profile="vn_hose",
broker="ssi",
rebalance={"period": "quarter", "threshold": 0.05},
)
print(res.summary()["rebalance_count"])- Không cần khai tín hiệu vào/ra: khi đã có
rebalance, mỗi mã chỉ cầnsymbol,data,weight(hoặctarget_weightsđổi theo thời gian — xem dưới). period:"month","quarter"hoặc"year"— tái cân bằng vào phiên cuối của mỗi kỳ.every_bars: tái cân bằng theo số phiên cố định, dùng thay hoặc cùng vớiperiod.threshold: tự tái cân bằng ngay khi tỷ trọng một mã lệch quá mức này so với mục tiêu, không cần đợi hết kỳ.weightcủa mỗi mã là tỷ trọng mục tiêu (tổng ≤ 1 thì phần còn lại là tiền mặt; tổng > 1 thì được chuẩn hoá lại). Muốn đổi tỷ trọng theo thời gian (ví dụ chiến lược luân chuyển ngành), khaitarget_weightstheo phiên cho từng mã, giá trịNonenghĩa là giữ tỷ trọng hiện tại.- Mỗi lần tái cân bằng: bán trước, mua sau, khối lượng làm tròn theo lô. Tiền bán cổ phiếu về sau T+2 nên lệnh mua thiếu tiền được mua bù dần trong tối đa
catchup_barsphiên kế tiếp (mặc định 5), hoặc mua đủ ngay nếu bạn bậtuse_unsettled_proceeds=True. res.summary()["rebalance_count"]cho biết đã tái cân bằng bao nhiêu lần.- Chỉ áp dụng cho vị thế mua tài sản cơ sở (cổ phiếu, crypto giao ngay); chưa dùng được với hồ sơ phái sinh hay bán khống.
Dò tham số
def dsl(lo, hi):
rsi = {"ind": "rsi", "params": {"length": 14}}
return {"entry_rules": [{"left": rsi, "op": "cross_above", "right": lo}],
"exit_rules": [{"left": rsi, "op": "gt", "right": hi}]}
tasks = [
{"param_id": f"rsi_{lo}_{hi}", "params": {"lo": lo, "hi": hi}, "rule_dsl": dsl(lo, hi)}
for lo in (25, 30, 35) for hi in (65, 70, 75)
]
sweep = bt.sweep(df, tasks, symbol="FPT", broker="ssi")
print(sweep["best_param_id"], sweep["deflated_sharpe_ratio"])Mỗi task có param_id, params (ghi lại để đọc kết quả) và một dạng tín hiệu. stop_loss_pct, take_profit_pct riêng của từng task thì khai trong task. Các tham số cấu hình khác dùng chung cho mọi task.
Kết quả:
| Khoá | Nội dung |
|---|---|
num_trials | Số tổ hợp đã chạy |
best_param_id, best_sharpe | Tổ hợp có Sharpe cao nhất |
deflated_sharpe_ratio | Deflated Sharpe của tổ hợp tốt nhất, tính với số lần thử bằng num_trials và độ lệch chuẩn Sharpe giữa các tổ hợp |
rows | Mỗi tổ hợp một dòng: param_id, params, total_return_pct, cagr_pct, sharpe_ratio, sortino_ratio, max_drawdown_pct, calmar_ratio, profit_factor, win_rate_pct, total_trades, final_equity |
Một lần sweep trừ một lượt mỗi tổ hợp vào dòng ngân sách backtest. Số tổ hợp tối đa mỗi lần theo cấp.
Walk-forward
wf = bt.walk_forward(df, tasks, train_bars=252, test_bars=63, symbol="FPT", broker="ssi")
for fold in wf["folds"]:
print(fold["test_start_date"], fold["best_param_id"], fold["out_of_sample_return_pct"])
print(wf["walk_forward_efficiency"], wf["overfit_warning"])Mỗi cửa sổ (fold) lấy train_bars phiên để chọn tổ hợp tốt nhất, rồi chấm tổ hợp đó trên test_bars phiên ngay sau. Cửa sổ cuộn lên test_bars phiên mỗi lần.
| Khoá | Nội dung |
|---|---|
folds | Mỗi cửa sổ: ngày bắt đầu/kết thúc của đoạn trong mẫu và ngoài mẫu, best_param_id, Sharpe và lợi suất trong mẫu, ngoài mẫu |
walk_forward_efficiency | Lợi suất bình quân mỗi phiên ngoài mẫu chia cho trong mẫu, cộng dồn qua các cửa sổ. Càng thấp thì càng có dấu hiệu quá khớp |
stitched_oos_return_pct | Lợi suất của các đoạn ngoài mẫu nối lại |
overfit_warning | True khi walk_forward_efficiency dưới 0,5 |
Monte Carlo
res = bt.run(df, rule_dsl=dsl(30, 70), symbol="FPT", broker="ssi")
mc = bt.monte_carlo(res.daily_returns, num_paths=1000, ruin_drawdown_threshold_pct=25.0, seed=42)Hàm lấy mẫu lại có hoàn lại lợi suất từng phiên (bootstrap, coi các phiên độc lập với nhau) để dựng num_paths đường vốn. Kết quả: terminal_equity_p5, terminal_equity_p50, terminal_equity_p95, max_drawdown_p50_pct, max_drawdown_p95_pct, và risk_of_ruin_pct, tức tỷ lệ đường có sụt giảm vượt ruin_drawdown_threshold_pct.
Cách lấy mẫu này bỏ qua tính chuỗi của lợi suất: những phiên giảm liên tiếp trong thực tế bị xáo trộn. Hãy đọc kết quả như một khoảng tham khảo, không phải xác suất.
Deflated Sharpe Ratio
dsr = bt.deflated_sharpe_ratio(res.daily_returns, periods_per_year=252, num_trials=1, trial_sharpe_std=0.0)Theo Bailey và Lopez de Prado (2014): xác suất Sharpe thật sự lớn hơn 0, sau khi tính tới độ lệch, độ nhọn của lợi suất và số lần bạn đã thử. Thử càng nhiều tổ hợp thì ngưỡng càng cao. Giá trị gần 1 là tốt; dưới 0,95 thì chưa đủ bằng chứng là chiến lược tốt hơn may rủi.
num_trials: số tổ hợp bạn đã thử, kể cả những tổ hợp đã bỏ đi trước khi gọi hàm. Khai thiếu số lần thử là cách nhanh nhất để tự đánh lừa mình.trial_sharpe_std: độ lệch chuẩn của Sharpe (đã quy đổi theo năm) giữa các lần thử, ví dụ tính từ cộtsharpe_ratiotrongrowscủasweep.
Hàm chỉ trừ hao số lần thử khi có cả hai giá trị. Để trial_sharpe_std=0 thì num_trials không có tác dụng: kết quả là xác suất Sharpe lớn hơn 0 của một lần thử duy nhất. sweep tự tính cả hai giá trị này.
import statistics
# best_returns: lợi suất từng phiên của tổ hợp tốt nhất, lấy từ lần chạy lại bằng bt.run
std = statistics.stdev(r["sharpe_ratio"] for r in sweep["rows"])
dsr = bt.deflated_sharpe_ratio(best_returns, num_trials=sweep["num_trials"], trial_sharpe_std=std)