NASD인가 AMEX인가 — KIS로 미국 ETF 자동매매할 때 만나는 거래소 코드와 시차

미장봇을 만들 때, KIS(한국투자증권) 해외주식 API는 처음엔 순하게 굴었습니다. 대상이 TQQQ / SQQQ 둘뿐이었으니까요. 시세를 부르면 값이 오고, 매수를 넣으면 체결됐습니다. 그런데 포트폴리오를 여러 버킷으로 넓혀 SPMO·MTUM·QUAL·SPY·VEU·AGG·BIL 같은 ETF를 담기 시작하자, 봇이 조용히 아무것도 안 사기 시작했습니다.

크래시가 난 게 아닙니다. 에러 팝업도, 스택 트레이스도 없었습니다. 그냥 “사야 할 게 있는데 주문이 안 나가는” 두 가지 상황이 따로 터졌고, 둘 다 원인이 같은 곳을 가리켰습니다 — 한국에서 미국장을 다룰 때 생기는 두 개의 좌표계, 거래소(어디에 상장됐나)와 시계(지금 장이 열렸나). 이번 글은 그 두 함정을 실제 로그·코드와 함께 풀어 봅니다. 미국주식 자동매매 봇을 처음 짜는 분이라면 거의 반드시 만나는 지점입니다.

🧭 이 글에서 다루는 세 가지
NASD vs AMEX — 미국 ETF마다 KIS 거래소 코드가 다르고, 틀리면 조용히 실패한다
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"로 불러야 합니다. 멀티버킷을 켜자마자 첫 시드에서 이런 로그가 떴습니다.

text

Initial seed: cannot fetch price for SPMO
BUY: Cannot get price for SPMO

2026년 5월 15일, 멀티버킷 시드를 처음 돌린 날. 거래소 코드가 안 맞아 2회 연속 0건 매수로 끝났습니다. 해법 자체는 단순합니다 — 티커별로 올바른 거래소를 박아 둔 매핑 테이블과, 그걸 조회하는 헬퍼 하나.

Python

# 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)만 붙이면 끝날 거라고요. 고치고, 봇을 재기동하고, 시드를 다시 돌렸더니 — 토씨 하나 안 틀리고 같은 에러가 다시 났습니다.

text

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)을 명시하고서야 조용해졌습니다.
💡 교훈 — 횡단(cross-cutting) 기본값 인자는 모든 레이어를 뒤져라
거래소·계좌·리전처럼 여러 함수가 공유하는 기본값은, 한 호출 지점만 고쳐도 안쪽 헬퍼에 같은 함정이 또 있을 확률이 높다. “한 군데 고치면 끝”이라는 가정을 버리고 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 재시도 갭을 찾아냈습니다. 아래는 그 틱 로직을 정리한 그림입니다.

mermaid diagram

🔁 다이어그램 요약: 새 거래일 틱은 미국장 마감 시각에 뜨므로 월말 리밸런스가 곧장 보류된다 — 핵심은 보류분을 pending 플래그로 기억했다가 나중에 정규장(RTH)에 재진입하는 순간 다시 실행하는 것. 이 재시도 경로가 없으면 주문은 영영 안 나간다.

수정은 시드 로직을 그대로 미러링했습니다. _gem_pending / _trend_pending 플래그를 두고, ① 봇 초기화 시점에 “이번 달 리밸런스를 아직 안 했으면” arming(재시작·유예창 대응), ② 장 마감으로 보류될 때도 arming. 그리고 매 틱마다 장이 열리는 순간을 감지해 보류분을 실행합니다. 상태 파일로 멱등성을 잡아 부분 실패 시에도 다음 틱에 다시 시도하고요.

고친 뒤 추세 버킷이 처음으로 제대로 실행된 결과는 이랬습니다 — 목표 노출 83.31%TQQQ 83.3% + BIL 16.7%의 변동성 타깃 분할. 시그널만 오고 주문은 안 나가던 버킷이, 드디어 실제 포지션을 잡았습니다.

💡 교훈 — 장 마감 시각에 뜨는 틱에서 “다음 틱에 재시도”는 거짓말이다
한국에서 미국장을 자동매매하면 시차가 로직에 그대로 박힙니다. 데일리 틱이 항상 미국장 마감 시각(KST 13:00)에 뜬다면, 그 시각에 못 한 일을 “다음 틱에 하면 되지”라고 미루는 건 정규장 재진입 경로가 없는 한 영영 안 하는 것과 같습니다. 새 매매 로직을 스케줄러에 붙일 땐 반드시 보류 플래그 + 장 열림 감지 재시도를 함께 달아야 합니다.

두 함정의 공통점 — 한국에서 미국장을 본다는 것

거래소 코드와 시차는 전혀 다른 버그처럼 보이지만, 뿌리가 같습니다. 둘 다 겉보기엔 같은데 실은 다른 것을 코드가 같다고 취급해서 난 사고예요.

거래소 — “미국 ETF니까 다 NASD겠지”가 함정. 나스닥과 NYSE Arca는 같은 미국 시장이지만 KIS에겐 다른 코드다.
시계 — “날짜 바뀌었으니 리밸런스하면 되지”가 함정. 한국의 새 날(KST 13:00)과 미국의 장중은 겹치지 않는다.

한국에서 미국주식 봇을 돌린다는 건, 종목의 상장 거래소와 시장의 운영 시간이라는 두 좌표계를 늘 의식한다는 뜻입니다. 둘 다 크래시로 안 드러나고 조용한 미실행으로 새기 때문에, 로그를 안 보면 “봇이 잘 도는 줄” 착각하기 딱 좋습니다. 그래서 이런 봇은 “매수가 실제로 체결됐는가”를 상태 파일과 broker 잔고로 교차 확인하는 습관이 중요합니다.

📌 이 편에서 챙겨 갈 세 가지
① 미국 ETF의 KIS 거래소 코드는 종목마다 다르다. 상장 거래소(NASD vs AMEX)를 확인하고 매핑 테이블로 관리하라.

② 거래소·계좌 같은 횡단 기본값 인자는 한 곳만 고치면 안 된다. 안쪽 헬퍼까지 전수 검색.

③ 한국에서 미국장 자동매매 = 시차가 로직에 박힌다. 장 마감 시각에 뜨는 틱의 “다음 틱 재시도”는 정규장 재진입 경로가 있어야만 참이다.

다음 편에서는 API 연동을 하다 보면 거의 반드시 만나는 인증·시크릿 관련 에러 4종과 그 진짜 원인을 모아 봅니다 — “유효하지 않은 AppKey”가 사실 키 문제가 아니었던 이야기부터요.

※ 이 글은 미국주식 자동매매 봇을 직접 만들며 겪은 엔지니어링·연구 기록입니다. 특정 종목·전략의 매수나 매도를 권유하지 않으며, 소개한 코드·설정은 학습용 예시로 실제 운용 환경에 따라 결과가 다를 수 있습니다. 모든 투자 판단과 책임은 투자자 본인에게 있습니다.

G
GraceMoon
데이터로 보는 시장·일상 기록

공개 데이터(시장·뉴스·실거래)를 모아 직접 정리하고, 올리기 전에 사실관계를 한 번 더 확인합니다.

본 글은 공개된 데이터와 출처를 바탕으로 작성했습니다. 최종 업데이트: 2026-07-10

댓글 달기

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

위로 스크롤