본문 바로가기
inpilot.dev

백테스트 수익률이 너무 좋으면 룩어헤드부터 의심한다

2026-08-01 · 2 min read

$ pytest test_no_future_leak

FAILED — 미래를 뒤섞자 과거가 바뀜

처음 돌린 백테스트는 연 80%가 나왔다

당연히 틀린 숫자였다. 전략이 좋았던 게 아니라 미래를 보고 있었다. 룩어헤드 편향(look-ahead bias)은 티가 안 난다. 에러가 안 나고, 그냥 성적표가 좋아진다. 그래서 제일 위험하다.

내 코드에서 실제로 잡은 경로 네 개를 순서대로 적는다.

1. 종가를 보고 종가에 산다

가장 흔하고, 가장 크게 부풀린다.

# 틀림: 오늘 종가로 신호를 만들고 오늘 종가에 체결
if close[t] > ma20[t]:
    buy(price=close[t])

오늘 종가는 장이 끝나야 확정된다. 그 값을 보고 그날 종가에 사는 건 불가능하다. 신호가 나온 다음 봉에서 체결해야 한다.

if close[t] > ma20[t]:
    buy(price=open[t + 1])

신호는 t 종가, 체결은 t+1 시가
종가를 보고 그 종가에 사는 건 물리적으로 불가능하다

이거 하나 고쳤더니 연 80%가 20%대로 떨어졌다. 나머지 60%는 전부 환상이었다.

2. 재무 데이터를 "그 분기"에 붙인다

DART에서 2025년 1분기 실적을 받아서 2025년 3월 시점 전략에 쓰면 안 된다. 1분기 보고서는 5월에 공시된다. 3월에는 세상에 없는 숫자다.

붙일 기준결과
분기 종료일 (bsns_year + 분기)❌ 최대 45일 미래를 봄
공시 접수일 (rcept_dt)✅ 실제로 알 수 있었던 시점

그래서 재무 테이블에는 회계 기간과 별도로 접수일 컬럼을 반드시 저장한다. 조인 조건이 fiscal_period가 아니라 rcept_dt <= 거래일이 되어야 한다.

정정공시까지 생각하면 더 복잡해진다. 나중에 수정된 숫자는 그 시점엔 없던 값이다. 여기까지 완벽히 처리하려면 point-in-time 데이터베이스가 필요한데, 개인 프로젝트 수준에서는 접수일 기준 조인까지만 하고 한계를 명시하는 게 현실적이다.

3. 지금 상장된 종목만 테스트한다

오늘 기준 KOSPI 종목 리스트로 5년 백테스트를 돌리면, 그 5년 사이 상장폐지된 종목이 전부 빠진다. 망한 회사만 빠지고 살아남은 회사만 남으니 성적이 좋을 수밖에 없다. 생존 편향이다.

해결은 데이터 문제라 돈이나 수집 노력이 든다. 상폐 종목까지 포함된 유니버스를 따로 쌓아야 한다. 못 하겠으면 최소한 "이 백테스트는 생존 편향이 있다"고 결과에 박아둔다. 숫자를 못 고칠 때 할 수 있는 건 숫자를 믿지 않는 것뿐이다.

4. 지표를 전체 구간에 대고 계산한다

# 틀림: 전체 기간 평균·표준편차로 정규화
z = (price - price.mean()) / price.std()

price.mean()은 마지막 날 데이터까지 포함한 값이다. 2020년 시점 계산에 2025년 가격이 들어간다.

rolling으로 바꾸면 해결되지만, 여기도 함정이 있다. center=True면 뒤쪽 데이터를 쓴다. 기본값 확인하고 쓴다.

z = (price - price.rolling(60).mean()) / price.rolling(60).std()

검증: 셔플 테스트

눈으로 다 못 잡는다. 나는 이 체크를 붙여뒀다.

def test_no_future_leak():
    """미래 구간을 뒤섞어도 과거 신호가 그대로면 누수 없음"""
    signals_a = strategy(prices)
    tampered = prices.copy()
    tampered[500:] = tampered[500:].sample(frac=1).values   # 미래만 파괴
    signals_b = strategy(tampered)
    assert (signals_a[:500] == signals_b[:500]).all()

미래 데이터를 망가뜨렸는데 과거 신호가 바뀐다면, 그 전략은 미래를 보고 있다. 룩어헤드를 찾는 가장 싼 방법이고, 내 경우엔 여기서 4번을 잡았다.

한 줄 요약

신호는 t, 체결은 t+1. 재무는 접수일 기준. 지표는 rolling. 그리고 셔플 테스트로 증명한다.

새 글이 올라오면 받아보기

스팸 없이, 새 글이 올라올 때만 보내드려요.

댓글

댓글은 giscus 설정 후 표시됩니다. (docs/SETUP-features.md 참고)