KIS API 주문 취소·정정 — TTTC0803U와 원주문번호
KIS API에서 취소·정정은 새 주문이 아니라 "원주문을 지목하는 요청"입니다. 그래서 반드시 원주문번호(ORGN_ODNO)가 있어야 하고, 이 값은 매수·매도 주문 응답에서 딱 한 번 내려옵니다. 엔드포인트는 /uapi/domestic-stock/v1/trading/order-rvsecncl 하나이고 tr_id는 실전이 TTTC0803U, 정정이냐 취소냐는 RVSE_CNCL_DVSN_CD에 01(정정) 또는 02(취소)를 넣어 가릅니다. 주문번호를 잃어버렸다면 정정취소가능주문조회(TTTC8036R)로 되찾을 수 있습니다.
KIS API 발급 30분 완성에서 첫 주문까지는 다뤘지만, 실제로 봇을 돌리기 시작하면 바로 다음에 막히는 지점이 취소·정정입니다. 지정가로 넣은 주문이 하루 종일 안 붙어 있고, 봇은 그걸 어떻게 거둬들여야 할지 모릅니다.
이 글은 미체결 주문을 취소하거나 가격을 정정하는 한 가지 문제만 다룹니다. 발급·토큰 발행은 위 글에, 오류 코드 전반은 KIS API 에러코드 11가지에 있습니다.
목차
- 왜 취소가 어려운가 — 주문번호를 버리기 때문
- 취소 요청은 이렇게 생겼다 (TTTC0803U)
- 주문번호를 잃어버렸다면 — 정정취소가능주문조회
- 정정은 취소 + 재주문이다
- 부분 체결이라는 함정
- 키움 REST API와 무엇이 다른가
- 봇에 넣는 타임아웃 자동취소
1. 왜 취소가 어려운가 — 주문번호를 버리기 때문
HTS 화면에서는 미체결 목록에서 클릭 한 번이면 끝나는 일이라, API도 비슷할 거라 생각하기 쉽습니다. 하지만 API에는 "방금 그 주문"이라는 개념이 없습니다. 어떤 주문을 없앨지 번호로 정확히 지목해야 합니다.
그 번호는 매수·매도 주문이 성공했을 때 응답 output에 들어 있습니다.
{
"rt_cd": "0",
"msg_cd": "APBK0013",
"msg1": "주문 전송 완료 되었습니다.",
"output": {
"KRX_FWDG_ORD_ORGNO": "06010", ← 주문 접수 조직번호
"ODNO": "0000117057", ← 주문번호 (취소할 때 이 값이 원주문번호가 된다)
"ORD_TMD": "121052" ← 주문시각
}
}
가장 흔한 사고가 여기서 납니다. 주문 함수가 rt_cd == "0"만 확인하고 True를 반환하도록 짜 놓으면, ODNO는 그대로 버려집니다. 그러면 몇 시간 뒤 "이 주문 취소해"라는 상황이 왔을 때 취소할 방법이 없습니다. 주문을 내는 함수는 성공 여부가 아니라 주문번호를 반환해야 합니다.
def send_order(code: str, qty: int, price: int, side: str) -> dict:
url = f"{BASE}/uapi/domestic-stock/v1/trading/order-cash"
headers = {
"authorization": f"Bearer {access_token}",
"appkey": APP_KEY, "appsecret": APP_SECRET,
"tr_id": "TTTC0802U" if side == "buy" else "TTTC0801U",
"custtype": "P",
}
body = {
"CANO": CANO, "ACNT_PRDT_CD": "01",
"PDNO": code,
"ORD_DVSN": "00", # 00 = 지정가
"ORD_QTY": str(qty),
"ORD_UNPR": str(price),
}
res = requests.post(url, headers=headers, json=body, timeout=5).json()
if res.get("rt_cd") != "0":
raise RuntimeError(f'{res.get("msg_cd")} {res.get("msg1")}')
out = res["output"]
# ★ 반드시 저장한다 — 이 두 값이 없으면 취소도 정정도 못 한다
return {"odno": out["ODNO"], "orgno": out["KRX_FWDG_ORD_ORGNO"]}
2. 취소 요청은 이렇게 생겼다 (TTTC0803U)
취소와 정정은 같은 엔드포인트를 씁니다. /uapi/domestic-stock/v1/trading/order-rvsecncl 하나에 tr_id로 실전(TTTC0803U)과 모의투자(VTTC0803U)를 가릅니다.
| 필드 | 의미 | 취소할 때 넣는 값 |
|---|---|---|
KRX_FWDG_ORD_ORGNO | 주문 접수 조직번호 | 원주문 응답의 같은 값 |
ORGN_ODNO | 원주문번호 | 원주문 응답의 ODNO |
RVSE_CNCL_DVSN_CD | 정정취소구분코드 | 01 정정 / 02 취소 |
ORD_DVSN | 주문구분 | 원주문과 동일하게 |
QTY_ALL_ORD_YN | 잔량전부 주문 여부 | Y면 남은 수량 전부 |
ORD_QTY | 주문수량 | 전부 취소면 0 처리 가능 |
ORD_UNPR | 주문단가 | 취소는 0, 정정은 새 가격 |
def cancel_order(orgno: str, odno: str) -> str:
url = f"{BASE}/uapi/domestic-stock/v1/trading/order-rvsecncl"
headers = {
"authorization": f"Bearer {access_token}",
"appkey": APP_KEY, "appsecret": APP_SECRET,
"tr_id": "TTTC0803U", # 모의투자는 VTTC0803U
"custtype": "P",
}
body = {
"CANO": CANO, "ACNT_PRDT_CD": "01",
"KRX_FWDG_ORD_ORGNO": orgno,
"ORGN_ODNO": odno, # ★ 원주문번호
"RVSE_CNCL_DVSN_CD": "02", # 02 = 취소
"ORD_DVSN": "00",
"ORD_QTY": "0",
"ORD_UNPR": "0",
"QTY_ALL_ORD_YN": "Y", # 남은 수량 전부 취소
}
res = requests.post(url, headers=headers, json=body, timeout=5).json()
if res.get("rt_cd") != "0":
raise RuntimeError(f'{res.get("msg_cd")} {res.get("msg1")}')
return res["output"]["ODNO"] # 취소도 새 주문번호를 받는다
파라미터명·필수 여부는 개정될 수 있습니다. 특히 QTY_ALL_ORD_YN과 ORD_QTY의 조합, 계좌 상품코드(ACNT_PRDT_CD) 규칙은 계좌 종류에 따라 다르게 요구될 수 있으니 KIS Developers 공식 문서에서 현재 규격을 확인한 뒤 코드에 반영하시기 바랍니다. 이 글의 값은 발행 시점 기준입니다.
3. 주문번호를 잃어버렸다면 — 정정취소가능주문조회
봇을 재시작했거나 저장을 빠뜨려 ODNO를 모른다면, 지금 취소·정정이 가능한 주문 목록을 통째로 받아올 수 있습니다. 경로는 /uapi/domestic-stock/v1/trading/inquire-psbl-rvsecncl, tr_id는 TTTC8036R입니다.
url = f"{BASE}/uapi/domestic-stock/v1/trading/inquire-psbl-rvsecncl"
headers = {..., "tr_id": "TTTC8036R"}
params = {
"CANO": CANO, "ACNT_PRDT_CD": "01",
"INQR_DVSN_1": "0",
"INQR_DVSN_2": "0",
"CTX_AREA_FK100": "", "CTX_AREA_NK100": "", # 연속조회 키
}
res = requests.get(url, headers=headers, params=params, timeout=5).json()
for row in res["output"]:
print(row["odno"], row["pdno"], row["ord_qty"], row["psbl_qty"], row["ord_unpr"])
# odno=주문번호 pdno=종목코드 psbl_qty=취소·정정 가능수량
봇 재시작 시 이걸 먼저 부르세요. 프로세스가 죽었다 살아나면 메모리에 있던 주문 상태는 전부 사라집니다. 기동 직후 이 조회로 살아 있는 주문을 복원한 다음 전략 루프를 시작하면, 유령 주문이 장중에 갑자기 체결되는 사고를 막을 수 있습니다. 장애 복구 설계 전반은 봇 장애·복구 플레이북에 정리돼 있습니다.
4. 정정은 취소 + 재주문이다
가격을 바꾸는 정정은 RVSE_CNCL_DVSN_CD를 01로 두고 ORD_UNPR에 새 가격을 넣으면 됩니다. 여기서 반드시 알아야 할 성질이 하나 있습니다.
정정이 성공하면 주문번호가 바뀝니다. 원주문을 거두고 새 조건으로 다시 접수하는 형태이기 때문입니다. 따라서 봇이 가격을 따라가며 정정을 반복하는 로직(이른바 체이싱)을 짤 때는, 정정 응답을 받을 때마다 저장된 번호를 새 값으로 덮어써야 합니다.
state = {"odno": "0000117057", "orgno": "06010"}
def revise_price(new_price: int):
body = {
..., "ORGN_ODNO": state["odno"],
"RVSE_CNCL_DVSN_CD": "01", # 01 = 정정
"ORD_UNPR": str(new_price),
"QTY_ALL_ORD_YN": "Y",
}
res = requests.post(url, headers=headers, json=body, timeout=5).json()
if res["rt_cd"] != "0":
raise RuntimeError(res["msg1"])
state["odno"] = res["output"]["ODNO"] # ★ 반드시 갱신
정정 체이싱은 호출량을 폭발시킵니다. 호가가 움직일 때마다 정정을 보내면 초당 호출이 순식간에 한도를 넘고 EGW00201이 뜹니다. 최소 정정 간격과 최소 가격 변화 폭을 두고, 그래도 몰리면 호출 제한 설계 쪽 리미터를 태우세요.
5. 부분 체결이라는 함정
100주 지정가 매수를 넣었는데 40주만 체결된 상태에서 취소를 보내면, 취소되는 것은 남은 60주뿐입니다. 40주는 이미 내 계좌에 들어와 있습니다.
문제는 봇이 "취소 성공 = 포지션 없음"으로 가정할 때입니다. 그러면 장부상 수량과 실제 잔고가 어긋나고, 그 상태로 손절 로직이 돌면 없는 수량을 팔려다 오류가 나거나 있는 수량을 방치하게 됩니다.
# ❌ 위험: 취소 응답만 믿는다
cancel_order(orgno, odno)
position[code] = 0
# ✅ 안전: 취소 후 실제 잔고를 다시 조회해 동기화한다
cancel_order(orgno, odno)
balance = inquire_balance() # tr_id: TTTC8434R (주식잔고조회)
position[code] = int(balance.get(code, {}).get("hldg_qty", 0))
체결 상태를 실시간으로 받고 싶다면 WebSocket 체결통보(H0STCNI0)를 구독하는 방법도 있습니다. 구독 구조와 접속키 발급은 KIS WebSocket 실시간 시세에 정리돼 있습니다.
6. 키움 REST API와 무엇이 다른가
같은 국내주식인데도 증권사마다 규격이 다릅니다. 키움증권 REST API는 정정과 취소가 아예 다른 api-id로 분리돼 있습니다.
| 항목 | 한국투자증권 KIS | 키움증권 REST |
|---|---|---|
| 엔드포인트 | /uapi/.../order-rvsecncl 하나 | /api/dostk/ordr 공통 경로 |
| 정정 | tr_id: TTTC0803U + RVSE_CNCL_DVSN_CD=01 | api-id: kt10002 |
| 취소 | 같은 tr_id + RVSE_CNCL_DVSN_CD=02 | api-id: kt10003 |
| 원주문 지정 | ORGN_ODNO + KRX_FWDG_ORD_ORGNO | 원주문번호 필드 |
| 성패 판정 | 본문 rt_cd | 본문 return_code |
구조가 이렇게 다르므로, 증권사를 나중에 추가할 계획이라면 cancel(order_id) 같은 공통 인터페이스로 감싸 두는 것이 낫습니다. 전략 코드가 RVSE_CNCL_DVSN_CD를 직접 아는 순간 증권사 교체 비용이 급등합니다. 증권사별 규격 차이 전반은 증권사 API 비교에, 키움 REST 전환은 키움 REST API vs OpenAPI+에 정리해 뒀습니다.
7. 봇에 넣는 타임아웃 자동취소
실무에서 취소 로직이 실제로 쓰이는 자리는 대개 하나입니다 — "N초 안에 안 붙으면 거둬들인다".
import time
PENDING_TIMEOUT = 60 # 초
def place_with_timeout(code, qty, price, side):
o = send_order(code, qty, price, side)
deadline = time.monotonic() + PENDING_TIMEOUT
while time.monotonic() < deadline:
alive = {r["odno"] for r in list_revisable_orders()} # TTTC8036R
if o["odno"] not in alive:
return "filled_or_gone" # 체결됐거나 이미 사라짐
time.sleep(3) # 호출 한도를 고려한 폴링 간격
cancel_order(o["orgno"], o["odno"]) # 시간 초과 → 잔량 취소
sync_position(code) # 부분 체결 대비 잔고 재조회
return "cancelled"
폴링 간격을 너무 짧게 잡지 마세요. 미체결 조회도 호출 한도를 소모합니다. 1초 간격으로 여러 종목을 돌리면 그것만으로 한도를 다 씁니다. 체결 여부가 급한 전략이라면 폴링 대신 체결통보 WebSocket으로 옮기는 것이 정석입니다.
모의투자에서 먼저 확인하세요. 취소·정정은 실제 자금이 걸린 상태를 바꾸는 요청이라, 코드 한 줄 잘못으로 의도치 않은 주문이 남거나 사라질 수 있습니다. 모의투자 계좌에서 부분 체결·타임아웃·재시작 복원까지 돌려 본 뒤 실전으로 옮기시기 바랍니다. 전환 시 바꿔야 할 것들은 KIS 모의투자 실전 전환에 정리돼 있습니다. 이 글의 tr_id·필드명·정책은 발행 시점 기준이며 변경될 수 있으니 공식 문서에서 확인하세요.
자주 묻는 질문
주문번호를 저장 안 했는데 지금 취소해야 합니다.
TTTC8036R(정정취소가능주문조회)로 현재 살아 있는 주문 목록을 받아 종목코드·수량으로 대상을 찾은 뒤 그 odno로 취소하면 됩니다. 다만 같은 종목에 여러 주문이 있으면 구분이 어려우므로, 결국 주문 시점에 저장하는 습관이 정답입니다.
취소했는데 msg1에 "정정취소 불가"가 뜹니다.
대개 이미 전량 체결됐거나 이미 취소된 주문입니다. 취소 직전에 TTTC8036R로 아직 살아 있는지 확인하고, 목록에 없으면 취소를 시도하지 않는 편이 안전합니다. 상태 확인 없이 취소를 반복하면 불필요한 호출로 한도만 소모합니다.
시장가 주문도 취소할 수 있나요?
시장가는 접수되는 즉시 체결되는 것이 보통이라 취소할 틈이 거의 없습니다. 취소·정정이 의미 있는 것은 지정가처럼 호가에 걸려 대기하는 주문입니다. 유동성이 얇은 종목에서 일부만 체결되고 남는 경우는 있으므로, 그때는 잔량 취소가 필요할 수 있습니다.
해외주식도 같은 방식인가요?
아닙니다. 해외주식은 별도 경로와 tr_id를 쓰고, 시장가 지원 여부나 취소 가능 시간대도 국내와 다릅니다. 미국주식 특유의 제약은 별도로 확인해야 하며, 정확한 규격은 공식 문서에서 확인하시기 바랍니다.
주문 상태 관리까지 되는 봇을 만들려면
주문·취소·정정·부분체결 동기화와 재시작 복원까지, 알고랩이 한국투자증권·키움 API로 제작합니다.
24시간 빠른 답변 가능합니다.