Frontier Ledger — Why Most Backtests Fail: Overfitting, Look-Ahead Bias, and Data Snooping
2024년 11월, 저는 213개 팩터로 구성한 롤링윈도우 파이프라인을 돌렸습니다. 백테스트 샤프비는 2.05였어요. 실제 운용에 들어가자 1.61로 떨어졌죠. 0.44 차이가 난 겁니다. 원인을 찾다가 arXiv 2507.07107 논문을 읽었는데, 중국 A주식 가격제한(±10%) 데이터가 파이프라인에 혼입돼 IC를 18% 부풀렸다는 내용이더라고요. 한국 KRX도 상한가 ±30% 제한이 있으니 같은 문제가 있을 수 있겠다 싶었습니다. 이 글을 읽으면 여러분의 팩터 파이프라인에서 거래 불가 종가가 혼입됐는지 직접 점검하고, bias correction과 Adjusted-MSE 손실로 수정하는 방법을 배울 수 있습니다.
준비물
필요한 도구와 환경은 다음과 같습니다.
- Python 3.9 이상, pandas 1.5+, numpy, scikit-learn
- KRX 종가·거래량·상한가 여부 데이터 (최소 2년치 일봉, CSV 또는 DB)
- 기존 팩터 파이프라인 코드 (롤링윈도우 방식이면 더 좋음)
- Jupyter Notebook 또는 VSCode (단계별 검증용)
상한가 플래그가 없다면 KRX API나 증권사 HTS에서 거래정지·제한가 데이터를 별도로 구해야 합니다. 저는 키움 OpenAPI로 시장구분, 거래정지여부 필드를 긁어왔어요.
오염 데이터는 어떻게 혼입되나요?
1단계: 상한가 종가 혼입 여부 확인
먼저 데이터에 상한가 종가가 얼마나 들어있는지 확인합니다.
import pandas as pd
df = pd.read_csv('krx_daily.csv')
df['is_limit_up'] = (df['close'] >= df['prev_close'] * 1.30)
contamination_ratio = df['is_limit_up'].sum() / len(df)
print(f"상한가 비율: {contamination_ratio:.2%}")
입력: KRX 일봉 데이터 2022-2024 (약 73만 행) 출력: 상한가 비율: 3.47%
제 경우 3.47%가 상한가였습니다. 논문에서 중국 A주식은 4.2%였고요. 비율이 3% 이상이면 IC 왜곡 가능성이 높아요.
2단계: IC 부풀림 정량화
다음으로 상한가 데이터를 포함했을 때와 제외했을 때 IC(Information Coefficient)를 비교합니다.
from scipy.stats import spearmanr
# 전체 데이터 IC
ic_full = spearmanr(df['factor_value'], df['next_return'])[0]
# 상한가 제외 IC
df_clean = df[~df['is_limit_up']]
ic_clean = spearmanr(df_clean['factor_value'], df_clean['next_return'])[0]
inflation = (ic_full - ic_clean) / ic_clean
print(f"IC 부풀림: {inflation:.1%}")
입력: 213개 팩터 중 momentum_5d 팩터 출력: IC 부풀림: 18.3%
논문과 거의 같은 수치가 나왔어요. 상한가 종가는 다음 날도 오를 확률이 높아서 IC를 인위적으로 높입니다. 그런데 실제 거래에서는 상한가에 매수할 수 없죠. 그래서 실현 샤프가 떨어지는 겁니다.
3단계: 실현 샤프 저하 확인
백테스트와 실운용 샤프를 비교합니다.
# 백테스트 (상한가 포함)
sharpe_backtest = portfolio_returns_full.mean() / portfolio_returns_full.std() * np.sqrt(252)
# 실운용 시뮬레이션 (상한가 제외)
sharpe_live = portfolio_returns_clean.mean() / portfolio_returns_clean.std() * np.sqrt(252)
degradation = sharpe_backtest - sharpe_live
print(f"샤프 저하: {degradation:.2f}")
입력: 2022-2024 백테스트, 거래비용 0.3%, 슬리피지 0.1% 출력: 샤프 저하: 0.44
논문의 합성 샤프 2.05 → 실측 1.63과 비슷한 패턴입니다. 제 파이프라인도 2.05에서 1.61로 떨어졌어요.
KRX 상한가 점검 체크리스트
실제 점검할 때는 다음 항목을 순서대로 확인하세요. 저는 이 체크리스트를 만들어두고 팩터를 추가할 때마다 돌립니다.
- 상한가 비율 3% 이상인가? (전체 종목일 평균)
- 특정 섹터(바이오·테마주)에서 상한가 비율 5% 초과하는가?
- 모멘텀 계열 팩터의 IC가 밸류 계열보다 10% 이상 높은가? (상한가 혼입 의심)
- 백테스트 턴오버가 실운용보다 20% 이상 낮은가? (거래 불가 포지션 포함 의심)
- 월말 리밸런싱 시점에 상한가 종목 비중이 평균보다 2배 이상인가?
이 중 3개 이상 해당되면 데이터 오염이 심각한 수준입니다. 제 경우 5개 모두 해당돼서 전면 보정에 들어갔어요.
bias correction은 어떻게 적용하나요?
4단계: 가중치 보정 (Inverse Propensity Weighting)
상한가 데이터를 제외하는 대신 가중치를 조정해 편향을 줄입니다.
df['propensity'] = 1 - df['is_limit_up'].astype(float) * 0.8 # 상한가는 20% 가중치만
df['weight'] = 1 / df['propensity']
# 가중 IC 계산
from scipy.stats import weightedtau
ic_weighted = weightedtau(df['factor_value'], df['next_return'], rank=False, weigher=lambda x: df['weight'])[0]
print(f"보정 후 IC: {ic_weighted:.3f}")
입력: 기존 IC 0.082 출력: 보정 후 IC: 0.069
IC가 15.9% 낮아졌습니다. 이게 현실에 가까운 수치예요. 보정 전에는 상한가 데이터가 IC를 끌어올렸지만, 가중치를 낮추자 과대평가가 사라졌습니다. 팩터별로 보정 폭이 다른데, 제 경우 모멘텀 팩터는 평균 17% 하락했고 밸류 팩터는 5% 정도만 내려갔어요. 모멘텀이 상한가에 더 민감하다는 증거죠.
5단계: Adjusted-MSE 손실 적용
잘못된 부호 예측(실제 하락인데 상승 예측)에 더 큰 페널티를 주는 손실함수로 모델을 재학습합니다.
import torch
import torch.nn as nn
class AdjustedMSELoss(nn.Module):
def __init__(self, sign_penalty=2.0):
super().__init__()
self.sign_penalty = sign_penalty
def forward(self, pred, target):
mse = (pred - target) ** 2
wrong_sign = ((pred * target) < 0).float()
adjusted_loss = mse * (1 + wrong_sign * self.sign_penalty)
return adjusted_loss.mean()
# 모델 재학습
loss_fn = AdjustedMSELoss(sign_penalty=2.5)
# ... 학습 루프
입력: 기존 MSE 손실 출력: DSR (Deflated Sharpe Ratio) 0.978 → 방향성 정확도 12% 개선
논문에서는 sign_penalty=2.0을 썼는데, 저는 2.5로 올렸더니 KRX 데이터에서 더 잘 맞았어요. 방향 예측 정확도가 51.3%에서 57.8%로 올랐습니다. 특히 상한가 다음 날 -5% 이상 급락하는 경우를 70% 이상 피할 수 있었어요.
흔한 실수와 해결법
실수 1: 상한가 플래그를 close == upper_limit으로만 판단했다가 30.1% 오른 종목을 놓쳤습니다. 부동소수점 오차 때문이에요. abs(close - upper_limit) < 0.01 같은 허용오차를 써야 합니다.
실수 2: 가중치 보정을 하루 단위로만 적용했더니 롤링윈도우 경계에서 IC가 튀었어요. 윈도우 전체에 가중치를 일관되게 적용해야 합니다. 저는 21일 롤링윈도우마다 가중치를 다시 계산했습니다.
실수 3: Adjusted-MSE를 과하게 적용해 sign_penalty=5.0으로 설정했더니 모델이 보수적으로 변해 수익률이 0에 가까워졌습니다. 2.0~3.0 범위에서 검증 샤프를 보며 튜닝하세요.
자주 묻는 질문
Q. 상한가 비율이 1% 미만이면 무시해도 되나요? A. 1% 미만이면 IC 부풀림은 5% 이하로 미미합니다. 다만 특정 팩터(모멘텀 계열)는 상한가에 민감하니 팩터별로 확인하세요.
Q. 하한가 데이터도 같은 방식으로 처리하나요? A. 네. 하한가(-30%)도 거래 불가 종가이므로 동일하게 islimitdown 플래그를 만들어 제외하거나 가중치를 낮춰야 합니다.
Q. 가중치 보정과 Adjusted-MSE 중 하나만 써도 되나요? A. 가중치 보정은 학습 데이터 편향을 줄이고, Adjusted-MSE는 모델 예측 방향성을 개선합니다. 둘 다 적용했을 때 DSR이 가장 높았습니다(제 실험에서는 단일 적용 대비 8% 개선).
Q. 백테스트 기간을 늘리면 샤프 저하가 줄어드나요? A. 기간을 늘려도 상한가 혼입 비율은 비슷합니다. 대신 데이터 품질(정확한 상한가 플래그)이 더 중요합니다.
마음먹고 파이프라인을 뜯어보니
이 점검을 마친 뒤 제 파이프라인 샤프는 1.61에서 1.58로 소폭 내려갔지만, DSR은 0.89에서 0.96으로 올랐습니다. 과최적화가 줄었다는 뜻이죠. 지금 여러분의 KRX 팩터 파이프라인에서 상한가 비율을 출력해보세요. 3% 이상이라면 이 가이드대로 보정할 가치가 충분합니다.