룩어헤드 바이어스 방지법

룩어헤드 바이어스란?

룩어헤드 바이어스(Look-Ahead Bias)는 백테스트 시점에서 아직 알 수 없는 미래 데이터를 전략 로직에 사용하는 오류입니다. 이 편향이 포함된 백테스트는 실제보다 훨씬 좋은 성과를 보여주며, 실전 매매에서 처참한 결과로 이어집니다.

퀀트 트레이딩에서 가장 치명적이면서도 발견하기 어려운 실수 중 하나이며, 초보자부터 경험자까지 누구나 빠질 수 있는 함정입니다. 이 글에서는 룩어헤드 바이어스의 유형을 분류하고, 파이썬 코드로 탐지·방지하는 실전 기법을 다룹니다.

룩어헤드 바이어스의 주요 유형

유형 설명 실수 예시
데이터 스누핑 미래 가격으로 현재 신호 생성 당일 종가로 당일 매수 결정
지표 선행 참조 미래 값이 포함된 지표 사용 전체 기간 평균으로 정규화
이벤트 타이밍 발표 전 정보를 사용 실적 발표 전에 실적 데이터 참조
유니버스 선택 미래 기준으로 종목 선정 현재 시총 상위로 과거 백테스트
파라미터 최적화 전체 데이터로 파라미터 튜닝 인-샘플/아웃-샘플 미분리

흔한 룩어헤드 코드 실수

아래는 실제로 자주 발생하는 룩어헤드 바이어스 코드 패턴입니다.

import pandas as pd
import numpy as np

# ❌ 잘못된 예: 전체 데이터로 정규화 (미래 정보 포함)
def bad_normalize(df):
    """미래 데이터를 사용한 정규화 — 룩어헤드 바이어스!"""
    df['close_norm'] = (df['close'] - df['close'].mean()) / df['close'].std()
    return df

# ✅ 올바른 예: 롤링 윈도우로 과거 데이터만 사용
def good_normalize(df, window=100):
    """과거 데이터만 사용한 정규화"""
    rolling_mean = df['close'].rolling(window, min_periods=20).mean()
    rolling_std = df['close'].rolling(window, min_periods=20).std()
    df['close_norm'] = (df['close'] - rolling_mean) / (rolling_std + 1e-10)
    return df


# ❌ 잘못된 예: 당일 종가로 당일 매매 결정
def bad_signal(df):
    """당일 종가를 알고 매매 — 룩어헤드 바이어스!"""
    df['signal'] = np.where(df['close'] > df['close'].rolling(20).mean(), 1, 0)
    # 이 신호로 당일 시가에 매매? → 종가를 미리 알 수 없음!
    return df

# ✅ 올바른 예: 전일 종가 기준으로 신호 생성
def good_signal(df):
    """전일 데이터만 사용한 시그널"""
    df['signal'] = np.where(
        df['close'].shift(1) > df['close'].shift(1).rolling(20).mean(),
        1, 0
    )
    # shift(1): 전일 데이터 → 오늘 시가에 매매 가능
    return df

룩어헤드 바이어스 자동 탐지

수동 코드 리뷰만으로는 복잡한 전략에서 룩어헤드를 놓치기 쉽습니다. 자동화된 탐지 방법을 활용하세요.

방법 1: 데이터 셔플 테스트

미래 데이터에 의존하는 전략은 시간 순서를 섞어도 비슷한 성과를 보입니다. 반대로 올바른 전략은 시간 순서가 깨지면 성과가 무너집니다.

def shuffle_test(df: pd.DataFrame, strategy_fn, n_trials: int = 100) -> dict:
    """시간 순서 셔플 테스트로 룩어헤드 탐지
    
    Args:
        df: OHLCV DataFrame
        strategy_fn: 전략 함수 (df → returns Series)
        n_trials: 셔플 반복 횟수
    
    Returns:
        원본 vs 셔플 성과 비교
    """
    # 원본 성과
    original_returns = strategy_fn(df.copy())
    original_sharpe = original_returns.mean() / (original_returns.std() + 1e-10) * np.sqrt(252)
    
    # 셔플 성과
    shuffle_sharpes = []
    for _ in range(n_trials):
        shuffled = df.copy()
        shuffled['close'] = np.random.permutation(shuffled['close'].values)
        # High/Low/Open도 같이 섞어야 일관성 유지
        idx = np.random.permutation(len(shuffled))
        for col in ['open', 'high', 'low', 'close', 'volume']:
            shuffled[col] = shuffled[col].values[idx]
        
        try:
            shuf_returns = strategy_fn(shuffled)
            shuf_sharpe = shuf_returns.mean() / (shuf_returns.std() + 1e-10) * np.sqrt(252)
            shuffle_sharpes.append(shuf_sharpe)
        except:
            continue
    
    # 비교
    avg_shuffle = np.mean(shuffle_sharpes)
    p_value = np.mean([s >= original_sharpe for s in shuffle_sharpes])
    
    result = {
        'original_sharpe': round(original_sharpe, 3),
        'shuffle_avg_sharpe': round(avg_shuffle, 3),
        'p_value': round(p_value, 4),
        'suspected_lookahead': p_value > 0.05,
    }
    
    if result['suspected_lookahead']:
        print(f"⚠️ 룩어헤드 의심! 셔플 성과와 차이 없음 (p={p_value:.3f})")
    else:
        print(f"✅ 시간 의존성 확인 (p={p_value:.3f})")
    
    return result

방법 2: 지연 주입 테스트

데이터에 인위적 지연을 추가했을 때 성과가 크게 변하면 룩어헤드가 있을 가능성이 높습니다.

def delay_injection_test(df: pd.DataFrame, strategy_fn,
                          delays: list = [0, 1, 2, 3, 5]) -> pd.DataFrame:
    """지연 주입으로 룩어헤드 탐지
    
    정상 전략: delay 0 → 1 사이에 소폭 하락
    룩어헤드 전략: delay 증가에 따라 성과 급락
    """
    results = []
    
    for delay in delays:
        df_delayed = df.copy()
        
        if delay > 0:
            # 모든 가격 데이터를 delay만큼 지연
            for col in ['open', 'high', 'low', 'close', 'volume']:
                df_delayed[col] = df_delayed[col].shift(delay)
            df_delayed = df_delayed.dropna()
        
        returns = strategy_fn(df_delayed)
        sharpe = returns.mean() / (returns.std() + 1e-10) * np.sqrt(252)
        total_return = (1 + returns).prod() - 1
        
        results.append({
            'delay': delay,
            'sharpe': round(sharpe, 3),
            'total_return': round(total_return * 100, 2),
        })
    
    result_df = pd.DataFrame(results)
    
    # 급락 패턴 감지
    if len(results) >= 2:
        drop = results[0]['sharpe'] - results[1]['sharpe']
        if drop > results[0]['sharpe'] * 0.5:
            print(f"⚠️ delay=1에서 샤프 {drop:.2f} 급락 → 룩어헤드 가능성 높음")
        else:
            print(f"✅ 지연 주입 테스트 통과 (성과 변화: {drop:.2f})")
    
    return result_df

Point-in-Time 데이터 관리

근본적인 해결책은 Point-in-Time(PIT) 데이터 관리입니다. 각 시점에서 실제로 알 수 있었던 정보만 사용하도록 데이터를 구성합니다.

class PointInTimeData:
    """시점 기준 데이터 관리자 — 룩어헤드 원천 차단"""
    
    def __init__(self, df: pd.DataFrame):
        self.full_data = df.copy()
        self.current_idx = 0
    
    def reset(self):
        self.current_idx = 0
    
    def advance(self):
        """한 스텝 진행"""
        self.current_idx += 1
    
    @property
    def available_data(self) -> pd.DataFrame:
        """현재 시점까지만 반환 — 미래 데이터 접근 불가"""
        return self.full_data.iloc[:self.current_idx + 1].copy()
    
    @property
    def current_bar(self) -> pd.Series:
        """현재 봉 데이터"""
        return self.full_data.iloc[self.current_idx]
    
    def get_indicator(self, column: str, window: int) -> float:
        """과거 데이터만으로 지표 계산"""
        data = self.available_data[column]
        if len(data) < window:
            return np.nan
        return data.rolling(window).mean().iloc[-1]


def safe_backtest(df: pd.DataFrame, strategy_fn) -> list:
    """룩어헤드 방지 백테스트 프레임워크"""
    pit = PointInTimeData(df)
    trades = []
    position = 0
    
    # 워밍업 기간 (지표 계산용)
    warmup = 50
    
    for i in range(warmup, len(df)):
        pit.current_idx = i
        
        # 전략은 과거 데이터만 접근 가능
        available = pit.available_data
        signal = strategy_fn(available)
        
        current_price = pit.current_bar['close']
        
        if signal == 1 and position == 0:
            position = 1
            entry_price = current_price
            entry_time = pit.current_bar.name
            
        elif signal == -1 and position == 1:
            position = 0
            pnl = (current_price - entry_price) / entry_price
            trades.append({
                'entry_time': entry_time,
                'exit_time': pit.current_bar.name,
                'entry_price': entry_price,
                'exit_price': current_price,
                'pnl_pct': round(pnl * 100, 4),
            })
    
    return trades

방지 체크리스트

코드 리뷰 시 아래 항목을 체크하면 대부분의 룩어헤드를 사전에 차단할 수 있습니다:

  • shift(1) 확인: 시그널 생성에 사용된 모든 데이터가 최소 1봉 이전인지 확인
  • 롤링 연산만 사용: 전체 평균/표준편차 대신 rolling() 사용
  • 인-샘플/아웃-샘플 분리: 파라미터 튜닝 데이터와 검증 데이터를 반드시 분리
  • 이벤트 발표 시간 확인: 재무 데이터가 실제 발표된 시점 이후에만 사용
  • 유니버스 재구성: 백테스트 각 시점에서 당시 실제 유니버스를 사용
  • 셔플/지연 테스트 실행: 자동 탐지 도구로 최종 검증

마무리

룩어헤드 바이어스는 자동매매 백테스트에서 가장 위험하면서도 교묘한 함정입니다. Point-in-Time 데이터 관리, shift 기반 시그널 생성, 자동 탐지 테스트를 습관화하면 이 편향을 효과적으로 차단할 수 있습니다.

룩어헤드 방지와 함께 백테스트 데이터 전처리를 체계적으로 수행하면 신뢰할 수 있는 전략 검증이 가능합니다. 또한 백테스트 과적합 방지법도 함께 적용하여 실전과 백테스트의 괴리를 최소화하세요.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux