KIS API 잔고조회 — 50건에서 잘리는 연속조회 해결
koreainvestment/open-trading-api)의 주식잔고조회 샘플 주석에는
"실전계좌는 한 번의 호출에 최대 50건, 모의계좌는 최대 20건"이라고 그대로 적혀 있습니다.
나머지를 받으려면 ① 응답 헤더의 tr_cont가 F 또는 M인지 보고
② 응답 본문의 ctx_area_fk100·ctx_area_nk100을 다음 요청의
CTX_AREA_FK100·CTX_AREA_NK100에 되돌려 넣고
③ 두 번째 호출부터는 요청 헤더 tr_cont에 N을 넣어 반복하면 됩니다.
tr_id는 실전 TTTC8434R, 모의 VTTC8434R입니다.
KIS API 발급 가이드대로 appkey·appsecret을 받고
첫 주문까지 성공한 다음, 대부분 두 번째로 부딪히는 곳이 잔고조회입니다.
증상이 특이합니다. 에러가 안 납니다.
rt_cd는 0이고 응답도 정상인데, 보유 종목이 30개인 계좌에서 20개만 돌아옵니다.
그래서 사람들이 엉뚱한 곳을 뒤집니다 — 계좌번호가 틀렸나, INQR_DVSN을 잘못 넣었나,
에러코드 문제인가.
전부 아닙니다. 정상 동작입니다. 이 글은 그 하나만 다룹니다.
이 글의 순서
- 왜 잘리는가 — 실전 50건 · 모의 20건
- 연속조회의 실제 구조 (요청·응답 3요소)
TTTC8434R파라미터 전체- 동작하는 코드
output1과output2— 여기서 총액이 부풀려진다- 막히는 지점 5가지
- 체크리스트
1. 왜 잘리는가 — 실전 50건 · 모의 20건
한국투자증권 공식 깃허브 저장소의 주식잔고조회 예제 inquire_balance.py 문서화 주석에는
다음 문장이 그대로 들어 있습니다.
즉 모의투자로 개발하면 20건에서, 실전으로 넘어가면 50건에서 잘립니다. 모의에서 실전으로 전환할 때 이 숫자가 바뀐다는 점이 특히 고약합니다. 모의에서 20개 넘겨 테스트해 본 적이 없으면, 실전에서 51번째 종목을 산 날 처음 터집니다.
이것이 조용한 사고가 되는 이유. 잔고를 잘못 읽은 봇은 이미 보유한 종목을 "없다"고 판단합니다. 그러면 같은 종목을 또 삽니다. 손절 로직도 51번째 종목은 영원히 보지 못합니다. 에러 로그에는 아무것도 안 남습니다 — API는 성공했으니까요.
2. 연속조회의 실제 구조 (요청·응답 3요소)
KIS의 연속조회는 흔한 page=2 방식이 아닙니다.
서버가 "어디까지 읽어 줬는지"를 담은 표시를 응답에 실어 보내고, 클라이언트가 그것을 그대로 돌려주는 방식입니다.
움직이는 값은 딱 세 개입니다.
| 값 | 어디에 있나 | 규칙 |
|---|---|---|
tr_cont | 헤더 (요청·응답 양쪽) | 첫 요청 "" → 이후 요청 "N" / 응답이 F·M이면 더 있음 |
CTX_AREA_FK100 | 요청 파라미터 | 첫 호출 빈 값 → 응답 본문 ctx_area_fk100을 그대로 |
CTX_AREA_NK100 | 요청 파라미터 | 첫 호출 빈 값 → 응답 본문 ctx_area_nk100을 그대로 |
가장 헷갈리는 지점. tr_cont는 헤더에 있고,
ctx_area_fk100·ctx_area_nk100은 응답 본문에 있습니다.
그리고 응답 필드는 소문자, 요청 파라미터는 대문자입니다.
res.json()["ctx_area_nk100"]으로 꺼내서 "CTX_AREA_NK100"에 넣는 것이 맞습니다.
이 대소문자를 맞추지 못해 2페이지가 영원히 1페이지와 같아지는 사고가 흔합니다.
3. TTTC8434R 파라미터 전체
엔드포인트는 /uapi/domestic-stock/v1/trading/inquire-balance, 방식은 GET입니다.
tr_id는 실전 TTTC8434R, 모의 VTTC8434R로 갈립니다.
파라미터는 대부분 필수라 하나라도 빠지면 조회가 안 됩니다.
| 파라미터 | 의미 | 값 |
|---|---|---|
CANO | 종합계좌번호 | 계좌번호 8-2 체계의 앞 8자리 |
ACNT_PRDT_CD | 계좌상품코드 | 뒤 2자리 (보통 01) |
AFHR_FLPR_YN | 시간외단일가·거래소여부 | N 기본 / Y 시간외단일가 / X NXT |
OFL_YN | 오프라인여부 | 빈 값 |
INQR_DVSN | 조회구분 | 01 대출일별 / 02 종목별 |
UNPR_DVSN | 단가구분 | 01 |
FUND_STTL_ICLD_YN | 펀드결제분 포함 | N / Y |
FNCG_AMT_AUTO_RDPT_YN | 융자금액 자동상환 | N |
PRCS_DVSN | 처리구분 | 00 전일매매포함 / 01 미포함 |
CTX_AREA_FK100 | 연속조회검색조건100 | 첫 호출 빈 값 |
CTX_AREA_NK100 | 연속조회키100 | 첫 호출 빈 값 |
AFHR_FLPR_YN에 X(NXT) 값이 있다는 점을 눈여겨보십시오.
대체거래소가 생기면서 잔고 조회에도 거래소 구분이 들어왔습니다.
주문 쪽 거래소 라우팅은 NXT·KRX 주문 라우팅에 따로 정리해 두었습니다.
4. 동작하는 코드
공식 샘플은 재귀로 되어 있지만, 운영 코드에서는 while 루프에 상한을 두는 편이 읽기도 쉽고 안전합니다.
import time, requests
BASE = "https://openapi.koreainvestment.com:9443" # 모의: openapivts...:29443
PATH = "/uapi/domestic-stock/v1/trading/inquire-balance"
TR_ID = "TTTC8434R" # 모의: VTTC8434R
def fetch_all_positions(access_token, cano, acnt_prdt_cd="01", max_pages=20):
holdings, summary = [], None
fk100, nk100, tr_cont = "", "", "" # 첫 호출은 전부 빈 값
for page in range(max_pages):
headers = {
"content-type": "application/json; charset=utf-8",
"authorization": f"Bearer {access_token}",
"appkey": APP_KEY,
"appsecret": APP_SECRET,
"tr_id": TR_ID,
"custtype": "P",
"tr_cont": tr_cont, # "" → 이후 "N"
}
params = {
"CANO": cano, "ACNT_PRDT_CD": acnt_prdt_cd,
"AFHR_FLPR_YN": "N", "OFL_YN": "",
"INQR_DVSN": "02", # 종목별
"UNPR_DVSN": "01",
"FUND_STTL_ICLD_YN": "N",
"FNCG_AMT_AUTO_RDPT_YN": "N",
"PRCS_DVSN": "00",
"CTX_AREA_FK100": fk100,
"CTX_AREA_NK100": nk100,
}
r = requests.get(BASE + PATH, headers=headers, params=params, timeout=10)
j = r.json()
if j.get("rt_cd") != "0":
raise RuntimeError(f'{j.get("msg_cd")} {j.get("msg1")}')
holdings += j.get("output1") or []
summary = j.get("output2") # 덮어쓴다 — 더하지 않는다
cont = r.headers.get("tr_cont", "") # ← 헤더에서 읽는다
if cont not in ("F", "M"):
break # 마지막 페이지
fk100 = j.get("ctx_area_fk100", "").strip()
nk100 = j.get("ctx_area_nk100", "").strip()
tr_cont = "N" # 2회차부터는 N
time.sleep(0.2) # 호출 제한 대비
else:
print(f"[warn] {max_pages}페이지에서 중단 — 잔고가 더 있을 수 있음")
return holdings, summary
응답 output1의 한 행은 대략 이런 모양입니다.
{
"pdno": "005930",
"prdt_name": "삼성전자",
"hldg_qty": "10",
"ord_psbl_qty": "10",
"pchs_avg_pric": "71500.0000",
"prpr": "73200",
"evlu_pfls_amt": "17000"
}
내 계좌가 잘리는지 30초 만에 확인하는 법.
루프를 돌리기 전에 첫 응답의 헤더만 찍어 보십시오.
print(r.headers.get("tr_cont")) — 여기서 M이나 F가 나오면
지금 당신의 봇은 잔고를 절반만 보고 있습니다.
5. output1과 output2 — 여기서 총액이 부풀려진다
응답에는 출력이 두 개 있습니다. output1은 보유 종목 목록(행이 여러 개),
output2는 계좌 단위 요약(예수금·총평가금액 등)입니다.
문제는 output2가 페이지마다 따라온다는 것입니다.
공식 샘플은 두 출력을 각각 pandas 데이터프레임에 pd.concat으로 누적하는데,
이걸 그대로 두고 총평가금액을 합계 내면 페이지 수만큼 곱해집니다.
3페이지짜리 계좌에서 예수금이 3배로 잡히는 사고가 여기서 나옵니다.
그 상태로 "예수금의 20%까지 매수" 같은 규칙을 돌리면 주문 금액이 3배가 됩니다.
위 코드처럼 output2는 누적하지 말고 마지막 값으로 덮어쓰거나,
데이터프레임으로 누적했다면 .iloc[-1] 한 행만 쓰십시오.
연속조회·중복 매수 방지·호출량 설계까지 포함해 설계합니다. 지금 쓰시는 증권사와 전략만 알려 주시면 구조를 잡아 드립니다. 24시간 빠른 답변 가능합니다.
무료로 물어보기 →6. 막히는 지점 5가지
① tr_cont를 본문에서 찾는다
tr_cont는 응답 본문에 없습니다. 헤더에 있습니다.
j["tr_cont"]는 KeyError가 나거나 None이 되고,
그러면 항상 1페이지에서 멈춥니다. r.headers.get("tr_cont")가 맞습니다.
② 두 번째 호출에도 tr_cont를 빈 값으로 보낸다
이어보기 키만 넣고 헤더 tr_cont를 ""로 두면 서버가 새 조회로 처리할 수 있습니다.
결과는 1페이지가 무한 반복되는 루프입니다. 2회차부터는 "N"입니다.
③ 대소문자를 그대로 복사한다
응답은 ctx_area_nk100(소문자), 요청 파라미터는 CTX_AREA_NK100(대문자)입니다.
둘을 헷갈리면 빈 값이 계속 들어가 역시 1페이지만 반복됩니다.
④ 보유수량 0인 종목을 그대로 쓴다
공식 샘플 주석에는 "당일 전량매도한 잔고도 보유수량 0으로 보여질 수 있으나, 해당 보유수량 0인 잔고는 최종 D-2일 이후에는 잔고에서 사라집니다"라고 적혀 있습니다.
즉 hldg_qty가 "0"인 행이 섞여 옵니다.
"보유 종목 수"를 len(output1)으로 세면 이미 판 종목까지 세게 됩니다.
int(row["hldg_qty"]) > 0으로 걸러야 합니다.
⑤ 페이지를 쉬지 않고 돌린다
연속조회는 페이지 수만큼 호출이 늘어납니다. 공식 샘플도 다음 페이지 호출 직전에 지연 함수를 넣고 재귀 깊이에 상한을 둡니다. 잔고가 큰 계좌를 여러 전략이 동시에 조회하면 초당 호출 제한에 걸립니다. 전체 호출량 설계는 레이트리밋 설계 글을 참고하십시오.
7. 체크리스트
tr_cont를 응답 헤더에서 읽고 있는가- 종료 조건이
F·M이 아닐 때로 되어 있는가 - 2회차부터 요청 헤더
tr_cont에N을 넣는가 ctx_area_fk100·ctx_area_nk100을 가공 없이 그대로 되돌리는가output2를 누적하지 않고 마지막 값으로 쓰는가hldg_qty == "0"행을 걸렀는가- 페이지 사이 지연과 최대 페이지 상한이 있는가
- 모의(
VTTC8434R·20건)와 실전(TTTC8434R·50건)의 건수 차이를 알고 있는가
확인 캐치. 이 글의 엔드포인트·tr_id·파라미터·건수 제한은
2026년 8월 11일 기준 한국투자증권 공식 깃허브 저장소
koreainvestment/open-trading-api의 주식잔고조회 예제 원본과 그 주석에서 확인한 값입니다.
API 스펙과 건수 제한은 증권사 사정으로 예고 없이 바뀔 수 있으므로
구현 전 KIS Developers 공식 문서에서 최신 값을 확인하십시오.
본 글은 특정 종목이나 수익률에 대한 어떠한 예측이나 권유도 담고 있지 않습니다.
마무리
정리하면 한 줄입니다.
잔고조회는 실전 50건·모의 20건에서 잘리고, 헤더 tr_cont가 종료 조건이며, 본문의 ctx_area_*가 이어보기 표시입니다.
에러가 안 나기 때문에 모르면 영원히 모르는 종류의 버그입니다.
잔고가 "지금 무엇을 들고 있는가"라면, "지금 얼마나 더 살 수 있는가"는 별도 API입니다 — 예수금 필드로 수량을 계산하다 미수가 나는 문제는 매수가능조회 — ORD_DVSN 00 아닌 01에 정리했습니다. 주문을 낸 뒤 미체결을 거둬들이는 방법은 주문 취소·정정에 정리해 두었습니다. 다른 증권사를 함께 보고 있다면 개인 Open API 개방 현황도 참고하십시오.