h5i-db를 활용한 시점 데이터 백테스트 방법
h5i-db는 모든 쓰기 작업을 버전화하여 백테스트 시 미래 데이터가 참조되는 누수 현상을 방지합니다. 본문은 API(애플리케이션 프로그래밍 인터페이스)를 통한 과거 시점 데이터 지정 방식과 공시 지연 데이터를 활용한 데이터 검증 과정을 상세히 설명합니다.
시점 데이터(Point-in-time data)와 데이터 누수 방지
시점 데이터는 과거 특정 시점에 데이터셋이 보유했던 기록을 의미합니다. 백테스트(backtest)에서 이는 미래의 수치를 미리 반영하는 오류를 방지하고, 논리적으로 방어 가능한 결과와 그렇지 않은 결과를 구분하게 해줍니다. h5i-db는 Rust로 작성되고 Python API(애플리케이션 프로그래밍 인터페이스)를 지원하는 신생 오픈소스 시계열 데이터베이스입니다. 이 데이터베이스는 모든 쓰기 작업을 버전 번호와 함께 저장하며, 읽기 작업 시 과거 특정 시점의 버전을 지정(pinning)할 수 있도록 지원합니다. 아래는 데이터 지정 기능을 통해 차단할 수 있는 데이터 누수 현상을 실제 공시 데이터를 통해 측정한 결과이며, 이어지는 내용은 노트북 환경에서 직접 실행 가능한 시나리오입니다.
백테스트에서 시점 데이터(point-in-time data)란 무엇인가?
모든 시장 데이터에는 두 가지 타임스탬프가 존재한다. 이벤트 타임(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_end2026-06-30로 끝나는 분기의 경우, 10688 보고서는 해당 기간 종료 후 평균 34.1일이 지나서 도착했다. 이 패널은 16 분기에 걸쳐 해당 측정치를 반복한다. 또한 운용사는 보고서를 제출한 지 한참 지난 후에도 내용을 수정할 수 있으므로, 과거 날짜를 설명하는 기록은 해당 시점이 지난 후에도 계속 변경될 수 있다.
미래 정보 편향(Look-ahead bias)은 데이터 저장의 문제이다
백테스팅에서의 미래 정보 편향에 관한 당사의 가이드는 데이터 누출(leakage)을 규율의 문제로 다룬다. 모든 피처(feature)에 시차를 두고 공시일을 준수하라는 것이다. 이러한 규율은 누군가 잊어버리기 전까지는 유지되지만, 그 실패는 조용히 일어난다. 데이터가 누출된 백테스트는 더 높은 샤프 지수(Sharpe ratio)를 기록하며, 어떠한 오류 메시지도 출력하지 않는다.
시점 데이터(Point-in-time) 저장은 이러한 보장을 한 단계 아래 계층으로 이동시킨다. 전략에 전달되는 데이터 프레임이 특정 버전으로 고정된 읽기(read) 작업에서 비롯될 경우, 전략 코드가 이후에 어떤 작업을 수행하더라도 해당 버전 이후에 기록된 행(row)은 데이터에 나타날 수 없다. 검증은 코드 리뷰의 영역을 넘어 읽기 작업 자체의 속성이 된다.
배당금은 이러한 시차 문제를 다른 측면에서 보여준다. 현금 배당은 먼저 선언(declaration)된 후 나중에 배당락(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 month2026-07-01에 시작된 달에 배당 선언은 배당락일보다 평균 88.2일 앞서 이루어졌으며, 해당 패널 데이터는 동일한 측정 기준으로 24개월의 기간을 포괄한다. 해당 기간 내의 시뮬레이션 날짜로 최신 배당 테이블을 읽어 들이면, 선언이 존재하기도 수주 전부터 배당 정보가 이미 테이블에 기록되어 있는 상태가 된다.
저장된 이력의 재작성
지연 도착은 하나의 실패 유형입니다. 재작성(restatement)은 또 다른 유형입니다. 기업 활동은 이미 체결된 가격을 다시 씁니다. 4대 1 주식 분할 이후, 조정된 시계열상의 모든 이전 가격은 4로 나누어지며, 오늘 다운로드한 시계열은 트레이더가 과거에 확인했던 호가창(tape)과 더 이상 일치하지 않습니다. 분할 조정 가격 이력에 관한 당사의 노트는 해당 산술 과정을 다룹니다. 여기서 중요한 것은 빈도입니다.
모든 수치 뒤에 숨겨진 정확한 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_date2026-04-01에 시작된 분기 동안, 131건의 액면분할과 303건의 액면병합이 효력을 발생했습니다. 각각의 사례는 리서치 파이프라인이 이미 캐시(cache)했을 수 있는 가격 이력을 재작성합니다. 버전 관리 저장소(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시간 내내 쏟아집니다. 지난 90일 동안 09:00시에는 790건의 기사가, 20:00시에는 404건의 기사가 송고되었습니다. 저녁 헤드라인에 당일 오후 4시 종가 타임스탬프를 찍는 행위는 종가 매매 전략에 수 시간의 사후 확신 편향(hindsight)을 제공하는 꼴이 됩니다.
실행 가능한 시점(point-in-time) 시나리오
이 작업은 모두 Python 패키지 내에서 이루어집니다. 본 프로젝트는 별도의 설치가 필요한 Rust 명령줄 도구도 제공하지만, 이 가이드에서는 사용하지 않습니다. 샘플 데이터는 외부 다운로드 없이 로컬에서 생성됩니다.
- 2026년 8월 4일에 배포된 고정 릴리스
pip install 'h5i-db==0.1.6'을 설치합니다. Python 3.9 이상 버전이 필요하며pyarrow>=14이 함께 설치됩니다. 사전 빌드된 휠(wheel)은 x86-64 및 arm64 기반 Linux, Apple silicon macOS, x86-64 기반 Windows를 지원합니다. 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는 시간대(timezone) 정보가 포함된 날짜 및 시간 데이터입니다.- 데이터베이스와 테이블을 생성하고 시간 열의 이름을 지정합니다:
db = h5i_db.Database('pit.db', create=True), 이어서db.create_table('prices', schema, time_column='ts')을 실행합니다. - 특정 키 아래에 파일을 수집(ingest)합니다:
db.append('prices', pq.read_table('day1.parquet'), idempotency_key='load-day1'). 이 호출은 생성된 커밋(commit)을 반환합니다. - 동일한 라인을 다시 실행합니다. 프로젝트 문서에 따르면, 동일한 키로 반복 실행할 경우 이미 생성된 커밋을 찾아
"segments_added": 0과 함께 반환하며, 행을 중복 작성하지 않습니다. 재시도 전후로db.versions('prices')를 출력하여 버전 목록이 유지되는지 확인합니다. idempotency_key='load-day2'아래에 두 번째 날짜 데이터를 수집한 뒤, 두 날짜에 걸쳐 쿼리를 수행합니다:db.sql('SELECT symbol, count(*) AS n, avg(price) AS px FROM prices GROUP BY symbol').to_pandas().- 2일 차 데이터가 반영되기 이전의 상태로 테이블을 읽습니다:
db.read('prices', version=1). 동일한 작업을 위해 해당 메서드에as_of=및snapshot=인수를 사용합니다.
6단계는 주의 깊게 살펴볼 필요가 있습니다. 중복된 추가(append) 작업은 오류를 발생시키지 않습니다. 해당 시점부터 테이블은 잘못된 상태가 되며, 이후 실행되는 모든 작업은 이 오류를 그대로 이어받게 됩니다. 8단계는 그 결과물입니다. 3월에 계산된 수치를 8월에 동일한 고정 버전으로 재계산할 수 있는데, 이는 재현 가능한 백테스트에 관한 본 문서가 프레임워크 수준에서 주장하는 핵심 속성입니다.
프로젝트의 주장과 검증 내용
README는 다음과 같은 벤치마크를 앞세우고 있습니다.
2천만 행의 OHLCV(시가·고가·저가·종가·거래량) 및 VWAP(거래량 가중 평균 가격) 롤업 작업에서 DuckDB와 Polars보다 4.5배 이상 빠름
이 수치는 2026년 8월 기준 h5i-db README에서 인용한 프로젝트 자체 측정값입니다. 당사는 이를 직접 실행하지 않았으며, 위에서 언급한 내용은 해당 수치에 의존하지 않습니다.
이 분야에서는 속도보다 성숙도가 더 중요합니다. 해당 저장소는 작성 시점 기준 29개의 별(star)을 받았으며, Apache-2.0 라이선스 하에 버전 0.1.6으로 운영되고 있습니다. 이는 유지보수 인력이 적고, 마이너 버전 업데이트 간에도 API가 변경될 수 있음을 의미합니다. 해당 엔진이 부하가 걸린 상태에서 장기간 운영된 공개 기록은 없습니다. 정확한 버전을 고정하고 데이터베이스에 공급된 Parquet 파일을 보관하면, 릴리스 변경으로 인해 동작이 달라질 경우 이전 상태로 되돌릴 수 있습니다. 원본 데이터를 직접 보관하는 것은 벤더가 임의로 과거 데이터를 수정하는 상황을 방지하는 습관과 같으며, 이는 주식 데이터의 생존 편향 전반에 걸쳐 관통하는 주제입니다.
자주 묻는 질문(FAQ)
시점(point-in-time) 데이터란 무엇입니까?
시점 데이터는 각 사실을 알 수 있게 된 시점의 타임스탬프와 함께 저장된 데이터셋으로, 쿼리를 통해 과거 특정 시점에 어떤 정보가 확인 가능했는지 재구성할 수 있습니다. 단순한 '최신 값' 테이블은 오늘의 수정된 수치로 과거 데이터를 덮어쓰기 때문에 이러한 기능을 수행할 수 없습니다.
버전 관리 저장소는 미래 예측 편향(look-ahead bias)을 제거합니까?
아니요. 버전 관리는 시뮬레이션된 의사결정 시점 이후에 기록된 값을 읽는 유형의 데이터 누출을 방지할 뿐입니다. 피처 구성 과정에서 전체 이력을 바탕으로 계산된 통계치를 사용하여 샘플의 스케일을 조정하는 등 다른 방식으로 여전히 데이터가 누출될 수 있습니다.
데이터 수집 시 멱등성 키(idempotency key)는 어떤 역할을 합니까?
쓰기 작업에 라벨을 지정하여 재시도 시 동일한 쓰기 작업임을 인식하게 합니다. 이를 통해 로더(loader)가 시스템 충돌 후 다시 실행되더라도 동일한 행을 중복 추가하지 않게 되며, 이는 테이블의 데이터가 조용히 잘못되는 오류를 방지합니다.
h5i-db는 실운영 환경에 적합합니까?
이 글을 작성하는 시점 기준으로 Apache-2.0 라이선스 하의 버전 0.1.6이며 GitHub에서 별 29개를 받았습니다. 해당 규모의 초기 소프트웨어는 API 변경이 잦고 공개된 운영 이력이 부족하므로, 버전 고정(version pin)과 소스 파일의 자체 복사본을 유지해야 평가의 가역성을 확보할 수 있습니다.
위의 모든 패널은 해당 수치를 산출한 SQL과 함께 제공되므로, 패널을 열어 수치가 어떻게 계산되었는지 확인하십시오. 동일한 질문을 Strasmore 터미널에서 평이한 영어로 입력할 수 있습니다.