Viết quy tắc giao dịch thành mã chạy được
Quá trình dịch quy tắc sang mã là nơi bạn phát hiện ra phần lớn thứ mình tưởng đang theo thực chất không có định nghĩa nào cả.
Nội dung bài · 9 mục
Viết quy tắc giao dịch thành mã nghe như việc kỹ thuật thuần tuý, nhưng phần khó không nằm ở cú pháp. Nó nằm ở chỗ bạn buộc phải định nghĩa chính xác những gì mình đang làm — và đó là lúc phát hiện ra phần lớn "quy tắc" mình tưởng đang theo thực chất không có định nghĩa nào cả.
Từ câu mô tả tới điều kiện số
Lấy một quy tắc điển hình: mua khi thị trường vượt kháng cự với khối lượng lớn.
Câu này có bốn chỗ mơ hồ, và mỗi chỗ phải trả lời bằng số.
"Kháng cự" là gì? Giá cao nhất trong bao nhiêu phiên gần đây? Hay vùng giá đã chạm mấy lần? Chọn: giá cao nhất 20 phiên gần nhất, không tính phiên hiện tại.
"Vượt" nghĩa là gì? Chạm tới, hay đóng cửa trên? Vượt bao nhiêu mới tính? Chọn: giá đóng cửa cao hơn ngưỡng ít nhất 0,3 phần trăm, để tránh những cú chạm sát mép.
"Khối lượng lớn" là bao nhiêu? Chọn: khối lượng phiên hiện tại lớn hơn 1,3 lần trung bình 20 phiên.
Mua lúc nào? Đây là chỗ sinh lỗi nhiều nhất. Bạn biết giá đóng cửa sau khi phiên đã đóng, lúc đó không đặt lệnh được nữa. Chọn: vào lệnh ở phiên kế tiếp.
Sau bốn câu trả lời, quy tắc mơ hồ đã thành một điều kiện kiểm tra được — và bạn vừa đưa ra bốn quyết định mà trước đây vẫn làm theo cảm tính mỗi lần một khác.
Cách trình bày dạng luồng này đáng vẽ ra trước khi viết mã, vì nó lộ ngay những chỗ bạn chưa quyết định.
Hàm tín hiệu: nhận gì và trả gì
Nguyên tắc quan trọng nhất: hàm tín hiệu chỉ nhận đúng những gì đã biết tại thời điểm đó.
def signal(window: pd.DataFrame) -> str:
"""window: dữ liệu tính tới phiên hiện tại, phiên cuối là phiên vừa đóng."""
if len(window) < 21:
return "flat"
prior = window.iloc[:-1] # bỏ phiên hiện tại khỏi phép tính ngưỡng
last = window.iloc[-1]
resistance = prior["high"].tail(20).max()
avg_volume = prior["volume"].tail(20).mean()
if last["close"] > resistance * 1.003 and last["volume"] > avg_volume * 1.3:
return "long"
return "flat"
Ba chi tiết đáng chú ý trong đoạn mã trên.
Hàm nhận một cửa sổ dữ liệu, không nhận cả bảng. Nhờ vậy nó không có cách nào chạm vào dòng tương lai — lỗi nhìn trước tương lai bị chặn từ thiết kế chứ không phải bằng kỷ luật.
Ngưỡng kháng cự tính trên prior, tức đã bỏ phiên hiện tại. Nếu tính cả phiên hiện tại thì giá đóng cửa hôm nay tham gia vào chính ngưỡng mà nó phải vượt — vô nghĩa nhưng rất hay gặp.
Hàm trả về trạng thái, không trả về khối lượng hay lệnh. Đây là ranh giới với tầng rủi ro và nó phải được giữ nghiêm.
Cùng một hàm cho backtest và chạy thật
Đây là hệ quả trực tiếp của thiết kế trên, và là lý do nó đáng làm.
# backtest: lặp qua lịch sử, mỗi bước đưa vào cửa sổ tính tới thời điểm đó
for i in range(21, len(data)):
s = signal(data.iloc[:i])
...
# chạy thật: mỗi khi có nến mới, đưa vào cửa sổ gần nhất
s = signal(recent_bars)
Hai nơi gọi cùng một hàm. Không có hai bản logic để lệch nhau, và mọi khác biệt giữa kết quả mô phỏng với kết quả thật chắc chắn không đến từ việc mã khác nhau.
Thêm điều kiện thoát
Hàm trên mới trả lời khi nào vào. Thoát cũng phải là quy tắc, không phải quyết định lúc đang có vị thế.
Ba cách thoát thường dùng, và nên chọn trước chứ đừng kết hợp cả ba ngay từ đầu: thoát khi tín hiệu đảo chiều, thoát khi chạm mức lỗ định trước, hoặc thoát sau một số phiên cố định.
Mỗi cách thoát là một giả định riêng về thị trường và cần kiểm định riêng. Người mới thường thêm cả ba rồi tối ưu từng cái — đó là cách tạo ra một hệ thống có sáu tham số mà không cái nào có lý do tồn tại.
Kiểm tra tính ổn định của tham số
Sau khi có hàm chạy được, phép thử quan trọng nhất không phải "lợi nhuận bao nhiêu" mà "kết quả có ổn định khi xê dịch tham số không".
Hình dạng đáng mong đợi là một vùng thoai thoải như trên: các giá trị lân cận đều cho kết quả tương đương. Nếu 20 phiên cho 15% còn 19 và 21 phiên cho 2%, bạn đang nhìn vào nhiễu chứ không phải tín hiệu — và con số 15% đó sẽ không lặp lại. Hình minh hoạ cơ chế, không phải kết quả thật.
Ba lỗi hay gặp khi dịch quy tắc
Dùng dữ liệu chưa có tại thời điểm đó. Đã nói ở trên nhưng nó xuất hiện dưới nhiều dạng tinh vi. Ví dụ tính chỉ báo trên toàn bộ chuỗi rồi mới cắt cửa sổ: phép tính đã chạm dữ liệu tương lai trước khi bạn cắt. Luôn cắt trước, tính sau.
Quy tắc nhìn được nhưng không giao dịch được. Một điều kiện dựa trên giá thấp nhất trong ngày chỉ biết được khi ngày đã kết thúc. Quy tắc dựa trên "giá chạm mức X rồi bật lên" cũng vậy — lúc bạn biết nó đã bật thì đã muộn. Mỗi vế điều kiện phải trả lời được câu hỏi: tại thời điểm ra quyết định, tôi có con số này chưa?
Thêm ngoại lệ để cứu những lệnh thua trong quá khứ. Nhìn backtest thấy ba lệnh lỗ nặng, bạn thêm một điều kiện loại bỏ đúng ba trường hợp đó. Kết quả đẹp hơn ngay, nhưng điều kiện vừa thêm không có lý do kinh tế nào — nó chỉ mô tả ba sự kiện cụ thể đã xảy ra. Đây là dạng khớp quá mức khó nhận ra nhất vì nó đến từ thiện chí cải thiện.
Cách tự kiểm: với mỗi vế điều kiện, viết ra một câu giải thích vì sao nó có ý nghĩa về mặt thị trường. Vế nào không viết được câu đó thì bỏ.
Ghi lại mọi quyết định, kể cả đứng ngoài
Một thói quen đáng tập ngay: hàm tín hiệu chạy xong thì ghi nhật ký cả khi trả về "đứng ngoài", kèm giá trị của từng vế điều kiện.
Nhờ vậy khi bot không vào lệnh trong một nhịp mà lẽ ra nó nên vào, bạn đọc nhật ký là biết vế nào chặn — thay vì đoán. Phần này là nội dung của bài về nhật ký ở vị trí kế tiếp trong lộ trình.
Sơ đồ này là thứ nên vẽ ra trước khi viết dòng mã đầu tiên của phần quản lý trạng thái. Hai nhánh màu đỏ là những đường đi mà người mới hay quên xử lý — và chúng xảy ra thường xuyên hơn đường đi chính.
Bắt đầu bằng quy tắc đơn giản nhất có thể
Có một cám dỗ rất mạnh khi mới viết mã: thêm điều kiện. Thêm một bộ lọc xu hướng, thêm một chỉ báo xác nhận, thêm một ngoại lệ cho phiên đầu tuần.
Mỗi điều kiện thêm vào làm đường vốn trong backtest đẹp lên — gần như luôn luôn. Đó chính là vấn đề: nó đẹp lên vì bạn vừa cho mô hình thêm một chiều để khớp với nhiễu của đúng đoạn dữ liệu này.
Quy tắc đầu tiên nên có tối đa hai hoặc ba tham số, và mỗi tham số phải giải thích được bằng một câu về thị trường. Khi nó chạy thật rồi, bạn sẽ có dữ liệu để biết nên thêm gì — và lúc đó việc thêm có căn cứ chứ không phải đoán.
Trước khi sang bài sau
Nên làm được: viết một hàm tín hiệu nhận cửa sổ dữ liệu và trả về trạng thái; chạy nó qua toàn bộ lịch sử bằng vòng lặp và xem chuỗi tín hiệu sinh ra; và kiểm tính ổn định khi xê dịch tham số.
Bài tiếp theo ghép hàm này vào vòng lặp chính của bot — nơi tín hiệu gặp thời gian thực.
Câu hỏi thường gặp
- Hàm tín hiệu nên trả về gì?
- Trạng thái mong muốn — mua, bán hoặc đứng ngoài — chứ không phải khối lượng hay lệnh cụ thể. Quyết định vào bao nhiêu thuộc về tầng rủi ro.
- Làm sao chặn lỗi nhìn trước tương lai?
- Viết hàm sao cho nó chỉ nhận một cửa sổ dữ liệu tính tới thời điểm hiện tại. Nếu hàm nhận cả bảng dữ liệu và tự chọn dòng, sớm muộn nó sẽ chạm vào dòng tương lai.
- Bao nhiêu tham số là quá nhiều?
- Mỗi tham số thêm vào là một chiều để mô hình học thuộc nhiễu. Quy tắc đầu tiên nên có tối đa hai hoặc ba, và kết quả phải ổn định khi xê dịch chúng.
- Nên viết bằng vòng lặp hay phép tính vector?
- Vector nhanh hơn nhiều khi backtest, nhưng dễ vô tình chạm dữ liệu tương lai. Người mới nên viết bằng vòng lặp trước cho đúng, tối ưu tốc độ sau khi đã chắc kết quả.