백테스트 수익률이 너무 좋으면 룩어헤드부터 의심한다
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])이거 하나 고쳤더니 연 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. 그리고 셔플 테스트로 증명한다.