Dữ liệu point-in-time trong backtest với h5i-db
Dữ liệu point-in-time giúp loại bỏ lỗi look-ahead bias trong backtest. Tìm hiểu cách h5i-db đánh số phiên bản dữ liệu và tác động của độ trễ công bố thông tin đến độ chính xác.
Dữ liệu tại thời điểm (point-in-time) là bản ghi về trạng thái của một tập dữ liệu tại một ngày trong quá khứ. Trong kiểm thử ngược (backtest), nó giúp phân biệt kết quả có thể kiểm chứng với kết quả vô tình sử dụng dữ liệu của tương lai. h5i-db là một cơ sở dữ liệu chuỗi thời gian mã nguồn mở mới, được viết bằng Rust với API Python. Nó lưu trữ mỗi lần ghi dưới dạng một phiên bản được đánh số và cho phép mọi truy vấn ghim lại một phiên bản cũ hơn. Dưới đây là hiện tượng rò rỉ dữ liệu mà tính năng ghim này ngăn chặn, được đo lường trên dữ liệu hồ sơ thực tế, theo sau là một kịch bản bạn có thể chạy trên máy tính cá nhân.
Dữ liệu tại thời điểm (point-in-time) trong kiểm thử lại (backtest) là gì?
Mỗi dữ liệu thị trường đều mang hai dấu thời gian. Thời gian sự kiện (event time) là thời điểm sự việc thực sự xảy ra. Thời gian đến (arrival time) là thời điểm thông tin đó được công bố rộng rãi ra bên ngoài đơn vị báo cáo. Một báo cáo danh mục đầu tư hàng quý mô tả các vị thế nắm giữ vào ngày cuối cùng của quý, nhưng phải mất vài tuần sau đó công chúng mới tiếp cận được. Do đó, một mô hình chỉ sử dụng thời gian sự kiện sẽ vô tình tiếp cận những thông tin mà tại thời điểm đó chưa ai có được.
Khoảng cách này có thể đo lường được. Các nhà quản lý tổ chức nộp Mẫu 13F sau khi kết thúc mỗi quý, và bảng dưới đây đo lường số ngày chênh lệch giữa quý mà báo cáo mô tả và ngày báo cáo đó được nộp.
Câu lệnh SQL chính xác đằng sau mỗi con số
WITH per_filing AS
(
SELECT
accession_number,
any(toDate(parseDateTimeBestEffortOrNull(toString(period)))) AS period_end,
any(toDate(filing_date)) AS filed_on
FROM global_markets.stocks_13f_filings
WHERE filing_date >= '2023-01-01'
GROUP BY accession_number
)
SELECT
toString(period_end) AS period_end_date,
countDistinct(accession_number) AS filings_count,
round(avg(dateDiff('day', period_end, filed_on)), 1) AS avg_days_to_public
FROM per_filing
WHERE period_end IS NOT NULL
AND filed_on >= period_end
AND filed_on <= period_end + 400
GROUP BY period_end
HAVING filings_count >= 100
ORDER BY period_endĐối với quý kết thúc vào 2026-06-30, các hồ sơ 10688 được nộp trung bình sau 34.1 ngày kể từ thời điểm kết thúc kỳ báo cáo. Bảng này lặp lại phép đo đó qua 16 quý. Một nhà quản lý cũng có thể sửa đổi báo cáo rất lâu sau khi đã nộp, vì vậy hồ sơ mô tả một ngày trong quá khứ vẫn có thể thay đổi ngay cả khi ngày đó đã qua.
Độ chệch nhìn trước (look-ahead bias) là một vấn đề về lưu trữ
Hướng dẫn của chúng tôi về độ chệch nhìn trước trong kiểm thử ngược (backtesting) coi việc rò rỉ dữ liệu là một vấn đề kỷ luật: hãy tạo độ trễ cho mọi đặc trưng và tuân thủ ngày công bố. Kỷ luật được duy trì cho đến khi ai đó quên mất, và sự thất bại diễn ra trong âm thầm. Một bài kiểm thử ngược bị rò rỉ dữ liệu sẽ cho ra tỷ lệ Sharpe tốt hơn mà không hề báo lỗi.
Lưu trữ dữ liệu tại thời điểm (point-in-time storage) chuyển sự đảm bảo xuống một tầng thấp hơn. Khi khung dữ liệu được cung cấp cho một chiến lược đến từ một bản đọc được ghim vào một phiên bản cụ thể, một hàng dữ liệu được ghi sau phiên bản đó sẽ không thể xuất hiện trong khung dữ liệu, bất kể mã chiến lược thực hiện điều gì tiếp theo. Việc kiểm tra không còn là xem xét mã nguồn mà trở thành một thuộc tính của thao tác đọc.
Cổ tức cho thấy khoảng cách thời gian từ một góc độ khác. Cổ tức tiền mặt được công bố trước và ngày giao dịch không hưởng quyền (ex-dividend date) diễn ra sau, và một bảng dữ liệu được tải ngày hôm nay chứa cả hai ngày cho mỗi khoản thanh toán, bao gồm cả những khoản chưa được công bố vào ngày đang được mô phỏng.
Câu lệnh SQL chính xác đằng sau mỗi con số
SELECT
toString(toStartOfMonth(ex_dividend_date)) AS month,
round(avg(dateDiff('day', declaration_date, ex_dividend_date)), 1) AS avg_days_announced_ahead,
countDistinct(ticker) AS payers_count
FROM global_markets.stocks_dividends
WHERE ex_dividend_date >= toStartOfMonth(today() - 730)
AND ex_dividend_date < toStartOfMonth(today())
AND declaration_date >= '1990-01-01'
AND declaration_date <= ex_dividend_date
GROUP BY month
ORDER BY monthTrong tháng bắt đầu từ 2026-07-01, các thông báo được đưa ra trung bình 88.2 ngày trước ngày giao dịch không hưởng quyền, và bảng dữ liệu bao phủ 24 tháng của cùng một phép đo. Nếu đọc một bảng cổ tức hiện đại tại một ngày mô phỏng nằm trong khoảng thời gian đó, khoản thanh toán đã nằm sẵn ở đó, nhiều tuần trước khi thông báo thực sự tồn tại.
Lịch sử lưu trữ bị ghi đè
Đến muộn là một dạng lỗi. Ghi lại (restatement) là dạng lỗi khác. Các sự kiện doanh nghiệp ghi đè lên các mức giá đã được khớp lệnh: sau khi chia tách cổ phiếu theo tỷ lệ bốn-chia-một, mọi mức giá trước đó trong chuỗi dữ liệu đã điều chỉnh đều bị chia cho bốn, và chuỗi dữ liệu tải xuống ngày hôm nay không còn khớp với bảng điện mà nhà giao dịch đã theo dõi. Ghi chú của chúng tôi về lịch sử giá đã điều chỉnh chia tách giải thích chi tiết về các phép tính này. Điều quan trọng ở đây là tần suất.
Câu lệnh SQL chính xác đằng sau mỗi con số
SELECT
toString(toStartOfQuarter(execution_date)) AS quarter_start_date,
countDistinctIf(id, split_to > split_from) AS forward_splits,
countDistinctIf(id, split_to < split_from) AS reverse_splits
FROM global_markets.stocks_splits
WHERE execution_date >= toStartOfQuarter(today() - 1460)
AND execution_date < toStartOfQuarter(today())
AND split_from > 0
AND split_to > 0
GROUP BY quarter_start_date
ORDER BY quarter_start_dateTrong quý bắt đầu từ 2026-04-01, đã có 131 đợt chia tách cổ phiếu và 303 đợt gộp cổ phiếu có hiệu lực. Mỗi sự kiện đều ghi lại một lịch sử giá mà quy trình nghiên cứu có thể đã lưu trong bộ nhớ đệm. Lưu trữ theo phiên bản không ngăn chặn được việc ghi lại này. Nó ghi lại trạng thái mới dưới dạng một phiên bản mới và giữ cho phiên bản cũ có thể đọc được, đây chính là yếu tố biến một kết quả cũ kỹ thành một kết quả có thể tái lập.
Dấu thời gian của tin tức cũng mang cái bẫy tương tự ở quy mô nhỏ hơn.
Câu lệnh SQL chính xác đằng sau mỗi con số
SELECT
formatDateTime(toTimeZone(published_utc, 'America/New_York'), '%H:00') AS et_hour,
countDistinct(id) AS articles
FROM global_markets.stocks_news
WHERE published_utc >= today() - 90
GROUP BY et_hour
ORDER BY et_hourCác tiêu đề tin tức được công bố suốt ngày đêm, trong tất cả 24 giờ của ngày giao dịch tại New York. Khung giờ 09:00 có 790 bài báo trong 90 ngày qua, và khung giờ 20:00 có 404 bài. Việc gắn một tiêu đề tin tức buổi tối vào mức giá đóng cửa lúc 4 giờ chiều của ngày hôm đó sẽ khiến một chiến lược giao dịch đóng cửa có được lợi thế nhìn lại (hindsight) trong vài giờ.
Một kịch bản tại thời điểm cụ thể mà bạn có thể chạy
Mọi thứ ở đây đều nằm trên gói Python. Dự án cũng cung cấp một công cụ dòng lệnh Rust, một bản cài đặt riêng biệt mà hướng dẫn này không cần đến. Dữ liệu mẫu được tạo cục bộ, không cần tải xuống.
- Cài đặt bản phát hành đã ghim:
pip install 'h5i-db==0.1.6', được công bố vào ngày 4 tháng 8 năm 2026. Nó yêu cầu Python 3.9 trở lên và đi kèm vớipyarrow>=14. Các tệp wheel được xây dựng sẵn hỗ trợ Linux trên x86-64 và arm64, cùng với macOS trên chip Apple và Windows trên x86-64. - Mô tả dữ liệu một lần, sau
import pyarrow as pavàimport pyarrow.parquet as pq:schema = pa.schema([('ts', pa.timestamp('us', tz='UTC')), ('symbol', pa.string()), ('price', pa.float64())]). - Ghi hai hàng dữ liệu giả định vào một tệp cục bộ bằng
pq.write_table(pa.table({'ts': [d1, d2], 'symbol': ['ACME', 'ACME'], 'price': [10.0, 10.5]}, schema=schema), 'day1.parquet'), trong đód1vàd2là các mốc thời gian có nhận diện múi giờ. - Tạo cơ sở dữ liệu và bảng, đặt tên cho cột thời gian:
db = h5i_db.Database('pit.db', create=True)sau đó làdb.create_table('prices', schema, time_column='ts'). - Nạp tệp dưới một khóa:
db.append('prices', pq.read_table('day1.parquet'), idempotency_key='load-day1'). Lệnh gọi này trả về commit mà nó đã thực hiện. - Chạy lại chính dòng đó. Dự án ghi lại rằng một lần lặp lại với cùng một khóa sẽ tìm thấy commit mà nó đã tạo ra và trả về nó cùng với
"segments_added": 0thay vì ghi lại các hàng đó lần thứ hai. Indb.versions('prices')ở cả hai phía của lần thử lại và quan sát danh sách phiên bản giữ nguyên. - Nạp ngày thứ hai dưới
idempotency_key='load-day2', sau đó truy vấn trên cả hai:db.sql('SELECT symbol, count(*) AS n, avg(price) AS px FROM prices GROUP BY symbol').to_pandas(). - Đọc bảng như trạng thái trước khi ngày thứ hai được nạp:
db.read('prices', version=1). Cùng một phương thức đó nhận các đối sốas_of=vàsnapshot=cho cùng một công việc.
Bước 6 là bước cần lưu tâm. Một thao tác thêm dữ liệu trùng lặp không gây ra lỗi. Nó khiến bảng bị sai lệch kể từ thời điểm đó, và mọi lần chạy sau đó đều kế thừa thiệt hại một cách âm thầm. Bước 8 là kết quả đạt được: một con số được tính toán vào tháng Ba có thể được tính toán lại vào tháng Tám từ cùng một phiên bản đã ghim, đây là đặc tính mà bài viết của chúng tôi về kiểm thử lại có thể tái lập lập luận ở cấp độ khung làm việc.
Những tuyên bố của dự án và kết quả kiểm chứng của chúng tôi
Phần README mở đầu bằng một kết quả benchmark:
nhanh hơn gấp bốn phẩy năm lần so với DuckDB và Polars trong các tác vụ tổng hợp OHLCV+VWAP trên hai mươi triệu dòng dữ liệu
Con số đó là phép đo của chính dự án, được trích dẫn từ README của h5i-db vào tháng tám năm hai nghìn hai mươi sáu. Chúng tôi đã không thực hiện kiểm thử này và không có nội dung nào ở trên phụ thuộc vào nó.
Trong trường hợp này, mức độ hoàn thiện quan trọng hơn tốc độ. Kho lưu trữ này có hai mươi chín sao tại thời điểm viết bài và đang ở phiên bản không phẩy một phẩy sáu theo giấy phép Apache-2.0. Sự kết hợp này đồng nghĩa với việc nhóm duy trì còn nhỏ và API vẫn có thể thay đổi giữa các bản phát hành nhỏ. Chưa có hồ sơ công khai lâu dài nào về việc công cụ này vận hành dưới tải trọng lớn. Việc ghim chính xác phiên bản và lưu giữ các tệp parquet đã nạp vào cơ sở dữ liệu sẽ giúp bạn có đường lui nếu một bản phát hành làm thay đổi hành vi của hệ thống. Việc tự lưu giữ bản sao dữ liệu thô cũng là thói quen giúp bảo vệ bạn trước việc nhà cung cấp thay đổi dữ liệu lịch sử, một chủ đề xuyên suốt trong thiên kiến sống sót trong dữ liệu chứng khoán.
Câu hỏi thường gặp
Dữ liệu tại thời điểm (point-in-time) là gì?
Dữ liệu tại thời điểm là tập hợp dữ liệu được lưu trữ kèm theo dấu thời gian (timestamp) tại thời điểm mỗi sự kiện trở nên khả dụng, nhờ đó một truy vấn có thể tái tạo lại những gì đã hiển thị vào bất kỳ ngày nào trong quá khứ. Một bảng "giá trị mới nhất" thông thường không thể làm được điều này, vì nó ghi đè lên quá khứ bằng các con số đã được điều chỉnh của ngày hôm nay.
Lưu trữ theo phiên bản có loại bỏ được thiên kiến nhìn trước (look-ahead bias) không?
Không. Việc đánh phiên bản chỉ giải quyết một loại rò rỉ dữ liệu, đó là trường hợp một lượt chạy đọc các giá trị được ghi sau thời điểm ra quyết định giả định. Việc xây dựng tính năng (feature construction) vẫn có thể bị rò rỉ theo những cách khác, chẳng hạn như việc điều chỉnh tỷ lệ một mẫu dựa trên các số liệu thống kê được tính toán trên toàn bộ lịch sử của mẫu đó.
Khóa tính lũy đẳng (idempotency key) có tác dụng gì trong quá trình nạp dữ liệu?
Nó gắn nhãn cho một thao tác ghi để hệ thống nhận diện việc thử lại là cùng một thao tác ghi đó. Một trình nạp dữ liệu có thể chạy lại sau khi gặp sự cố mà không cần thêm các hàng dữ liệu trùng lặp, đây chính là lỗi khiến bảng dữ liệu bị sai lệch âm thầm.
h5i-db đã sẵn sàng cho môi trường sản xuất chưa?
Tại thời điểm viết bài, phần mềm này đang ở phiên bản 0.1.6 với hai mươi chín sao trên GitHub, theo giấy phép Apache-2.0. Các phần mềm sơ khai với quy mô đó thường có sự thay đổi API liên tục và hồ sơ theo dõi công khai còn hạn chế, vì vậy việc ghim phiên bản và lưu trữ bản sao tệp nguồn của riêng bạn là cách để đảm bảo quá trình đánh giá có thể đảo ngược.
Mỗi bảng điều khiển phía trên đều đi kèm với mã SQL đã tạo ra nó, vì vậy hãy mở một bảng và đọc cách con số đó được tính toán. Những câu hỏi tương tự có thể được đặt bằng tiếng Anh thông thường trên thiết bị đầu cuối Strasmore.