미국주식 봇을 4개월 굴리며 실계좌에서 겪은 사고는 대개 전략이 아니라 예외 상황에서 났다. 주문 직후 프로세스가 죽거나, 잔고가 몇 초 늦게 갱신되거나, 재시도가 스스로 차단을 불러온 경우다. 그때마다 안전장치를 하나씩 넣었다.
백테스트를 통과해도 실계좌에서 터진 것들
백테스트는 시그널을 계산하는 코드만 검증한다. 그 시그널이 계좌에 반영되는 동안 사고를 막는 코드는 실계좌에서 한 번 다치기 전까지 빠진 줄도 몰랐다.
아래 사고는 실제로 겪었거나, 코드를 다시 읽다가 이 순서면 언젠가 터지겠다고 잡아낸 것이다. 날짜순으로는 4월 13일 봇을 켠 직후의 500 에러, 4월 30일 코드 점검에서 나온 패치 3건, 5월 15일 첫 매수(시드)의 영구 차단, 6월 2일 CPU 100% 해소다.
주문이 받아들여지면 체결가보다 상태를 먼저 적는다
매수 주문이 증권사에 접수되면 봇은 체결가를 알아내려고 2초 간격으로 5번 조회한다. 이 10초 사이에 프로세스가 강제로 죽으면, 계좌에는 51주가 들어 있는데 재시작한 봇은 자기가 무엇을 샀는지 모른다. 손절도 익절도 감시하지 않는 주인 없는 포지션이 그대로 남는다.
해법은 순서를 바꾸는 것이었다. 주문이 수락되면 체결가를 알기 전이라도 상태 파일부터 쓰고, 체결가는 나중에 알게 됐을 때 덮어쓴다. 그러면 느린 조회 구간에서 죽더라도 재시작할 때 상태 파일을 읽고 증권사 잔고와 대조해 포지션을 다시 넘겨받을 수 있다.

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주를 또 팔아서 포지션이 음수가 된다. 그래서 잔고 대신 그 주문번호의 체결 내역을 합산한다.
# 잔고 폴링이 아니라 '그 주문번호의 체결 내역'을 합산한다
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%로 물고 있던 일이다. 기능도 거래도 정상이었는데, 원인을 보니 time.sleep()이 예외 처리 블록 안에만 있었다. 에러가 났을 때만 30초 쉬고, 아무 일 없는 평상시에는 곧바로 루프로 다시 들어갔다.
루프 본문을 함수로 빼고 sleep을 매 반복마다 무조건 실행하도록 옮겼다. 포지션을 감시하는 중에는 5초, 그 밖에는 30초를 쉰다. 재시작한 뒤 재 보니 2분 동안 쓴 CPU 시간이 0.95초였다.
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)성실한 재시도가 인증을 막아 버린 날
재시도가 인증을 막아 버린 일도 있었다. 토큰 발급 API에는 분당 1회 제한이 있는데, 증권사 서버가 잠깐 불안정할 때 봇이 30초 간격으로 다시 시도하다가 하루짜리 차단을 맞았다. 전말은 4편에 적었다.
지금은 실패가 쌓이면 대기 시간을 60초, 5분, 15분, 30분, 1시간으로 늘려 가고, 한 번 성공하면 처음으로 돌아간다.
봇을 새로 켠 직후 15~60초 동안 조회 API가 500을 돌려주는 콜드 스타트도 4편에서 다룬 문제다. 이때 자본 조회가 실패하면 그날 매매 수량이 0으로 잡혀 하루를 날린다. 그래서 켜자마자 가벼운 시세 조회를 10초 간격으로 최대 90초까지 보내 보고, 서버가 응답하는 걸 확인한 다음 초기화를 진행한다.
조용히 실패하던 곳을 일부러 시끄럽게
시드 매수가 0건인데 완료로 찍혔던 일처럼, 에러 없이 아무 일도 안 일어나는 경우는 로그만 봐서는 알 수 없었다. 그런 곳 두 군데를 일부러 시끄럽게 바꿨다.
하나는 만료된 토큰이다. 예전에는 인증 객체가 만료된 토큰이라도 일단 돌려줬다. 그러면 호출한 쪽은 토큰을 받았다고 믿고 API를 부르고, 인증 실패가 엉뚱한 API마다 흩어져 터진다. 지금은 유효한 토큰이 없으면 빈 문자열을 돌려주고, 남은 대기 시간을 적은 CRITICAL 로그를 남긴다. 호출하는 쪽은 그 즉시 모든 요청을 멈춘다. 실패 지점이 한 곳에 모이니 진단이 몇 분이면 끝난다.
다른 하나는 시드 로직의 완료 표시다. 포트폴리오에 처음 종목을 담는 시드 로직은 재시도가 폭주하지 않도록 한 번 실행하면 완료로 표시했다. 그런데 거래소 코드 문제로 매수가 0건이었던 날에도 완료 표시가 찍혔고, 그 뒤로는 재시작을 해도 시드 로직에 다시 들어가지 못했다. 지금은 매수가 1건 이상일 때만 완료로 기록하고, 0건이면 표시하지 않은 채 seeded_at NOT marked; next tick will retry 경고를 남긴다.
손절 주문은 가격보다 체결이 먼저다
처음에는 매수와 매도 모두 1% 여유만 둔 지정가를 냈다. 급락장에서 호가가 지정가 아래로 밀려 손절 주문이 체결되지 않았고, 당일 주문이 만료되면 다음 날 강제 청산으로 넘어가는 경로가 열렸다. 매수는 비싸게 사지 않는 게 중요하지만 손절은 확실히 빠져나오는 게 중요하다. 그래서 매수 여유는 1%로 두고 매도 여유만 3%로 넓혔다. 몇 푼 더 손해를 보더라도 포지션이 밤을 넘기지 않게 하려는 것이다.
손실에도 정지 버튼을 달았다
위의 장치들은 시스템이 망가지는 걸 막는다. 전략이 시장과 맞지 않는 시기에 손실이 쌓이는 건 서킷 브레이커로 막았다. 조건에 걸리면 다음 주까지 매매를 멈추는 규칙이다.
| 발동 조건 | 기준 | 해제 |
|---|---|---|
| 연속 손절 | 3연패 | 다음 주에 리셋 |
| 주간 누적 손실 | 주초 자본 대비 3% | 다음 주에 리셋 |
| 승리했을 때 | 연패 카운터를 0으로 | – |
주간 손실률은 지금 자본이 아니라 주초 자본으로 나눈다. 지금 자본으로 나누면 자본이 줄수록 같은 손실이 더 큰 비율로 잡혀서 브레이커가 들쭉날쭉해진다.
테스트가 실계좌 기록을 건드리던 문제
실계좌 판단을 가장 직접 오염시킨 건 의외로 테스트 코드였다. 테스트가 실제 거래 기록 파일에 그대로 쓰면서 누적 통계가 뒤틀렸고, 그 통계를 보는 서킷 브레이커 판단까지 틀어졌다. 테스트를 실제 파일과 떼어 놓는 장치는 있었지만 자동으로 걸리는 설정이 아니어서 일부 경로에서 새어 나갔다. 지금은 그 장치를 모든 테스트에 자동으로 걸고, 테스트 전후로 거래 기록 파일의 해시를 비교한다. 해시가 바뀌었으면 격리 실패로 본다.
장치가 아직 살아 있는지는 어떻게 아나
안전장치는 리팩터링 한 번에 조용히 원래대로 돌아갈 수 있다. 그래서 장치마다 되돌아갔을 때 눈에 띄는 신호를 하나씩 정해 뒀다.
| 안전장치 | 재발을 알리는 신호 |
|---|---|
| 주문 직후 상태 저장 | 기동할 때 브로커 보유와 상태 파일이 다르면 CRITICAL 알림 |
| 부분체결 재매도 | 같은 날 같은 종목 매도 주문이 2회 넘게 나감 |
| 메인 루프 sleep | CPU 사용 시간이 경과 시간에 비례해 쌓임 |
| 토큰 백오프 | 신규 토큰 발급 로그 0건에 당일 거래도 0건 |
| 콜드 스타트 예열 | 예열 성공까지 30초 이상 걸리거나 3회 이상 재시도 |
| 청산 체결 보장 | 손절·익절 주문 후 일정 시간 안에 체결되지 않음 |
| 테스트 격리 | 테스트 전후 거래 기록 파일 해시가 다름 |
이 장치들은 모두 확신이 없을 때 봇이 움직이지 않고 멈추게 만든 장치다. 돌아보면 이번 사고들은 대부분 봇이 기억하는 수량과 증권사 잔고가 달라서 생겼다.
주문 수량과 주문가능금액이 왜 자꾸 1주씩 어긋났는지는 다음 편에 적었다.
이 글은 내가 만든 자동매매 시스템의 엔지니어링 기록이고 특정 종목이나 전략을 권하는 글이 아니다. 본문의 수치와 로그는 내 개발·운영 환경에서 관측한 값이라 같은 결과를 보장하지 않는다.