📚 토스 자동매매 개발기 시리즈 (전체 14편 중 Phase 4)
- Phase 0 — 기반 시스템 구축 (#0)
- Phase 0-1 — 통화 단위 버그 수정 (#0-1)
- Phase 1 — 설정 분리 및 재시도 로직 (#3)
- Phase 1-2 — 재시도 로직 후속 보완 (#4)
- Phase 1-3 — pytest 테스트 도입 (#5)
- Phase 1-4 — 로그 로테이션 (#6)
- Phase 2-1 — 전략 클래스 추상화 (#7)
- Phase 2-2 — 복수 종목 감시 지원 (#8)
- Phase 3-1 — 데이터 영속성 설계 (#9)
- Phase 3-2 — DB 모듈 구현 (#10)
- Phase 3-3 — DB 통합 (#11)
- Phase 2-3 — 전략 플러그인 인터페이스 (#12)
- Phase 4 — 백테스트 엔진 구현 (#13)
← 이전 글: Phase 4 (백테스트 엔진 구현, #13) | 다음 글: Phase 5 (발행 전)
💡 이번 Phase 4 요약
- 목적: 백테스팅에 필요한 과거 시세 데이터를 토스증권 캔들 API로 실제 수집·저장
- 핵심 포인트: 응답 스키마가 확정되지 않은 상태에서 방어적으로 파싱 로직을 먼저 설계하고, 이후 실제 토큰으로 검증하는 순서로 리스크를 줄임
- 검증: 신규 테스트 7개 추가(캔들 파싱 3개, 페이지네이션 연동 2개, DB 시각 보존 2개), 전체 78개 테스트 통과
백테스트 로직 자체는 지난 Phase 4 첫 글(#13)에서 이미 완성해뒀다. 매수-매도 시뮬레이션도 돌아가고, 성과 리포트도 나온다. 근데 정작 “과거 시세를 어디서 가져올 건데?”라는 질문에는 계속 답을 못 하고 있었다. 이번 글은 그 마지막 구멍을 메우는 작업이다.
토스증권 캔들 API, 진짜 있었다
제일 먼저 확인해야 했던 건 “토스증권 Open API에 과거 시세 조회 기능이 있긴 한가”였다. developers.tossinvest.com 공식 문서를 뒤져보니 GET /api/v1/candles 엔드포인트가 실제로 존재했다. 파라미터는 symbol, interval(1m/1d만 지원), count(최대 200), before(페이지네이션용), adjusted(수정주가 여부)로 구성되어 있었다.
눈여겨본 부분은 Rate Limit이 MARKET_DATA_CHART라는 별도 그룹으로 관리된다는 점이었다. 다른 API 그룹과 분리되어 있다는 뜻이라, 캔들 조회를 과도하게 반복해도 주문/잔고 조회 쪽에는 영향을 안 준다는 걸 미리 확인해둔 셈이다.
스키마 확정 전에, 방어적으로 먼저 짠다
공식 문서만으로는 응답 JSON의 정확한 래퍼 구조까지는 100% 확신할 수 없었다. 기존에 붙였던 다른 API들이 대체로 {"result": [...]} 패턴을 따랐던 걸 참고해서, 그 가정에 맞춰 toss_common.py에 get_candles()를 우선 구현했다. 재시도 데코레이터도 그대로 적용했다.
동시에 toss_test.py에는 원본 응답을 눈으로 직접 까볼 수 있는 진단 함수 fetch_candles_raw()를 추가했다. 예전에 보유종목 API 스키마를 확인할 때 썼던 것과 같은 접근이다 — 확신 없는 부분은 짐작으로 밀어붙이지 않고, 실제 응답을 눈으로 보고 확정한다.
def get_candles(symbol: str, interval: str = "1d", count: int = 200, before: str | None = None):
"""토스증권 캔들 API 호출 (방어적 파싱)"""
params = {"symbol": symbol, "interval": interval, "count": count}
if before:
params["before"] = before
resp = _request_with_retry("GET", "/api/v1/candles", params=params)
data = resp.json()
result = data.get("result", {})
return result.get("candles", []), result.get("nextBefore")
200개 제한, 페이지네이션으로 넘어서기
매도 시점이 빠져있던 예전 문제와 비슷한 맥락으로, 캔들 API도 한 번 호출만으론 충분한 과거 데이터를 모을 수 없었다. 최대 200개 제한이 있으니, backtest.py에 fetch_and_store_historical_prices()를 만들어 before 파라미터로 반복 호출하면서 원하는 개수만큼 캔들을 모으고 시간순 정렬 후 price_snapshots에 저장하도록 했다.
여기서 신경 쓴 디테일 하나: 과거 데이터를 백필할 때 저장 시각이 “지금”이 되어버리면 안 된다는 점이다. 그 캔들이 실제로 발생한 시각이 보존돼야 나중에 시뮬레이션 정합성이 맞는다. 그래서 db.py의 insert_price_snapshot()에 captured_at 선택 파라미터를 추가해서 실제 캔들 시각을 그대로 넣을 수 있게 했다.
def insert_price_snapshot(conn, symbol: str, price: float, captured_at: str | None = None):
ts = captured_at or datetime.utcnow().isoformat()
conn.execute(
"INSERT INTO price_snapshots (symbol, price, captured_at) VALUES (?, ?, ?)",
(symbol, price, ts),
)
conn.commit()
토스증권 캔들 API(GET /api/v1/candles)는 before 파라미터로 페이지네이션하며, 응답은 {"result": {"candles": [...], "nextBefore": ...}} 구조를 따른다. 최대 200개 제한이 있어 과거 데이터 백필 시 반복 호출이 필요하다.
모킹으로 먼저, 실제 토큰으로 나중에
실제 API 응답 스키마가 확정 전이었으니, 검증 순서는 항상 그렇듯 모킹이 먼저였다. 2페이지 페이지네이션 → 시간순 정렬 → DB 저장(실제 캔들 시각 보존) → 기존 run_backtest()와의 연계까지 파이프라인 전체를 모킹으로 통과시킨 뒤, 마지막에 실제 계정 토큰으로 toss_test.py를 실행해 원본 응답을 확인했다.
결과는 예상대로였다. 방어적으로 가정했던 {"result": {"candles": [...], "nextBefore": ...}} 구조가 실제 응답과 정확히 일치했다. 추가 수정 없이 그대로 쓸 수 있었고, 캔들이 최신순으로 내려온다는 것도 확인돼서 미리 넣어둔 정렬 로직이 실제로 필요했다는 게 실증됐다. (예시 종목코드는 마스킹: 005930, 000660 등 가상 코드로 대체해 테스트)
Phase 0~4, 여기까지 왔다
신규 테스트 7개(캔들 파싱 3개, 페이지네이션 연동 2개, DB 시각 보존 2개)를 추가해 전체 78개 테스트가 통과했다. 이걸로 Phase 4(백테스팅)의 세 항목 — 과거 시세 수집/저장, 시뮬레이션, 결과 리포트 — 이 전부 끝났고, Phase 0부터 이어온 큰 로드맵이 일단락됐다.
자주 묻는 질문
Q1. 토스증권 Open API로 과거 시세(캔들) 데이터를 받아올 수 있나요?
A. 네. GET /api/v1/candles 엔드포인트를 통해 일봉(1d)·분봉(1m) 데이터를 조회할 수 있습니다. 다만 한 번에 최대 200개까지만 제공되므로, 그 이상은 before 파라미터로 페이지네이션해야 합니다.
Q2. 캔들 조회도 다른 API처럼 Rate Limit에 걸리나요?
A. 별도의 MARKET_DATA_CHART 그룹으로 관리되기 때문에, 캔들 조회를 반복해도 주문·잔고 조회용 Rate Limit과는 분리되어 있습니다.
Q3. 응답 스키마를 미리 확신할 수 없을 땐 어떻게 접근했나요?
A. 기존 API들의 공통 패턴(result 래퍼)을 가정해 방어적으로 먼저 구현하고, 원본 응답을 그대로 찍어보는 진단 함수를 따로 만들어 실제 토큰으로 검증한 뒤 확정했습니다.
Q4. 과거 데이터를 백필할 때 저장 시각은 어떻게 처리하나요?
A. insert_price_snapshot()에 captured_at 선택 파라미터를 추가해, 저장 시점이 아니라 캔들이 실제로 발생한 시각을 그대로 기록하도록 했습니다.
“캔들 API 페이지네이션으로 과거 시세 확보하기 | 토스 자동매매 개발기 #14”에 대한 3개의 생각