투자 도구·자동화

백테스트용 무료 시장 데이터, 소스별로 무엇을 조심해야 할까: FinanceDataReader·pykrx·DART·SEC 함정 지도

readtest 2026. 10. 6. 21:18

백테스트는 결과보다 어디서 받은 데이터인지에서 먼저 틀어진다. 이 블로그의 백테스트 글을 쓰면서 FinanceDataReader, pykrx(한국거래소 공표값), DART, SEC, 야후 파이낸스, Shiller·Ken French 자료를 모두 써 봤고, 소스마다 "되는 것"과 "안 되는 것"이 달랐다. 이 글은 그 경험을 소스별 함정 지도로 정리한 것이다. 오류 사례는 데이터 오류 6가지와 일곱 번째 오류에, DART 사용법은 DART OpenAPI 글에 있으니, 여기서는 어떤 소스에서 무엇을 조심해야 하는지를 한 장으로 본다.

소스별 한눈에 보기

소스 받을 수 있는 것 조심할 점 (직접 겪은 것)
FinanceDataReader 국내·미국 일봉, 지수 가격은 분할 보정이 되어 있지만 거래량은 보정되지 않았다. 거래정지 중에는 거래량 0에 종가만 그대로 이어진다. 기본으로 받는 종목 목록은 현재 상장 종목이라 상장폐지 종목은 따로 모아야 했다.
pykrx (한국거래소 공표값) 날짜별 시가총액, PER·PBR, EPS·BPS 등 시점 기준 스냅샷 (2005년부터, 상장폐지 종목 포함) 짧은 간격으로 대량 호출하면 IP가 차단된다. import 때 자동 로그인을 하므로 스크립트를 연달아 띄우지 않는다.
DART OpenAPI 연간·분기 재무제표, 공시일 짧은 시간 대량 호출 뒤 연결이 끊겼다. 분기 값은 누적이라 단독 분기로 바꾸는 계산이 필요하다.
SEC (EDGAR XBRL) 미국 상장사 재무(2009년 이후) 주식수가 최신 재공시 기준으로 들어오는 경우가 있어 분할을 직접 보정해야 한다. 시점별로 그대로 받을 수 있는 재무가 없어 책의 1980~2000년대 기간은 재현할 수 없었다.
야후 파이낸스 (yfinance) 조정 종가, 시간봉, 금리(^IRX) 시간봉은 최근 약 730일만 받을 수 있다. 비공식 경로라 형식이 바뀔 수 있다.
Shiller, Ken French 자료 장기 시장 수익률(1871~), 요인 포트폴리오(상장폐지 종목 포함) 자료가 끝나는 시점이 정해져 있다(Shiller 자료는 2023년 6월까지). 최근 구간은 다른 소스로 이어 붙여야 한다.

※ 위 내용은 2026년 10월 기준으로 이 블로그의 작업에서 직접 확인한 것이다. 서버 정책과 라이브러리는 바뀔 수 있다.

함정 1. 가격은 보정돼 있는데 거래량은 아니다

삼성전자는 2018년 5월 4일 50분의 1 액면분할을 했다. FinanceDataReader의 종가는 분할에 맞게 보정되어 있어서 2018년 4월 24일 종가가 50,460원으로 나온다. 하지만 거래량은 그대로다. 거래정지가 풀린 날 거래량은 39,565,391주로, 분할 전 거래량(약 30~60만 주)의 수십 배다.

import FinanceDataReader as fdr
import pandas as pd


def resume_jumps(df, min_gap=1, window=20, ratio=10):
    """거래량 0이 이어진 뒤 재개한 날, 재개 후 window일 중앙값 / 정지 전 중앙값이 ratio 이상이면 반환."""
    v = df["Volume"].values
    out, i, n = [], 0, len(df)
    while i < n:
        if v[i] == 0:
            j = i
            while j < n and v[j] == 0:
                j += 1
            if j - i >= min_gap and i >= window and j + window <= n:
                before = pd.Series(v[i - window:i]).median()
                after = pd.Series(v[j:j + window]).median()
                if before > 0 and after / before >= ratio:
                    out.append((df.index[j].date(), int(before), int(after), after / before))
            i = j
        else:
            i += 1
    return out


d = fdr.DataReader("005930", "2010-01-01", "2026-09-30")
print(resume_jumps(d))

이 코드를 삼성전자 2010~2026년에 돌리면 2018년 5월 4일 한 건이 나온다. 재개 전 20일 거래량 중앙값은 248,504주, 재개 후 20일 중앙값은 14,407,267주로 약 58배다. 거래량 급증 조건으로 종목을 고르는 전략이라면 이 날들이 전부 "신호"로 잡힌다. 이 문제를 놓쳐서 국내 백테스트 수치를 고친 과정은 일곱 번째 오류 글에 있다. 거래량을 쓰는 규칙이라면 분할·거래정지 재개 직후 일정 기간(우리는 60거래일)의 거래량을 제외하는 방법을 썼다.

함정 2. 서버는 생각보다 쉽게 막는다

  • KRX(pykrx): 0.3초 간격으로 수백 건을 호출하다가 한국거래소 데이터 서버가 차단 안내 페이지로 응답하기 시작했다. 약 8시간 뒤에 풀렸다. 이후 호출 간격을 1.5초 이상으로 두고, 로그인을 반복하지 않도록 했다.
  • DART: 상장주식수 API를 짧은 시간에 대량으로 호출한 뒤 연결이 끊겼다. 브라우저로도 접속이 안 되는 상태가 한동안 이어졌다. 풀린 뒤 분기 재무 627건은 호출 간격 0.65초로 받았다. 자세한 내용은 DART OpenAPI 글에 있다.

그래서 대량 수집 스크립트에는 최소 호출 간격과 "연결 오류가 이어지면 즉시 멈추기"를 기본으로 넣는다. 아래는 이 블로그에서 쓴 방식을 일반화한 도우미다. 최소 간격을 지키고, 연결 오류가 연속으로 나면 예외를 던져 수집을 멈추게 한다.

import time


class ServerBlocked(Exception):
    pass


class Polite:
    def __init__(self, min_interval=1.5, max_errors=3):
        self.min_interval = min_interval
        self.max_errors = max_errors
        self._last = 0.0
        self._errors = 0

    def call(self, fn, *args, **kwargs):
        wait = self.min_interval - (time.monotonic() - self._last)
        if wait > 0:
            time.sleep(wait)
        self._last = time.monotonic()
        try:
            out = fn(*args, **kwargs)
            self._errors = 0
            return out
        except (ConnectionError, TimeoutError) as e:
            self._errors += 1
            if self._errors >= self.max_errors:
                raise ServerBlocked("연결 오류가 연속으로 났다: 중단하고 시간을 두고 다시 시도") from e
            raise

사용은 p = Polite(1.5)를 만들고 p.call(함수, 인자)로 부르면 된다. 연결 오류 두 번째에서 ServerBlocked가 나는 동작까지 간단히 돌려 확인했다. 호출 간격(1.5초 등)은 우리가 겪은 경험에서 나온 값이고, 서버의 공식 허용치가 아니다. 서버 이용 약관과 호출 한도를 먼저 확인하는 것이 맞다.

함정 3. 같은 이름의 캐시가 옛 데이터를 돌려준다

백테스트 엔진에 결과를 캐시해 두는 구조였는데, 캐시 키에 입력 데이터가 들어 있지 않았다. 데이터를 고쳐서 다시 돌려도 이전 결과가 그대로 나왔다. 데이터를 바꿔 다시 계산할 때는 캐시를 비우거나, 키에 데이터 버전(파일 수정 시각이나 해시)을 넣어야 한다. "수정했는데 숫자가 안 바뀐다"면 데이터가 아니라 캐시부터 의심해 보자.

함정 4. 방어 장치도 가정이다

미국 마법공식 글(/50)에서는 현재 상장 전체 3,977개 종목을 쓰다 보니 가격 오류나 극단값이 섞였다. 그래서 진입가가 1달러 미만인 종목은 제외하고, 종목별 1년 수익률에 +500% 상한을 뒀다. 이런 방어 장치는 결과를 안정시키지만 내가 정한 가정이다. 상한을 바꾸면 수치가 달라질 수 있으니 글에 그대로 밝혀야 한다.

수집 전후 체크리스트

  • 이 소스의 가격·거래량·주식수는 각각 분할 보정이 되어 있는가? (하나만 보정된 경우가 있다)
  • 상장폐지 종목이 들어 있는가? 없다면 전략에 유리한 쪽인지 불리한 쪽인지(싼 부실주를 사는 전략은 유리해진다) 먼저 따져 보았는가?
  • 재무 데이터는 공시일 기준으로 쓰고 있는가? (결산일 기준으로 쓰면 미래 정보가 섞인다)
  • 호출 간격과 중단 조건을 코드에 넣었는가? 연결이 끊기면 즉시 멈추는가?
  • 받은 원본을 파일로 저장해 두었는가? 같은 코드로 다시 받으면 같은 값이 나오는가?
  • 데이터를 바꿨을 때 캐시가 무효화되는가?
  • 방어 장치(가격 하한, 수익률 상한)와 그 값을 글에 적었는가?
  • 결과가 이상하게 좋거나 나쁘면 코드보다 먼저 원본 몇 개를 눈으로 열어 보았는가?

이 글의 한계

  • 이 블로그 작업에서 겪은 것만 담았다. 다른 종목·기간·라이브러리 버전에서는 다른 문제가 있을 수 있다.
  • 차단 시간·호출 간격은 한 번의 경험이다. 서버의 공식 기준이 아니며, 정책은 바뀔 수 있다.
  • 소스별 표는 완전하지 않다. 모든 소스의 모든 문제를 확인한 것이 아니라 우리가 쓴 범위에서 정리했다.
  • 점검 코드는 하나의 예시다. 삼성전자 한 종목에서 동작을 확인했고, 전 종목 일반화에는 경계값(min_gap, ratio)을 따로 조정해야 한다.

정리

  • 무료 데이터는 소스마다 되는 것과 안 되는 것이 다르다. 가격과 거래량의 분할 보정이 따로일 수 있다.
  • 삼성전자 사례에서 재개 후 거래량이 정지 전의 약 58배로 나타났다. 거래량 조건을 쓰는 전략은 이런 구간을 반드시 점검해야 한다.
  • 공개 서버는 빠른 호출에 쉽게 차단한다. 최소 간격과 즉시 중단을 코드에 넣어 두자.
  • 캐시와 방어 장치도 결과에 영향을 주는 가정이다.

이 글과 함께 읽으면 좋은 글: 데이터 오류 6가지와 체크리스트 · DART OpenAPI 재무제표 받기 · 백테스트 재점검: 일곱 번째 오류


※ 본 글은 개인 학습 기록이며 특정 종목의 매수·매도를 권유하지 않습니다. 각 데이터 제공처의 이용 약관과 호출 한도를 직접 확인하고 지켜 주시기 바랍니다. 투자 판단과 책임은 투자자 본인에게 있습니다.

반응형