Chứng Minh Không Có Look-Ahead Trong Backtest Đa Khung Thời Gian: Nhiễu Loạn Tương Lai, Chứng Minh Quá Khứ Không Thể Nhìn Thấy Nó
Nằm trong series "Backtest Không Ảo Tưởng".
Bài viết trước kết thúc bằng một phép chẩn đoán: kiểm tra dịch-một-nến (one-bar shift test). Dịch mọi lệnh khớp (fill) lùi lại một nến; nếu hiệu suất sụp đổ, nghĩa là bạn đã giao dịch trong quá khứ. Kiểm tra đó là đủ cho một chiến lược đơn khung thời gian, nơi look-ahead ẩn nấp trong khâu thực thi (execution) — bạn ra quyết định tại nến i và khớp lệnh cũng tại nến i.
Các chiến lược đa khung thời gian có một nơi rò rỉ thứ hai, kín đáo hơn, mà kiểm tra dịch-nến không chạm tới được. Khi một quyết định trên nến 1 phút tham chiếu đến xu hướng khung 1 giờ, rò rỉ không còn nằm ở lệnh khớp nữa — nó nằm trong chỉ báo (indicator), được tính từ một nến khung thời gian cao hơn mà vẫn chưa hình thành xong. Bài viết này nói về rò rỉ đó, và về cách chứng minh — bằng cơ chế thực nghiệm, chứ không phải bằng cách nhìn chằm chằm vào code — rằng engine của bạn không mắc phải nó.
Khẳng định mà chúng tôi đưa ra ở cuối bài là mạnh mẽ: engine đa-TF của chúng tôi đã vượt qua 25 trên 25 kiểm tra parity và rò rỉ trên 86,400 nến dữ liệu ETHUSDT 1 phút thực tế, bao gồm một probe dịch-chuyển-tương-lai nhiễu loạn mọi nến tương lai và xác nhận không một quyết định quá khứ nào dịch chuyển. Và vì engine này giống hệt (bit-identical) với lõi chuẩn (canonical core) của bot live, nên bằng chứng này là một thuộc tính của chính hệ thống live, chứ không chỉ của backtest.
Bãi mìn đa khung thời gian

Một chiến lược đa khung thời gian (multi-TF) ra quyết định trên một đồng hồ nhanh trong khi tham chiếu các đồng hồ chậm hơn. Engine của chúng tôi là một ví dụ cụ thể: một quy tắc động lượng (momentum) ba-khung-thời-gian, trong đó xu hướng ở khung thời gian cao (HTF) và xu hướng ở khung thời gian giữa (MTF) khóa cổng (gate) các lệnh vào, còn một cross ở khung thời gian thấp (LTF) kích hoạt (trigger) chúng. Trên đồng hồ nền 1 phút, một lệnh vào chỉ được kích hoạt khi cross HMA/HMA3 trên khung 1 phút cùng chiều với xu hướng khung 1 giờ và xu hướng khung 15 phút; một lệnh thoát được kích hoạt khi xu hướng ở khung thời gian cao hơn đảo chiều ngược với vị thế, hoặc khi LTF cross ngược trở lại.
Nguy hiểm này mang tính cấu trúc. Mỗi một trong các giá trị khung-thời-gian-cao đó phải được trả lời trên từng nến 1 phút — 60 lần mỗi giờ — và mỗi câu trả lời chỉ được phép sử dụng những gì một bot live thực sự biết tại thời điểm đó. Chỉ cần một trong khoảng 180 lượt đọc trên mỗi nến (ba khung thời gian, xu hướng vào và thoát, các cổng độ tách) chạm vào một nến chưa được in ra, là bạn đã có look-ahead. Diện tích bề mặt cho một lỗi lệch-một (off-by-one) là rất lớn, và không như rò rỉ ở khâu thực thi, nó không tự báo hiệu bằng cách làm dịch chuyển lệnh khớp.
Một nến đang hình thành thì chưa có giá đóng cửa cuối cùng
Đây chính là cái bẫy. Giả sử lúc đó là 10:37 và bạn đang ra quyết định trên nến 1 phút vừa đóng. Quy tắc của bạn cần xu hướng khung 1 giờ. Nến 1 giờ bao trùm khoảng 10:00–11:00 vẫn đang hình thành — giá đóng cửa cuối cùng của nó sẽ không được biết đến cho tới 10:59:59. Vậy tại 10:37 bạn thực sự biết những gì? Chỉ là giá đóng cửa đang chạy (running close) của nến đó tính đến lúc này, chính là giá đóng cửa của nến 10:37. Giá đóng cửa cuối cùng của nến đó còn cách 23 phút nữa mới xuất hiện, tức là nằm ở tương lai.
Backtest đa-TF ngây thơ làm một việc trông hoàn toàn vô hại: nó resample toàn bộ chuỗi 1 phút sang khung 1 giờ một lần duy nhất, ngay từ đầu, rồi sau đó, với mỗi nến 1 phút, đọc ra "giá đóng cửa khung 1 giờ." Nhưng giá trị mà nó đọc được cho mọi nến 1 phút nằm giữa 10:00 và 11:00 chính là giá đóng cửa cuối cùng của khoảng 10:00–11:00 — một con số mà, trong thời gian thực, không hề tồn tại cho tới khi giờ đó kết thúc. Mọi quyết định bên trong giờ đó âm thầm được trao cho tới 59 phút của tương lai. Và đây không phải một rò rỉ nhỏ: giá đóng cửa ở khung thời gian cao là một trong những yếu tố dự báo mạnh nhất có thể có cho lợi nhuận khung 1 phút trong tương lai gần mà bạn sắp giao dịch, vì vậy rò rỉ nó gần như là đọc trước đáp án. Đây chính là kênh rò rỉ chỉ báo (indicator-leakage) từ bài phân loại (taxonomy), nhưng được khuếch đại: cái nhìn trộm không chỉ là một nến, mà có thể lên tới toàn bộ một chu kỳ HTF.
Kiểm tra dịch-một-nến từ bài trước không bắt được lỗi này. Bạn có thể dịch lệnh khớp lùi lại một nến, nhưng chuỗi 1 giờ đã resample vẫn bị nhiễm bẩn — rò rỉ nằm ở cách chỉ báo được xây dựng, chứ không nằm ở thời điểm bạn giao dịch.
Quy tắc của bot live, tái tạo chính xác: ngữ nghĩa nến-đã-đóng (closed-bar)

Quy tắc đúng chính là quy tắc mà bot live đã đang chạy. Trong codebase của chúng tôi, đó là một class nhỏ, RunningCandleBuffer, được chuyển nguyên văn ra từ bộ mô phỏng tick (tick simulator) của hệ thống live. Nó truyền dòng (stream) các nến nền thành các nến có chu kỳ cố định, và trên mỗi nến nền, tính chỉ báo HTF từ một mảng rất cụ thể:
all_closes = np.array(self.closes + [self.current_close], dtype=np.float64)
Hãy đọc điều đó theo đúng nghĩa đen. self.closes là các giá đóng cửa cuối cùng của những nến đã đóng xong — một nến chỉ được thêm vào khi một ranh giới chu kỳ mới bị vượt qua, và giá trị được lưu của nó là giá đóng cửa của nến nền cuối cùng bên trong chu kỳ đó (candle_buffer.py:39–44). Nến đang hình thành chỉ đóng góp đúng một con số, self.current_close, chính là giá đóng cửa đang chạy (running) — giá đóng cửa của nến nền gần nhất, base_close[i]. Đó là một đại lượng đã biết tại nến i, theo đúng định nghĩa. Giá đóng cửa cuối cùng của nến đang hình thành không bao giờ được sử dụng, vì nó chưa hề tồn tại.
Vì vậy, chỉ báo HTF tại 10:37 nhìn thấy [..., close(9:00 candle), close(10:37 so far)]. Khi nến 10:38 in ra, ô cuối cùng cập nhật thành close(10:38). Khi vượt qua mốc 11:00, close(10:59) trở thành giá trị cuối cùng của nến vừa đóng, và một ô đang-hình-thành mới được mở ra. Không có thời điểm nào một quyết định bên trong giờ đó chạm vào giá đóng cửa cuối cùng của khoảng 10:00–11:00. Đó chính là ngữ nghĩa nến-đã-đóng (closed-bar): các nến đã đóng đóng góp các giá đóng cửa đã hoàn tất, còn nến đang hình thành chỉ đóng góp giá đóng cửa đang chạy của nó.
Engine tốc độ cao của chúng tôi (engine_multitf.py) là một bản tái triển khai vector hóa, biên dịch bằng numba, của logic này. Thay vì một vòng lặp Python với một danh sách phình to dần, nó tính trước, cho mỗi nến nền i, có bao nhiêu nến đã đóng hoàn tất (n_closed[i]) và xếp cửa sổ HMA/HMA3 thành [closed candle closes…, base_close[i]] — giá đóng cửa đang chạy được ghim vào ô cuối cùng (engine_multitf.py:168–169). Đó là cùng một phép toán, chỉ được trải phẳng ra để tăng tốc trên ba khung thời gian với các cổng độ tách theo hướng (directional separation gates). Hợp đồng (contract) ở đây rất rõ ràng: giá trị tại nến i chỉ phụ thuộc vào base_close[0..i].
Đó là khẳng định. Phần còn lại của bài viết là cách chúng tôi chứng minh nó, bởi vì một khẳng định nằm trong docstring thì chẳng có giá trị gì cả.
Parity là điều kiện cần nhưng chưa đủ
Một engine numba vector hóa, trải phẳng một class dạng streaming thành các vòng lặp tường minh, chính là nơi các lỗi lệch-một (off-by-one) sinh sôi. Vì vậy cổng kiểm tra đầu tiên là một bài kiểm tra parity từng-bit-một (bit-for-bit) so với tham chiếu chuẩn (canonical reference), trên một lát dữ liệu thực — ETHUSDT 1 phút, tháng 1–2 năm 2024, 86,400 nến.
Chúng tôi kiểm tra hai điều độc lập với nhau, đối chiếu với hai tham chiếu độc lập:
- Chỉ báo và các cross so với
RunningCandleBufferchạy từng nến một (bar-by-bar). Với mỗi khung thời gian, chúng tôi phát lại class của hệ thống live trên toàn bộ 86,400 nến và so sánh các sự kiện cross — nến, hướng, độ tách — để có một sự khớp chính xác, cộng thêm các giá trị HMA/HMA3 với dung sai số thực (floating-point tolerance) (tham chiếu dùngnp.dot, engine dùng vòng lặp tường minh, nên thứ tự cộng khác nhau ở khoảng ~1e-15). Các cross khớp chính xác: 408 cross trên HTF (khung 1 giờ, độ dài HMA 21), 2,792 trên MTF (15m/14), 3,691 trên LTF (1m/50). Không một sự kiện cross nào khác biệt về nến hay hướng. - Giao dịch so với một mô phỏng thuần Python độc lập của các quy tắc giao dịch, được điều khiển bởi chính các cross của tham chiếu. Việc này tái tạo lại vòng lặp của backtest live — xu hướng vào khóa cổng các lệnh vào, một đảo chiều xu hướng thoát hoặc một cross LTF ngược chiều sẽ đóng vị thế, khớp lệnh tại
open[i+1], phí khứ hồi (round-trip) 0.09%, buộc đóng vị thế ở nến cuối cùng — mà không dùng bất kỳ cơ chế numba nào của engine. Sau đó chúng tôi so sánh từng giao dịch một: nến vào/ra, chiều lệnh (sides), giá vào/ra, PnL, lý do thoát lệnh, và tổng thời gian giữ vị thế (active-time).
Các ngưỡng độ tách (separation thresholds) trong bài kiểm tra không phải là các giá trị mặc định vô hại. Chúng được chọn để chạm vào một góc khó — một ngưỡng thoát MTF được đặt cao hơn ngưỡng vào tương ứng — điều này buộc phải kích hoạt nhánh "lần đầu tiên xu hướng thoát được xác định trong khi vị thế đang mở", nhánh mà tham chiếu coi là một đảo chiều. Parity phải giữ vững ở các trường hợp góc (corner case), chứ không chỉ ở con đường dễ dàng.
So sánh từng trường dữ liệu một, các giao dịch hoàn toàn giống hệt nhau: 466 giao dịch cho cấu hình dual, 211 giao dịch cho cấu hình triple, với tổng PnL khớp nhau đến 1e-12 và mọi trường dữ liệu của từng giao dịch bằng nhau trong phạm vi dung sai. Hai cách triển khai không chia sẻ bất kỳ đoạn code nào — một engine vector hóa đã biên dịch và một vòng lặp Python ngây thơ chạy trên các cross của một triển khai thứ ba — cho ra cùng một tập giao dịch, khớp đến tận chữ số thập phân cuối cùng.
Đó là một kết quả mạnh mẽ, nhưng nó không phải là bằng chứng của việc không có look-ahead. Parity chỉ cho biết engine tốc độ cao tái tạo trung thực tham chiếu. Nếu bản thân tham chiếu bị rò rỉ — nếu RunningCandleBuffer nhìn trộm — thì parity sẽ trung thực tái tạo lại chính rò rỉ đó và vẫn đạt. Sự nhất quán giữa các cách triển khai chỉ cho bạn biết rằng chúng giống nhau, chứ không phải chúng có tính nhân quả (causal). Để chứng minh tính nhân quả, bạn cần một loại kiểm tra khác, một loại hỏi thẳng engine liệu quá khứ có thể nhìn thấy tương lai hay không.
Probe dịch-chuyển-tương-lai: bằng chứng thực sự

Định nghĩa của look-ahead mang tính vận hành (operational), vì vậy hãy kiểm tra nó theo cách vận hành. Look-ahead nghĩa là một quyết định trong quá khứ phụ thuộc vào dữ liệu tương lai. Mệnh đề phản đảo (contrapositive) chính là một kiểm tra bạn có thể chạy: nếu bạn thay đổi tương lai và bất kỳ quyết định quá khứ nào dịch chuyển, thì quá khứ đã đọc tương lai. Vậy hãy thay đổi tương lai — một cách mạnh tay — và quan sát quá khứ.
Chọn một điểm cắt j tại 60% của chuỗi (nến 51,840 trong tổng số 86,400). Nhiễu loạn mọi nến từ j trở về sau: nhân tất cả giá đóng cửa và giá mở cửa tương lai với 1.05. Tính lại toàn bộ chồng tín hiệu (signal stack) cho cả ba khung thời gian trên dữ liệu đã bị nhiễu loạn. Sau đó khẳng định rằng mọi thứ nằm nghiêm ngặt trước j đều giống hệt nhau ở cấp độ bit (bitwise identical) so với lần chạy không bị nhiễu loạn:
j = int(n * 0.6) # bar 51,840
cl2 = cl.copy(); cl2[j:] *= 1.05 # shove the future up 5%
op2 = op.copy(); op2[j:] *= 1.05
base = [precompute_tf_signals(cl, ts, p, L) for (p, L) in tf_params]
pert = [precompute_tf_signals(cl2, ts, p, L) for (p, L) in tf_params]
for s0, s1 in zip(base, pert):
assert eq_nan(s0.hma[:j], s1.hma[:j]) # HMA identical, NaNs included
assert eq_nan(s0.hma3[:j], s1.hma3[:j])
assert np.array_equal(s0.cross[:j], s1.cross[:j]) # every cross event
assert np.array_equal(s0.sep[:j], s1.sep[:j]) # every separation
Không phải "gần đúng." Không phải "trong phạm vi dung sai." np.array_equal, với yêu cầu NaN phải khớp với NaN — mọi giá trị HMA, mọi giá trị HMA3, mọi cờ cross, và mọi độ tách trên 51,840 nến quá khứ phải là cùng một số thực (float) giống hệt nhau. Sau đó cùng một khẳng định như vậy áp dụng cho giao dịch: mọi giao dịch có thời điểm thoát lệnh trước j phải không đổi ở từng trường dữ liệu. Nếu một cú đẩy 5% vào tương lai làm dịch chuyển dù chỉ một giá trị HMA quá khứ ở chữ số thập phân thứ mười hai, thì một quyết định quá khứ đã tham chiếu tới tương lai, và probe thất bại.
Engine của chúng tôi vượt qua bài kiểm tra này — cho cả ba khung thời gian, và cho cả hai mô phỏng giao dịch dual lẫn triple. Việc nhiễu loạn 34,560 nến tương lai để lại 51,840 nến quá khứ và mọi giao dịch đã đóng trong số đó nguyên vẹn y hệt như trước. Đó không phải là sự nhất quán giữa các cách triển khai; đó là một minh chứng trực tiếp rằng ranh giới thông tin theo thời gian được giữ vững.
Một phép thử không thể thất bại thì chẳng chứng minh được điều gì
Có một cách để vượt qua probe ở trên mà chẳng chứng minh được gì cả: nếu sự nhiễu loạn là một no-op — nếu việc nhân tương lai với 1.05 không thay đổi bất cứ điều gì ở bất cứ đâu — thì phát biểu "quá khứ không đổi" là hiển nhiên đúng và hoàn toàn vô nghĩa. Một dấu tick xanh trên một bài kiểm tra không thể thất bại còn tệ hơn là không có kiểm tra nào cả, bởi vì nó tạo ra một sự tự tin giả tạo. Vì vậy probe này mang thêm hai khẳng định nữa để nó thực sự có hiệu lực.
Tương lai phải thực sự thay đổi. Chúng tôi khẳng định rằng sự nhiễu loạn đã thực sự làm thay đổi các cross ở đâu đó trong khoảng [j, n):
assert not np.array_equal(s0.cross[j:], s1.cross[j:]) # probe has teeth
Giờ đây kết quả mới có ý nghĩa: cùng một cú đẩy 5% viết lại toàn bộ tương lai lại để quá khứ giống hệt nhau từng bit một. Sự nhiễu loạn là có thật, nó lan truyền về phía trước, và nó dừng hẳn lại tại điểm cắt. Một rò rỉ một chiều — quá khứ đọc tương lai — sẽ lan ngược trở lại qua j; nhưng ở đây điều đó không xảy ra.
Ranh giới nằm chính xác tại nến hiện tại — không sớm một nến, không muộn một nến. Một kiểu thất bại tinh vi hơn sẽ là một engine có tính nhân quả nhưng trễ (stale): nó bỏ qua giá đóng cửa đang chạy của nến hiện tại và phản ứng chậm một nến (không rò rỉ, nhưng có độ trễ mà giao dịch live sẽ không có), hoặc một engine phản ứng sớm một nến (một rò rỉ một-nến). Vì vậy chúng tôi nhiễu loạn một nến duy nhất j (nhân với 1.02) và khẳng định hai điều cùng lúc: quá khứ [0, j) không bị đụng chạm, và hma[j] phản ứng ngay lập tức.
cl3 = cl.copy(); cl3[j] *= 1.02 # nudge exactly one bar
s3 = precompute_tf_signals(cl3, ts, p_ltf, L_ltf)
assert eq_nan(s0.hma[:j], s3.hma[:j]) # nothing before j moves
assert s0.hma[j] != s3.hma[j] # bar j reacts on the same bar
Điều này ghim chặt ranh giới một cách chính xác. Giá đóng cửa đang chạy của nến đang hình thành đi vào chỉ báo tại nến j với độ trễ bằng không và không hề có khả năng nhìn trước (foresight): nến j nhìn thấy giá đóng cửa của chính nó ngay lập tức, và không nến nào trước đó nhìn thấy nó cả. Đó chính là lằn ranh dao cạo (knife-edge) mà ngữ nghĩa nến-đã-đóng phải đứng đúng trên đó, và bài kiểm tra xác nhận engine đứng đúng vị trí đó.
Đây là toàn bộ cổng kiểm tra — tất cả 25 kiểm tra đứng giữa engine này và một backtest bị ngụy tạo:
| Nhóm | Mỗi kiểm tra khẳng định điều gì | Số lượng |
|---|---|---|
| Chỉ báo & cross (×3 khung thời gian) | các sự kiện cross khớp chính xác với RunningCandleBuffer; độ tách tại các điểm cross; giá trị HMA/HMA3 (rtol 1e-9) |
9 |
| Giao dịch (Trades) (dual + triple) | số lượng giao dịch; khớp từng trường dữ liệu; tổng PnL đến 1e-12; tổng thời gian giữ vị thế (active-time) | 8 |
| Probe dịch-chuyển-tương-lai (dual + triple) | tín hiệu quá khứ không đổi ở cấp bit; probe thực sự có hiệu lực (tương lai có thay đổi); giao dịch trước j không đổi; nhiễu loạn một nến đơn lẻ được khoanh vùng chính xác |
8 |
| Tổng | 25 |
Hai nhóm đầu tiên xác lập rằng engine tốc độ cao chính là tham chiếu live. Nhóm thứ ba xác lập rằng tham chiếu đó có tính nhân quả. Bạn cần cả ba: một engine tốc độ cao bị rò rỉ, một engine bị rò rỉ nhưng vẫn có tính nhân quả, và một engine có tính nhân quả nhưng bị trễ là ba kiểu thất bại khác nhau, và cổng kiểm tra này loại trừ từng kiểu một.
Vì sao probe này không quan tâm đến khung thời gian cụ thể
Vẻ đẹp của probe dịch-chuyển-tương-lai nằm ở chỗ nó không biết và cũng không quan tâm rò rỉ nằm ở đâu. Nó không bao giờ đề cập đến khung thời gian, resampling, hay ranh giới nến. Nó chỉ hỏi: liệu việc nhiễu loạn tương lai có làm dịch chuyển quá khứ hay không? Điều đó khiến nó trở thành công cụ phù hợp chính xác cho rò rỉ đa-TF, thứ mà một kiểm tra dịch-lệnh-khớp bỏ lỡ.
Hãy xem xét trực tiếp lỗi ngây thơ resample-toàn-bộ-chuỗi. Nếu chuỗi 1 giờ được xây dựng bằng cách resample toàn bộ chuỗi ngay từ đầu, thì "giá đóng cửa khung 1 giờ" đọc được tại nến j-1000 (nằm sâu bên trong giờ kết thúc sau j) sẽ là giá đóng cửa cuối cùng của một nến mà giá đóng cửa cuối cùng đó phụ thuộc vào các nến tại và sau j. Nhân tương lai với 1.05 thì giá đóng cửa cuối cùng đó thay đổi — vì vậy chỉ báo HTF tại j-1000 thay đổi, cổng tại j-1000 thay đổi, và một quyết định quá khứ dịch chuyển. Probe sẽ sáng đèn ngay lập tức trên chuỗi HTF, tại một nến cách điểm cắt tới một ngàn bước.
Chuỗi HTF của engine chúng tôi không dịch chuyển, bởi vì tại j-1000 nến đang hình thành chỉ đóng góp base_close[j-1000] — một giá đóng cửa quá khứ — và giá đóng cửa cuối cùng của nến đó đơn giản là không bao giờ được tham chiếu tới cho đến khi ranh giới đó được vượt qua. Probe này mù trước cơ chế bên trong nhưng vẫn bắt được lỗi, đó chính xác là điều bạn muốn ở một bằng chứng: nó ràng buộc hành vi (không quyết định quá khứ nào phụ thuộc vào dữ liệu tương lai) thay vì kiểm toán cách triển khai (chúng ta có đánh chỉ số resample đúng không?). Hành vi là thứ thực sự giao dịch; cách triển khai là thứ bạn hy vọng khớp với hành vi đó.
Backtest và live chia sẻ chung một sự thật

Có thêm một lý do khiến điều này quan trọng hơn một cuộc kiểm toán backtest thông thường. Tham chiếu mà engine được chứng minh dựa vào — RunningCandleBuffer — không phải là một fixture kiểm thử được viết ra để làm cho backtest trông đẹp. Đó chính là logic nến của bản thân bot live, được lấy nguyên văn ra từ bộ mô phỏng tick chạy trong môi trường sản xuất (production). Quy tắc nến-đã-đóng mà probe kiểm chứng chính là quy tắc mà bot live thực thi, từng nến một.
Vì vậy cổng parity làm nhiệm vụ kép. Nó chứng minh engine tốc độ cao khớp với tham chiếu, và bởi vì tham chiếu đó chính là lõi live, nó chứng minh engine tốc độ cao khớp với hệ thống live. Bài viết trước đã cảnh báo rằng rò rỉ là lời giải thích gọn gàng nhất cho một khoảng cách parity giữa backtest và live — bot live là nơi duy nhất mà bạn không thể nhìn trộm về mặt cơ chế, vì vậy một backtest có nhìn trộm sẽ phân kỳ ngay khoảnh khắc nó chạy live. Ở đây, khoảng cách đó được khép lại bởi chính cách xây dựng: backtest và bot chia sẻ chung một candle buffer, chung một quy tắc cross, chung một định nghĩa về "đã biết tại nến i." Con số mà quá trình tìm kiếm (search) tối ưu hóa chính là con số mà bot có thể thực sự giao dịch được.
Đó chính là toàn bộ mục đích của việc chứng minh không có look-ahead thay vì mặc định giả định nó không có. Một quá trình tìm kiếm đa-TF chạy qua hàng ngàn cấu hình; nếu engine bị rò rỉ, quá trình tìm kiếm sẽ tìm ra cấu hình khai thác rò rỉ đó một cách mạnh mẽ nhất và trao cho bạn một "người chiến thắng" bị ngụy tạo — kiểu thất bại mà bài phân loại (taxonomy) đã đo được ở mức Sharpe 15 chỉ từ nhiễu thuần túy. Probe chính là thứ cho phép bạn tin tưởng rằng người chiến thắng đó là thật trước khi bạn đấu nối nó với vốn thực.
Probe này chứng minh được gì và không chứng minh được gì
Sự nghiêm ngặt đối với bài kiểm tra có tác dụng theo cả hai chiều, vì vậy hãy chính xác về phạm vi của nó. Probe dịch-chuyển-tương-lai chứng minh một thuộc tính duy nhất, cụ thể: trên dữ liệu này, không có tín hiệu hay quyết định giao dịch nào tại hoặc trước nến j phụ thuộc vào bất kỳ nến nào sau j — ranh giới thông tin theo thời gian được giữ vững xuyên suốt đường đi của chỉ báo, cross, và giao dịch. Đó chính xác là rò rỉ đa-TF mà chúng ta đặt mục tiêu loại bỏ, và đó là thuộc tính mà một buổi code review không thể xác lập được.
Nó không chứng minh rằng chiến lược có edge (lợi thế). Một engine hoàn toàn có tính nhân quả vẫn có thể thua lỗ một cách trung thực, và probe hoàn toàn im lặng về điều đó — và đúng là nó nên như vậy; chứng minh không rò rỉ và tìm ra một edge thực sự là hai công việc tách biệt, và việc gộp lẫn chúng chính là cách các backtest bị rò rỉ được đưa vào triển khai thực tế. Nó không bao quát các thiên lệch phi thời gian: survivorship (thiên lệch sống sót) trong công cụ tài chính, thiên lệch lựa chọn (selection bias) từ việc chỉ chạy probe sau khi engine đã trông có vẻ tốt, hay một mô hình phí quá dễ dãi. Và tự bản thân nó cũng không chứng minh rằng lệnh khớp live khớp với lệnh khớp backtest — trượt giá (slippage) và độ trễ (latency) là những khoảng cách có thật mà probe không thể nhìn thấy, bởi vì nó vận hành trên đường đi của quyết định, chứ không phải trên nơi thực thi lệnh (execution venue). Điều khép lại khoảng cách đó là một sự thật tách biệt: lõi nến của engine giống hệt (bit-identical) với lõi nến của bot live.
Một lưu ý trung thực về chính thiết kế của probe: nó cắt tại một j duy nhất (60% của chuỗi dữ liệu). Thuộc tính mà nó kiểm chứng là đồng nhất theo j — không có gì đặc biệt về nến 51,840 cả — vì vậy một lát cắt là một kiểm tra công bằng cho một thuộc tính mang tính cấu trúc, nhưng một phiên bản cẩn trọng hơn (paranoid) sẽ quét j trên toàn bộ chuỗi. Chúng tôi cho rằng một lát cắt được chọn lựa kỹ càng cộng với kiểm tra khoanh vùng một-nến là đủ, bởi vì một rò rỉ ẩn mình trước một nhiễu loạn tương lai 34,560 nến nhưng lại xuất hiện ở một lát cắt khác sẽ phải là một lỗi rất kỳ lạ. Vấn đề mấu chốt là biết rõ giới hạn của bằng chứng mà bạn có, chứ không phải giả vờ rằng một kiểm tra duy nhất là một lượng từ phổ quát (universal quantifier).
Những điều rút ra
- Các chiến lược đa khung thời gian rò rỉ qua nến đang hình thành, không phải qua lệnh khớp. Một quyết định bên trong một chu kỳ HTF chưa kết thúc chỉ được phép dùng giá đóng cửa đang chạy của nến đó (giá đóng cửa nền mới nhất), không bao giờ dùng giá đóng cửa cuối cùng của nó. Các backtest resample-toàn-bộ-chuỗi trao cho mỗi quyết định bên trong chu kỳ tới cả một chu kỳ HTF đầy đủ của tương lai.
- Kiểm tra dịch-một-nến không chạm tới rò rỉ này. Nó bắt được look-ahead ở khâu thực thi; rò rỉ đa-TF nằm ở cách chỉ báo được xây dựng. Bạn cần một probe khác.
- Tái tạo chính xác quy tắc của hệ thống live, sau đó chứng minh nó. Chúng tôi đã xây dựng lại ngữ nghĩa nến-đã-đóng của
RunningCandleBuffercủa bot thành một engine vector hóa, và đặt nó sau một cổng kiểm tra gồm 25 bài: cross khớp chính xác so với tham chiếu (408 / 2,792 / 3,691), giao dịch giống hệt nhau từng trường dữ liệu (466 dual, 211 triple), PnL đến 1e-12. - Parity là điều kiện cần nhưng chưa đủ. Khớp với một tham chiếu chỉ chứng minh rằng bạn giống nhau, không chứng minh rằng bạn có tính nhân quả. Một tham chiếu bị rò rỉ được tái tạo trung thực thì vẫn rò rỉ.
- Probe dịch-chuyển-tương-lai chính là bằng chứng thực sự. Nhiễu loạn mọi nến tại hoặc sau
j; khẳng định mọi tín hiệu và giao dịch trướcjkhông đổi ở cấp độ bit. Nếu tương lai có thể làm dịch chuyển quá khứ, bạn đang có look-ahead. - Trang bị cho probe khả năng thực sự phát hiện lỗi. Khẳng định rằng tương lai đã thực sự thay đổi (sự nhiễu loạn không phải là một no-op) và rằng một cú hích một-nến phản ứng ngay trên chính nến đó (không rò rỉ, không trễ). Một kiểm tra không thể thất bại thì chẳng chứng minh được điều gì.
- Khi backtest và live chia sẻ chung một lõi, bằng chứng được chuyển giao. Vì engine giống hệt bit-for-bit với logic nến của bot live, không-có-look-ahead cũng là một thuộc tính của chính hệ thống live — và khoảng cách parity giữa backtest và live được khép lại bởi chính cách xây dựng.
Bài viết trước đã cho thấy một rò rỉ một-dòng-code ngụy tạo ra Sharpe 15 như thế nào. Bài này cho thấy kỷ luật ngược lại: không phải "rò rỉ đánh lừa bạn như thế nào," mà là cách chứng minh, một cách cơ chế thực nghiệm và trên dữ liệu thực, rằng một engine cụ thể không bị rò rỉ. Hãy nhiễu loạn tương lai. Nếu quá khứ không hề nao núng, bạn đang giao dịch ở hiện tại.
Tác Giả
Trading-systems engineer
Trading-systems engineer building bots since 2017: cross-exchange arbitrage (connected up to 30 venues), cointegration-based pairs arbitrage across spot and futures, scalping, news and sentiment-driven strategies, trend algorithms, and portfolio management and balancing algorithms. Also builds sub-millisecond order execution, big-data warehouses, backtesting engines, AI agents, and trading interfaces (incl. open-source profitmaker.cc). Stack: JS/TS, Python, Rust/Zig/Go, DevOps, backend, frontend, architecture.