Development/Indicator Lab

TTM Squeeze

MildChoco 2026. 4. 1. 11:21

 

볼린저 밴드와 켈트너 채널 포스트에서 각각 역추세 전략을 테스트했을 때, 두 지표 모두 BTC 같은 추세 시장에서 한계가 명확했습니다(+84%, +83%). 이번 글에서는 이 두 지표를 조합해서 만든 전략인 TTM Squeeze를 다룹니다. John Carter가 개발한 이 전략은 단독으로는 부진했던 볼린저 밴드와 켈트너 채널이 합쳐지면 어떤 결과를 내는지 보여줍니다.


TTM Squeeze란?

TTM Squeeze는 시장이 변동성 수축(스퀴즈) 상태에서 확장 상태로 전환되는 순간을 포착하는 전략입니다. 두 가지 구성 요소가 있습니다.

 

1. 스퀴즈 감지 (볼린저 밴드 + 켈트너 채널)

볼린저 밴드와 켈트너 채널 포스트에서 설명했듯이, 볼린저 밴드는 표준편차로, 켈트너 채널은 ATR로 밴드 폭을 결정합니다. 볼린저 밴드는 변동성 변화에 민감하게 반응하고, 켈트너 채널은 안정적으로 반응합니다.

이 차이를 이용합니다. 변동성이 극단적으로 낮아지면, 민감한 볼린저 밴드가 급격히 수축해서 켈트너 채널 안쪽으로 들어갑니다. 이 상태가 "스퀴즈"입니다.

스퀴즈 ON  = 볼린저 하단 > 켈트너 하단  AND  볼린저 상단 < 켈트너 상단
스퀴즈 OFF = 위 조건이 깨짐 (볼린저가 켈트너 바깥으로 나감)

스퀴즈가 풀리면("fired") 변동성이 확장하면서 브레이크아웃이 발생할 가능성이 높습니다. 다만 스퀴즈 해소만으로는 방향을 알 수 없습니다.

 

2. 모멘텀 히스토그램 (방향 판단)

방향은 모멘텀 히스토그램으로 판단합니다. Carter의 원래 구현에서는 다음과 같이 계산합니다.

돈치안 미드라인 = (N일 최고가 + N일 최저가) / 2
델타 = 종가 - (돈치안 미드라인 + SMA(N)) / 2
모멘텀 = 델타의 선형회귀 평활값

모멘텀이 양수면 상승 방향, 음수면 하락 방향입니다.

빨간 점선이 볼린저 밴드, 파란 점선이 켈트너 채널입니다. 볼린저 밴드가 켈트너 채널 안쪽으로 좁아지는 구간이 스퀴즈 상태이고, 다시 바깥으로 나가면 스퀴즈가 해소됩니다.


전략 설계

진입: 스퀴즈 해소 + 모멘텀 양수. 볼린저 밴드가 켈트너 채널 안에 있다가 바깥으로 나가는 순간, 모멘텀이 0 위에 있으면 매수합니다.

청산: 세 가지 방식을 비교합니다.

  • 스퀴즈 재진입 (Squeeze ON) — 다시 스퀴즈 상태에 들어가면 청산. 변동성이 다시 줄어들었다는 뜻이므로 추세가 끝났다고 판단합니다.
  • 모멘텀 전환 (Momentum Flip) — 모멘텀 히스토그램이 양수에서 음수로 전환되면 청산. 상승 모멘텀이 소멸했다고 판단합니다.
  • 스퀴즈 재진입 또는 모멘텀 전환 — 위 두 조건 중 먼저 걸리는 것으로 청산. 두 개의 안전망을 동시에 운용합니다.

백테스트 조건

항목 설정
종목 BTC/USDT (Binance Spot)
기간 2018.01.01 ~ 2025.12.31
타임프레임 일봉 (1D)
볼린저 밴드 SMA 20, 2.0σ
켈트너 채널 EMA 20, ATR 14, ×2.0
모멘텀 기간 20, 선형회귀 20
진입 스퀴즈 해소 + 모멘텀 > 0
청산 스퀴즈 재진입 / 모멘텀 전환 / 둘 중 먼저 (비교)
초기 자본 $10,000
수수료 / 슬리피지 미적용

켈트너 채널 배수는 Carter 기본값(×1.5)이 아닌 ×2.0을 사용합니다. 테스트 결과 ×2.0이 BTC에서 더 좋은 성과를 보였습니다. ×1.5는 스퀴즈 조건이 까다로워서 진입 기회가 적고, ×2.0은 더 느슨한 조건으로 더 많은 기회를 포착합니다.


코드 구현

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                # 표준편차 배수

# 켈트너 채널 (스퀴즈 감지용)
KC_PERIOD = 20              # EMA 기간
KC_ATR_PERIOD = 14          # ATR 기간
KC_MULT = 2.0               # ATR 배수. Carter 기본값 1.5, 여기선 2.0이 최적
                            # 작을수록 스퀴즈 조건이 까다로워짐 (더 적은 진입)
                            # 클수록 스퀴즈 조건이 느슨해짐 (더 많은 진입)

# 모멘텀 히스토그램
MOM_PERIOD = 20             # 돈치안 미드라인 + SMA 기간
LR_PERIOD = 20              # 선형회귀 평활화 기간

INITIAL_CAPITAL = 10000     # 초기 자본
ZOOM_DAYS = 180             # 차트 확대 구간 (최근 N일)
# ============================

# 데이터 로드
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"]

# --- ATR ---
high_low = df["high"] - df["low"]
high_close = (df["high"] - df["close"].shift(1)).abs()
low_close = (df["low"] - df["close"].shift(1)).abs()
tr = pd.concat([high_low, high_close, low_close], axis=1).max(axis=1)
df["atr"] = tr.rolling(window=KC_ATR_PERIOD).mean()

# --- 켈트너 채널 ---
df["kc_mid"] = df["close"].ewm(span=KC_PERIOD, adjust=False).mean()
df["kc_upper"] = df["kc_mid"] + KC_MULT * df["atr"]
df["kc_lower"] = df["kc_mid"] - KC_MULT * df["atr"]

# --- 스퀴즈 상태 ---
df["squeeze"] = (df["bb_lower"] > df["kc_lower"]) & (df["bb_upper"] < df["kc_upper"])

# --- 모멘텀 히스토그램 (Carter 방식) ---
dc_high = df["high"].rolling(window=MOM_PERIOD).max()
dc_low = df["low"].rolling(window=MOM_PERIOD).min()
dc_mid = (dc_high + dc_low) / 2
sma = df["close"].rolling(window=MOM_PERIOD).mean()
delta = df["close"] - (dc_mid + sma) / 2

def linear_regression_value(series, period):
    result = pd.Series(index=series.index, dtype=float)
    for i in range(period - 1, len(series)):
        y = series.iloc[i - period + 1:i + 1].values
        if np.any(np.isnan(y)):
            continue
        x = np.arange(period)
        slope = (period * np.sum(x * y) - np.sum(x) * np.sum(y)) / (period * np.sum(x * x) - np.sum(x) ** 2)
        intercept = (np.sum(y) - slope * np.sum(x)) / period
        result.iloc[i] = intercept + slope * (period - 1)
    return result

df["momentum"] = linear_regression_value(delta, LR_PERIOD)

# --- 백테스트 ---
def run_ttm_squeeze(df, exit_mode):
    """
    진입: 스퀴즈 해소 + 모멘텀 양수
    청산:
      - "squeeze_on": 다시 스퀴즈 상태 진입 시
      - "momentum_flip": 모멘텀이 양수 → 음수 전환 시
      - "squeeze_or_momentum": 위 두 조건 중 먼저 걸리는 것
    """
    capital = INITIAL_CAPITAL
    holdings = 0
    buy_price = 0
    in_position = False
    trades = []
    equity = []

    for i in range(1, len(df)):
        close = df["close"].iloc[i]
        sq = df["squeeze"].iloc[i]
        sq_prev = df["squeeze"].iloc[i - 1]
        mom = df["momentum"].iloc[i]
        mom_prev = df["momentum"].iloc[i - 1]

        if pd.isna(sq) or pd.isna(mom) or pd.isna(mom_prev):
            equity.append(capital + holdings * close)
            continue

        squeeze_fired = sq_prev and not sq  # 스퀴즈 해소

        if not in_position:
            if squeeze_fired and mom > 0:
                buy_price = close
                holdings = capital / buy_price
                capital = 0
                in_position = True
        else:
            do_exit = False
            if exit_mode == "squeeze_on":
                do_exit = sq
            elif exit_mode == "momentum_flip":
                do_exit = mom < 0 and mom_prev >= 0
            elif exit_mode == "squeeze_or_momentum":
                do_exit = sq or (mom < 0 and mom_prev >= 0)

            if do_exit:
                capital = holdings * close
                pnl = (close - buy_price) / buy_price * 100
                trades.append({"pnl_pct": round(pnl, 2)})
                holdings = 0
                in_position = False

        equity.append(capital + holdings * close)

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

    eq = pd.Series(equity)
    mdd = ((eq - eq.cummax()) / eq.cummax() * 100).min() if len(eq) > 0 else 0
    return capital, trades, mdd, equity

# --- 바이앤홀드 ---
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("=" * 95)
print(f"TTM Squeeze (BB {BB_PERIOD}/{BB_STD}, KC {KC_PERIOD}/ATR {KC_ATR_PERIOD}/x{KC_MULT})")
print(f"Entry: Squeeze fired + Momentum > 0")
print(f"Data: {df.index[0].strftime('%Y-%m-%d')} ~ {df.index[-1].strftime('%Y-%m-%d')}")
print("=" * 95)
print(f"  {'Exit':<35} {'Capital':>10} {'Return':>10} {'MDD':>8} {'Trades':>7} {'Win':>5} {'Lose':>5} {'WinRate':>8} {'AvgW':>8} {'AvgL':>8}")
print("-" * 95)

exit_modes = {
    "squeeze_on":          "Squeeze ON",
    "momentum_flip":       "Momentum Flip",
    "squeeze_or_momentum": "Squeeze ON or Momentum Flip",
}

results = {}
for mode, label in exit_modes.items():
    cap, trades, mdd, eq = run_ttm_squeeze(df, mode)
    ret = (cap - INITIAL_CAPITAL) / INITIAL_CAPITAL * 100
    w = len([t for t in trades if t["pnl_pct"] > 0])
    l = len([t for t in trades if t["pnl_pct"] <= 0])
    wr = w / len(trades) * 100 if trades else 0
    avgw = np.mean([t["pnl_pct"] for t in trades if t["pnl_pct"] > 0]) if w > 0 else 0
    avgl = np.mean([t["pnl_pct"] for t in trades if t["pnl_pct"] <= 0]) if l > 0 else 0
    print(f"  {label:<35} ${cap:>9,.0f} {ret:>+9.2f}% {mdd:>+7.2f}% {len(trades):>6}  {w:>5} {l:>5} {wr:>7.1f}% {avgw:>+7.1f}% {avgl:>+7.1f}%")
    results[mode] = eq

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

# --- 에쿼티 커브 ---
fig, ax = plt.subplots(figsize=(14, 6))
for mode, label in exit_modes.items():
    ax.plot(results[mode], label=label, alpha=0.8)
ax.set_title(f"Equity Curve - TTM Squeeze Exit Strategies (KC x{KC_MULT})")
ax.set_ylabel("Equity ($)")
ax.legend()
ax.grid(True, alpha=0.3)
plt.tight_layout()

# --- 차트 1: 캔들 + BB + KC (상승장) ---
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_lower"], color="#E53935", width=0.8, linestyle="--"),
    mpf.make_addplot(df_bull["kc_upper"], color="#1E88E5", width=0.8, linestyle="--"),
    mpf.make_addplot(df_bull["kc_lower"], color="#1E88E5", width=0.8, linestyle="--"),
]

fig2, axes2 = mpf.plot(df_bull, type="candle", style="charles",
    title=f"BTC/USDT  TTM Squeeze (BB red, KC blue) (2020.10 ~ 2021.04)",
    volume=False, figsize=(14, 8),
    addplot=ap_bull, returnfig=True)

# --- 차트 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_lower"], color="#E53935", width=0.8, linestyle="--"),
    mpf.make_addplot(df_zoom["kc_upper"], color="#1E88E5", width=0.8, linestyle="--"),
    mpf.make_addplot(df_zoom["kc_lower"], color="#1E88E5", width=0.8, linestyle="--"),
]

fig3, axes3 = mpf.plot(df_zoom, type="candle", style="charles",
    title=f"BTC/USDT  TTM Squeeze (BB red, KC blue) (Last {ZOOM_DAYS}D)",
    volume=False, figsize=(14, 8),
    addplot=ap_zoom, returnfig=True)

plt.show()

 


변경 포인트

BB_PERIOD / BB_STD (기본 20 / 2.0) — 볼린저 밴드 설정. 표준 설정이며 일반적으로 변경할 필요는 없습니다.

 

KC_PERIOD / KC_ATR_PERIOD / KC_MULT (기본 20 / 14 / 2.0) — 켈트너 채널 설정. 가장 중요한 변수는 KC_MULT입니다. Carter 기본값은 1.5이지만, BTC에서는 2.0이 더 좋은 결과를 보였습니다. 값을 줄이면 스퀴즈 조건이 까다로워져서 진입 기회가 줄어들고, 값을 키우면 느슨해져서 진입 기회가 늘어납니다. 1.0~2.5 범위에서 실험해볼 수 있습니다.

 

MOM_PERIOD / LR_PERIOD (기본 20 / 20) — 모멘텀 히스토그램 설정. 기간을 늘리면 모멘텀이 더 느리게 반응하고, 줄이면 더 빠르게 반응합니다. Carter 기본값은 둘 다 20입니다.


결과 분석

 

===============================================================================================
TTM Squeeze (BB 20/2.0, KC 20/ATR 14/x2.0)
Entry: Squeeze fired + Momentum > 0
Data: 2018-01-01 ~ 2025-12-31
===============================================================================================
  Exit                                   Capital     Return      MDD  Trades   Win  Lose  WinRate     AvgW     AvgL
-----------------------------------------------------------------------------------------------
  Squeeze ON                          $  228,876  +2188.76%  -39.21%     46     23    23    50.0%   +23.7%    -5.7%
  Momentum Flip                       $  115,012  +1050.12%  -42.22%     37     19    18    51.4%   +27.2%    -7.6%
  Squeeze ON or Momentum Flip         $  198,675  +1886.75%  -32.19%     46     24    22    52.2%   +20.4%    -5.2%
-----------------------------------------------------------------------------------------------
  B&H                                 $   65,507   +555.07%
===============================================================================================
청산 수익률 MDD 거래 승률 평균 수익 평균 손실
스퀴즈 재진입 +2188% -39.21% 46 50.0% +23.7% -5.7%
모멘텀 전환 +1050% -42.22% 37 51.4% +27.2% -7.6%
스퀴즈 재진입 또는 모멘텀 전환 +1886% -32.19% 46 52.2% +20.4% -5.2%

 

세 가지 청산 방식 모두 B&H(+555%)를 크게 이겼습니다. 슈퍼트렌드 + ATR 트레일링 전략(+2225%, MDD -44%)과 비교해도 경쟁력 있는 수치입니다.

 

스퀴즈 재진입 청산 (+2188%): 수익률이 가장 높습니다. 변동성이 다시 수축될 때까지 포지션을 유지하므로 큰 추세를 오래 탑니다. 다만 MDD가 -39%로 상대적으로 높습니다.

 

모멘텀 전환 청산 (+1050%): 수익률이 가장 낮습니다. 모멘텀이 음수로 전환하면 바로 빠져나오는데, 이게 너무 빨라서 추세 중간에 청산되는 경우가 있습니다. 거래 횟수도 37회로 줄었는데, 이는 청산 후 다음 스퀴즈까지 기다리는 동안 재진입 기회를 놓친 결과입니다.

 

스퀴즈 재진입 또는 모멘텀 전환 (+1886%, MDD -32%): 수익률은 스퀴즈 재진입 단독보다 낮지만, MDD가 -32%로 전체 테스트 중 가장 낮습니다. 두 개의 청산 안전망이 동시에 작동해서, 어느 쪽이든 먼저 위험 신호를 감지하면 빠져나옵니다. 승률도 52.2%로 가장 높고, 평균 손실(-5.2%)이 가장 작습니다.

 

슈퍼트렌드 + ATR 트레일링 전략과 나란히 비교하면:

전략 수익률 MDD 거래 승률
ST x2.0 + ATR Trail x3.0 (슈퍼트렌드+ATR) +2225% -44.45% 52 46.2%
TTM Squeeze + 스퀴즈/모멘텀 청산 +1886% -32.19% 46 52.2%

 

수익률은 슈퍼트렌드가 더 높지만(+2225% vs +1886%), MDD는 TTM Squeeze가 12%p 낮습니다(-44% vs -32%). 성격이 다른 두 전략입니다 — 수익을 우선하면 슈퍼트렌드+ATR 전략, 리스크 관리를 우선하면 TTM Squeeze 전략이 유리합니다.

 

에쿼티 커브에서 "Squeeze ON or Momentum Flip"(초록)이 가장 안정적인 우상향 경로를 보여줍니다. "Squeeze ON"(파랑)이 더 높이 올라가지만 하락 구간도 더 깊고, "Momentum Flip"(주황)은 수익을 덜 가져갑니다.


정리

TTM Squeeze는 볼린저 밴드와 켈트너 채널을 조합한 전략입니다. 단독으로는 +84% 수준에 머물렀던 두 지표가, 스퀴즈 감지라는 목적으로 합쳐지니 +1886%(수익 대비 낮은 MDD) 또는 +2188%(수익 극대화)를 냈습니다.

 

핵심 원리는 "변동성 수축 → 확장 전환점을 포착하라"입니다. 시장이 조용해지면(스퀴즈) 에너지가 응축되고, 폭발하면(스퀴즈 해소) 큰 움직임이 나옵니다. 모멘텀 히스토그램으로 방향을 확인하고 타는 것이 TTM Squeeze의 전부입니다.

 


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