자동매매 프로그램을 운영하다 보면 “손절했다"는 기록과 “실제로 팔렸다"는 사실이 다를 수 있다는 것을 알게 됩니다. 저는 그것을 2026년 9월 10일에 알았습니다.

무슨 일이 있었나

그날 봇은 미국 주식 베이커휴즈(BKR) 4주를 들고 있었습니다. 장이 열리고 주가가 손절선에 닿자 봇은 정해진 대로 매도 주문을 냈습니다. 미국 동부 시간 09:37(한국 시간 22:37), 지정가 $62.35였습니다.

문제는 그 주문이 체결되지 않았다는 것입니다.

2026년 9월 10일 BKR 장중 주가

야후 파이낸스의 5분봉으로 그날을 다시 보면 이렇습니다.

시각 (미국 동부)시가고가저가종가
09:3063.6163.6462.5162.69
09:3562.7262.7261.9262.01
09:4062.0162.2161.3961.42
09:4561.4261.4260.9460.95
09:5060.9161.1560.7860.88

전날 종가는 $63.64였습니다. 장 시작 5분 만에 62달러대 중반까지 밀렸고, 주문이 나간 09:35~09:40 사이에 이미 $62.01 근처에서 거래되고 있었습니다. 09:40 이후로는 그날 장이 끝날 때까지 한 번도 $62.35 이상에서 거래되지 않았습니다. 가장 높았던 가격이 $62.21입니다. 그날 저가는 $59.04, 종가는 $59.40였습니다.

지정가 매도는 그 가격 이상에서만 팔겠다는 주문입니다. 주가가 지정가 아래로 내려가 버리면 주문은 호가창에 걸린 채로 남습니다. 손절하려던 주식이 손절가 밑에서 그대로 방치된 것입니다.

진짜 버그는 그다음이었다

주문이 체결되지 않는 것은 시장에서 흔히 있는 일입니다. 더 큰 문제는 봇이 그 사실을 몰랐다는 것입니다.

봇은 매도 주문을 내자마자 그 종목을 “청산 완료"로 기록했습니다. 장부에서는 보유 종목이 아니게 됐으니 더는 감시하지 않았습니다. 실제 계좌에는 4주가 손실이 커지는 채로 남아 있었습니다. 팔았다고 믿는 주식, 저는 이것을 유령 손절이라고 부릅니다.

나중에 실제 계좌에 4주가 그대로 남아 있는 것을 확인했습니다. 걸려 있던 미체결 주문을 취소하고 시장가에 가까운 가격으로 다시 팔았습니다. 평균 체결가는 $59.90, 손실률은 -6.35%였습니다. 원래 팔려던 $62.35보다 3.9% 낮은 가격입니다.

4주였기에 금액으로는 2만 원 남짓한 손실이었습니다. 하지만 같은 버그가 큰 포지션에서 났다면 이야기가 달랐을 것입니다. 자동매매에서 무서운 것은 손실 자체보다, 프로그램이 알고 있는 상태와 실제 상태가 어긋나는 것입니다.

원인 두 가지

첫째, 손절 주문을 현재가 그대로의 지정가로 냈습니다. 주가가 빠르게 내려가는 순간에는 주문이 거래소에 닿을 때쯤 가격이 이미 더 아래에 있습니다. 급락 중의 지정가 매도는 체결되지 않을 가능성이 높습니다.

둘째, 주문 접수를 체결로 취급했습니다. 코드의 흐름이 대략 이랬습니다.

# 고치기 전 (구조만 단순화한 예시)
if price <= stop_price:
    place_limit_sell(symbol, qty, price)   # 주문만 내고
    position.held = False                  # 바로 청산으로 기록

주문을 보내는 것과 주문이 체결되는 것 사이에는 시간이 있고, 그 사이에 시장은 움직입니다. 이 두 줄 사이에서 문제가 생겼습니다.

어떻게 고쳤나

바로 고친 것은 주문 가격입니다. 손절 매도를 시장보다 조금 낮은 지정가로 냅니다. 현재가에 0.996을 곱한 가격, 즉 0.4% 아래에 매도 주문을 냅니다. 매수 호가보다 낮은 가격의 지정가 매도는 그 자리에서 가장 좋은 매수 호가와 체결됩니다. 시장가 주문과 비슷하게 빨리 체결되면서도, 최악의 체결가에 하한이 생깁니다.

# 고친 후 (구조만 단순화한 예시)
if price <= stop_price:
    place_limit_sell(symbol, qty, price * 0.996)   # 시장보다 0.4% 아래

가격만 고쳐서는 절반만 해결한 것입니다. 이 일을 겪고 정리한 원칙이 두 가지 더 있습니다.

체결을 확인한 뒤에만 장부를 바꾼다. 주문이 전부 체결됐다는 응답을 받기 전까지는 보유 종목으로 남겨 둬야 합니다. 체결되지 않았으면 취소하고 다시 시도하거나 사람에게 알려야 합니다.

# 갖춰야 하는 구조 (예시)
order = place_limit_sell(symbol, qty, price * 0.996)
fill = wait_for_fill(order, timeout=30)
if fill.filled_qty == qty:
    position.held = False          # 체결을 확인한 뒤에만 기록
else:
    cancel(order)
    retry_or_alert(symbol)

장부와 실제 잔고를 주기적으로 대조한다. 장부에 없는데 계좌에 있는 종목, 장부에 있는데 계좌에 없는 종목을 찾아 알리는 점검입니다. 주문 로직에 어떤 버그가 있든 마지막에 걸러 주는 안전장치입니다.

0.4%면 충분한가

솔직히 말하면 충분하지 않을 수 있습니다. 위 표에서 09:35 봉의 시가부터 09:40 봉의 종가까지 주가는 $62.72에서 $61.42까지, 10분 동안 2% 넘게 움직였습니다. 이런 속도에서는 0.4% 아래의 지정가도 체결되지 않을 수 있습니다.

그래서 가격 여유를 얼마나 두느냐보다 체결 확인과 재시도가 더 중요합니다. 여유를 크게 두면 체결은 잘 되지만 평소에 불필요하게 싸게 팔게 되고, 작게 두면 급락에서 놓칩니다. 어느 쪽을 택하든 “안 팔렸을 때 프로그램이 그것을 알고 다시 시도하는가"가 갖춰져 있어야 합니다.

정리

  • 지정가 손절은 급락에서 체결되지 않을 수 있습니다. 2026년 9월 10일 BKR에서는 $62.35에 낸 손절 주문이 끝내 체결되지 않았고, 결국 $59.90에 팔았습니다.
  • 더 위험한 것은 체결되지 않은 주문을 프로그램이 체결된 것으로 기록하는 것입니다.
  • 주문 접수와 체결을 구분하고, 체결을 확인한 뒤에만 상태를 바꿔야 합니다.
  • 장부와 실제 잔고의 대조는 마지막 안전장치입니다.

자료: 주문과 체결 내역은 직접 운영하는 자동매매 프로그램의 기록입니다. 장중 주가는 Yahoo Finance의 BKR 5분봉(2026-09-10)을 사용했습니다. 본문의 코드는 구조를 보여 주기 위해 단순화한 예시이며 실제 운영 코드와 다릅니다.

이 글은 정보 제공을 위한 것이며 투자 권유가 아닙니다. 특정 종목이나 매매 방법을 권하는 글이 아닙니다.