낸 주문을 관리한다 — 정정·취소와 미체결 상태 설계
자동매매를 만들 때 대부분의 시간은 "주문을 어떻게 넣는가"에 들어간다. 인증을 통과하고, 종목과 수량을 실어 보내고, 주문번호를 받으면 일단 성공한 것 같다.
그런데 실제 운영에서 사람 손이 가장 많이 가는 지점은 주문을 넣는 순간이 아니다. 넣어놓고 안 나간 주문이다. 지정가로 걸어둔 주문이 하루 종일 체결되지 않는 상황, 가격이 이미 지나가버려 의미가 없어진 주문, 취소를 눌렀는데 정말 취소됐는지 확신이 안 서는 상황 — 이걸 처리하는 코드가 없으면 결국 사람이 매번 들여다보게 된다.
주문 체결 확인과 멱등성 설계에서 "주문을 냈다는 것과 체결됐다는 것은 다른 사건"이라고 정리했다. 이 글은 그 사이에 낀 상태, 즉 접수는 됐지만 아직 체결되지 않은 주문을 어떻게 다룰지에 관한 것이다.
이 글의 범위
증권사별 정정·취소 API의 파라미터 이름이나 코드값은 다루지 않는다. 회사마다 다르고 개정되기도 해서, 여기에 적으면 읽는 시점에 이미 틀린 정보가 된다. 코드는 전부 generic 의사코드이며 실제 필드명과 엔드포인트는 각자 쓰는 증권사의 공식 문서에서 확인해야 한다.
다루는 것은 상태 설계다 — 미체결을 어떻게 인식하고, 언제 손을 대고, 손댄 뒤 무엇을 다시 확인하는가.
왜 정정·취소 로직이 필요한가
미체결 주문을 방치하면 세 가지가 쌓인다.
- 자금이 묶인다. 매수 주문이 걸려 있는 동안 그 금액은 다른 판단에 쓸 수 없다. 전략은 현금이 있다고 계산하는데 실제로는 없는 상태가 생긴다.
- 의도와 어긋난 체결이 나중에 터진다. 아침에 건 지정가가 오후에 시장이 반대로 움직이며 체결되는 경우가 있다. 그 시점에는 진입 근거가 이미 사라졌는데 포지션만 남는다.
- 중복 주문이 생긴다. 안 나갔다고 판단해 다시 넣었는데 원래 주문이 살아 있으면 두 배가 들어간다. 이건 주문 체결 확인과 멱등성 설계에서 다룬 중복 방지와 같은 문제의 다른 얼굴이다.
그래서 주문을 낸 뒤에는 반드시 끝을 보는 루프가 있어야 한다. 체결되거나, 취소되거나, 사람에게 넘어가거나 — 셋 중 하나로 끝나야 한다. "낸 채로 둔다"는 상태로 하루를 넘기지 않는 것이 원칙이다.
정정과 취소는 새 주문이 아니다
여기서 구조적으로 헷갈리는 지점이 있다. 정정과 취소는 원주문을 지목하는 요청이지 독립된 주문이 아니다. 그래서 원주문 번호를 들고 있지 않으면 아무것도 할 수 없다.
python 코드 보기
# generic 의사코드. 실제 필드명·코드값은 각 증권사 공식 문서 확인.
def amend_order(orig_order_id, new_price=None, new_qty=None):
# 정정 = 원주문을 지목해 가격/수량을 바꾼다
return broker.request(
kind="AMEND",
orig_id=orig_order_id,
price=new_price,
qty=new_qty,
client_ref=make_ref(orig_order_id, "amend"), # 멱등키
)
def cancel_order(orig_order_id, qty=None):
# 취소 = 원주문의 미체결 잔량을 거둔다. qty 생략 시 잔량 전부
return broker.request(
kind="CANCEL",
orig_id=orig_order_id,
qty=qty,
client_ref=make_ref(orig_order_id, "cancel"),
)중요한 건 세 가지다.
첫째, 정정·취소의 대상은 미체결 잔량뿐이다. 100주 중 40주가 이미 체결됐다면 손댈 수 있는 건 60주다. 원주문 수량으로 취소를 요청하면 거부되거나 잔량만 처리된다. 그러니 취소 요청 전에 잔량을 조회하거나, 잔량 기준으로 요청하고 결과를 다시 확인해야 한다.
둘째, 정정은 주문번호를 바꿀 수 있다. 증권사에 따라 정정 후 새 주문번호가 발급된다. 이걸 모르고 옛 번호를 계속 들고 있으면 그다음 취소가 대상을 못 찾는다. 정정 응답에서 받은 번호로 추적 대상을 갱신해야 한다.
셋째, 정정·취소 요청 자체에도 멱등키가 필요하다. 응답이 유실돼 재시도할 때 취소가 두 번 나가면, 첫 취소로 이미 사라진 주문에 대해 두 번째 요청이 오류를 뱉는다. 요청 단위로 고유 참조값을 붙이는 이유는 주문을 낼 때와 똑같다.
미체결 조회와 타임아웃 설계
미체결을 다루려면 먼저 "지금 살아 있는 주문이 무엇인가"를 알아야 한다. 여기서 흔한 실수가 내 메모리에 있는 목록을 진실로 삼는 것이다. 프로그램이 재시작되면 그 목록은 사라지고, 브로커 쪽에는 주문이 그대로 살아 있다.
진실원은 항상 브로커의 미체결 조회다. 내 기록은 대조용이다.
python 코드 보기
def reconcile_open_orders():
remote = broker.list_open_orders() # 진실원
local = store.list_tracked_orders()
for o in remote:
if o.id not in local:
store.adopt(o) # 내가 모르는 살아있는 주문 — 반드시 입양한다
for o in local:
if o.id not in {r.id for r in remote}:
store.close(o) # 브로커에 없으면 끝난 주문 — 결과를 확인해 확정adopt 쪽을 빠뜨리는 경우가 많은데, 재시작 후 고아 주문이 생기는 원인이 대부분 여기다. 내가 모르는 주문이 있으면 무시할 게 아니라 관리 대상으로 끌고 와야 한다.
그다음이 타임아웃이다. 얼마나 기다렸다가 손을 댈지는 전략이 정하는 값이지 기술이 정하는 값이 아니다. 다만 설계에서 지킬 원칙은 있다.
- 시각이 아니라 경과 시간으로 잰다. "9시 30분에 취소"가 아니라 "접수 후 N분 경과 시 취소"로 둔다. 주문이 늦게 접수된 경우까지 일관되게 처리된다.
- 장 운영시간을 반영한다. 장이 닫힌 동안 흐른 시간을 대기 시간으로 세면, 다음 날 개장하자마자 전부 타임아웃 처리된다. 이 함정은 장 운영시간·휴장일·타임존에서 다룬 것과 같은 종류다.
- 행동을 단계로 나눈다. 1차 경과 시 가격 정정, 2차 경과 시 취소, 그래도 남으면 알림 후 정지 — 이렇게 두면 한 번의 판단 실수가 전부를 되돌리지 못한다.
정정 뒤에는 반드시 다시 확인한다
가장 자주 빠뜨리는 단계다. 정정이나 취소 요청이 접수됐다는 응답은 처리됐다는 뜻이 아니다. 요청을 보낸 시점과 그 요청이 처리되는 시점 사이에 원주문이 체결되면 정정은 무효가 된다. 이 틈은 구조적으로 존재하며, 요청을 아무리 정확히 보내도 없어지지 않는다.
python 코드 보기
def amend_and_verify(order_id, new_price):
amend_order(order_id, new_price=new_price)
for _ in range(MAX_CHECKS):
sleep(CHECK_INTERVAL)
state = broker.get_order(order_id) # 응답이 아니라 조회로 확인
if state.status in ("FILLED", "CANCELED"):
return state # 이미 끝난 주문 — 정정은 의미 없음
if state.price == new_price:
return state # 정정 반영 확인
raise AmendNotConfirmed(order_id) # 확인 실패는 성공이 아니다원칙은 027편과 같다. 요청의 응답이 아니라 조회 결과를 신뢰한다. 그리고 확인에 실패했을 때 예외를 던지는 것이 핵심이다. 여기서 조용히 넘어가면 정정됐다고 믿는 프로그램과 정정되지 않은 실제 상태가 갈라진다.
확인 루프에서 나오는 오류 코드를 어떻게 분류할지는 증권사 API 에러코드 핸들링에서 정리한 기준을 그대로 쓴다. 재시도해도 되는 오류와 재시도하면 안 되는 오류를 섞으면, 취소 재시도가 무한히 반복된다.
함정 — 중복 취소와 레이스 컨디션
미체결 관리에서 사고가 나는 지점은 대부분 시간차다.
가장 흔한 것이 중복 취소다. 타임아웃 루프가 취소를 걸었는데 응답이 늦어, 다음 주기가 또 취소를 건다. 이미 취소된 주문이라 두 번째 요청은 오류가 되고, 그 오류를 재시도 대상으로 분류해두면 계속 재시도한다. 요청 수 제한에 걸리는 건 시간문제다 — 이 제한을 다루는 방법은 증권 API 레이트리밋과 재시도 설계에 따로 정리했다.
막는 방법은 상태 플래그다. 취소 요청을 보낸 순간 주문을 CANCELING으로 표시하고, 그 상태인 주문은 다음 주기가 건드리지 않는다. 확인이 끝나야 CANCELED나 원상태로 돌아간다.
두 번째는 취소와 체결이 겹치는 경우다. 취소 요청이 나가는 사이에 부분 체결이 일어나면, 취소는 남은 잔량에만 적용된다. 이때 "취소 성공"만 보고 포지션이 없다고 판단하면 실제로는 체결된 수량을 들고 있게 된다. 취소 후에는 반드시 체결 수량을 다시 조회한다.
세 번째는 정정 폭주다. 가격이 계속 밀린다고 짧은 주기로 정정을 반복하면, 정정마다 새 요청이 나가고 주문번호가 바뀌며 추적이 꼬인다. 정정에도 최소 간격과 횟수 상한을 둔다. 상한에 닿으면 정정을 포기하고 취소하는 쪽이 안전하다.
자주 하는 실수
- 취소 요청의 응답만 보고 끝났다고 처리한다. 접수와 처리는 다르다. 조회로 확인하고, 확인 실패는 실패로 다룬다.
- 원주문 수량으로 취소를 건다. 대상은 미체결 잔량이다. 부분 체결된 주문에서 어긋난다.
- 정정 후 바뀐 주문번호를 갱신하지 않는다. 그다음 취소가 대상을 잃는다.
- 재시작 후 브로커에 살아 있는 주문을 입양하지 않는다. 관리되지 않는 고아 주문이 남는다.
- 취소 진행 중 플래그가 없다. 같은 주문에 취소가 중복으로 나가고 요청 수만 소모한다.
- 장 마감 시간을 대기 시간에 포함한다. 다음 날 개장 직후 전 주문이 한꺼번에 타임아웃된다.
오늘의 정리
- 주문은 체결·취소·사람에게 인계 중 하나로 끝나야 한다. "낸 채로 둔다"를 상태로 인정하지 않는 것이 미체결 관리의 출발점이다.
- 정정·취소는 새 주문이 아니라 원주문을 지목하는 요청이다. 대상은 미체결 잔량이고, 정정은 주문번호를 바꿀 수 있다.
- 살아 있는 주문의 진실원은 브로커의 미체결 조회다. 내 메모리는 대조용이며, 모르는 주문은 무시하지 말고 입양한다.
- 타임아웃은 시각이 아니라 경과 시간으로 재고, 장 운영시간을 반영하고, 정정 → 취소 → 정지로 단계를 나눈다.
- 정정·취소 뒤에는 조회로 다시 확인한다. 확인 실패를 성공으로 처리하는 순간, 프로그램이 믿는 상태와 실제 상태가 갈라진다.
이 블로그는 익명으로 운영되며, 실계좌 정보·실제 금액·구체적인 시스템 상세는 공개하지 않는다. 이 글은 개인의 개발·운영 경험을 정리한 기록으로 투자 조언이나 종목 추천이 아니며, 어떤 수익도 보장하지 않는다. 투자 판단과 그로 인한 손실의 책임은 투자자 본인에게 있다.