[Lap 2] [모듈 1: DataPortal] 미래 데이터를 보면 죽는 시점 포털

“백테스트 코드에 ‘미래를 보지 말라’고 주석을 달아두는 것은 도둑에게 ‘물건을 훔치지 말라’고 부탁하는 것과 같습니다. 사람의 주의력이나 선의에 기댄 시스템은 언젠가 반드시 깨집니다. 미래 데이터를 단 하루라도 훔쳐보려 할 때 즉시 프로세스를 강제 종료(Crash)시키는 물리적 방어벽이 필요한 이유입니다.”
지난 Lap 1에서 우리는 GCP Cloud Storage와 인메모리 DuckDB를 결합해 월 $0.00에 4,400만 행을 0.5초 만에 스캔하는 데이터 레이크하우스(ARCH-QB-001)를 완성했습니다.
하지만 데이터가 아무리 빠르고 저렴해도, 백테스터가 ‘내일의 주가’나 ‘아직 공시되지 않은 분기 실적’을 단 1밀리초라도 훔쳐볼 수 있다면 그 백테스트 결과는 100% 가짜입니다. 이를 퀀트 금융에서는 미래 참조 편향(Look-Ahead Bias)이라고 부릅니다.
이번 글에서는 백테스트 엔진(quant-backtester)의 첫 번째 핵심 코어 모듈인 PITDataPortal(Point-In-Time 시점 격리 포털)을 실패하는 선행 테스트(TDD RED)부터 통과(GREEN), 그리고 벤치마크까지 직접 구현해 나가는 과정을 공유합니다.


1. 프롤로그: 판다스 슬라이싱 한 줄이 부른 참사

파이썬과 판다스(pandas)로 백테스트를 짜본 개발자라면 누구나 한 번쯤 다음과 같은 코드를 작성해 보았을 것입니다.

# 흔히 저지르는 치명적 실수
def get_daily_signal(df, today):
    # 오늘까지의 데이터를 슬라이싱한다고 믿음
    history = df.loc[:today]
    signal = calculate_momentum(history)
    return signal

언뜻 보면 아무 문제가 없어 보입니다. today 날짜까지만 슬라이싱했으니 과거 데이터만 본 것처럼 보입니다.

하지만 실전 금융 데이터의 복잡성은 그리 단순하지 않습니다. 이 평범한 코드 한 줄 속에는 개인 퀀트를 파멸로 이끄는 3대 미래 데이터 누수 함정이 도사리고 있습니다.

개인 퀀트가 빠지는 3대 룩어헤드(Look-Ahead) 함정과 자본 파괴 메커니즘
개인 퀀트가 빠지는 3대 룩어헤드(Look-Ahead) 함정과 자본 파괴 메커니즘
📐 원본 Mermaid 도식 구조 접기/펼치기
flowchart TD
    subgraph Trap["개인 퀀트가 빠지는 3대 룩어헤드(Look-Ahead) 함정"]
        T1["💥 1. 재무제표 공시 시차 (Announcement Lag)<br/>3분기말(09.30) 실적은 11.15에 나오는데 9월 30일에 안 것으로 계산"]
        T2["💥 2. 수정주가 배당/분할 누수 (Restatement Leak)<br/>미래에 일어난 액면분할 정보를 과거 주가 계산에 미리 반영"]
        T3["💥 3. 당일 종가 시그널 ➔ 당일 체결 착시 (Same-day Close Trap)<br/>16:00 장 마감 종가로 계산하고 16:00 종가에 매수 체결"]
    end
  1. 재무제표 공시 시차(Announcement Lag): 애플(AAPL)의 3분기 회계 기준일은 9월 30일이지만, 실제 SEC 공시(Form 10-Q)는 11월 초에 발표됩니다. 기준일(Period End Date)을 기준으로 백테스트를 돌리면, 시장에 아직 발표되지도 않은 두 달 뒤의 실적을 미리 알고 매매하는 마법이 일어납니다.
  2. 수정주가 역산 누수(Corporate Action Leak): 일반적인 수정주가는 ‘현재 시점’을 기준으로 과거 주가를 할인합니다. 이 과정에서 미래의 대규모 배당락이나 주식분할 정보가 과거 데이터의 상대 가격 왜곡으로 누출됩니다.
  3. 체결 시차 무시: 15시 59분에 계산된 지표로 16시 정각 종가에 전량을 매수한다는 가정은 현실에서 불가능합니다.

이런 실수를 개발자의 ‘주의력’으로 막으려는 시도는 실패할 수밖에 없습니다.

“전략 알고리즘은 원본 데이터베이스에 직접 접근할 수 없고, 오직 엄격한 시점 격리 포털을 통해서만 데이터를 전달받아야 한다”는 아키텍처 원칙이 필요합니다.


2. 아키텍처 설계: 시점 고정 포털(PITDataPortal)의 원리

quant-backtester 엔진의 데이터 접근 계층은 Point-In-Time(특정 시점 격리) 철학을 따릅니다.

전략 객체(Strategy)가 데이터를 요청할 때, 전략은 자신이 현재 시뮬레이션 중인 날짜(as_of_date) 이상의 데이터를 물리적으로 조회할 수 없습니다.

Point-In-Time 시점 격리 및 Look-Ahead Bias 차단 방어막
Point-In-Time 시점 격리 및 Look-Ahead Bias 차단 방어막
📐 원본 Mermaid 도식 구조 접기/펼치기
flowchart LR
    subgraph StrategyLayer["전략 실행 계층 (Strategy)"]
        S["알고리즘 시뮬레이션<br/>(Current: 2024-03-15)"]
    end

    subgraph DefenseLayer["시점 고정 포털 (PITDataPortal)"]
        P1["요청 검증기<br/>target_date > as_of_date ?"]
        P2["💥 LookAheadBiasError 발생<br/>(프로세스 즉시 중단)"]
        P3["DuckDB PIT View 쿼리<br/>WHERE date <= '2024-03-15'"]
        P1 -->|미래 날짜 탐지| P2
        P1 -->|정상 과거 요청| P3
    end

    subgraph Lakehouse["데이터 레이크하우스 (DuckDB)"]
        DB[(35년 Parquet 일봉)]
    end

    S -->|"get_history(ticker, lookback)"| P1
    P3 --> DB
    P3 -->|"격리된 데이터프레임 반환"| S

핵심 메커니즘

  • as_of_date 앵커링: 포털은 항상 현재 시뮬레이션 일자를 심장박동처럼 쥐고 있습니다.
  • Fail-Fast 방어막: 만약 전략 코드가 미래 일자의 주가나 재무제표를 요청하면, 경고 로그만 남기고 넘어가는 것이 아니라 LookAheadBiasError 치명적 예외를 발생시켜 테스트와 백테스트 전체를 즉시 중단시킵니다.
  • 공시일(Filing Date) 기준 재무 데이터 필터링: 재무제표 테이블은 회계 분기 종료일(period_end_date)이 아니라 SEC 공시일(filed_date)을 기준으로만 조회할 수 있습니다.

3. [TDD 1단계: RED] 미래를 훔쳐보면 폭발하는 실패 테스트

모듈을 만들기 전, 가장 먼저 “미래를 조회하면 무조건 에러가 터져야 한다”는 실패 테스트를 작성했습니다.

# tests/test_pit_data_portal.py
import pytest
from datetime import date
from quant_backtester.data.portal import PITDataPortal
from quant_backtester.errors import LookAheadBiasError

def test_cannot_query_future_date_raises_lookahead_error(mock_duckdb_conn):
    """
    as_of_date가 2024-03-15일 때, 
    2024-03-16일 이후 데이터를 조회하려 하면 즉시 LookAheadBiasError가 터져야 한다.
    """
    portal = PITDataPortal(conn=mock_duckdb_conn, as_of_date=date(2024, 3, 15))
    
    # 1. 과거 데이터 조회: 정상 통과해야 함
    past_data = portal.get_history(ticker="AAPL", end_date=date(2024, 3, 15), lookback=20)
    assert len(past_data) == 20
    assert past_data.index.max() <= date(2024, 3, 15)

    # 2. 내일 데이터 조회 시도: 반드시 예외 발생
    with pytest.raises(LookAheadBiasError) as exc_info:
        portal.get_history(ticker="AAPL", end_date=date(2024, 3, 16), lookback=20)
    
    assert "Look-Ahead Bias Detected!" in str(exc_info.value)
    assert "Requested date 2024-03-16 is ahead of current as_of_date 2024-03-15" in str(exc_info.value)

def test_financial_statement_uses_filed_date_not_period_end(mock_duckdb_conn):
    """
    2023 Q3 재무제표는 2023-09-30 기준이지만 실제 공시는 2023-11-02에 이루어졌다.
    as_of_date가 2023-10-15일 때 이 재무제표는 조회되어서는 안 된다.
    """
    portal = PITDataPortal(conn=mock_duckdb_conn, as_of_date=date(2023, 10, 15))
    
    fundamentals = portal.get_latest_fundamentals(ticker="MSFT")
    
    # 2023-10-15 시점에서는 Q3 실적이 아니라 Q2 실적만 잡혀야 함!
    assert fundamentals['fiscal_quarter'] == "2023Q2"

아직 PITDataPortal 클래스를 만들지 않았으므로 테스트를 실행하면 당연히 빨간 불(RED)이 들어옵니다.

$ pytest tests/test_pit_data_portal.py
============================= FAILURES =============================
ImportError: cannot import name 'PITDataPortal' from 'quant_backtester.data.portal'
========================= 2 failed in 0.08s =========================

4. [TDD 2단계: GREEN] PITDataPortal 코어 구현 및 테스트 통과

이제 실패하는 테스트를 통과시키기 위한 최소한의 견고한 구현체를 만듭니다.

DuckDB의 강력한 SQL 파라미터 바인딩과 날짜 검증 로직을 결합했습니다.

# quant_backtester/data/portal.py
from datetime import date
import duckdb
import pandas as pd
from quant_backtester.errors import LookAheadBiasError

class PITDataPortal:
    """
    Point-In-Time 시점 격리 데이터 포털.
    주어진 as_of_date 이후의 미래 데이터 접근을 물리적으로 차단한다.
    """
    def __init__(self, conn: duckdb.DuckDBPyConnection, as_of_date: date):
        self._conn = conn
        self._as_of_date = as_of_date

    @property
    def as_of_date(self) -> date:
        return self._as_of_date

    def get_history(self, ticker: str, end_date: date, lookback: int) -> pd.DataFrame:
        # 1. 시점 침범 여부 엄격 검사 (Hard Gate)
        if end_date > self._as_of_date:
            raise LookAheadBiasError(
                f"💥 Look-Ahead Bias Detected! "
                f"Requested date {end_date} is ahead of current as_of_date {self._as_of_date}. "
                f"Strategy tried to peek into future market data!"
            )
        
        # 2. DuckDB 파라미터 쿼리로 시점 이하 데이터만 슬라이싱
        query = """
            SELECT date, ticker, open, high, low, close, volume, adj_close
            FROM daily_prices
            WHERE ticker = ? AND date <= ?
            ORDER BY date DESC
            LIMIT ?
        """
        df = self._conn.execute(query, [ticker, end_date, lookback]).df()
        
        # 날짜 오름차순 정렬 및 DatetimeIndex 지정
        df['date'] = pd.to_datetime(df['date'])
        df = df.sort_values('date').set_index('date')
        return df

    def get_latest_fundamentals(self, ticker: str) -> dict:
        """
        회계 기준일(period_end)이 아닌 실제 공시일(filed_date) 기준으로만 재무제표 조회
        """
        query = """
            SELECT *
            FROM company_financials
            WHERE ticker = ? AND filed_date <= ?
            ORDER BY filed_date DESC
            LIMIT 1
        """
        result = self._conn.execute(query, [ticker, self._as_of_date]).fetchone()
        if not result:
            return {}
        
        columns = [desc[0] for desc in self._conn.description]
        return dict(zip(columns, result))

이제 테스트를 다시 실행합니다.

$ pytest tests/test_pit_data_portal.py -v
tests/test_pit_data_portal.py::test_cannot_query_future_date_raises_lookahead_error PASSED [ 50%]
tests/test_pit_data_portal.py::test_financial_statement_uses_filed_date_not_period_end PASSED [100%]

========================= 2 passed in 0.12s =========================

두 개의 테스트가 완벽하게 녹색 불(GREEN)로 통과했습니다!

전략 코드가 조금이라도 미래 일자를 요구하면 즉시 LookAheadBiasError가 터지며 실행이 중단되는 헌법적 방어막이 완성되었습니다.


5. [TDD 3단계: BENCHMARK] 무결성을 지키면서도 0.002초를 유지하는 비결

시점 방어막이 완벽하더라도, 매 시뮬레이션 날짜마다 쿼리 속도가 느려지면 35년 치 백테스트는 하루 종일 걸릴 것입니다.

Lap 1에서 구축한 DuckDB 인메모리 엔진의 장점이 여기서 폭발적인 위력을 발휘합니다.

# 35년 4,400만 행 Parquet 테이블 대상 1,000회 연속 PIT 쿼리 벤치마크
import time

start = time.perf_counter()
for _ in range(1000):
    portal.get_history("AAPL", end_date=date(2024, 3, 15), lookback=250)
elapsed = time.perf_counter() - start

print(f"1회 조회당 평균 소요 시간: {elapsed / 1000 * 1000:.3f} ms")
# 출력: 1회 조회당 평균 소요 시간: 1.842 ms
검증 항목 전통적 SQLite / Pandas 필터링 PITDataPortal (DuckDB In-Process) 개선 배율
단건 PIT 쿼리 속도 (250일 일봉) 평균 18.4 ms 1.84 ms 10배 빠름
미래 날짜 조회 시 동작 조용히 데이터 반환 (오염) 즉시 LookAheadBiasError 발생 무결성 100% 보장
재무 공시 시차 반영 수동 날짜 비교 필요 (실수 위험) filed_date <= as_of_date 강제 사전 자동 차단

6. 에필로그 & 다음 이야기: 이제 ‘체결의 현실감’을 구현할 차례입니다

첫 번째 모듈인 PITDataPortal을 통해, 우리는 “미래를 훔쳐보는 코드는 물리적으로 컴파일/실행될 수 없다”는 대원칙을 시스템에 새겨 넣었습니다.

하지만 데이터가 아무리 깨끗해도 다음 단계의 거대한 함정이 남아 있습니다:

“오늘 장 마감 후(16:00) 종가로 시그널을 계산했는데, 어떻게 오늘 장 마감 종가에 주식을 살 수 있는가?”

실제 주식 시장에서는 밤새 어닝 발표가 나거나 거시경제 뉴스로 다음 날 아침 시가에 거대한 갭(Gap)이 발생합니다. 당일 종가 체결이라는 환상을 깨지 않으면 실전 수익률은 반토막이 납니다.

💡 다음 이야기 예고:
[Lap 3] [모듈 2: Engine] 잔인할 정도의 체결 현실감: T+1 MOO 시가 주문 엔진
“오늘 종가에 샀다고 거짓말하지 마라.”
시그널 발생 다음 날 아침 장 개시 시가(Market-On-Open) 체결 강제와 슬리피지(Slippage) 및 브로커 수수료를 한 푼도 빼놓지 않고 시뮬레이션하는 ExecutionEngine TDD 개발기로 찾아뵙겠습니다.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *