평가손익 기준 손실 한도 자동 정지 구현 | 토스 자동매매 개발기 #20

어제 1회 주문 한도 방어 로직을 끝내고 나서, 오늘은 Phase 7의 두 번째 준비 항목인 “누적 손실 한도 자동 정지”를 붙였다. 이번엔 코드보다 설계 결정에 시간을 더 썼다.

🌐 Read in English

💡 이번 Phase 7(2차) 요약
  • 목적: 계좌 전체 평가손익률이 설정 한도(기본 -10%)를 넘으면 신규 매수 감시를 자동으로 멈추게 만들었다.
  • 핵심 포인트: 실현손익 기록이 아직 없는 구조라, API에서 이미 받아오던 평가손익(profitLoss.rate)을 기준으로 삼았고 계산 기간은 자정 리셋 없이 누적으로 감시하도록 했다.
  • 검증: test_risk_control.py 7개 신규 + 기존 테스트 갱신, pytest 전체 131개 통과.

실현손익이 아니라 평가손익을 쓴 이유

손실 한도를 만들기 전에 제일 먼저 막힌 지점은 “손실을 뭘로 잴 것인가”였다. 보통 리스크 관리라고 하면 매도해서 확정된 실현손익을 기준으로 생각하기 쉬운데, 지금 이 봇의 구조는 “매수 후 감시 종료”라서 애초에 매도 이력을 남기는 테이블이 없다. 새로 실현손익 트래킹을 만들 수도 있었지만, 그러면 오늘 하려던 손실 한도 기능보다 데이터 모델 확장이 먼저 필요해져서 범위가 커진다.

대신 토스증권 API 응답에 이미 들어있는 profitLoss.rate(평가손익률)를 그대로 활용하기로 했다. 계좌 전체 보유 종목의 평가손익률을 합산해서 한도(기본 -10%)를 넘는지만 확인하면 되니 별도 데이터 모델 없이 바로 구현할 수 있었다.

계산 기간: 자정 리셋 없는 누적 방식

두 번째 결정은 “이 손실을 언제 기준으로 다시 계산할 것인가”였다. 하루 단위로 리셋해서 “오늘 하루 -10%”처럼 볼 수도 있었지만, 리셋 로직을 넣으려면 타임존 처리와 스케줄 경계값 테스트가 따라온다. 지금 단계에서는 그 복잡도를 감수할 이유가 없다고 판단해서, 그냥 계좌 전체를 계속 누적으로 감시하는 쪽으로 갔다. 단순한 만큼 놓치는 경우도 적다.

risk_control.py — check_and_enforce_loss_limit()

신설한 risk_control.py의 핵심 함수는 check_and_enforce_loss_limit()이다. 계좌 평가손익률을 확인해서 한도를 넘으면 Phase 5에서 이미 만들어둔 engine_control.pause()를 호출해 감시 루프(신규 매수 판단)만 멈춘다.

여기서 한 가지 설계 원칙은 확실히 하고 넘어갔다. 이 모듈은 절대 매도를 실행하지 않는다. 기존 보유 종목을 팔지 말지는 여전히 사람이 직접 판단해야 하는 영역으로 남겨뒀다. 자동화가 할 일은 “더 사지 못하게 막는 것”까지고, 파는 결정까지 자동화하면 책임 범위가 완전히 달라진다고 봤다.

이미 일시정지된 상태에서 또 한도를 넘는 걸로 판정되는 경우, 중복으로 알림을 보내지 않도록 상태 체크를 넣었다. 안 그러면 30분마다 똑같은 텔레그램 알림이 계속 울린다.

scheduler.py 통합 — API 중복 호출 피하기

손실 점검을 어디서 돌릴지도 고민이었다. 실시간 매수 감시 루프인 auto_trader.py에 넣을 수도 있었지만, 이미 30분마다 전체 계좌를 스캔하는 scheduler.py에 넣는 게 관심사 분리 측면에서 더 맞다고 봤다.

여기서 리팩터링이 하나 필요했다. 기존 toss_portfolio.collect_price_snapshots()는 API 조회와 스냅샷 저장을 한 함수 안에서 같이 처리하고 있었다. 스케줄러가 같은 API 조회 결과를 스냅샷 저장과 손실 한도 점검 양쪽에서 재사용하려면, “저장” 부분만 순수 함수로 분리할 필요가 있었다. 그래서 store_snapshots_from_holdings()를 새로 빼내고, collect_price_snapshots()는 그 안에서 새 함수를 호출하는 얇은 래퍼로 남겼다. 이렇게 하면 API를 두 번 부르지 않고도 한 번의 스캔 결과를 두 군데서 쓸 수 있다.

알림 전송 로직도 새로 만들지 않았다. 일시정지가 걸리면 Phase 5에서 만든 auto_trader.py의 일시정지 감지 로직이 다음 감시 주기에 알아서 텔레그램 알림을 보내주기 때문에, 기존 인프라를 그대로 재사용하면 됐다.

검증 — pytest 131개 전부 통과

test_risk_control.py에 신규 7개 테스트를 추가했다. 한도 이내/초과 경계값, alerts 테이블 기록 여부, 이미 일시정지된 상태에서의 중복 알림 방지, 데이터가 없거나 rate 필드가 없는 경우까지 다뤘다.

test_scheduler.py도 새 구조에 맞게 갱신해서, fetch_holdings()가 정확히 1회만 호출되고 같은 데이터가 스냅샷 저장과 손실 점검 양쪽에서 재사용되는지 확인했다. test_config.py에는 손실 한도 값 자체가 안전 범위(1%~50%) 안에 있는지 보는 회귀 방지 테스트도 하나 추가했다.

리팩터링을 거쳤는데도 기존 collect_price_snapshots 관련 테스트는 시그니처와 반환값 변경 없이 그대로 통과했다. 전체 pytest를 다시 돌려서 131개 전부 통과(기존 122개 + 신규 9개)를 확인했다.

5중 안전장치가 됐다

기존 4중 안전장치(1회 주문 한도, DRY_RUN 시뮬레이션, 목표가 1원, 실제 주문 API 물리적 주석 처리)에 오늘 만든 누적 손실 한도 자동 정지가 더해져 5중 구조가 됐다.

단계내용
0단계1회 주문 한도 초과 시 즉시 거부
신규누적 평가손익률 한도 초과 시 감시 자동 정지
1단계DRY_RUN=True 시뮬레이션 모드
2단계목표가 1원 (시장 체결 불가)
3단계실제 주문 API 물리적 주석 처리

다음으로 남은 것

Phase 7에서 아직 남은 준비 항목은 API 키 권한 범위 점검, 알림 이중화(텔레그램+이메일 등), 실제 목표가·전략으로 백테스트 검증이다. 이 항목들이 다 끝나면, 안전장치를 단계적으로 해제하는 절차를 진행할 예정이다. (※ 본 글은 안전장치 개발 과정만 다루며, 실제 계좌·거래 정보는 포함하지 않았습니다.)

💻 코드 전체는 프로젝트 저장소에서 확인할 수 있습니다.

FAQ

Q1. 왜 실현손익이 아니라 평가손익 기준으로 손실을 판단하나요?

지금 봇 구조가 “매수 후 감시 종료” 방식이라 매도 이력 자체가 시스템에 없기 때문입니다. 실현손익을 쓰려면 매도 트래킹 테이블부터 새로 만들어야 해서, 우선은 API에서 바로 받아오는 평가손익률(profitLoss.rate)을 기준으로 구현했습니다.

Q2. 손실 한도를 넘으면 보유 종목을 자동으로 매도하나요?

아니요. 이 모듈은 신규 매수 감시만 자동으로 멈추고, 기존 보유 종목의 매도는 여전히 사용자가 직접 판단합니다. 매도까지 자동화하지 않은 건 의도된 설계입니다.

Q3. 손실 계산 기간은 어떻게 리셋되나요?

자정 리셋 없이 계좌 전체를 누적으로 계속 감시합니다. 타임존·경계값 처리의 복잡도를 피하기 위한 선택이었습니다.

Q4. API를 중복으로 호출하지 않나요?

네, 그 부분을 리팩터링했습니다. 기존 collect_price_snapshots()에서 저장 로직만 store_snapshots_from_holdings()로 분리해서, 한 번의 API 조회 결과를 스냅샷 저장과 손실 한도 점검 양쪽에서 재사용합니다.

“평가손익 기준 손실 한도 자동 정지 구현 | 토스 자동매매 개발기 #20”에 대한 4개의 생각

댓글 남기기