Home > AI > AI-Pairing > 종목마다 따로 놀던 최고점이 멀쩡한 계좌를 손절 모드로 밀어넣었다

종목마다 따로 놀던 최고점이 멀쩡한 계좌를 손절 모드로 밀어넣었다
AI Claude Code 자동매매 트러블슈팅

문제: 손실이 없는데 손절 모드였다

자동매매 시스템에 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은 계좌 전체의 개념인데 종목 단위 상태에 얹혀 있었다.

어떻게 고쳤나

  1. 계좌 단위 상태를 분리했다. 상태 저장소에 __account__라는 예약 키를 만들었다(종목 코드는 숫자·영문 조합이라 이 키와 충돌하지 않는다).
  2. 기존에 갈라진 데이터는 1회성으로 마이그레이션했다. 여러 값 중 하나를 골라야 하는데, 가장 낮은 값이 아니라 가장 높은 값을 택했다. peak는 정의상 최고점이고, 낮은 값을 고르면 MDD를 실제보다 작게 계산해 위험 방어가 느슨해지기 때문이다. 보수적인 쪽을 택한 것.
  3. export_state에서 equity_peak을 제거하고 export_account_state로 분리했다. 이걸 안 하면 종목 파일로 값이 다시 새어나가 같은 분열이 재발할 수 있었다.
  4. 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) 상태에 얹은 것뿐이었다.

시스템이 틀린 답을 낼 때 항상 로직부터 의심하게 되는데, 이번 경우엔 로직보다 한 단계 아래 — 그 로직이 참조하는 상태가 애초에 누구 것이었는지 — 를 봐야 했다.