티스토리 뷰

봇이 어디선가 돈을 흘리고 있는 것 같다

승률은 나쁘지 않은데 잔고가 안 늘었다. 어딘가에서 돈이 새고 있는 게 분명했다. 코드를 한참 들여다봐도 명백한 버그는 안 보였지만, 의심 가는 곳이 너무 많았다. 그래서 결심했다. 코어 머니 로직을 전수 리뷰한다.

리뷰 범위를 P0~P4 우선순위로 나누고, 마스터 체크리스트 이슈 하나에 묶었다. 돈에 직접 영향 가는 P0 → 토큰/비용 정확도 P1 → 엣지 케이스 P2 → 테스트 보강 P3 → 데이터 위생 P4. 한 번에 다 보지 않고, 순서대로 잡았다.

P0: 돈 유실 — OrderResult.success=False의 함정

가장 먼저 발견한 위험은 매수/매도 함수의 실패 처리였다.

# 기존
result = trader.buy_market(coin, amount)
if not result.success:
    record_signal(skip_reason="order_failed")
    return  # 여기서 끝

문제: 업비트가 주문을 접수는 받았는데 응답이 타임아웃이나 rate limit에 걸려 success=False로 돌아오는 경우가 있다. 봇은 매수 안 됐다고 판단하지만, 실제로는 체결됐을 수 있다. DB에는 기록이 없으니 다음 틱에 같은 코인을 또 매수한다. 그러면 의도한 1배 포지션이 2배가 된다.

수정은 간단했다. 실패 응답을 받으면 즉시 잔고를 다시 조회해서 실제 체결 여부를 확인한다. 체결됐으면 그제야 DB에 기록하고 종료.

P1: LLM 비용 과소 집계 — 18% 차이

이건 발견했을 때 놀랐다. Anthropic 청구서와 내가 계산한 비용이 안 맞았다. 공식가 기준 $4.91인데 실제 청구는 $5.83. 차이 18%.

# 문제 흐름
total_input += response.usage.input_tokens
try:
    parsed = json.loads(response.content)
except JSONDecodeError:
    continue  # 다음 재시도로
# MAX_RETRIES 모두 실패하면 return None
# → 누적된 토큰은 어디에도 저장 안 됨

JSON 파싱 실패로 재시도가 발생하면 토큰은 계속 누적되는데, 최종적으로 모든 재시도가 실패하면 그 토큰들이 DB에 저장 안 됐다. 매일 몇 번씩 발생하니 누적 차이가 18%까지 벌어진 것.

수정은 finally에서 토큰을 무조건 기록하도록 변경. 실패해도 비용은 발생했으니 실측 데이터에 반영해야 한다.

P2: MAX_DAILY_CALLS의 일 경계 처리

일일 LLM 호출 제한 로직이 WHERE DATE(timestamp) = DATE('now') 였다. DATE('now')는 UTC 기준이다. 우리 봇은 KST로 돌아간다. 자정 기준이 9시간 어긋나서 23:59에 호출 제한에 걸리면 9시간 동안 추가 호출이 막혔다.

KST 기준으로 일자 계산을 명시적으로 변경하고, 일경계 통과 케이스 테스트를 추가했다. 자잘하지만 운영 데이터를 부정확하게 만드는 부분이라 잡고 가야 했다.

Emergency 호출이 동적 간격을 무력화

리뷰 중간에 발견한 hotfix. 4/18 새벽 03시대 호출 패턴이 이상했다.

02:53 → 03:03 → 03:13 → 03:23
(10분 간격 4회 연속)

포지션 0 + 매매 활발도 낮음 → ACTIVE 모드(60분 간격)여야 하는데 10분마다 호출됐다. 원인은 check_emergency()가 매 10분 스케줄러 틱마다 실행되면서, 1시간 가격 변동 ±3%를 계속 트리거했던 것. Emergency가 동적 간격을 통째로 무력화하고 있었다.

수정: Emergency 호출도 마지막 분석 시각 기준 최소 간격을 두도록. ±3%는 1시간 윈도우인데 호출은 10분마다라 같은 변동을 4번 감지하는 격이었다.

P3: 테스트 커버리지 — 미테스트 8건

P0~P2 수정과 함께 테스트도 추가했다. 가장 중요한 건 OrderResult.success=False 플로우. mock으로 실패 응답을 주입하고, 그때 잔고 재조회 → DB 기록까지 검증.

fee guard도 테스트가 빈약했다. 손실 구간 매도 신호가 차단되고, 익절 구간은 통과하는지 케이스별로. 그리고 order_uuid end-to-end — 매수 후 같은 uuid가 DB에 저장되는지 검증. 이 부분은 중복 매수 감지의 마지막 보루라서 꼭 필요했다.

P4: 데이터 위생 — hold 신호 25,978건

리뷰 마지막 단계. trade_signals 테이블이 무거워지고 있었다. 최근 24시간 hold 신호만 25,978건. 1분에 1번씩 코인마다 hold 기록하니 당연한 결과인데, 영구 보존할 이유가 없다.

# 주간 정리 — 14일 이전 hold만 삭제 (buy/sell은 영구 보존)
DELETE FROM trade_signals
WHERE signal_type = 'hold'
  AND timestamp < datetime('now', '-14 days')

주간 스케줄(일요일 03:00 KST)에 추가했다. 한 번 돌리니 DB가 200MB → 12MB로 줄었다.

리뷰가 끝나고

P0~P4를 다 잡고 보니, 봇이 직접적으로 큰 돈을 흘리고 있던 건 아니었다. 부분 체결 위험, 비용 18% 과소 집계, 일경계 9시간 멈춤, hold 신호 누적 — 각각은 작은 새는 곳이지만 합쳐서 운영의 정확도를 떨어뜨리고 있었다.

그리고 한 가지 깨달았다. 운영 시작 후엔 코드 리뷰가 더 중요하다. PR 머지 시점의 리뷰는 기능 동작에 집중하지만, 운영하면서 보면 다르다. 실제 데이터, 실제 비용 청구서, 실제 거래 로그를 옆에 두고 코드를 보면 보이지 않던 것이 보인다.

다음 글에서는 이 리뷰 와중에 추가한 두 개의 큰 기능을 다룬다. 코인마다 다른 전략을 LLM이 배정하는 기능, 그리고 LLM 비용을 80% 절감한 프롬프트 캐싱 활성화 분투기.


반응형
댓글