본문 바로가기
inpilot.dev

시세 데이터를 저장하다 깨달은 것 — 없는 봉과 있는 봉

2025-09-07 · 3 min read

$ INSERT … ON CONFLICT DO NOTHING

INSERT 0 0 — 같은 봉은 안 들어감

테이블은 단순한데 결정할 게 많다

CREATE TABLE candles (
  symbol     TEXT        NOT NULL,
  interval   TEXT        NOT NULL,      -- '1m' | '1d'
  ts         TIMESTAMPTZ NOT NULL,      -- 봉의 시작 시각
  open       BIGINT      NOT NULL,      -- 최소단위 정수
  high       BIGINT      NOT NULL,
  low        BIGINT      NOT NULL,
  close      BIGINT      NOT NULL,
  volume     BIGINT      NOT NULL,
  PRIMARY KEY (symbol, interval, ts)
);

PK를 (symbol, interval, ts)로 잡는 게 제일 중요하다. 같은 봉을 두 번 수집해도 INSERT가 충돌해서 막힌다. 재수집 스크립트를 몇 번 돌려도 데이터가 불어나지 않는다. ON CONFLICT DO NOTHING을 붙이면 "빠진 것만 채우기"가 그냥 된다.

ts가 봉의 시작인지 인지도 정해서 문서에 박아둔다. 거래소마다 다르고, 섞이면 1분씩 어긋난 채로 백테스트가 돌아간다. 나는 시작 기준으로 통일했다. 09:00 봉은 09:00:00~09:00:59를 담는다.

거래가 없는 분봉은 어떻게 하나

거래량 적은 종목은 몇 분씩 체결이 없다. 그때 선택지가 둘이다.

방식장점문제
행을 안 만든다저장 공간 절약, 사실에 충실이동평균·인덱싱에서 시간 간격이 불규칙
직전 종가로 채운다(O=H=L=C, V=0)시계열이 규칙적"진짜 거래"와 구분이 안 됨

나는 저장은 비우고, 조회 계층에서 채운다로 갔다. DB에는 일어난 일만 넣고, 백테스트가 읽을 때 reindex로 채운다.

df = df.reindex(full_range(start, end, "1min"))
df["volume"] = df["volume"].fillna(0)
df[["open","high","low","close"]] = df[["open","high","low","close"]].ffill()

이유는 되돌릴 수 있어서다. 저장 단계에서 채워버리면 나중에 "이 봉은 실제 체결이었나"를 영원히 알 수 없다. 원본은 원본대로 둔다.

결측과 미수집을 구분해야 한다

이게 실제로 물린 지점이다. 특정 구간에 봉이 없다. 두 가지 뜻이 가능하다.

  1. 그 시간에 거래가 없었다 → 정상
  2. 내 수집기가 죽어 있었다 → 구멍

행이 없다는 사실만으로는 구분이 안 된다. 그런데 2번을 1번으로 착각하면 백테스트가 "그 기간엔 아무 일도 없었다"고 믿고 조용히 틀린 결과를 낸다.

수집 이력을 별도로 남긴다.

CREATE TABLE ingest_ranges (
  symbol   TEXT NOT NULL,
  interval TEXT NOT NULL,
  from_ts  TIMESTAMPTZ NOT NULL,
  to_ts    TIMESTAMPTZ NOT NULL,       -- 이 구간은 '조회를 완료'했다
  PRIMARY KEY (symbol, interval, from_ts)
);

"조회를 완료한 구간"을 기록해두면, 봉이 없는 게 결측인지 미수집인지 질의로 판정된다. 백테스트 시작 전에 이걸 먼저 검사한다.

def assert_covered(symbol, start, end):
    holes = uncovered_ranges(symbol, "1d", start, end)
    assert not holes, f"수집 안 된 구간: {holes}"

파티셔닝은 아플 때 한다

1분봉은 종목당 하루 390행 남짓, 2000종목 5년이면 수십억 행이 된다. 그런데 처음부터 파티셔닝·TimescaleDB를 붙이는 건 과하다.

내 기준은 이렇다.

  • 일봉만 필요하다 → 그냥 Postgres 한 테이블. 5년 × 2000종목 = 약 250만 행. 아무 문제 없다
  • 분봉 + 종목 수십 개 → 여전히 한 테이블로 버틴다
  • 분봉 전 종목 → 그때 월 단위 파티션이나 시계열 DB를 검토한다

먼저 인덱스와 쿼리를 본다. WHERE symbol = ? AND ts BETWEEN ?가 PK 앞부분을 그대로 타면 대부분의 느림은 파티셔닝이 아니라 다른 데서 온다.

백필과 실시간은 같은 함수로

수집 코드를 두 벌 만들면 반드시 갈라진다. 실시간 스트림에서 받은 봉과 과거 API로 받은 봉이 다른 형식으로 저장되는 사고가 난다.

def upsert_candles(rows: list[Candle]) -> int:
    """실시간·백필 모두 이 함수만 통과한다"""
    ...
 
def test_realtime_and_backfill_agree():
    """같은 구간을 두 경로로 받으면 같은 값이어야 한다"""
    a = fetch_history(sym, day)
    b = replay_stream(sym, day)
    assert normalize(a) == normalize(b)

이 테스트가 처음 돌았을 때 실패했다. 실시간 쪽 거래량이 누적값이었고 백필 쪽은 봉별 값이었다. 눈으로는 몇 달 못 찾았을 종류의 차이다.

한 줄 요약

PK로 중복을 막고, 빈 봉은 조회 때 채우고, 수집 완료 구간을 따로 기록해 구멍을 구분한다.