h5i-db ข้อมูล Point-in-Time สำหรับการทำ Backtest
ทำความเข้าใจการใช้ Point-in-time data เพื่อป้องกัน Look-ahead bias ในการทำ Backtest ด้วย h5i-db ระบบฐานข้อมูลที่จัดเก็บทุกการเขียนเป็นเวอร์ชันเพื่อลดความคลาดเคลื่อนของข้อมูล
ข้อมูล ณ จุดเวลา (Point-in-time data)
ข้อมูล ณ จุดเวลาคือบันทึกที่ระบุว่าชุดข้อมูลมีเนื้อหาอย่างไรในอดีต ในการทำ backtest ข้อมูลประเภทนี้จะช่วยแยกผลลัพธ์ที่สามารถอ้างอิงได้จริงออกจากผลลัพธ์ที่เกิดจากการแอบนำข้อมูลในอนาคตมาใช้ h5i-db เป็นฐานข้อมูลอนุกรมเวลาแบบโอเพนซอร์สที่พัฒนาขึ้นใหม่ โดยเขียนด้วยภาษา Rust และมี Python API รองรับ ฐานข้อมูลนี้จะจัดเก็บทุกการเขียนเป็นเวอร์ชันที่มีหมายเลขกำกับ และช่วยให้การอ่านข้อมูลสามารถระบุย้อนกลับไปยังเวอร์ชันก่อนหน้าได้ ด้านล่างนี้คือตัวอย่างการรั่วไหลของข้อมูลที่การระบุเวอร์ชัน (pinning) สามารถป้องกันได้ ซึ่งวัดจากข้อมูลการยื่นรายงานจริง ตามด้วยสถานการณ์จำลองที่คุณสามารถทดสอบได้บนแล็ปท็อป
ข้อมูลแบบ point-in-time ในการทำ backtest คืออะไร
ข้อเท็จจริงในตลาดทุกประการจะมีประทับเวลา (timestamp) สองชุด ชุดแรกคือเวลาที่เหตุการณ์เกิดขึ้น (event time) ส่วนชุดที่สองคือเวลาที่ข้อมูลนั้นถูกเผยแพร่จนบุคคลภายนอกบริษัทที่รายงานสามารถรับรู้ได้ (arrival time) รายงานการถือครองหุ้นรายไตรมาสจะระบุสถานะการถือครอง ณ วันสุดท้ายของไตรมาส แต่กว่าข้อมูลจะถึงมือสาธารณชนต้องใช้เวลาอีกหลายสัปดาห์ ดังนั้น หากโมเดลใช้เพียงเวลาที่เหตุการณ์เกิดขึ้นในการเชื่อมโยงข้อมูล โมเดลนั้นจะได้รับข้อมูลที่ในขณะนั้นยังไม่มีใครล่วงรู้
ระยะเวลาดังกล่าวสามารถวัดค่าได้ ผู้จัดการกองทุนสถาบันต้องยื่นแบบ Form 13F หลังจากปิดไตรมาส และตารางด้านล่างนี้จะวัดจำนวนวันระหว่างไตรมาสที่รายงานระบุไว้กับวันที่ยื่นรายงานจริง
SQL เบื้องหลังตัวเลขแต่ละตัว
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สำหรับไตรมาสที่สิ้นสุดใน 2026-06-30 ข้อมูลการยื่น 10688 มาถึงโดยเฉลี่ย 34.1 วันหลังจากช่วงเวลาที่รายงานครอบคลุม ตารางนี้แสดงการวัดค่าดังกล่าวซ้ำตลอด 16 ไตรมาส นอกจากนี้ ผู้จัดการกองทุนยังสามารถแก้ไขรายงานได้หลังจากยื่นไปแล้วนานพอสมควร ดังนั้นบันทึกที่อธิบายเหตุการณ์ในอดีตจึงมีการเปลี่ยนแปลงอยู่ตลอดแม้เวลาจะผ่านพ้นไปแล้วก็ตาม
Look-ahead bias is a storage problem
Our guide to look-ahead bias in backtesting treats leakage as a discipline: lag every feature and respect publication dates. Discipline holds until someone forgets, and the failure is quiet. A leaked backtest prints a better Sharpe ratio and no error at all.
Point-in-time storage moves the guarantee down a layer. When the frame handed to a strategy comes from a read pinned to a version, a row written after that version cannot appear in it, whatever the strategy code does next. The check stops being a code review and becomes a property of the read.
Dividends show the timing gap from the other side. A cash dividend is declared first and goes ex later, and a table loaded today carries both dates for every payment, including ones that had not been announced on the date being simulated.
SQL เบื้องหลังตัวเลขแต่ละตัว
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 monthIn the month beginning 2026-07-01, declarations landed an average of 88.2 days ahead of the ex-dividend date, and the panel covers 24 months of the same measurement. Read a modern dividend table at a simulated date inside that gap and the payment is already sitting there, weeks before the announcement existed.
ประวัติข้อมูลที่ถูกจัดเก็บมีการปรับปรุงใหม่
การมาถึงล่าช้าเป็นรูปแบบความล้มเหลวประการหนึ่ง ส่วนการปรับปรุงข้อมูลย้อนหลัง (restatement) เป็นอีกประการหนึ่ง การดำเนินการขององค์กร (corporate actions) จะเขียนทับราคาที่มีการซื้อขายไปแล้ว ตัวอย่างเช่น หลังจากการแตกหุ้นในอัตราสี่ต่อหนึ่ง ราคาในอดีตทั้งหมดในชุดข้อมูลที่ปรับค่าแล้วจะถูกหารด้วยสี่ ส่งผลให้ชุดข้อมูลที่ดาวน์โหลดในวันนี้ไม่ตรงกับราคาที่เทรดเดอร์เห็นบนกระดานในขณะนั้น บทความของเราในหัวข้อ ประวัติราคาที่ปรับค่าจากการแตกหุ้น ได้อธิบายถึงวิธีการคำนวณไว้ สิ่งที่สำคัญในที่นี้คือความถี่ของการเกิดเหตุการณ์ดังกล่าว
SQL เบื้องหลังตัวเลขแต่ละตัว
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_dateในไตรมาสที่เริ่มต้นใน 2026-04-01 มีการแตกหุ้นแบบ forward splits จำนวน 131 รายการ และการแตกหุ้นแบบ reverse splits จำนวน 303 รายการ ซึ่งแต่ละรายการจะปรับปรุงประวัติราคาที่ระบบวิเคราะห์ข้อมูลอาจจัดเก็บไว้ในแคชไปแล้ว การจัดเก็บข้อมูลแบบเวอร์ชัน (versioned storage) ไม่ได้ป้องกันการปรับปรุงข้อมูลย้อนหลัง แต่จะบันทึกสถานะใหม่เป็นเวอร์ชันใหม่และคงเวอร์ชันเดิมไว้ให้สามารถอ่านได้ ซึ่งเป็นสิ่งที่เปลี่ยนผลลัพธ์ที่ล้าสมัยให้กลายเป็นผลลัพธ์ที่สามารถทำซ้ำได้
การประทับเวลาของข่าวก็มีกับดักในลักษณะเดียวกันนี้ในระดับที่ย่อยลงมา
SQL เบื้องหลังตัวเลขแต่ละตัว
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_hourพาดหัวข่าวมีการเผยแพร่ตลอดเวลาตลอดทั้ง 24 ชั่วโมงของวันทำการในนิวยอร์ก ในช่วงเวลา 09:00 น. มีบทความจำนวน 790 บทความในช่วงเก้าสิบวันที่ผ่านมา และในช่วงเวลา 20:00 น. มีจำนวน 404 บทความ การนำพาดหัวข่าวช่วงเย็นไปประทับเวลากับราคาปิดตลาดที่เวลา 16:00 น. ของวันนั้น จะทำให้กลยุทธ์ที่เทรดในช่วงปิดตลาดได้รับข้อมูลที่ล่วงรู้เหตุการณ์ก่อนหน้าไปแล้วหลายชั่วโมง
สถานการณ์จำลองแบบ Point-in-time ที่คุณสามารถทดสอบได้
ทุกอย่างในที่นี้จะทำงานอยู่บน Python package โครงการนี้ยังมาพร้อมกับเครื่องมือ command line ที่เขียนด้วย Rust ซึ่งเป็นการติดตั้งแยกต่างหากที่ไม่จำเป็นสำหรับขั้นตอนการทดลองนี้ ข้อมูลตัวอย่างจะถูกสร้างขึ้นในเครื่องโดยไม่มีการดาวน์โหลด
- ติดตั้งรุ่นที่กำหนดไว้:
pip install 'h5i-db==0.1.6'ซึ่งเผยแพร่เมื่อวันที่ 4 สิงหาคม 2026 โดยต้องการ Python 3.9 หรือใหม่กว่า และจะติดตั้งpyarrow>=14มาด้วย ไฟล์ Prebuilt wheels รองรับระบบปฏิบัติการ Linux บนสถาปัตยกรรม x86-64 และ arm64 รวมถึง macOS บน Apple silicon และ Windows บน x86-64 - อธิบายข้อมูลหนึ่งครั้ง หลังจาก
import pyarrow as paและimport pyarrow.parquet as pq:schema = pa.schema([('ts', pa.timestamp('us', tz='UTC')), ('symbol', pa.string()), ('price', pa.float64())]) - เขียนแถวข้อมูลสมมติสองแถวลงในไฟล์ในเครื่องด้วย
pq.write_table(pa.table({'ts': [d1, d2], 'symbol': ['ACME', 'ACME'], 'price': [10.0, 10.5]}, schema=schema), 'day1.parquet')โดยที่d1และd2คือค่าวันที่และเวลาที่ระบุโซนเวลา - สร้างฐานข้อมูลและตาราง โดยตั้งชื่อคอลัมน์เวลา:
db = h5i_db.Database('pit.db', create=True)จากนั้นdb.create_table('prices', schema, time_column='ts') - นำเข้าไฟล์ภายใต้คีย์:
db.append('prices', pq.read_table('day1.parquet'), idempotency_key='load-day1')การเรียกใช้คำสั่งนี้จะส่งคืนค่า commit ที่สร้างขึ้น - รันคำสั่งเดิมซ้ำอีกครั้ง โครงการระบุว่าการทำซ้ำโดยใช้คีย์เดิมจะพบค่า commit ที่สร้างไว้ก่อนหน้าแล้ว และส่งคืนค่าดังกล่าวพร้อมกับ
"segments_added": 0แทนที่จะเขียนแถวข้อมูลซ้ำเป็นครั้งที่สอง ให้พิมพ์db.versions('prices')ทั้งก่อนและหลังการลองใหม่ แล้วสังเกตว่ารายการเวอร์ชันจะไม่มีการเปลี่ยนแปลง - นำเข้าข้อมูลวันที่สองภายใต้
idempotency_key='load-day2'จากนั้นทำการ query ข้ามทั้งสองวัน:db.sql('SELECT symbol, count(*) AS n, avg(price) AS px FROM prices GROUP BY symbol').to_pandas() - อ่านตารางในสถานะก่อนที่ข้อมูลวันที่สองจะถูกเพิ่มเข้ามา:
db.read('prices', version=1)วิธีการเดียวกันนี้สามารถใช้as_of=และsnapshot=เป็นอาร์กิวเมนต์เพื่อทำงานเดียวกันได้
ขั้นตอนที่ 6 คือจุดที่ควรพิจารณาให้ดี การเพิ่มข้อมูลซ้ำจะไม่ทำให้เกิดข้อผิดพลาด แต่จะทำให้ตารางอยู่ในสถานะที่ไม่ถูกต้องตั้งแต่นาทีนั้นเป็นต้นไป และการรันคำสั่งในภายหลังทั้งหมดจะได้รับผลกระทบที่ผิดพลาดนี้ไปโดยไม่รู้ตัว ขั้นตอนที่ 8 คือผลลัพธ์ที่สำคัญ: ตัวเลขที่คำนวณในเดือนมีนาคมสามารถนำมาคำนวณใหม่ในเดือนสิงหาคมโดยใช้เวอร์ชันที่กำหนดไว้เดิม ซึ่งเป็นคุณสมบัติที่เราได้กล่าวถึงในบทความเรื่อง การทดสอบย้อนหลังที่ทำซ้ำได้ ในระดับเฟรมเวิร์ก
สิ่งที่โครงการกล่าวอ้างและสิ่งที่เราตรวจสอบ
README ของโครงการเริ่มต้นด้วยการเปรียบเทียบประสิทธิภาพ:
เร็วกว่า DuckDB และ Polars มากกว่าสี่จุดห้าเท่า ในการทำ OHLCV+VWAP rollups บนข้อมูลยี่สิบล้านแถว
ตัวเลขดังกล่าวเป็นการวัดผลของโครงการเอง ซึ่งอ้างอิงจาก h5i-db README ณ เดือนสิงหาคม 2026 เราไม่ได้ทำการทดสอบด้วยตนเอง และเนื้อหาข้างต้นทั้งหมดไม่ได้ขึ้นอยู่กับตัวเลขนี้
ในกรณีนี้ ความพร้อมใช้งานมีความสำคัญมากกว่าความเร็ว ตัวเก็บข้อมูล (repository) นี้มีดาว 29 ดวง ณ เวลาที่เขียน และอยู่ในเวอร์ชัน 0.1.6 ภายใต้ใบอนุญาต Apache-2.0 ซึ่งหมายความว่ามีผู้ดูแลจำนวนน้อยและ API ยังสามารถเปลี่ยนแปลงได้ระหว่างการอัปเดตเวอร์ชันย่อย อีกทั้งยังไม่มีบันทึกการใช้งานจริงของเอนจินนี้ภายใต้ภาระงานหนักเป็นเวลานาน การล็อกเวอร์ชันให้ชัดเจนและการเก็บไฟล์ parquet ที่ใช้ป้อนข้อมูลเข้าฐานข้อมูลจะช่วยให้คุณสามารถย้อนกลับได้หากมีการเปลี่ยนแปลงพฤติกรรมในเวอร์ชันใหม่ การเก็บสำเนาข้อมูลดิบของคุณเองเป็นนิสัยเดียวกับการป้องกันไม่ให้ผู้ให้บริการปรับเปลี่ยนประวัติข้อมูลของคุณ ซึ่งเป็นประเด็นสำคัญที่กล่าวถึงใน อคติจากการอยู่รอดในข้อมูลหุ้น
คำถามที่พบบ่อย
ข้อมูลแบบ point-in-time คืออะไร
ข้อมูลแบบ point-in-time คือชุดข้อมูลที่จัดเก็บพร้อมประทับเวลา (timestamp) ซึ่งระบุช่วงเวลาที่ข้อเท็จจริงแต่ละรายการเริ่มเป็นที่รับรู้ได้ ทำให้การสืบค้นสามารถสร้างภาพจำลองสิ่งที่ปรากฏให้เห็น ณ วันที่ใดก็ตามในอดีตได้ ในขณะที่ตารางแสดง "ค่าล่าสุด" เพียงอย่างเดียวไม่สามารถทำเช่นนั้นได้ เนื่องจากเป็นการเขียนทับข้อมูลในอดีตด้วยตัวเลขที่ปรับปรุงใหม่ของปัจจุบัน
การจัดเก็บข้อมูลแบบเวอร์ชันช่วยขจัดอคติจากการมองไปข้างหน้า (look-ahead bias) หรือไม่
ไม่ช่วย การทำเวอร์ชันช่วยแก้ไขปัญหาข้อมูลรั่วไหลได้เพียงประเภทเดียว คือกรณีที่การประมวลผลอ่านค่าที่ถูกบันทึกไว้หลังจากเวลาที่จำลองการตัดสินใจไปแล้ว แต่การสร้างฟีเจอร์ (feature construction) ยังคงสามารถเกิดการรั่วไหลในรูปแบบอื่นได้ เช่น การปรับสเกลกลุ่มตัวอย่างด้วยสถิติที่คำนวณจากประวัติข้อมูลทั้งหมด
คีย์ idempotency ทำหน้าที่อะไรในระหว่างการนำเข้าข้อมูล
คีย์นี้จะทำหน้าที่ระบุการเขียนข้อมูล เพื่อให้ระบบรับรู้ได้ว่าการลองใหม่ (retry) คือการเขียนข้อมูลชุดเดิม ตัวโหลดข้อมูลจึงสามารถทำงานต่อได้หลังจากระบบขัดข้องโดยไม่เพิ่มแถวข้อมูลซ้ำ ซึ่งเป็นความผิดพลาดที่ทำให้ตารางข้อมูลผิดเพี้ยนไปโดยไม่รู้ตัว
h5i-db พร้อมสำหรับการใช้งานจริง (production) แล้วหรือยัง
ปัจจุบันเป็นเวอร์ชัน 0.1.6 โดยมี 29 ดาวบน GitHub ณ เวลาที่เขียนบทความนี้ ภายใต้สัญญาอนุญาต Apache-2.0 ซอฟต์แวร์ระยะเริ่มต้นที่มีขนาดเท่านี้ย่อมมีการเปลี่ยนแปลงของ API อยู่ตลอดและยังมีประวัติการใช้งานในวงกว้างไม่มากนัก การล็อกเวอร์ชัน (version pin) และการเก็บสำเนาไฟล์ซอร์สโค้ดไว้ด้วยตนเองคือสิ่งที่ช่วยให้การประเมินผลของคุณสามารถย้อนกลับได้
แผงข้อมูลทุกรายการข้างต้นมาพร้อมกับคำสั่ง SQL ที่ใช้สร้างข้อมูลนั้น ดังนั้นคุณสามารถเปิดดูและอ่านวิธีการคำนวณตัวเลขดังกล่าวได้ สำหรับคำถามเดียวกันนี้ คุณสามารถสอบถามเป็นภาษาอังกฤษทั่วไปได้ผ่านทาง Strasmore terminal