무엇이 문제였나: 팔아야 할 때 주문이 끊겼다
자동매매 매도 미체결 문제는 손실보다 무서운 면이 있습니다. 시스템이 팔았다고 믿고 넘어가거나, 못 판 사실을 알면서도 아무것도 하지 않기 때문입니다. 저희가 겪은 사고는 뒤쪽이었습니다. 2026년 6월 1일 09:01, 종목 242040을 1주 보유한 상태에서 BBSqueeze 전략이 1차 청산 구간인 tier1의 청산 신호를 냈습니다. 진입가는 8,470원이었습니다.
시스템은 정해진 순서대로 주문 방식을 올렸습니다. 처음에는 8,910원 지정가, 다음은 상대편 최우선 호가로 내는 최유리지정가(주문 코드 03), 마지막은 시장가(주문 코드 01)입니다. 이렇게 체결이 안 될 때 더 공격적인 주문으로 넘어가는 단계를 이 글에서는 주문 단계 상향이라고 부르겠습니다. 3종 모두 30초 타임아웃으로 거부 처리됐고, 체결 수량은 0/1이었습니다.
여기까지만 보면 운 나쁜 미체결입니다. 진짜 문제는 그다음입니다. 주가는 급등을 이어 갔고 시스템의 청산 관리자는 09:03부터 장중 내내 매분 이 종목을 평가했습니다. 평가 로그에는 가격이 9,060원에서 9,940원으로, 수익이 +17%까지 찍혔습니다. 그런데 매도 주문은 단 한 번도 다시 나가지 않았습니다. 결국 운영자가 손으로 팔아 포지션을 닫았습니다.
비용은 두 층으로 나뉩니다. 첫째, 자동 청산이 장 시작 직후 한 종목에서 완전히 멈췄습니다. 둘째, 사람이 시장을 지켜보지 않았다면 이 포지션은 어떤 청산 규칙의 보호도 받지 못한 채 남았을 것입니다. 이번 주문은 1주라 금액이 작았지만, 같은 결함은 종목 수와 금액을 가리지 않습니다. 참고로 이 시스템에는 계좌 40만 원으로 고가주를 살 수 없다는 자본 부족 문제도 따로 있었습니다. 기록은 이번 사고를 그 문제와 구분해 청산 경로의 결함으로 분류합니다.
처음 잰 숫자: 3종 주문 체결 0, 평가 구간 주문 0건
측정은 새 도구 없이 그날의 trader.log 한 파일로 했습니다. 종목 코드로 줄을 추리고, 주문 결정·타임아웃·전환·실패·평가 로그를 시각순으로 늘어놓았습니다. 그림 1은 그 결과를 세 숫자로 줄인 값입니다.

로그를 시각순으로 놓으면 사고가 두 구간으로 갈립니다. 09:01:19부터 09:02:51까지는 시스템이 팔려고 애쓴 구간이고, 09:03 이후는 평가만 하고 주문을 내지 않는 구간입니다. 앞 구간은 92초 만에 끝났고, 뒤 구간은 장중 내내 이어졌습니다.

눈에 걸린 줄이 하나 더 있습니다. 09:02:51의 POSITION_SYNC_MISMATCH입니다. 로그 이름은 불일치지만 값은 내부 1주, KIS 1주로 같습니다. 이 줄의 쓸모는 다른 데 있었습니다. 시스템 내부 기록과 증권사 잔고가 모두 1주라고 말하니, 매도가 체결되지 않았다는 사실을 두 곳에서 교차 확인한 셈입니다.
가격도 나란히 놓았습니다. 09:01:18 현재가는 9,000원이었는데 주문 라우터가 집은 매수 최우선 호가는 8,910원이었습니다. 현재가보다 1% 낮고, 9,106원인 tier1 손절선보다도 낮습니다.

어떤 가설을 세웠나?
진단은 세 가설을 거쳤습니다. 처음 둘은 틀렸거나 절반만 맞았고, 그 과정을 숨기지 않고 적습니다.
- 가설 1, 정상 청산: 로그에 BBSqueeze EXIT가 찍혔으니 매도가 끝났다. AI 코딩 도구가 낸 1차 보고의 내용입니다.
- 가설 2, 낡은 호가: 호가가 낡아서 안 팔렸으니 호가 검증만 넣으면 사고가 사라진다.
- 가설 3, 재시도 부재: 주문 단계 상향이 전부 실패하면 그 tier를 포기하고, 이후 tier2나 손절 신호가 다시 떠도 주문을 안 보낸다.
가설 3이 맞다면 고칠 곳이 분명합니다. 청산 신호가 다시 올라올 때 주문을 보내는 재시도 경로를 주문 실행 쪽, 즉 exit_executor와 order_manager에 추가하면 됩니다. 평가 로그가 매분 찍혔으니 신호도 살아 있으리라고 믿기 쉬웠습니다. 실제로 사고 기록의 첫 판단도 17% 익절 기회가 매분 떠도 시스템이 움직이지 않았다는 쪽이었습니다.
가설은 어디서 틀렸나?
가설 1은 체결 수량 앞에서 바로 무너졌습니다. EXIT 로그는 팔라는 신호일 뿐 팔렸다는 증거가 아닙니다. 신호와 체결은 다른 사건이고, 판정은 체결 수량으로 해야 합니다. 작은 오독처럼 보여도 이 보고를 믿었다면 조사 자체가 시작되지 않았을 것입니다.
가설 2는 절반만 맞습니다. 호가 문제는 실재했습니다. 그러나 이 가설로는 사고의 두 가지 사실을 설명하지 못합니다. 하나는 시장가 주문까지 체결 0이었다는 사실이고, 다른 하나는 주문 0건이 장중 내내 이어졌다는 사실입니다. 낡은 호가는 첫 지정가의 실패만 설명합니다. 호가를 아무리 잘 골라도 주문이 나가지 않으면 소용이 없습니다.
가설 3은 코드를 따라가자 전제가 깨졌습니다. 재시도 경로가 있었더라도 그 경로를 깨울 신호가 없었습니다. 분봉 청산 평가 함수 evaluate_minute_exit가 다음 틱부터 돌지 않았고, EXIT 신호 자체가 영구히 나오지 않았기 때문입니다. 첫 기록은 신호가 다시 떠도 주문이 안 나간다고 적었지만, 추적 결과 신호는 다시 뜨지 않았습니다. 주문 쪽에 재시도 코드를 붙였다면 호출되지 않는 코드를 하나 더 만들 뻔했습니다.
그럼 매분 찍힌 평가 로그는 뭐였을까요? 이 로그는 EXIT_MANAGER의 것이고, 주문을 일으키는 분봉 평가는 디스패처 쪽에 있습니다. 이름이 비슷한 두 경로가 따로 돌았습니다. 한쪽이 멀쩡히 로그를 남기니 다른 쪽이 죽은 사실이 가려졌습니다. 로그가 찍힌다는 사실과 그 로그가 주문까지 이어진다는 사실은 별개입니다.
진짜 원인은 무엇인가?
사고에는 서로 독립적인 원인이 두 개 있었습니다. 첫째는 왜 처음 주문이 안 팔렸는가이고, 둘째는 왜 다시 주문하지 않았는가입니다. 치명적인 쪽은 둘째입니다.
첫째 원인은 호가 신선도를 확인하지 않은 점입니다. smart_order_router.py 292번째 줄의 매도 로직은 매수 최우선 호가를 그대로 지정가로 썼습니다. 매수 최우선 호가는 지금 가장 비싸게 사 주겠다는 쪽의 가격이라, 이 값으로 파는 선택 자체는 합리적인 기본값입니다. 다만 그 값이 최신이라는 전제가 붙습니다. 급등 직전의 얇거나 지연된 호가를 잡으면 그 가격에는 사 줄 매수세가 없습니다. 게다가 주가가 9,890원까지 뛰면서 8,910원 지정가는 체결될 길이 없어졌습니다. 호가가 현재가에서 일정 비율 이상 벗어나면 낡았다고 판정하고 시장가로 직행하는 로직이 없었습니다.
둘째 원인은 디스패처 두 파일에 있었습니다. bb_v2_minute_dispatcher.py의 373~378번째 줄(추적 손절 경로는 428번째 줄)과 v15_minute_exit_dispatcher.py의 187~192번째 줄은 전량 청산 때 콜백이 성공했든 실패했든 무조건 entry_dispatcher.remove_position을 호출했습니다. 주문이 실패해도 감시 상태부터 지웠다는 뜻입니다. 상태가 사라지면 다음 틱에 evaluate_minute_exit가 돌지 않고, EXIT 신호도 주문도 나오지 않습니다.
한 줄로 줄이면, 코드는 매도를 요청했다는 사실만으로 매도가 끝났다고 간주하고 뒷정리를 해 버렸습니다.
구성 요소별 상태를 나란히 놓으면 왜 이 결함이 눈에 띄지 않았는지 보입니다. 시스템의 절반은 종목을 여전히 보유 중으로 알았고, 주문을 만드는 쪽만 종목을 잊었습니다.
| 구성 요소 | 상태 | 로그에 보이는 모습 |
|---|---|---|
| 리스크 관리자의 포지션 | 유지(exit_executor 746~750번째 줄) | 내부 1주 |
| 증권사 잔고 | 유지 | KIS 1주 |
| EXIT_MANAGER 평가 | 계속 동작 | 가격 9,060원→9,940원, 수익 +17% |
| 디스패처 분봉 상태 | 삭제 | 코드 추적으로 확인 |
| 분봉 청산 평가와 EXIT 신호 | 멈춤 | 주문 로그 0건 |
2026년 6월 1일 trader.log와 코드 추적 기록을 합친 표
포지션 정보는 원래 유지되고 있었으므로, 잘못 지워진 곳은 디스패처 상태 하나였습니다. 그래서 수정 범위도 좁게 잡을 수 있었습니다. 청산 로직이 성과를 어떻게 바꾸는지는 자동매매 청산 전략 실측에 따로 적어 두었습니다.
조치: 호가 판정과 감시 상태, 두 곳을 고쳤다
원인이 둘이니 수정도 둘로 나눴고, 커밋도 따로 만들었습니다. 첫째 수정은 낡은 호가 판정입니다. ExecutionContext에 기준가 reference_price를 추가하고, SmartOrderRouter에 _stale_quote_reason() 판정을 넣었습니다. 매수 최우선 호가가 현재가보다 0.5% 이상 낮으면 지정가를 건너뛰고 시장가로 직행합니다. 설정 이름은 sor.stale_quote_pct이고, 매수 주문은 매도 최우선 호가로 대칭 적용합니다.
사고 당시 값을 넣어 보면 호가 8,910원은 현재가 9,000원보다 1% 낮으므로 기준 0.5%를 넘습니다. 새 코드라면 지정가를 쓰지 않습니다. order_manager._get_optimal_order_strategy가 현재가를 reference_price로 넘기고, 이 값이 없는 호출은 기존 동작을 그대로 씁니다.
둘째 수정이 핵심입니다. 재시도 코드를 새로 쓰지 않고 감시 상태를 지우는 조건만 바꿨습니다. execute_exit는 원래 아무 값도 돌려주지 않았는데(None), 이제 체결 여부를 참·거짓으로 돌려줍니다. 전량 체결(FILLED)과 부분 체결(PARTIAL)이 참입니다. _on_bb_v2/v15_exit_signal은 이 값을 그대로 위로 전달하고, 디스패처 3곳은 콜백이 거짓이면 remove_position을 건너뜁니다.

이 방식의 장점은 단순함입니다. 상태가 남아 있으면 다음 틱의 평가가 곧 재시도입니다. 별도의 재시도 큐도, 타이머도 필요 없습니다. 평소 청산을 판단하던 코드가 그대로 다시 판단하니, tier2나 손절처럼 그사이 조건이 바뀌어도 새 상황에 맞는 신호가 나옵니다. 콜백이 연결되지 않은 경우(None)는 기존처럼 지웁니다.
하나는 일부러 미뤘습니다. 급등주에서 타임아웃을 30초보다 줄이고 첫 시도를 시장가로 내는 안입니다. 첫째와 둘째 수정이 청산 실패를 이중으로 막으니 우선순위를 낮췄고, 재시작 뒤 효과를 재 보고 판단하기로 했습니다.
재측정 결과: 테스트는 통과, 실거래 수치는 아직 없다
먼저 분명히 해 둘 점이 있습니다. 이 글의 재측정은 테스트 재측정입니다. 수정 뒤 실제 급등주에서 매도가 체결됐다는 기록은 근거 자료에 없습니다. 호가 판정 수정은 머지와 푸시까지 끝났지만 실행 중인 프로그램을 재시작해야 적용되는 상태였고, 재시작 뒤의 체결 결과는 자료에 남아 있지 않습니다.
테스트로 확인한 값은 이렇습니다. 호가 판정 수정은 관련 테스트 53/53을 통과했고 그중 4건이 신규입니다. 감시 상태 수정은 신규 테스트 7건을 추가했습니다. execute_exit 반환값 4건과 디스패처 상태 유지 3건입니다. 뒤쪽 3건은 함수를 따로 부르지 않고 실제 on_tick 청산 경로를 태웠습니다. 사고가 일어난 길을 그대로 지나가게 한 셈입니다.
| 항목 | 고치기 전(2026년 6월 1일 실거래) | 고친 뒤 |
|---|---|---|
| 호가 괴리 판정 | 없음 | 현재가 대비 0.5% 이상이면 시장가 직행 |
| 호가 8,910원, 현재가 9,000원 | 8,910원 지정가 주문 | 괴리 1%, 지정가 건너뜀 |
| 주문 실패 뒤 감시 상태 | 콜백 결과와 무관하게 삭제 | 콜백 False면 유지 |
| 주문 실패 뒤 다음 틱 | 평가 없음, 주문 로그 0건 | 재평가·재주문(테스트 3건) |
| execute_exit 반환값 | None | bool(FILLED/PARTIAL이면 True) |
| 관련 테스트 | 해당 없음 | 53/53 통과, 신규 합계 11건 |
| 실거래 매도 체결 | 체결 0/1 | 측정 기록 없음 |
신규 합계 11건은 호가 판정 4건과 감시 상태 7건을 더한 값이다.
표의 마지막 줄이 이 글의 가장 중요한 칸입니다. 재주문 코드가 호출된다는 사실은 입증했지만, 그 주문이 급등장에서 체결된다는 사실은 입증하지 못했습니다. 그래서 개선을 퍼센트로 적지 않았습니다. 고친 뒤의 분모가 아직 없으니까요.
실거래 재측정을 한다면 숫자 하나로 끝내지 않고 아래 세 값을 같은 로그에서 함께 봐야 합니다. 셋 다 trader.log에 이미 찍히는 주문·체결 줄로 셀 수 있고, 다음 급등 구간이 곧 측정 기회입니다.
- SELL_ESCALATE_FAILED 뒤 같은 종목의 다음 매도 주문 로그가 나오는가, 나온다면 몇 초 뒤인가
- 호가 괴리로 시장가에 직행한 주문이 몇 건이고, 체결가는 현재가와 얼마나 차이 나는가
- 실패가 이어진 종목에서 주문이 몇 번 나갔는가, 그 횟수가 감당할 수준인가
남는 한계: 아직 모르는 것은 무엇인가?
첫째, 시장가 주문까지 체결 0이었던 이유를 모릅니다. 가장 큰 구멍입니다. 호가 판정은 지정가를 건너뛰고 시장가로 직행하는 수정인데, 사고 당일에는 그 시장가 단계도 30초 타임아웃으로 끝났습니다. 09:02:20의 정정취소 가능수량 없음 로그가 단서지만, 기록은 이 줄의 의미를 확정하지 않았습니다. 내부 1주, KIS 1주 로그 옆에도 매도 실패 원인을 별도로 조사해야 한다는 메모가 남아 있습니다. 시장가 주문이 그날 증권사에 어떤 형태로 들어갔는지 밝히기 전에는, 첫째 수정이 같은 상황을 해결한다고 말할 수 없습니다.
둘째, 재주문의 상한이 기록에 없습니다. 상태를 유지하면 실패가 이어질 때 매 틱 주문이 나갈 수 있습니다. 첫 사고가 주문이 너무 적어서 났다면, 다음 사고는 주문이 너무 많아서 날 수도 있습니다. 횟수 제한이나 간격 제어가 있는지는 기록으로 확인할 수 없어, 재시작 뒤 로그에서 직접 봐야 합니다.
셋째, 부분 체결을 성공으로 칩니다. PARTIAL이 참이면 여러 주를 보유한 종목이 일부만 팔린 뒤 상태가 지워질 수 있습니다. 이번 사고는 1주라 드러나지 않았고, 남은 수량을 어떻게 처리하는지는 기록에 없습니다. 콜백이 None인 경로도 기존처럼 지우므로 같은 질문이 남습니다.
넷째, 기준값 0.5%의 근거 실측이 기록에 없습니다. 값이 너무 작으면 평범한 호가 차이에도 시장가가 나가 체결가를 양보하게 되고, 너무 크면 이번 같은 호가를 다시 놓칩니다. 사고 한 건을 계기로 넣은 설정값이라 재시작 뒤 시장가 직행 빈도와 체결가를 함께 재야 합니다.
다섯째, 테스트 자체의 신뢰도입니다. 53/53은 관련 범위의 결과이고 전체 테스트 묶음의 결과가 아닙니다. 당시 이미 이 사고와 무관한 실패가 6건 있었습니다. test_execution_algos.py의 TestGetAlgoSchedule 5건과 test_exit_policy.py의 1건입니다. 2026년 7월 20일에 전체를 다시 돌리자 122건 실패와 28건 에러가 나왔고, 그중 일부는 단독 실행하면 통과하는 순서 의존 문제였습니다. 7월 23일 정리에서는 가짜 객체(MagicMock) 설정이 실패를 가리고 있다가 수정 뒤 5건이 연쇄로 드러나기도 했습니다. 통과 표시 하나만으로 안심할 수 없는 이유입니다.
이 사고에서 옮겨 갈 수 있는 교훈은 금융 코드에만 머물지 않습니다. 외부 시스템에 요청을 보내고 결과를 기다리는 자동화라면 아래 네 질문을 그대로 던질 수 있습니다.
- 요청 성공이 아니라 결과 확인 뒤에 상태를 지우는가?
- 완료 보고가 신호 로그가 아니라 체결 수량 같은 결과 값을 보는가?
- 로그가 찍히는 경로와 실제 행동을 일으키는 경로가 같은 경로인가?
- 실패가 이어질 때 재시도가 멈추지도, 폭주하지도 않게 상한이 있는가?
사내 자동화가 실패를 조용히 삼키고 있는지 살펴보고 싶다면 상담으로 상황을 알려 주세요.