미국주식 자동매매 봇이 매일 두드리는 KIS API 세 가지 — 시세·잔고·주문

🤖 자동매매 봇이 하루 종일 실제로 두드리는 KIS API는 딱 세 개다

한국투자증권(KIS)의 REST API 위에 미국주식 자동매매 봇(이하 미장봇)을 올렸다. 화면에 보이는 전략은 화려하지만, 자동매매 루프가 하루 종일 실제로 호출하는 건 딱 세 종류다. 지금 얼마인가(시세), 내 계좌엔 무엇이 얼마나 있는가(잔고), 그리고 사고판다(주문). 이 세 개를 얇은 함수로 감싸면 그 위에 어떤 전략이든 얹을 수 있다.

이 글은 그 세 함수를 만들며 만난 것들 — 특히 “겉보기엔 같은데 실제론 다른” 지점들 — 을 실제 코드·로그와 함께 정리한 엔지니어링 기록이다. 특정 종목이나 전략의 매매를 권유하는 글이 아니다.

먼저, KIS REST API가 어떤 물건인가

KIS API는 증권사 계좌를 프로그램으로 조작하게 열어 준 HTTP 인터페이스다. 국내주식·해외주식·선물옵션까지 수백 개의 엔드포인트가 있지만, 각 요청은 거래 종류 식별자(tr_id)로 “이건 미국 현재가 조회”, “이건 미국 매수 주문”처럼 성격을 선언한다. 같은 URL이라도 tr_id가 다르면 전혀 다른 동작이 되기도 한다. 그래서 봇을 짤 때 실제로 외워야 할 건 URL보다 tr_id의 지도에 가깝다. 아래에서 반복해서 나올 여덟 자리 코드들(HHDFS00000300, CTRP6504R, TTTT1002U…)이 바로 그 지도의 지명들이다.

모든 호출이 공유하는 헤더 한 벌

세 함수 이야기를 하기 전에, 모든 KIS 호출이 공통으로 얹는 헤더부터. 인증은 OAuth2 client_credentials로 발급한 액세스 토큰을 Bearer로 실어 보내고, 여기에 앱키·앱시크릿을 함께 붙인다. 이 네 줄이 모든 요청의 밑바닥에 깔린다.

Python

@property
def headers(self) -> dict:
    return {
        "content-type": "application/json; charset=utf-8",
        "authorization": f"Bearer {self.token}",
        "appkey": self.app_key,
        "appsecret": self.app_secret,
    }

토큰은 한 번 받아 config/token.json에 캐시하고 약 23시간 재사용한다. 모의투자(paper)와 실전(live)은 서로 다른 도메인이라, 캐시에 is_virtual 플래그를 함께 저장해 환경이 바뀌면 캐시를 버리고 새로 받는다. 토큰 발급 자체에는 “분당 1회” 수준의 제한이 걸려 있어서, 실패하면 곧바로 재시도하지 않고 60초 → 5분 → … → 1시간으로 물러나는 지수 백오프를 둔다. 이 백오프를 안 넣었다가 겪은 사고는 다음 편(API 4대 에러)에서 따로 다룬다.

① 시세 — 거래소 코드가 3글자와 4글자로 갈린다

가장 단순한 호출. 현재가 조회는 tr_id HHDFS00000300 하나로 끝난다.

Python

url = f"{base_url}/uapi/overseas-price/v1/quotations/price"
headers = {"tr_id": "HHDFS00000300"}
excd = _EXCHANGE_MAP_4TO3.get(exchange, exchange)   # NASD → NAS
params = {"AUTH": "", "EXCD": excd, "SYMB": symbol}

여기서 첫 함정을 만난다. 같은 나스닥인데 주문 API는 네 글자 NASD, 시세 API는 세 글자 NAS를 쓴다. 겉보기엔 같은 “나스닥”인데 API 계열마다 표기가 다른 것이다.

📈 시세 계열
NAS
3글자 · EXCD 파라미터

🧾 주문·잔고 계열
NASD
4글자 · OVRS_EXCG_CD 파라미터

코드를 두 곳에 흩어 두면 언젠가 반드시 어긋나므로, 매핑을 한 곳에 박아 두고 시세 계열 호출 직전에만 변환한다.

Python

_EXCHANGE_MAP_4TO3 = {"NASD": "NAS", "NYSE": "NYS", "AMEX": "AMS"}

응답에서 실제로 꺼내 쓰는 필드는 last(현재가)·open·high·low·tvol(거래량) 정도다. 이 거래소 코드·시차 문제는 그 자체로 한 편을 할 만큼 지뢰가 많아 다음 편에서 이어 다룬다.

② 잔고 — 이름이 비슷한 API가 셋, 용도는 다르다

여기가 이 글의 핵심이다. “잔고를 본다”는 한 문장 뒤에 KIS에는 세 개의 서로 다른 API가 있고, 이름이 비슷해서 처음엔 아무거나 써도 될 것 같지만 필수 파라미터도 반환값도 다르다. 각각이 답하는 질문이 미묘하게 다르다는 게 핵심이다.

답하는 질문 API / tr_id 필수 파라미터
USD 예수금·출금가능액은? inquire-present-balance
CTRP6504R
WCRC_FRCR_DVSN_CD=02, NATN_CD=840 (종목코드 불필요)
이 종목, 몇 주 살 수 있나? inquire-psamount
TTTS3007R
ITEM_CD + OVRS_ORD_UNPR (둘 다 필수)
지금 뭘 몇 주 들고 있나? inquire-balance
TTTS3012R
(현금 정보 없음)

그래서 잔고 함수를 하나로 두되 인자에 따라 갈라지게 만들었다. “이 종목 몇 주 살 수 있나?”를 물으면 종목·단가가 필요한 주문가능수량 API로, 아무 인자 없이 “USD 현금 얼마?”를 물으면 종목이 필요 없는 예수금 API로.

Python

def get_us_balance(self, symbol="", unit_price=0.0):
    if symbol:
        # "이 종목을 이 단가에 몇 주?" → inquire-psamount / TTTS3007R
        # ITEM_CD 와 OVRS_ORD_UNPR 이 둘 다 있어야 한다
        params = {..., "OVRS_ORD_UNPR": f"{unit_price:.4f}", "ITEM_CD": symbol}
        headers = {"tr_id": "TTTS3007R"}
        ...  # returns available_cash + max_qty
    # symbol 이 없으면 → 무조건적 USD 현금 잔고
    # inquire-present-balance / CTRP6504R — ITEM_CD 불필요
    params = {..., "WCRC_FRCR_DVSN_CD": "02", "NATN_CD": "840"}
    headers = {"tr_id": "CTRP6504R"}
    ...  # output2 에서 crcy_cd == "USD" 행의 frcr_drwg_psbl_amt_1

보유 포지션(무엇을 몇 주 들고 있나)은 또 다른 inquire-balance(TTTS3012R)에서 output1 리스트로 받아 ovrs_cblc_qty(수량)·pchs_avg_pric(평단)을 꺼낸다. 이 API에는 현금 정보가 없다. 세 개를 용도별로 확실히 나눠 두지 않으면 바로 다음 절의 사고가 난다.

🔴 빈 문자열 하나가 부른 장애 — APBN0746

한동안 잔고 함수가 어떤 호출에선 정상, 어떤 호출에선 변덕스럽게 실패했다. 증상은 HTTP 500, 혹은 rt_cd=7APBN0746 "상품이 없습니다".

첫 가설은 자연스럽게 틀렸다. HTTP 500이니 “KIS 서버 일시 장애”라고 봤다. 그런데 잠시 뒤 같은 엔드포인트를 다른 시점에 호출하면 멀쩡히 200이 왔다. 서버가 문제라면 이렇게 결정적으로 갈릴 리가 없다 — 여기서 방향이 꺾였다. 서버가 아니라 내가 보낸 요청이 문제였다.

mermaid diagram

🔁 다이어그램 요약: HTTP 500이 떠도 재현이 변덕스러우면 서버가 아니라 내 요청을 의심한다 — request body를 덤프해 보니 ITEM_CD가 빈 값으로 나간 게 원인이었다.

진짜 원인은 한 줄이었다. 현금 잔고를 보려고 get_us_balance(symbol="")를 불렀는데, 분기 없이 항상 주문가능수량 API(inquire-psamount)로 흘러 ITEM_CD=""(빈 종목코드)로 나갔다. “상품이 없습니다”는 그러니까 서버가 정확히 맞는 말을 한 것이었다. 해결은 앞 절의 분기 그대로 — symbol이 비면 예수금 API로 보내면 끝난다.

이 사건이 남긴 진짜 교훈은 분기 자체보다 로깅이었다. KIS는 4xx/5xx를 던질 때 본문 JSON의 msg_cd·msg1에 진짜 원인을 담아 준다. 상태 코드만 찍으면 “500 떴다”까지밖에 모르고, 여러 KIS 호출이 몰리는 구간에선 어느 tr_id가 터졌는지도 안 보인다. 그래서 실패 로그에 엔드포인트·tr_id·응답 본문을 함께 남기도록 바꿨다.

Python

logger.warning(
    f"KIS HTTP {resp.status_code} [{endpoint} tr_id={tr_id}] "
    f"(attempt {attempt}/{max_attempts}): {body_snip}")

🧠 이 한 줄 덕분에 이후 비슷한 500은 “서버 탓일까”를 고민할 것도 없이 로그에서 tr_id와 msg_cd를 같이 읽고 바로 원인을 짚게 됐다. HTTP 500을 만나면 서버를 의심하기 전에 내가 방금 보낸 request body부터 덤프한다 — 이게 이 시리즈에서 반복될 원칙이다.

③ 주문 — 한 번만 쏘고 재시도하지 않는다

시세·잔고는 GET이라 실패하면 재시도해도 그만이지만, 주문(POST)은 다르다. 자동 재시도가 곧 중복 주문이 될 수 있어서, 공용 요청 함수에 retry 스위치를 두고 주문에서만 꺼 버린다. “네트워크가 끊긴 건지, 주문은 들어갔는데 응답만 못 받은 건지”를 클라이언트가 확신할 수 없기 때문에, 애매하면 다시 쏘지 않는 쪽이 안전하다.

Python

# retry=False: POST 주문은 재시도하지 않음 (중복 주문 방지)
data = self.client._request("POST", url, headers=headers,
                            json_body=body, retry=False)

주문 tr_id는 모의·실전에서 또 갈린다. 같은 “미국 매수”라도 실전은 TTTT1002U, 모의투자는 VTTT1002U다. 이 분기를 모드 초기화 때 한 번만 정해 두면 이후 코드는 매수·매도만 신경 쓰면 된다.

Python

if trading_mode == "live":
    self.buy_tr_id, self.sell_tr_id = "TTTT1002U", "TTTT1006U"
else:  # 모의투자(paper)
    self.buy_tr_id, self.sell_tr_id = "VTTT1002U", "VTTT1006U"

한 가지 더. KIS 해외주식은 진짜 시장가 주문을 지원하지 않아서, “시장가처럼” 즉시 체결시키려면 현재가에 약간의 슬리피지를 얹은 지정가로 낸다(매수는 조금 위, 매도는 조금 아래). 이 슬리피지·수수료 버퍼를 사이징과 어긋나게 잡으면 “주문가능금액 초과”로 거부당하는데, 그 이야기는 운영 편에서 따로 다룬다.

세 함수가 자동매매의 최소 골격이다

정리하면 이렇다. 시세로 현재가를 얻어 몇 주를 살지 계산하고, 잔고로 실제로 그만큼 살 현금이 있는지 확인하고, 주문으로 집행한다. 전략이 아무리 복잡해도 브로커와 맞닿는 표면은 결국 이 얇은 세 함수다.

mermaid diagram

📊 다이어그램 요약: 자동매매 루프는 시세로 현재가를 얻어 매수 수량을 계산하고, 잔고로 살 현금이 있는지 확인한 뒤, 주문으로 집행한다 — 브로커와 맞닿는 표면은 이 세 함수뿐이다.

그리고 그 얇은 층에서 배운 건 대부분 “이름이 비슷해도 같은 게 아니다”였다 — 3글자와 4글자로 갈리는 거래소 코드, 세 갈래로 나뉜 잔고 API, GET과 달리 재시도하면 안 되는 주문. API 문서를 훑을 때 “비슷해 보이는 것”을 만나면 오히려 더 의심하고 필수 파라미터와 반환 스키마를 하나씩 대조하는 습관이, 결국 이 봇을 안정적으로 굴리는 데 가장 크게 기여했다.

이 편에서 챙겨 갈 세 가지

✓ 시세와 주문은 거래소 코드 자릿수가 다르다 — 매핑은 한 곳에 박고 계열별로만 변환한다.

✓ 잔고는 질문에 따라 API가 셋 — 인자 유무로 분기해 빈 종목코드가 새어 나가지 않게 한다.

✓ HTTP 500이 변덕스럽게 나면 서버가 아니라 내 요청을 덤프한다 — 로그에 tr_id·msg_cd를 남기는 것만으로 디버깅 시간이 절반으로 준다.

다음 편에서는 여기서 살짝 맛본 거래소 코드와 시차의 함정(주문은 왜 네 글자인가, 데일리 틱은 왜 한국 시간 오후 1시에 떨어지는가)을 정면으로 다룬다.

이 글은 자동매매 시스템을 만들며 남긴 개발 기록이며, 특정 종목·전략의 매매를 권유하지 않습니다. 투자 판단과 그 결과에 대한 책임은 투자자 본인에게 있습니다. 모든 코드·수치는 실제 저장소 기준입니다.

G
GraceMoon
소프트웨어 개발 기록

소프트웨어 개발 관점에서 자료를 모아 직접 정리하고, 올리기 전에 한 번 더 확인합니다.

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

댓글 달기

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

위로 스크롤