반응형
📌 이 글은 ETF 자동 리밸런싱 시리즈의 버그 회고 기록입니다.
👉 첫 글 보기

자동매매 봇을 만들고 실계좌로 돌렸더니, 매도 주문이 매수로 들어갔습니다.

텔레그램에는 "SELL 주문 완료"라고 왔는데, 증권사 앱을 열어보니 매수 주문이 걸려 있었습니다. 봇이 거짓말을 한 겁니다.

원인 찾고, 고치고, 재발 방지까지 적용한 과정을 기록해둡니다.

1. 증상 발견

텔레그램에 이런 메시지가 왔습니다.

📝 SELL 주문: TQQQ 11주 @ $43.83 📊 일일 리포트 국면: BEAR 현재 비중: TQQQ: 37.0% / QQQ: 0.0% / SCHD: 35.4% 매도 주문: TQQQ 11주 매수 주문: QQQ 1주, SCHD 9주

BEAR 국면이라 TQQQ 비중(37%)이 너무 높으니 11주를 팔고, 그 돈으로 QQQ와 SCHD를 사는 리밸런싱이었습니다. 메시지만 보면 정상입니다.

그런데 한국투자증권 앱을 열어보니:

종목 구분 주문량 체결량 미체결량
TQQQ 매수 11 0 11

매도가 아니라 매수 주문이 들어가 있었습니다.

QQQ 1주, SCHD 9주 매수도 실행되지 않았습니다. 매도가 안 됐으니 현금이 없어서 매수할 수 없었던 겁니다. 봇은 "매도 완료"라고 보냈지만, 실제로는 TQQQ를 11주 더 사려고 시도한 셈입니다.

2. 원인 분석 — TR_ID가 뭔데?

한국투자증권(KIS) 해외주식 REST API는 매수와 매도를 서로 다른 TR_ID(거래ID)로 구분합니다.

구분 TR_ID 설명
매수 TTTT1002U 해외주식 매수 주문
매도 TTTT1006U 해외주식 매도 주문

HTTP 엔드포인트가 같고, URL도 같고, 요청 바디 구조도 같습니다. 차이는 HTTP 헤더에 실어 보내는 tr_id 값 하나뿐입니다.

문제의 코드는 이랬습니다.

# config.py — 버그 있던 코드
TR_ID_ORDER = 'TTTT1002U'  # 매수 전용인데 모르고 공용으로 씀
# execution_engine.py — 버그 있던 코드
def _place_order(symbol, qty, limit_price, side):
    if side == 'BUY':
        body['SLL_BUY_DVSN_CD'] = '02'
    else:
        body['SLL_BUY_DVSN_CD'] = '01'

    data = kis_api_call('POST', '/uapi/.../order', TR_ID_ORDER, body=body)

SLL_BUY_DVSN_CD라는 필드로 매수/매도를 구분하려 했는데, 이 필드는 해외주식 주문 API에서 사용하지 않는 필드였습니다. API는 이 값을 무시하고, 헤더의 tr_id만 보고 매수/매도를 결정합니다.

그러니까 side가 SELL이든 BUY이든, 항상 TTTT1002U(매수)로 요청이 나간 겁니다.

원인: 국내주식 API 예제를 참고해서 해외주식 코드를 작성하면서, 매수/매도 구분 방식의 차이를 놓친 것. 국내주식은 SLL_BUY_DVSN_CD로, 해외주식은 TR_ID로 구분합니다.

3. 수정 — 한 줄이 바꾼 매수/매도의 차이

수정은 간단했습니다.

# config.py — 수정 후
TR_ID_ORDER_BUY  = 'TTTT1002U'  # 해외주식 매수
TR_ID_ORDER_SELL = 'TTTT1006U'  # 해외주식 매도
# execution_engine.py — 수정 후
tr_id = TR_ID_ORDER_BUY if side == 'BUY' else TR_ID_ORDER_SELL
data = kis_api_call('POST', '/uapi/.../order', tr_id, body=body)

TR_ID를 매수/매도로 분리하고, side에 따라 올바른 TR_ID를 선택하도록 했습니다. 불필요한 SLL_BUY_DVSN_CD 필드도 제거했습니다. 변경은 이게 전부입니다.

4. 추가 발견 — 120초 고정 대기의 함정

TR_ID 버그를 고치면서 주문 실행 흐름을 다시 살펴보니, 또 다른 문제가 있었습니다.

매도 주문을 넣고 무조건 120초만 기다린 뒤 매수를 진행하고 있었습니다.

1. 매도 주문 제출
2. time.sleep(120) ← 120초 고정 대기
3. USD 잔고 재조회
4. 매수 수량 재계산
5. 매수 주문 제출

120초 안에 매도가 체결되지 않으면 어떻게 될까요?

  • 현금이 없는 상태에서 매수 시도
  • 지정가 주문이라 체결이 보장되지 않음
  • 장 마감 30분 전이라 유동성도 줄어드는 시간대

솔직히 말하면 "2분이면 되겠지"라는 희망 회로였습니다. 이번에도 매도(실제로는 매수)가 미체결인데 바로 매수를 시도해서 QQQ/SCHD 주문이 전부 실패했고요.

5. 개선 — 미체결 조회 API로 체결 확인

120초 고정 대기를 KIS 미체결 조회 API 폴링으로 교체했습니다.

기존 개선 후
매도 후 대기 sleep(120) 고정 미체결 API 폴링 (30초 간격)
체결 확인 없음 (희망 회로) 주문번호로 미체결 잔량 확인
타임아웃 없음 최대 10분, 초과 시 경고 알림
매수 진행 조건 시간만 지나면 진행 체결 확인 후 진행, 현금 부족 시 스킵

새로 추가한 TR_ID는 TTTS3018R(해외주식 미체결 조회)입니다. 30초 간격으로 미체결 목록을 조회하고, 해당 주문번호가 목록에서 사라지면 체결 완료로 판단합니다.

핵심 차이: "120초 지나면 됐겠지" → "API로 확인하고, 진짜 됐을 때만 진행"

6. 텔레그램 알림 전면 개편

이번 사고에서 텔레그램 메시지만으로는 문제를 알아챌 수 없었습니다. 📝 SELL 주문: TQQQ 11주라고 보내놓고 실제로는 매수가 들어갔으니까요.

그래서 메시지만 봐도 뭔가 잘못됐는지 바로 알 수 있도록 뜯어고쳤습니다.

주문 제출 알림

기존 개선 후
📝 SELL 주문: TQQQ 11주 @ $43.83 🔻 SELL 주문 제출 종목: TQQQ 수량: 11주 지정가: $43.83 예상 금액: $482.13 주문번호: 0012345678

주문번호가 포함돼서, 증권사 앱에서 바로 대조할 수 있습니다.

새로 추가된 알림

  • 매도 체결 확인 — 미체결 API로 확인 후 전송
  • 주문 실행 완료 요약 — 매도/매수 건수, 체결 상태, 잔여 현금까지 한눈에
  • 일일 리포트 강화 — 국면 판단 근거(QQQ/MA200/VIX), 총 자산, 비중 차이(🔴/🟢 시각 표시) 포함

메시지 수는 3개에서 6~7개로 늘었지만, 이제 앱을 안 열어봐도 됩니다.

7. 테스트 19건 추가

솔직히 테스트가 있었으면 배포 전에 잡았을 버그입니다.

테스트 그룹 건수 핵심 검증
TestPlaceOrder 3 매수는 TTTT1002U, 매도는 TTTT1006U 사용 검증
TestIsOrderFilled 4 미체결 목록 유무에 따른 체결 판단
TestWaitForSellsFill 4 즉시 체결, 타임아웃, 부분 체결 처리
TestExecuteOrders 2 매도→체결확인→매수 순서, 미체결 시 스킵
기타 6 미체결 조회 API, 매수 수량 재계산
합계 19 전체 56건 → 75건 (전체 통과)

TestPlaceOrder가 핵심입니다. "매도 주문 시 TTTT1006U TR_ID를 사용하는가?" — 이거 하나만 있었어도 됐습니다.

8. 교훈 정리

테스트에서 안 잡히면 실계좌에서 잡힌다

KIS API의 매수/매도 구분은 URL도 같고, 요청 바디도 같습니다. 차이는 헤더의 TR_ID 하나뿐. 이걸 코드 리뷰에서 눈으로 잡기는 어렵습니다. 금융 API는 "코드가 의도대로 동작하는가"보다 "API가 의도대로 호출되는가"를 테스트해야 한다는 걸 뼈저리게 느꼈습니다.

알림이 부실하면 디버깅도 늦는다

기존 알림은 "내가 SELL이라고 보냈다"는 것만 알려줬습니다. 실제로 API에 뭐가 갔는지, 주문번호가 뭔지, 체결은 됐는지 전혀 몰랐습니다. 이번에 증권사 앱을 직접 열어보지 않았으면 한참 뒤에야 알았을 겁니다.

체결은 가정이 아니라 확인이다

time.sleep(120) 후 "됐겠지" 하고 넘어가면, 매도 미체결 → 현금 부족 → 매수 실패 → 포트폴리오 불균형. 이번에 정확히 이 순서로 터졌습니다.

변경 파일 요약

파일 변경 내용
config.py TR_ID 매수/매도 분리, 미체결 조회 TR_ID 추가
execution_engine.py TR_ID 분기, 미체결 조회 폴링, 실행 완료 요약
telegram_notifier.py 6개 함수 신규/개편 (상세 알림)
main.py 새 알림 함수 적용
test_execution.py 19건 테스트 추가
⚠️ 본 글에서 소개하는 도구와 전략은 정보 제공 목적이며, 투자 권유가 아닙니다. 암호화폐/ETF 투자는 원금 손실 위험이 있으므로 투자 결정 전 충분한 검토를 권장합니다.
반응형

+ Recent posts