[16/17] 자동매매 개발일지 – 주문 수량 계산

봇이 주문 수량을 계산할 때 쓰는 가격과 증권사가 그 주문을 받아 줄지 판정할 때 쓰는 가격은 같지 않다. 잔고가 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)

봐야 할 곳은 셋째 줄과 넷째 줄이다. 봇의 내부 기록은 51주 @ $61.01인데, 실제로 증권사에 나간 주문은 51주 @ $61.66이다. 같은 주문인데 단가가 65센트 다르고, 이 65센트 차이로 그날은 매매를 못 했다. 상태머신은 주문이 실패하면 곧바로 그날 매매 종료 상태(DONE_TODAY)로 넘어가기 때문에 재시도도 없었다.

봇의 산수와 증권사의 산수

당시 계좌의 미국주식 주문가능금액은 $3,128.22였다. 봇의 수량 계산은 지극히 상식적이었다. 자본을 시세로 나누고 소수점을 버린다.

Python
shares = int(3128.22 / 61.01)   # = 51

51주에 $61.01을 곱하면 $3,111.51이라 잔고 안에 넉넉히 들어온다. 문제는 증권사가 이 산수를 보지 않는다는 데 있다. 증권사가 보는 건 실제로 접수된 주문서, 즉 51주에 $61.66이고 금액으로는 $3,144.66이다. 한도 $3,128.22를 $16.44 넘겼다. 1주 값의 4분의 1이 모자라서 51주 전체가 거부된 것이다.

시세와 증권사 사이에 끼어드는 것들

65센트는 주문 계층에서 나왔다. 한국투자증권 해외주식은 나스닥 종목에 진짜 시장가 주문을 지원하지 않는다. 그래서 봇의 시장가 매수 함수는 이름과 달리 현재가보다 조금 높은 지정가를 걸어 즉시 체결을 흉내 낸다.

시세와 증권사 사이에 끼어드는 것들 도식

수량은 시세로 정해지는데, 그 뒤에서 주문 계층이 지정가를 1% 올려 접수한다. 수량 계산(사이징)은 이 인상분을 모르니 증권사 검증 단계에서 한도를 넘겨 거부된다. 이 구조를 한 번 보고 나니 비슷한 변환이 곳곳에 숨어 있는 게 보였다. 봇이 아는 가격과 증권사가 판정하는 금액 사이에는 적어도 네 겹이 있다.

변환누가 붙이나사이징이 알아야 하는 이유
매수 슬리피지주문 계층체결가가 시세보다 높아져 자본이 모자란다
거래 수수료증권사주문 금액과 별도로 빠져서 조금씩 부족해진다
매도 슬리피지주문 계층익절가가 계획보다 낮아져 실현 손익비가 달라진다
환율 평가증권사 증거금 규정원화나 다른 통화로 살 때 평가액이 깎여서 잡힌다

마지막 줄은 원화 계좌로 미국주식을 사는 국내 투자자에게 특히 크다. 국내 증권사들의 해외주식 통합증거금 설명서를 보면, 거래 통화가 아닌 통화의 자산을 증거금으로 쓸 때는 당일 매매기준환율보다 5% 불리한 환율로 평가한다. 그래서 계좌에 찍힌 원화를 그대로 달러로 바꿔 계산하면 증권사 화면의 주문가능금액보다 늘 몇 퍼센트 크게 나온다.

분모를 1주당 총비용으로 바꾼 한 줄

고친 방법은 단순하다. 나눗셈의 분모를 시세 대신 증권사가 실제로 검증할 1주당 총비용으로 바꿨다.

Python
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의 기본값도 99%였다

암호화폐 자동매매 프레임워크로 잘 알려진 freqtrade의 설정 문서에도 관련된 파라미터가 두 개 있다.

파라미터기본값존재 이유
tradable_balance_ratio0.99잔고 전체가 아니라 1%를 뺀 금액만 매매에 쓰게 한다
amount_reserve_percent0.05최소 주문금액을 계산할 때 손절폭과 함께 여유분을 남겨 주문 거절 가능성을 피한다

나는 사고를 겪고 나서 손으로 포지션 상한을 0.99로 정했는데, 오래 다듬어진 오픈소스 프레임워크의 기본값도 같은 숫자였다.

아직 닫지 못한 두 개의 틈

사고 기록을 쓰면서 나 자신에게 남긴 권고는 유효 단가를 계산하는 함수를 하나만 두고 사이징, 주문, 익절이 모두 같은 값을 쓰게 하라는 것이었다. 그런데 코드를 다시 열어 보니 그 함수는 아직 문서 속 권고로만 있었다. 실제로는 같은 수식이 다섯 군데에 복사돼 있고, 설정값이 없을 때 쓰는 기본값도 두 갈래로 갈려 있다.

사용 위치슬리피지 기본값수수료 기본값
인트라데이 진입0.0050.0009
최초 시드 매수0.010.0025
모멘텀 버킷 리밸런스0.010.0025
추세 슬리브 리밸런스0.010.0025
분기말 편차 보정0.010.0025

설정 파일에 값이 들어 있는 한 다섯 곳 모두 같은 숫자(1%, 0.25%)를 쓰니 당장 오작동하지는 않는다. 하지만 설정 키 하나가 빠지는 순간 인트라데이 경로만 유효 단가를 0.66% 낮게 잡고 같은 사고를 다시 낸다. 지금 상태는 사고를 막아 둔 쪽에 가깝고, 원인을 없앤 건 아직 아니다.

두 번째 틈은 더 미묘하다. 사이징은 신호가 뜬 순간의 가격 $61.01을 쓰는데, 주문 함수는 접수 직전에 시세를 다시 조회한다. 그날 다시 조회한 가격은 $61.05였다. 접수까지 몇 초 사이에 값이 움직인 것이다. 지금은 이 정도 변동이 1.25% 쿠션 안에 들어가지만, 수량을 정한 가격과 주문서에 찍힌 가격이 서로 다른 시점의 숫자라는 건 그대로다. 변동성이 큰 개장 직후에는 이 간극이 쿠션을 넘어설 수 있다.

증권사에 직접 물어보면 되지 않을까

사실 이 문제에는 계산이 필요 없는 답이 있다. 증권사 API에는 이 종목을 이 가격에 몇 주까지 살 수 있는지 그대로 알려 주는 조회가 따로 있다. 종목 코드와 단가를 같이 넘기면 매수가능금액과 최대주문가능수량을 돌려주는데, 슬리피지와 수수료, 증거금, 환율이 이미 반영된 증권사 자신의 답이다.

코드를 확인해 보니 이 조회는 이미 구현돼 있고 테스트까지 붙어 있었다. 그런데 정작 실매매 사이징 경로 다섯 곳은 전부 종목을 지정하지 않는 단순 잔고 조회를 쓰고 있었다.

덧붙이면 봇의 자본 동기화가 읽는 값은 외화 출금가능금액이고, 보유 종목 평가액까지 들어간 총자산이 아니다. 이 둘을 헷갈리면 수익률까지 잘못 계산하게 된다.

그럼 왜 아직 안 바꿨느냐 하면, 이 조회를 쓰면 주문이 느려지기 때문이다. 이 조회는 종목마다 불러야 해서 진입 판단 직전에 API 왕복이 한 번 늘어난다. 인트라데이 진입은 신호가 뜨고 1~2초 안에 주문을 넣어야 의미가 있고, 토큰 발급과 조회에는 분당 호출 제한도 걸려 있다. 초 단위 지연을 감수할지, 계산으로 근사하고 여유분을 둘지 사이에서 지금은 후자를 골랐다. 다만 월 1회만 도는 리밸런스 경로는 지연이 문제가 되지 않으니, 이쪽부터 증권사의 답을 그대로 쓰게 바꾸는 게 다음 순서라고 본다.

주문을 낸 뒤에 생기는 사고들은 15편에, 잔고 조회 API가 셋으로 갈리는 이유는 2편에, 비용을 계산에서 빠뜨렸을 때 치른 대가는 8편에 적었다.

이 글은 내 자동매매 시스템의 개발·운영 기록이고 특정 종목이나 매매 기법을 권하는 글이 아니다. 본문의 수치는 내 계좌의 실제 로그와 코드에서 뽑은 값이라 계좌 상태와 증권사 정책, 시장 상황에 따라 달라진다.

참고한 자료: Freqtrade Configuration (tradable_balance_ratio, amount_reserve_percent), 유안타증권 해외주식 통합증거금 안내, 신한투자증권 해외주식 통합증거금 서비스 설명서(PDF), Alpaca Community, Handling “insufficient buying power”

댓글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다