Vnstock Logo

Danh mục, tối ưu và chống quá khớp

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.

Ai dùng được

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àmLàm gì
run_portfolioBacktest nhiều mã chung một tài khoản tiền
sweepChạ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_forwardChọn bộ tốt nhất trong mẫu, chấm điểm ngoài mẫu, cuộn theo thời gian
monte_carloLấ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_ratioXá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ã

Python
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, data và một dạng tín hiệu (signals, entries/exits, hoặc rule_dsl). weight mặc định 1.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_portfolio nhận cùng tham số cấu hình với run, kể cả pyramiding, lệnh giới hạn/dừng theo mã (entry_limit, entry_stop, exit_stop khai 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 khai symbol để áp đúng mã; thiếu symbol là lỗi QUANT_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:

Python
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ần symbol, data, weight (hoặc target_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ới period. 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ỳ.
  • weight củ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), khai target_weights theo phiên cho từng mã, giá trị None nghĩ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_bars phiên kế tiếp (mặc định 5), hoặc mua đủ ngay nếu bạn bật use_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ố

Python
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_trialsSố tổ hợp đã chạy
best_param_id, best_sharpeTổ hợp có Sharpe cao nhất
deflated_sharpe_ratioDeflated 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
rowsMỗ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

Python
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
foldsMỗ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_efficiencyLợ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_pctLợi suất của các đoạn ngoài mẫu nối lại
overfit_warningTrue khi walk_forward_efficiency dưới 0,5

Monte Carlo

Python
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

Python
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ột sharpe_ratio trong rows của sweep.

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.

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