실거래 자동매매 봇을 살려두는 안전장치 8가지

미장봇 개발기 · 15편 · 시스템과 운영

실거래 자동매매 봇을 살려두는 안전장치 8가지

백테스트를 통과한 코드가 실계좌에서 무너지는 지점은 전략이 아니라 정상이 아닌 경로다. 프로세스가 주문 직후 죽고, 브로커 잔고가 몇 초 늦게 갱신되고, 재시도 루프가 스스로를 차단당하게 만든다. 미국주식 자동매매 봇을 4개월 굴리며 실제로 맞은 사고와, 그 자리에 하나씩 박아 넣은 안전장치를 코드와 로그 그대로 정리했다.

🧨 봇을 죽이는 건 전략이 아니라 예외 경로다

자동매매 봇의 코드는 크게 두 종류다. 하나는 시그널을 계산하는 코드, 다른 하나는 그 시그널이 실제 계좌에 반영되는 동안 사고가 나지 않게 지키는 코드다. 백테스트는 앞의 절반만 검증한다. 뒤의 절반은 실계좌에서 한 번 다치기 전까지는 존재조차 눈에 안 띈다.

아래 사고들은 모두 실제로 겪었거나, 다중 렌즈 코드 감사에서 “이 순서면 언젠가 터진다”로 잡아낸 것들이다. 공통점이 하나 있다. 전부 시장이 아니라 시스템 쪽에서 왔다.

04-13
콜드스타트 500

04-30
감사 3건 패치

05-15
시드 영구 차단

06-02
CPU 100% 해소

💾 ① 주문이 수락된 순간 상태부터 적는다

🔴 사고 시나리오 — 고아 포지션

매수 주문은 브로커에 정상 접수됐다. 봇은 이어서 체결가를 알아내려고 2초 간격 5회 폴링을 돈다. 이 10초 사이에 프로세스가 강제 종료되면? 증권사 계좌에는 51주가 들려 있는데, 봇은 재시작 후 자기가 무엇을 샀는지 모른다. 손절도 익절도 감시하지 않는 주인 없는 포지션이 시장에 방치된다.

해법은 코드를 늘리는 게 아니라 순서를 바꾸는 것이었다. 주문이 수락된 즉시(=체결가를 알기 전에) 상태 파일을 먼저 쓴다. 체결가는 나중에 알게 되면 그때 덮어쓰면 된다.

graphviz diagram

📊 다이어그램 요약: 매수 주문이 수락되면 체결가 폴링(2초×5회)에 들어가기 전에 포지션을 파일로 먼저 남긴다. 그 느린 구간에서 프로세스가 죽어도 재시작 때 상태 파일을 읽고 브로커 잔고와 대조해 포지션을 다시 인수한다.

Python

order_no = order_result.get("order_no", "")
logger.info(f"BUY ORDER OK: #{order_no}")
# 체결가 폴링(느림) 이전에 먼저 영속화한다.
# 폴링 중 크래시해도 재시작이 상태 파일로 포지션을 되찾을 수 있다.
self._pending_buy_order_no = order_no
self._save_position_state()

fill_price = self.kis_client.get_us_filled_price(order_no, self.position.symbol)
if fill_price:
    self._apply_fill_price(fill_price)   # 체결가로 진입가·손절·익절 재계산 후 다시 저장

여기에 두 겹을 더 얹었다. 하나는 원자적 쓰기다. 상태 파일을 직접 열어 쓰다가 죽으면 반쯤 잘린 JSON이 남아 복구 자체가 실패한다. 그래서 임시 파일에 다 쓴 뒤 os.replace()로 갈아끼운다. 파일 시스템 레벨에서 원자적이라 “완전한 이전 버전” 아니면 “완전한 새 버전”만 존재한다.

다른 하나는 복구 시 브로커를 진실로 삼는 것이다. 상태 파일은 어디까지나 봇의 기억이고, 실제 보유는 증권사가 안다. 그래서 복구 경로는 파일을 읽은 다음 반드시 잔고를 조회한다.

🟢 복구 규칙 세 줄

▶ 브로커에 해당 종목이 없다 → 상태 파일은 유령이므로 폐기

▶ 수량이 다르다 → 무조건 브로커 수량을 채택

▶ 잔고 조회 자체가 실패 → 상태 파일로 진행하되 경고 로그를 남김(감시 대상)

🔁 ② 잔고 조회로 재매도를 판단하지 않는다

부분체결은 자동매매에서 가장 조용하게 사고를 만드는 상황이다. 10주 매도 주문 중 6주만 체결됐다면 남은 4주를 다시 팔아야 한다. 문제는 “남은 수량”을 어떻게 아느냐다.

직관적인 방법은 잔고를 다시 조회하는 것이다. 그런데 증권사 잔고 API는 정산 지연 때문에 체결 직후 몇 초간 체결 이전 수량을 그대로 돌려준다. 그 값을 믿고 재매도를 넣으면 이미 팔린 6주를 또 파는 셈이 되고, 포지션이 음수로 내려간다.

Python

# 잔고 폴링이 아니라 '그 주문번호의 체결 내역'을 합산한다
time.sleep(2)  # KIS가 체결을 기록할 시간
executions = self.kis_client.get_us_today_executions(self.position.symbol)
filled_qty = sum(e.get("fill_qty", 0) for e in executions
                 if e.get("order_no") == order_no)
remaining_qty = self.position.shares - filled_qty

if filled_qty > 0 and remaining_qty > 0:
    self.kis_order.sell_market(self.position.symbol, remaining_qty)   # 남은 수량만
elif filled_qty == 0:
    logger.warning("NO FILL DETECTED — 중복 매도를 피하려 재시도 생략, 대조 로직에 위임")

핵심은 마지막 분기다. 체결이 0건으로 보일 때는 아무것도 하지 않는다. “안 팔린 것 같으니 한 번 더”가 가장 위험한 선택지이기 때문이다. 확인되지 않은 상태에서는 행동을 멈추고 나중의 대조(reconcile) 단계로 넘긴다. 자동매매에서 모르면 멈추는 쪽이 거의 항상 옳다.

🔥 ③ 봇이 스스로를 공격하지 않게 만든다

외부 장애보다 자주 겪은 건 봇이 자기 자신을 망가뜨리는 경우였다. 두 가지 형태로 나타났다.

🟡 첫 번째 — CPU 한 코어를 상시 100% 태우던 메인 루프

기능은 멀쩡했다. 거래도 정상이었다. 그런데 프로세스가 코어 하나를 계속 100%로 물고 있었다. 원인은 time.sleep()예외 처리 블록 안에만 있었다는 것. 즉 에러가 날 때만 30초 쉬고, 아무 일도 없는 평상시에는 곧바로 루프를 재진입했다. 하는 일이 없을수록 더 바쁘게 도는 역설적인 구조였다.

수정은 루프 본문을 함수로 빼고 sleep을 모든 반복에서 무조건 실행하도록 옮긴 것뿐이다. 포지션 감시 중에는 5초, 그 외에는 30초. 재시작 후 측정한 CPU 시간은 2분 동안 0.95초로 떨어졌다.

Python

def _loop_iteration(self) -> None:
    try:
        self._tick()
    except (KeyboardInterrupt, SystemExit):
        raise                       # 정상 종료 경로는 그대로 전파
    except Exception as e:
        logger.exception(f"Unhandled error in tick: {e}")
        self.notifier.notify_error(f"Tick error: {e}")
    # sleep은 예외 여부와 무관하게 매 반복 실행 — 이게 busy-spin을 막는 전부다
    sleep_time = 5 if self.state == BotState.POSITION_OPEN else 30
    time.sleep(sleep_time)
🟡 두 번째 — 재시도가 인증을 막아버리는 자가 차단

증권사 서버가 잠시 흔들렸을 때 봇은 30초 간격으로 토큰 발급을 재시도했다. 그런데 그 API에는 분당 1회 제한이 있었다. 성실한 재시도가 위반 횟수를 쌓았고, 결국 하루짜리 차단으로 돌아왔다. 표면 에러 메시지는 “유효하지 않은 앱키”였지만 키는 멀쩡했다. 이 사고의 전말은 앞선 편에서 다뤘다.

지금은 실패가 누적되면 60초 → 5분 → 15분 → 30분 → 1시간으로 대기가 늘어나고, 한 번 성공하면 초기화된다. 재시도 간격을 짧게 두는 습관은 외부 API 앞에서 미덕이 아니다.

비슷한 맥락으로 콜드 스타트 대비도 넣었다. 새 프로세스는 시작 직후 15~60초 동안 조회 API가 무더기로 500을 뱉는 구간을 지난다. 이때 자본 조회가 실패하면 그날 매매 수량이 0으로 계산돼 하루가 통째로 날아간다. 그래서 기동 직후 가벼운 시세 호출을 10초 간격으로 최대 90초 두드려 서버가 깨어난 걸 확인한 뒤에 본격적인 초기화를 진행한다.

📣 ④ 조용한 실패를 시끄럽게 만든다

가장 비싼 버그는 에러를 안 내는 버그다. 로그는 평화롭고 알림도 오지 않는데 그저 아무 일도 일어나지 않는 상태 — 자동매매에서는 이게 손실보다 무섭다. 두 군데를 일부러 시끄럽게 바꿨다.

🧠 만료 토큰을 그대로 넘겨주지 않기

예전엔 인증 객체가 만료된 토큰이라도 일단 돌려줬다. 그러면 호출한 쪽은 “토큰은 받았다”고 믿고 API를 때리고, 인증 실패가 엉뚱한 엔드포인트마다 흩어져 터진다. 지금은 유효한 토큰이 없으면 빈 문자열을 반환하고, 남은 대기 시간을 명시한 CRITICAL 로그를 남긴다. 호출부는 그 즉시 모든 요청을 중단한다. 실패 지점이 한 곳으로 모이면 진단은 몇 분이면 끝난다.

🧠 실패한 작업에 “완료” 도장을 찍지 않기

포트폴리오 최초 편입(시드) 로직에는 “한 번 실행하면 완료 표시”라는 규칙이 있었다. 재시도 폭주를 막으려는 의도였다. 그런데 거래소 코드 문제로 매수가 0건이었던 날에도 완료 도장이 찍혔고, 그 뒤로는 재시작을 해도 시드 로직에 영영 진입하지 못했다. 지금은 매수 1건 이상일 때만 완료로 기록하고, 0건이면 표시하지 않은 채 seeded_at NOT marked; next tick will retry 경고를 남긴다. 부분 실패와 완전 실패는 다르게 다뤄야 한다.

🎯 ⑤ 청산은 가격보다 체결을 우선한다

매수와 매도는 목적이 다르다. 매수는 비싸게 사지 않는 게 중요하고, 매도는 확실히 빠져나오는 게 중요하다. 처음엔 이 차이를 반영하지 않고 양쪽 모두 1% 여유만 두고 지정가를 냈다. 급락장에서 손절 주문이 호가 아래로 떨어져 체결되지 않았고, 당일 주문 만료 후 다음 날 강제 청산으로 넘어가는 최악의 경로가 열렸다.

매수 여유 (buy)

1%

매도 여유 (sell)

3%

매도 슬리피지 허용폭을 1%에서 3%로 넓혔다. 몇 푼 더 손해 보더라도 포지션이 밤을 넘기지 않는 쪽이 압도적으로 싸다.

🛑 ⑥ 손실 자체에도 정지 버튼을 단다

위의 장치들이 “시스템이 망가지는 것”을 막는다면, 서킷 브레이커는 전략이 시장과 안 맞는 국면을 막는다. 규칙은 단순하다.

발동 조건 기준 해제
연속 손절 3연패 주가 바뀌면 리셋
주간 누적 손실 주초 자본 대비 3% 주가 바뀌면 리셋
승리 시 처리 연패 카운터 0으로

주간 손실률을 현재 자본이 아니라 주초 자본 기준으로 계산하는 게 포인트다. 현재 자본을 분모로 쓰면 자본이 줄어들수록 같은 손실이 더 큰 비율로 잡혀 브레이커가 들쭉날쭉해진다. 기준선은 한 주 동안 고정되어야 한다.

🧪 ⑦ 테스트가 실계좌 기록을 건드리지 못하게 한다

의외로 실계좌 판단을 가장 직접적으로 오염시킨 건 테스트 코드였다. 테스트가 실제 거래 기록 파일에 그대로 쓰면서 누적 통계가 뒤틀렸고, 그 통계를 보는 서킷 브레이커의 판단 기준까지 흔들렸다.

격리 장치는 이미 있었다. 다만 자동 적용 설정이 아니어서 일부 경로에서 조용히 새어 나갔다. 격리는 “쓸 수 있는 옵션”이 아니라 기본값이어야 한다. 지금은 격리 픽스처를 전역 자동 적용으로 두고, 테스트 실행 전후로 거래 기록 파일의 해시를 비교해 값이 변했으면 격리 실패로 간주한다.

🔎 ⑧ 안전장치마다 “재발했는지” 볼 신호를 정한다

안전장치를 넣는 것보다 어려운 건 그게 여전히 작동하는지 확인하는 일이다. 리팩터링 한 번이면 조용히 원복될 수 있기 때문에, 장치마다 되돌아갔을 때 눈에 띄는 신호를 하나씩 정해뒀다.

안전장치 재발을 알리는 신호
주문 직후 상태 저장 기동 시 브로커 보유 ≠ 상태 파일 → CRITICAL 알림
부분체결 재매도 같은 날 같은 종목 매도 주문이 2회 넘게 발사
메인 루프 sleep CPU 사용 시간이 경과 시간에 비례해 누적
토큰 백오프 신규 토큰 발급 로그 0건 + 당일 거래 0건
콜드 스타트 예열 예열 성공까지 30초 이상 또는 3회 이상 재시도
청산 체결 보장 손절·익절 주문 후 일정 시간 내 미체결
테스트 격리 테스트 전후 거래 기록 파일 해시 불일치

여덟 개를 관통하는 원칙은 하나다. 확신이 없을 때 봇은 행동하지 말고 멈춰야 한다. 체결이 확인되지 않으면 재매도하지 않고, 토큰이 유효하지 않으면 빈 값을 돌려 호출을 끊고, 매수가 0건이면 완료 표시를 찍지 않는다. 자동매매에서 잘못된 행동은 되돌릴 수 없지만, 잠깐의 멈춤은 거의 언제나 되돌릴 수 있다.

🧭 다음 편 — 봇이 보는 잔고와 증권사가 보는 잔고

이번 편의 사고들은 대부분 봇의 기억과 브로커의 사실이 어긋나는 순간에서 나왔다. 부분체결, 정산 지연, 유령 상태 파일 모두 같은 뿌리다. 다음 편에서는 이 어긋남이 가장 구조적으로 드러나는 지점 — 주문 수량 계산과 실제 주문 가능 금액이 왜 계속 1주씩 어긋나는지, 그리고 그것을 단일 함수 하나로 정리한 과정을 다룬다.

이 글은 개인이 만든 자동매매 시스템의 엔지니어링 기록이며, 특정 종목·전략·상품에 대한 투자 권유가 아닙니다. 본문에 등장하는 수치와 로그는 필자의 개발·운영 환경에서 관측된 값으로 동일한 결과를 보장하지 않습니다. 레버리지 상품과 자동매매는 원금 손실 위험이 있으며, 모든 투자 판단과 그 결과는 투자자 본인에게 귀속됩니다.

댓글 달기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다

위로 스크롤