키움 REST API 잔고조회 — kt00018 파싱 함정
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을 받습니다.
문제는 그다음 — 받은 값을 그대로 쓰면 주문이 거부되거나, 에러 없이 틀린 수량이 나갑니다.
이 글은 붙이는 방법이 아니라 읽는 방법을 다룹니다.
목차
- 요청 실물 — 항목이 두 개뿐이다
- 응답은 두 층으로 온다
- 함정 ① 종목코드에 접두어가 붙는다
- 함정 ② 숫자가 0으로 채워진 문자열이다
- 함정 ③ 보유수량 ≠ 매매가능수량
- 함정 ④ dmst_stex_tp는 KRX / NXT 문자열이다
- 포지션 구조체로 정규화하기
- 자주 묻는 것
1. 요청 실물 — 항목이 두 개뿐이다
| 항목 | 값 |
|---|---|
| API ID | kt00018 (계좌평가잔고내역요청) |
| Method / URL | POST /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주가 묶여 있다는 뜻입니다. 대표적인 이유는 이렇습니다.
- 이미 매도 주문을 걸어 둔 물량 — 호가창에 나가 있는 동안 잠깁니다.
- 결제가 끝나지 않은 당일 매수분 — 국내 주식은 매수 즉시 전량이 자유롭게 처분되지 않는 구간이 있습니다.
- 신용·대주 관련 제약 —
crd_tp가 붙은 물량입니다.
매도 수량을 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를 합산해 얻습니다.
잔고와 미체결을 같이 읽어야 비로소 "낼 수 있는 수량"이 나옵니다.
둘 중 하나만 보는 봇은 반드시 언젠가 중복 주문을 냅니다.
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 주문 라우팅에 정리돼 있습니다.
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을 따로 호출하는 편이 맞습니다.
"평가금액이 있으니 그만큼 살 수 있다"고 계산하면 매수 주문이 거부됩니다.
이 값들을 그대로 믿어도 되나요?
이 글의 API ID·URL·요청/응답 항목명과 접두어·자릿수 규칙은 2026-08-20 시점 키움증권 REST API 공식 명세에서 확인한 것이고, 코드 안의 값은 이해를 돕기 위한 예시입니다. 증권사 명세는 예고 없이 바뀌므로 운영에 넣기 전 개발자 포털의 현재 명세로 대조하시고, 호출 빈도는 요청 개수 제한을 감안해 설계하시기 바랍니다.
계좌 상태를 정확히 읽는 봇이 필요하다면
잔고·미체결·예수금을 하나의 상태로 묶어 실제로 돌아가는 자동매매 프로그램으로 만들어 드립니다. 24시간 빠른 답변 가능합니다.
무료 상담 시작하기