Strasmore Research
การเรียนรู้ Matt Connorโดย Matt Connor · data as of August 16, 2026 · refreshed weekly

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 หลังจากปิดไตรมาส และตารางด้านล่างนี้จะวัดจำนวนวันระหว่างไตรมาสที่รายงานระบุไว้กับวันที่ยื่นรายงานจริง

คำสั่งดึงข้อมูลระยะเวลาการยื่นรายงานการถือครองหุ้น 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
Run this yourself

สำหรับไตรมาสที่สิ้นสุดใน 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.

คำสั่งดึงข้อมูลจำนวนวันระหว่างการประกาศจ่ายเงินปันผลจนถึงวันขึ้นเครื่องหมาย XD แยกตามเดือน
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 month
Run this yourself

In 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
Run this yourself

ในไตรมาสที่เริ่มต้นใน 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
Run this yourself

พาดหัวข่าวมีการเผยแพร่ตลอดเวลาตลอดทั้ง 24 ชั่วโมงของวันทำการในนิวยอร์ก ในช่วงเวลา 09:00 น. มีบทความจำนวน 790 บทความในช่วงเก้าสิบวันที่ผ่านมา และในช่วงเวลา 20:00 น. มีจำนวน 404 บทความ การนำพาดหัวข่าวช่วงเย็นไปประทับเวลากับราคาปิดตลาดที่เวลา 16:00 น. ของวันนั้น จะทำให้กลยุทธ์ที่เทรดในช่วงปิดตลาดได้รับข้อมูลที่ล่วงรู้เหตุการณ์ก่อนหน้าไปแล้วหลายชั่วโมง

สถานการณ์จำลองแบบ Point-in-time ที่คุณสามารถทดสอบได้

ทุกอย่างในที่นี้จะทำงานอยู่บน Python package โครงการนี้ยังมาพร้อมกับเครื่องมือ command line ที่เขียนด้วย Rust ซึ่งเป็นการติดตั้งแยกต่างหากที่ไม่จำเป็นสำหรับขั้นตอนการทดลองนี้ ข้อมูลตัวอย่างจะถูกสร้างขึ้นในเครื่องโดยไม่มีการดาวน์โหลด

  1. ติดตั้งรุ่นที่กำหนดไว้: 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
  2. อธิบายข้อมูลหนึ่งครั้ง หลังจาก import pyarrow as pa และ import pyarrow.parquet as pq: schema = pa.schema([('ts', pa.timestamp('us', tz='UTC')), ('symbol', pa.string()), ('price', pa.float64())])
  3. เขียนแถวข้อมูลสมมติสองแถวลงในไฟล์ในเครื่องด้วย pq.write_table(pa.table({'ts': [d1, d2], 'symbol': ['ACME', 'ACME'], 'price': [10.0, 10.5]}, schema=schema), 'day1.parquet') โดยที่ d1 และ d2 คือค่าวันที่และเวลาที่ระบุโซนเวลา
  4. สร้างฐานข้อมูลและตาราง โดยตั้งชื่อคอลัมน์เวลา: db = h5i_db.Database('pit.db', create=True) จากนั้น db.create_table('prices', schema, time_column='ts')
  5. นำเข้าไฟล์ภายใต้คีย์: db.append('prices', pq.read_table('day1.parquet'), idempotency_key='load-day1') การเรียกใช้คำสั่งนี้จะส่งคืนค่า commit ที่สร้างขึ้น
  6. รันคำสั่งเดิมซ้ำอีกครั้ง โครงการระบุว่าการทำซ้ำโดยใช้คีย์เดิมจะพบค่า commit ที่สร้างไว้ก่อนหน้าแล้ว และส่งคืนค่าดังกล่าวพร้อมกับ "segments_added": 0 แทนที่จะเขียนแถวข้อมูลซ้ำเป็นครั้งที่สอง ให้พิมพ์ db.versions('prices') ทั้งก่อนและหลังการลองใหม่ แล้วสังเกตว่ารายการเวอร์ชันจะไม่มีการเปลี่ยนแปลง
  7. นำเข้าข้อมูลวันที่สองภายใต้ idempotency_key='load-day2' จากนั้นทำการ query ข้ามทั้งสองวัน: db.sql('SELECT symbol, count(*) AS n, avg(price) AS px FROM prices GROUP BY symbol').to_pandas()
  8. อ่านตารางในสถานะก่อนที่ข้อมูลวันที่สองจะถูกเพิ่มเข้ามา: 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