문제: 손실이 없는데 손절 모드였다
equity_peak의 정의:
- 계좌 자산이 지금까지 찍은 최고점 기록
- 여기서 얼마나 떨어졌는지(MDD, Max Drawdown) 계산
- 일정 이상 하락 시 신규 매수 차단
그런데 실제 저장 상태는 달랐다.
- 저장 단위: 종목별 전략 인스턴스마다 따로
- 원인: 종목마다 전략 객체 생성 시점이 상이 → 그 시점의 잔고를 각자 자기 peak으로 채택
2026년 7월 30일 실측 — 운용 중인 46개 종목의 peak 값이 네 갈래로 분열.
| peak 값 | 종목 수 | 시점 |
|---|---|---|
| 최고 (기준 100%) | 7 | A |
| 최고 대비 -0.1% | 24 | B ← 차단 발동 기준 |
| 최고 대비 -15.4% | 11 | C |
| 최고 대비 -78.3% | 4 | 초기 자산(전부 ETF) |
- 계산 기준: 가장 오래되고 낮은 값 → MDD -18.4%
- 결과: 신규 매수 전면 차단
그런데 실제로는 수익 중이었다
| 지표 | 값 |
|---|---|
| 1년 실현손익 | 플러스 |
| 승률 | 62.1% (18승 11패) |
| Profit Factor | 4.27 |
| 대출·미수 | 없음 |
- 손절 로직의 판단 근거: 실제 자산 흐름이 아니라 네 갈래로 오염된 기준값 중 가장 낮은 것
시스템은 잘못된 결론에 아주 논리적으로 도달한 셈이다.
원인을 어떻게 찾았나
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개 값을 계좌 단위로 통합
→ A (기존 값: A, B, C, D — 가장 높은 A 채택)
계좌 peak : A
종목에 남은 peak : 0 (0이어야 함)
잔여 상태 : consecutive_losses / cooldown_remaining / peak_price 보존
테스트 결과:
- 통과 199건, 그중 신규 작성 31건
- 신규 테스트 항목
- 분열 재현
- 최댓값 선택 로직
- 마이그레이션의 멱등성(여러 번 실행해도 결과 동일)
- 계좌 키가 종목 상태와 격리되는지
- 복원 후 peak이 단일 값으로 유지되는지
일부러 하지 않은 것
- 이 작업은 상태 구조만 수정. 차단 자체는 미해제
- 이유 — 통합된 peak 값(가장 높은 A 시점 값)이 실제로 있었던 자산인지, 집계 오류가 섞인 값인지 이 시점에 미확정
-
그 판단과 차단 해제는 완전히 별개의 문제로 분리
- 수정 이후: peak 재분열 가능성 없음
- 자산이 실제로 줄어드는 상황(예: 출금)에서도 계좌 단위 단일 값으로 일관 관리
남는 교훈
이 버그는 처음부터 “계산이 틀렸다”가 아니라 “상태를 누가 갖고 있어야 하는가”를 잘못 설계해서 생겼다. 로직 자체는 각 조각이 다 말이 됐다 — 전략은 종목별로 존재하고, 생성 시점 잔고를 시작점으로 잡는 것도 자연스럽다. 문제는 “계좌 전체의 최고점”이라는, 본질적으로 전역(global)인 값을 지역(local) 상태에 얹은 것뿐이었다.
시스템이 틀린 답을 낼 때 항상 로직부터 의심하게 되는데, 이번 경우엔 로직보다 한 단계 아래 — 그 로직이 참조하는 상태가 애초에 누구 것이었는지 — 를 봐야 했다.