안녕하세요! AI와 함께 코딩 한 줄 모르는 상태에서 비트코인 자동매매 봇을 구축해 나가는 My AI Stories 시리즈, 시즌 2의 4화입니다.
지난 3화에서는 고정 비율 손절선(Stop-loss)을 시뮬레이터에 연동해 보았습니다. 하지만 결과는 참혹했죠. 20일 이동평균선 기준 고정 손절선은 득보다 실이 컸고, 오히려 잦은 휩소(Whipsaw, 흔들기)에 당해 소중한 수익 기회만 날려버리는 함정에 빠졌습니다.
단순히 "내 진입가에서 -2% 떨어지면 무조건 손절한다"는 식의 고정 룰은, 그때그때 바뀌는 시장의 숨결(변동성)을 전혀 담아내지 못하기 때문입니다.
그래서 이번 4화에서는 이 문제를 해결하기 위해 두 가지 강력한 무기를 탑재해 보았습니다.
- 시장 변동성에 맞춰 스스로 손절선을 늘리고 줄이는 'ATR(Average True Range) 기반 동적 손절선'
- 거래량이 터진 진짜 돌파에만 올라타 가짜 추세를 걸러내는 '거래량(Volume) 필터'
백테스팅 엔진을 한 단계 업그레이드하고, 과거 3개년 비트코인 데이터(btc_4h_3years.csv)로 검증한 흥미진진한 실험 결과를 바로 공개합니다!
1. 두 가지 솔루션의 개념 이해하기 (비전공자 가이드)
💡 ATR(Average True Range) 동적 손절선이란?
ATR은 시장이 최근 얼마나 크게 출렁였는지 보여주는 **'변동성 지표'**입니다.
- 변동성이 큰 폭락장/폭등장에서는 캔들의 크기가 커지므로 ATR 값이 커집니다. 이때는 가격 흔들림에 휩쓸리지 않도록 손절선을 넓게 잡습니다.
- 반대로 횡보하거나 조용한 평화로운 장에서는 ATR 값이 작아집니다. 이때는 위험을 미연에 방지하도록 손절선을 좁게 잡습니다. 즉, 시장 환경에 맞춰 실시간으로 반응하는 똑똑한 손절선입니다.
💡 거래량(Volume) 필터란?
돌파 매매의 가장 큰 적은 바로 **'가짜 돌파(Fakeout)'**입니다. 가격이 이평선 위로 찔끔 올라갔다가 이내 꼬꾸라지는 현상이죠. 세력이나 시장의 진짜 매수세가 들어왔다면 보통 평소보다 거래량이 크게 동반됩니다. 거래량 필터는 "종가가 이동평균선 위에 있더라도, 최근 평균 거래량보다 최소 N배 이상 거래량이 터졌을 때만 신뢰하고 매수한다"는 철벽 수문장 역할을 합니다.

2. 파이썬 백테스팅 엔진 업그레이드 (소스코드)
기존 시뮬레이터에 ATR 계산 함수와 거래량 필터 진입 조건을 추가한 개선 코드입니다. 이번 코드에도 왕복 수수료 총 0.2%(진입 시 0.1%, 청산 시 0.1%)를 반영하여 철저히 실전형으로 설계했습니다.
import pandas as pd
def run_backtest_advanced(ma_window=120, use_atr_stop=False, atr_window=14, atr_multiplier=2.0, use_volume_filter=False, volume_multiplier=1.5):
# 1. 3개년 비트코인 데이터 로드
df = pd.read_csv('btc_4h_3years.csv')
df['ma'] = df['close'].rolling(window=ma_window).mean()
# 2. ATR(Average True Range) 계산
df['close_prev'] = df['close'].shift(1)
df['tr1'] = df['high'] - df['low']
df['tr2'] = (df['high'] - df['close_prev']).abs()
df['tr3'] = (df['low'] - df['close_prev']).abs()
df['tr'] = df[['tr1', 'tr2', 'tr3']].max(axis=1)
df['atr'] = df['tr'].rolling(window=atr_window).mean()
# 3. 거래량 필터용 20개 캔들 평균 거래량 계산
df['volume_ma'] = df['volume'].rolling(window=20).mean()
df = df.dropna().reset_index(drop=True)
# 가상 계좌 설정
init_balance = 10000.0
balance = init_balance
holdings = 0.0
entry_price = 0.0
entry_atr = 0.0
stoploss_triggered = False
portfolio_values = []
for i in range(len(df)):
current_price = df.loc[i, 'close']
current_ma = df.loc[i, 'ma']
current_volume = df.loc[i, 'volume']
current_vol_ma = df.loc[i, 'volume_ma']
current_atr = df.loc[i, 'atr']
total_asset = balance + (holdings * current_price)
portfolio_values.append(total_asset)
# 보유 중일 때 청산 조건 체크
if holdings > 0:
if use_atr_stop:
# ATR 기반 동적 손절가 계산: 진입가 - (진입 시점 ATR * 설정 승수)
dynamic_stop_price = entry_price - (entry_atr * atr_multiplier)
if current_price <= dynamic_stop_price:
balance = (holdings * current_price) * 0.999
holdings = 0.0
stoploss_triggered = True
# 일반 매도 조건 (이동평균선 붕괴)
if holdings > 0 and current_price < current_ma:
balance = (holdings * current_price) * 0.999
holdings = 0.0
# 손절 플래그 리셋 조건 (가격이 이평선 아래로 가야 다시 매수 허용)
if current_price < current_ma:
stoploss_triggered = False
# 매수 진입 조건 (돌파 발생 시)
if current_price > current_ma and holdings == 0 and not stoploss_triggered:
volume_condition = True
# 거래량 필터가 켜져 있으면, 최근 20캔들 평균 거래량 대비 돌파봉 거래량이 배수 이상인지 점검
if use_volume_filter:
volume_condition = current_volume > (current_vol_ma * volume_multiplier)
if volume_condition:
holdings = (balance * 0.999) / current_price
balance = 0.0
entry_price = current_price
entry_atr = current_atr # 진입 시점의 ATR 기억
final_asset = balance + (holdings * df.iloc[-1]['close'])
df['portfolio_value'] = portfolio_values
df['cum_max'] = df['portfolio_value'].cummax()
df['drawdown'] = (df['portfolio_value'] - df['cum_max']) / df['cum_max']
mdd = df['drawdown'].min() * 100
cumulative_return = ((final_asset - init_balance) / init_balance) * 100
return cumulative_return, mdd
3. 대망의 3개년 시뮬레이션 결과 성적표
이동평균선 주기 20일(4시간봉 120캔들)을 기준으로 기본 전략, ATR 동적 손절, 거래량 필터, 그리고 두 전략을 혼합한 복합 전략의 성적표입니다.
| 전략 구분 | 세부 조건 설정 | 누적 수익률 (%) | 최대 낙폭 (MDD, %) | 성과 평가 및 의의 |
|---|---|---|---|---|
| 기본 전략 | 손절 없음 / 필터 없음 | +114.97% | -32.96% | 기준 비교군 (20일선 단순 돌파) |
| ATR 동적 손절 | ATR 승수 1.5배 | +101.49% | -32.96% | 고정 손절선 대비 수익 손실 소폭 방어 |
| ATR 승수 2.0배 | +114.97% | -32.96% | 손절 범위가 넓어 기본 전략과 동일하게 작동 | |
| 거래량 필터 단독 | 거래량 1.2배 초과 진입 (최적) | +141.74% | -27.30% | 수익률 +26%p 상승, MDD 5.6%p 획기적 개선 |
| 거래량 1.5배 초과 진입 | +100.55% | -29.60% | 기준이 너무 엄격하여 좋은 진입 기회 상실 | |
| 거래량 1.8배 초과 진입 | +91.72% | -32.57% | 진입 횟수 과소로 인한 성과 저하 | |
| 복합 전략 | ATR 2.0 + 거래량 1.2배 (최종) | +149.54% | -27.26% | 역대 최고 성적. 수익률 극대화 및 변동성 제어 완료 |
| ATR 2.0 + 거래량 1.5배 | +40.57% | -28.83% | 필터 기준이 높아 진입 차단이 과도하게 발생 | |
| ATR 2.5 + 거래량 1.2배 | +148.30% | -27.26% | ATR 손절이 가끔 작동하여 미세한 차이 발생 | |
| ATR 2.5 + 거래량 1.5배 | +109.78% | -29.60% | 거래량 허들이 높아져 수익률 둔화 |
4. 데이터가 남긴 흥미로운 교훈
1) 거래량 필터(Volume Filter)의 압도적인 하드캐리
이번 실험에서 가장 놀라운 발견은 거래량 필터 단독 적용 시 성과가 대폭 향상되었다는 점입니다. 거래량이 직전 20개 평균 거래량의 1.2배 이상 터진 돌파봉에서만 진입하도록 차단막을 쳤을 뿐인데, 수익률은 +114.97%에서 +141.74%로 껑충 뛰었고, MDD는 -32.96%에서 -27.30%로 낮아졌습니다. 이유는 단순합니다. 주말 새벽 시간이나 횡보장 끝자락에서 거래량 없이 흘러내리다 발생한 '가짜 돌파'에 속아 잦은 매매 수수료만 내던 구간을 완벽하게 필터링했기 때문입니다.
2) ATR 동적 손절은 든든한 소방수
ATR 동적 손절 단독으로는 MDD에 엄청난 개선을 보여주지는 못했습니다. 이미 20일선 자체가 추세를 비교적 길게 타기 때문입니다. 하지만 거래량 필터(1.2배)와 ATR 동적 손절(2.0배)을 혼합 적용했을 때 누적 수익률 +149.54%, MDD -27.26%로 가장 이상적인 최적의 결과를 냈습니다. 가짜 돌파는 거래량 필터가 앞에서 1차로 막아주고, 진입한 후 예기치 못한 급락 블랙스완이 발생했을 때는 ATR 동적 손절이 2차 소방수 역할을 해주며 환상의 시너지를 낸 것입니다. (단, 복합 전략에서도 필터 허들을 높이거나 비율을 안 맞추면 오히려 성과가 크게 나빠진다는 사실도 표를 통해 확인할 수 있습니다.)
5. 에필로그 및 다음 화 예고
실험을 마치고 마침내 비전공자인 저도 마음 편히 봇을 돌릴 수 있는 수준인 고점 대비 최대 낙폭 -27%대 안전선을 확보하게 되었습니다. 시작 자금이 얼마였든 운용 중에 겪는 고점 대비 평가 자산의 최대 손실 범위를 크게 줄이고, 최종 자산은 우상향 곡선을 그릴 수 있는 탄탄한 전략입니다.
하지만 우리의 백테스팅은 아직 끝나지 않았습니다. 지금 전략은 비트코인이 상승할 때는 돈을 벌지만, 비트코인이 기나긴 하락장에 빠지면 손실만 안 보고 가만히 있을 뿐 자산을 늘리지 못합니다.
그래서 다음 5화에서는 비트코인이 하락할 때도 돈을 버는 양방향 거래 전략(Short 포지션 도입) 또는 상승 추세가 강할 때 불타기(피라미딩)를 통해 수익 극대화를 시도하는 실험에 도전해 보겠습니다!
여러분의 자동매매 시스템에는 어떤 안전 필터를 달아두셨나요? 거래량이나 ATR을 실전 매매에 어떻게 활용하고 계시는지 댓글로 지혜를 나누어 주세요!
(참고: 본 포스팅에 기재된 시뮬레이션 결과는 과거 데이터 기반의 백테스팅 결과로, 미래의 투자 수익을 보장하지 않습니다. 모든 투자 책임은 본인에게 있습니다.)