📚 토스 자동매매 개발기 시리즈 (Phase 4 / 전체 13편)
- #0 · Phase 0 — 기반 시스템 구축
- #0-1 · Phase 0-1 — 통화 단위 버그 수정
- #3 · Phase 1 — 설정 분리 및 재시도 로직
- #4 · Phase 1-2 — 재시도 로직 후속 보완
- #5 · Phase 1-3 — pytest 테스트 도입
- #6 · Phase 1-4 — 로그 로테이션
- #7 · Phase 2-1 — 전략 클래스 추상화
- #8 · Phase 2-2 — 복수 종목 감시 지원
- #9 · Phase 3-1 — 데이터 영속성 설계
- #10 · Phase 3-2 — DB 모듈 구현
- #11 · Phase 3-3 — DB 연동 통합
- #12 · Phase 2-3 — 전략 플러그인 인터페이스
- #13 · Phase 4 — 백테스트 엔진 설계 (현재 글)
💡 이번 Phase 4 요약
- 목적: 전략 코드를 건드리지 않고 가격 리스트만으로 동작하는 백테스트 엔진을 붙여서, 승률·손익비·MDD를 자동 계산한다.
- 핵심 포인트: 데이터 출처(자체 축적/외부/가상)와 백테스팅 로직을 분리하고, 매도 규칙(익절/손절)은
strategy.py가 아니라backtest.py가 전담하도록 설계했다. - 검증:
test_backtest.py13개 신규 작성, 전체 pytest 71개 전부 통과.
Phase 0부터 Phase 3까지 기반·안정성·전략 모듈화·데이터 영속성을 순서대로 다졌으니, 로드맵상 다음은 백테스팅이었다. 그런데 막상 시작하려니 첫 질문부터 걸렸다. “과거 시세는 대체 어디서 가져오지?”
데이터 출처와 백테스팅 로직을 분리하기로 했다
토스증권 Open API에 과거 시세 조회 엔드포인트가 있는지부터 확인해야 했는데, 이건 다음 세션으로 미뤘다. 지금까지 쌓인 price_snapshots 테이블도 실행 이력이 짧아서 아직 백테스트용 데이터로 쓰기엔 부족했다.
그래서 결론을 냈다. backtest.py는 데이터가 어디서 왔는지 신경 쓰지 않는다. list[float] 형태의 가격 리스트만 받으면, 그게 자체 축적 데이터든 외부 공개 데이터든 테스트용 가상 데이터(005930, 000660 같은 가상 종목코드로 마스킹)든 똑같이 동작한다.
def run_backtest(strategy, prices: list[float], take_profit=0.05, stop_loss=0.03):
trades = []
i = 0
while i < len(prices):
if strategy.should_buy(prices[:i+1]):
entry_price = prices[i]
exit_i, reason = _find_exit(prices, i, entry_price, take_profit, stop_loss)
trades.append({
"entry": entry_price,
"exit": prices[exit_i],
"reason": reason, # "take_profit" | "stop_loss" | "period_end"
})
i = exit_i + 1
else:
i += 1
return trades
strategy.py는 그대로, 매도 규칙만 새로 만들었다
기존 strategy.py의 should_buy()는 “언제 살지”만 판단한다. 실전 매매에서는 매수 후 해당 종목 감시를 바로 종료하는 구조라 문제가 없었는데, 백테스팅에서 승률·손익비를 계산하려면 반드시 매도 시점이 있어야 했다.
전략 클래스를 건드리는 대신 config.py에 익절 +5%, 손절 -3% 기준을 추가하고, 매도 판단은 backtest.py가 전담하도록 설계했다. 전략은 매수 신호만 책임지고, 청산은 백테스트 엔진이 책임지는 구조다.
승률·손익비·MDD 계산 — compute_metrics()
거래 리스트가 나오면 compute_metrics(trades)가 승률, 손익비(평균이익/평균손실), 최대낙폭(MDD)을 계산한다. 손실 거래가 하나도 없는 경우 손익비를 억지로 무한대나 0으로 두지 않고 None으로 명확히 구분했다. 나중에 리포트에서 “손실 거래 없음”과 “손익비 0″을 헷갈릴 여지를 아예 없앤 것.
백테스트 엔진은 가격 리스트만 받아 매수·매도를 시뮬레이션하고 승률·손익비·MDD를 계산하는 구조로, 전략 클래스와 매도 규칙을 분리 설계하면 기존 코드를 건드리지 않고도 백테스팅을 붙일 수 있다.
테스트 13개 중 1개가 실패했는데, 원인은 코드가 아니었다
test_backtest.py를 13개 작성해서 처음 pytest를 돌렸을 때 test_stop_loss_trigger가 FAILED로 떨어졌다. 로직 버그인 줄 알고 run_backtest 내부를 한참 들여다봤는데, 원인은 테스트 데이터 설계 실수였다. 손절 이후에도 가격이 여전히 매수 기준가 이하로 남아 있어서, 엔진이 정상적으로 재매수를 발생시킨 것뿐이었다. 테스트 쪽 가격 시퀀스를 수정하니 바로 통과했다.
전체 재실행 결과 기존 58개 + 신규 13개, 총 71개 전부 통과했고 안전장치 값도 변경되지 않은 걸 확인했다. 가상의 30일치 시세로 실제 백테스트를 돌려보니 거래 4건(익절 3, 손절 1), 승률 75%, 손익비 1.90, MDD 3.12%가 리포트로 나왔다.
남겨둔 한계 한 가지
내부 상태(가격 이력)를 갖는 MovingAverageStrategy는 문제가 하나 있다. 매도 후 다음 구간으로 건너뛰는 백테스트 루프 구조상, 건너뛴 구간이 이동평균 계산에 반영되지 않는다. 무상태인 ThresholdBuyStrategy는 문제없지만, 이건 코드 주석으로 명확히 남겨뒀다. 다음에 손댈 때 또 헤매지 않도록.
다음 단계
Phase 4의 3개 항목(과거 시세 수집, 백테스트 시뮬레이션, 결과 리포트) 중 시뮬레이션과 리포트는 끝났다. 남은 건 토스증권 Open API에 과거 시세(일봉/분봉) 조회 엔드포인트가 있는지 확인하는 것. 있다면 그게 가장 정확한 데이터 소스니까 우선 채택하고, backtest.py로 전략별 성과를 실제로 비교해볼 계획이다.
“가격 리스트 하나로 승률까지: 하이브리드 백테스트 엔진 만들기 | 토스 자동매매 개발기 #13 (Phase 4)”에 대한 3개의 생각