미장봇을 만들 때, KIS(한국투자증권) 해외주식 API는 처음엔 순하게 굴었습니다. 대상이 TQQQ / SQQQ 둘뿐이었으니까요. 시세를 부르면 값이 오고, 매수를 넣으면 체결됐습니다. 그런데 포트폴리오를 여러 버킷으로 넓혀 SPMO·MTUM·QUAL·SPY·VEU·AGG·BIL 같은 ETF를 담기 시작하자, 봇이 조용히 아무것도 안 사기 시작했습니다.
크래시가 난 게 아닙니다. 에러 팝업도, 스택 트레이스도 없었습니다. 그냥 “사야 할 게 있는데 주문이 안 나가는” 두 가지 상황이 따로 터졌고, 둘 다 원인이 같은 곳을 가리켰습니다 — 한국에서 미국장을 다룰 때 생기는 두 개의 좌표계, 거래소(어디에 상장됐나)와 시계(지금 장이 열렸나). 이번 글은 그 두 함정을 실제 로그·코드와 함께 풀어 봅니다. 미국주식 자동매매 봇을 처음 짜는 분이라면 거의 반드시 만나는 지점입니다.
▶ KST 13시의 함정 — 새 거래일은 열렸는데 미국장은 닫혀 있는 순간, 월말 리밸런스가 영영 미뤄지던 버그
▶ 둘의 공통점 — 겉보기엔 같은데 실은 다른 것을 코드가 같다고 취급하면 생기는 사고
한 단어 차이 — NASD와 AMEX
먼저 배경부터. KIS 해외주식 API는 종목 하나를 조회하거나 주문할 때 거래소 코드를 함께 받습니다. 미국주식이라고 다 같은 게 아니라, 그 종목이 어느 거래소에 상장됐는지를 코드로 알려 줘야 합니다. 대표적으로 NASD는 나스닥 상장, AMEX는 뉴욕증권거래소 계열의 NYSE Arca 상장 종목에 씁니다. 참고로 미국에 상장된 ETF의 상당수(특히 인덱스·팩터 ETF)는 나스닥이 아니라 NYSE Arca에 상장돼 있습니다.
문제는 이 코드가 종목의 실제 상장 거래소와 정확히 맞아야 한다는 것. 안 맞으면 KIS는 “잘못된 요청입니다”라고 친절히 알려 주는 대신, 그냥 가격을 안 줍니다. 가격이 없으면 봇은 매수 수량을 계산할 수 없어 주문을 건너뜁니다. 결과는 에러가 아니라 침묵입니다.
원래 봇은 모든 호출의 기본값이 exchange="NASD"였습니다. TQQQ·SQQQ·QQQ는 전부 나스닥 상장이라 이 기본값이 우연히 맞았죠. 그런데 새로 담은 팩터 ETF와 GEM 로테이션 유니버스(SPMO·MTUM·QUAL·SPY·VEU·AGG·BIL)는 대부분 NYSE Arca 상장이라 "AMEX"로 불러야 합니다. 멀티버킷을 켜자마자 첫 시드에서 이런 로그가 떴습니다.
Initial seed: cannot fetch price for SPMO
BUY: Cannot get price for SPMO
2026년 5월 15일, 멀티버킷 시드를 처음 돌린 날. 거래소 코드가 안 맞아 2회 연속 0건 매수로 끝났습니다. 해법 자체는 단순합니다 — 티커별로 올바른 거래소를 박아 둔 매핑 테이블과, 그걸 조회하는 헬퍼 하나.
# src/core/portfolio.py
# NASD = 나스닥 상장 (TQQQ 계열, QQQ)
# AMEX = NYSE Arca 상장 (팩터 ETF, GEM 로테이션 유니버스)
TICKER_EXCHANGE = {
"TQQQ": "NASD", "SQQQ": "NASD", "QQQ": "NASD",
"SPMO": "AMEX", "MTUM": "AMEX", "QUAL": "AMEX",
"SPY": "AMEX", "VEU": "AMEX", "AGG": "AMEX", "BIL": "AMEX",
}
def exchange_for(symbol: str) -> str:
"""티커 → KIS 거래소 코드. 미등록 종목은 NASD로 폴백해
거래소가 조용히 바뀌지 않게 한다 — 잘못된 폴백은 가격
조회 실패로 즉시 드러나므로 그때 테이블에 추가하면 된다."""
return TICKER_EXCHANGE.get((symbol or "").upper(), "NASD")
| KIS 거래소 코드 | 상장 거래소 | 해당 종목 (미장봇) |
|---|---|---|
NASD |
NASDAQ | TQQQ, SQQQ, QQQ |
AMEX |
NYSE Arca | SPMO, MTUM, QUAL, SPY, VEU, AGG, BIL |
여기까지면 “매핑 하나 만들면 끝”처럼 보입니다. 저도 그렇게 생각했고, 그게 이 사고의 진짜 교훈이 시작되는 지점입니다.
한 곳을 고쳤는데 또 같은 에러 — 함정은 한 겹 더 안쪽에
처음엔 시드 함수(_execute_initial_seed)의 get_us_price(symbol) 한 호출만 기본값 NASD라서 실패한 줄 알았습니다. 그 한 줄에 exchange=exchange_for(symbol)만 붙이면 끝날 거라고요. 고치고, 봇을 재기동하고, 시드를 다시 돌렸더니 — 토씨 하나 안 틀리고 같은 에러가 다시 났습니다.
BUY: Cannot get price for SPMO
kis_order.buy_market()이 내부에서 _get_market_price(symbol)를 또 부르고, 그 헬퍼가 다시 client.get_us_price(symbol)을 거래소 인자 없이 호출하고 있었습니다. 똑같은 기본값 함정이 시드 호출부와 주문 헬퍼, 두 레이어에 각각 있었던 거죠. 결국 시드 · 잔고 스냅샷 · GEM 로테이션 · drift 리밸런스까지 KIS를 부르는 호출 사이트 9곳 전부에 exchange=exchange_for(symbol)을 명시하고서야 조용해졌습니다.거래소·계좌·리전처럼 여러 함수가 공유하는 기본값은, 한 호출 지점만 고쳐도 안쪽 헬퍼에 같은 함정이 또 있을 확률이 높다. “한 군데 고치면 끝”이라는 가정을 버리고
get_us_price · buy_market · sell_market을 전수 검색한 뒤 수정 범위를 잡는 게 빠릅니다. 신규 ETF를 추가할 땐 TICKER_EXCHANGE 등록도 잊지 말 것 — 누락 시 기본값 NASD로 폴백해 첫 시드에서 즉시 터집니다.
KST 13시의 함정 — 새 날은 열렸는데 장은 닫혀 있다
거래소 코드를 잡고 나니 이번엔 시계가 문제였습니다. 미장봇은 하루 한 번 멀티버킷 틱(_daily_portfolio_tick)을 돌려 GEM·추세 버킷의 월말 리밸런스를 챙깁니다. 그런데 이 틱은 “새 거래일 감지”(_reset_day) 시점에만 발화합니다. 미국 동부시간으로 날짜가 바뀌는 건 ET 자정, 즉 한국시간 오후 1시(KST 13:00)죠.
문제가 보이시나요? KST 13:00에 미국장은 닫혀 있습니다. 미국 정규장(RTH, Regular Trading Hours)은 한국시간으로 밤 10시 30분에나 열리니까요(서머타임 기준, 겨울엔 11시 30분). 그래서 GEM·추세 리밸런스는 장이 닫혀 있으면(is_market_open()이 False) 실행을 보류(defer)합니다. 여기까진 정상 — 장 닫혔을 때 주문 안 넣는 건 당연합니다.
진짜 버그는 그 다음이었습니다. 시드 로직에는 나중에 장이 열리면 보류분을 다시 시도하는 RTH 재시도 경로가 있었는데, GEM·추세에는 그게 없었습니다. 게다가 틱이 끝나면서 그날을 “처리 완료”로 잠가 버립니다. 결과적으로 봇을 계속 켜 두면, 월말 주문이 KST 13:00 틱에서 한 번 보류된 뒤 그날 밤 장이 열려도 다시 시도되지 않았습니다.
0.0, 마지막 시그널 날짜는 null 그대로. 오히려 봇을 껐다가 미국 정규장 시간(밤 10:30~새벽 5시)에 새로 켤 때만 우연히 실행됐습니다. 초기 시드가 하필 성공했던 것도 이 우연 덕이었죠 — 시드에는 RTH 재시도가 있었으니까요.이 버그는 2026년 6월 1일에 잡혔습니다. 5월 29일 월말 추세 리밸런스가 KST 13:00 틱에서 보류된 채 미실행으로 남아 있었고, “장이 열렸는데 추세 매매를 안 한다”는 관찰에서 출발해 RTH 재시도 갭을 찾아냈습니다. 아래는 그 틱 로직을 정리한 그림입니다.

🔁 다이어그램 요약: 새 거래일 틱은 미국장 마감 시각에 뜨므로 월말 리밸런스가 곧장 보류된다 — 핵심은 보류분을 pending 플래그로 기억했다가 나중에 정규장(RTH)에 재진입하는 순간 다시 실행하는 것. 이 재시도 경로가 없으면 주문은 영영 안 나간다.
수정은 시드 로직을 그대로 미러링했습니다. _gem_pending / _trend_pending 플래그를 두고, ① 봇 초기화 시점에 “이번 달 리밸런스를 아직 안 했으면” arming(재시작·유예창 대응), ② 장 마감으로 보류될 때도 arming. 그리고 매 틱마다 장이 열리는 순간을 감지해 보류분을 실행합니다. 상태 파일로 멱등성을 잡아 부분 실패 시에도 다음 틱에 다시 시도하고요.
고친 뒤 추세 버킷이 처음으로 제대로 실행된 결과는 이랬습니다 — 목표 노출 83.31% → TQQQ 83.3% + BIL 16.7%의 변동성 타깃 분할. 시그널만 오고 주문은 안 나가던 버킷이, 드디어 실제 포지션을 잡았습니다.
한국에서 미국장을 자동매매하면 시차가 로직에 그대로 박힙니다. 데일리 틱이 항상 미국장 마감 시각(KST 13:00)에 뜬다면, 그 시각에 못 한 일을 “다음 틱에 하면 되지”라고 미루는 건 정규장 재진입 경로가 없는 한 영영 안 하는 것과 같습니다. 새 매매 로직을 스케줄러에 붙일 땐 반드시
보류 플래그 + 장 열림 감지 재시도를 함께 달아야 합니다.
두 함정의 공통점 — 한국에서 미국장을 본다는 것
거래소 코드와 시차는 전혀 다른 버그처럼 보이지만, 뿌리가 같습니다. 둘 다 겉보기엔 같은데 실은 다른 것을 코드가 같다고 취급해서 난 사고예요.
▶ 시계 — “날짜 바뀌었으니 리밸런스하면 되지”가 함정. 한국의 새 날(KST 13:00)과 미국의 장중은 겹치지 않는다.
한국에서 미국주식 봇을 돌린다는 건, 종목의 상장 거래소와 시장의 운영 시간이라는 두 좌표계를 늘 의식한다는 뜻입니다. 둘 다 크래시로 안 드러나고 조용한 미실행으로 새기 때문에, 로그를 안 보면 “봇이 잘 도는 줄” 착각하기 딱 좋습니다. 그래서 이런 봇은 “매수가 실제로 체결됐는가”를 상태 파일과 broker 잔고로 교차 확인하는 습관이 중요합니다.
② 거래소·계좌 같은 횡단 기본값 인자는 한 곳만 고치면 안 된다. 안쪽 헬퍼까지 전수 검색.
③ 한국에서 미국장 자동매매 = 시차가 로직에 박힌다. 장 마감 시각에 뜨는 틱의 “다음 틱 재시도”는 정규장 재진입 경로가 있어야만 참이다.
다음 편에서는 API 연동을 하다 보면 거의 반드시 만나는 인증·시크릿 관련 에러 4종과 그 진짜 원인을 모아 봅니다 — “유효하지 않은 AppKey”가 사실 키 문제가 아니었던 이야기부터요.
본 글은 공개된 데이터와 출처를 바탕으로 작성했습니다. 최종 업데이트: 2026-07-10