AlgoLab Blog · 키움 계좌 실무 · 2026

키움 REST API 체결내역 조회 — kt00009 부분체결

키움증권 · 체결 집계 2026-08-25 · 약 8분 읽기 · 알고랩 AlgoLab
한 줄 요약 10주 주문이 3주·4주·3주로 쪼개져 체결됐을 때 그 세 건을 각각 받아 오는 TR은 kt00009(계좌별주문체결현황요청)입니다. 키움 공식 저장소의 국내주식 계좌 예제 기준으로, 응답 항목에 체결번호 cntr_no가 있는 TR은 kt00009 하나뿐입니다. ka10076(체결요청)은 주문번호·체결가·미체결수량처럼 주문을 기준으로 한 항목만 주고, kt00007(계좌별주문체결내역상세요청)은 주문잔량 ord_remnq 중심입니다. 그리고 가장 조용히 틀리는 지점은 fr_ord_no(시작주문번호)를 넣으면 요약의 약정금액 engg_amt에서 이전 주문이 통째로 빠진다는 것입니다.

키움 REST API로 주문(kt10000)을 내고, 미체결(ka10075)로 아직 살아 있는지까지 확인했다면 그다음에 반드시 마주치는 질문이 하나 있습니다.

세 질문의 답이 전부 같은 곳에 있습니다. 체결을 '주문 단위'로 보느냐 '체결 건 단위'로 보느냐입니다. 키움 REST API는 이 둘을 다른 TR로 나눠 놓았고, 이름만 봐서는 어느 쪽이 어느 쪽인지 알 수 없습니다. 이 글은 그 구분과, 거기서 파생되는 함정 다섯 개만 다룹니다.

목차

  1. 체결을 보는 TR 세 개 — 한 장 대조
  2. kt00009 실물 — 요청·응답 항목 전체
  3. 함정 5가지
  4. 부분체결을 평균단가로 접는 코드
  5. 폴링 대신 실시간으로 넘기는 지점
  6. 자주 묻는 것

1. 체결을 보는 TR 세 개 — 한 장 대조

먼저 구조부터 잡고 갑니다. 키움 REST API의 계좌 조회는 엔드포인트가 기능별로 갈라지지 않습니다. 아래 세 TR은 전부 POST /api/dostk/acnt이고, 무엇을 조회할지는 헤더 api-id가 결정합니다. 이 구조가 낯설다면 키움 REST API vs OpenAPI+ 차이를 먼저 보시면 빠릅니다.

항목ka10076 (체결요청)kt00009 (계좌별주문체결현황요청)kt00007 (계좌별주문체결내역상세요청)
Method / URLPOST /api/dostk/acnt셋 다 동일, api-id 헤더로만 갈림
응답 리스트 키cntracnt_ord_cntr_prst_arrayacnt_ord_cntr_prps_dtl
체결번호없음cntr_no 있음없음
체결시간없음(ord_tm 주문시간)cntr_tmcnfm_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)' 쪽 약어이고 kt00007prps(상세)와 세 글자 중 두 글자가 같습니다. 두 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 기본값이 KRXNXT 체결이 사라진다

공식 예제의 호출 예시는 dmst_stex_tp='KRX'로 되어 있습니다. 그대로 복사하면 넥스트트레이드(NXT)에서 체결된 건이 응답에 들어오지 않습니다. 전체를 보려면 %를 넣어야 합니다. 같은 계열인 ka10076항목 이름이 stex_tp이고 값도 0 통합·1 KRX·2 NXT로 체계가 달라서, 두 TR 사이에서 값을 복사하면 조용히 어긋납니다. 주문이 어디로 흘러가는지는 NXT·KRX 주문 라우팅에 정리해 두었습니다.

TR항목명'전체'를 뜻하는 값
kt00009 · kt00007dmst_stex_tp% (문자)
ka10076 · ka10075stex_tp0 (통합)

함정 3 — qry_tp='0'이면 체결수량 0인 행이 섞인다

kt00009의 조회구분 qry_tp0:전체 / 1:체결입니다. 0으로 두면 아직 체결되지 않은 주문도 함께 오고, 그 행의 cntr_qty0이 됩니다. 이 상태에서 평균단가를 구하면 분모에 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 제약도 이 조합으로 걸어 두면, 재시작 후 같은 날 데이터를 다시 조회해도 같은 체결이 두 번 쌓이지 않습니다. 기록을 어떻게 남길지는 자동매매 로깅·거래일지에 정리해 두었습니다.

ord_no 0000123 매수 10주 @71,500 체결 cntr_no 1 · 3주 @71,500 cntr_tm 090312 cntr_no 2 · 4주 @71,600 cntr_tm 090314 cntr_no 3 · 3주 @71,600 cntr_tm 090319 수량가중 평균 71,570원 / 10주 ka10076 으로 보면 이 세 줄이 나뉘지 않는다 — 응답 항목에 cntr_no 가 없다 체결 건 단위 기록이 필요하면 kt00009
주문 1건 → 체결 3건. 이 분해를 응답으로 받을 수 있는 TR은 kt00009다

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 해결에 정리해 두었습니다.

실무에서 권하는 배치는 이렇습니다.

마지막 대사(對査)를 하루 한 번만 돌려도 봇이 잘못 계산한 포지션을 그날 안에 잡아냅니다. 연속조회를 끝까지 도는 구현 패턴은 키움 REST API 연속조회 — cont-yn·next-key에 있습니다.

정리ka10076오늘 요약(수수료·세금 포함), kt00009체결 건 원본(cntr_no·cntr_tm), kt00007주문의 생애(ord_remnq·정정취소). 셋을 한 번에 다 붙일 필요는 없고, 거래일지를 남길 생각이라면 kt00009부터입니다.

6. 자주 묻는 것

ka10076만으로 평균단가를 낼 수는 없나요?

주문 단위의 값으로는 낼 수 있습니다. 다만 체결 건별 시각과 단가를 분리해 남길 수는 없습니다ka10076 응답 항목에는 cntr_nocntr_tm도 없기 때문입니다. 슬리피지를 체결 건 단위로 측정하거나, 체결 시각과 시세를 대조해 실행 품질을 보려면 kt00009가 필요합니다.

모의투자에서도 같은 항목이 오나요?

모의투자는 도메인이 https://mockapi.kiwoom.com으로 다르고, App Key·Secret도 운영과 별개로 발급됩니다. 항목 구조는 같은 명세를 따르지만 체결 자체가 모의 체결이라 부분체결이 실전과 같은 패턴으로 발생하지 않을 수 있습니다. 부분체결 처리 로직은 모의에서 '동작 확인'만 하고, 수량 분해 검증은 실계좌 소액으로 한 번 더 하시는 편이 안전합니다.

어제 이전 체결도 조회되나요?

ord_dt(주문일자, YYYYMMDD)에 날짜를 넣어 조회합니다. 다만 증권사 조회 API의 과거 데이터 보관 기간은 TR마다 다르고 공지 없이 바뀝니다. 성과 집계를 과거 데이터 재조회에 의존하지 말고, 체결이 발생한 그날 자기 DB에 적재해 두는 구조가 맞습니다. 그게 거래일지를 따로 만드는 이유입니다.

2026-08-25 시점 키움증권 공식 REST API 저장소의 국내주식 계좌 예제에서 확인한 항목명·설명 기준이고, 코드 안의 값과 응답 예시는 구조 이해를 돕기 위한 것입니다. 증권사 명세는 예고 없이 바뀌므로 운영에 넣기 전 개발자 포털의 현재 명세로 대조하시고, 도메인·TR 코드는 상수로 박지 말고 설정으로 빼 두시기 바랍니다.

부분체결까지 제대로 집계하는 봇이 필요하다면

체결 건 단위 기록·평균단가·재시작 복원까지 묶어서 실제로 돌아가는 자동매매 프로그램으로 만들어 드립니다. 24시간 빠른 답변 가능합니다.

무료 상담 시작하기