문제: 손실이 없는데 손절 모드였다
자동매매 시스템에 equity_peak이라는 값이 있다. 계좌 자산이 지금까지 찍은 최고점을 기록해두고, 여기서 얼마나 떨어졌는지(MDD, Max Drawdown)를 계산해서 일정 이상 빠지면 신규 매수를 막는 용도다.
그런데 실제로는 이 값이 종목별 전략 인스턴스마다 따로 저장되고 있었다. 종목마다 전략 객체가 생성되는 시점이 다르니, 그 시점의 잔고를 각자 자기 peak으로 잡아버린 것이다.
2026년 7월 30일 실측해보니, 운용 중인 46개 종목의 peak 값이 네 갈래로 갈라져 있었다.
| peak 값 | 종목 수 | 시점 |
|---|---|---|
| 6,527,996 | 7 | A |
| 6,522,996 | 24 | B ← 차단 발동 기준 |
| 5,525,481 | 11 | C |
| 1,414,195 | 4 | 초기 자산(전부 ETF) |
가장 오래되고 낮은 값을 기준으로 MDD가 계산되면서 -18.4%로 잡혔고, 신규 매수가 전면 차단됐다.
그런데 실제로는 수익 중이었다
차단이 걸렸다고 해서 실제로 손실이 나고 있었던 건 아니었다.
| 지표 | 값 |
|---|---|
| 1년 실현손익 | +231,698원 |
| 승률 | 62.1% (18승 11패) |
| Profit Factor | 4.27 |
| 대출·미수 | 0원 |
손절 로직이 “지금 큰 손실 중”이라고 판단한 근거는 실제 자산 흐름이 아니라, 네 갈래로 오염된 기준값 중 가장 낮은 것이었다. 시스템은 잘못된 결론에 아주 논리적으로 도달한 셈이다.
원인을 어떻게 찾았나
realtime_engine.py의 전략 생성 로직을 보면, 전략은 종목별로 개별 생성되고 각자 생성 시점의 잔고를 peak으로 잡는다. 상태를 저장했다가 복원하는 import_state는 저장된 옛 peak를 그대로 우선시하도록 되어 있었다(코드 주석에도 그렇게 명시돼 있었다). 종목별 생성 시점이 다르니 값이 갈라지고, 한 번 갈라지면 영구히 고착되는 구조였다.
즉 버그는 로직 자체의 계산 실수가 아니라, “이 값을 누가 소유하는가”를 잘못 설계한 것이었다. equity_peak은 계좌 전체의 개념인데 종목 단위 상태에 얹혀 있었다.
어떻게 고쳤나
- 계좌 단위 상태를 분리했다. 상태 저장소에
__account__라는 예약 키를 만들었다(종목 코드는 숫자·영문 조합이라 이 키와 충돌하지 않는다). - 기존에 갈라진 데이터는 1회성으로 마이그레이션했다. 여러 값 중 하나를 골라야 하는데, 가장 낮은 값이 아니라 가장 높은 값을 택했다. peak는 정의상 최고점이고, 낮은 값을 고르면 MDD를 실제보다 작게 계산해 위험 방어가 느슨해지기 때문이다. 보수적인 쪽을 택한 것.
export_state에서 equity_peak을 제거하고export_account_state로 분리했다. 이걸 안 하면 종목 파일로 값이 다시 새어나가 같은 분열이 재발할 수 있었다.get_restore_state()가 종목별 상태와 계좌 상태를 합쳐서 반환하도록 했다. 호출하는 쪽에서 따로 두 상태를 합치게 두면 실수로 빠뜨릴 수 있는데, 실제로 테스트 작성 중에 이 문제가 재현됐다.
마이그레이션은 프로덕션 상태 파일 사본으로 먼저 실행해서 결과를 확인했다.
[StrategyState] 종목별 equity_peak 46종목/4개 값을 계좌 단위로 통합
→ 6527996 (기존 값: 6,527,996, 6,522,996, 5,525,481, 1,414,195)
계좌 peak : 6,527,996
종목에 남은 peak : 0 (0이어야 함)
잔여 상태 : consecutive_losses / cooldown_remaining / peak_price 보존
테스트는 199건이 통과했고, 그중 31건이 이번에 새로 작성한 것이다. 분열 재현, 최댓값 선택 로직, 마이그레이션의 멱등성(여러 번 실행해도 결과가 같은지), 계좌 키가 종목 상태와 격리되는지, 복원 후 peak이 단일 값으로 유지되는지를 각각 테스트로 남겼다.
일부러 하지 않은 것
이 작업은 상태 구조만 고쳤다. 차단 자체를 풀지는 않았다. 통합된 peak 값 6,527,996이 실제로 있었던 자산인지, 아니면 어딘가 집계 오류가 섞인 값인지는 이 시점에 확정하지 못했기 때문이다. 그 판단과 차단 해제는 완전히 별개의 문제로 남겨뒀다.
다만 이 수정 이후로는 peak이 다시 갈라질 일이 없고, 실제로 자산이 줄어드는 상황(예: 출금)에서도 계좌 단위의 단일 값으로 일관되게 관리된다.
남는 교훈
이 버그는 처음부터 “계산이 틀렸다”가 아니라 “상태를 누가 갖고 있어야 하는가”를 잘못 설계해서 생겼다. 로직 자체는 각 조각이 다 말이 됐다 — 전략은 종목별로 존재하고, 생성 시점 잔고를 시작점으로 잡는 것도 자연스럽다. 문제는 “계좌 전체의 최고점”이라는, 본질적으로 전역(global)인 값을 지역(local) 상태에 얹은 것뿐이었다.
시스템이 틀린 답을 낼 때 항상 로직부터 의심하게 되는데, 이번 경우엔 로직보다 한 단계 아래 — 그 로직이 참조하는 상태가 애초에 누구 것이었는지 — 를 봐야 했다.