키움 REST API 체결내역 조회 — kt00009 부분체결
kt00009(계좌별주문체결현황요청)입니다.
키움 공식 저장소의 국내주식 계좌 예제 기준으로,
응답 항목에 체결번호 cntr_no가 있는 TR은 kt00009 하나뿐입니다.
ka10076(체결요청)은 주문번호·체결가·미체결수량처럼 주문을 기준으로 한 항목만 주고,
kt00007(계좌별주문체결내역상세요청)은 주문잔량 ord_remnq 중심입니다.
그리고 가장 조용히 틀리는 지점은 fr_ord_no(시작주문번호)를 넣으면
요약의 약정금액 engg_amt에서 이전 주문이 통째로 빠진다는 것입니다.
키움 REST API로 주문(kt10000)을 내고,
미체결(ka10075)로 아직 살아 있는지까지 확인했다면
그다음에 반드시 마주치는 질문이 하나 있습니다.
- 10주를 냈는데 3주·4주·3주로 나눠 체결됐다. 이 세 건을 각각 기록하려면 어디를 봐야 하나?
- 내 봇이 계산한 평균 매입단가가 HTS 화면과 몇 원씩 어긋난다. 어느 숫자가 맞나?
- 오늘 약정금액을 집계했는데 매일 조금씩 적게 나온다. 어디서 새는 건가?
세 질문의 답이 전부 같은 곳에 있습니다. 체결을 '주문 단위'로 보느냐 '체결 건 단위'로 보느냐입니다. 키움 REST API는 이 둘을 다른 TR로 나눠 놓았고, 이름만 봐서는 어느 쪽이 어느 쪽인지 알 수 없습니다. 이 글은 그 구분과, 거기서 파생되는 함정 다섯 개만 다룹니다.
목차
- 체결을 보는 TR 세 개 — 한 장 대조
- kt00009 실물 — 요청·응답 항목 전체
- 함정 5가지
- 부분체결을 평균단가로 접는 코드
- 폴링 대신 실시간으로 넘기는 지점
- 자주 묻는 것
1. 체결을 보는 TR 세 개 — 한 장 대조
먼저 구조부터 잡고 갑니다. 키움 REST API의 계좌 조회는
엔드포인트가 기능별로 갈라지지 않습니다.
아래 세 TR은 전부 POST /api/dostk/acnt이고,
무엇을 조회할지는 헤더 api-id가 결정합니다.
이 구조가 낯설다면 키움 REST API vs OpenAPI+ 차이를 먼저 보시면 빠릅니다.
| 항목 | ka10076 (체결요청) | kt00009 (계좌별주문체결현황요청) | kt00007 (계좌별주문체결내역상세요청) |
|---|---|---|---|
| Method / URL | POST /api/dostk/acnt — 셋 다 동일, api-id 헤더로만 갈림 | ||
| 응답 리스트 키 | cntr | acnt_ord_cntr_prst_array | acnt_ord_cntr_prps_dtl |
| 체결번호 | 없음 | cntr_no 있음 | 없음 |
| 체결시간 | 없음(ord_tm 주문시간) | cntr_tm | cnfm_tm(확인시간) |
| 미체결/잔량 | oso_qty(미체결수량) | 없음 | ord_remnq(주문잔량) |
| 비용 | tdy_trde_cmsn·tdy_trde_tax | 없음 | 없음 |
| 요약 블록 | 없음 | 약정금액 3종 | 없음 |
| 거래소 항목 | stex_tp (0통합·1KRX·2NXT) | dmst_stex_tp (%·KRX·NXT·SOR) | dmst_stex_tp (%·KRX·NXT·SOR) |
| 쓸 자리 | 당일 요약 + 수수료·세금 | 체결 건 단위 기록 | 주문 하나의 생애 추적 |
대조표를 한 문장으로 줄이면 — 오늘 얼마 벌었나에 가까운 요약은 ka10076,
거래일지에 한 줄씩 남길 원본은 kt00009,
정정·취소까지 포함해 주문 하나가 어떻게 흘러갔는지는 kt00007입니다.
2. kt00009 실물 — 요청·응답 항목 전체
요청 항목부터 봅니다. 도메인은 실전 https://api.kiwoom.com,
모의투자 https://mockapi.kiwoom.com이 별개이고,
헤더에는 authorization(Bearer + 접근토큰)과 api-id가 들어갑니다.
import requests
BASE = "https://api.kiwoom.com" # 모의투자는 https://mockapi.kiwoom.com
TOKEN = "<접근토큰>"
body = {
"stk_bond_tp" : "0", # 주식채권구분 0:전체 1:주식 2:채권
"mrkt_tp" : "0", # 시장구분 0:전체 1:코스피 2:코스닥 3:OTCBB 4:ECN
"sell_tp" : "0", # 매도수구분 0:전체 1:매도 2:매수
"qry_tp" : "1", # 조회구분 0:전체 1:체결 ← 체결만 보려면 1
"dmst_stex_tp" : "%", # 국내거래소구분 %:전체 KRX NXT SOR ← KRX로 두면 NXT가 빠진다
"ord_dt" : "", # 주문일자 YYYYMMDD (당일은 빈값)
"stk_cd" : "", # 종목코드 (전체는 빈값)
"fr_ord_no" : "", # 시작주문번호 ← 값을 넣으면 약정금액에서 이전 주문이 빠진다
}
r = requests.post(
f"{BASE}/api/dostk/acnt",
headers={
"Content-Type" : "application/json;charset=UTF-8",
"authorization" : f"Bearer {TOKEN}",
"api-id" : "kt00009",
},
json=body, timeout=10,
)
print(r.json())
print(r.headers.get("cont-yn"), r.headers.get("next-key"))
응답에서 체결 목록은 acnt_ord_cntr_prst_array라는 이름으로 옵니다.
이름이 길어서 오타가 나기 쉬운데, prst는 '현황(present status)' 쪽 약어이고
kt00007의 prps(상세)와 세 글자 중 두 글자가 같습니다.
두 TR을 같은 모듈에서 다루면 여기서 한 번은 헤맵니다.
배열 안의 항목은 다음과 같습니다(공식 예제의 항목명 대응표 기준).
| 필드 | 뜻 | 봇에서 쓰는 자리 |
|---|---|---|
ord_no | 주문번호 | 주문 단위 묶음 키 |
cntr_no | 체결번호 | 중복 기록 방지 키 |
cntr_qty / cntr_uv | 체결수량 / 체결단가 | 수량가중 평균단가 계산 |
cntr_tm | 체결시간 | 거래일지 타임스탬프 |
ord_qty / ord_uv | 주문수량 / 주문단가 | 슬리피지 측정 기준값 |
cnfm_qty | 확인수량 | 접수 확인 대조 |
orig_ord_no | 원주문번호 | 정정·취소 연결 |
mdfy_cncl_tp | 정정/취소구분 | 정정분 중복 집계 차단 |
io_tp_nm / trde_tp | 주문유형구분 / 매매구분 | 매수·매도 방향 |
stk_cd / stk_nm | 종목번호 / 종목명 | 포지션 키 |
acpt_tp / setl_tp | 접수구분 / 결제구분 | 상태 분기 |
crd_deal_tp | 신용거래구분 | 현금·신용 분리 |
cond_uv | 스톱가 | 조건부 주문 확인 |
dmst_stex_tp | 국내거래소구분 | KRX·NXT 분리 집계 |
그리고 배열과 별개로 요약 항목 세 개가 같이 옵니다. 이게 이 TR의 진짜 무기입니다.
{
"acnt_ord_cntr_prst_array": [
{"ord_no": "0000123", "cntr_no": "1", "stk_cd": "005930",
"cntr_qty": "3", "cntr_uv": "71500", "cntr_tm": "090312", "io_tp_nm": "현금매수"},
{"ord_no": "0000123", "cntr_no": "2", "stk_cd": "005930",
"cntr_qty": "4", "cntr_uv": "71600", "cntr_tm": "090314", "io_tp_nm": "현금매수"},
{"ord_no": "0000123", "cntr_no": "3", "stk_cd": "005930",
"cntr_qty": "3", "cntr_uv": "71600", "cntr_tm": "090319", "io_tp_nm": "현금매수"}
],
"sell_grntl_engg_amt": "0", // 매도약정금액
"buy_engg_amt": "715700", // 매수약정금액
"engg_amt": "715700", // 약정금액
"return_code": 0,
"return_msg": "정상적으로 처리되었습니다"
}
위 응답은 구조를 보여 주기 위한 예시이고 숫자는 실제 체결이 아닙니다.
핵심은 같은 ord_no가 세 줄로 나뉘어 있고 그 셋을 가르는 것이 cntr_no라는 점입니다.
ka10076에는 이 열이 아예 없기 때문에, 같은 상황을 ka10076으로 조회하면
체결을 세 건으로 갈라 볼 방법이 없습니다.
3. 함정 5가지
함정 1 — fr_ord_no는 필터가 아니라 약정금액까지 깎는다
키움 공식 예제는 fr_ord_no를 이렇게 설명합니다 —
"시작주문번호의 이전 주문은 조회 되지 않으며 약정금액에도 포함 되지 않음".
앞부분만 읽고 페이지 커서처럼 쓰는 코드가 많은데, 뒷부분이 진짜입니다.
이 값을 넣은 채로 engg_amt를 읽으면 앞쪽 주문이 통째로 빠진 금액이 돌아오고,
응답 코드는 0(정상)이라 아무 경고도 없습니다.
증상 — 일일 약정금액이 HTS 화면보다 매번 조금씩 적게 나온다.
그런데 어떤 날은 맞는다(그날 첫 주문이 마침 fr_ord_no였을 때).
→ 집계용 호출에서는 fr_ord_no를 반드시 빈 문자열로 두십시오.
함정 2 — dmst_stex_tp 기본값이 KRX면 NXT 체결이 사라진다
공식 예제의 호출 예시는 dmst_stex_tp='KRX'로 되어 있습니다.
그대로 복사하면 넥스트트레이드(NXT)에서 체결된 건이 응답에 들어오지 않습니다.
전체를 보려면 %를 넣어야 합니다.
같은 계열인 ka10076은 항목 이름이 stex_tp이고 값도 0 통합·1 KRX·2 NXT로 체계가 달라서,
두 TR 사이에서 값을 복사하면 조용히 어긋납니다.
주문이 어디로 흘러가는지는 NXT·KRX 주문 라우팅에 정리해 두었습니다.
| TR | 항목명 | '전체'를 뜻하는 값 |
|---|---|---|
kt00009 · kt00007 | dmst_stex_tp | % (문자) |
ka10076 · ka10075 | stex_tp | 0 (통합) |
함정 3 — qry_tp='0'이면 체결수량 0인 행이 섞인다
kt00009의 조회구분 qry_tp는 0:전체 / 1:체결입니다.
0으로 두면 아직 체결되지 않은 주문도 함께 오고, 그 행의 cntr_qty는 0이 됩니다.
이 상태에서 평균단가를 구하면 분모에 0이 섞여
ZeroDivisionError가 나거나, 예외 처리를 해 두었다면 조용히 단가가 낮아집니다.
체결만 필요하면 qry_tp='1'이 맞습니다.
함정 4 — 응답 값이 전부 문자열이다
키움 REST API 응답 항목의 타입은 문자열입니다.
cntr_qty, cntr_uv, engg_amt가 전부 "3", "71500" 같은 문자열로 오고,
파이썬에서 그대로 더하면 숫자가 아니라 문자열이 이어 붙습니다("3" + "4" == "34").
부호가 붙어 오는 항목도 있어서 int()를 바로 씌우면 터집니다.
조회 직후 변환 계층을 한 번 두고 그 아래에서는 숫자만 다루는 구조가 안전합니다.
같은 함정을 잔고조회 kt00018에서도 그대로 만납니다.
def num(v, default=0):
"""키움 응답 문자열 → 숫자. 부호·공백·빈값 방어."""
s = str(v or "").strip().replace(",", "")
if not s or s in ("-", "+"):
return default
try:
return int(s)
except ValueError:
try:
return float(s)
except ValueError:
return default
함정 5 — cntr_no를 계좌 전체에서 유일한 값으로 믿는 것
체결번호는 중복 기록을 막는 키로 쓰기 좋지만,
그 값이 계좌 전체·전 기간에서 유일하다고 가정하지 마십시오.
안전한 방식은 (주문일자, 주문번호, 체결번호) 세 개를 묶은 복합 키입니다.
거래일지 테이블의 UNIQUE 제약도 이 조합으로 걸어 두면,
재시작 후 같은 날 데이터를 다시 조회해도 같은 체결이 두 번 쌓이지 않습니다.
기록을 어떻게 남길지는 자동매매 로깅·거래일지에 정리해 두었습니다.
4. 부분체결을 평균단가로 접는 코드
실제로 봇이 필요로 하는 것은 결국 "이 주문의 평균 체결단가와 총 수량"입니다.
kt00009로 받은 체결 건들을 ord_no로 묶어 수량가중 평균을 내면 됩니다.
단순 평균((71500+71600+71600)/3)을 쓰면 수량이 다른 체결에서 값이 틀어집니다.
from collections import defaultdict
def fetch_fills(token, ord_dt="", stk_cd=""):
"""kt00009 연속조회 — 체결만, 전체 거래소."""
body = {"stk_bond_tp": "0", "mrkt_tp": "0", "sell_tp": "0",
"qry_tp": "1", # 체결만
"dmst_stex_tp": "%", # KRX + NXT + SOR 전체
"ord_dt": ord_dt, "stk_cd": stk_cd,
"fr_ord_no": ""} # 약정금액을 깎지 않으려면 빈값
rows, cont, key = [], None, None
for _ in range(10): # 공식 예제의 페이지 상한과 동일
h = {"Content-Type": "application/json;charset=UTF-8",
"authorization": f"Bearer {token}", "api-id": "kt00009"}
if cont == "Y":
h["cont-yn"], h["next-key"] = "Y", key
r = requests.post(f"{BASE}/api/dostk/acnt", headers=h, json=body, timeout=10)
data = r.json()
if data.get("return_code") not in (None, 0):
raise RuntimeError(f"{data.get('return_code')} {data.get('return_msg')}")
rows += data.get("acnt_ord_cntr_prst_array") or []
cont, key = r.headers.get("cont-yn"), r.headers.get("next-key")
if cont != "Y":
break
time.sleep(0.2) # 공식 예제의 요청 간격
return rows
def avg_fill_price(rows):
"""(ord_dt, ord_no, cntr_no) 중복 제거 후 주문별 수량가중 평균."""
seen, agg = set(), defaultdict(lambda: [0, 0]) # ord_no -> [수량, 금액]
for f in rows:
pk = (f.get("ord_dt", ""), f["ord_no"], f.get("cntr_no", ""))
if pk in seen:
continue
seen.add(pk)
q, p = num(f.get("cntr_qty")), num(f.get("cntr_uv"))
if q <= 0: # qry_tp=0으로 받았을 때의 미체결 행 방어
continue
agg[f["ord_no"]][0] += q
agg[f["ord_no"]][1] += q * p
return {o: {"qty": q, "avg": round(amt / q, 2)} for o, (q, amt) in agg.items()}
왜 이 순서인가 — 중복 제거(cntr_no) → 0수량 방어(qry_tp) → 수량가중 평균.
셋 중 하나만 빠져도 숫자는 나오지만 틀린 숫자가 나옵니다.
그리고 틀린 평균단가는 손절 라인 계산으로 그대로 흘러 들어갑니다.
5. 폴링 대신 실시간으로 넘기는 지점
kt00009를 짧은 주기로 계속 부르면 호출 제한에 걸립니다.
키움 공식 예제 코드는 연속조회 반복문에서 요청 간격 0.2초,
최대 조회 페이지 10을 기본값으로 두고 있는데,
이는 예제의 기본값이지 허용치의 보장이 아닙니다.
초과하면 429가 돌아옵니다 —
대응은 키움 REST API 429 해결에 정리해 두었습니다.
실무에서 권하는 배치는 이렇습니다.
- 주문 직후 수 초 —
ka10075(미체결)로 살아 있는지 확인 - 장중 상시 — WebSocket 실시간 체결 통보로 받고, REST 폴링은 끈다
- 봇 재시작 직후 —
kt00009로 당일 체결 전량 복원(연속조회cont-yn·next-key끝까지) - 장 마감 후 1회 —
kt00009배열 합계와engg_amt, 그리고ka10076의tdy_trde_cmsn·tdy_trde_tax를 대사
마지막 대사(對査)를 하루 한 번만 돌려도 봇이 잘못 계산한 포지션을 그날 안에 잡아냅니다. 연속조회를 끝까지 도는 구현 패턴은 키움 REST API 연속조회 — cont-yn·next-key에 있습니다.
정리 —
ka10076은 오늘 요약(수수료·세금 포함),
kt00009는 체결 건 원본(cntr_no·cntr_tm),
kt00007은 주문의 생애(ord_remnq·정정취소).
셋을 한 번에 다 붙일 필요는 없고, 거래일지를 남길 생각이라면 kt00009부터입니다.
6. 자주 묻는 것
ka10076만으로 평균단가를 낼 수는 없나요?
주문 단위의 값으로는 낼 수 있습니다. 다만 체결 건별 시각과 단가를 분리해 남길 수는 없습니다 —
ka10076 응답 항목에는 cntr_no도 cntr_tm도 없기 때문입니다.
슬리피지를 체결 건 단위로 측정하거나, 체결 시각과 시세를 대조해 실행 품질을 보려면 kt00009가 필요합니다.
모의투자에서도 같은 항목이 오나요?
모의투자는 도메인이 https://mockapi.kiwoom.com으로 다르고,
App Key·Secret도 운영과 별개로 발급됩니다.
항목 구조는 같은 명세를 따르지만 체결 자체가 모의 체결이라
부분체결이 실전과 같은 패턴으로 발생하지 않을 수 있습니다.
부분체결 처리 로직은 모의에서 '동작 확인'만 하고, 수량 분해 검증은 실계좌 소액으로 한 번 더 하시는 편이 안전합니다.
어제 이전 체결도 조회되나요?
ord_dt(주문일자, YYYYMMDD)에 날짜를 넣어 조회합니다.
다만 증권사 조회 API의 과거 데이터 보관 기간은 TR마다 다르고 공지 없이 바뀝니다.
성과 집계를 과거 데이터 재조회에 의존하지 말고,
체결이 발생한 그날 자기 DB에 적재해 두는 구조가 맞습니다. 그게 거래일지를 따로 만드는 이유입니다.
2026-08-25 시점 키움증권 공식 REST API 저장소의 국내주식 계좌 예제에서 확인한 항목명·설명 기준이고, 코드 안의 값과 응답 예시는 구조 이해를 돕기 위한 것입니다. 증권사 명세는 예고 없이 바뀌므로 운영에 넣기 전 개발자 포털의 현재 명세로 대조하시고, 도메인·TR 코드는 상수로 박지 말고 설정으로 빼 두시기 바랍니다.
부분체결까지 제대로 집계하는 봇이 필요하다면
체결 건 단위 기록·평균단가·재시작 복원까지 묶어서 실제로 돌아가는 자동매매 프로그램으로 만들어 드립니다. 24시간 빠른 답변 가능합니다.
무료 상담 시작하기