AlgoLab Blog · 키움 계좌 실무 · 2026

키움 REST API 잔고조회 — kt00018 파싱 함정

키움증권 · 계좌 2026-08-20 · 약 7분 읽기 · 알고랩 AlgoLab
한 줄 요약 키움 REST API 잔고조회는 kt00018(계좌평가잔고내역요청), POST /api/dostk/acnt입니다. 필수 항목은 조회구분 qry_tp(1 합산 / 2 개별)와 국내거래소구분 dmst_stex_tp(KRX / NXT) 둘뿐이라 붙이기는 쉽습니다. 어려운 건 읽는 쪽입니다. 보유 종목 목록은 acnt_evlt_remn_indv_tot라는 긴 이름으로 오고, 그 안의 종목번호는 005930이 아니라 A005930 (접두어 A 주식 / J ELW / Q ETN)입니다. 금액·수량은 부호 포함 15자리를 0으로 채운 문자열이고, rmnd_qty(보유수량)와 trde_able_qty(매매가능수량)는 다른 값입니다.

봇이 매도 판단을 하려면 "지금 뭘 몇 주 들고 있나"를 알아야 합니다. 키움 REST API 주식 주문(kt10000)으로 주문을 내고, ka10075로 미체결을 추적하는 데까지 왔다면 남은 조각이 이 잔고입니다.

그런데 kt00018은 붙이는 난이도와 읽는 난이도가 크게 다릅니다. 요청 항목이 두 개뿐이라 5분이면 200을 받습니다. 문제는 그다음 — 받은 값을 그대로 쓰면 주문이 거부되거나, 에러 없이 틀린 수량이 나갑니다. 이 글은 붙이는 방법이 아니라 읽는 방법을 다룹니다.

목차

  1. 요청 실물 — 항목이 두 개뿐이다
  2. 응답은 두 층으로 온다
  3. 함정 ① 종목코드에 접두어가 붙는다
  4. 함정 ② 숫자가 0으로 채워진 문자열이다
  5. 함정 ③ 보유수량 ≠ 매매가능수량
  6. 함정 ④ dmst_stex_tp는 KRX / NXT 문자열이다
  7. 포지션 구조체로 정규화하기
  8. 자주 묻는 것

1. 요청 실물 — 항목이 두 개뿐이다

항목값
API IDkt00018 (계좌평가잔고내역요청)
Method / URLPOST /api/dostk/acnt
실전 / 모의 도메인https://api.kiwoom.com / https://mockapi.kiwoom.com
필수 헤더api-id · authorization(Bearer + 접근토큰) · Content-Type
필수 요청 항목qry_tp(조회구분 · 1 합산 / 2 개별)
dmst_stex_tp(국내거래소구분 · KRX / NXT)
연속조회응답 헤더 cont-yn · next-key
import requests

BASE  = "https://api.kiwoom.com"      # 모의투자는 https://mockapi.kiwoom.com
TOKEN = "..."                          # au10001로 발급받은 접근토큰

def balance(qry_tp="1", stex="KRX"):
    headers = {
        "Content-Type":  "application/json;charset=UTF-8",
        "authorization": f"Bearer {TOKEN}",
        "api-id":        "kt00018",
    }
    body = {
        "qry_tp":       qry_tp,        # 1:합산, 2:개별
        "dmst_stex_tp": stex,          # KRX 또는 NXT  ← 숫자가 아니다
    }
    r = requests.post(f"{BASE}/api/dostk/acnt", headers=headers, json=body, timeout=5)
    r.raise_for_status()
    return r.json()

계좌번호를 넣는 항목이 없다는 점을 눈여겨보십시오. 키움 REST API는 접근토큰이 계좌를 이미 지정하기 때문에 요청에 계좌번호를 실을 자리가 없습니다. 한국투자증권 KIS가 CANO와 ACNT_PRDT_CD를 매 요청에 요구하는 것과 반대입니다. 토큰이 어디서 나오는지는 키움 접근토큰 유효기간과 expires_dt에 정리해 두었습니다.

2. 응답은 두 층으로 온다

kt00018 응답은 계좌 전체 요약이 최상위에 있고, 보유 종목 목록이 그 아래 리스트로 들어 있는 구조입니다.

층항목의미
계좌 전체tot_pur_amt총매입금액
tot_evlt_amt총평가금액
tot_evlt_pl총평가손익금액
tot_prft_rt총수익률(%) — 소수점 둘째 자리
prsm_dpst_aset_amt추정예탁자산
종목별
(acnt_evlt_remn_indv_tot)
stk_cd종목번호 — 접두어 1자리 + 코드 6자리
stk_nm종목명
rmnd_qty보유수량
trde_able_qty매매가능수량
pur_pric · cur_prc매입가 · 현재가
evltv_prft · prft_rt평가손익 · 수익률(%)
pur_amt · evlt_amt매입금액 · 평가금액
pur_cmsn · sell_cmsn · tax · sum_cmsn매입수수료 · 평가수수료 · 세금 · 수수료합

이 밖에 pred_buyq·pred_sellq(전일 매수·매도수량), tdy_buyq·tdy_sellq(금일 매수·매도수량), poss_rt(보유비중), crd_tp·crd_tp_nm·crd_loan_dt(신용구분·대출일)가 함께 옵니다.

응답의 형태는 이렇게 생겼습니다(항목명은 공식 명세, 값은 이해를 돕기 위한 예시입니다).

{
  "tot_pur_amt":  "000000000718000",
  "tot_evlt_amt": "000000000730000",
  "tot_evlt_pl":  "000000000012000",
  "tot_prft_rt":  "1.67",
  "acnt_evlt_remn_indv_tot": [
    {
      "stk_cd":        "A005930",
      "stk_nm":        "삼성전자",
      "rmnd_qty":      "000000000000010",
      "trde_able_qty": "000000000000007",
      "pur_pric":      "000000000071800",
      "cur_prc":       "000000073000",
      "evltv_prft":    "000000000012000",
      "prft_rt":       "1.67",
      "poss_rt":       "100.00"
    }
  ],
  "return_code": 0,
  "return_msg":  "정상적으로 처리되었습니다"
}

이 한 덩어리 안에 오늘 다룰 함정 세 개가 전부 들어 있습니다. A005930, 000000000000010, 그리고 rmnd_qty 10과 trde_able_qty 7의 차이입니다.

3. 함정 ① 종목코드에 접두어가 붙는다

공식 명세는 kt00018 응답의 stk_cd를 접두어 1자리 + 종목코드 6자리로 정의하고, 접두어를 A 주식 / J ELW / Q ETN으로 설명합니다. 그래서 삼성전자는 A005930으로 옵니다.

여기서 사고가 납니다. 주문 TR kt10000은 접두어 없는 6자리 종목코드를 받습니다. 잔고에서 읽은 A005930을 그대로 주문에 넣으면 매도해야 할 종목을 찾지 못합니다. 반대로 접두어를 무조건 잘라 내면 ELW·ETN인지 아닌지를 잃어버립니다.

PREFIX = {"A": "STOCK", "J": "ELW", "Q": "ETN"}

def split_code(raw):
    """'A005930' → ('005930', 'STOCK')"""
    raw = (raw or "").strip()
    if len(raw) == 7 and raw[0] in PREFIX:
        return raw[1:], PREFIX[raw[0]]
    return raw, "UNKNOWN"        # 6자리로 오는 경우도 방어

code, kind = split_code(row["stk_cd"])   # ('005930', 'STOCK')

종목코드 표기가 자리마다 다른 것은 키움만의 일이 아닙니다. 마스터 파일에서 코드를 뽑을 때도 같은 문제가 생기고, 코스피·코스닥 종목코드 마스터 파일 파싱에 그 계열의 함정을 정리해 두었습니다. 내부에서는 항상 6자리 순수 코드 하나로 통일하고, 증권사별 표기는 어댑터 경계에서만 변환하는 것이 원칙입니다.

4. 함정 ② 숫자가 0으로 채워진 문자열이다

명세는 금액 항목을 "좌측 0-padding 처리된 부호 포함 15자리 숫자"로, 현재가·전일종가는 12자리로 정의합니다. 수량도 같은 방식입니다. 즉 "000000000000010"은 10주라는 뜻이고 앞의 0에는 의미가 없습니다.

파이썬에서는 int()가 앞의 0을 알아서 무시하므로 변환만 하면 끝납니다. 위험한 건 변환하지 않고 그대로 쓰는 경우입니다.

# 나쁜 예 — 전부 조용히 틀린다
if row["rmnd_qty"] > "0":        # 문자열 사전순 비교
    ...
qty = row["rmnd_qty"][-3:]        # 자릿수를 세어 자르기 (수량이 커지면 깨진다)

# 좋은 예 — 경계에서 한 번만 변환한다
def i(v, d=0):
    try:    return int(str(v).strip() or d)
    except (TypeError, ValueError): return d

def f(v, d=0.0):
    try:    return float(str(v).strip() or d)
    except (TypeError, ValueError): return d

qty  = i(row["rmnd_qty"])         # 10
rate = f(row["prft_rt"])          # 1.67

수익률만 타입이 다릅니다. prft_rt와 tot_prft_rt는 소수점 둘째 자리까지 포맷된 백분율이라 int()로 변환하면 ValueError가 납니다. 금액·수량은 int, 비율은 float로 나누어 처리하십시오. 부호가 앞에 붙어 올 수 있으므로 문자열을 잘라서 판단하지 마십시오 — 손실 구간에서만 자릿수가 하나 밀립니다.

5. 함정 ③ 보유수량 ≠ 매매가능수량

예시에서 rmnd_qty는 10인데 trde_able_qty는 7이었습니다. 3주가 묶여 있다는 뜻입니다. 대표적인 이유는 이렇습니다.

매도 수량을 rmnd_qty로 계산하면 주문이 거부됩니다. 그리고 이 거부는 봇 입장에서 가장 나쁜 시점에 납니다 — 손절 조건이 걸려 급히 팔아야 할 때입니다. 매도 가능 수량은 trde_able_qty를 기준으로 하고, 여기에 더해 지금 미체결로 걸어 둔 매도 주문 수량까지 빼야 중복 주문을 막습니다.

def sellable(row, pending_sell_qty=0):
    """실제로 지금 낼 수 있는 매도 수량"""
    return max(0, i(row["trde_able_qty"]) - pending_sell_qty)

pending_sell_qty는 ka10075 미체결 조회에서 같은 종목의 매도 주문 oso_qty를 합산해 얻습니다. 잔고와 미체결을 같이 읽어야 비로소 "낼 수 있는 수량"이 나옵니다. 둘 중 하나만 보는 봇은 반드시 언젠가 중복 주문을 냅니다.

rmnd_qty (보유수량) 10주 trde_able_qty (매매가능수량) 7주 결제 미완·신용 3주 지금 낼 수 있는 수량 5주 미체결 2주
보유수량에서 두 번 깎여야 실제 주문 가능 수량이 나온다

6. 함정 ④ dmst_stex_tp는 KRX / NXT 문자열이다

같은 /api/dostk/acnt 아래에 있는 다른 TR들은 거래소를 stex_tp라는 이름의 숫자 문자열(0 통합 / 1 KRX / 2 NXT)로 받습니다. 그런데 kt00018만 dmst_stex_tp라는 다른 이름이고 값도 "KRX" · "NXT" 같은 문자열입니다.

# 다른 TR과 헷갈리기 쉬운 지점
ka10075_body = {"all_stk_tp": "0", "trde_tp": "0", "stex_tp": "0"}      # 숫자
kt00018_body = {"qry_tp": "1",     "dmst_stex_tp": "KRX"}               # 문자열

NXT 출범 이후 같은 종목이 두 시장에서 거래되기 때문에 이 구분이 생겼습니다. 어느 쪽 기준으로 평가된 잔고를 받을지가 여기서 갈리므로, 봇의 주문 라우팅과 잔고 조회의 기준을 같은 값으로 맞춰 두십시오. 배경은 NXT·KRX 주문 라우팅에 정리돼 있습니다.

2026-10-29 18시부터 kt00018은 초당 3회다

키움이 2026-10-27 공지로 kt00018의 유량제어를 초당 5회에서 초당 3회로 낮췄습니다. 적용은 10월 29일 오후 6시 이후이고, 다른 TR의 기준은 그대로입니다. 초과하면 유량제어 오류로 조회가 제한될 수 있다는 것이 공지 내용입니다. 공통 호출 함수 간격을 초당 5회(0.2초)에 맞춰 두었다면 이 TR만 따로 늦춰야 합니다. cont-yn·next-key로 넘기는 연속조회도 호출이므로 페이지 사이에 같은 간격을 둡니다.

import time

KT00018_MIN_GAP = 0.34          # 초당 3회 (키움 공지 2026-10-27)
_last_kt00018 = 0.0

def wait_kt00018():
    global _last_kt00018
    gap = time.monotonic() - _last_kt00018
    if gap < KT00018_MIN_GAP:
        time.sleep(KT00018_MIN_GAP - gap)
    _last_kt00018 = time.monotonic()

이 함수는 프로세스 하나 안에서만 간격을 지킵니다. 잔고는 봇 시작 때 한 번 전체를 읽고, 장 시작 전 대조는 장전 점검 5가지처럼 하루 일과에 넣으면 호출을 크게 늘리지 않아도 됩니다.

7. 포지션 구조체로 정규화하기

지금까지의 함정을 한 곳에 모으면, 잔고를 읽는 계층은 이렇게 정리됩니다. 이 함수 바깥에서는 문자열도, 접두어도, 0-padding도 보이지 않아야 합니다.

def positions(stex="KRX"):
    res  = balance(qry_tp="1", stex=stex)
    rows = res.get("acnt_evlt_remn_indv_tot", [])   # ← 리스트 이름 주의

    out = []
    for r in rows:
        code, kind = split_code(r["stk_cd"])
        out.append({
            "code":     code,                       # '005930'
            "kind":     kind,                       # 'STOCK'
            "name":     r["stk_nm"],
            "qty":      i(r["rmnd_qty"]),           # 보유수량
            "sellable": i(r["trde_able_qty"]),      # 매매가능수량
            "avg":      i(r["pur_pric"]),           # 매입가
            "last":     i(r["cur_prc"]),            # 현재가
            "pl":       i(r["evltv_prft"]),         # 평가손익
            "pl_rate":  f(r["prft_rt"]),            # 수익률 %
        })

    summary = {
        "buy":  i(res["tot_pur_amt"]),
        "eval": i(res["tot_evlt_amt"]),
        "pl":   i(res["tot_evlt_pl"]),
        "rate": f(res["tot_prft_rt"]),
    }
    return summary, out

보유 종목이 많다면 연속조회를 잊지 마십시오. 응답 헤더 cont-yn이 Y면 목록이 잘린 것이고 next-key로 이어 받아야 합니다. 이걸 빼먹으면 앞부분 종목만 보고 "나머지는 없다"고 판단합니다. 구현 패턴은 키움 REST API 연속조회 — cont-yn·next-key에 있습니다.

한국투자증권 KIS를 함께 쓰신다면 대응 TR은 잔고조회이고 항목명 체계가 전혀 다릅니다. KIS 잔고조회와 연속조회 처리와 비교해 보면, 두 증권사를 하나의 포지션 구조체로 정규화하는 어댑터가 왜 필요한지가 분명해집니다.

자주 묻는 것

qry_tp 1(합산)과 2(개별)는 뭐가 다른가요?

같은 종목을 여러 번에 걸쳐 매수했을 때 한 줄로 합쳐서 볼지, 나누어 볼지의 차이입니다. 봇에서 포지션 수량과 평균단가만 필요하다면 1(합산)이 다루기 쉽습니다. 매입 시점별로 구분해 관리하는 전략이라면 2(개별)를 씁니다.

잔고에 현금(예수금)은 왜 안 보이나요?

kt00018은 평가잔고가 주제입니다. 최상위의 prsm_dpst_aset_amt(추정예탁자산)로 대략을 볼 수 있지만, 주문 가능 금액을 정확히 알아야 한다면 예수금 계열 TR을 따로 호출하는 편이 맞습니다. "평가금액이 있으니 그만큼 살 수 있다"고 계산하면 매수 주문이 거부됩니다.

kt00018은 초당 몇 번까지 부를 수 있나요?

키움 공지(2026-10-27)에 따라 2026-10-29 오후 6시 이후 초당 3회입니다. 이전 기준은 초당 5회였고 다른 TR의 기준은 바뀌지 않았습니다. 연속조회 페이지도 호출에 포함해 간격을 두십시오.

이 값들을 그대로 믿어도 되나요?

이 글의 API ID·URL·요청/응답 항목명과 접두어·자릿수 규칙은 2026-08-20 시점 키움증권 REST API 공식 명세에서 확인한 것이고, 코드 안의 값은 이해를 돕기 위한 예시입니다. 증권사 명세는 예고 없이 바뀌므로 운영에 넣기 전 개발자 포털의 현재 명세로 대조하시고, 호출 빈도는 요청 개수 제한을 감안해 설계하시기 바랍니다. 유량제어 값은 2026-11-04에 키움 공지로 다시 확인했습니다.

계좌 상태를 정확히 읽는 봇이 필요하다면

잔고·미체결·예수금을 하나의 상태로 묶어 실제로 돌아가는 자동매매 프로그램으로 만들어 드립니다. 24시간 빠른 답변 가능합니다.

무료 상담 시작하기