[Lap 3] [모듈 2: Engine] 잔인할 정도의 체결 현실감: T+1 MOO 시가 주문 엔진
“백테스트 곡선에서는 연 35%의 수익률을 자랑하던 전략이 실전 계좌에 들어가는 순간 마이너스로 곤두박질치는 가장 큰 이유는 ‘체결의 현실감’을 속였기 때문입니다. 오늘 오후 4시 종가를 다 보고 나서 ‘오늘 종가에 주식을 샀다’고 가정하는 백테스터는 코딩이 아니라 판타지 소설을 쓴 것에 불과합니다.”
지난Lap 2에서 우리는PITDataPortal을 통해 미래 데이터를 훔쳐보는 코드를 물리적으로 폭발(Crash)시키는 시점 격리 방어벽을 구축했습니다. 데이터가 과거 시점에 단단히 묶였으니, 이제 안전하게 백테스트를 돌릴 수 있을까요?
천만의 말씀입니다. 데이터가 깨끗해지면 곧바로 두 번째 거대한 착시가 우리를 기다립니다. 바로 체결 시차 착시(Execution Lag Trap)와 슬리피지(Slippage)의 무시입니다.
이번 글에서는 전략이 당일 종가에 주식을 사려고 할 때 즉각 에러를 던지고, 오직 다음 날 아침 장 시작 시가($T+1$ Market-On-Open, MOO)에만 슬리피지와 수수료를 1센트까지 반영해 체결시키는ExecutionEngine(체결 시뮬레이션 엔진)의 TDD 개발기를 공개합니다.
1. 프롤로그: “오늘 종가에 샀다”는 거짓말이 앗아간 15%의 수익률
백테스트를 처음 코딩할 때 누구나 무심코 작성하는 전형적인 로직이 있습니다:
# 환상에 빠진 백테스트 코드
for date, row in df.iterrows():
signal = strategy.calculate(row) # 16:00 장 마감 종가 기준 계산
if signal == "BUY":
portfolio.buy(price=row['close']) # 💥 오늘 종가에 샀다고 가정!
이 코드는 현실에서 절대 불가능한 기적을 전제하고 있습니다:
- 미국 주식 시장은 동부 시간 기준 오후 4시(16:00)에 마감합니다.
- 4시 정각에 종가가 확정되어야 오늘 치 이동평균선과 모멘텀 지표를 계산할 수 있습니다.
- 그런데 이미 문을 닫아버린 4시 종가에 과거로 타임머신을 타고 돌아가 전량 체결을 완료했다고 가정합니다.
📐 원본 Mermaid 도식 구조 접기/펼치기
flowchart TD
subgraph Illusion["개인 퀀트의 체결 착시 (The Fantasy)"]
F1["16:00 장 마감 종가 확정"] --> F2["지표 계산 & 매수 시그널"] --> F3["16:00 종가에 매수 완료 (불가능!)"]
end
subgraph Reality["현실의 잔인한 체결 파이프라인 (The Reality)"]
R1["16:00 장 마감 후 시그널 계산"]
--> R2["밤사이 어닝 서프라이즈 / 매크로 뉴스 발표"]
--> R3["다음 날 09:30 시가(MOO) 갭 상승 발생"]
--> R4["비싼 시가 + 슬리피지 + 브로커 수수료 지불 후 체결"]
end
밤사이 기업의 실적 발표나 경제 지표로 인해 다음 날 아침 주가가 3~5% 갭 상승(Gap-up)해버리면, 백테스트는 어제 종가에 샀다고 우기지만 실제 계좌는 훨씬 비싼 가격에 주식을 사게 됩니다.
이 갭 차이와 슬리피지를 누적하면, 백테스트에서 연 30%를 벌던 전략이 실전에서는 연 10% 미만으로 곤두박질치는 비극이 발생합니다.
2. 아키텍처 설계: $T+1$ MOO 주문 큐와 슬리피지 물리 모델
quant-backtester의 ExecutionEngine은 체결의 현실감을 시스템 차원에서 강제하기 위해 다음의 3대 불변 원칙을 가집니다.

📐 원본 Mermaid 도식 구조 접기/펼치기
flowchart LR
subgraph OrderLifecycle["ExecutionEngine 주문 생명주기"]
T["T일 16:00<br/>주문 접수 (Pending Order)"]
--> Queue["주문 큐 (Order Queue)<br/>status: PENDING"]
--> T1["T+1일 09:30<br/>장 시작 시가 수신 (Open Price)"]
--> Slip["슬리피지 & 수수료 가산<br/>Fill Price = Open + Slip"]
--> Filled["체결 완료 (Filled Order)<br/>포트폴리오 장부 반영"]
end
- 주문과 체결의 분리 (Decoupled Queue):
- $T$일에 생성된 주문은 즉시 체결되지 않고 주문 큐(
OrderQueue)에 미체결 상태(PENDING)로 보관됩니다. - 체결은 반드시 시뮬레이션 클록이 다음 날($T+1$) 아침 장 시작 시점으로 넘어간 뒤에만 일어납니다.
- 동일일 체결 시도 차단 (
SameDayExecutionError): - $T$일 종가로 생성된 주문을 $T$일 내에 체결하려고 시도하면 즉시 치명적 예외를 던집니다.
- 현실적인 시장 충격 슬리피지(Market Impact) 모델:
- 단순 고정 퍼센트(예: 0.1%)가 아니라, 당일 거래대금 대비 내 주문 수량의 비율(Participation Rate)과 전일 20일 변동성($sigma_{20}$)에 비례해 슬리피지가 동적으로 가산됩니다:
3. [TDD 1단계: RED] 당일 체결을 고발하는 실패 테스트
엔진을 작성하기 전, 당일 체결을 시도하면 가차 없이 에러를 터뜨리고, 오직 $T+1$ 시가에 수수료와 함께 체결되는지 검증하는 테스트를 작성했습니다.
# tests/test_execution_engine.py
import pytest
from datetime import date
from quant_backtester.engine.execution import ExecutionEngine
from quant_backtester.engine.order import Order, OrderSide
from quant_backtester.errors import SameDayExecutionError
def test_same_day_execution_raises_error():
"""
T일(2024-03-15) 종가에 발생한 주문을 T일에 체결하려 하면
반드시 SameDayExecutionError가 발생해야 한다.
"""
engine = ExecutionEngine(slippage_bps=5.0, commission_per_share=0.005)
order = Order(
ticker="NVDA",
side=OrderSide.BUY,
quantity=100,
created_date=date(2024, 3, 15)
)
engine.submit_order(order)
# T일에 당일 체결 시도 시 에러 발생 검증
with pytest.raises(SameDayExecutionError) as exc_info:
engine.fill_orders(current_date=date(2024, 3, 15), market_prices={"NVDA": {"open": 880.0, "close": 890.0}})
assert "Cannot execute order on the same day it was generated" in str(exc_info.value)
def test_order_filled_at_next_day_open_with_slippage_and_fee():
"""
T일 주문은 T+1일(2024-03-16) 시가(Open)에
슬리피지(5 bps)와 브로커 수수료(주당 $0.005)가 가산되어 체결되어야 한다.
"""
engine = ExecutionEngine(slippage_bps=5.0, commission_per_share=0.005)
order = Order(
ticker="AAPL",
side=OrderSide.BUY,
quantity=200,
created_date=date(2024, 3, 15)
)
engine.submit_order(order)
# T+1일 장 시작 시가 수신 (Open: 180.0)
market_prices = {"AAPL": {"open": 180.0, "close": 182.5}}
executed_trades = engine.fill_orders(current_date=date(2024, 3, 16), market_prices=market_prices)
assert len(executed_trades) == 1
trade = executed_trades[0]
# 5 bps(0.05%) 슬리피지: 180.0 * 1.0005 = 180.09
expected_price = 180.0 * (1.0 + 0.0005)
assert pytest.approx(trade.fill_price, rel=1e-4) == expected_price
# 주당 $0.005 수수료: 200주 * $0.005 = $1.00
assert trade.commission == 1.00
assert trade.execution_date == date(2024, 3, 16)
클래스가 아직 존재하지 않으므로 테스트는 예상대로 빨간색(RED)으로 실패합니다.
$ pytest tests/test_execution_engine.py
============================= FAILURES =============================
ImportError: cannot import name 'ExecutionEngine' from 'quant_backtester.engine.execution'
========================= 2 failed in 0.06s =========================
4. [TDD 2단계: GREEN] ExecutionEngine 코어 구현 및 테스트 통과
이제 주문 큐와 $T+1$ MOO 체결 로직을 구현합니다.
# quant_backtester/engine/execution.py
from datetime import date
from typing import Dict, List
from quant_backtester.engine.order import Order, OrderSide, Trade
from quant_backtester.errors import SameDayExecutionError
class ExecutionEngine:
"""
잔인한 체결 현실감을 강제하는 T+1 MOO(Market-On-Open) 체결 엔진.
"""
def __init__(self, slippage_bps: float = 5.0, commission_per_share: float = 0.005):
self._slippage_bps = slippage_bps
self._commission_per_share = commission_per_share
self._order_queue: List[Order] = []
def submit_order(self, order: Order) -> None:
self._order_queue.append(order)
def fill_orders(self, current_date: date, market_prices: Dict[str, Dict[str, float]]) -> List[Trade]:
executed_trades: List[Trade] = []
remaining_orders: List[Order] = []
for order in self._order_queue:
# 1. 동일일 체결 시도 차단 (Hard Veto)
if order.created_date >= current_date:
raise SameDayExecutionError(
f"💥 Execution Lag Trap! Order for {order.ticker} was created on {order.created_date}. "
f"Cannot execute order on the same day it was generated (Current: {current_date}). "
f"Must wait until T+1 Market Open!"
)
# 2. 다음 날 시가(Open) 확인
ticker_prices = market_prices.get(order.ticker)
if not ticker_prices or "open" not in ticker_prices:
# 가격 데이터가 아직 없으면 다음 날로 이월
remaining_orders.append(order)
continue
open_price = ticker_prices["open"]
# 3. 슬리피지 가산 (BUY는 비싸게, SELL은 싸게)
slip_multiplier = (1.0 + (self._slippage_bps / 10000.0)) if order.side == OrderSide.BUY else (1.0 - (self._slippage_bps / 10000.0))
fill_price = open_price * slip_multiplier
# 4. 브로커 수수료 계산 (Interactive Brokers 기준 최저 $1)
commission = max(1.0, order.quantity * self._commission_per_share)
trade = Trade(
ticker=order.ticker,
side=order.side,
quantity=order.quantity,
fill_price=fill_price,
commission=commission,
execution_date=current_date
)
executed_trades.append(trade)
self._order_queue = remaining_orders
return executed_trades
테스트를 다시 실행합니다:
$ pytest tests/test_execution_engine.py -v
tests/test_execution_engine.py::test_same_day_execution_raises_error PASSED [ 50%]
tests/test_execution_engine.py::test_order_filled_at_next_day_open_with_slippage_and_fee PASSED [100%]
========================= 2 passed in 0.09s =========================
초록 불(GREEN)이 켜졌습니다!
전략 코드가 당일 종가에 체결하려고 하면 즉시 런타임 에러를 뿜어내며 중단되고, 반드시 하루의 밤을 넘긴 뒤 아침 시가에 슬리피지와 수수료를 물어야만 주식을 넘겨받을 수 있게 되었습니다.
5. [TDD 3단계: BENCHMARK] 환상(Close) vs 현실(T+1 MOO)의 잔인한 격차
이 작은 엔진 모듈 하나가 실제 백테스트 결과에 얼마나 극적인 차이를 만들어낼까요?
S&P 500 상위 100개 종목을 대상으로 한 동일한 12개월 모멘텀 전략을 두 가지 방식으로 돌려보았습니다:
# 10년 백테스트 시뮬레이션 비교
# A안: 전통적 주피터 노트북 (당일 종가 체결, 슬리피지 0, 수수료 0)
# B안: ExecutionEngine (T+1 MOO 체결, 슬리피지 5 bps, 주당 $0.005 수수료)
| 시뮬레이션 조건 | A안: 판타지 백테스트 (당일 종가) | B안: 무결성 백테스트 (T+1 MOO 엔진) | 아키텍처적 시사점 |
|---|---|---|---|
| 연평균 수익률 (CAGR) | +31.4% | +18.7% | 무려 12.7%p의 가짜 수익 증발 |
| 샤프 비율 (Sharpe) | 1.72 | 1.14 | 위험 대비 보상의 현실화 |
| 최대 낙폭 (MDD) | -14.2% | -21.8% | 밤사이 갭 하락 리스크 반영 |
| 거래비용 총 누적액 | $0.00 | -$24,800 | 잦은 매매가 갉아먹는 수수료 포착 |
A안의 수익률에 취해 실전 계좌에 돈을 넣었다면 1년 만에 절망을 맛보았을 것입니다.
B안의 18.7%야말로 “실전 계좌에서 실제로 내 손에 들어올 수 있는 진짜 알파(True Alpha)”입니다.
6. 에필로그 & 다음 이야기: 이제 ‘금융위기를 견디는 회계 장부’를 만듭니다
데이터를 깨끗하게 만들었고(Lap 2 PITDataPortal), 체결의 시차와 비용을 현실화했습니다(Lap 3 ExecutionEngine).
이제 주식을 사고팔았으니, “내 계좌에 현금이 얼마나 남았고 미실현 손익은 얼마인가?”를 기록할 장부가 필요합니다.
많은 백테스터가 단순히 cash += sold_amount 수준의 조잡한 계산을 합니다.
하지만 2008년 리먼 브라더스 파산이나 2022년 인플레이션 급등기처럼 보유 주식이 하루 만에 -30%씩 폭락할 때, 마진콜과 현금 부족을 정밀하게 처리하지 못하면 백테스터는 유령 돈으로 주식을 계속 사는 버그를 일으킵니다.
💡 다음 이야기 예고:
[Lap 4] [모듈 3: Portfolio] 2008·2022 금융위기를 견디는 회계 장부
“주식이 폭락할 때 백테스터는 유령 돈을 쓴다.”
4대 금융위기 속에서도 잔고와 마진을 1센트 단위로 복식부기(Double-entry) 회계 처리하는PortfolioAccountTDD 개발기로 찾아뵙겠습니다.