자동매매 봇 만들 때 흔히 하는 실수 7가지 — 초보가 꼭 피해야 할 것들
자동매매 봇에서 돈을 잃는 원인은 대개 전략이 나빠서가 아니라 구현이 틀려서입니다. 백테스트에서는 잘 되던 게 실전에서 무너지는 이유는 거의 정해져 있습니다. 자주 반복되는 일곱 가지를 정리합니다.
⚠️ 이 글은 교육·정보 제공 목적입니다. 투자 권유가 아니며, 투자 손실 책임은 본인에게 있습니다.
1. 미래 데이터를 몰래 쓰는 것 (Look-ahead Bias)
가장 흔하고, 가장 치명적입니다. 오늘 종가로 계산한 신호로 오늘 종가에 체결하는 백테스트는 현실에 존재하지 않습니다. 종가는 장이 끝나야 확정되니까요.
# 잘못 — 오늘 종가를 알아야 나오는 신호로 오늘 매수
signal = df["close"] > df["ma20"]
df["position"] = signal.astype(int)
# 올바름 — 신호는 어제 것, 체결은 오늘
df["position"] = signal.shift(1).fillna(0).astype(int)
이 한 줄 차이로 백테스트 수익률이 몇 배씩 바뀝니다. 수익률이 비현실적으로 좋다면 십중팔구 여기부터 의심하세요.
2. 수수료와 슬리피지를 빼먹는 것
하루에 몇 번씩 매매하는 전략은 수수료만으로 수익이 사라집니다. 슬리피지(주문 낸 가격과 실제 체결 가격의 차이)까지 더하면 더 그렇습니다.
FEE = 0.0005 # 편도 0.05% (거래소·증권사마다 다름)
SLIPPAGE = 0.0005 # 체결 미끄러짐 가정
trades = df["position"].diff().abs() # 포지션이 바뀐 날 = 거래 발생
cost = trades * (FEE + SLIPPAGE)
df["net_ret"] = df["ret"] * df["position"] - cost
print(f"비용 반영 누적: {(1 + df['net_ret']).prod() - 1:.2%}")
비용을 넣었을 때 수익이 남지 않는 전략은, 실전에서도 남지 않습니다. 이 검증을 나중으로 미루지 마세요.
3. 파라미터를 데이터에 억지로 맞추는 것 (과최적화)
이동평균 기간을 5부터 100까지 다 돌려보고 제일 좋은 값을 고르면, 그건 전략을 찾은 게 아니라 과거 데이터의 우연을 외운 것입니다.
# 위험 신호: 인접한 값끼리 결과가 널뛴다
# ma=19 → +12% ma=20 → +87% ma=21 → +8%
# → 20이 특별한 게 아니라, 그냥 운 좋은 구간에 맞은 것
건강한 파라미터는 주변 값에서도 완만하게 좋습니다. 하나만 뾰족하게 튀면 버리세요. 그리고 기간을 나눠 검증하세요 — 앞 70%로 정하고, 뒤 30%는 손대지 않은 채 확인용으로 남기는 방식입니다.
4. 예외 처리 없이 API를 호출하는 것
네트워크는 반드시 끊깁니다. 거래소는 반드시 점검을 합니다. 예외 처리가 없으면 봇은 포지션을 든 채로 죽습니다.
import time, requests
def safe_get(url, params=None, retries=3):
for i in range(retries):
try:
res = requests.get(url, params=params, timeout=10)
if res.status_code == 429: # 호출 제한
time.sleep(2 ** i) # 점점 길게 대기
continue
res.raise_for_status()
return res.json()
except requests.RequestException as e:
if i == retries - 1:
notify(f"API 실패, 봇 정지: {e}") # 사람에게 알림
raise
time.sleep(2 ** i)
핵심은 재시도가 아니라 "실패했을 때 사람이 안다"입니다. 조용히 죽은 봇이 제일 위험합니다.
5. 주문 중복 — 같은 신호로 두 번 사는 것
주문을 냈는데 응답이 늦으면, 코드는 실패로 판단하고 다시 냅니다. 그렇게 2배 물량이 들어갑니다.
# 주문 전에 현재 포지션을 먼저 확인
pos = get_position(symbol)
if pos["qty"] > 0:
log.info("이미 보유 중 — 신규 진입 건너뜀")
return
# 클라이언트 주문번호로 중복 방지 (거래소가 지원하는 경우)
client_id = f"{symbol}-{signal_date}-entry" # 같은 신호면 같은 ID
place_order(symbol, qty, client_order_id=client_id)
원칙은 하나입니다. 주문 직전에 실제 잔고를 확인한다. 내부 변수만 믿으면 재시작·오류 때 어긋납니다.
6. 손절 규칙이 없는 것
진입 조건은 열심히 만들면서 청산 조건은 "오르면 판다"로 끝내는 경우가 많습니다. 손실은 규칙이 없으면 무한정 커집니다.
MAX_LOSS_PCT = 0.02 # 1회 거래 최대 손실 2%
def position_size(capital, entry, stop):
risk_per_share = entry - stop
if risk_per_share <= 0:
raise ValueError("손절가가 진입가보다 높습니다")
qty = (capital * MAX_LOSS_PCT) / risk_per_share
return int(qty)
# 손절 폭이 넓으면 수량이 자동으로 줄어듭니다
qty = position_size(10_000_000, entry=71_000, stop=68_000)
"얼마 살까"가 아니라 "틀렸을 때 얼마 잃을까"부터 정하는 것 — 이 순서가 자금 관리의 전부입니다.
7. 실계좌부터 시작하는 것
가장 비싼 실수입니다. 코드가 논리적으로 맞아 보여도 실행 환경에서는 늘 예상 밖의 일이 생깁니다.
권장 순서는 이렇습니다.
- 백테스트 — 과거 데이터에서 비용 포함해 검증
- 페이퍼 트레이딩 — 실시간 시세로 돌리되 주문은 로그만. 최소 2~4주
- 모의투자 계좌 — 실제 주문 흐름 확인
- 소액 실전 — 잃어도 되는 금액으로 시작
2번에서 걸러지는 버그가 정말 많습니다. 데이터 지연, 휴장일 처리, 자정 넘김, 토큰 만료 — 전부 실시간으로 돌려봐야 드러납니다.
정리
- 미래 참조 — 신호는
shift(1)로 미뤄 적용 - 비용 — 수수료 + 슬리피지 반영 후에도 남는지 확인
- 과최적화 — 주변 값에서도 좋아야 진짜
- 예외 처리 — 실패 시 멈추고 알리기
- 중복 주문 — 주문 전 실제 잔고 확인
- 손절 — 손실 한도부터 정하고 수량 계산
- 실계좌 직행 금지 — 페이퍼 트레이딩을 반드시 거칠 것
봇 개발의 목표는 화려한 전략이 아니라 내일도 같은 규칙대로 도는 시스템입니다. 위 일곱 개만 피해도 대부분의 사고는 일어나지 않습니다.
댓글
댓글 쓰기