Development/Indicator Lab

볼린저 밴드 (Bollinger Bands)

MildChoco 2026. 3. 28. 11:21

 

이번 글부터 새로운 카테고리인 변동성/밴드 지표를 다룹니다. 이전에 돈치안 채널(최고가/최저가 기반 밴드)을 다뤘는데, 이번 섹션에서는 통계적 변동성과 ATR을 활용한 밴드 지표들을 소개합니다.

첫 번째는 가장 대중적인 밴드 지표인 볼린저 밴드입니다. John Bollinger가 1980년대에 개발했습니다.


지표 소개

볼린저 밴드는 이동평균을 중심으로 표준편차만큼 위아래로 밴드를 그립니다.

중심선 = SMA(N)
상단밴드 = SMA(N) + K × 표준편차(N)
하단밴드 = SMA(N) - K × 표준편차(N)

 

일반적으로 N=20, K=2를 사용합니다. 종가 20일 이동평균을 중심으로, 20일 표준편차의 2배만큼 위아래로 밴드를 설정합니다.

돈치안 채널과의 차이

8편에서 다룬 돈치안 채널은 N일 최고가/최저가로 밴드를 만듭니다. 밴드 폭이 오직 극단값에 의해 결정되므로, 하나의 큰 캔들이 밴드를 넓혀놓으면 N일 동안 유지됩니다.

볼린저 밴드는 표준편차를 사용하므로 변동성에 동적으로 반응합니다. 변동성이 커지면 밴드가 넓어지고, 변동성이 줄어들면 밴드가 좁아집니다. 이 "수축(squeeze)과 확장"이 볼린저 밴드의 핵심 특성입니다.

 

 

상단밴드(빨간 점선), 중심선(주황), 하단밴드(초록 점선)가 가격을 감싸고 있습니다. 상승장에서 가격이 상단밴드를 따라 올라가는 "밴드 워킹(band walking)" 현상이 보입니다.

 

 

하단의 Band Width 차트는 밴드 폭을 백분율로 보여줍니다. 밴드가 좁아지는 구간(squeeze) 이후 급격한 움직임이 나오는 패턴을 관찰할 수 있습니다.


해석

볼린저 밴드에서 읽을 수 있는 정보는 세 가지입니다.

밴드 터치: 가격이 하단밴드에 닿으면 과매도, 상단밴드에 닿으면 과매수로 해석합니다. 다만 강한 추세에서는 밴드를 따라 가격이 계속 이동(밴드 워킹)하므로 단순 역추세 신호로 쓰기엔 위험합니다.

밴드 폭 (변동성): 밴드가 좁아지면 변동성이 낮다는 뜻이고, 곧 큰 움직임이 올 수 있다는 신호입니다. 다만 방향은 알 수 없습니다.

밴드 이탈: 종가가 밴드 바깥으로 나가면 강한 모멘텀을 의미합니다. 상단 이탈은 강한 상승, 하단 이탈은 강한 하락입니다.


가설 설정

가장 기본적인 역추세 전략을 테스트합니다.

  • 진입 — 종가가 하단밴드를 아래에서 위로 크로스 (하단밴드 탈출)
  • 청산 — 종가가 중심선(SMA) / 상단밴드 위로 크로스 (비교)

"하단밴드까지 떨어졌다가 반등하면 사고, 중심선이나 상단밴드에 도달하면 판다."


백테스트 조건

항목 설정
종목 BTC/USDT (Binance Spot)
기간 2018.01.01 ~ 2025.12.31
타임프레임 일봉 (1D)
BB 기간 / 배수 20일 / 2.0
진입 종가가 하단밴드 상향 크로스
청산 중심선(SMA) / 상단밴드 크로스 (비교)
초기 자본 $10,000
수수료 / 슬리피지 미적용

코드 구현

import pandas as pd
import numpy as np
import mplfinance as mpf
import matplotlib.pyplot as plt

# ===== 설정 =====
DATA_FILE = "BTCUSDT_1d.csv"
BB_PERIOD = 20          # 볼린저 밴드 SMA 기간
BB_STD = 2.0            # 표준편차 배수
EXIT_MODES = ["mid", "upper"]  # 청산 기준: 중심선 vs 상단밴드
INITIAL_CAPITAL = 10000
ZOOM_DAYS = 180
# ================

# 데이터 로드
df = pd.read_csv(DATA_FILE, index_col="timestamp", parse_dates=True)

# 볼린저 밴드 계산
df["bb_mid"] = df["close"].rolling(window=BB_PERIOD).mean()
df["bb_std"] = df["close"].rolling(window=BB_PERIOD).std()
df["bb_upper"] = df["bb_mid"] + BB_STD * df["bb_std"]
df["bb_lower"] = df["bb_mid"] - BB_STD * df["bb_std"]

# 밴드 폭 (변동성 시각화용)
df["bb_width"] = (df["bb_upper"] - df["bb_lower"]) / df["bb_mid"] * 100

# 백테스트 함수: 하단밴드 터치 진입, 중심선/상단밴드 청산
def run_bb_backtest(df, exit_mode, initial_capital):
    capital = initial_capital
    holdings = 0
    buy_price = 0
    in_position = False
    trades = []
    equity_curve = []

    for i in range(BB_PERIOD + 1, len(df)):
        close = df["close"].iloc[i]
        close_prev = df["close"].iloc[i - 1]
        lower = df["bb_lower"].iloc[i]
        lower_prev = df["bb_lower"].iloc[i - 1]
        mid = df["bb_mid"].iloc[i]
        mid_prev = df["bb_mid"].iloc[i - 1]
        upper = df["bb_upper"].iloc[i]
        upper_prev = df["bb_upper"].iloc[i - 1]

        if pd.isna(lower) or pd.isna(mid):
            equity_curve.append(capital + holdings * close)
            continue

        if not in_position:
            # 종가가 하단밴드 아래에서 위로 크로스 → 매수
            if close > lower and close_prev <= lower_prev:
                buy_price = close
                holdings = capital / buy_price
                capital = 0
                in_position = True
        else:
            if exit_mode == "mid":
                # 종가가 중심선 위로 크로스 → 매도
                if close > mid and close_prev <= mid_prev:
                    sell_price = close
                    capital = holdings * sell_price
                    pnl = (sell_price - buy_price) / buy_price * 100
                    trades.append({"pnl_pct": round(pnl, 2)})
                    holdings = 0
                    in_position = False
            elif exit_mode == "upper":
                # 종가가 상단밴드 위로 크로스 → 매도
                if close > upper and close_prev <= upper_prev:
                    sell_price = close
                    capital = holdings * sell_price
                    pnl = (sell_price - buy_price) / buy_price * 100
                    trades.append({"pnl_pct": round(pnl, 2)})
                    holdings = 0
                    in_position = False

        equity_curve.append(capital + holdings * close)

    if in_position:
        last_price = df["close"].iloc[-1]
        capital = holdings * last_price
        pnl = (last_price - buy_price) / buy_price * 100
        trades.append({"pnl_pct": round(pnl, 2)})
        holdings = 0

    eq = pd.Series(equity_curve)
    peak = eq.cummax()
    mdd = ((eq - peak) / peak * 100).min()

    return capital, trades, mdd

# 바이앤홀드
first_price = df["close"].dropna().iloc[0]
last_price = df["close"].dropna().iloc[-1]
bnh_return = (last_price - first_price) / first_price * 100
bnh_capital = INITIAL_CAPITAL * (1 + bnh_return / 100)

# 실행
print("=" * 65)
print(f"Bollinger Bands ({BB_PERIOD}, {BB_STD}) 백테스트 — 청산 기준 비교")
print(f"진입: 종가가 하단밴드 상향 크로스")
print(f"Data: {df.index[0].strftime('%Y-%m-%d')} ~ {df.index[-1].strftime('%Y-%m-%d')}")
print("=" * 65)
print(f"  {'Exit':<12} {'Capital':>10} {'Return':>10} {'MDD':>8} {'Trades':>7} {'Win':>5} {'Lose':>5} {'WinRate':>8}")
print("-" * 65)

for exit_mode in EXIT_MODES:
    capital, trades, mdd = run_bb_backtest(df, exit_mode, INITIAL_CAPITAL)
    total_return = (capital - INITIAL_CAPITAL) / INITIAL_CAPITAL * 100
    wins = len([t for t in trades if t["pnl_pct"] > 0])
    losses = len([t for t in trades if t["pnl_pct"] <= 0])
    win_rate = wins / len(trades) * 100 if trades else 0

    label = "Middle" if exit_mode == "mid" else "Upper"
    print(f"  {label:<12} ${capital:>9,.0f} {total_return:>+9.2f}% {mdd:>+7.2f}% {len(trades):>6}  {wins:>5} {losses:>5} {win_rate:>7.1f}%")

print("-" * 65)
print(f"  {'B&H':<12} ${bnh_capital:>9,.0f} {bnh_return:>+9.2f}%")
print("=" * 65)

# 차트 1 — 상승장 캔들 + 볼린저 밴드
df_bull = df.loc["2020-10-01":"2021-04-30"].copy()

ap_bull = [
    mpf.make_addplot(df_bull["bb_upper"], color="#E53935", width=0.8, linestyle="--"),
    mpf.make_addplot(df_bull["bb_mid"], color="#FF9800", width=1.0),
    mpf.make_addplot(df_bull["bb_lower"], color="#43A047", width=0.8, linestyle="--"),
]

fig1, axes1 = mpf.plot(df_bull, type="candle", style="charles",
    title=f"BTC/USDT  Bollinger Bands ({BB_PERIOD}, {BB_STD}) (2020.10 ~ 2021.04, Bull)",
    volume=False, figsize=(14, 8),
    addplot=ap_bull, returnfig=True,
    fill_between=dict(y1=df_bull["bb_upper"].values, y2=df_bull["bb_lower"].values, alpha=0.1, color="gray"))

# 차트 2 — 최근 구간 + 밴드 폭
df_zoom = df.tail(ZOOM_DAYS).copy()

ap_zoom = [
    mpf.make_addplot(df_zoom["bb_upper"], color="#E53935", width=0.8, linestyle="--"),
    mpf.make_addplot(df_zoom["bb_mid"], color="#FF9800", width=1.0),
    mpf.make_addplot(df_zoom["bb_lower"], color="#43A047", width=0.8, linestyle="--"),
]

fig2, axes2 = mpf.plot(df_zoom, type="candle", style="charles",
    title=f"BTC/USDT  Bollinger Bands ({BB_PERIOD}, {BB_STD}) (Last {ZOOM_DAYS}D)",
    volume=False, figsize=(14, 8),
    addplot=ap_zoom, returnfig=True,
    fill_between=dict(y1=df_zoom["bb_upper"].values, y2=df_zoom["bb_lower"].values, alpha=0.1, color="gray"))

# 밴드 폭 서브플롯
ax_bw = fig2.add_axes([0.12, 0.08, 0.76, 0.20])
bw_data = df_zoom["bb_width"].dropna()
ax_bw.plot(range(len(bw_data)), bw_data.values, color="#1E88E5", linewidth=1.2)
ax_bw.set_ylabel("Band Width (%)")
ax_bw.set_xlim(0, len(bw_data))
ax_bw.grid(True, alpha=0.3)
axes2[0].set_position([0.12, 0.33, 0.76, 0.60])

plt.show()

 


사용법

BB_PERIODBB_STD로 밴드 설정을, EXIT_MODES로 청산 기준을 변경할 수 있습니다.


결과 분석

 

=================================================================
Bollinger Bands (20, 2.0) 백테스트 — 청산 기준 비교
진입: 종가가 하단밴드 상향 크로스
Data: 2018-01-01 ~ 2025-12-31
=================================================================
  Exit            Capital     Return      MDD  Trades   Win  Lose  WinRate
-----------------------------------------------------------------
  Middle       $   18,400    +84.00%  -61.56%     46     31    15    67.4%
  Upper        $    7,196    -28.04%  -76.73%     29     18    11    62.1%
-----------------------------------------------------------------
  B&H          $   65,507   +555.07%
=================================================================
청산 수익률 MDD 거래 승률
Middle (SMA) +84.00% -61.56% 46 67.4%
Upper (상단밴드) -28.04% -76.73% 29 62.1%

 

중심선 청산이 +84%로 양수지만, B&H(+555%)에 비하면 크게 미치지 못합니다. 상단밴드 청산은 -28%로 손실입니다.

이 결과는 예상 범위 안입니다. 볼린저 밴드 하단 터치 진입은 역추세 전략이고, 역추세 전략이 크립토 같은 추세 시장에서 어려운 것은 이전 글들에서 반복적으로 확인했습니다.

 

중심선 vs 상단밴드 청산의 차이: 상단밴드 청산은 "하단에서 사서 상단까지 기다린다"는 논리인데, 문제는 가격이 상단밴드까지 도달하지 않고 다시 떨어지는 경우가 많다는 것입니다. 거래 횟수가 46회에서 29회로 줄었다는 것은 청산 신호 없이 포지션을 오래 들고 있는 경우가 많았다는 뜻이고, 그 동안 수익이 되돌려진 것입니다.

 

MDD는 두 모드 모두 높습니다(-61%, -76%). 하락 추세에서 하단밴드를 터치하고 들어갔는데 반등 없이 계속 떨어지는 상황이 반복된 결과입니다. 이것이 볼린저 밴드 단독 역추세 전략의 한계입니다.

 

차트에서도 이 특성이 보입니다. 상승장(2020.10~2021.04)에서는 가격이 상단밴드를 따라 올라가는 "밴드 워킹"이 보이고, 최근 구간의 Band Width 차트에서는 밴드가 좁아졌다 넓어지는 수축-확장 사이클을 확인할 수 있습니다.


정리

볼린저 밴드는 SMA를 중심으로 표준편차로 밴드를 그리는 지표입니다. 변동성에 동적으로 반응하는 것이 특징이며, 수축(squeeze)과 확장이 핵심 신호입니다.

 

단독 역추세 전략(하단밴드 진입)은 BTC 같은 추세 시장에서는 한계가 명확합니다. 볼린저 밴드의 진정한 강점은 단독 매매 신호보다는 변동성 상태 판단(스퀴즈, 밴드 폭, 상대적 위치)에 있으며, 다른 지표와 조합할 때 효과적입니다.

 

다음 글에서는 볼린저 밴드와 구조는 비슷하지만 표준편차 대신 ATR을 사용하는 켈트너 채널을 다룹니다. 같은 밴드인데 변동성 측정 방식이 다르면 어떤 차이가 나는지 비교합니다.


본 포스팅은 지표의 원리와 백테스트 과정을 정리한 기술 블로그이며, 특정 자산의 매매를 권유하지 않습니다.
백테스트 결과는 과거 데이터 기반이며 실제 수익을 보장하지 않습니다. 모든 투자의 책임은 본인에게 있습니다.

'Development > Indicator Lab' 카테고리의 다른 글

슈퍼트렌드 (SuperTrend)  (0) 2026.03.30
켈트너 채널 (Keltner Channel)  (0) 2026.03.29
멀티타임프레임 전략  (0) 2026.03.26
Williams %R  (0) 2026.03.19
CCI (Commodity Channel Index)  (0) 2026.03.18