주문 수량은 시세가 아니라 증권사가 검증하는 금액으로 계산한다
미국주식 자동매매 봇 개발기 · 운영편 ② — 포지션 사이징과 주문가능금액
봇이 “몇 주를 살 수 있는가”를 계산할 때 쓰는 가격과, 증권사가 “이 주문을 받아줄 것인가”를 판정할 때 쓰는 가격은 같지 않다. 이 차이는 평소에 아무 문제도 일으키지 않다가, 계좌 잔고가 1주 단위로 빡빡해지는 날 주문가능금액 초과 한 줄로 하루를 통째로 날린다. 실제 거부 로그와 그걸 닫은 한 줄짜리 수식, 그리고 아직 닫지 못한 틈까지 기록으로 남긴다.
🚨 신호는 정상, 주문만 거부 — 2026년 4월 29일 로그
그날 봇은 아무것도 틀리지 않았다. 장 초반 15분 박스권(ORB)을 정상적으로 잡았고, 되돌림 진입 신호도 정상적으로 발사했다. 그리고 마지막 한 줄에서 멈췄다.
22:45:00 ORB: H=61.08 L=60.45 Range=0.63
23:00:05 SIGNAL: TQQQ Long @ 61.01 SL=60.67 TP=61.69 R:R=1:2.0
23:00:21 POSITION OPENED: TQQQ 51shares @ $61.01
23:00:21 ORDER: BUY TQQQ x51 @ $61.66 [live]
23:00:21 ERROR KIS API error: 주문가능금액을 초과 했습니다
23:00:21 ERROR ORDER FAILED: TQQQ buy x51
23:00:21 STATE: SCANNING → DONE_TODAY (Order execution failed)
주목할 지점은 3번째와 4번째 줄이다. 봇의 내부 기록은 51주 @ $61.01인데, 실제로 증권사에 나간 주문은 51주 @ $61.66이다. 같은 주문인데 단가가 65센트 다르다. 그리고 이 65센트가 하루치 매매 기회를 삼켰다. 상태머신은 주문 실패를 곧바로 DONE_TODAY로 처리하기 때문에, 그날은 재시도조차 없었다.
📐 하나의 주문을 두 개의 산수가 다르게 본다
당시 계좌의 미국주식 주문가능금액은 $3,128.22였다. 봇의 수량 계산은 지극히 상식적이었다. 자본을 시세로 나누고 소수점을 버린다.
shares = int(3128.22 / 61.01) # = 51
51주 × $61.01 = $3,111.51. 잔고 안에 넉넉히 들어온다. 문제는 증권사가 이 산수를 검증하지 않는다는 것이다. 증권사가 보는 건 실제로 접수된 주문서, 즉 51주 × $61.66이다.
한도를 넘긴 금액은 $16.44. 1주 값의 4분의 1이 모자라서 51주 전체가 거부됐다.
🔀 시세와 증권사 사이에 끼어드는 변환들
65센트는 어디서 왔을까. 원인은 주문 계층에 있다. 한국투자증권 해외주식은 나스닥 종목에 대해 진짜 시장가 주문을 지원하지 않는다. 그래서 buy_market()은 이름과 달리 현재가보다 약간 높은 지정가를 걸어 즉시 체결을 흉내낸다. 즉시 체결을 사려고 지불하는 프리미엄인 셈이다.

📊 그림 요약: 수량은 시세 기준으로 정해지는데, 주문 계층이 그 뒤에서 지정가를 1% 올려 접수한다. 사이징이 그 인상분을 모르기 때문에 증권사 검증 단계에서 한도를 넘겨 거부된다.
이 구조를 한 번 보고 나면, 비슷한 변환이 곳곳에 숨어 있다는 게 보인다. 봇이 아는 가격과 증권사가 판정하는 금액 사이에는 최소 네 겹의 층이 있다.
| 변환 | 누가 붙이나 | 사이징이 알아야 하는 이유 |
|---|---|---|
| 매수 슬리피지 | 주문 계층 | 체결가가 시세보다 높아져 자본이 모자란다 |
| 거래 수수료 | 증권사 | 주문 금액 외에 별도로 빠져 미세하게 부족해진다 |
| 매도 슬리피지 | 주문 계층 | 익절가가 계획보다 낮아져 실현 손익비가 달라진다 |
| 환율 평가 | 증권사 증거금 규정 | 원화·타통화로 살 때 평가액이 깎여서 잡힌다 |
마지막 줄은 특히 원화 계좌로 미국주식을 사는 국내 투자자에게 크다. 국내 증권사들의 해외주식 통합증거금 설명서를 보면, 거래 통화가 아닌 다른 통화 자산을 증거금으로 충당할 때 당일 매매기준환율보다 5% 불리한 환율로 평가한다고 명시돼 있다. 주문가능금액을 산출할 때 원화 자산에 105%의 환율을 적용하거나 타통화 자산을 95%로 인정하는 식이다. 계좌에 찍힌 원화를 그대로 달러로 환산해 “이만큼 살 수 있다”고 계산하면, 증권사 화면의 주문가능금액보다 항상 몇 퍼센트 크게 나온다. 봇이 보는 숫자가 낙관적일 수밖에 없는 구조적 이유다.
🛠 고친 방법 — 유효 단가 한 줄
해법은 단순하다. 나눗셈의 분모를 시세가 아니라 증권사가 실제로 검증할 1주당 총비용으로 바꾸면 된다.
buy_slip = params["order"]["buy_slippage_pct"] # 0.01
comm_rate = params["commission"]["rate_per_side"] # 0.0025
eff_price = price * (1 + buy_slip + comm_rate) # 1주 총비용
shares = int(sizing_capital / eff_price)
여기에 포지션 상한을 100% → 99%로 낮춰 1%의 여유 바닥을 뒀다. 환율 정산 지연이나 미결제 금액처럼 봇이 실시간으로 알 수 없는 잔돈을 흡수하는 완충재다.
같은 사고 상황에 이 수식을 대입하면 유효 단가는 $61.01 × 1.0125 = $61.77이 되고, 수량은 51주가 아니라 50주가 된다. 50주 × $61.66 = $3,083.00으로 한도 안에 $45의 여유를 두고 들어간다. 즉 1.25%의 쿠션은 주문 접수까지의 미세한 가격 변동까지 함께 흡수한다.
✅ 안전장치가 성능을 갉아먹었을까? 백테스트로 확인했다. 60일 구간 25거래 중 수량이 1주 줄어든 건 2거래, 최종 자본 차이는 0.04%였다. 전략 지표는 사실상 그대로인 채 하루를 통째로 잃는 사고만 사라진 셈이다. 안전장치를 넣을 때는 “이게 수익을 얼마나 깎는가”를 반드시 같이 재야 한다. 재보지 않으면 무서워서 못 넣거나, 겁 없이 과하게 넣는다.
🌍 같은 결론에 먼저 도착해 있던 프레임워크들
이 문제가 특정 증권사나 특정 봇의 사정이 아니라는 건, 성숙한 오픈소스 트레이딩 프레임워크의 기본값을 보면 분명해진다. 대표적인 암호화폐 자동매매 프레임워크 freqtrade의 설정 문서에는 두 개의 관련 파라미터가 있다.
| 파라미터 | 기본값 | 존재 이유 |
|---|---|---|
| tradable_balance_ratio | 0.99 | 잔고 전체가 아니라 1%를 뺀 금액만 매매에 쓰게 한다 |
| amount_reserve_percent | 0.05 | 최소 주문금액 계산 시 손절폭과 함께 여유분을 남겨 “주문 거절 가능성”을 피한다 |
먼저 부딪힌 사람들이 남긴 기본값이 99%라는 사실은 꽤 의미심장하다. 우리가 사고를 겪고 나서 손으로 정한 포지션 상한 0.99와 같은 숫자에 독립적으로 도달한 것이다. 미국 API 브로커 커뮤니티에서도 “매수여력 부족(insufficient buying power)”으로 주문이 거부되는 문의는 상시 반복되는 단골 주제이고, 대체로 원인은 같다. 잔고를 100% 꽉 채워 계산했는데 수수료·호가 변동·미결제 금액이 그 위에 얹히는 것이다. 결국 여유분 없이 잔고를 꽉 채워 주문하는 설계는 언젠가 반드시 거부를 만난다.
🧩 아직 닫히지 않은 두 개의 틈
사고 기록을 정리하면서 스스로에게 남긴 권고는 “유효 단가를 계산하는 단일 함수를 만들어 사이징·주문·익절이 전부 같은 값을 쓰게 하라”였다. 그런데 코드를 다시 열어보니 그 함수는 아직 문서 속 권고로만 존재한다. 실제로는 같은 수식이 다섯 군데에 복사돼 있고, 설정값이 없을 때 쓰는 기본값이 두 갈래로 갈려 있다.
| 사용 위치 | 슬리피지 기본값 | 수수료 기본값 |
|---|---|---|
| 인트라데이 진입 | 0.005 | 0.0009 |
| 최초 시드 매수 | 0.01 | 0.0025 |
| 모멘텀 버킷 리밸런스 | 0.01 | 0.0025 |
| 추세 슬리브 리밸런스 | 0.01 | 0.0025 |
| 분기말 편차 보정 | 0.01 | 0.0025 |
설정 파일에 값이 들어 있는 한 다섯 곳 모두 같은 숫자(1%·0.25%)를 쓰므로 지금 당장 오작동하지는 않는다. 하지만 설정 키 하나가 빠지는 순간, 인트라데이 경로만 유효 단가를 0.66% 낮게 잡고 다시 같은 사고를 낸다. 사고를 막은 것과 사고의 원인을 제거한 것은 다르다. 지금 상태는 전자에 가깝다.
두 번째 틈은 더 미묘하다. 사이징은 신호가 발생한 순간의 가격 $61.01을 쓰지만, 주문 함수는 접수 직전에 시세를 다시 조회한다. 그날 재조회된 가격은 $61.05였다. 접수까지 몇 초 사이에 값이 움직인 것이다. 지금은 1.25%의 쿠션이 이 정도 흔들림을 삼켜주지만, 원리적으로는 “수량을 정한 가격”과 “주문서에 찍힌 가격”이 여전히 다른 시점의 숫자다. 변동성이 큰 개장 직후에는 이 간극이 쿠션을 넘어설 수 있다.
📞 더 정확한 답 — 증권사에 직접 물어보는 길
사실 이 문제에는 계산이 필요 없는 정답이 있다. 증권사 API에는 “이 종목을 이 가격에 몇 주까지 살 수 있는가”를 그대로 알려주는 조회가 따로 있다. 종목 코드와 단가를 같이 넘기면 매수가능금액과 최대주문가능수량을 함께 돌려준다. 우리 계산과 달리 슬리피지·수수료·증거금·환율이 이미 반영된, 증권사 자신의 답이다.
💡 그런데 코드를 확인해보니 이 조회는 이미 구현돼 있고 테스트까지 붙어 있는데, 정작 실매매 사이징 경로 다섯 곳은 전부 종목을 지정하지 않는 단순 잔고 조회를 쓰고 있었다. 증권사가 답을 주는데 우리가 묻지 않고 있었던 셈이다.
그럼 왜 아직 바꾸지 않았나. 트레이드오프가 있기 때문이다. 이 조회는 종목별로 호출해야 해서 진입 판단 직전에 API 왕복이 한 번 늘어난다. 인트라데이 진입은 신호가 뜬 뒤 1~2초 안에 주문을 넣어야 의미가 있고, 토큰 발급과 조회에는 분당 호출 제한이 걸려 있다. 초 단위 지연을 감수할 것인가, 계산으로 근사하고 여유분을 둘 것인가. 지금 선택은 후자다. 다만 월 1회만 도는 리밸런스 경로는 지연이 무의미하므로, 이쪽부터 증권사의 답을 그대로 쓰도록 바꾸는 게 다음 순서로 맞다고 본다.
✅ 내 봇에서 같은 간극을 찾는 점검 목록
▶ 수량을 정한 가격과 주문서에 찍히는 가격이 같은가? 다르다면 그 차이만큼 언젠가 거부된다.
▶ ‘자본’ 변수에 담긴 게 총자산인가 출금가능 현금인가? 우리 봇의 자본 동기화는 외화 출금가능금액을 읽는다. 보유 종목 평가액이 포함된 총자산이 아니다. 이걸 헷갈리면 수익률까지 잘못 계산한다.
▶ 잔고를 100% 쓰도록 설계돼 있는가? 1~5%의 여유 바닥은 성능 손실이 아니라 체결률 보험이다.
▶ 같은 비용 수식이 여러 곳에 복사돼 있지 않은가? 복사본이 늘어나면 기본값이 조용히 갈라진다.
▶ 신호 발사와 주문 거부가 같은 초에 찍히는 로그가 있는가? 이 조합이 바로 재발 탐지 신호다.
자동매매 봇을 만들면서 가장 자주 부딪히는 벽은 전략의 정교함이 아니라 “내 프로그램이 세상을 어떻게 이해하고 있는가”와 “세상이 실제로 어떻게 판정하는가” 사이의 틈이었다. 시세, 자본, 수량 — 셋 다 숫자 하나로 보이지만 어느 것도 하나가 아니다. 거부 로그는 그 틈이 있다고 알려주는 가장 친절한 신호였다.
📚 함께 읽기
• 실거래 봇이 죽지 않게 만드는 안전장치 모음 — 주문 이후 단계의 사고들
• 증권사 API 기본 연계 함수 만들기 — 잔고 조회 API가 셋으로 갈리는 이유
• 수수료가 좋은 전략을 죽인 기록 — 비용을 계산에서 빠뜨렸을 때의 대가
본 글은 개인 자동매매 시스템의 개발·운영 기록이며 특정 종목이나 매매 기법의 투자 권유가 아닙니다. 본문의 수치는 필자 계좌의 실제 로그와 코드에서 추출한 값으로, 계좌 상태·증권사 정책·시장 상황에 따라 달라집니다. 레버리지 ETF를 포함한 모든 투자에는 원금 손실 위험이 있으며, 투자 판단과 그 결과에 대한 책임은 투자자 본인에게 있습니다.