Strasmore Research
Навчання Matt ConnorВід Matt Connor · data as of August 16, 2026 · refreshed weekly

h5i-db: point-in-time дані для backtest

Point-in-time дані усувають look-ahead bias у backtest на рівні зберігання. Як h5i-db версіонує кожен запис і що filing lag показує про застарілу історію.

Point-in-time data — це запис про те, які дані містив набір на певну дату в минулому. У backtest такий підхід відділяє результат, який можна обґрунтувати, від результату, що непомітно використав дані з майбутнього. h5i-db — молода open-source база даних часових рядів, написана мовою Rust і оснащена Python API. Вона зберігає кожен запис як пронумеровану версію й дає змогу прив’язати будь-яке читання до попередньої версії. Нижче описано витік даних, якому запобігає така фіксація версії, на основі реальних даних із регуляторних подань. Далі наведено сценарій, який можна запустити на ноутбуці.

Що таке point-in-time дані в backtest?

Кожен ринковий факт має дві часові позначки. Event time — це момент, коли подія відбулася. Arrival time — момент, коли інформація про неї стала доступною для всіх за межами компанії, яка її розкрила. Квартальний звіт про структуру портфеля описує позиції на останній день кварталу, але оприлюднюється через кілька тижнів. Тому модель, яка об’єднує дані лише за event 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 — у зберіганні даних

У нашому посібнику про look-ahead bias у backtesting витік даних розглядається як питання дисципліни: для кожної ознаки потрібно встановлювати лаг і дотримуватися дат публікації. Дисципліна працює, доки хтось не припуститься помилки, а збій залишається непомітним. Backtest із витоком даних показує кращий коефіцієнт Sharpe і не видає жодної помилки.

Point-in-time storage переносить цю гарантію на нижчий рівень. Якщо frame, переданий стратегії, формується з read, зафіксованого на певній версії, рядок, доданий після цієї версії, не може потрапити до нього — незалежно від подальших дій коду стратегії. Перевірка стає не частиною code review, а властивістю самого read.

Дивіденди показують часовий розрив з іншого боку. Спочатку оголошується cash dividend, а ex-dividend date настає пізніше. Таблиця, завантажена сьогодні, містить обидві дати для кожної виплати, зокрема для тих, які ще не були оголошені на дату, що моделюється.

ЗапитКількість днів між оголошенням дивідендів і датою ex-dividend за місяцями
Точний 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

У місяці, що починається з 2026-07-01, оголошення в середньому відбувалися за 88.2 днів до ex-dividend date, а панель охоплює 24 місяців такого самого вимірювання. Якщо прочитати сучасну таблицю дивідендів на змодельовану дату всередині цього проміжку, виплата вже буде в ній — за кілька тижнів до фактичного оголошення.

Збережена історія переписується

Запізніле надходження даних — один із варіантів збою. Перерахунок — інший. Корпоративні дії переписують ціни, за якими торги вже відбулися: після split у співвідношенні four-to-one кожну попередню ціну в скоригованому ряді ділять на чотири, і ряд, завантажений сьогодні, більше не відповідає стрічці, яку бачив трейдер. У нашій нотатці про історію цін із поправкою на split розглянуто відповідні розрахунки. Тут важлива саме частота.

ЗапитПоділи акцій, що набувають чинності щокварталу: прямі та зворотні
Точний 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, набули чинності 131 forward splits і 303 reverse splits. Кожен із них перераховує історію цін, яку research pipeline уже міг зберегти в кеші. Версіоноване сховище не запобігає такому перерахунку. Воно записує новий стан як нову версію й залишає старий доступним для читання. Саме це перетворює застарілий результат на відтворюваний.

Позначки часу новин містять таку саму пастку, але в мініатюрі.

ЗапитЧас публікації ринкових новин за годинами Нью-Йорка
Точний 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 годин нью-йоркського торгового дня. За останні 90 днів на 09:00 припадало 790 матеріалів, а на 20:00 — 404. Якщо позначити вечірній заголовок часом закриття цього дня о 16:00, стратегія, що торгує на закритті, отримує перевагу заднім числом у кілька годин.

Сценарій на певний момент часу, який можна запустити

Усе описане тут стосується Python-пакета. Проєкт також постачається з інструментом командного рядка на Rust, але для цього покрокового прикладу потрібне окреме встановлення, якого він не потребує. Приклад даних генерується локально, без завантаження.

  1. Встановіть зафіксований реліз: pip install 'h5i-db==0.1.6', опублікований 4 серпня 2026 року. Він потребує Python 3.9 або новішої версії та містить pyarrow>=14. Попередньо зібрані 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 — об’єкти datetime із часовою зоною.
  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', а потім виконайте запит за обома днями: 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 демонструє перевагу підходу: число, обчислене в березні, можна повторно обчислити в серпні з тієї самої зафіксованої версії. Саме цю властивість обґрунтовує на рівні фреймворку наш матеріал про відтворюваний backtest.

Що заявляє проєкт і що ми перевірили

README починається з такого бенчмарку:

більш ніж у 4,5 раза швидше за DuckDB і Polars під час агрегації OHLCV+VWAP на 20 млн рядків

Це власний показник проєкту, наведений у README h5i-db за даними, отриманими в серпні 2026 року. Ми його не перевіряли, і жоден із наведених вище висновків від нього не залежить.

У цьому випадку зрілість важливіша за швидкість. На момент підготовки матеріалу репозиторій мав 29 зірок і версію 0.1.6 та поширювався за ліцензією Apache-2.0. Це означає, що проєкт підтримує невелика команда, а API ще може змінюватися навіть у межах мінорних релізів. Немає тривалої публічної історії роботи цього рушія під навантаженням. Фіксація точної версії та збереження parquet-файлів, на основі яких було створено базу даних, дають змогу повернутися до попереднього стану, якщо новий реліз змінить поведінку. Власна копія сирих даних — це така сама звичка, яка захищає від непомітної зміни історичних даних постачальником; саме ця тема проходить через survivorship bias в даних про акції.

Поширені запитання

Що таке point-in-time data?

Point-in-time data — це набір даних із часовими мітками, які показують, коли кожен факт став відомим. Завдяки цьому запит може відтворити інформацію, доступну на будь-яку дату в минулому. Звичайна таблиця з «останнім значенням» цього не дає, оскільки вона замінює минулі дані сьогоднішніми виправленими значеннями.

Чи усуває версійне зберігання look-ahead bias?

Ні. Версійність усуває один тип витоку даних: ситуацію, коли розрахунок зчитує значення, записані вже після моменту змодельованого рішення. Однак під час формування ознак витік може виникати інакше. Наприклад, якщо вибірку масштабують за статистиками, розрахованими за всю її історію.

Для чого потрібен idempotency key під час завантаження даних?

Він позначає операцію запису, щоб повторну спробу можна було розпізнати як ту саму операцію. Після збою завантажувач може повторно виконати операцію без додавання тих самих рядків удруге. Саме така помилка може непомітно зробити таблицю некоректною.

Чи готова h5i-db до використання у production?

На момент написання це версія 0.1.6 із 29 зірками на GitHub, поширювана за ліцензією Apache-2.0. Раннє програмне забезпечення такого масштабу може часто змінювати API та має обмежену публічну історію використання. Фіксація версії й власна копія файлів вихідного коду дають змогу скасувати результати оцінювання.


Кожна панель вище містить SQL-запит, за допомогою якого її побудовано. Відкрийте панель і перегляньте, як було розраховано показник. Ті самі запитання можна поставити звичайною англійською мовою в терміналі Strasmore.