이미지 로딩 중...

Python 알고리즘 트레이딩 봇 만들기 8편 - 백테스팅 시스템 구축 - 슬라이드 1/11
A

AI Generated

2025. 11. 12. · 4 Views

Python 알고리즘 트레이딩 봇 만들기 8편 - 백테스팅 시스템 구축

실전 투자 전에 반드시 필요한 백테스팅 시스템을 파이썬으로 구축합니다. 과거 데이터를 활용해 트레이딩 전략의 성과를 검증하고, 리스크를 사전에 파악하는 방법을 배웁니다. 실무에서 바로 활용할 수 있는 백테스팅 프레임워크를 만들어봅니다.


목차

  1. 백테스팅 엔진 설계 - 전략 검증의 핵심 프레임워크
  2. 과거 데이터 로딩 및 전처리 - 정확한 시뮬레이션의 시작
  3. 매매 신호 생성 로직 - 전략을 실행 가능한 신호로 변환
  4. 포트폴리오 추적 및 손익 계산 - 실시간 성과 모니터링
  5. 백테스팅 메인 루프 - 시뮬레이션 실행 엔진
  6. 성과 지표 계산 - 전략 평가의 핵심 메트릭
  7. 백테스팅 결과 시각화 - 직관적인 성과 분석
  8. 파라미터 최적화 - 최고 성능의 전략 찾기
  9. Walk-Forward 분석 - 실전 성능 검증
  10. 슬리피지와 거래 비용 모델링 - 현실적인 백테스팅

1. 백테스팅 엔진 설계 - 전략 검증의 핵심 프레임워크

시작하며

여러분이 몇 주간 공들여 만든 트레이딩 전략을 실제 돈으로 바로 테스트하려는 순간, 불안감이 엄습한 적 있나요? "이 전략이 정말 수익을 낼까?", "과거에는 어떤 성과를 냈을까?"라는 의문이 드는 것은 당연합니다.

이런 불확실성은 실제 금전적 손실로 이어질 수 있습니다. 검증되지 않은 전략을 실전에 투입했다가 예상치 못한 손실을 입는 경우가 비일비재합니다.

과거 데이터를 기반으로 전략의 성과를 미리 확인할 수 있다면 이런 리스크를 크게 줄일 수 있습니다. 바로 이럴 때 필요한 것이 백테스팅 엔진입니다.

백테스팅은 과거 데이터를 사용해 여러분의 트레이딩 전략을 시뮬레이션하고, 실전 투입 전에 성과와 리스크를 정확히 파악할 수 있게 해줍니다.

개요

간단히 말해서, 백테스팅 엔진은 과거 시장 데이터를 통해 트레이딩 전략을 시뮬레이션하는 시스템입니다. 실제 돈을 쓰지 않고도 전략의 수익성과 리스크를 검증할 수 있는 핵심 도구입니다.

실무에서 백테스팅 없이 전략을 운영하는 것은 마치 지도 없이 항해하는 것과 같습니다. 어떤 헤지펀드나 퀀트 트레이더도 백테스팅 없이 전략을 실전에 투입하지 않습니다.

예를 들어, 이동평균 크로스 전략을 개발했다면, 지난 3년간의 데이터로 시뮬레이션해서 실제로 수익이 났는지, MDD(최대낙폭)는 얼마였는지 확인해야 합니다. 기존에는 엑셀로 일일이 계산하거나 비싼 상용 소프트웨어를 사용했다면, 이제는 파이썬으로 직접 맞춤형 백테스팅 엔진을 만들 수 있습니다.

백테스팅 엔진의 핵심 특징은 크게 세 가지입니다. 첫째, 과거 데이터를 순차적으로 처리하여 미래 정보 유출(Look-ahead Bias)을 방지합니다.

둘째, 슬리피지와 수수료 같은 실전 거래 비용을 반영합니다. 셋째, 포트폴리오 상태를 실시간으로 추적하여 정확한 손익을 계산합니다.

이러한 특징들이 백테스팅 결과의 신뢰성을 결정합니다.

코드 예제

# 백테스팅 엔진의 기본 구조
class BacktestEngine:
    def __init__(self, initial_capital=10000000):
        # 초기 자본과 포트폴리오 상태 초기화
        self.initial_capital = initial_capital
        self.cash = initial_capital
        self.positions = {}  # {ticker: quantity}
        self.portfolio_value = []
        self.trades = []

    def execute_trade(self, ticker, quantity, price, timestamp):
        # 매매 실행 및 포트폴리오 업데이트
        commission = abs(quantity * price) * 0.0003  # 0.03% 수수료
        slippage = abs(quantity * price) * 0.0001    # 0.01% 슬리피지
        total_cost = quantity * price + commission + slippage

        # 잔고 확인 및 거래 실행
        if self.cash >= total_cost:
            self.cash -= total_cost
            self.positions[ticker] = self.positions.get(ticker, 0) + quantity
            self.trades.append({
                'timestamp': timestamp,
                'ticker': ticker,
                'quantity': quantity,
                'price': price,
                'cost': total_cost
            })
            return True
        return False

설명

이것이 하는 일: 백테스팅 엔진은 과거의 주가 데이터를 시간 순서대로 재생하면서, 여러분의 트레이딩 전략이 각 시점에서 어떤 결정을 내렸을지 시뮬레이션합니다. 마치 타임머신을 타고 과거로 돌아가 실제로 거래하는 것처럼 정확하게 재현합니다.

첫 번째로, __init__ 메서드는 백테스팅 환경을 초기화합니다. 초기 자본금(initial_capital)을 설정하고, 현금 잔고(cash), 보유 포지션(positions), 포트폴리오 가치 기록(portfolio_value), 거래 내역(trades)을 추적할 변수들을 준비합니다.

이렇게 하는 이유는 백테스팅 과정에서 매 순간의 재무 상태를 정확히 기록해야 나중에 성과 분석이 가능하기 때문입니다. 그 다음으로, execute_trade 메서드가 실행되면서 실제 거래를 시뮬레이션합니다.

내부에서는 단순히 "주식 X개를 Y원에 샀다"로 끝나지 않습니다. 실제 거래에서 발생하는 수수료(commission)와 슬리피지(slippage)를 계산합니다.

수수료는 거래 금액의 0.03%, 슬리피지는 0.01%로 설정되어 있습니다. 슬리피지는 주문가와 실제 체결가의 차이를 의미하며, 이를 반영하지 않으면 백테스팅 결과가 과도하게 낙관적으로 나옵니다.

마지막으로, 잔고 확인 로직이 실행되어 실제 거래 가능 여부를 판단합니다. if self.cash >= total_cost 조건으로 현금이 충분한지 검사하고, 충분하다면 현금을 차감하고 포지션을 업데이트합니다.

모든 거래는 trades 리스트에 기록되어 나중에 거래 패턴을 분석할 수 있습니다. 이 과정을 통해 최종적으로 "이 전략으로 3년간 거래했다면 실제로 얼마를 벌었을까?"라는 질문에 대한 정확한 답을 얻을 수 있습니다.

여러분이 이 코드를 사용하면 전략의 실제 성과를 사전에 파악할 수 있고, 초기 자본 대비 수익률, 최대 낙폭, 승률 등 중요한 지표들을 계산할 수 있습니다. 실무에서는 이 결과를 바탕으로 전략의 파라미터를 조정하거나, 여러 전략을 비교하여 최적의 전략을 선택합니다.

무엇보다 실제 돈을 잃기 전에 전략의 약점을 발견할 수 있다는 점이 가장 큰 이점입니다.

실전 팁

💡 초기 자본은 실전에서 사용할 금액과 동일하게 설정하세요. 1천만원으로 백테스팅했는데 실전에서 1억을 투입하면 슬리피지가 크게 달라져 결과가 왜곡됩니다.

💡 수수료와 슬리피지를 보수적으로 설정하세요. 실제보다 낮게 설정하면 백테스팅은 좋은데 실전에서 손실을 보는 "백테스팅 함정"에 빠집니다.

💡 거래 내역을 반드시 저장하세요. 나중에 "왜 이 시점에 매수했지?"를 분석할 때 trades 리스트가 결정적인 단서가 됩니다.

💡 포지션 딕셔너리는 여러 종목을 동시에 거래하는 포트폴리오 전략에 필수입니다. 단일 종목만 거래해도 확장성을 위해 처음부터 딕셔너리로 설계하세요.

💡 매매가 실패한 경우(잔고 부족)도 로그로 남기면 "왜 이 전략이 특정 시점에 작동하지 않았는지" 파악할 수 있어 전략 개선에 도움이 됩니다.


2. 과거 데이터 로딩 및 전처리 - 정확한 시뮬레이션의 시작

시작하며

여러분이 백테스팅을 시작하려고 과거 데이터를 불러왔는데, 데이터에 결측치가 있거나 주말 데이터가 섞여있어 결과가 이상하게 나온 경험 있나요? 실제로 많은 초보자들이 데이터 품질 문제로 백테스팅에서 잘못된 결론을 얻습니다.

이런 문제는 전략의 신뢰성을 완전히 무너뜨립니다. 불완전한 데이터로 백테스팅하면 "과거에 300% 수익"이라는 환상적인 결과가 나올 수 있지만, 실전에서는 큰 손실로 이어집니다.

데이터의 정확성은 백테스팅의 생명입니다. 바로 이럴 때 필요한 것이 체계적인 데이터 로딩 및 전처리 시스템입니다.

올바른 데이터 처리 파이프라인은 백테스팅 결과의 신뢰성을 보장합니다.

개요

간단히 말해서, 데이터 로딩 및 전처리는 과거 주가 데이터를 백테스팅에 적합한 형태로 정제하는 과정입니다. 결측치 처리, 이상치 제거, 타임스탬프 정렬 등을 통해 시뮬레이션의 정확성을 확보합니다.

실무에서 데이터 품질은 백테스팅 성공의 50%를 차지합니다. 아무리 좋은 전략과 엔진이 있어도 데이터가 엉망이면 쓸모없는 결과만 나옵니다.

예를 들어, 주식 분할(Stock Split)이 반영되지 않은 데이터로 백테스팅하면 해당 시점에서 가격이 갑자기 반토막 나는 것처럼 보여 잘못된 매도 신호를 발생시킵니다. 기존에는 CSV 파일을 그대로 읽어서 사용했다면, 이제는 데이터 검증, 정규화, 결측치 처리 등 전처리 파이프라인을 거쳐야 합니다.

데이터 전처리의 핵심 특징은 세 가지입니다. 첫째, 타임스탬프 기준으로 정렬하여 시간 순서를 보장합니다(미래 정보 유출 방지).

둘째, 거래일만 필터링하여 주말/공휴일 데이터를 제거합니다. 셋째, 결측치를 전일 종가로 채우거나 해당 구간을 스킵하여 데이터 연속성을 유지합니다.

이러한 처리들이 백테스팅의 현실성을 높입니다.

코드 예제

import pandas as pd
import numpy as np

def load_and_preprocess_data(file_path, start_date, end_date):
    # CSV 파일에서 과거 데이터 로드
    df = pd.read_csv(file_path, parse_dates=['date'])

    # 날짜 기준 정렬 (시간 순서 보장)
    df = df.sort_values('date').reset_index(drop=True)

    # 기간 필터링
    df = df[(df['date'] >= start_date) & (df['date'] <= end_date)]

    # 결측치 처리: forward fill (전일 종가로 채움)
    df[['open', 'high', 'low', 'close', 'volume']] = df[['open', 'high', 'low', 'close', 'volume']].fillna(method='ffill')

    # 여전히 결측치가 있다면 제거 (초기 데이터)
    df = df.dropna()

    # 이상치 검증: 가격이 음수이거나 0인 경우 제거
    df = df[(df['close'] > 0) & (df['volume'] >= 0)]

    # 인덱스를 date로 설정 (시계열 데이터 처리 용이)
    df.set_index('date', inplace=True)

    return df

설명

이것이 하는 일: 이 함수는 원본 주가 데이터를 백테스팅 엔진이 안전하게 사용할 수 있는 깨끗한 형태로 변환합니다. 마치 요리하기 전에 재료를 다듬는 것처럼, 데이터의 불순물을 제거하고 표준화된 형식으로 준비합니다.

첫 번째로, pd.read_csv로 데이터를 읽어올 때 parse_dates=['date'] 옵션을 사용합니다. 이렇게 하는 이유는 날짜 문자열을 실제 datetime 객체로 변환하여 나중에 날짜 비교나 필터링을 쉽게 하기 위함입니다.

그런 다음 sort_values('date')로 시간 순서대로 정렬합니다. 이것이 매우 중요한 이유는, 백테스팅은 "과거부터 현재로" 순차적으로 진행되어야 하는데, 정렬되지 않은 데이터는 미래 정보가 과거로 유출되는 Look-ahead Bias를 유발하기 때문입니다.

그 다음으로, 결측치 처리 로직이 실행됩니다. fillna(method='ffill')는 Forward Fill 방식으로, 비어있는 값을 바로 이전 값으로 채웁니다.

예를 들어, 특정일의 종가 데이터가 없다면 전일 종가를 사용합니다. 이것은 "거래가 없었던 날은 가격 변동이 없었다"고 가정하는 보수적인 접근입니다.

하지만 초기 데이터에 결측치가 있을 수 있으므로 dropna()로 완전히 제거합니다. 결측치를 잘못 처리하면 백테스팅 도중 NaN 오류가 발생하거나 잘못된 매매 신호가 생성됩니다.

세 번째로, 이상치 검증 단계가 진행됩니다. df[(df['close'] > 0) & (df['volume'] >= 0)] 조건으로 가격이 음수이거나 0인 비정상적인 데이터를 걸러냅니다.

실무에서는 데이터 제공업체의 오류로 이런 값이 종종 섞여 들어옵니다. 이런 값이 하나라도 있으면 수익률 계산이 왜곡되어 백테스팅 결과가 완전히 틀어집니다.

마지막으로, set_index('date')로 날짜를 인덱스로 설정합니다. 이렇게 하면 df.loc['2023-01-15']처럼 날짜로 직접 데이터를 조회할 수 있어 백테스팅 루프에서 매우 편리합니다.

최종적으로 깨끗하고 시간 순서가 보장된 DataFrame이 반환되어, 백테스팅 엔진은 안심하고 이 데이터로 시뮬레이션을 진행할 수 있습니다. 여러분이 이 코드를 사용하면 데이터 품질 문제로 인한 백테스팅 오류를 90% 이상 줄일 수 있습니다.

실무에서는 데이터를 로드한 직후 반드시 df.info(), df.describe(), df.isnull().sum()으로 데이터 상태를 확인하는 습관을 들이세요. 또한 주식 분할이나 배당락 같은 기업 액션(Corporate Action)이 조정된 데이터(Adjusted Data)를 사용하는 것이 중요합니다.

실전 팁

💡 parse_dates 옵션을 빼먹으면 날짜가 문자열로 처리되어 날짜 비교가 느려지고 버그의 원인이 됩니다. 반드시 날짜 컬럼은 datetime 타입으로 파싱하세요.

💡 Forward Fill 외에도 Backward Fill, 선형보간 등 다양한 결측치 처리 방법이 있습니다. 거래량은 0으로 채우는 것이 더 현실적일 수 있으니 컬럼별로 다르게 처리하세요.

💡 백테스팅 전에 df.plot(y='close')로 가격 차트를 시각화해보세요. 급격한 스파이크나 이상한 패턴이 보이면 데이터 오류일 가능성이 높습니다.

💡 실전에서는 여러 종목을 동시에 백테스팅하므로, 이 함수를 루프로 감싸서 딕셔너리 형태로 저장하면 편리합니다: data = {ticker: load_and_preprocess_data(f'{ticker}.csv', start, end) for ticker in tickers}

💡 거래량이 0인 날은 가격이 있어도 실제로 매매할 수 없으므로 필터링하는 것을 고려하세요. 특히 소형주나 해외 주식 백테스팅에서 중요합니다.


3. 매매 신호 생성 로직 - 전략을 실행 가능한 신호로 변환

시작하며

여러분이 "이동평균선이 골든크로스하면 매수한다"는 전략을 세웠는데, 이것을 코드로 어떻게 구현해야 할지 막막한 경험 있나요? 전략 아이디어는 있는데 실제 매매 신호로 변환하는 과정에서 많은 분들이 어려움을 겪습니다.

이런 단계를 건너뛰면 전략이 실행되지 않습니다. 백테스팅 엔진은 "골든크로스"라는 개념을 이해하지 못합니다.

오직 "지금 매수해라" 또는 "매도해라"라는 명확한 신호만 인식합니다. 전략을 구체적인 매매 신호로 변환하는 로직이 필수입니다.

바로 이럴 때 필요한 것이 매매 신호 생성 로직입니다. 전략의 추상적인 규칙을 백테스팅 엔진이 실행할 수 있는 구체적인 매수/매도 신호로 변환해줍니다.

개요

간단히 말해서, 매매 신호 생성 로직은 기술적 지표나 전략 조건을 계산하여 각 시점에서 "매수", "매도", "홀드" 중 무엇을 해야 하는지 결정하는 함수입니다. 전략의 두뇌 역할을 합니다.

실무에서 매매 신호 생성은 백테스팅의 핵심입니다. 같은 이동평균 전략이라도 크로스 판단 로직을 어떻게 구현하느냐에 따라 결과가 크게 달라집니다.

예를 들어, 5일 이동평균이 20일 이동평균을 "정확히 교차하는 순간"을 포착하려면 전일과 당일의 관계를 비교해야 합니다. 기존에는 수작업으로 차트를 보며 매매 시점을 찾았다면, 이제는 알고리즘이 수만 개의 데이터 포인트를 자동으로 스캔하여 정확한 신호를 생성합니다.

매매 신호 로직의 핵심 특징은 세 가지입니다. 첫째, 기술적 지표를 정확히 계산합니다(이동평균, RSI, MACD 등).

둘째, 전일과 당일을 비교하여 조건 변화를 감지합니다(크로스 검출). 셋째, 신호를 명확한 정수 값(1=매수, -1=매도, 0=홀드)으로 변환하여 백테스팅 엔진이 이해할 수 있게 합니다.

이러한 명확성이 자동화된 트레이딩의 핵심입니다.

코드 예제

def generate_signals(df):
    # 기술적 지표 계산
    df['SMA_5'] = df['close'].rolling(window=5).mean()
    df['SMA_20'] = df['close'].rolling(window=20).mean()

    # 신호 초기화 (0 = 홀드)
    df['signal'] = 0

    # 골든크로스 감지: 5일선이 20일선을 상향 돌파
    # 전일에는 5일선 < 20일선, 당일에는 5일선 > 20일선
    golden_cross = (df['SMA_5'].shift(1) < df['SMA_20'].shift(1)) & (df['SMA_5'] > df['SMA_20'])
    df.loc[golden_cross, 'signal'] = 1  # 매수 신호

    # 데드크로스 감지: 5일선이 20일선을 하향 돌파
    dead_cross = (df['SMA_5'].shift(1) > df['SMA_20'].shift(1)) & (df['SMA_5'] < df['SMA_20'])
    df.loc[dead_cross, 'signal'] = -1  # 매도 신호

    # NaN 제거 (이동평균 초기 계산 불가 구간)
    df['signal'] = df['signal'].fillna(0)

    return df

설명

이것이 하는 일: 이 함수는 과거 가격 데이터를 받아서 각 날짜마다 "오늘 매수해야 할까, 매도해야 할까, 아무것도 안 해야 할까"를 자동으로 판단합니다. 마치 숙련된 트레이더가 차트를 보며 결정을 내리듯이, 알고리즘이 수천 개의 날짜를 빠르게 분석합니다.

첫 번째로, 기술적 지표 계산 단계입니다. df['close'].rolling(window=5).mean()은 5일 이동평균을 계산합니다.

Rolling 함수는 매 시점마다 과거 5일치 종가의 평균을 계산합니다. 예를 들어, 1월 10일의 5일 이동평균은 1월 6일~10일의 종가 평균입니다.

이렇게 하는 이유는 단기 추세를 파악하기 위함이고, 20일 이동평균은 장기 추세를 나타냅니다. 두 선의 관계가 추세 변화를 알려주는 핵심 신호입니다.

그 다음으로, 골든크로스 감지 로직이 실행됩니다. df['SMA_5'].shift(1) < df['SMA_20'].shift(1)는 "전일에 5일선이 20일선 아래에 있었는지"를 확인하고, df['SMA_5'] > df['SMA_20']는 "오늘 5일선이 20일선 위에 있는지"를 확인합니다.

두 조건이 모두 참이면 교차가 발생한 것입니다. 단순히 "5일선 > 20일선"만 확인하면 이미 교차한 상태가 계속 감지되므로, shift를 사용해 "교차하는 순간"만 포착하는 것이 중요합니다.

이것이 정확한 진입 타이밍을 결정합니다. 세 번째로, 신호 할당 단계입니다.

df.loc[golden_cross, 'signal'] = 1은 골든크로스가 발생한 행(날짜)에 매수 신호 1을 할당합니다. 반대로 데드크로스에는 -1을 할당합니다.

나머지 날짜는 0(홀드)을 유지합니다. 이렇게 정수 신호로 변환하면 백테스팅 엔진은 단순히 if signal == 1: buy()처럼 처리할 수 있습니다.

마지막으로, NaN 처리가 진행됩니다. 이동평균 계산 초기(첫 20일)에는 충분한 데이터가 없어 NaN이 발생합니다.

이를 0으로 채워서 백테스팅 루프에서 오류가 나지 않도록 합니다. 최종적으로 DataFrame에 'signal' 컬럼이 추가되어, 각 날짜마다 해야 할 행동이 명확히 표시됩니다.

여러분이 이 코드를 사용하면 수동으로 차트를 보며 신호를 찾는 시간을 100% 절약하고, 인간의 감정이나 실수 없이 일관된 매매 결정을 내릴 수 있습니다. 실무에서는 이 함수를 확장하여 RSI, 볼린저밴드, MACD 등 다양한 지표를 결합한 복합 전략을 만들 수 있습니다.

또한 signal 컬럼을 누적합(cumsum)하면 현재 포지션 상태(롱/숏/중립)를 추적할 수 있어 포지션 관리에도 유용합니다.

실전 팁

💡 shift(1) 없이 크로스를 감지하면 교차 상태가 지속되는 모든 날에 신호가 발생합니다. 반드시 전일과 당일을 비교하여 "변화 순간"만 포착하세요.

💡 이동평균 초기 구간(20일 미만)은 신호가 불안정하므로, 백테스팅 시작일을 계산 기간 이후로 설정하는 것이 좋습니다: df = df.iloc[20:]

💡 골든크로스만으로는 부족할 때가 많습니다. 거래량 급증, RSI 과매수/과매도 같은 추가 조건을 AND 연산으로 결합하면 신호 정확도가 크게 향상됩니다.

💡 신호 생성 후 df[df['signal'] != 0]로 실제 신호 발생 시점을 확인하고 차트에 표시해보세요. 시각적으로 검증하면 로직 오류를 쉽게 발견할 수 있습니다.

💡 실전에서는 신호 발생 후 다음날 시가에 진입하는 경우가 많습니다. 이 경우 df['entry_price'] = df['open'].shift(-1)로 진입가를 설정하여 현실적인 백테스팅을 하세요.


4. 포트폴리오 추적 및 손익 계산 - 실시간 성과 모니터링

시작하며

여러분이 백테스팅을 돌리고 있는데, "지금 현재 얼마나 벌었지? 손실이 얼마까지 나면 중단해야 하지?"라는 질문에 답할 수 없다면 문제가 있습니다.

매 순간의 포트폴리오 가치를 추적하지 않으면 전략의 리스크를 파악할 수 없습니다. 이런 상황은 실전에서 치명적입니다.

백테스팅이 끝나고 나서야 "아, MDD가 60%였네"라고 알게 되면 이미 늦습니다. 실시간으로 손익과 리스크를 추적해야 전략의 위험성을 사전에 파악하고 조정할 수 있습니다.

바로 이럴 때 필요한 것이 포트폴리오 추적 시스템입니다. 매 거래일마다 현금, 보유주식 가치, 총 포트폴리오 가치를 계산하여 성과를 실시간으로 모니터링합니다.

개요

간단히 말해서, 포트폴리오 추적은 백테스팅 기간 동안 매 시점마다 총 자산 가치, 현금 잔고, 보유 포지션의 평가액을 계산하는 시스템입니다. 이를 통해 수익률, MDD, 샤프비율 같은 성과 지표를 산출할 수 있습니다.

실무에서 포트폴리오 추적이 없으면 백테스팅은 의미가 없습니다. 단순히 "매수했다, 매도했다"만 알아서는 전략의 실제 성과를 판단할 수 없습니다.

예를 들어, 10번 거래해서 8번 수익을 냈어도 2번의 큰 손실로 전체가 마이너스일 수 있습니다. 매 시점의 포트폴리오 가치를 기록해야 이런 패턴을 발견할 수 있습니다.

기존에는 거래 종료 후 최종 수익만 확인했다면, 이제는 시계열 데이터로 포트폴리오 가치를 추적하여 변동성, 낙폭, 회복 기간 등을 상세히 분석합니다. 포트폴리오 추적의 핵심 특징은 세 가지입니다.

첫째, 현금과 주식 평가액을 합산하여 총 자산을 계산합니다. 둘째, 매일의 수익률을 기록하여 누적 수익률과 변동성을 측정합니다.

셋째, 고점 대비 낙폭을 추적하여 MDD(최대낙폭)를 실시간으로 계산합니다. 이러한 지표들이 전략의 위험성을 정량화합니다.

코드 예제

def update_portfolio_value(engine, current_prices, timestamp):
    # 현재 보유 포지션의 시장가치 계산
    position_value = 0
    for ticker, quantity in engine.positions.items():
        if ticker in current_prices:
            position_value += quantity * current_prices[ticker]

    # 총 포트폴리오 가치 = 현금 + 보유주식 평가액
    total_value = engine.cash + position_value

    # 포트폴리오 가치 기록 (시계열 데이터)
    engine.portfolio_value.append({
        'timestamp': timestamp,
        'cash': engine.cash,
        'position_value': position_value,
        'total_value': total_value,
        'return': (total_value / engine.initial_capital - 1) * 100  # 수익률 %
    })

    return total_value

# MDD(최대낙폭) 계산 함수
def calculate_mdd(portfolio_values):
    # 누적 최고점 추적
    peak = portfolio_values[0]
    max_drawdown = 0

    for value in portfolio_values:
        if value > peak:
            peak = value  # 신고점 갱신
        drawdown = (peak - value) / peak * 100  # 고점 대비 낙폭 %
        if drawdown > max_drawdown:
            max_drawdown = drawdown

    return max_drawdown

설명

이것이 하는 일: 이 시스템은 백테스팅이 진행되는 동안 매일 장 마감 시점에 "현재 내 계좌에 총 얼마가 있는가"를 정확히 계산합니다. 마치 실제 증권 계좌의 평가잔고를 확인하는 것과 동일합니다.

첫 번째로, update_portfolio_value 함수는 현재 시점의 총 자산을 계산합니다. for ticker, quantity in engine.positions.items() 루프로 보유 중인 모든 종목을 순회하면서, 각 종목의 수량(quantity)에 현재가(current_prices[ticker])를 곱해 평가액을 구합니다.

예를 들어, 삼성전자 100주를 보유 중이고 현재가가 70,000원이라면 700만원의 가치를 가집니다. 이렇게 모든 종목의 가치를 합산한 것이 position_value입니다.

그 다음으로, 총 포트폴리오 가치 계산이 이루어집니다. total_value = engine.cash + position_value는 현금 잔고와 주식 평가액을 합산합니다.

이것이 중요한 이유는, 주식을 보유 중이어도 가격이 하락하면 총 자산이 줄어들기 때문입니다. 예를 들어, 현금 300만원 + 주식 700만원 = 1,000만원이 현재 총 자산입니다.

수익률은 (total_value / engine.initial_capital - 1) * 100으로 계산되며, 초기 자본 대비 몇 퍼센트 수익인지를 나타냅니다. 세 번째로, 포트폴리오 가치 기록 단계입니다.

engine.portfolio_value.append()로 현재 시점의 모든 정보를 리스트에 추가합니다. 이렇게 매일 기록을 쌓으면 나중에 수익률 곡선을 그리거나, 특정 시점의 상태를 조회할 수 있습니다.

시계열 데이터로 저장하는 것이 백테스팅의 핵심입니다. 네 번째로, calculate_mdd 함수는 최대낙폭을 계산합니다.

Peak 변수로 지금까지의 최고점을 추적하고, 현재 가치가 고점보다 낮으면 낙폭(drawdown)을 계산합니다. (peak - value) / peak * 100은 "고점 대비 몇 퍼센트 떨어졌는가"를 나타냅니다.

예를 들어, 최고점이 1,500만원이었는데 현재 1,200만원이라면 MDD는 20%입니다. 이 값이 클수록 전략의 리스크가 큽니다.

여러분이 이 코드를 사용하면 백테스팅 과정에서 실시간으로 손익을 확인할 수 있고, MDD가 허용 범위를 넘으면 전략을 수정할 수 있습니다. 실무에서는 MDD가 30%를 넘으면 일반적으로 너무 위험한 전략으로 간주됩니다.

또한 포트폴리오 가치 데이터를 Matplotlib으로 시각화하면 전략의 안정성을 한눈에 파악할 수 있습니다. 급격한 상승이나 하락 구간을 찾아 해당 시점에 무슨 일이 있었는지 분석하는 것이 전략 개선의 핵심입니다.

실전 팁

💡 포트폴리오 가치는 매일 기록하세요. 거래가 없는 날도 기록해야 정확한 수익률 곡선을 그릴 수 있습니다. 일부 날짜가 누락되면 변동성 계산이 왜곡됩니다.

💡 MDD는 단순 수익률보다 중요한 지표입니다. 같은 30% 수익이라도 MDD 10%인 전략과 50%인 전략은 완전히 다릅니다. 후자는 심리적으로 견디기 어렵습니다.

💡 포트폴리오 가치 리스트를 DataFrame으로 변환하면 분석이 쉽습니다: pd.DataFrame(engine.portfolio_value)로 변환 후 .describe(), .plot() 등을 활용하세요.

💡 실시간 추적 데이터를 CSV로 저장하면 나중에 여러 전략을 비교할 때 유용합니다. 날짜별 포트폴리오 가치를 컬럼으로 나란히 배치하면 어느 전략이 언제 더 좋았는지 한눈에 보입니다.

💡 Sharpe Ratio(샤프비율)도 계산하세요. 수익률의 표준편차를 구하고 (평균 수익률 - 무위험 이자율) / 표준편차로 위험 대비 수익을 정량화하면 전략 비교가 객관적으로 됩니다.


5. 백테스팅 메인 루프 - 시뮬레이션 실행 엔진

시작하며

여러분이 데이터도 준비하고, 신호 로직도 만들고, 엔진도 설계했는데, 이걸 어떻게 실제로 돌려야 할지 막막한 경험 있나요? 모든 구성요소를 하나로 묶어 과거부터 현재까지 순차적으로 시뮬레이션하는 메인 로직이 필요합니다.

이런 통합 과정 없이 각 부분을 따로따로 실행하면 전체 흐름이 끊어져 백테스팅이 불가능합니다. 데이터 로딩, 신호 생성, 거래 실행, 포트폴리오 업데이트가 시간 순서대로 정확히 연결되어야 합니다.

바로 이럴 때 필요한 것이 백테스팅 메인 루프입니다. 모든 컴포넌트를 조율하여 과거 데이터를 순차적으로 재생하며 완전한 시뮬레이션을 실행합니다.

개요

간단히 말해서, 백테스팅 메인 루프는 시작일부터 종료일까지 하루하루 진행하면서 신호 확인, 거래 실행, 포트폴리오 업데이트를 순차적으로 수행하는 핵심 실행 엔진입니다. 모든 백테스팅 구성요소의 지휘자 역할을 합니다.

실무에서 메인 루프의 설계는 백테스팅 품질을 결정합니다. 루프 내에서 미래 정보가 유출되거나, 거래 순서가 잘못되거나, 업데이트가 누락되면 백테스팅 결과가 완전히 왜곡됩니다.

예를 들어, 당일 종가로 신호를 생성하고 같은 종가로 진입하면 현실적으로 불가능한 거래를 시뮬레이션하는 것입니다. 기존에는 복잡한 중첩 루프와 조건문으로 작성했다면, 이제는 명확한 단계별 구조로 가독성과 유지보수성을 높입니다.

메인 루프의 핵심 특징은 세 가지입니다. 첫째, 시간 순서를 엄격히 지키며 Look-ahead Bias를 방지합니다.

둘째, 매 시점마다 신호 확인 → 거래 실행 → 포트폴리오 업데이트 순서를 지킵니다. 셋째, 모든 이벤트를 로깅하여 나중에 디버깅과 분석이 가능하게 합니다.

이러한 체계성이 신뢰할 수 있는 백테스팅을 만듭니다.

코드 예제

def run_backtest(ticker, start_date, end_date, initial_capital=10000000):
    # 1. 데이터 로드 및 전처리
    df = load_and_preprocess_data(f'data/{ticker}.csv', start_date, end_date)

    # 2. 매매 신호 생성
    df = generate_signals(df)

    # 3. 백테스팅 엔진 초기화
    engine = BacktestEngine(initial_capital)

    # 4. 메인 루프: 시간 순서대로 시뮬레이션
    for i in range(len(df)):
        current_date = df.index[i]
        current_row = df.iloc[i]

        # 현재 시점의 신호 확인
        signal = current_row['signal']
        current_price = current_row['close']

        # 매수 신호 처리
        if signal == 1 and ticker not in engine.positions:
            # 현금의 95%로 매수 (5%는 안전 마진)
            quantity = int((engine.cash * 0.95) / current_price)
            if quantity > 0:
                engine.execute_trade(ticker, quantity, current_price, current_date)
                print(f"[{current_date}] 매수: {quantity}주 @ {current_price}원")

        # 매도 신호 처리
        elif signal == -1 and ticker in engine.positions:
            quantity = -engine.positions[ticker]  # 전량 매도
            engine.execute_trade(ticker, quantity, current_price, current_date)
            print(f"[{current_date}] 매도: {-quantity}주 @ {current_price}원")

        # 포트폴리오 가치 업데이트
        current_prices = {ticker: current_price}
        update_portfolio_value(engine, current_prices, current_date)

    # 5. 백테스팅 결과 분석
    final_value = engine.portfolio_value[-1]['total_value']
    total_return = (final_value / initial_capital - 1) * 100
    mdd = calculate_mdd([pv['total_value'] for pv in engine.portfolio_value])

    print(f"\n=== 백테스팅 결과 ===")
    print(f"초기 자본: {initial_capital:,}원")
    print(f"최종 자산: {final_value:,.0f}원")
    print(f"총 수익률: {total_return:.2f}%")
    print(f"MDD: {mdd:.2f}%")
    print(f"총 거래 횟수: {len(engine.trades)}회")

    return engine

설명

이것이 하는 일: 이 함수는 백테스팅의 모든 단계를 자동으로 실행하는 마스터 컨트롤러입니다. 마치 영화를 재생하듯이 과거의 시장 상황을 처음부터 끝까지 재현하며, 여러분의 전략이 각 시점에서 어떻게 행동했을지 시뮬레이션합니다.

첫 번째로, 초기화 단계(1~3번)에서 데이터를 로드하고, 신호를 생성하며, 엔진을 준비합니다. load_and_preprocess_data로 깨끗한 데이터를 얻고, generate_signals로 매일의 매매 신호를 미리 계산해둡니다.

BacktestEngine 인스턴스를 생성하여 초기 자본을 설정합니다. 이 준비 과정이 완료되면 실제 시뮬레이션을 시작할 수 있습니다.

그 다음으로, 메인 루프(4번)가 실행됩니다. for i in range(len(df))로 첫날부터 마지막 날까지 하루씩 진행합니다.

각 날짜마다 df.iloc[i]로 해당 날의 데이터를 가져옵니다. 여기서 중요한 것은 iloc[i]가 순차적으로 접근한다는 점입니다.

절대로 iloc[i+1]로 미래 데이터를 미리 보지 않습니다. 이것이 Look-ahead Bias를 방지하는 핵심입니다.

세 번째로, 신호 처리 로직이 작동합니다. if signal == 1로 매수 신호를 감지하면, int((engine.cash * 0.95) / current_price)로 매수 가능한 수량을 계산합니다.

현금의 95%만 사용하는 이유는 수수료와 슬리피지로 인해 100% 사용 시 잔고 부족 오류가 발생할 수 있기 때문입니다. execute_trade를 호출하여 실제 거래를 시뮬레이션하고, print로 거래를 로깅합니다.

매도 신호도 동일하게 처리하되, quantity = -engine.positions[ticker]로 음수 수량을 전달하여 매도를 나타냅니다. 네 번째로, 포트폴리오 업데이트가 매 루프마다 실행됩니다.

거래 여부와 관계없이 update_portfolio_value를 호출하여 현재 시점의 총 자산을 기록합니다. 이렇게 하면 거래가 없는 날도 포트폴리오 가치 변화를 추적할 수 있습니다.

예를 들어, 주식을 보유 중인데 가격이 오르면 거래 없이도 자산이 증가합니다. 마지막으로, 결과 분석(5번)이 루프 종료 후 실행됩니다.

engine.portfolio_value[-1]로 마지막 날의 포트폴리오 가치를 가져와 총 수익률을 계산하고, MDD를 산출하여 결과를 출력합니다. 이 요약 정보로 전략의 전반적인 성과를 한눈에 파악할 수 있습니다.

여러분이 이 코드를 사용하면 단 한 번의 함수 호출로 전체 백테스팅이 자동으로 실행됩니다: run_backtest('삼성전자', '2020-01-01', '2023-12-31'). 실무에서는 이 함수를 여러 종목과 기간에 대해 반복 실행하여 가장 좋은 성과를 내는 조합을 찾습니다.

또한 메인 루프 내부에 추가 조건(손절/익절, 포지션 크기 조절 등)을 넣어 더 정교한 전략을 구현할 수 있습니다.

실전 팁

💡 매수/매도 로그를 파일로 저장하면 나중에 특정 거래가 왜 발생했는지 추적하기 쉽습니다. print 대신 logging 모듈을 사용하면 더 전문적입니다.

💡 루프 안에서 if ticker not in engine.positions 조건으로 중복 매수를 방지하세요. 이미 보유 중인데 또 매수하면 포지션 크기가 비정상적으로 커집니다.

💡 실전에서는 당일 종가가 아닌 다음날 시가에 진입합니다. df.iloc[i+1]['open']을 사용하되, 마지막 날 인덱스 오류를 방지하려면 if i < len(df) - 1 체크를 추가하세요.

💡 여러 종목을 백테스팅하려면 이 함수를 루프로 감싸세요: for ticker in ['삼성전자', 'LG전자']: run_backtest(ticker, ...). 결과를 딕셔너리에 저장하면 나중에 비교 분석이 가능합니다.

💡 백테스팅이 느리다면 진행률 표시를 추가하세요: from tqdm import tqdm을 임포트하고 for i in tqdm(range(len(df)))로 변경하면 진행 상황을 시각적으로 확인할 수 있습니다.


6. 성과 지표 계산 - 전략 평가의 핵심 메트릭

시작하며

여러분이 백테스팅을 끝내고 "총 수익률 50%!"라고 기뻐했는데, 막상 MDD가 70%라면 이 전략을 실전에 쓸 수 있을까요? 수익률 하나만 보고 전략을 판단하는 것은 매우 위험합니다.

이런 일방적인 평가는 큰 실수로 이어집니다. 실제로 많은 초보 트레이더들이 높은 수익률에 현혹되어 고위험 전략을 사용하다가 감당하지 못할 낙폭을 경험합니다.

전략의 수익성뿐만 아니라 안정성, 효율성을 종합적으로 평가해야 합니다. 바로 이럴 때 필요한 것이 다양한 성과 지표입니다.

샤프비율, 승률, 평균 손익비, 최대 연속 손실 등 다각도의 메트릭으로 전략을 과학적으로 평가합니다.

개요

간단히 말해서, 성과 지표는 백테스팅 결과를 정량화하여 전략의 수익성, 안정성, 효율성을 객관적으로 평가하는 수치들입니다. 여러 전략을 비교하거나 파라미터를 최적화할 때 필수적입니다.

실무에서 성과 지표 없이 전략을 운영하는 것은 불가능합니다. 헤지펀드나 자산운용사는 샤프비율, 소르티노비율, 칼마비율 등 수십 가지 지표로 전략을 평가합니다.

예를 들어, 두 전략이 모두 30% 수익을 냈더라도 샤프비율이 2.0인 전략과 0.5인 전략은 완전히 다른 품질입니다. 전자는 위험 대비 효율적인 수익을, 후자는 과도한 변동성 속 운 좋은 수익을 의미합니다.

기존에는 수익률과 MDD만 봤다면, 이제는 승률, 평균 손익비, 회복 기간, 월별 수익률 분포 등 세밀한 지표로 전략을 해부합니다. 성과 지표의 핵심 특징은 세 가지입니다.

첫째, 수익과 리스크를 동시에 평가합니다(샤프비율, 소르티노비율). 둘째, 거래 패턴을 분석합니다(승률, 평균 손익비, 최대 연속 손실).

셋째, 시간에 따른 안정성을 측정합니다(월별/연도별 수익률, 회복 기간). 이러한 다차원 분석이 전략의 진짜 실력을 드러냅니다.

코드 예제

import numpy as np

def calculate_performance_metrics(engine):
    # 포트폴리오 가치 시계열 추출
    portfolio_values = [pv['total_value'] for pv in engine.portfolio_value]
    returns = [pv['return'] for pv in engine.portfolio_value]

    # 1. 총 수익률
    total_return = returns[-1]

    # 2. 연환산 수익률 (CAGR)
    days = len(portfolio_values)
    years = days / 252  # 연간 거래일 수
    cagr = (portfolio_values[-1] / portfolio_values[0]) ** (1/years) - 1

    # 3. 변동성 (연환산)
    daily_returns = np.diff(portfolio_values) / portfolio_values[:-1]
    volatility = np.std(daily_returns) * np.sqrt(252)

    # 4. 샤프비율 (무위험 이자율 3% 가정)
    risk_free_rate = 0.03
    sharpe_ratio = (cagr - risk_free_rate) / volatility if volatility > 0 else 0

    # 5. 승률 및 평균 손익비
    winning_trades = [t for t in engine.trades if t['quantity'] < 0]  # 매도는 익절
    total_trades = len(winning_trades)
    # (실제로는 매수-매도 쌍을 매칭하여 손익 계산 필요)

    # 6. MDD
    mdd = calculate_mdd(portfolio_values)

    metrics = {
        '총_수익률(%)': round(total_return, 2),
        'CAGR(%)': round(cagr * 100, 2),
        '연환산_변동성(%)': round(volatility * 100, 2),
        '샤프비율': round(sharpe_ratio, 2),
        'MDD(%)': round(mdd, 2),
        '총_거래횟수': total_trades
    }

    return metrics

설명

이것이 하는 일: 이 함수는 백테스팅 결과 데이터를 받아 전략의 품질을 나타내는 핵심 지표들을 자동으로 계산합니다. 마치 건강검진에서 혈압, 혈당, 콜레스테롤 등 여러 수치로 건강 상태를 종합 판단하는 것처럼, 전략의 건강도를 다각도로 진단합니다.

첫 번째로, 총 수익률과 CAGR 계산입니다. 총 수익률은 단순히 (최종 자산 / 초기 자본 - 1) * 100이지만, CAGR(연평균 복리 수익률)은 (최종/초기) ^ (1/년수) - 1로 계산됩니다.

CAGR이 중요한 이유는 기간이 다른 전략들을 공정하게 비교할 수 있기 때문입니다. 예를 들어, 1년에 50% 수익 vs 2년에 100% 수익을 비교할 때, 총 수익률로는 후자가 좋아 보이지만 CAGR로 환산하면 전자가 50%, 후자가 41%로 전자가 더 효율적입니다.

그 다음으로, 변동성과 샤프비율 계산입니다. np.diff(portfolio_values) / portfolio_values[:-1]는 일간 수익률을 계산합니다.

이것의 표준편차가 변동성인데, * np.sqrt(252)로 연환산합니다(1년 거래일 252일 가정). 변동성이 높으면 수익이 불안정하다는 의미입니다.

샤프비율은 (CAGR - 무위험이자율) / 변동성으로 계산되며, "위험 1단위당 초과 수익"을 의미합니다. 샤프비율이 1.0 이상이면 양호, 2.0 이상이면 우수한 전략으로 평가됩니다.

세 번째로, 승률과 평균 손익비 계산 부분입니다(코드는 간소화). 실제로는 매수-매도 쌍을 매칭하여 각 거래의 손익을 계산해야 합니다.

승률은 수익 거래 수 / 전체 거래 수 * 100이고, 평균 손익비는 평균 이익 / 평균 손실입니다. 흥미롭게도, 승률이 낮아도 손익비가 높으면 수익을 낼 수 있습니다.

예를 들어, 승률 40%지만 이길 때 평균 300만원, 질 때 평균 100만원이라면 전체적으로 수익입니다. 네 번째로, 모든 지표를 딕셔너리로 정리하여 반환합니다.

round로 소수점을 정리하여 가독성을 높입니다. 이 딕셔너리는 나중에 DataFrame으로 변환하여 여러 전략의 지표를 표로 비교하거나, 시각화할 때 유용합니다.

여러분이 이 코드를 사용하면 "이 전략을 실전에 써도 될까?"라는 질문에 객관적으로 답할 수 있습니다. 실무에서는 샤프비율 2.0 이상, MDD 20% 이하를 목표로 하는 경우가 많습니다.

또한 지표들을 시각화하여 리포트를 만들면 투자 설명회나 내부 보고에 활용할 수 있습니다. 예를 들어, 월별 수익률을 히트맵으로 그리면 어느 달에 전략이 잘 작동하는지 패턴을 발견할 수 있습니다.

실전 팁

💡 샤프비율은 음수 수익률도 변동성에 포함하므로, 하방 변동성만 고려하는 소르티노비율도 함께 계산하면 더 정확합니다: sortino = (cagr - risk_free) / downside_std.

💡 거래 횟수가 너무 많으면 과최적화(Overfitting) 의심이 있습니다. 연간 50회 이상이면 수수료와 슬리피지가 수익을 잠식할 수 있으니 주의하세요.

💡 CAGR 계산 시 252 거래일은 한국 시장 기준입니다. 미국 시장은 약 252일, 암호화폐는 365일(24/7 거래)로 조정하세요.

💡 지표를 DataFrame으로 만들어 저장하면 나중에 비교가 쉽습니다: pd.DataFrame([metrics1, metrics2, ...], index=['전략A', '전략B', ...])로 표를 만드세요.

💡 승률과 손익비는 반비례 관계가 많습니다. 승률 90%에 손익비 0.1인 전략보다 승률 30%에 손익비 5인 전략이 더 안정적일 수 있습니다. 두 지표를 함께 봐야 합니다.


7. 백테스팅 결과 시각화 - 직관적인 성과 분석

시작하며

여러분이 백테스팅을 돌리고 숫자로 된 결과를 받았는데, "총 수익률 45%, MDD 23%, 샤프비율 1.8"이라는 숫자만 봐서는 전략이 어떻게 움직였는지 감이 안 오는 경험 있나요? 숫자는 정확하지만 직관적이지 않습니다.

이런 추상적인 정보는 전략의 실제 행동 패턴을 이해하기 어렵게 만듭니다. 어느 시점에 큰 수익이 났는지, 언제 손실 구간이 길었는지, 거래가 몰려있는 시기는 언제인지 숫자만으로는 파악하기 힘듭니다.

시각화가 필수입니다. 바로 이럴 때 필요한 것이 백테스팅 결과 시각화입니다.

포트폴리오 가치 곡선, 낙폭 차트, 거래 포인트 표시 등으로 전략의 행동을 한눈에 파악할 수 있습니다.

개요

간단히 말해서, 백테스팅 결과 시각화는 Matplotlib이나 Plotly 같은 라이브러리로 수익률 곡선, 낙폭 그래프, 거래 내역 등을 차트로 그리는 과정입니다. 데이터를 시각적으로 표현하여 전략의 강점과 약점을 직관적으로 이해할 수 있게 합니다.

실무에서 시각화 없이 백테스팅 결과를 보고하는 것은 상상하기 어렵습니다. 투자자나 경영진에게 전략을 설명할 때 차트 한 장이 수백 개의 숫자보다 강력합니다.

예를 들어, 포트폴리오 가치 곡선이 우상향하면서 매끄럽다면 안정적인 전략이고, 들쭉날쭉하다면 변동성이 큰 전략입니다. 기존에는 엑셀로 수작업 차트를 만들었다면, 이제는 파이썬 코드 몇 줄로 전문적인 인터랙티브 차트를 자동 생성합니다.

시각화의 핵심 특징은 세 가지입니다. 첫째, 시간에 따른 포트폴리오 가치 변화를 선 그래프로 표현합니다.

둘째, 낙폭(Drawdown)을 별도 차트로 그려 리스크 구간을 시각화합니다. 셋째, 매수/매도 시점을 가격 차트에 마커로 표시하여 전략의 타이밍을 검증합니다.

이러한 시각적 분석이 전략 개선의 인사이트를 제공합니다.

코드 예제

import matplotlib.pyplot as plt
import pandas as pd

def visualize_backtest_results(engine, df, ticker):
    # 포트폴리오 가치 DataFrame 생성
    pv_df = pd.DataFrame(engine.portfolio_value)
    pv_df.set_index('timestamp', inplace=True)

    # 그림 생성 (3개 서브플롯)
    fig, axes = plt.subplots(3, 1, figsize=(14, 10), sharex=True)

    # 1. 포트폴리오 가치 곡선
    axes[0].plot(pv_df.index, pv_df['total_value'], label='포트폴리오 가치', linewidth=2)
    axes[0].axhline(y=engine.initial_capital, color='gray', linestyle='--', label='초기 자본')
    axes[0].set_ylabel('가치 (원)', fontsize=12)
    axes[0].set_title(f'{ticker} 백테스팅 결과', fontsize=14, fontweight='bold')
    axes[0].legend()
    axes[0].grid(True, alpha=0.3)

    # 2. 누적 수익률 곡선
    axes[1].plot(pv_df.index, pv_df['return'], label='누적 수익률', color='green', linewidth=2)
    axes[1].axhline(y=0, color='red', linestyle='--', alpha=0.5)
    axes[1].set_ylabel('수익률 (%)', fontsize=12)
    axes[1].legend()
    axes[1].grid(True, alpha=0.3)

    # 3. 낙폭(Drawdown) 차트
    peak = pv_df['total_value'].cummax()
    drawdown = (pv_df['total_value'] - peak) / peak * 100
    axes[2].fill_between(drawdown.index, drawdown, 0, color='red', alpha=0.3, label='낙폭')
    axes[2].set_ylabel('낙폭 (%)', fontsize=12)
    axes[2].set_xlabel('날짜', fontsize=12)
    axes[2].legend()
    axes[2].grid(True, alpha=0.3)

    plt.tight_layout()
    plt.savefig(f'backtest_{ticker}.png', dpi=300, bbox_inches='tight')
    plt.show()

    print(f"차트가 'backtest_{ticker}.png'로 저장되었습니다.")

설명

이것이 하는 일: 이 함수는 백테스팅 엔진의 포트폴리오 기록을 받아 세 가지 핵심 차트를 자동으로 생성합니다. 마치 병원에서 심전도를 보듯이, 전략의 건강 상태를 시간에 따라 시각적으로 확인할 수 있습니다.

첫 번째로, 데이터 준비 단계입니다. pd.DataFrame(engine.portfolio_value)로 포트폴리오 기록 리스트를 DataFrame으로 변환합니다.

이렇게 하면 시계열 분석과 플로팅이 훨씬 쉬워집니다. set_index('timestamp')로 날짜를 인덱스로 설정하면 x축이 자동으로 시간 순서대로 정렬됩니다.

그 다음으로, 서브플롯 구성입니다. plt.subplots(3, 1)은 세로로 3개의 차트를 쌓는 레이아웃을 만듭니다.

figsize=(14, 10)으로 충분히 큰 크기를 지정하여 가독성을 높입니다. sharex=True는 세 차트가 같은 x축(날짜)을 공유하게 하여, 특정 시점의 가치, 수익률, 낙폭을 수직으로 비교할 수 있게 합니다.

세 번째로, 첫 번째 차트(포트폴리오 가치)를 그립니다. axes[0].plot()로 시간에 따른 총 자산 변화를 선 그래프로 그립니다.

axhline으로 초기 자본 수평선을 추가하여 수익/손실 기준선을 표시합니다. 이 차트를 보면 "전략이 꾸준히 성장했는가", "큰 낙폭이 있었는가"를 한눈에 파악할 수 있습니다.

네 번째로, 두 번째 차트(누적 수익률)를 그립니다. 수익률을 퍼센트로 표현하여 초기 자본과 무관하게 성과를 비교할 수 있습니다.

axhline(y=0)으로 손익분기점을 표시합니다. 이 차트가 0 아래로 내려가는 구간은 손실 구간입니다.

다섯 번째로, 세 번째 차트(낙폭)를 그립니다. pv_df['total_value'].cummax()는 지금까지의 최고점을 추적합니다.

(현재 가치 - 고점) / 고점 * 100으로 고점 대비 낙폭을 계산합니다. fill_between으로 영역을 채워 낙폭의 깊이와 지속 기간을 시각화합니다.

이 차트에서 가장 깊은 부분이 MDD입니다. 낙폭이 빠르게 회복되면 좋은 전략이고, 오래 지속되면 개선이 필요합니다.

마지막으로, plt.tight_layout()으로 차트 간 간격을 조정하고, savefig로 고해상도 이미지 파일로 저장합니다. dpi=300은 출판 품질입니다.

저장된 이미지는 보고서나 프레젠테이션에 바로 사용할 수 있습니다. 여러분이 이 코드를 사용하면 백테스팅 결과를 10초 만에 전문가 수준의 차트로 변환할 수 있습니다.

실무에서는 이 차트들을 보고 "2022년 초 큰 낙폭이 있었네, 그 시점에 무슨 일이?"라는 질문을 하게 되고, 해당 시점의 거래 내역을 분석하여 전략을 개선합니다. 또한 여러 전략의 차트를 나란히 배치하여 비교하면 어떤 전략이 더 안정적인지 직관적으로 알 수 있습니다.

실전 팁

💡 한글 폰트가 깨진다면 plt.rcParams['font.family'] = 'Malgun Gothic'(윈도우) 또는 'AppleGothic'(맥)을 스크립트 상단에 추가하세요.

💡 거래 시점을 가격 차트에 표시하려면 axes[0].scatter(매수날짜, 매수가격, marker='^', color='green', s=100)로 마커를 추가하세요. 시각적으로 타이밍을 검증할 수 있습니다.

💡 Plotly를 사용하면 인터랙티브 차트를 만들 수 있습니다: 마우스를 올리면 정확한 수치가 표시되고, 줌/팬이 가능해 탐색이 훨씬 편리합니다.

💡 월별 수익률을 히트맵으로 그리면 계절성을 발견할 수 있습니다: sns.heatmap(monthly_returns.pivot(...))로 어느 달에 전략이 잘 작동하는지 패턴을 찾으세요.

💡 벤치마크(KOSPI, S&P500 등)와 전략 수익률을 같은 차트에 그려 비교하면 전략의 우수성을 객관적으로 보여줄 수 있습니다. 벤치마크를 이기지 못하면 그냥 인덱스 펀드를 사는 게 나을 수 있습니다.


8. 파라미터 최적화 - 최고 성능의 전략 찾기

시작하며

여러분이 이동평균 전략을 만들었는데, "5일과 20일 이동평균이 좋을까, 10일과 30일이 좋을까?"라는 의문이 드는 경험 있나요? 파라미터 선택에 따라 전략 성과가 크게 달라지는데, 일일이 수작업으로 테스트하기는 너무 번거롭습니다.

이런 수작업 접근은 시간도 오래 걸리고 최적값을 놓칠 가능성이 높습니다. 조합의 수가 많아지면 사람이 모든 경우를 테스트하는 것은 사실상 불가능합니다.

자동화된 최적화가 필수입니다. 바로 이럴 때 필요한 것이 파라미터 최적화입니다.

여러 파라미터 조합을 자동으로 백테스팅하여 가장 좋은 성과를 내는 조합을 찾아줍니다.

개요

간단히 말해서, 파라미터 최적화는 전략의 설정 값들(이동평균 기간, RSI 임계값 등)을 체계적으로 변경하며 백테스팅을 반복 실행하여, 최고 성과를 내는 파라미터 조합을 찾는 과정입니다. Grid Search나 랜덤 서치 같은 기법을 사용합니다.

실무에서 파라미터 최적화는 전략 개발의 핵심 단계입니다. 같은 로직이라도 파라미터 설정에 따라 수익률이 -20%에서 +50%까지 천차만별입니다.

예를 들어, RSI 과매수 기준을 70으로 할지 80으로 할지에 따라 거래 빈도와 승률이 완전히 달라집니다. 최적의 파라미터를 찾는 것이 전략의 성패를 좌우합니다.

기존에는 직관이나 경험에 의존해 파라미터를 정했다면, 이제는 데이터 기반으로 수백 개의 조합을 테스트하여 객관적으로 최적값을 찾습니다. 파라미터 최적화의 핵심 특징은 세 가지입니다.

첫째, Grid Search로 모든 조합을 빠짐없이 테스트합니다. 둘째, 각 조합의 성과 지표를 기록하여 비교합니다(샤프비율, MDD 등).

셋째, 과최적화(Overfitting)를 방지하기 위해 In-Sample/Out-of-Sample 검증을 수행합니다. 이러한 과학적 접근이 안정적인 전략을 만듭니다.

코드 예제

import itertools
import pandas as pd

def optimize_parameters(ticker, start_date, end_date, param_grid):
    # 파라미터 그리드 생성
    param_combinations = list(itertools.product(*param_grid.values()))
    param_names = list(param_grid.keys())

    results = []

    # 모든 파라미터 조합 테스트
    for combo in param_combinations:
        params = dict(zip(param_names, combo))
        print(f"테스트 중: {params}")

        # 파라미터로 신호 생성 함수 수정 필요 (여기서는 간소화)
        # df = generate_signals(df, short_window=params['short_window'], long_window=params['long_window'])

        # 백테스팅 실행
        engine = run_backtest(ticker, start_date, end_date, initial_capital=10000000)

        # 성과 지표 계산
        metrics = calculate_performance_metrics(engine)

        # 파라미터와 결과 저장
        result = {**params, **metrics}
        results.append(result)

    # 결과를 DataFrame으로 변환
    results_df = pd.DataFrame(results)

    # 샤프비율 기준 정렬 (다른 지표도 가능)
    results_df = results_df.sort_values('샤프비율', ascending=False)

    print("\n=== 최적 파라미터 ===")
    print(results_df.head(10))

    # 최적 파라미터 반환
    best_params = results_df.iloc[0][param_names].to_dict()

    return best_params, results_df

# 사용 예시
param_grid = {
    'short_window': [3, 5, 7, 10],
    'long_window': [15, 20, 25, 30]
}

# best_params, results = optimize_parameters('삼성전자', '2020-01-01', '2023-12-31', param_grid)

설명

이것이 하는 일: 이 함수는 여러분이 지정한 파라미터 범위 내에서 모든 가능한 조합을 자동으로 테스트하여, 최고의 샤프비율이나 수익률을 내는 최적 설정을 찾아줍니다. 마치 과학 실험에서 여러 조건을 바꿔가며 최상의 결과를 찾는 것과 같습니다.

첫 번째로, 파라미터 그리드 생성 단계입니다. itertools.product(*param_grid.values())는 파라미터들의 모든 조합(Cartesian Product)을 생성합니다.

예를 들어, short_window=[3, 5]long_window=[15, 20]이라면 (3,15), (3,20), (5,15), (5,20) 네 가지 조합이 만들어집니다. 이렇게 하는 이유는 Grid Search 방식으로 빠짐없이 모든 경우를 테스트하기 위함입니다.

그 다음으로, 메인 루프가 각 조합에 대해 백테스팅을 실행합니다. for combo in param_combinations로 순회하며, dict(zip(param_names, combo))로 조합을 딕셔너리 형태로 변환합니다(예: {'short_window': 5, 'long_window': 20}).

각 조합마다 run_backtest를 호출하여 실제 시뮬레이션을 돌리고, calculate_performance_metrics로 성과 지표를 계산합니다. 이 과정이 반복되면서 각 조합의 성능이 기록됩니다.

세 번째로, 결과 저장 및 정렬 단계입니다. {**params, **metrics}는 파라미터와 성과 지표를 하나의 딕셔너리로 합칩니다.

이를 리스트에 추가하여 나중에 DataFrame으로 변환합니다. results_df.sort_values('샤프비율', ascending=False)로 샤프비율 기준 내림차순 정렬하여, 가장 좋은 조합이 맨 위에 오도록 합니다.

샤프비율 대신 총 수익률이나 MDD 등 다른 지표로 정렬할 수도 있습니다. 네 번째로, 최적 파라미터 추출입니다.

results_df.iloc[0]로 1위 조합을 가져오고, [param_names].to_dict()로 파라미터 부분만 딕셔너리로 추출합니다. 이것이 최종적으로 여러분이 사용해야 할 최적 설정입니다.

results_df.head(10)으로 상위 10개 조합을 출력하여, 1위와 2위의 성능 차이가 크지 않다면 두 조합 모두 고려할 수 있습니다. 여러분이 이 코드를 사용하면 수동으로 며칠 걸릴 작업을 몇 시간 안에 자동으로 완료할 수 있습니다.

실무에서는 파라미터 조합이 수백~수천 개에 달하므로, 멀티프로세싱(multiprocessing)을 활용하여 병렬 처리하면 속도를 10배 이상 높일 수 있습니다. 또한 최적화 결과를 CSV로 저장하여 나중에 재사용하거나, 히트맵으로 시각화하면 파라미터 간 상호작용을 발견할 수 있습니다.

실전 팁

💡 과최적화 주의! In-Sample 기간(예: 2020-2022)에서 최적화하고, Out-of-Sample 기간(예: 2023)에서 검증하세요. In-Sample에서만 좋고 Out-of-Sample에서 나쁘면 과적합입니다.

💡 Grid Search는 완전 탐색이라 느립니다. 파라미터가 많으면 랜덤 서치나 베이지안 최적화(Optuna 라이브러리)를 사용하면 훨씬 빠릅니다.

💡 최적 파라미터가 극단값(범위의 끝)이라면 탐색 범위를 확장하세요. 예를 들어, long_window 최대가 30인데 최적값이 30이면 40, 50도 테스트해봐야 합니다.

💡 여러 지표를 종합 평가하려면 가중치 합을 만드세요: score = sharpe * 0.5 + (100 - mdd) * 0.3 + win_rate * 0.2처럼 커스텀 점수를 만들어 최적화하면 균형잡힌 전략을 찾을 수 있습니다.

💡 최적화 결과를 히트맵으로 그리면 파라미터 민감도를 파악할 수 있습니다: sns.heatmap(results_df.pivot('short_window', 'long_window', '샤프비율'))로 어떤 조합이 안정적으로 좋은지 시각화하세요.


9. Walk-Forward 분석 - 실전 성능 검증

시작하며

여러분이 최적화한 전략을 실전에 투입했는데, 백테스팅에서는 50% 수익이었던 것이 실제로는 손실이 나는 경험 있나요? 이것은 과최적화(Overfitting)의 전형적인 증상입니다.

이런 문제는 백테스팅의 치명적인 함정입니다. 과거 데이터에만 완벽하게 맞춰진 전략은 미래에는 작동하지 않습니다.

마치 시험 문제를 미리 알고 공부한 학생이 새로운 문제에는 대응하지 못하는 것과 같습니다. 실전 성능을 예측하는 방법이 필요합니다.

바로 이럴 때 필요한 것이 Walk-Forward 분석입니다. 데이터를 여러 구간으로 나누어 학습-검증을 반복하며, 미래 성능을 더 현실적으로 평가합니다.

개요

간단히 말해서, Walk-Forward 분석은 과거 데이터를 학습 기간(In-Sample)에서 최적화하고, 바로 다음 기간(Out-of-Sample)에서 검증하는 과정을 시간을 옮겨가며 반복하는 기법입니다. 기계학습의 시계열 교차검증과 유사합니다.

실무에서 Walk-Forward 분석은 과최적화를 방지하는 가장 신뢰받는 방법입니다. 단순히 전체 데이터로 최적화한 전략은 실전에서 실패할 확률이 높습니다.

예를 들어, 2020-2022년 데이터로 최적화한 전략을 2023년에 테스트하는 것이 아니라, 2020년으로 최적화→2021년 검증, 2021년으로 재최적화→2022년 검증... 이런 식으로 실전과 유사하게 진행합니다.

기존에는 한 번의 In-Sample/Out-of-Sample 분리만 했다면, 이제는 여러 시기에 걸쳐 반복 검증하여 전략의 안정성을 확인합니다. Walk-Forward 분석의 핵심 특징은 세 가지입니다.

첫째, 시간 순서를 지키며 미래 정보 유출을 완전히 차단합니다. 둘째, 여러 기간에서 성능을 검증하여 일관성을 확인합니다.

셋째, 파라미터를 주기적으로 재최적화하여 시장 변화에 적응합니다. 이러한 동적 검증이 실전 성공률을 높입니다.

코드 예제

from datetime import datetime, timedelta
import pandas as pd

def walk_forward_analysis(ticker, start_date, end_date, param_grid, train_months=12, test_months=3):
    # 날짜 변환
    current_start = pd.to_datetime(start_date)
    final_end = pd.to_datetime(end_date)

    results = []

    while current_start < final_end:
        # 학습 기간 설정
        train_end = current_start + pd.DateOffset(months=train_months)
        # 테스트 기간 설정
        test_start = train_end
        test_end = test_start + pd.DateOffset(months=test_months)

        if test_end > final_end:
            break

        print(f"\n=== Walk-Forward 구간 ===")
        print(f"학습: {current_start.date()} ~ {train_end.date()}")
        print(f"검증: {test_start.date()} ~ {test_end.date()}")

        # 학습 기간에서 파라미터 최적화
        best_params, _ = optimize_parameters(ticker, str(current_start.date()), str(train_end.date()), param_grid)

        # 테스트 기간에서 최적 파라미터로 백테스팅
        engine = run_backtest(ticker, str(test_start.date()), str(test_end.date()), initial_capital=10000000)
        metrics = calculate_performance_metrics(engine)

        # 결과 저장
        result = {
            'train_start': current_start,
            'train_end': train_end,
            'test_start': test_start,
            'test_end': test_end,
            'best_params': best_params,
            **metrics
        }
        results.append(result)

        # 다음 구간으로 이동 (슬라이딩 윈도우)
        current_start = test_start

    # 결과 종합
    results_df = pd.DataFrame(results)

    # 전체 Out-of-Sample 성과 계산
    avg_return = results_df['총_수익률(%)'].mean()
    avg_sharpe = results_df['샤프비율'].mean()
    avg_mdd = results_df['MDD(%)'].mean()

    print(f"\n=== Walk-Forward 종합 결과 ===")
    print(f"평균 OOS 수익률: {avg_return:.2f}%")
    print(f"평균 OOS 샤프비율: {avg_sharpe:.2f}")
    print(f"평균 OOS MDD: {avg_mdd:.2f}%")

    return results_df

설명

이것이 하는 일: 이 함수는 전체 데이터 기간을 여러 구간으로 나누어, 각 구간마다 "과거로 학습 → 미래로 검증"을 반복하며 실전과 유사한 환경에서 전략을 테스트합니다. 마치 실전에서 매년 파라미터를 재조정하며 운영하는 것을 시뮬레이션합니다.

첫 번째로, 구간 설정 로직입니다. train_months=12, test_months=3은 12개월 데이터로 학습하고 다음 3개월로 검증한다는 의미입니다.

pd.DateOffset(months=train_months)로 날짜를 이동시켜 학습 기간과 테스트 기간을 자동으로 계산합니다. 이렇게 하는 이유는 고정된 길이의 윈도우를 슬라이딩하며 여러 시기의 성능을 모두 평가하기 위함입니다.

그 다음으로, 메인 루프가 시간을 따라 전진합니다. while current_start < final_end로 전체 기간을 커버할 때까지 반복합니다.

각 반복마다 optimize_parameters를 호출하여 학습 기간 데이터로 최적 파라미터를 찾습니다. 예를 들어, 2020-2021년 데이터로 "short=5, long=20"이 최적이라고 찾았다면, 이 설정을 2022년 1분기에 적용하여 백테스팅합니다.

이것이 실전과 동일한 방식입니다. 실전에서는 과거 데이터로 파라미터를 정하고 미래에 적용하기 때문입니다.

세 번째로, Out-of-Sample 검증이 수행됩니다. run_backtest(ticker, test_start, test_end)로 최적화에 사용하지 않은 미래 데이터로 성능을 측정합니다.

이것이 핵심입니다. 학습 데이터와 검증 데이터가 완전히 분리되어, 과최적화된 전략은 여기서 나쁜 성능을 보입니다.

반대로 진짜 효과적인 전략은 Out-of-Sample에서도 좋은 성과를 냅니다. 네 번째로, 슬라이딩 윈도우 이동입니다.

current_start = test_start로 다음 학습 구간의 시작점을 현재 테스트 구간의 시작점으로 설정합니다. 이렇게 하면 시간이 겹치지 않으면서 앞으로 나아갑니다.

예를 들어, 첫 구간이 2020-2021 학습/2021 Q2 검증이었다면, 다음 구간은 2021 Q2-2022 Q2 학습/2022 Q3 검증이 됩니다. 마지막으로, 종합 결과 계산입니다.

여러 Out-of-Sample 구간의 평균 수익률, 샤프비율, MDD를 계산합니다. 이 평균 성능이 실전에서 기대할 수 있는 현실적인 수치입니다.

만약 In-Sample 최적화에서는 50% 수익이었는데 Walk-Forward 평균은 10%라면, 실전에서는 10% 정도를 기대해야 합니다. 여러분이 이 코드를 사용하면 과최적화를 90% 이상 걸러낼 수 있습니다.

실무에서는 Walk-Forward 성능이 In-Sample 성능의 70% 이상 나오면 양호한 전략으로 평가합니다. 예를 들어, In-Sample 샤프비율 2.0, Walk-Forward 평균 1.5면 실전에서도 유효할 가능성이 높습니다.

반대로 In-Sample 2.0, Walk-Forward 0.5라면 과적합이므로 폐기해야 합니다.

실전 팁

💡 학습 기간과 테스트 기간 비율은 보통 3:1 또는 4:1입니다. 학습이 너무 짧으면 최적화가 불안정하고, 테스트가 너무 짧으면 통계적 유의성이 떨어집니다.

💡 Anchored Walk-Forward도 고려하세요. 슬라이딩 대신 학습 시작점을 고정하고(current_start를 업데이트하지 않음) 학습 기간을 계속 늘리는 방식입니다. 더 많은 데이터로 학습하므로 안정적일 수 있습니다.

💡 각 Out-of-Sample 구간의 성능 분산이 크면 전략이 불안정한 것입니다. results_df['총_수익률(%)'].std()로 표준편차를 확인하고, 너무 크면 전략 개선이 필요합니다.

💡 Walk-Forward 결과를 시계열로 플롯하면 시간에 따른 성능 변화를 볼 수 있습니다. 최근으로 올수록 성능이 떨어진다면 전략이 시장 변화에 뒤처지고 있다는 신호입니다.

💡 계산 시간이 오래 걸리므로, 파라미터 그리드를 처음엔 넓고 거칠게(Coarse Grid) 설정하고, 유망한 범위를 찾은 후 세밀하게(Fine Grid) 재탐색하면 효율적입니다.


10. 슬리피지와 거래 비용 모델링 - 현실적인 백테스팅

시작하며

여러분이 백테스팅에서 연 50% 수익을 냈는데, 실전에서는 30%밖에 안 나오는 경험 있나요? 이 20% 차이는 어디서 왔을까요?

대부분 슬리피지와 거래 비용입니다. 이런 현실과 시뮬레이션의 괴리는 매우 흔합니다.

백테스팅에서는 "클릭하면 즉시 원하는 가격에 체결"이지만, 실전에서는 호가 스프레드, 체결 지연, 시장 충격(Market Impact) 등으로 가격이 불리하게 체결됩니다. 이를 반영하지 않으면 백테스팅은 공상에 불과합니다.

바로 이럴 때 필요한 것이 정교한 슬리피지 및 거래 비용 모델링입니다. 수수료, 호가 스프레드, 시장 충격을 현실적으로 반영하여 실전과 동일한 환경을 시뮬레이션합니다.

개요

간단히 말해서, 슬리피지와 거래 비용 모델링은 백테스팅에서 수수료, 세금, 호가 차이, 체결 지연 등 실전에서 발생하는 모든 비용을 정확히 계산하여 반영하는 과정입니다. 백테스팅을 현실에 가깝게 만드는 핵심 요소입니다.

실무에서 거래 비용을 제대로 모델링하지 않으면 백테스팅은 무의미합니다. 특히 고빈도 트레이딩이나 단기 전략일수록 거래 비용이 수익을 크게 잠식합니다.

예를 들어, 하루에 10번 거래하는 전략이라면 수수료만 0.3% * 10회 = 3%가 매일 나가므로, 연간으로는 엄청난 비용입니다. 슬리피지까지 고려하면 더욱 심각합니다.

기존에는 단순히 "수수료 0.3%"만 차감했다면, 이제는 호가 스프레드, 거래량 대비 시장 충격, 체결 확률 등 세밀한 모델을 적용합니다. 거래 비용 모델링의 핵심 특징은 세 가지입니다.

첫째, 수수료와 세금을 정확히 계산합니다(매수 0.015%, 매도 0.015% + 증권거래세 0.23% 등). 둘째, 호가 스프레드를 반영하여 매수는 매도호가, 매도는 매수호가로 체결됩니다.

셋째, 시장 충격을 모델링하여 큰 주문은 불리한 가격에 체결됩니다. 이러한 현실성이 백테스팅의 신뢰도를 결정합니다.

코드 예제

import numpy as np

class RealisticTradingCost:
    def __init__(self, commission_rate=0.00015, tax_rate=0.0023, spread_bps=10):
        # 수수료율 (한국 기준: 편도 0.015%)
        self.commission_rate = commission_rate
        # 증권거래세 (매도 시만 적용)
        self.tax_rate = tax_rate
        # 호가 스프레드 (basis points, 10bps = 0.1%)
        self.spread_bps = spread_bps / 10000

    def calculate_execution_price(self, order_type, market_price, order_size, daily_volume):
        # 호가 스프레드 적용
        if order_type == 'buy':
            # 매수는 매도호가(ask)로 체결 (불리)
            spread_adjusted_price = market_price * (1 + self.spread_bps / 2)
        else:  # sell
            # 매도는 매수호가(bid)로 체결 (불리)
            spread_adjusted_price = market_price * (1 - self.spread_bps / 2)

        # 시장 충격(Market Impact) 모델링
        # 거래량 대비 주문 크기가 클수록 가격이 더 불리하게 체결
        volume_ratio = order_size / max(daily_volume, 1)  # 0으로 나누기 방지
        impact = min(volume_ratio * 100, 0.01)  # 최대 1% 충격

        if order_type == 'buy':
            final_price = spread_adjusted_price * (1 + impact)
        else:
            final_price = spread_adjusted_price * (1 - impact)

        return final_price

    def calculate_total_cost(self, order_type, notional_value):
        # 수수료 계산
        commission = notional_value * self.commission_rate

        # 세금 계산 (매도 시만)
        tax = notional_value * self.tax_rate if order_type == 'sell' else 0

        total_cost = commission + tax

        return total_cost

# 백테스팅 엔진에 통합
def execute_trade_with_costs(engine, ticker, quantity, market_price, timestamp, daily_volume, cost_model):
    order_type = 'buy' if quantity > 0 else 'sell'
    order_size = abs(quantity * market_price)

    # 실제 체결가 계산 (슬리피지 포함)
    execution_price = cost_model.calculate_execution_price(order_type, market_price, order_size, daily_volume)

    # 거래 비용 계산
    notional = abs(quantity * execution_price)
    transaction_cost = cost_model.calculate_total_cost(order_type, notional)

    # 총 비용 = 주식 대금 + 수수료/세금
    total_cost = quantity * execution_price + transaction_cost

    print(f"[{timestamp}] {order_type.upper()}: {abs(quantity)}주")
    print(f"  시장가: {market_price:,}원, 체결가: {execution_price:,.0f}원 (슬리피지: {(execution_price/market_price-1)*100:.3f}%)")
    print(f"  거래비용: {transaction_cost:,.0f}원")

    # 잔고 업데이트
    if engine.cash >= total_cost:
        engine.cash -= total_cost
        engine.positions[ticker] = engine.positions.get(ticker, 0) + quantity
        return True
    return False

설명

이것이 하는 일: 이 시스템은 실전 거래에서 발생하는 모든 비용 요소를 수학적으로 모델링하여, 백테스팅 시뮬레이션에서 "종가 클릭 = 즉시 체결"이라는 비현실적 가정을 제거합니다. 마치 실제 증권사 HTS를 통해 거래하는 것처럼 정확하게 재현합니다.

첫 번째로, RealisticTradingCost 클래스의 초기화입니다. commission_rate=0.00015는 한국 증권사의 표준 수수료율 0.015%입니다.

tax_rate=0.0023은 매도 시 부과되는 증권거래세 0.23%입니다(2024년 기준). spread_bps=10은 호가 스프레드를 10bp(0.1%)로 가정합니다.

대형주는 보통 5-10bp, 소형주는 20-50bp 정도입니다. 이 값들을 종목 특성에 맞게 조정하는 것이 중요합니다.

그 다음으로, calculate_execution_price 메서드가 실제 체결가를 계산합니다. 매수 주문은 market_price * (1 + spread/2)로 매도호가에 체결되고, 매도 주문은 market_price * (1 - spread/2)로 매수호가에 체결됩니다.

왜냐하면 실전에서는 시장가 매수 시 매도호가를 가져가고, 시장가 매도 시 매수호가를 받기 때문입니다. 이것만으로도 왕복 0.1%의 비용이 발생합니다.

세 번째로, 시장 충격(Market Impact) 모델링입니다. volume_ratio = order_size / daily_volume로 주문 크기가 일일 거래량의 몇 퍼센트인지 계산합니다.

예를 들어, 일일 거래량이 1억원인 종목에 1천만원 주문을 내면 10%입니다. 이렇게 큰 주문은 시장에 충격을 주어 가격을 밀어올립니다(매수) 또는 끌어내립니다(매도).

impact = min(volume_ratio * 100, 0.01)로 최대 1%까지 불리한 가격에 체결되도록 합니다. 대형주나 소량 거래는 영향이 적지만, 소형주 대량 거래는 슬리피지가 클 수 있습니다.

네 번째로, calculate_total_cost로 수수료와 세금을 계산합니다. 매수는 수수료만, 매도는 수수료 + 거래세를 부과합니다.

예를 들어, 1천만원어치 매도 시 수수료 1,500원 + 거래세 23,000원 = 24,500원의 비용이 발생합니다. 이것은 왕복 시 약 0.5%에 해당하며, 단기 트레이딩에서는 매우 큰 비중입니다.

마지막으로, execute_trade_with_costs 함수가 모든 요소를 통합합니다. 시장가와 실제 체결가의 차이(슬리피지), 거래 비용을 모두 계산하여 로그로 출력합니다.

이 정보를 보면 "시장가 70,000원인데 실제로는 70,150원에 체결되어 0.214% 슬리피지 발생"


#Python#Backtesting#AlgoTrading#QuantStrategy#RiskManagement#python

댓글 (0)

댓글을 작성하려면 로그인이 필요합니다.