Development/Indicator Lab

멀티타임프레임 전략

MildChoco 2026. 3. 26. 11:21

지금까지 개별 지표를 하나씩 다루면서 일봉(1D) 위에서 백테스트를 돌렸습니다. 이번 글은 그 지표들을 실전에 적용해보는 첫 번째 전략 글입니다. 앞으로는 새로운 지표 소개와 실전 전략을 번갈아 가며 다룰 예정입니다.

왜 멀티타임프레임인가

단일 타임프레임의 한계는 이미 이전 글들에서 반복적으로 확인했습니다. RSI 과매도 크로스, 스토캐스틱 크로스, MACD 크로스 등 오실레이터 단독 전략은 하락 추세에서 역추세 신호를 계속 잡아서 연속 손실을 냅니다.

 

11편(MA 필터)에서 이동평균을 추세 필터로 사용하면 이 문제가 줄어드는 것을 확인했습니다. 멀티타임프레임은 이 아이디어를 확장한 것입니다 — 상위 타임프레임에서 추세를 판단하고, 하위 타임프레임에서 진입 타이밍을 잡습니다.

 

구조

상위 TF (1D)  →  추세 방향 판단  →  "지금 사도 되는 환경인가?"
하위 TF (1H)  →  진입 신호 감지  →  "정확히 언제 사는가?"

 

상위 TF는 큰 그림을 보고, 하위 TF는 세밀한 타이밍을 잡습니다. 상위 TF가 하락 추세라고 판단하면, 하위 TF에서 아무리 좋은 매수 신호가 나와도 무시합니다.


전략 설계

이 글에서는 지금까지 다룬 지표 중 가장 단순한 조합을 사용합니다.

 

상위 TF — 1D SMA(50) 추세 필터

  • 종가가 SMA(50) 위에 있으면 상승 추세 → 매수 허용
  • 종가가 SMA(50) 아래에 있으면 하락 추세 → 매수 차단

하위 TF — 1H RSI(14) 진입

  • RSI가 30 아래에서 위로 크로스 → 매수 (과매도 탈출)
  • RSI가 청산 기준(50/60/70) 위에서 아래로 크로스 → 매도

비교를 위해 두 가지 모드를 돌립니다.

  1. 1H RSI 단독 — 추세 필터 없이 1H RSI 신호만으로 매매
  2. 1D SMA + 1H RSI — 1D가 상승 추세일 때만 1H RSI 신호 수용

이렇게 하면 "추세 필터 하나가 어떤 차이를 만드는가"를 직접 비교할 수 있습니다.

타임프레임 정렬

상위 TF 정보를 하위 TF에 매핑할 때 미래 데이터 유출에 주의해야 합니다. 1H 바에서 "오늘 1D 캔들"의 종가를 참조하면 아직 완성되지 않은 데이터를 쓰는 것입니다.

이 백테스트에서는 가장 최근 완성된 1D 캔들의 추세 상태를 1H에 적용합니다. 예를 들어, 3월 15일 14:00(1H)에서는 3월 14일(1D)의 추세를 참조합니다.

 

 

주황색 선이 SMA(50)입니다. 종가가 SMA 위에 있는 구간(상승 추세)에서만 1H 진입 신호를 받아들이고, 아래에 있는 구간(하락 추세)에서는 무시합니다.


백테스트 조건

항목 설정
종목 BTC/USDT (Binance Spot)
기간 2020.01.01 ~ 2023.12.31 (1H 데이터 범위)
상위 TF 일봉 (1D) — SMA(50) 추세 필터
하위 TF 1시간봉 (1H) — RSI(14) 진입/청산
진입 1H RSI 30 상향 크로스 (+ 선택적 1D 추세 필터)
청산 1H RSI 50 / 60 / 70 하향 크로스 (비교)
초기 자본 $10,000
수수료 / 슬리피지 미적용

코드 구현

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

# ===== 설정 =====
DATA_1D = "BTCUSDT_1d.csv"
DATA_1H = "BTCUSDT_1h.csv"
SMA_PERIOD = 50           # 1D 추세 필터 SMA
RSI_PERIOD = 14           # 1H RSI
RSI_ENTRY = 30            # 과매도 탈출 진입
RSI_EXITS = [50, 60, 70]  # 비교할 청산 기준
INITIAL_CAPITAL = 10000
ZOOM_HOURS = 500          # 차트 확대 구간 (1H 바 수)
# ================

# --- 데이터 로드 ---
df_1d = pd.read_csv(DATA_1D, index_col="timestamp", parse_dates=True)
df_1h = pd.read_csv(DATA_1H, index_col="timestamp", parse_dates=True)

# --- 1D: SMA 계산 ---
df_1d["sma"] = df_1d["close"].rolling(window=SMA_PERIOD).mean()
df_1d["trend_up"] = df_1d["close"] > df_1d["sma"]

# --- 1H: RSI 계산 ---
delta = df_1h["close"].diff()
gain = delta.clip(lower=0)
loss = -delta.clip(upper=0)
avg_gain = gain.rolling(window=RSI_PERIOD, min_periods=RSI_PERIOD).mean()
avg_loss = loss.rolling(window=RSI_PERIOD, min_periods=RSI_PERIOD).mean()

# Wilder smoothing
for i in range(RSI_PERIOD, len(avg_gain)):
    if pd.notna(avg_gain.iloc[i - 1]):
        avg_gain.iloc[i] = (avg_gain.iloc[i - 1] * (RSI_PERIOD - 1) + gain.iloc[i]) / RSI_PERIOD
        avg_loss.iloc[i] = (avg_loss.iloc[i - 1] * (RSI_PERIOD - 1) + loss.iloc[i]) / RSI_PERIOD

rs = avg_gain / avg_loss
df_1h["rsi"] = 100 - (100 / (1 + rs))

# --- 1D 추세 정보를 1H에 매핑 ---
# 각 1H 바 시점에서 가장 최근 완성된 1D 캔들의 추세를 사용
df_1d_trend = df_1d[["trend_up"]].copy()
df_1d_trend.index = df_1d_trend.index.tz_localize(None) if df_1d_trend.index.tz else df_1d_trend.index
df_1h.index = df_1h.index.tz_localize(None) if df_1h.index.tz else df_1h.index

# 1D 추세를 1H 인덱스에 forward-fill
df_1h["trend_up"] = df_1d_trend["trend_up"].reindex(df_1h.index, method="ffill")

# --- 백테스트 함수 ---
def run_backtest(df, rsi_entry, rsi_exit, initial_capital, use_trend_filter=False):
    capital = initial_capital
    holdings = 0
    buy_price = 0
    in_position = False
    trades = []
    equity_curve = []

    for i in range(1, len(df)):
        rsi = df["rsi"].iloc[i]
        rsi_prev = df["rsi"].iloc[i - 1]
        close = df["close"].iloc[i]
        trend = df["trend_up"].iloc[i] if use_trend_filter else True

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

        if use_trend_filter and pd.isna(trend):
            equity_curve.append(capital + holdings * close)
            continue

        if not in_position:
            # RSI 과매도(30) 상향 크로스 + (선택적) 1D 상승추세
            if rsi > rsi_entry and rsi_prev <= rsi_entry and trend:
                buy_price = close
                holdings = capital / buy_price
                capital = 0
                in_position = True
        else:
            # RSI 청산 기준 하향 크로스
            if rsi < rsi_exit and rsi_prev >= rsi_exit:
                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

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

    return capital, trades, mdd, equity_curve


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

# --- 실행 ---
print("=" * 80)
print(f"멀티타임프레임 전략 백테스트 — 1D SMA({SMA_PERIOD}) 필터 + 1H RSI({RSI_PERIOD})")
print(f"1H Data: {df_1h.index[0].strftime('%Y-%m-%d')} ~ {df_1h.index[-1].strftime('%Y-%m-%d')}")
print(f"진입: 1H RSI {RSI_ENTRY} 상향 크로스")
print("=" * 80)

header = f"  {'Mode':<20} {'Exit':<8} {'Capital':>10} {'Return':>10} {'MDD':>8} {'Trades':>7} {'Win':>5} {'Lose':>5} {'WinRate':>8}"
print(header)
print("-" * 80)

results = []

for rsi_exit in RSI_EXITS:
    # 1) 1H RSI 단독
    cap1, trades1, mdd1, eq1 = run_backtest(df_1h, RSI_ENTRY, rsi_exit, INITIAL_CAPITAL, use_trend_filter=False)
    ret1 = (cap1 - INITIAL_CAPITAL) / INITIAL_CAPITAL * 100
    w1 = len([t for t in trades1 if t["pnl_pct"] > 0])
    l1 = len([t for t in trades1 if t["pnl_pct"] <= 0])
    wr1 = w1 / len(trades1) * 100 if trades1 else 0

    print(f"  {'1H RSI Only':<20} RSI {rsi_exit:<3} ${cap1:>9,.0f} {ret1:>+9.2f}% {mdd1:>+7.2f}% {len(trades1):>6}  {w1:>5} {l1:>5} {wr1:>7.1f}%")
    results.append(("1H_only", rsi_exit, cap1, ret1, mdd1, trades1, eq1))

    # 2) 1D SMA 필터 + 1H RSI
    cap2, trades2, mdd2, eq2 = run_backtest(df_1h, RSI_ENTRY, rsi_exit, INITIAL_CAPITAL, use_trend_filter=True)
    ret2 = (cap2 - INITIAL_CAPITAL) / INITIAL_CAPITAL * 100
    w2 = len([t for t in trades2 if t["pnl_pct"] > 0])
    l2 = len([t for t in trades2 if t["pnl_pct"] <= 0])
    wr2 = w2 / len(trades2) * 100 if trades2 else 0

    print(f"  {'1D SMA + 1H RSI':<20} RSI {rsi_exit:<3} ${cap2:>9,.0f} {ret2:>+9.2f}% {mdd2:>+7.2f}% {len(trades2):>6}  {w2:>5} {l2:>5} {wr2:>7.1f}%")
    results.append(("mtf", rsi_exit, cap2, ret2, mdd2, trades2, eq2))

    print()

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

# --- 에쿼티 커브 비교 차트 ---
# 가장 좋은 청산 기준의 1H only vs MTF 비교
best_exit = RSI_EXITS[1]  # 기본 60 — 결과 보고 조정 가능

eq_1h_only = None
eq_mtf = None
for mode, ex, cap, ret, mdd, trades, eq in results:
    if mode == "1H_only" and ex == best_exit:
        eq_1h_only = eq
    if mode == "mtf" and ex == best_exit:
        eq_mtf = eq

if eq_1h_only and eq_mtf:
    fig, ax = plt.subplots(figsize=(14, 6))
    ax.plot(eq_1h_only, label=f"1H RSI Only (Exit {best_exit})", alpha=0.8)
    ax.plot(eq_mtf, label=f"1D SMA({SMA_PERIOD}) + 1H RSI (Exit {best_exit})", alpha=0.8)

    ax.set_title(f"Equity Curve — 1H RSI Only vs MTF (1D SMA + 1H RSI), Exit RSI {best_exit}")
    ax.set_ylabel("Equity ($)")
    ax.legend()
    ax.grid(True, alpha=0.3)
    plt.tight_layout()

# --- 1D 추세 필터 시각화 ---
df_1d_plot = df_1d.loc["2021-06-01":"2022-06-30"].copy()
if len(df_1d_plot) > 0:
    sma_plot = mpf.make_addplot(df_1d_plot["sma"], color="#FF9800", width=1.5)

    # 추세 배경색을 위한 마커
    up_marker = df_1d_plot["close"].where(df_1d_plot["trend_up"], np.nan)
    down_marker = df_1d_plot["close"].where(~df_1d_plot["trend_up"], np.nan)

    fig2, axes2 = mpf.plot(df_1d_plot, type="candle", style="charles",
        title=f"BTC/USDT 1D — SMA({SMA_PERIOD}) Trend Filter (2021.06 ~ 2022.06)",
        volume=False, figsize=(14, 7),
        addplot=[sma_plot],
        returnfig=True)

# --- 1H RSI + 매매 신호 차트 (최근 구간) ---
df_1h_zoom = df_1h.tail(ZOOM_HOURS).copy()

fig3, axes3 = mpf.plot(df_1h_zoom, type="candle", style="charles",
    title=f"BTC/USDT 1H — RSI({RSI_PERIOD}) + Trade Signals (Last {ZOOM_HOURS} bars)",
    volume=False, figsize=(14, 8),
    returnfig=True)

ax_rsi = fig3.add_axes([0.12, 0.08, 0.76, 0.25])
rsi_data = df_1h_zoom["rsi"].dropna()
x = range(len(rsi_data))

ax_rsi.plot(x, rsi_data.values, color="#1E88E5", linewidth=1.2)
ax_rsi.axhline(y=RSI_ENTRY, color="#43A047", linewidth=0.8, linestyle="--", label=f"Entry {RSI_ENTRY}")
ax_rsi.axhline(y=70, color="#E53935", linewidth=0.8, linestyle="--", label="70")
ax_rsi.axhline(y=50, color="gray", linewidth=0.5, linestyle=":")
ax_rsi.fill_between(x, 0, RSI_ENTRY, alpha=0.1, color="#43A047")
ax_rsi.fill_between(x, 70, 100, alpha=0.1, color="#E53935")
ax_rsi.set_ylim(0, 100)
ax_rsi.set_ylabel("RSI")
ax_rsi.set_xlim(0, len(rsi_data))
ax_rsi.legend(loc="upper left", fontsize=8)

# 추세 필터 상태 표시
trend_zoom = df_1h_zoom["trend_up"].dropna()
for j in range(len(trend_zoom)):
    if trend_zoom.iloc[j]:
        ax_rsi.axvspan(j, j + 1, alpha=0.05, color="green")
    else:
        ax_rsi.axvspan(j, j + 1, alpha=0.05, color="red")

axes3[0].set_position([0.12, 0.38, 0.76, 0.55])

plt.show()

 

핵심은 use_trend_filter 파라미터입니다. False면 1H RSI 신호만으로 매매하고, True면 1D SMA 추세 필터를 추가합니다.


사용법

SMA_PERIOD로 추세 판단 기준을, RSI_EXITS로 청산 기준을 변경할 수 있습니다.

두 데이터 파일(BTCUSDT_1d.csv, BTCUSDT_1h.csv)이 같은 폴더에 있어야 합니다.


결과 분석

 

================================================================================
멀티타임프레임 전략 백테스트 — 1D SMA(50) 필터 + 1H RSI(14)
1H Data: 2020-01-01 ~ 2023-12-31
진입: 1H RSI 30 상향 크로스
================================================================================
  Mode                 Exit        Capital     Return      MDD  Trades   Win  Lose  WinRate
--------------------------------------------------------------------------------
  1H RSI Only          RSI 50  $    8,692    -13.08%  -40.65%    231    134    97    58.0%
  1D SMA + 1H RSI      RSI 50  $    9,984     -0.16%  -22.01%     98     61    37    62.2%

  1H RSI Only          RSI 60  $   10,361     +3.61%  -55.84%    184    119    65    64.7%
  1D SMA + 1H RSI      RSI 60  $   14,277    +42.77%  -32.90%     83     55    28    66.3%

  1H RSI Only          RSI 70  $    8,016    -19.84%  -63.94%    127     87    40    68.5%
  1D SMA + 1H RSI      RSI 70  $   11,455    +14.55%  -50.87%     71     50    21    70.4%

--------------------------------------------------------------------------------
  B&H                           $   59,033   +490.33%
================================================================================

 

모든 청산 기준에서 MTF 전략이 1H RSI 단독보다 나은 결과를 보였습니다. 정리하면 이렇습니다.

모드 청산 수익률 MDD 거래 승률
1H RSI Only 50 -13.08% -40.65% 231 58.0%
1D SMA + 1H RSI 50 -0.16% -22.01% 98 62.2%
1H RSI Only 60 +3.61% -55.84% 184 64.7%
1D SMA + 1H RSI 60 +42.77% -32.90% 83 66.3%
1H RSI Only 70 -19.84% -63.94% 127 68.5%
1D SMA + 1H RSI 70 +14.55% -50.87% 71 70.4%

 

추세 필터 하나가 바꾼 것을 세 가지로 나눠서 봅니다.

 

1. 불필요한 거래 제거. 1H RSI 단독은 231회(Exit 50) 거래를 냈지만, MTF는 98회로 절반 이하입니다. 하락 추세에서 나오는 과매도 탈출 신호가 필터에 의해 차단된 것입니다. 이전 글들에서 반복적으로 확인했던 "하락장에서 오실레이터 역추세 신호가 연속 손실을 만든다"는 문제가 여기서도 그대로 나타나고, 상위 TF 필터가 이것을 막아줍니다.

 

2. MDD 대폭 개선. Exit 60 기준으로 -55.84% → -32.90%로, 약 23%p 줄었습니다. Exit 50에서는 -40.65% → -22.01%로 거의 절반입니다. 에쿼티 커브 차트를 보면, 1H RSI 단독(파란색)은 하락장 구간에서 자본이 크게 빠지는 반면, MTF(주황색)는 같은 구간에서 상대적으로 평탄하게 유지됩니다.

 

3. 승률 일관적 향상. 모든 청산 기준에서 MTF의 승률이 1H 단독보다 2~4%p 높습니다. 거래 횟수가 줄면서 나쁜 거래가 빠진 것입니다.

 

 

에쿼티 커브에서 파란색의 1H RSI 단독라인과 주황색의 MTF의 차이가 명확하게 보입니다. 하락장 구간에서 단독 전략은 자본이 크게 잠식되었지만, MTF는 같은 구간에서 상대적으로 평탄합니다. B&H가 +490%인 반면, 가장 좋은 MTF 조합도 +42.77%에 그칩니다. RSI 과매도 크로스라는 진입 조건 자체가 보수적이고, 큰 상승을 놓치는 구간이 많습니다. 이것은 이 전략이 나쁘다는 뜻이 아니라, "추세 필터 + 오실레이터 진입"이라는 프레임워크 자체의 성격입니다 — 큰 수익보다는 리스크 관리에 강점이 있습니다.

 

 

1H 차트 하단의 RSI 패널에서 배경색이 초록(상승 추세)과 빨강(하락 추세)으로 나뉩니다. 초록 구간에서만 RSI 30 상향 크로스를 진입 신호로 받아들이고, 빨강 구간의 신호는 무시하는 구조입니다.

최적 조합: Exit RSI 60

Exit 60이 가장 균형 잡힌 결과를 보였습니다. Exit 50은 너무 일찍 청산해서 수익을 충분히 가져가지 못하고, Exit 70은 너무 늦게 청산해서 수익을 되돌려 줍니다. 이것은 15편(RSI)에서 확인한 패턴과 일치합니다 — RSI 60이 "중립 약간 위"에서 빠져나오는 적절한 타이밍입니다.


정리

멀티타임프레임은 단순하지만 효과적인 구조입니다. 상위 TF(1D)에서 추세를 판단하고, 하위 TF(1H)에서 진입 타이밍을 잡는 것만으로, 거래 횟수를 절반 이하로 줄이고 MDD를 20%p 이상 개선했습니다. 핵심은 "나쁜 거래를 하지 않는 것"입니다.

이 글에서 확인한 핵심 원칙을 정리하면 이렇습니다.

  1. 상위 TF의 역할은 필터다. 상위 TF는 진입 신호를 만들지 않습니다. "지금이 사도 되는 환경인가?"만 판단합니다.
  2. 하위 TF의 역할은 타이밍이다. 환경이 허가되면, 하위 TF에서 구체적인 진입 시점을 잡습니다.
  3. 미래 데이터 유출을 주의한다. 상위 TF 정보를 하위 TF에 매핑할 때 아직 완성되지 않은 캔들을 참조하지 않아야 합니다.

실전에서는 1D → 1H 2단 구조를 넘어서, H4/H1/M15 같은 3단 구조로 확장할 수 있습니다. 상위 TF에서 방향, 중간 TF에서 구조, 하위 TF에서 정밀 진입을 잡는 방식입니다. 이 글에서는 "멀티타임프레임의 원리와 효과"에 집중했고, TF를 더 세분화하는 것은 같은 논리의 연장입니다.


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

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

켈트너 채널 (Keltner Channel)  (0) 2026.03.29
볼린저 밴드 (Bollinger Bands)  (0) 2026.03.28
Williams %R  (0) 2026.03.19
CCI (Commodity Channel Index)  (0) 2026.03.18
스토캐스틱 (Stochastic Oscillator)  (0) 2026.03.17