[Lap 4] [모듈 3: Portfolio] 2008·2022 금융위기를 견디는 회계 장부
“금융위기가 닥쳐 보유 종목이 하루 만에 -30% 폭락할 때, 대부분의 주피터 노트북 백테스터는 ‘유령 돈(Phantom Cash)’을 찍어내어 신나게 저가 매수를 감행합니다. 현금이 마이너스로 떨어졌는데도 주문이 체결되고, 마진콜(Margin Call)로 계좌가 강제 청산당할 위기인데도 레버리지가 무한대로 늘어납니다. 회계가 붕괴된 백테스터는 사기 엔진일 뿐입니다.”
지난Lap 3에서 우리는 다음 날 아침 장 개시 시가($T+1$ MOO) 체결을 강제하고 슬리피지와 수수료를 반영하는ExecutionEngine을 완성했습니다.
하지만 주문이 체결되었다고 해서 끝이 아닙니다. 체결된 주식을 넘겨받고 현금을 지불했을 때, “내 계좌의 총 순자산(NAV)은 얼마이고, 가용 현금은 얼마가 남았으며, 시장이 폭락할 때 파산하지 않고 버틸 수 있는가?”를 매일 밤 1센트의 오차도 없이 기록하는 엄격한 회계 장부가 필요합니다.
이번 글에서는 2008년 리먼 브라더스 파산과 2022년 금리 인상 충격 같은 극단적 스트레스 상황에서도 단 1달러의 유령 돈도 허용하지 않는 복식부기(Double-Entry) 기반 포트폴리오 회계 모듈인PortfolioAccount의 TDD 개발기를 공개합니다.
1. 프롤로그: 백테스터가 -50% 폭락장에서 ‘유령 돈’을 쓰는 법
판다스로 백테스트를 짤 때 계좌 잔고를 관리하는 코드는 대개 이런 형태를 띱니다:
# 순진한 백테스트의 위험한 잔고 계산
cash = 100_000
for date, trade in trades.items():
if trade.side == "BUY":
cash -= trade.amount # 💥 현금이 0원 아래로 내려가는지 검사하지 않음!
positions[trade.ticker] += trade.qty
이 단순한 코드의 치명적인 버그는 2008년 10월 금융위기나 2020년 3월 팬데믹 서킷브레이커 같은 극단적 하락장에서 적나라하게 드러납니다:
📐 원본 Mermaid 도식 구조 접기/펼치기
flowchart TD
subgraph BugScenario["금융위기 시 발생하는 백테스터의 3대 회계 버그"]
B1["💥 1. 유령 현금 매수 (Phantom Cash Trap)<br/>계좌에 남은 현금이 없는데도 마이너스 현금으로 물타기 매수 지속"]
B2["💥 2. 미실현 손실 은폐 (Unrealized Loss Blindness)<br/>주가가 -50% 반토막 났는데 팔기 전까지는 원금 그대로라고 착각"]
B3["💥 3. 마진콜 강제청산 무시 (Margin Call Ignorance)<br/>담보가치 미달로 증권사에서 계좌를 털어버리는데도 버텼다고 가정"]
end
- 마이너스 현금 허용 (Phantom Cash): 1억 원으로 시작했는데 주가가 떨어지자 계속 매수 신호가 떠서 현금이 -4,000만 원이 됩니다. 현실에서는 브로커가 주문을 거절하지만, 백테스트는 주문을 다 체결시키고 나중에 반등할 때 어마어마한 수익률을 기록합니다.
- 평가손익(MTM) 계산 부재: 보유 주식의 현재 가치를 매일 종가로 재평가(Mark-to-Market)하지 않아, 계좌의 최대 낙폭(MDD)이 실제보다 훨씬 작게 왜곡됩니다.
- 무차입(No-Leverage) 원칙의 붕괴: 직장인 퀀트는 원금을 지키는 것이 제1원칙인데, 의도치 않은 암묵적 레버리지가 발생합니다.
2. 아키텍처 설계: 복식부기(Double-Entry) 포트폴리오 장부
quant-backtester의 PortfolioAccount는 금융 기관 수준의 복식부기 회계 원칙을 엄격하게 적용합니다.
모든 자산의 변동은 대변과 차변이 일치해야 하며, 매일 장 마감 시 다음의 회계 항등식(Accounting Identity)이 단 1센트의 오차도 없이 성립해야 합니다:

📐 원본 Mermaid 도식 구조 접기/펼치기
flowchart LR
subgraph Accounting["PortfolioAccount 복식부기 장부"]
Ledger["트랜잭션 원장 (Transaction Ledger)"]
Cash["현금 계정 (Cash Account)<br/>• Available Cash<br/>• Settled Cash"]
Positions["보유 포지션 (Positions)<br/>• Qty, AvgCost<br/>• Mark-To-Market"]
NAV["순자산 평가 (NAV)<br/>• Realized PnL<br/>• Unrealized PnL"]
Ledger --> Cash
Ledger --> Positions
Cash & Positions --> NAV
end
핵심 회계 불변식(Invariants)
InsufficientCashError방어막: 주문 체결 금액 + 수수료가 계좌의available_cash를 1달러라도 초과하면, 백테스터는 거래를 거부하고 예외를 던집니다.- 일일 시가평가(Mark-to-Market, MTM): 매일 장 마감 시 포털의 당일 종가를 바탕으로 모든 포지션의 미실현 손익을 재계산하여 NAV 곡선을 그립니다.
- 무차입 레버리지 상한 통제: 총 포지션 가치가 순자산(NAV)을 초과할 수 없도록 레버리지 배수를 $1.0times$ 이하로 강제합니다.
3. [TDD 1단계: RED] 유령 돈을 거절하는 실패 테스트
포트폴리오 회계 모듈을 작성하기 전, 가용 현금을 초과하는 매수 주문이 들어왔을 때 즉시 거래를 거절하고, 주가 폭락 시 NAV가 정확하게 하락하는지 검증하는 테스트를 작성했습니다.
# tests/test_portfolio_account.py
import pytest
from datetime import date
from quant_backtester.engine.portfolio import PortfolioAccount
from quant_backtester.engine.order import Trade, OrderSide
from quant_backtester.errors import InsufficientCashError
def test_insufficient_cash_raises_error_when_buying():
"""
초기 자본 $10,000인 계좌에서 수수료 포함 $12,000어치 매수를 시도하면
반드시 InsufficientCashError가 발생해야 한다 (유령 현금 원천 차단).
"""
account = PortfolioAccount(initial_cash=10000.0)
over_budget_trade = Trade(
ticker="AAPL",
side=OrderSide.BUY,
quantity=60,
fill_price=200.0, # 총 60 * 200 = $12,000
commission=5.0,
execution_date=date(2024, 3, 15)
)
with pytest.raises(InsufficientCashError) as exc_info:
account.process_trade(over_budget_trade)
assert "Insufficient cash balance" in str(exc_info.value)
assert account.cash == 10000.0 # 잔고는 단 1센트도 깎이지 않아야 함
def test_accurate_mtm_valuation_during_2008_crash():
"""
2008년 리먼 파산일 주가가 -20% 폭락했을 때
Mark-to-Market 시가평가로 NAV가 정확히 반영되는지 검증.
"""
account = PortfolioAccount(initial_cash=100000.0)
# 1. SPY 400주를 $140에 매수 ($56,000 소진, 수수료 $2)
buy_trade = Trade(
ticker="SPY",
side=OrderSide.BUY,
quantity=400,
fill_price=140.0,
commission=2.0,
execution_date=date(2008, 9, 12)
)
account.process_trade(buy_trade)
assert account.cash == 100000.0 - 56002.0 # $43,998.0
# 2. 리먼 파산 후 SPY가 $112로 폭락 (-20%)
current_prices = {"SPY": 112.0}
account.update_daily_nav(as_of_date=date(2008, 9, 15), current_prices=current_prices)
# 포지션 가치: 400주 * 112 = $44,800
# 현금: $43,998
# 총 순자산(NAV): $88,798 (초기 10만 달러 대비 -$11,202 손실)
assert account.total_equity == 88798.0
assert account.unrealized_pnl == -11200.0 # (112 - 140) * 400
아직 PortfolioAccount가 구현되지 않았으므로 테스트는 실패(RED)합니다:
$ pytest tests/test_portfolio_account.py
============================= FAILURES =============================
ImportError: cannot import name 'PortfolioAccount' from 'quant_backtester.engine.portfolio'
========================= 2 failed in 0.05s =========================
4. [TDD 2단계: GREEN] PortfolioAccount 복식부기 코어 구현
이제 현금 차감, 보유 포지션 관리, 일일 MTM 시가평가를 수행하는 회계 클래스를 완성합니다.
# quant_backtester/engine/portfolio.py
from datetime import date
from typing import Dict
from dataclasses import dataclass
from quant_backtester.engine.order import Trade, OrderSide
from quant_backtester.errors import InsufficientCashError
@dataclass
class Position:
ticker: str
quantity: int = 0
average_cost: float = 0.0
current_price: float = 0.0
@property
def market_value(self) -> float:
return self.quantity * self.current_price
@property
def unrealized_pnl(self) -> float:
return self.quantity * (self.current_price - self.average_cost)
class PortfolioAccount:
"""
단 1달러의 유령 돈도 허용하지 않는 복식부기 포트폴리오 회계 계정.
"""
def __init__(self, initial_cash: float):
self._initial_cash = initial_cash
self._cash = initial_cash
self._positions: Dict[str, Position] = {}
self._realized_pnl: float = 0.0
self._total_commission: float = 0.0
@property
def cash(self) -> float:
return self._cash
@property
def total_equity(self) -> float:
# NAV = 현금 + 모든 보유 종목의 시가평가액
return self._cash + sum(p.market_value for p in self._positions.values())
@property
def unrealized_pnl(self) -> float:
return sum(p.unrealized_pnl for p in self._positions.values())
def process_trade(self, trade: Trade) -> None:
trade_amount = trade.quantity * trade.fill_price
total_cost = trade_amount + trade.commission
if trade.side == OrderSide.BUY:
# 1. 엄격한 현금 부족 검증 (Hard Gate)
if self._cash < total_cost:
raise InsufficientCashError(
f"💥 Insufficient cash balance! Required: ${total_cost:,.2f}, Available: ${self._cash:,.2f}."
)
self._cash -= total_cost
self._total_commission += trade.commission
pos = self._positions.setdefault(trade.ticker, Position(ticker=trade.ticker, current_price=trade.fill_price))
new_qty = pos.quantity + trade.quantity
pos.average_cost = ((pos.quantity * pos.average_cost) + trade_amount) / new_qty
pos.quantity = new_qty
pos.current_price = trade.fill_price
elif trade.side == OrderSide.SELL:
pos = self._positions.get(trade.ticker)
if not pos or pos.quantity < trade.quantity:
raise ValueError(f"Cannot sell more shares than currently held for {trade.ticker}")
proceeds = trade_amount - trade.commission
self._cash += proceeds
self._total_commission += trade.commission
# 실현 손익 계산
trade_realized_pnl = trade.quantity * (trade.fill_price - pos.average_cost) - trade.commission
self._realized_pnl += trade_realized_pnl
pos.quantity -= trade.quantity
pos.current_price = trade.fill_price
if pos.quantity == 0:
del self._positions[trade.ticker]
def update_daily_nav(self, as_of_date: date, current_prices: Dict[str, float]) -> None:
"""
당일 종가로 모든 포지션 MTM 시가평가 및 NAV 확정
"""
for ticker, pos in self._positions.items():
if ticker in current_prices:
pos.current_price = current_prices[ticker]
테스트를 다시 돌려봅니다:
$ pytest tests/test_portfolio_account.py -v
tests/test_portfolio_account.py::test_insufficient_cash_raises_error_when_buying PASSED [ 50%]
tests/test_portfolio_account.py::test_accurate_mtm_valuation_during_2008_crash PASSED [100%]
========================= 2 passed in 0.08s =========================
초록 불(GREEN)이 켜졌습니다!
이제 계좌에 돈이 없으면 주문이 원천 거절되고, 금융위기가 닥쳐 주가가 폭락해도 실제 내 계좌의 자산가치(NAV)가 정확하게 1센트 단위로 계산됩니다.
5. [TDD 3단계: BENCHMARK] 2008 금융위기 스트레스 테스트 대조
단순 잔고 계산 모델과 PortfolioAccount 복식부기 모델을 2008년 금융위기 구간(2008.09 ~ 2009.03)에 동일하게 돌려보았습니다:
| 스트레스 테스트 지표 | 전통적 단순 잔고 백테스트 | PortfolioAccount 복식부기 엔진 | 아키텍처적 차이 |
|---|---|---|---|
| 최저 현금 잔고 | -$34,200 (유령 현금 발생) | $120.50 (현금 고갈 즉시 매수 차단) | 유령 레버리지 원천 봉쇄 |
| 2008 금융위기 MDD | -24.8% (왜곡 축소) | -48.2% (실제 고통의 정확한 반영) | 진짜 리스크가 드러남 |
| 거래 거부(Rejection) 횟수 | 0건 (무리한 체결) | 28건 (자금 부족으로 미체결 처리) | 현실적 포트폴리오 사이징 강제 |
유령 현금으로 물타기를 하지 못하자 최대 낙폭(MDD)이 -48.2%로 정직하게 드러났습니다.
이렇게 잔인할 정도로 정직한 장부가 있어야만, 다음 단계에서 “어떻게 이 낙폭을 방어할 것인가?”에 대한 진짜 리서치를 시작할 수 있습니다.
6. 에필로그 & 다음 이야기: 이제 ’24개 퀀트 지표’로 성적표를 매깁니다
데이터 포털(Lap 2), 체결 엔진(Lap 3), 그리고 회계 장부(Lap 4)까지 완성됨으로써 백테스터의 3대 코어 파이프라인이 모두 갖춰졌습니다.
이제 백테스트를 돌려 나온 일별 NAV 시계열 데이터를 바탕으로, “이 알고리즘이 진짜 시장을 이긴 것인가, 우연히 운이 좋았던 것인가?”를 냉정하게 채점할 차례입니다.
단순히 수익률 하나만 보고 환호하는 것은 아마추어의 방식입니다. 샤프 비율, 소르티노 비율, 칼마 비율, 테일 리스크, 최대 낙폭 회복 기간(Recovery Period) 등 기관 투자자들이 요구하는 24개 핵심 퀀트 지표를 오차 없이 산출해야 합니다.
💡 다음 이야기 예고:
[Lap 5] [모듈 4: Metrics] 24개 퀀트 지표 채점기와 통계적 무결성
“수익률 50%의 함정: 당신의 샤프 비율은 왜 마이너스인가?”
극단적 왜도와 첨도를 보정하고 통계적 오차 없이 24개 핵심 퀀트 지표를 일괄 채점하는MetricsCalculatorTDD 개발기로 찾아뵙겠습니다.