만들면서 배운 것

백테스트 뜻, 제가 실제 돌리는 코드 구조로 설명해봤습니다 🧮

생각에 잠긴 초록이 아빠

“백테스트”를 검색하면 “과거 데이터로 전략을 미리 돌려보는 것”이라는 설명까지는 금방 나옵니다.

그런데 그 안에서 코드가 실제로 뭘 계산하는지 보여주는 글은 잘 없더라고요. 저도 처음엔 그냥 “옛날 데이터 넣고 결과 보는 거겠지” 정도로만 알았습니다.

지난 빌드로그에서 자동매매를 만들며 신호 대부분이 함정이었다는 이야기를 했는데요. 그 판단 기준으로 쓴 게 바로 이 백테스트였습니다. 오늘은 그 판단 기준 자체, 그러니까 코드 안에서 뭘 어떻게 계산하는지를 그대로 풀어보려고 합니다.

처음 버전엔 세 가지가 빠져 있었다

제가 만든 백테스트 코드 맨 위 주석에 이렇게 적혀 있습니다.

“v2 변경: ① 레짐 필터 ② 주간 리밸런싱 ③ 유니버스 정제(밈·스테이블·랩드 제외)”

버전을 나눠 적어뒀다는 건, 처음 버전(v1)엔 이 세 가지가 없었다는 뜻이죠. 그리고 없어서 문제가 됐다는 뜻이기도 하고요.

레짐 필터가 없으면 어떻게 될까요. 하락장에서도 “상대적으로 덜 떨어진” 자산을 계속 사 모으게 됩니다. 상대강도만 보면 하락장에서도 순위는 매길 수 있으니까요. 그래서 기준 자산이 이동평균선 아래로 내려가면 전량 현금으로 도망가는 규칙을 추가했습니다.

유니버스 정제도 마찬가지입니다. “시가총액 상위”만 기준으로 종목을 뽑으면 스테이블코인이나 랩드코인처럼 가격이 거의 안 움직이는 자산이 섞여 들어옵니다. 이런 자산이 껴 있으면 상대강도 계산 자체가 왜곡되거든요. 그래서 이런 자산군을 미리 제외 목록에 넣어뒀습니다.

코드가 실제로 계산하는 것

여기서부터가 진짜입니다. 파라미터를 코드 맨 위에 상수로 몰아뒀어요.

LOOKBACK     = 20      # 상대강도 계산 기간(일)
TOP_K        = 4       # 동시 보유 종목 수 (동일비중)
TP           = 0.15    # 익절 +15%
SL           = 0.07    # 손절 -7%
REBAL_DAYS   = 7        # 주간 리밸런싱
REGIME_MA    = 100     # 기준 자산 종가가 이 이동평균선 위일 때만 진입
FEE          = 0.0005
SLIPPAGE     = 0.0005

진입 조건은 단순합니다. 유니버스 안에서 기준 자산 대비 상대강도가 높은 상위 4개를 골라 동일 비중으로 들어갑니다. 단, 기준 자산 종가가 이동평균 100일선 위에 있을 때만요. 아래에 있으면 아예 진입하지 않고 현금으로 대기합니다.

청산 조건은 매일 먼저 체크합니다. 손절 조건을 익절보다 먼저 확인하도록 순서를 정해뒀어요. 하루 안에 손절가와 익절가를 둘 다 건드리는 날이 생기면, 어느 쪽을 먼저 인정할지 규칙이 있어야 결과가 매번 똑같이 재현되니까요.

if lo <= e*(1-SL): sell(m, day, e*(1-SL), "SL")
elif hi >= e*(1+TP): sell(m, day, e*(1+TP), "TP")

손절·익절에 안 걸린 종목은 일주일마다 다시 상대강도 순위를 매겨서, 상위 4개 밖으로 밀려나면 정리하고 새로 들어온 종목으로 교체합니다.

그리고 비용. 이 부분을 처음엔 가볍게 봤는데, 넣고 안 넣고에 따라 결과가 꽤 갈립니다. 진입·청산 가격에 슬리피지 0.05%를 먼저 반영하고, 그 위에 수수료 0.05%를 한 번 더 뗍니다. 진입 4번·청산 4번이 주 단위로 반복되면 이 0.1%가 쌓여서 결코 무시할 수 없는 크기가 됩니다.

eff = price*(1+SLIPPAGE); spend = min(alloc, cash)
fee = spend*FEE; qty = (spend-fee)/eff

비용을 아예 안 넣고 돌리면 결과가 실제보다 좋게 나옵니다. 진입·청산 횟수가 많은 전략일수록 이 착시가 커지고요. 그래서 비용 반영 여부가 백테스트 결과를 볼 때 제일 먼저 확인해야 할 항목입니다.

개념도 — 진입→보유→청산 흐름과 매 거래에 붙는 비용

결과는 이렇게 확인한다

한 번 돌리면 리포트 파일이 하나 생깁니다. 기간, 벤치마크 대비 결과, 최대 낙폭(MDD), 샤프지수, 승률, 총 매매 건수, 현금으로 대기한 기간의 비율까지 표로 정리해서요.

숫자 하나만 보고 판단하면 안 되는 이유가 여기 있습니다. 예를 들어 승률이 높아도 현금 대기 비율이 낮으면 그만큼 계속 진입·청산을 반복했다는 뜻이라 비용이 그만큼 더 쌓였다는 뜻이고, 현금 대기 비율이 높으면 애초에 규칙이 별로 작동을 안 했다는 뜻일 수도 있습니다. 지표는 따로따로가 아니라 같이 봐야 의미가 생깁니다.

한계도 그대로 적어둔다

같은 구조로 만든 미국 주식용 백테스트 코드에는 이런 주석이 있습니다.

“유니버스=현 대형주 약 50 (⚠️ 생존편향)”

지금 살아남아서 대형주가 된 종목만 유니버스에 넣었다는 뜻입니다. 그때는 대형주가 아니었다가 지금은 사라진 종목은 애초에 빠져 있어서, 결과가 실제보다 좋게 나올 수밖에 없는 구조적 한계입니다.

이걸 굳이 코드 주석에 남겨둔 이유는, 나중에 결과만 보고 착각하지 않으려고요. 백테스트는 결과를 자랑하는 도구가 아니라 조건을 하나씩 빼먹었을 때 얼마나 착시가 커지는지 확인하는 도구니까요.

백테스트는 얼마나 잘 맞았는지 보여주는 도구가 아니라, 비용과 조건을 빼먹었을 때 착시가 얼마나 커지는지 보여주는 도구다.


따라 하기

똑같이 돌려보고 싶다면 이렇게 하면 됩니다.

  1. 코드를 그대로 실행한다
cd <프로젝트 폴더>
python3 backtest.py

외부 라이브러리를 하나도 안 깔아도 됩니다. API 호출을 표준 라이브러리(urllib)만으로 짜뒀거든요. 의존성이 늘어날수록 “이거 왜 안 돌아가지”로 밤을 새울 확률도 늘어나서, POC 단계에선 일부러 이렇게 짰습니다.

  1. 파라미터만 바꿔가며 실험한다
TOP_K = 4   # 보유 종목 수를 바꾸고 싶으면 여기만
SL = 0.07   # 손절 폭을 바꾸고 싶으면 여기만

코드 흐름은 안 건드리고 맨 위 상수만 바꾸는 구조로 짜뒀습니다. 실험할 때마다 로직 코드를 뒤지지 않아도 되게 하려고요.

  1. 결과를 확인한다
open report.html

지표 표와 자산 곡선 그래프가 브라우저로 바로 뜹니다. 매매 내역도 한 줄씩 다 남아 있어서, 특정 날짜에 왜 그런 판단을 했는지 거슬러 올라가 볼 수 있습니다.

새벽에 초록이 재우고 나서 노트북을 켜고 데이터를 받아올 때가 많았는데요. API 호출마다 0.12초씩 쉬어가게 짜놔서 1000일치 받는 데 생각보다 시간이 걸립니다. 그 사이 커피 한 잔 마시면서 코드를 한 번 더 읽어보는 게 습관이 됐어요.

뿌듯해하는 초록이 아빠

오늘도 한 걸음. 🐴

이 글은 산업 구조와 만드는 과정을 정리한 개인 기록입니다.
특정 종목의 매수·매도 추천이 아니며, 투자 판단과 그 결과는 독자 본인에게 있습니다.

← 글 목록으로