3 minute read

백테스트의 제1원칙은 “각 시점의 판단에는 그 시점까지 알 수 있었던 정보만 쓴다”였습니다. 이 원칙을 어기는 것이 미래 참조 편향(look-ahead bias)입니다. 지금까지는 shift(1)을 빼먹는 경우만 이야기했는데, 실제로 미래 정보가 새어 들어오는 경로는 훨씬 다양하고 대부분 알아채기 어렵습니다. 오늘은 그 얼굴들을 하나씩 뜯어봅니다.

얼굴 1: 신호와 수익률의 시점이 어긋남

가장 기본적인 형태이고, 앞에서 다뤘으니 짧게 복습만 합니다. 이달 말 종가로 계산한 신호를 이달 수익률에 적용하면, 이미 끝난 달에 그 결과를 알고 투자한 셈이 됩니다.

signal = (monthly.pct_change(12) > 0).astype(int)
position = signal.shift(1)     # 이 줄이 없으면 시간여행
strategy = position * monthly.pct_change()

이건 눈에 잘 띕니다. 문제는 지금부터 나오는 것들입니다.

얼굴 2: 재무제표는 결산일에 공개되지 않는다

밸류 팩터를 백테스트한다고 합시다. “매년 말 PBR이 낮은 종목을 산다”는 규칙입니다. 여기서 PBR의 분모인 순자산은 12월 31일 결산 재무제표에서 나옵니다. 그런데 그 재무제표가 실제로 공개되는 시점은 언제일까요? 대개 이듬해 2~3월입니다. 감사보고서 확정까지 고려하면 3월 말이 흔합니다.

즉 “12월 31일에 12월 결산 PBR로 종목을 고른다”는 것은, 석 달 뒤에야 알 수 있는 숫자를 미리 알고 투자하는 것입니다. 이 오류는 밸류·퀄리티처럼 재무 데이터를 쓰는 전략에서 거의 예외 없이 발생하고, 성과를 상당히 부풀립니다. 실적이 좋게 나올 회사를 발표 전에 미리 사는 것과 같기 때문입니다.

해결책은 재무 데이터에 공시 시차를 반영해서 “사용 가능 시점”을 따로 두는 것입니다.

import pandas as pd

# fin: 결산 기준일을 인덱스로 하는 분기 재무 데이터 (예: PBR)
# 보수적으로 결산일 + 90일부터 사용 가능하다고 가정
fin = fin.copy()
fin['available_from'] = fin.index + pd.DateOffset(days=90)

# 각 매매 시점에 "그때까지 공개된 가장 최신 재무 데이터"를 붙인다
prices_df = monthly.rename('close').reset_index().rename(columns={'index': 'date'})
fin_df = fin.sort_values('available_from')

merged = pd.merge_asof(
    prices_df.sort_values('date'),
    fin_df,
    left_on='date',
    right_on='available_from',
    direction='backward'      # 과거 방향으로만 찾는다 = 미래 참조 차단
)

pd.merge_asofdirection='backward'를 주는 것이 핵심입니다. 각 매매 시점보다 이전에 공개된 데이터만 가져옵니다. 90일이라는 숫자는 보수적인 가정치이고, 실제 공시 일정을 확인해서 조정하면 됩니다. 공시일 데이터가 있다면 그것을 available_from으로 쓰는 것이 가장 정확합니다.

얼굴 3: 오늘의 지수 구성종목으로 과거를 본다

“코스피200 종목 중에서 모멘텀 상위를 산다”는 전략을 만들 때, 흔히 지금 시점의 코스피200 구성종목 목록을 받아서 10년 전부터 백테스트합니다. 문제는 10년 전에는 그 목록이 달랐다는 것입니다.

지금 코스피200에 들어 있는 종목은 지난 10년간 살아남았고 성장했기 때문에 들어 있는 것입니다. 10년 전 시점에서 “앞으로 지수에 편입될 종목들”만 골라 투자한 셈이니, 성과가 좋을 수밖에 없습니다. 이건 미래 참조 편향이면서 동시에 다음 글에서 다룰 생존 편향과 겹치는 영역입니다.

해결책은 각 시점의 구성종목 목록(point-in-time universe)을 쓰는 것인데, 이 데이터는 무료로 구하기 어렵습니다. 현실적인 차선책은 두 가지입니다. 첫째, 지수 대신 “그 시점에 상장되어 있던 종목 중 시가총액 상위 N개”처럼 그 시점 데이터만으로 재구성할 수 있는 유니버스를 쓰는 것. 둘째, 그것도 어렵다면 결과를 볼 때 “유니버스 편향으로 성과가 부풀려져 있다”는 것을 명시하고 보수적으로 해석하는 것입니다.

얼굴 4: 수정주가는 미래를 알고 있다

수정주가(adjusted price)는 액면분할, 배당 등을 과거 가격에 소급 반영한 것입니다. 수익률 계산에는 이게 맞습니다. 그런데 수정주가에는 미묘한 함정이 있습니다.

2018년 삼성전자가 50:1 액면분할을 했을 때, 수정주가 기준으로 2017년 가격은 약 5만 원대로 표시됩니다. 하지만 2017년에 실제로 거래된 가격은 250만 원대였습니다. “주가가 10만 원 이하인 종목만 산다” 같은 가격 수준에 기반한 규칙을 수정주가로 백테스트하면, 2017년의 삼성전자를 살 수 있었던 것으로 잘못 계산됩니다. 그 시점에는 분할이 있을지 아무도 몰랐는데 말이죠.

원칙은 이렇습니다. 수익률 계산은 수정주가로, 가격 수준·거래 단위 판단은 당시 실제 가격으로. 둘을 구분해서 보관해야 합니다.

얼굴 5: 전체 기간 통계로 정규화하기

이건 정말 자주, 그리고 조용히 일어나는 실수입니다. 지표를 표준화할 때 전체 기간의 평균과 표준편차를 쓰는 경우입니다.

# 잘못된 방법: 전체 기간 평균·표준편차 (2026년 데이터가 2010년 판단에 영향)
z_wrong = (x - x.mean()) / x.std()

# 올바른 방법: 각 시점까지의 데이터만 사용
z_right = (x - x.expanding().mean()) / x.expanding().std()

# 또는 최근 N개 기간만 사용 (롤링)
z_rolling = (x - x.rolling(36).mean()) / x.rolling(36).std()

x.mean()은 2010년부터 2026년까지의 평균입니다. 2010년의 신호를 이 값으로 정규화하면, 2010년에는 몰랐던 이후 16년의 평균 수준을 알고 판단하는 것이 됩니다. 이 실수는 “평균 대비 얼마나 높은가”를 쓰는 모든 전략 — 밸류, 저변동성, 평균회귀 — 에 숨어 있을 수 있습니다. 비슷한 예로 머신러닝 전처리에서 전체 데이터로 스케일러를 학습시키는 것도 같은 오류입니다.

점검 체크리스트

백테스트 코드를 완성했을 때 이 다섯 가지를 스스로에게 묻습니다.

  1. 신호 계산에 쓰인 마지막 데이터 시점이 매매 시점보다 앞인가? (shift 확인)
  2. 재무·거시 데이터에 공시 시차를 반영했는가? (merge_asof, direction='backward')
  3. 유니버스(종목 목록)가 그 시점의 목록인가, 지금 목록인가?
  4. 가격 수준 규칙에 수정주가를 쓰고 있지는 않은가?
  5. 평균·표준편차·분위수 계산에 미래 데이터가 섞여 있지 않은가? (expanding, rolling)

이 중 하나라도 “잘 모르겠다”면 백테스트 성과에 물음표를 붙여야 합니다.

정리

  • 미래 참조 편향은 shift 누락 외에도 재무제표 공시 시차, 현재 지수 구성종목 사용, 수정주가로 가격 수준 판단, 전체 기간 통계 사용 등 여러 경로로 스며든다.
  • 재무 데이터는 merge_asof(direction='backward')로 공시 시점 이후에만 사용한다.
  • 정규화는 expanding 또는 rolling으로 각 시점까지의 데이터만 쓴다.
  • 눈에 띄지 않는 경로일수록 성과를 크게 부풀리므로, 체크리스트로 매번 점검한다.

다음 글에서는 얼굴 3에서 잠깐 언급한 생존 편향(survivorship bias)을 따로 다룹니다. 상장폐지되어 데이터에서 사라진 종목들이 백테스트를 어떻게 왜곡하는지, 그리고 무료 데이터로 이 문제를 얼마나 완화할 수 있는지 살펴보겠습니다.


이 글은 학습과 정보 공유 목적으로 작성되었으며, 특정 종목이나 상품에 대한 투자 권유가 아닙니다. 투자의 판단과 책임은 투자자 본인에게 있습니다.

Updated: