포지션 사이징 4가지 — 봇 매수 수량 계산과 실전 보정
수량 = (평가금액 × 1회 허용손실률) ÷ (ATR × 손절배수)로 역산하면
종목이 바뀌어도 한 번의 실패가 계좌에 주는 충격이 일정하게 유지됩니다.
그리고 계산된 수량은 그대로 주문에 못 넣습니다.
국내주식은 KIS 매수가능조회 TTTC8908R의 nrcvb_buy_qty와 대조하고,
업비트 원화 마켓은 최소 주문 5,000원,
바이낸스 현물은 LOT_SIZE의 stepSize·minQty와
NOTIONAL의 minNotional을 통과해야 주문이 접수됩니다.
먼저 분명히 — 이 글은 손실 크기를 관리하는 규칙을 다룹니다. 어떤 사이징 방식도 수익을 만들거나 보장하지 않으며, 특정 종목·전략을 권하지 않습니다. 아래 공식들은 널리 알려진 학술·실무 개념이고, 과거 데이터로 계산한 값이 미래에 그대로 반복된다는 보장은 없습니다. 투자 판단과 결과의 책임은 투자자 본인에게 있습니다.
자동매매 사양서의 대부분은 "언제 사고 언제 파나"에 쓰여 있고, "얼마나 사나"는 "1종목당 100만원" 같은 상수 한 줄로 끝납니다. 그런데 실전에서 계좌가 망가지는 순서를 보면 진입 규칙보다 이 한 줄이 먼저입니다.
목차
- 왜 수량이 신호보다 먼저 망가지나
- 사이징 4가지 비교
- ① 고정 금액 · ② 고정 비율
- ③ ATR 리스크 기반 — 실무 기본값
- ④ 변동성 타깃
- 켈리 공식은 왜 그대로 안 쓰나
- 계산된 수량을 그대로 못 쓰는 이유 — 보정 4단계
- 백테스트로 사이징만 검증하기
- 자주 묻는 질문
1. 왜 수량이 신호보다 먼저 망가지나
같은 신호라도 수량에 따라 손실 발생 시 충격 크기가 완전히 달라집니다. 금액을 고정하면 하루 3퍼센트씩 움직이는 종목과 12퍼센트씩 움직이는 종목에 같은 돈이 들어가고, 후자에서 한 번 손절되면 전자보다 훨씬 큰 금액이 사라집니다.
문제는 이 편차가 기록에 잘 안 남는다는 점입니다. 기간별매매손익 조회로 종목별 실현손익을 뽑아 보면 특정 종목들에서만 손실이 유독 크게 찍히는 경우가 흔한데, 이때 대부분은 진입 조건이 아니라 그 종목에 실린 수량이 원인입니다. 진입 규칙만 계속 고치면서 몇 달을 보내게 되는 전형적인 경로입니다.
사이징이 하는 일은 하나입니다 — 종목마다 다른 변동성을 "1회 손실 금액"이라는 하나의 잣대로 환산하는 것. 그래야 전략의 성패를 신호의 품질로만 평가할 수 있습니다. 이 얘기는 자동매매 리스크 관리의 손절·한도 설계와 한 세트로 봐야 합니다.
2. 사이징 4가지 비교
| 방식 | 계산 | 장점 | 주의 |
|---|---|---|---|
| ① 고정 금액 | 항상 같은 원화 금액 | 가장 단순 · 검증 쉬움 | 변동성 무시 · 계좌가 커져도 안 늘어남 |
| ② 고정 비율 | 평가금액 × p% |
계좌 크기에 자동 연동 | 여전히 변동성 무시 · 손실 후 축소 효과만 있음 |
| ③ ATR 리스크 | 허용손실액 ÷ (ATR × k) |
1회 손실이 상수로 고정 | 손절을 실제로 지켜야 성립 · 갭에는 무력 |
| ④ 변동성 타깃 | 목표변동성 ÷ 실현변동성 × 기준비중 | 포트폴리오 전체 변동성 관리 | 추정 구간 길이에 민감 · 회전율 상승 |
3. ① 고정 금액 · ② 고정 비율
가장 단순한 두 가지입니다. 코드가 짧고 검증이 쉬워서 봇의 첫 버전에는 이 둘 중 하나로 시작하는 것이 맞습니다. 사이징 로직이 복잡하면 초기에 버그를 구분하기 어렵기 때문입니다.
def size_fixed_amount(price, amount=1_000_000):
"""① 고정 금액 — 항상 같은 돈만큼"""
return int(amount // price)
def size_fixed_fraction(price, equity, pct=0.10):
"""② 고정 비율 — 평가금액의 pct 만큼"""
return int((equity * pct) // price)
②는 계좌가 커지면 수량도 같이 커지고, 손실이 나면 자동으로 줄어듭니다. 다만 둘 다 종목의 변동성을 전혀 보지 않습니다. 이 지점이 다음 방식으로 넘어가는 이유입니다.
4. ③ ATR 리스크 기반 — 실무 기본값
핵심 아이디어는 "수량을 먼저 정하지 않는다"입니다. 한 번에 잃어도 되는 금액을 먼저 상수로 박고, 손절폭으로 나눠 수량을 역산합니다.
수량 = (평가금액 × 1회 허용손실률) ÷ (ATR × 손절배수)예) 평가금액 1,000만원 · 허용손실률 0.5% → 허용손실액 5만원
ATR 1,000원 · 손절배수 2 → 주당 손절폭 2,000원 → 수량 = 50,000 ÷ 2,000 = 25주
ATR(Average True Range)은 talib.ATR이나 pandas_ta로 계산합니다.
두 라이브러리는 같은 지표라도 초기값 처리와 평활 방식이 달라 값이 조금씩 다르게 나옵니다 —
이 차이는 TA-Lib과 pandas-ta 지표 차이에서 정리했습니다.
사이징에 쓰는 경우 백테스트와 실전 서버가 같은 라이브러리·같은 버전인지 반드시 맞추십시오.
import numpy as np
import pandas as pd
import talib
def size_atr_risk(df, equity, risk_pct=0.005, atr_period=14, stop_mult=2.0):
"""③ ATR 리스크 기반
df: 일봉 DataFrame (high, low, close)
risk_pct: 1회 허용 손실 비율 (0.005 = 0.5%)
"""
atr = talib.ATR(df["high"].values, df["low"].values,
df["close"].values, timeperiod=atr_period)
atr_now = atr[-1]
if not np.isfinite(atr_now) or atr_now <= 0:
return 0, None # 데이터 부족 - 주문 보류
risk_amount = equity * risk_pct # 한 번에 잃어도 되는 금액
stop_width = atr_now * stop_mult # 주당 손절폭
qty = int(risk_amount // stop_width)
entry = float(df["close"].iloc[-1])
stop_price = entry - stop_width # 손절가도 같이 돌려준다
return qty, stop_price
수량과 손절가를 한 함수에서 같이 돌려주십시오. 이 방식은 손절을 실제로 집행한다는 전제 위에서만 성립합니다. 수량만 계산하고 손절 주문은 다른 모듈에서 따로 만들면 두 값이 어긋난 채 운영되는 사고가 납니다 — 계산은 ATR 2배로 해놓고 실제 손절은 5퍼센트 고정으로 들어가는 식입니다.
이 방식의 한계 — 손절폭을 넘어서는 갭에는 무력합니다. 장 시작에 하한가로 시작하면 손절 주문은 훨씬 아래에서 체결되거나 아예 안 됩니다. 그래서 ATR 사이징은 1회 손실의 하한이 아니라 평상시 기대치로 봐야 하고, 일일 손실 한도·킬스위치를 계좌 단위로 따로 두는 것이 함께 필요합니다.
5. ④ 변동성 타깃
개별 종목의 손절폭이 아니라 포트폴리오 전체의 변동성을 일정하게 유지하는 방식입니다. 최근 실현 변동성이 목표보다 높으면 비중을 줄이고, 낮으면 늘립니다.
def size_vol_target(close: pd.Series, equity, base_weight=0.20,
target_vol=0.15, lookback=20, max_leverage=1.0):
"""④ 변동성 타깃 — 연율화 변동성을 target_vol에 맞춘다"""
ret = np.log(close).diff().dropna()
realized = ret.tail(lookback).std() * np.sqrt(252) # 연율화
if not np.isfinite(realized) or realized == 0:
return 0
scale = min(target_vol / realized, max_leverage / base_weight)
weight = base_weight * scale
notional = equity * weight
return int(notional // float(close.iloc[-1]))
np.sqrt(252)는 일간 수익률 표준편차를 연율로 바꾸는 관례적인 환산입니다.
코인처럼 24시간 거래되는 시장은 252가 아니라 365를 씁니다.
이 상수를 시장별로 안 바꾸면 변동성이 체계적으로 과소·과대 추정됩니다.
주의할 점은 lookback에 민감하다는 것입니다. 20일과 60일은 전혀 다른 비중을 내놓습니다.
그리고 변동성이 튈 때마다 비중을 조정하므로 매매 횟수가 늘어납니다 —
수수료·슬리피지를 반영하지 않은 백테스트에서는 이 비용이 안 보입니다.
6. 켈리 공식은 왜 그대로 안 쓰나
켈리 공식은 승률과 손익비를 정확히 안다는 전제에서 장기 성장률을 최대화하는 학술적 결과입니다. 자동매매의 문제는 그 승률과 손익비 자체가 과거 백테스트에서 추정한 값이라는 것입니다.
def kelly_fraction(win_rate, win_loss_ratio):
"""f* = p - (1-p)/b (b = 평균이익/평균손실)
⚠️ 추정치 기반이므로 그대로 쓰지 말 것"""
if win_loss_ratio <= 0:
return 0.0
f = win_rate - (1 - win_rate) / win_loss_ratio
return max(0.0, f)
f_star = kelly_fraction(0.45, 1.8) # 0.1444...
f_used = min(f_star * 0.5, 0.10) # 반켈리 + 종목당 상한 10%
승률 추정이 몇 퍼센트만 낙관적이어도 켈리 비중은 크게 부풀고, 그만큼 낙폭이 깊어집니다. 그래서 실무에서는 계산값의 절반 이하만 쓰는 '반켈리' 같은 축소 적용이 일반적이고, 거기에 종목당 상한을 따로 겁니다. 켈리는 상한을 가늠하는 참고선이지 목표치가 아닙니다.
7. 계산된 수량을 그대로 못 쓰는 이유 — 보정 4단계
여기가 백테스트 코드와 실전 봇이 갈리는 지점입니다. 사이징 함수가 내놓은 수량은 "이론값"이고, 그대로 주문하면 거절되거나 의도와 다르게 체결됩니다.
보정 1 — 실제로 살 수 있는지 확인 (국내주식)
KIS 매수가능조회는 /uapi/domestic-stock/v1/trading/inquire-psbl-order이고
TR ID는 실전 TTTC8908R / 모의 VTTC8908R입니다.
공식 안내에서 명시하는 함정이 하나 있습니다 —
ORD_DVSN을 00(지정가)로 조회하면 종목증거금율이 반영되지 않습니다.
증거금이 반영된 실제 가능수량을 보려면 ORD_DVSN을 01(시장가)로 넣어야 합니다.
| 응답 필드 | 의미 | 언제 쓰나 |
|---|---|---|
ord_psbl_cash | 주문가능현금 | 현금 여력 확인 |
psbl_qty_calc_unpr | 가능수량계산단가 | 어떤 단가로 계산됐는지 대조 |
nrcvb_buy_qty | 미수없는매수수량 | 미수 안 쓸 때의 상한 |
nrcvb_buy_amt | 미수없는매수금액 | 금액 기준 상한 |
max_buy_qty / max_buy_amt | 최대매수수량 / 금액 | 미수 사용 시 상한 |
ruse_psbl_amt | 재사용가능금액 | 매도 대금 재사용 여부 |
cma_evlu_amt | CMA평가금액 | CMA_EVLU_AMT_ICLD_YN에 따라 포함 |
봇에서는 min(이론수량, nrcvb_buy_qty)가 기본입니다.
미수를 쓰지 않는 전략이라면 max_buy_qty는 쳐다보지 마십시오 — 그 값을 쓰면 미수가 발생합니다.
필드 의미와 호출 방법은 KIS 매수가능조회에 자세히 정리해 뒀고,
토큰 발급부터의 전체 흐름은 KIS API 30분 가이드를 보시면 됩니다.
보정 2 — 가격을 호가 단위에 맞춘다
지정가 주문은 호가 단위에 맞지 않는 가격을 넣으면 거절됩니다. 국내주식 호가 단위는 가격대별로 다르고 제도 변경 이력이 있으므로 코드에 상수로 박지 말고 설정 파일로 빼서 KRX·증권사 공식 안내로 주기적으로 대조하십시오.
업비트 원화 마켓의 주문 가격 단위는 공식 문서에 표로 공개되어 있습니다.
예를 들어 10,000원 이상 50,000원 미만 구간은 10원 단위,
100,000원 이상 500,000원 미만은 100원 단위이며,
1원 미만 구간은 0.001 이하로 더 잘게 나뉩니다.
바이낸스 현물은 심볼별 PRICE_FILTER의 tickSize가 같은 역할을 합니다.
from decimal import Decimal, ROUND_DOWN
def round_to_tick(price, tick):
"""호가 단위로 내림 - float 연산 오차를 피하려 Decimal 사용"""
p, t = Decimal(str(price)), Decimal(str(tick))
return float((p / t).to_integral_value(rounding=ROUND_DOWN) * t)
def round_to_step(qty, step_size):
"""바이낸스 LOT_SIZE.stepSize 에 맞춰 수량 내림"""
q, s = Decimal(str(qty)), Decimal(str(step_size))
return float((q / s).to_integral_value(rounding=ROUND_DOWN) * s)
float로 반올림하면 언젠가 거절됩니다.
0.1 + 0.2 = 0.30000000000000004 같은 부동소수점 오차가
stepSize 검사에 그대로 걸립니다.
수량·가격 반올림은 Decimal로 하십시오. 코인 쪽에서 특히 자주 터지는 버그입니다.
보정 3 — 최소 주문 금액을 넘긴다
사이징 결과가 너무 작아지면 주문 자체가 성립하지 않습니다. 변동성이 큰 종목일수록 ATR 사이징 수량이 작아지므로, 이 상황은 생각보다 자주 발생합니다.
| 시장 | 기준 | 확인 방법 |
|---|---|---|
| 업비트 원화(KRW) 마켓 | 최소 주문 가능 금액 5,000원 | 공식 문서 "원화 마켓 주문 정보" |
| 바이낸스 현물 | NOTIONAL → minNotionalLOT_SIZE → minQty·stepSize |
GET /api/v3/exchangeInfo 심볼별 filters |
| 국내주식 | 수량 1주 미만이면 주문 불가 (단주 단위) |
계산 결과 0이면 주문 보류가 정답 |
바이낸스는 심볼마다 필터 값이 다르므로 하드코딩하지 말고 exchangeInfo를 받아 캐시해 두십시오.
API 키 발급과 권한 설정이 아직이라면 바이낸스 API 발급 가이드를 먼저 보시면 됩니다.
시장가 주문에는 NOTIONAL 필터의 applyMinToMarket,
구버전 MIN_NOTIONAL 필터의 applyToMarket 플래그가 별도로 적용됩니다.
def load_symbol_filters(exchange_info, symbol):
"""exchangeInfo에서 심볼 필터 추출 - 시작 시 1회 로드해 캐시"""
s = next(x for x in exchange_info["symbols"] if x["symbol"] == symbol)
f = {flt["filterType"]: flt for flt in s["filters"]}
return {
"tick": float(f["PRICE_FILTER"]["tickSize"]),
"step": float(f["LOT_SIZE"]["stepSize"]),
"min_qty": float(f["LOT_SIZE"]["minQty"]),
"min_notional": float(f.get("NOTIONAL", f.get("MIN_NOTIONAL", {}))
.get("minNotional", 0)),
}
def finalize_qty(qty, price, flt):
qty = round_to_step(qty, flt["step"])
if qty < flt["min_qty"]: return 0 # 주문 보류
if qty * price < flt["min_notional"]: return 0 # 명목가 미달
return qty
수량이 0으로 떨어졌을 때 "최소 수량으로 올려서 주문"하지 마십시오. 그 순간 사이징 규칙이 깨지고, 가장 변동성이 큰 종목에 규칙보다 큰 리스크가 실립니다. 주문을 건너뛰고 로그를 남기는 것이 정답입니다. 코인 쪽에서 최소 주문 금액에 자주 걸린다면 종목 선정 단계에서 걸러야 할 신호이기도 합니다 (알트코인 유니버스 필터).
보정 4 — 종목당 비중 상한과 총 노출 상한
사이징 공식이 아무리 정교해도 같은 섹터 다섯 종목에 동시에 신호가 뜨면 합쳐서 계좌의 대부분이 한 방향에 실릴 수 있습니다. 공식 밖에 상한을 따로 걸어야 합니다.
- 종목당 상한 — 평가금액 대비 최대 비중(예: 10%). 계산 결과가 넘으면 잘라 냅니다.
- 총 노출 상한 — 동시 보유 종목 수 × 비중의 합에 상한. 현금 비중을 확보합니다.
- 동시 신규 진입 수 제한 — 하루에 새로 여는 포지션 개수를 제한합니다.
- 중복 상관 제한 — 같은 섹터·같은 기초자산으로 묶이는 종목은 하나로 셉니다.
8. 백테스트로 사이징만 검증하기
검증 방법은 단순합니다. 진입·청산 규칙을 완전히 고정한 채 사이징 방식만 바꿔 가며 돌립니다. 그리고 수익률이 아니라 낙폭 분포를 봅니다.
- backtrader —
cerebro.addsizer()로bt.sizers의 사이저를 갈아 끼우거나bt.Sizer를 상속해 ATR 사이저를 직접 구현합니다. - vectorbt —
Portfolio.from_signals()에size와size_type을 넘겨 금액 기준·수량 기준·비중 기준을 바꿔 가며 비교합니다. - quantstats — 결과를 최대낙폭(MDD)·변동성 중심으로 읽습니다.
두 라이브러리의 차이와 선택 기준은 파이썬 백테스팅 라이브러리 비교에 정리해 뒀습니다.
수수료·슬리피지를 반드시 켜고 비교하십시오. 수량이 커지면 체결 비용도 같이 커지는데, 이를 빼면 "큰 수량이 항상 유리하다"는 왜곡된 결론이 나옵니다. 또한 여기서 나온 최적 파라미터를 그대로 실전에 옮기면 과최적화가 됩니다 — 사이징 파라미터도 진입 파라미터와 똑같이 기간을 나눠 검증해야 합니다. 과거 성과가 미래를 보장하지 않습니다.
9. 자주 묻는 질문
1회 허용 손실 비율은 얼마로 잡나요?
정답은 없고, 전략의 매매 빈도와 동시 보유 종목 수에 따라 달라집니다. 하루에 여러 번 매매하고 동시에 10종목을 들고 있다면 종목당 비율은 당연히 낮아야 합니다. 실무에서는 계좌의 0.5~1퍼센트 사이를 출발점으로 두고, "동시에 열 수 있는 최대 포지션 수 × 종목당 허용손실"이 감당 가능한지를 역산해 조정합니다. 이 값은 수익 목표가 아니라 버틸 수 있는 손실 크기에서 나와야 합니다.
분할 매수를 쓰면 사이징은 어떻게 되나요?
전체 목표 수량을 먼저 사이징으로 확정하고, 그 안에서 회차를 나누는 순서가 맞습니다.
회차마다 독립적으로 사이징을 돌리면 총 포지션이 의도보다 커집니다.
또한 2회차 진입 시점의 평균 단가(pchs_unpr)로 손절가를 다시 계산해야 하는데,
이 재계산을 빼먹는 것이 분할 매수 봇에서 가장 흔한 버그입니다.
레버리지·선물에서도 같은 공식을 쓰나요?
ATR 리스크 공식 자체는 같지만 계약 단위·증거금·청산가가 추가로 들어갑니다.
바이낸스 USDT-M 선물이라면 LOT_SIZE 외에 MARKET_LOT_SIZE·MIN_NOTIONAL이 따로 적용되고,
청산가가 손절가보다 가까우면 손절이 작동하기 전에 강제청산될 수 있어
사이징 결과를 청산가 기준으로 한 번 더 검증해야 합니다.
레버리지는 사이징이 아니라 리스크 증폭이며 손실도 같은 배수로 커집니다.
이 코드를 그대로 써도 되나요?
예시 코드는 계산 구조를 보여 주기 위한 것이고 그대로 운영에 넣기에는 부족합니다.
본문의 KIS API 필드명(nrcvb_buy_qty·ord_psbl_cash 등)과
ORD_DVSN 주의사항은 2026-08-21 시점 한국투자증권 공식 오픈API 저장소에서,
업비트 최소 주문 금액 5,000원과 원화 마켓 호가 단위는 같은 날 업비트 공식 문서에서,
바이낸스 필터명은 같은 날 바이낸스 공식 개발자 문서에서 확인한 것입니다.
수수료·호가 단위·최소 주문 금액·API 명세는 예고 없이 바뀌므로 운영 전 공식 문서로 다시 대조하시고,
상수는 코드가 아니라 설정으로 빼 두시기 바랍니다.
수량 규칙까지 코드로 박힌 봇이 필요하다면
진입 신호뿐 아니라 사이징·손절·한도까지 사양으로 확정해 자동매매 프로그램으로 만들어 드립니다. 24시간 빠른 답변 가능합니다.
무료 상담 시작하기