키움 REST API 미체결 조회 — ka10075 함정 5가지
ka10075(미체결요청),
"얼마에 채워졌나"는 ka10076(체결요청)입니다.
둘 다 POST /api/dostk/acnt로 같고, 헤더 api-id 값 하나로만 갈립니다.
가장 많이 걸리는 지점은 응답 목록의 이름이 서로 다르다는 것 —
ka10075는 oso, ka10076은 cntr입니다.
남은 수량은 oso_qty(미체결수량)이고,
원주문이 없는 건은 orig_ord_no가 "0000000"으로 옵니다.
그리고 응답 값은 전부 String이라 그대로 빼거나 더하면 안 됩니다.
키움 REST API 주식 주문(kt10000)으로 매수 주문을 한 건 내보내는 데까지는 대부분 어렵지 않게 갑니다. 문제는 그다음입니다. 주문을 던진 봇이 다음 판단을 하려면 반드시 되물어야 합니다.
- 이 주문, 아직 호가창에 걸려 있나 아니면 이미 채워졌나?
- 10주를 냈는데 3주만 체결됐다면 나머지 7주는?
- 손절을 걸어야 하는데 내가 지금 몇 주를 들고 있는 건가?
이 되물음을 담당하는 TR이 ka10075와 ka10076입니다.
그런데 이름이 미체결요청·체결요청이라 직관적인 것에 비해,
실제로 붙여 보면 응답 구조에서 예상 밖의 지점 다섯 군데에서 코드가 조용히 어긋납니다.
이 글은 그 다섯 군데만 다룹니다.
목차
- 두 TR 한 장 대조 — 같은 URL, 다른 api-id
- ka10075 미체결요청 실물
- ka10076 체결요청 실물
- 함정 5가지
- 주문 상태 판정 함수
- 폴링을 줄이는 구조
- 자주 묻는 것
1. 두 TR 한 장 대조 — 같은 URL, 다른 api-id
키움 REST API는 엔드포인트를 기능별로 쪼개지 않습니다.
계좌 관련 조회는 대부분 /api/dostk/acnt 하나로 들어가고,
무엇을 조회할지는 헤더 api-id가 결정합니다.
키움 REST API와 OpenAPI+의 차이를
이미 보셨다면 익숙한 구조일 텐데, 처음 보면 URL이 같아서 오타처럼 느껴집니다.
| 항목 | ka10075 (미체결요청) | ka10076 (체결요청) |
|---|---|---|
| Method / URL | POST /api/dostk/acnt — 동일 | |
| 구분 | 헤더 api-id: ka10075 | 헤더 api-id: ka10076 |
| 필수 요청 항목 | all_stk_tp(전체종목구분)trde_tp(매매구분)stex_tp(거래소구분) | qry_tp(조회구분)sell_tp(매도수구분)stex_tp(거래소구분) |
| 선택 요청 항목 | stk_cd(종목코드 6자리) | stk_cd · ord_no(검색 기준 주문번호) |
| 응답 리스트 이름 | oso | cntr |
| 핵심 수량 항목 | oso_qty(미체결수량) | cntr_qty(체결량) · cntr_pric(체결가) |
| 연속조회 | 응답 헤더 cont-yn·next-key — 동일 방식 | |
공통 요청 헤더는 api-id,
authorization(Bearer + 접근토큰),
Content-Type: application/json;charset=UTF-8입니다.
토큰이 어디서 나오고 언제 만료되는지는
키움 접근토큰 유효기간과 expires_dt에 정리해 두었습니다.
2. ka10075 미체결요청 실물
파이썬 requests로 호출하는 최소 형태입니다.
도메인은 실전 https://api.kiwoom.com, 모의투자 https://mockapi.kiwoom.com이 별개입니다.
import requests
BASE = "https://api.kiwoom.com" # 모의투자는 https://mockapi.kiwoom.com
TOKEN = "..." # au10001로 발급받은 접근토큰
def unfilled_orders(stk_cd=None):
headers = {
"Content-Type": "application/json;charset=UTF-8",
"authorization": f"Bearer {TOKEN}",
"api-id": "ka10075",
}
body = {
"all_stk_tp": "1" if stk_cd else "0", # 0:전체, 1:종목
"trde_tp": "0", # 0:전체, 1:매도, 2:매수
"stex_tp": "0", # 0:통합, 1:KRX, 2:NXT
}
if stk_cd:
body["stk_cd"] = stk_cd # 종목코드 6자리
r = requests.post(f"{BASE}/api/dostk/acnt", headers=headers, json=body, timeout=5)
r.raise_for_status()
return r.json(), r.headers.get("cont-yn"), r.headers.get("next-key")
응답에서 실제로 쓰게 되는 항목만 추리면 이렇습니다. 전부 타입이 String입니다.
| 항목 | 한글명 | 봇에서 쓰는 의미 |
|---|---|---|
oso | 미체결 | 목록 자체의 이름 |
ord_no | 주문번호 | 주문 식별자(7자리 숫자) |
orig_ord_no | 원주문번호 | 정정·취소의 뿌리. 없으면 "0000000" |
ord_stt | 주문상태 | 텍스트 상태값 |
ord_qty | 주문수량 | 처음 낸 수량 |
oso_qty | 미체결수량 | 아직 안 채워진 잔량 — 판정의 핵심 |
cntr_qty | 체결량 | 지금까지 채워진 수량 |
cntr_pric | 체결가 | 단위: 원 |
ord_pric | 주문가격 | 내가 건 지정가 |
cur_prc · sel_bid · buy_bid | 현재가·매도호가·매수호가 | 정정 판단에 쓰는 참고값 |
tm | 시간 | 주문 시간 HHmmss |
stex_tp · stex_tp_txt | 거래소구분 | 0 통합 / 1 KRX / 2 NXT |
sor_yn | SOR 여부 | Y/N |
stop_pric | 스톱가 | 스톱지정가주문일 때 |
응답의 형태는 이렇게 생겼습니다(항목명은 공식 명세, 값은 이해를 돕기 위한 예시입니다).
{
"oso": [
{
"ord_no": "0001234",
"orig_ord_no": "0000000",
"stk_cd": "005930",
"stk_nm": "삼성전자",
"ord_stt": "접수",
"io_tp_nm": "+매수",
"ord_qty": "10",
"ord_pric": "71800",
"oso_qty": "7",
"cntr_qty": "3",
"cntr_pric": "71800",
"cntr_tot_amt": "215400",
"tm": "091237",
"stex_tp": "1",
"stex_tp_txt": "KRX"
}
],
"return_code": 0,
"return_msg": "정상적으로 처리되었습니다"
}
읽는 법. ord_qty 10주 중 cntr_qty 3주가 채워졌고
oso_qty 7주가 남았습니다. 이 주문은 부분체결 상태로 아직 살아 있습니다.
전량 체결되면 이 주문은 ka10075 응답에서 사라지고
ka10076 쪽에 나타납니다.
3. ka10076 체결요청 실물
같은 URL에 api-id만 바꾸는데, 요청 항목 이름이 달라집니다.
all_stk_tp가 아니라 qry_tp(조회구분)이고,
trde_tp가 아니라 sell_tp(매도수구분)입니다.
값의 의미는 비슷한데 이름만 다른 전형적인 함정이라, ka10075 코드를 복사해서 api-id만 고치면 실패합니다.
def filled_orders(stk_cd=None, cursor_ord_no=None):
headers = {
"Content-Type": "application/json;charset=UTF-8",
"authorization": f"Bearer {TOKEN}",
"api-id": "ka10076", # ← 여기만 바꾸면 되는 게 아니다
}
body = {
"qry_tp": "1" if stk_cd else "0", # 0:전체, 1:종목 (all_stk_tp 아님)
"sell_tp": "0", # 0:전체, 1:매도, 2:매수 (trde_tp 아님)
"stex_tp": "0",
}
if stk_cd:
body["stk_cd"] = stk_cd
if cursor_ord_no:
body["ord_no"] = cursor_ord_no # 이 주문번호보다 '과거' 체결을 조회
r = requests.post(f"{BASE}/api/dostk/acnt", headers=headers, json=body, timeout=5)
r.raise_for_status()
return r.json()["cntr"] # ← 리스트 이름이 oso가 아니라 cntr
응답 항목은 ord_no·stk_cd·stk_nm·io_tp_nm(주문구분)·
ord_pric·ord_qty·cntr_pric·cntr_qty·oso_qty·
ord_stt·trde_tp·orig_ord_no·ord_tm·
stex_tp·stex_tp_txt·sor_yn·stop_pric,
그리고 tdy_trde_cmsn(당일매매수수료)·tdy_trde_tax(당일매매세금)입니다.
수수료·세금 항목이 여기 있다는 점은 그냥 넘기지 마십시오.
체결가만으로 손익을 계산하면 실제 계좌와 계속 어긋납니다.
체결 한 건의 실현손익은 cntr_pric에서 tdy_trde_cmsn과
tdy_trde_tax를 뺀 뒤에야 계좌 화면과 맞습니다.
시간 항목 이름도 다릅니다. ka10075는 tm,
ka10076은 ord_tm입니다. 둘 다 HHmmss 형식입니다.
두 응답을 하나의 내부 구조체로 합칠 때 여기서 KeyError가 납니다.
4. 함정 5가지
함정 ① 리스트 키가 oso / cntr로 다르다
가장 많이 걸립니다. ka10075는 oso, ka10076은 cntr입니다.
같은 /api/dostk/acnt를 부르니 응답 껍데기도 같을 거라 가정하고
res["output"]이나 res["oso"]로 고정해 두면
한쪽에서 조용히 빈 결과가 됩니다.
빈 리스트는 예외를 안 내기 때문에 "미체결이 없다"로 오독되기 쉽습니다.
# 나쁜 예 — 한쪽에서 KeyError, 혹은 조용히 빈 결과
rows = res["oso"]
# 좋은 예 — TR별 리스트 키를 표로 두고 꺼낸다
LIST_KEY = {"ka10075": "oso", "ka10076": "cntr", "kt00018": "acnt_evlt_remn_indv_tot"}
rows = res.get(LIST_KEY[api_id], [])
함정 ② 숫자가 전부 문자열이다
명세상 ord_qty·oso_qty·cntr_qty·cntr_pric가
모두 String입니다. 파이썬에서 문자열끼리 빼면 TypeError,
더하면 "10" + "7" = "107"이 됩니다.
후자가 훨씬 위험합니다 — 에러 없이 틀린 수량으로 다음 주문이 나갑니다.
def i(v, default=0):
"""키움 응답 문자열 → int. 빈 값·공백·부호 안전 처리"""
try:
return int(str(v).strip() or default)
except (TypeError, ValueError):
return default
remain = i(row["oso_qty"]) # 7
done = i(row["cntr_qty"]) # 3
total = i(row["ord_qty"]) # 10
assert done + remain == total # 부분체결 정합성 확인
함정 ③ orig_ord_no가 "0000000"이다
공식 명세는 원 주문이 없는 경우 orig_ord_no를 '0000000'으로 출력한다고
적고 있습니다. 빈 문자열도 null도 아닙니다.
그래서 if row["orig_ord_no"]: 같은 진위 판정은 항상 참이 되고,
"정정된 주문"과 "원주문"이 뒤섞입니다.
# 나쁜 예 — "0000000"은 빈 문자열이 아니므로 항상 True
is_revised = bool(row["orig_ord_no"])
# 좋은 예
is_revised = i(row["orig_ord_no"]) != 0
정정·취소를 넣는 봇이라면 이 판정이 특히 중요합니다.
정정 주문은 새 ord_no를 받고 orig_ord_no에 원주문번호를 남기므로,
이 둘을 이어 붙여야 "한 자리에 낸 주문"의 전체 이력이 복원됩니다.
함정 ④ ka10076의 ord_no는 필터가 아니라 커서다
공식 명세의 설명은 "검색 기준 값으로 입력한 주문번호 보다 과거에 체결된 내역이 조회됩니다"입니다.
즉 ord_no에 내 주문번호를 넣으면 그 주문은 결과에서 빠지고
그보다 이전 것들이 나옵니다. 특정 주문의 체결만 보려면 결과를 코드에서 걸러야 합니다.
# 특정 주문의 체결 내역만 보고 싶을 때
rows = filled_orders() # ord_no를 넣지 않는다
mine = [r for r in rows if r["ord_no"] == my_ord_no]
함정 ⑤ 목록이 잘려 오는데 눈치채지 못한다
응답 헤더의 cont-yn이 Y이면 뒤에 더 있다는 뜻이고,
같은 헤더의 next-key를 다음 요청 헤더에 실어야 이어집니다.
이걸 빼먹으면 주문이 많은 날에 앞부분만 보고 "나머지는 다 체결됐다"고 판단합니다.
구현 패턴은 키움 REST API 연속조회 — cont-yn·next-key에 정리해 두었습니다.
def fetch_all(api_id, body, list_key):
out, cont, key = [], None, None
while True:
h = {"Content-Type": "application/json;charset=UTF-8",
"authorization": f"Bearer {TOKEN}", "api-id": api_id}
if cont == "Y":
h["cont-yn"], h["next-key"] = "Y", key
r = requests.post(f"{BASE}/api/dostk/acnt", headers=h, json=body, timeout=5)
r.raise_for_status()
out += r.json().get(list_key, [])
cont, key = r.headers.get("cont-yn"), r.headers.get("next-key")
if cont != "Y":
return out
NXT 도입 이후 주의할 점 하나 더.
stex_tp는 0 통합 / 1 KRX / 2 NXT입니다.
1로 고정해 두면 NXT로 나간 체결이 목록에서 빠집니다.
조회는 통합(0)으로 받고 결과의 stex_tp_txt로 구분하는 편이 안전합니다.
왜 이 구분이 생겼는지는 NXT·KRX 주문 라우팅에 있습니다.
5. 주문 상태 판정 함수
위 다섯 가지를 반영하면, "이 주문이 지금 어떤 상태인가"는 이렇게 한 곳에서 판정할 수 있습니다.
ord_stt(주문상태) 텍스트에 의존하지 않는 것이 핵심입니다 —
문자열 표기는 바뀔 수 있지만 수량 관계는 바뀌지 않습니다.
def order_state(my_ord_no):
"""→ ('OPEN'|'PARTIAL'|'DONE'|'UNKNOWN', 체결수량, 잔량)"""
for row in fetch_all("ka10075", {"all_stk_tp":"0","trde_tp":"0","stex_tp":"0"}, "oso"):
if row["ord_no"] != my_ord_no:
continue
remain, done = i(row["oso_qty"]), i(row["cntr_qty"])
return ("PARTIAL" if done > 0 else "OPEN"), done, remain
# 미체결 목록에 없다 = 전량 체결됐거나 취소됐다
for row in fetch_all("ka10076", {"qry_tp":"0","sell_tp":"0","stex_tp":"0"}, "cntr"):
if row["ord_no"] == my_ord_no:
return "DONE", i(row["cntr_qty"]), i(row["oso_qty"])
return "UNKNOWN", 0, 0 # 취소·거부·조회 지연 — 사람이 봐야 한다
UNKNOWN을 DONE으로 뭉개지 마십시오.
미체결에도 체결에도 없는 주문은 취소됐거나, 거부됐거나, 조회가 아직 반영되지 않은 것입니다.
이 셋을 "체결"로 처리하면 보유하지 않은 수량을 팔려는 주문이 나갑니다.
봇이 스스로 판단할 수 없는 구간은 정직하게 남겨 두고 사람에게 알리는 편이 훨씬 안전합니다.
6. 폴링을 줄이는 구조
ka10075를 1초마다 부르면 되지 않느냐는 질문을 자주 받습니다.
됩니다. 그리고 얼마 안 가 429를 받습니다.
키움 REST API의 호출 제한과 증상은
키움 REST API 429 — 허용된 요청 개수 초과에 정리돼 있습니다.
실무에서 쓰는 절충은 대체로 이 세 층입니다.
| 층 | 수단 | 역할 |
|---|---|---|
| 1. 즉시 | WebSocket 실시간 체결 통보 | 체결이 나는 순간 알림 — 폴링 없이 반응 |
| 2. 주문 직후 | ka10075 짧은 간격 폴링 | 주문 접수 확인. 몇 초 뒤 간격을 늘린다 |
| 3. 주기·재시작 | ka10075 + ka10076 대조 | 봇이 죽었다 살아났을 때 상태 복원 |
3번을 빼먹는 봇이 의외로 많습니다. 프로세스가 재시작되면 메모리에 있던 주문 목록이 사라지는데,
시장에 걸어 둔 주문은 그대로 살아 있습니다.
재시작 직후 ka10075로 살아 있는 주문을 먼저 읽어 오지 않으면
같은 자리에 주문을 한 번 더 냅니다.
한국투자증권 KIS를 함께 쓰신다면 대응 TR은 다릅니다.
KIS는 주문체결조회에 TTTC0081R 계열을 쓰고 응답 구조도 달라서,
KIS 주문체결조회 정리와
이 글을 각각 어댑터로 감싸 내부에서는 하나의 공통 상태값으로 정규화하는 편이 유지보수가 쉽습니다.
자주 묻는 것
ka10075에 종목코드를 넣었는데 결과가 비어 있습니다
stk_cd만 넣고 all_stk_tp를 0(전체)으로 두지 않았는지 확인하십시오.
종목 단위로 조회하려면 all_stk_tp를 1(종목)로 바꿔야 합니다.
stex_tp를 1(KRX)로 박아 둔 경우에도 결과가 빌 수 있습니다.
부분체결이 여러 번 나면 어떻게 집계하나요?
ka10075의 cntr_qty는 그 주문의 누적 체결량으로 읽고,
체결 단가별 상세가 필요하면 ka10076 쪽 목록을 ord_no 기준으로 모아
cntr_pric·cntr_qty로 가중평균을 내는 방식이 일반적입니다.
지정가 주문이라도 여러 단가에 나뉘어 채워질 수 있다는 점만 기억하면 됩니다.
이 값들을 그대로 믿어도 되나요?
이 글의 API ID·URL·요청/응답 항목명과 "0000000" 같은 규칙은
2026-08-20 시점 키움증권 REST API 공식 명세에서 확인한 것이고,
코드 안의 값은 이해를 돕기 위한 예시입니다.
증권사 명세는 예고 없이 바뀌므로 운영에 넣기 전 개발자 포털의 현재 명세로 대조하시고,
도메인·TR 코드는 상수로 박지 말고 설정으로 빼 두시기 바랍니다.
주문 추적까지 제대로 도는 봇이 필요하다면
부분체결·재시작 복원·호출 제한까지 묶어서 실제로 돌아가는 자동매매 프로그램으로 만들어 드립니다. 24시간 빠른 답변 가능합니다.
무료 상담 시작하기