
지금까지 다룬 모멘텀, ROC, MACD는 모두 "추세의 방향과 강도"를 측정하는 지표였습니다. 양수면 상승, 음수면 하락 — 값의 범위에 제한이 없었죠.
RSI는 접근 방식이 다릅니다. 0~100 사이의 고정된 범위 안에서 현재 모멘텀의 상대적 위치를 보여줍니다. 이를 통해 "지금 너무 많이 올랐는가(과매수)", "너무 많이 내렸는가(과매도)"를 판단할 수 있습니다. J. Welles Wilder가 1978년에 개발한 이후 가장 널리 쓰이는 오실레이터 중 하나입니다.
지표 소개
RSI는 일정 기간 동안의 상승폭과 하락폭의 비율을 계산합니다.
RS = 평균 상승폭 / 평균 하락폭
RSI = 100 - (100 / (1 + RS))
평균 상승폭: 최근 N일 중 가격이 오른 날의 상승분 평균
평균 하락폭: 최근 N일 중 가격이 내린 날의 하락분 평균
예를 들어 14일 중 상승한 날의 평균이 $500이고, 하락한 날의 평균이 $250이면 RS = 2, RSI = 66.7입니다. 상승이 하락보다 2배 강하다는 뜻이죠.
RSI가 100에 가까우면 거의 매일 올랐다는 뜻(극단적 상승), 0에 가까우면 거의 매일 내렸다는 뜻(극단적 하락)입니다. 일반적으로 70 이상을 과매수, 30 이하를 과매도로 봅니다.

상승장에서 RSI가 70 위에 오래 머무르는 것을 볼 수 있습니다. 강한 추세에서는 과매수 상태가 장기간 유지되는 것이 정상입니다. RSI가 70 위에 있다고 바로 하락하는 것은 아닙니다.

하락 구간에서는 RSI가 30 아래로 내려가는 빈도가 늘어납니다. 30 아래에서 반등하는 패턴이 보이지만, 다시 하락하는 경우도 있습니다.
해석
RSI에서 읽을 수 있는 정보입니다.
과매수/과매도: RSI > 70이면 과매수(상승 과열), RSI < 30이면 과매도(하락 과열). 반전 가능성을 시사하지만, 강한 추세에서는 과매수/과매도 상태가 오래 지속될 수 있습니다.
50 기준선: RSI > 50이면 상승 모멘텀 우세, RSI < 50이면 하락 모멘텀 우세. 추세의 방향을 간단히 판단하는 기준입니다.
범위 제한의 의미: 모멘텀이나 MACD는 가격 수준에 따라 값이 달라지지만, RSI는 항상 0~100입니다. 2019년의 RSI 80과 2024년의 RSI 80은 같은 의미(강한 상승 모멘텀)입니다. 이 점은 ROC와 비슷한 장점이지만, RSI는 비율이 아닌 상대적 강도를 보여준다는 차이가 있습니다.
이전 지표들과의 관계
모멘텀/ROC는 "얼마나/몇 % 올랐는가"를 봤고, MACD는 "두 EMA의 차이가 어떻게 변하는가"를 봤습니다. RSI는 "상승과 하락 중 어느 쪽이 우세한가"를 봅니다.
또한 앞의 세 지표는 추세추종 신호(양수면 매수, 음수면 매도)를 주는 반면, RSI의 과매수/과매도는 본질적으로 역추세(평균회귀) 신호입니다. "너무 올랐으니 내릴 것이다, 너무 내렸으니 오를 것이다." 이 관점의 차이가 중요합니다.
가설 설정
RSI의 기본적인 과매도 진입 전략을 테스트합니다.
- 진입 — RSI가 30 아래에서 위로 크로스 (과매도 탈출)
- 청산 — ?
진입은 명확한데, 청산 기준은 선택지가 있습니다.
RSI 50 청산: "과매도에서 중립까지 회복하면 나온다." 평균회귀의 본질에 가장 가까운 논리입니다. 포지션 보유 기간이 짧고, 거래 빈도가 높아집니다.
RSI 60 청산: 중립을 약간 넘어서까지 보유합니다. 50보다 조금 더 수익을 취하되, 과매수까지는 가지 않는 절충안입니다.
RSI 70 청산: "과매도에서 과매수까지 풀스윙." 이론적으로 가장 큰 수익을 노리지만, RSI가 30에서 70까지 올라가려면 오래 걸릴 수 있어서 거래 횟수가 극단적으로 줄어들 수 있습니다.
어떤 기준이 가장 좋을까? 세 가지를 한 번에 비교해봅니다. 이전에 이동평균 기간을 바꿔가며 비교했던 것과 같은 방법입니다. 파라미터 하나를 고정하고 하나만 바꿔가며 비교하면, 그 파라미터가 전략에 미치는 영향을 분리해서 볼 수 있습니다.
백테스트 조건
| 항목 | 설정 |
|---|---|
| 종목 | BTC/USDT (Binance Spot) |
| 기간 | 2018.01.01 ~ 2025.12.31 |
| 타임프레임 | 일봉 (1D) |
| RSI 기간 | 14일 |
| 진입 | RSI가 30 위로 크로스 (고정) |
| 청산 | RSI 50 / 60 / 70 아래로 크로스 (비교) |
| 초기 자본 | $10,000 |
| 수수료 / 슬리피지 | 미적용 |
코드 구현
import pandas as pd
import numpy as np
import mplfinance as mpf
import matplotlib.pyplot as plt
# ===== 설정 =====
DATA_FILE = "BTCUSDT_1d.csv"
RSI_PERIOD = 14
RSI_OVERSOLD = 30
EXIT_LEVELS = [50, 60, 70] # 비교할 청산 기준
INITIAL_CAPITAL = 10000
ZOOM_DAYS = 180
# ================
# 데이터 로드
df = pd.read_csv(DATA_FILE, index_col="timestamp", parse_dates=True)
# RSI 계산
delta = df["close"].diff()
gain = delta.where(delta > 0, 0)
loss = (-delta).where(delta < 0, 0)
avg_gain = gain.ewm(alpha=1/RSI_PERIOD, adjust=False).mean()
avg_loss = loss.ewm(alpha=1/RSI_PERIOD, adjust=False).mean()
rs = avg_gain / avg_loss
df["rsi"] = 100 - (100 / (1 + rs))
# 백테스트 함수
def run_rsi_backtest(df, rsi_oversold, rsi_exit, initial_capital):
capital = initial_capital
holdings = 0
buy_price = 0
in_position = False
trades = []
equity_curve = []
for i in range(RSI_PERIOD + 1, len(df)):
rsi = df["rsi"].iloc[i]
rsi_prev = df["rsi"].iloc[i - 1]
close = df["close"].iloc[i]
if not in_position:
if rsi > rsi_oversold and rsi_prev <= rsi_oversold:
buy_price = close
holdings = capital / buy_price
capital = 0
in_position = True
else:
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 = capital + holdings * close
equity_curve.append(equity)
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"RSI ({RSI_PERIOD}) 과매도 크로스 백테스트 — 청산 기준 비교")
print(f"진입: RSI {RSI_OVERSOLD} 상향 크로스")
print(f"Data: {df.index[0].strftime('%Y-%m-%d')} ~ {df.index[-1].strftime('%Y-%m-%d')}")
print("=" * 65)
print(f" {'Exit':<10} {'Capital':>10} {'Return':>10} {'MDD':>8} {'Trades':>7} {'Win':>5} {'Lose':>5} {'WinRate':>8}")
print("-" * 65)
for exit_level in EXIT_LEVELS:
capital, trades, mdd = run_rsi_backtest(df, RSI_OVERSOLD, exit_level, 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
print(f" RSI {exit_level:<5} ${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':<10} ${bnh_capital:>9,.0f} {bnh_return:>+9.2f}%")
print("=" * 65)
# 차트 헬퍼: 캔들 + RSI 서브플롯
def plot_rsi(df_plot, title):
fig, axes = mpf.plot(df_plot, type="candle", style="charles",
title=title, volume=False, figsize=(14, 8), returnfig=True)
ax_rsi = fig.add_axes([0.12, 0.08, 0.76, 0.25])
rsi_data = df_plot["rsi"].dropna()
x = range(len(rsi_data))
ax_rsi.plot(x, rsi_data.values, color="#1E88E5", linewidth=1.2)
ax_rsi.axhline(y=70, color="#E53935", linewidth=0.8, linestyle="--", label="70")
ax_rsi.axhline(y=60, color="#FF8F00", linewidth=0.8, linestyle="--", label="60")
ax_rsi.axhline(y=50, color="gray", linewidth=0.8, linestyle="--", label="50")
ax_rsi.axhline(y=30, color="#43A047", linewidth=0.8, linestyle="--", label="30")
ax_rsi.fill_between(x, 70, 100, alpha=0.1, color="#E53935")
ax_rsi.fill_between(x, 0, 30, alpha=0.1, color="#43A047")
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)
axes[0].set_position([0.12, 0.38, 0.76, 0.55])
return fig
# 차트 1 — 상승장
df_bull = df.loc["2020-10-01":"2021-04-30"].copy()
plot_rsi(df_bull, f"BTC/USDT RSI ({RSI_PERIOD}) (2020.10 ~ 2021.04, Bull)")
# 차트 2 — 최근
df_zoom = df.tail(ZOOM_DAYS).copy()
plot_rsi(df_zoom, f"BTC/USDT RSI ({RSI_PERIOD}) (Last {ZOOM_DAYS}D)")
plt.show()
사용법
EXIT_LEVELS 리스트에 원하는 청산 기준을 추가/제거하면 됩니다. RSI_OVERSOLD도 바꿔서 진입 기준을 변경할 수 있습니다.
결과 분석

세 가지 청산 기준에 따른 차이가 뚜렷합니다.
RSI 50 (중립 회복): 18회 거래, +55%. 과매도에서 사서 중립까지만 가지고 있으니, 반등 초기만 먹고 나옵니다. 승률이 44.4%로 가장 낮은데, 중립까지 회복하지 못하고 다시 빠지는 경우가 의외로 많다는 뜻입니다. 다만 MDD는 -37%로 세 가지 중 가장 낮습니다.
RSI 60 (절충): 17회 거래, +229%. 세 가지 중 수익률 1위입니다. 50보다 조금 더 보유해서 반등의 상승분을 더 취하면서, 70까지 욕심내지 않아서 되돌림에 수익을 반납하는 것을 줄인 균형점입니다.
RSI 70 (과매수): 12회 거래, +126%. 거래가 가장 적고 승률은 66.7%로 가장 높지만, 수익률은 60보다 낮습니다. 30에서 70까지 올라가는 데 오래 걸리다 보니, 도달하기 전에 되돌리는 경우가 있고, 거래 12회 중 5번은 아예 70에 도달하지 못해 미체결 상태로 장기 보유하게 됩니다. MDD도 -50%로 가장 높습니다.
진입이 적은 것은 일봉이라는 타임프레임의 영향이 큽니다. 일봉에서 RSI가 30 아래로 내려가려면 상당한 하락이 필요하지만, 4시간봉이나 1시간봉에서는 훨씬 자주 발생합니다.
그리고 역추세 전략은 원래 이렇게 단일 타임프레임에서 단독으로 쓰는 것이 아닙니다. 실전에서의 역추세 매매는 큰 타임프레임에서 추세를 먼저 확인하고, 작은 타임프레임에서 과매도/과매수 타이밍을 잡는 구조입니다. 예를 들어 일봉에서 상승 추세가 확인되면, 4시간봉에서 RSI가 과매도에 빠졌을 때 매수합니다. 큰 흐름은 위로 가고 있으니, 일시적인 눌림에서 사면 추세 방향으로 복귀할 확률이 높다는 논리입니다. 하락 추세에서는 반대로 과매수에서 숏을 잡습니다.
이 관점에서 보면 RSI의 가치가 달라집니다. 이번 백테스트는 추세 확인 없이 과매도만 보고 진입했기 때문에, 하락 추세 속 과매도(반등 없이 계속 하락)와 상승 추세 속 과매도(높은 확률로 반등)를 구분하지 못했습니다. 추세 필터를 결합하면 승률과 수익률 모두 달라질 수 있습니다. 이 부분은 이후 실전 전략 편에서 직접 검증해보겠습니다.
정리
RSI는 0~100의 고정 범위 안에서 상승/하락의 상대적 강도를 보여주는 지표입니다. 모멘텀/MACD와 달리 과매수/과매도라는 개념을 도입해서, 추세추종이 아닌 역추세(평균회귀) 관점의 신호를 제공합니다.
다음 글에서는 RSI와 비슷한 오실레이터이지만 계산 방식이 다른 스토캐스틱(Stochastic)을 다룹니다. RSI가 "상승폭 vs 하락폭"을 보는 반면, 스토캐스틱은 "현재 가격이 최근 범위에서 어디에 있는가"를 봅니다.
본 포스팅은 지표의 원리와 백테스트 과정을 정리한 기술 블로그이며, 특정 자산의 매매를 권유하지 않습니다.
백테스트 결과는 과거 데이터 기반이며 실제 수익을 보장하지 않습니다. 모든 투자의 책임은 본인에게 있습니다.
'Development > Indicator Lab' 카테고리의 다른 글
| CCI (Commodity Channel Index) (0) | 2026.03.18 |
|---|---|
| 스토캐스틱 (Stochastic Oscillator) (0) | 2026.03.17 |
| MACD (Moving Average Convergence Divergence) (0) | 2026.03.15 |
| 모멘텀 (Momentum) (0) | 2026.03.14 |
| ROC (Rate of Change) (0) | 2026.03.13 |