AlgoLab Blog · 키움증권 주문 실무 · 2026

키움 REST API 주문 정정·취소 kt10002 함정 6가지

키움증권 · 주문 관리 2026-09-06 · 약 7분 읽기 · 알고랩 AlgoLab
한 줄 요약

키움 REST API의 정정은 kt10002, 취소는 kt10003이고, 매수·매도와 같은 POST /api/dostk/ordrapi-id 헤더로만 갈립니다. 가장 크게 걸리는 것은 응답이 새 ord_nobase_orig_ord_no(모주문번호)를 함께 돌려준다는 점입니다 — 원래 주문번호 하나로 상태를 추적하던 봇은 정정하는 순간 그 주문을 놓칩니다. 나머지 다섯은 ⑴ mdfy_qty·cncl_qty'0'을 넣으면 잔량 전부가 대상, ⑵ 정정에는 mdfy_uv(단가)가 필수이고 취소에는 단가 항목이 아예 없음, ⑶ 공식 예제가 조회용 연속조회 루프를 주문에도 그대로 달아 둠, ⑷ 신용주문은 경로가 /api/dostk/crdordr(정정 kt10008)로 따로, ⑸ 원주문번호는 7자리 문자열이라 int로 바꾸면 안 됩니다.

목차

  1. kt10002·kt10003이 받는 것과 주는 것
  2. 함정 1 — 정정하면 주문번호가 바뀐다
  3. 함정 2 — 수량 '0'은 "전부"다
  4. 함정 3 — 정정에는 단가가 필수다
  5. 함정 4 — 공식 예제의 루프와 오류 처리
  6. 함정 5 — 신용주문은 경로가 다르다
  7. 함정 6 — 원주문번호는 문자열이다
  8. 실전 코드 — 정정 체인을 잃지 않는 구조
  9. 자주 묻는 질문

1. kt10002·kt10003이 받는 것과 주는 것

키움증권 공식 REST API 저장소의 examples/국내주식/주문/에 있는 modify_domestic_stock_order.py·cancel_domestic_stock_order.py 원문을 대조한 내용입니다. 두 API 모두 매수 kt10000·매도 kt10001과 같은 POST /api/dostk/ordr를 쓰고, api-id 헤더 값으로만 갈립니다.

구분api-id경로요청 필드
매수kt10000/api/dostk/ordrdmst_stex_tp·stk_cd·ord_qty·ord_uv·trde_tp
매도kt10001매수와 동일
정정kt10002dmst_stex_tp·orig_ord_no·stk_cd·mdfy_qty·mdfy_uv·mdfy_cond_uv(선택)
취소kt10003dmst_stex_tp·orig_ord_no·stk_cd·cncl_qty

공식 예제의 함수 시그니처와 Args 주석을 그대로 옮기면 이렇습니다.

# 공식 예제 modify_domestic_stock_order.py — 원문 발췌
API_ID  = "kt10002"
API_URL = "/api/dostk/ordr"

def modify_domestic_stock_order(
    dmst_stex_tp: str,      # 국내거래소구분 — KRX,NXT,SOR
    orig_ord_no: str,       # 원주문번호 — 매수/매도 주문 요청 응답 결과로 받은 7자리 주문번호
    stk_cd: str,            # 종목코드
    mdfy_qty: str,          # 정정수량 — 단위: 1주, '0' 입력 시 잔량 전부 정정
    mdfy_uv: str,           # 정정단가 — 단위: 원
    mdfy_cond_uv: str | None = '',   # 정정조건단가 — 단위: 원
) -> pd.DataFrame:
    ...

# __main__ 의 호출 예시도 원문 그대로
df = modify_domestic_stock_order(
    dmst_stex_tp='KRX', orig_ord_no='0000139', stk_cd='005930',
    mdfy_qty='1', mdfy_uv='199700', mdfy_cond_uv='',
)

응답에서 꺼내 쓰는 필드는 예제의 COLUMNS 매핑에 그대로 적혀 있습니다. 여기가 이 글의 출발점입니다.

# kt10002 정정 응답
COLUMNS = {
    "ord_no":            "주문번호",
    "base_orig_ord_no":  "모주문번호",
    "mdfy_qty":          "정정수량",
    "dmst_stex_tp":      "국내거래소구분",
}

# kt10003 취소 응답
COLUMNS = {
    "ord_no":            "주문번호",
    "base_orig_ord_no":  "모주문번호",
    "cncl_qty":          "취소수량",
}

2. 함정 1 — 정정하면 주문번호가 바뀐다

응답에 ord_nobase_orig_ord_no둘 다 있습니다. 하나면 될 것을 둘로 돌려준다는 것은 이 둘이 다른 값이라는 뜻입니다. base_orig_ord_no가 내가 보낸 원주문(모주문)이고, ord_no정정 결과로 생긴 주문의 번호입니다.

봇 입장에서 이것이 왜 문제인지는 코드로 보면 분명합니다. 흔히 이렇게 짭니다.

# 흔한 구조 — 주문번호 하나로 상태를 들고 있는다
order_no = place_buy("005930", qty=10, price=70000)["ord_no"]   # '0000139'
...
modify_order(orig_ord_no=order_no, mdfy_qty="10", mdfy_uv="70500")

# 이후 미체결 조회에서 order_no 를 찾는다 → 없다
unfilled = get_unfilled_orders()
mine = [o for o in unfilled if o["ord_no"] == order_no]   # 빈 리스트

미체결요청 ka10075의 응답 필드를 보면 이 구조가 확인됩니다. 이 API는 ord_no(주문번호)와 orig_ord_no(원주문번호)를 각각 내려줍니다.

# ka10075 미체결요청 응답 COLUMNS 중 발췌 (공식 예제 원문)
"ord_no":       "주문번호",
"orig_ord_no":  "원주문번호",
"ord_stt":      "주문상태",
"oso_qty":      "미체결수량",
"cntr_tot_amt": "체결누계금액",

즉 주문은 단일 번호가 아니라 부모-자식 체인으로 관리됩니다. 그래서 봇에서는 주문번호 하나가 아니라 "내가 낸 최초 주문번호"를 키로 두고, 정정할 때마다 현재 유효한 번호를 갱신해야 합니다. 8장에 그 구조를 코드로 두었습니다.

정정을 여러 번 하면 체인이 길어지는데, 그 조각들을 한 번에 보는 API가 따로 있습니다 — 미체결 분할주문 상세 ka10088입니다. POST /api/dostk/acntord_no 하나만 보내면 osop(미체결분할주문리스트) 배열로 조각별 ord_qty·ord_pric·osop_qty·ord_stt가 돌아옵니다.

3. 함정 2 — 수량 '0'은 "전부"다

공식 예제 주석 원문입니다.

mdfy_qty: 정정수량 — 단위: 1주, '0' 입력 시 잔량 전부 정정
cncl_qty: 취소수량 — 단위: 1주, '0' 입력시 잔량 전부 취소

파이썬에서 '0'은 흔하게 만들어집니다. 계산 결과가 0이거나(str(remaining - filled)), 기본값을 0으로 둔 파라미터거나, int 필드를 문자열로 바꾸는 과정에서 나옵니다. 이때 봇의 의도는 대개 "취소할 게 없다"인데, 키움 서버는 "잔량 전부 취소"로 읽습니다. 부분 취소를 하는 코드라면 '0'을 보내기 전에 반드시 걸러야 합니다.

# 위험 — 수량이 0이면 전량 취소가 나간다
cancel_order(orig_ord_no=no, stk_cd=code, cncl_qty=str(qty_to_cancel))

# 안전 — 0은 호출 자체를 막고, 전량 취소는 명시적으로만
if qty_to_cancel <= 0:
    return None                       # 취소할 것이 없다
cancel_order(orig_ord_no=no, stk_cd=code, cncl_qty=str(qty_to_cancel))

def cancel_all(no, code, stex="KRX"):
    """잔량 전부 취소 — '0'을 쓰는 곳은 여기 하나뿐이어야 한다"""
    return cancel_order(orig_ord_no=no, stk_cd=code, cncl_qty="0", dmst_stex_tp=stex)

4. 함정 3 — 정정에는 단가가 필수다

예제의 필수 파라미터 검증 블록을 보면 mdfy_uv없으면 ValueError입니다.

# 공식 예제 원문 — 1. 필수 파라미터 검증
if not dmst_stex_tp: raise ValueError('dmst_stex_tp is required.')
if not orig_ord_no:  raise ValueError('orig_ord_no is required.')
if not stk_cd:       raise ValueError('stk_cd is required.')
if not mdfy_qty:     raise ValueError('mdfy_qty is required.')
if not mdfy_uv:      raise ValueError('mdfy_uv is required.')     # <- 단가 필수

여기서 두 가지가 따라옵니다. 첫째, 수량만 줄이는 정정이어도 mdfy_uv가격을 같이 실어야 합니다. 원래 가격을 그대로 보내려면 봇이 원주문 가격을 기억하고 있어야 하고, 기억이 없으면 ka10075ord_pric으로 다시 읽어야 합니다. 둘째, 취소 쪽 시그니처에는 단가 항목이 아예 없습니다(dmst_stex_tp·orig_ord_no·stk_cd·cncl_qty 넷뿐). 정정 함수를 복사해 취소를 만들면 남는 인자가 생깁니다.

선택 항목인 mdfy_cond_uv(정정조건단가)는 예제에서 기본값이 빈 문자열이고, None이 아닐 때만 바디에 들어갑니다. 조건부 주문을 쓰지 않는다면 그대로 두면 됩니다. 시장가 주문의 정정 가능 여부처럼 주문 유형별 제약은 예제에 적혀 있지 않으므로 반드시 키움증권 개발자 포털의 현재 명세와 모의투자로 먼저 확인하십시오.

5. 함정 4 — 공식 예제의 루프와 오류 처리

정정·취소 예제에도 조회 API용 연속조회 루프가 그대로 들어 있습니다.

# 공식 예제 원문 — 정정/취소에도 붙어 있는 루프
MAX_PAGES = 10
REQUEST_DELAY_SECONDS = 0.2

for page in range(MAX_PAGES):
    response = client.fetch_page(api_id=API_ID, path=API_URL, body=body,
                                 cont_yn=next_cont_yn, next_key=next_key)
    response_body = response.body
    if response_body.get("return_code") not in (None, 0):
        message_rows.append({k: response_body.get(k) for k in MESSAGE_COLUMNS})
    ...
    next_cont_yn = response.continuation.cont_yn
    if next_cont_yn != "Y":
        break
    time.sleep(REQUEST_DELAY_SECONDS)

두 가지가 위험합니다. ⑴ 이 루프는 같은 body로 같은 주문 엔드포인트를 다시 POST합니다. 조회에서는 다음 페이지를 받는 정상 동작이지만, 주문 계열에서 반복 전송은 성격이 다릅니다. 주문 코드에서는 루프를 지우고 단발 호출로 바꾸는 편이 안전합니다.return_code가 정상이 아닐 때 예외를 던지지 않고 메시지 행에 담아 두기만 합니다. 실패해도 함수는 DataFrame을 정상 반환하므로, 호출부에서 return_code를 직접 보지 않으면 실패한 취소를 성공으로 착각합니다.

키움 REST API는 주문 API 전반이 그렇듯 업무 오류도 HTTP 200으로 내려보내고 실패는 return_code·return_msg에만 담깁니다. raise_for_status()는 아무것도 잡지 못합니다. 호출 한도 관련 응답은 429 처리 쪽에 따로 정리해 두었습니다.

6. 함정 5 — 신용주문은 경로가 다르다

같은 저장소의 examples/국내주식/신용주문/modify_domestic_credit_stock_order.py 헤더 원문입니다.

# ---
# api_id: kt10008
# api_name: 신용 정정주문
# category: 국내주식
# sub_category: 신용주문
# api_url: /api/dostk/crdordr
# menu_path: 국내주식 > 신용주문 > 신용 정정주문(kt10008)
# ---

파라미터 구성은 현금 정정과 같습니다(dmst_stex_tp·orig_ord_no·stk_cd·mdfy_qty·mdfy_uv·mdfy_cond_uv). 다른 것은 api-id와 경로뿐입니다 — /api/dostk/ordr가 아니라 /api/dostk/crdordr. 그래서 현금 주문용 래퍼 하나로 신용 주문을 정정하려 하면 경로가 달라 실패합니다. 신용 계열은 kt10006~kt10009로 갈리므로, 봇에서 신용을 다룬다면 주문 종류를 인자로 받아 api-id와 경로를 같이 정하는 구조가 필요합니다.

7. 함정 6 — 원주문번호는 문자열이다

예제 주석은 "7자리 주문번호"라고 적고 '0000139'·'0000140'·'0000455'처럼 0으로 채운 문자열을 넘깁니다. 그런데 같은 저장소의 ka10088 예제는 ord_no='8'로 호출합니다. 표기가 저장소 안에서도 일정하지 않습니다.

따라서 int(ord_no)로 바꿔 저장하지 마십시오. 한 번이라도 정수로 변환하면 앞의 0이 사라지고, 다시 문자열로 만들 때 자릿수를 복원할 방법이 없습니다. 주문번호는 받은 문자열 그대로 저장하고 그대로 되돌려 보내는 것이 원칙입니다. 정렬이나 비교가 필요하면 별도 필드를 두십시오.

같은 맥락에서 dmst_stex_tp원주문에 쓴 값과 같게 보내야 합니다. 키움 REST는 KRX·NXT·SOR를 구분하고, 종목코드에도 거래소별 접미사(039490_NX·039490_AL)가 붙는 경우가 있습니다. 대체거래소 라우팅 자체는 NXT·KRX 주문 라우팅에서 다뤘습니다.

8. 실전 코드 — 정정 체인을 잃지 않는 구조

2장의 문제를 막는 최소 구조입니다. 핵심은 "내가 낸 최초 주문"을 키로 두고, 정정할 때마다 현재 유효한 주문번호만 갈아 끼우는 것입니다.

import requests

BASE = "https://api.kiwoom.com"          # 모의투자 도메인은 별도 — 공식 문서 확인
ORDR, CRD = "/api/dostk/ordr", "/api/dostk/crdordr"

def _post(token, api_id, path, body):
    r = requests.post(BASE + path, json=body, timeout=10, headers={
        "Content-Type": "application/json;charset=UTF-8",
        "authorization": f"Bearer {token}",
        "api-id": api_id,
    })
    r.raise_for_status()                  # HTTP 오류만 잡는다
    d = r.json()
    code = d.get("return_code")
    if code not in (None, 0):             # 업무 오류는 200 으로 온다
        raise RuntimeError(f"{api_id} 실패 code={code} msg={d.get('return_msg')}")
    return d


class OrderTracker:
    """최초 주문번호를 키로, 현재 유효한 주문번호를 값으로 들고 간다."""
    def __init__(self, token):
        self.token, self.live = token, {}   # {최초주문번호: 현재주문번호}

    def buy(self, code, qty, price, stex="KRX"):
        d = _post(self.token, "kt10000", ORDR, {
            "dmst_stex_tp": stex, "stk_cd": code,
            "ord_qty": str(qty), "ord_uv": str(price), "trde_tp": "0",
        })
        root = d["ord_no"]
        self.live[root] = root
        return root

    def modify(self, root, code, qty, price, stex="KRX"):
        if int(qty) <= 0:
            raise ValueError("정정수량 0 은 잔량 전부를 뜻한다 — 의도한 것인지 확인")
        cur = self.live[root]                       # 지금 살아 있는 번호로 보낸다
        d = _post(self.token, "kt10002", ORDR, {
            "dmst_stex_tp": stex, "orig_ord_no": cur, "stk_cd": code,
            "mdfy_qty": str(qty), "mdfy_uv": str(price), "mdfy_cond_uv": "",
        })
        self.live[root] = d["ord_no"]               # 새 번호로 갱신 — 이 한 줄이 핵심
        return d["ord_no"], d.get("base_orig_ord_no")

    def cancel(self, root, code, qty=None, stex="KRX"):
        cur = self.live[root]
        qty_s = "0" if qty is None else str(qty)    # None 일 때만 전량 취소
        if qty is not None and int(qty) <= 0:
            return None
        d = _post(self.token, "kt10003", ORDR, {
            "dmst_stex_tp": stex, "orig_ord_no": cur,
            "stk_cd": code, "cncl_qty": qty_s,
        })
        return d["ord_no"], d.get("cncl_qty")


# 사용
t = OrderTracker(access_token)
root = t.buy("005930", 10, 70000)          # 최초 주문번호를 키로 보관
t.modify(root, "005930", 10, 70500)        # 번호가 바뀌어도 root 로 계속 다룬다
t.cancel(root, "005930")                   # 잔량 전부 취소

검증 순서 — ⑴ 모의투자에서 매수 → 정정 → ka10075 미체결요청으로 ord_no·orig_ord_no가 어떻게 잡히는지 눈으로 확인하고, ⑵ 정정을 두 번 이상 걸어 ka10088로 체인이 어떻게 쌓이는지 본 뒤, ⑶ 그 결과에 맞춰 위 self.live 갱신 규칙을 확정하십시오. 체결 상태 추적은 체결 조회 kt00009실시간 주문체결(00)을 함께 쓰는 편이 안전합니다.

9. 자주 묻는 질문

Q. 정정하면 원래 주문번호로는 왜 조회가 안 되나요?

정정 응답이 ord_nobase_orig_ord_no따로 돌려주기 때문입니다. 미체결요청 ka10075ord_no(주문번호)와 orig_ord_no(원주문번호)를 각각 내려줍니다. 주문이 하나의 번호가 아니라 부모-자식 체인으로 관리된다는 뜻이므로, 봇에서는 최초 주문번호를 키로 두고 정정 응답의 ord_no로 현재 번호를 갱신해야 합니다. 정정을 반복해 생긴 조각은 ka10088 미체결 분할주문 상세로 한 번에 볼 수 있습니다.

Q. 수량을 줄이는 정정인데 가격도 꼭 보내야 하나요?

보내야 합니다. 공식 예제의 필수 파라미터 검증에서 mdfy_uv가 비어 있으면 ValueError가 납니다. 가격을 바꿀 생각이 없다면 원주문 가격을 그대로 다시 실어야 하고, 그 값을 봇이 들고 있지 않다면 ka10075ord_pric으로 읽어 오십시오. 반대로 취소 kt10003에는 단가 항목이 아예 없어서 dmst_stex_tp·orig_ord_no·stk_cd·cncl_qty 넷만 받습니다.

Q. cncl_qty에 0을 넣으면 아무 일도 안 일어나나요?

반대입니다. 공식 예제 주석이 "'0' 입력시 잔량 전부 취소"라고 명시합니다. 정정 쪽 mdfy_qty"'0' 입력 시 잔량 전부 정정"입니다. 계산 결과가 0이 되어 그대로 문자열로 넘어가는 경로가 실무에서 흔하므로, 0이면 호출 자체를 막고 전량 취소는 별도 함수로만 두는 편이 안전합니다.

Q. 신용으로 산 종목을 kt10002로 정정할 수 있나요?

경로가 다릅니다. 신용 정정은 kt10008이고 POST /api/dostk/crdordr를 씁니다. 파라미터 구성 자체는 현금 정정과 같아서 api-id와 경로만 갈아 끼우면 되지만, 현금 주문용 함수를 그대로 쓰면 경로가 맞지 않아 실패합니다. 신용 계열은 kt10006~kt10009 범위로 갈리므로 주문 종류를 인자로 받아 api-id와 경로를 함께 결정하도록 짜십시오. 신용거래 자체의 조건과 비용은 키움증권 공식 안내를 확인하셔야 합니다.

Q. 취소를 보냈는데 오류가 났는지 어떻게 아나요?

requestsraise_for_status()로는 알 수 없습니다. 키움 REST API는 업무 오류도 HTTP 200으로 내려보내고 실패 여부는 응답 본문의 return_code·return_msg에만 담기기 때문입니다. 게다가 공식 예제는 return_code가 정상이 아닐 때도 예외를 던지지 않고 메시지 행에 담아 DataFrame을 정상 반환합니다. 예제를 그대로 복사했다면 호출부에서 return_code를 직접 검사하도록 고치십시오. 8장의 _post가 그 형태입니다.

10. 정리

고지 — 알고랩(퀀트웍스)은 투자자문업·투자일임업을 영위하지 않으며, 고객의 규칙을 코드로 옮기는 도구 제공만 합니다. 이 글은 특정 종목이나 매매 전략을 추천하지 않고 수익을 보장하지 않습니다. 본문의 API 스펙은 2026-09-06 기준 키움증권 공식 REST API 저장소의 예제 코드 원문을 대조한 것이며 공지로 변경될 수 있습니다. 주문·정정·취소는 실제 자산이 움직이는 기능이므로 반드시 모의투자에서 충분히 검증한 뒤 실전에 올리시고, 파라미터 제약과 주문 유형별 정정 가능 범위는 키움증권 개발자 포털의 현재 명세를 직접 확인하십시오.

키움 REST API로 돌아가는 봇, 만들어 드립니다

주문·정정·취소까지 상태가 꼬이지 않는 구조로 짜 드립니다. 24시간 빠른 답변 가능합니다.

무료 상담 시작하기