
이전 글에서 RSI를 다뤘습니다. "상승폭 vs 하락폭"의 비율로 0~100 사이의 값을 만들어서, 과매수/과매도를 판단하는 지표였죠. 스토캐스틱도 같은 0-100 범위의 오실레이터이지만, 계산 방식이 다릅니다.
스토캐스틱은 "현재 가격이 최근 N일 범위에서 어디에 있는가"를 봅니다. George Lane이 1950년대에 개발한 지표로, RSI와 함께 가장 많이 쓰이는 오실레이터입니다.
지표 소개
스토캐스틱은 두 개의 선으로 구성됩니다.
%K = (현재 종가 - N일 최저가) / (N일 최고가 - N일 최저가) × 100
%D = %K의 M일 이동평균
예를 들어 최근 14일 최고가가 $100,000, 최저가가 $80,000이고 현재 종가가 $95,000이면:
%K = (95,000 - 80,000) / (100,000 - 80,000) × 100 = 75
현재 가격이 최근 범위의 75% 지점에 있다는 뜻입니다. 100에 가까우면 최근 최고가 근처, 0에 가까우면 최근 최저가 근처입니다.
Fast vs Slow 스토캐스틱
위 공식으로 계산한 것이 Fast %K인데, 이 값은 변동이 너무 심합니다. 실전에서는 Slow Stochastic을 주로 씁니다.
Slow %K = Fast %K의 3일 이동평균
Slow %D = Slow %K의 3일 이동평균
Fast %K를 한 번 평활화한 것이 Slow %K, 그걸 다시 평활화한 것이 Slow %D입니다. 모멘텀에 EMA를 씌워서 MACD를 만든 것과 같은 논리입니다. 이 글에서 "스토캐스틱"은 Slow Stochastic(14/3/3)을 의미합니다.
일반적으로 80 이상을 과매수, 20 이하를 과매도로 봅니다. RSI의 70/30보다 범위가 넓은데, 스토캐스틱이 RSI보다 더 자주 극단 영역에 들어가기 때문입니다.

%K(파랑)와 %D(주황) 두 선이 함께 움직입니다. 상승장에서 스토캐스틱이 80 위에 자주 올라가는 것을 볼 수 있습니다. RSI와 마찬가지로, 강한 추세에서는 과매수 상태가 오래 유지됩니다.

하락 구간에서는 20 아래로 내려가는 빈도가 늘어납니다. RSI보다 진폭이 크고, 0과 100에 더 자주 닿는 것이 특징입니다.
RSI와의 차이
RSI와 스토캐스틱은 둘 다 0~100 범위의 오실레이터이지만, 보는 관점이 다릅니다.
RSI: "최근 N일 동안 상승폭과 하락폭 중 어느 쪽이 강한가?" — 모멘텀의 방향과 강도
스토캐스틱: "현재 가격이 최근 N일 최고/최저 범위에서 어디에 있는가?" — 범위 내 상대적 위치
실질적인 차이도 있습니다. 스토캐스틱은 RSI보다 더 민감하게 반응합니다. 가격이 살짝만 움직여도 최근 범위 내 위치가 크게 바뀔 수 있기 때문입니다. 그래서 스토캐스틱의 과매수/과매도 기준이 80/20으로 RSI(70/30)보다 넓습니다.
가설 설정
RSI와 같은 구조로 테스트합니다. 진입은 과매도 탈출, 청산 기준을 50/60/80 세 가지로 비교합니다.
- 진입 — %K가 20 아래에서 위로 크로스 (과매도 탈출)
- 청산 — %K가 50 / 60 / 80 위에서 아래로 크로스
스토캐스틱이 RSI보다 민감하므로, 20 아래로 내려가는 횟수가 RSI 30보다 많을 것으로 예상됩니다. 즉 거래 횟수가 더 많아질 가능성이 높습니다.
백테스트 조건
| 항목 | 설정 |
|---|---|
| 종목 | BTC/USDT (Binance Spot) |
| 기간 | 2018.01.01 ~ 2025.12.31 |
| 타임프레임 | 일봉 (1D) |
| 스토캐스틱 | 14/3/3 (Slow) |
| 진입 | %K가 20 위로 크로스 (고정) |
| 청산 | %K가 50 / 60 / 80 아래로 크로스 (비교) |
| 초기 자본 | $10,000 |
| 수수료 / 슬리피지 | 미적용 |
코드 구현
import pandas as pd
import numpy as np
import mplfinance as mpf
import matplotlib.pyplot as plt
# ===== 설정 =====
DATA_FILE = "BTCUSDT_1d.csv"
STOCH_K = 14 # %K 기간
STOCH_D = 3 # %D 기간 (슬로우)
STOCH_SLOW = 3 # %K 평활화 기간
OVERBOUGHT = 80
OVERSOLD = 20
EXIT_LEVELS = [50, 60, 80] # 비교할 청산 기준
INITIAL_CAPITAL = 10000
ZOOM_DAYS = 180
# ================
# 데이터 로드
df = pd.read_csv(DATA_FILE, index_col="timestamp", parse_dates=True)
# 스토캐스틱 계산 (Slow Stochastic)
lowest_low = df["low"].rolling(window=STOCH_K).min()
highest_high = df["high"].rolling(window=STOCH_K).max()
# Fast %K → Slow %K (평활화)
fast_k = (df["close"] - lowest_low) / (highest_high - lowest_low) * 100
df["stoch_k"] = fast_k.rolling(window=STOCH_SLOW).mean() # Slow %K
df["stoch_d"] = df["stoch_k"].rolling(window=STOCH_D).mean() # Slow %D
# 백테스트 함수
def run_stoch_backtest(df, oversold, exit_level, initial_capital):
capital = initial_capital
holdings = 0
buy_price = 0
in_position = False
trades = []
equity_curve = []
start_idx = STOCH_K + STOCH_SLOW + STOCH_D
for i in range(start_idx, len(df)):
k = df["stoch_k"].iloc[i]
k_prev = df["stoch_k"].iloc[i - 1]
close = df["close"].iloc[i]
if not in_position:
if k > oversold and k_prev <= oversold:
buy_price = close
holdings = capital / buy_price
capital = 0
in_position = True
else:
if k < exit_level and k_prev >= exit_level:
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"Stochastic ({STOCH_K}/{STOCH_SLOW}/{STOCH_D}) 과매도 크로스 백테스트")
print(f"진입: %K {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_stoch_backtest(df, 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" %K {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)
# 차트 헬퍼
def plot_stoch(df_plot, title):
fig, axes = mpf.plot(df_plot, type="candle", style="charles",
title=title, volume=False, figsize=(14, 8), returnfig=True)
ax_st = fig.add_axes([0.12, 0.08, 0.76, 0.25])
k_data = df_plot["stoch_k"].dropna()
d_data = df_plot["stoch_d"].dropna()
x = range(len(k_data))
ax_st.plot(x, k_data.values, color="#1E88E5", linewidth=1.2, label="%K")
ax_st.plot(range(len(d_data)), d_data.values, color="#FF8F00",
linewidth=1.2, label="%D")
ax_st.axhline(y=OVERBOUGHT, color="#E53935", linewidth=0.8, linestyle="--")
ax_st.axhline(y=OVERSOLD, color="#43A047", linewidth=0.8, linestyle="--")
ax_st.axhline(y=50, color="gray", linewidth=0.5, linestyle=":")
ax_st.fill_between(x, OVERBOUGHT, 100, alpha=0.1, color="#E53935")
ax_st.fill_between(x, 0, OVERSOLD, alpha=0.1, color="#43A047")
ax_st.set_ylim(0, 100)
ax_st.set_ylabel("Stochastic")
ax_st.set_xlim(0, len(k_data))
ax_st.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_stoch(df_bull,
f"BTC/USDT Stochastic ({STOCH_K}/{STOCH_SLOW}/{STOCH_D}) (2020.10 ~ 2021.04, Bull)")
# 차트 2 — 최근
df_zoom = df.tail(ZOOM_DAYS).copy()
plot_stoch(df_zoom,
f"BTC/USDT Stochastic ({STOCH_K}/{STOCH_SLOW}/{STOCH_D}) (Last {ZOOM_DAYS}D)")
plt.show()
사용법
EXIT_LEVELS 리스트에 원하는 청산 기준을 추가/제거하면 됩니다. OVERSOLD를 바꾸면 진입 기준도 변경할 수 있습니다.
결과 분석
RSI와 비교하면 성격 차이가 명확합니다.
거래 횟수: RSI(12 ~ 18회) → 스토캐스틱(50 ~ 63회). 예상대로 스토캐스틱이 훨씬 민감해서 과매도 진입 기회가 3 ~ 4배 많습니다. 20 아래로 내려가는 것이 RSI 30보다 훨씬 자주 발생하기 때문입니다.
수익률: RSI 60 청산(+229%) → 스토캐스틱 50 청산(+168%). 거래가 많아졌는데 수익률은 오히려 낮아졌습니다. 민감하다는 건 노이즈에도 민감하다는 뜻이라, "진짜 과매도 반등"이 아닌 "잠깐 찍고 계속 하락"에도 진입하는 횟수가 늘어난 것입니다.
MDD: RSI(-37 ~ -50%) → 스토캐스틱(-65 ~ -77%). 특히 80 청산은 MDD -77%에 수익률 +4%로 사실상 의미가 없습니다. 과매도에서 과매수까지 기다리는 동안 하락장을 통째로 맞은 것입니다.
50 vs 60: RSI에서는 60이 압도적 1위였는데, 스토캐스틱에서는 50(+168%)과 60(+161%)이 비슷합니다. 스토캐스틱이 더 빠르게 움직이니 50에서 나가든 60에서 나가든 큰 차이가 없는 것입니다.
민감한 오실레이터가 반드시 좋은 것은 아닙니다. RSI와 스토캐스틱의 결과를 비교하면, 같은 역추세 논리라도 RSI쪽이 "걸러진 진입"을 제공해서 승률과 수익률 모두 우위에 있습니다. RSI에서 썼던 것과 같은 논리가 여기서도 적용됩니다 — 추세 필터 없이 단독으로 쓰면, 민감도와 관계없이 역추세 전략의 한계를 벗어나기 어렵습니다.
정리
스토캐스틱은 현재 가격이 최근 범위에서 어디에 있는지를 보여주는 오실레이터입니다. RSI와 같은 0~100 범위이지만 더 민감하게 반응하며, 과매수/과매도 영역에 더 자주 진입합니다.
다음 글에서는 또 다른 오실레이터인 CCI(Commodity Channel Index)를 다룹니다. CCI는 0~100 범위에 고정되지 않고, 평균 가격 대비 편차를 측정하는 방식으로 접근합니다.
본 포스팅은 지표의 원리와 백테스트 과정을 정리한 기술 블로그이며, 특정 자산의 매매를 권유하지 않습니다.
백테스트 결과는 과거 데이터 기반이며 실제 수익을 보장하지 않습니다. 모든 투자의 책임은 본인에게 있습니다.
'Development > Indicator Lab' 카테고리의 다른 글
| Williams %R (0) | 2026.03.19 |
|---|---|
| CCI (Commodity Channel Index) (0) | 2026.03.18 |
| RSI (Relative Strength Index) (3) | 2026.03.16 |
| MACD (Moving Average Convergence Divergence) (0) | 2026.03.15 |
| 모멘텀 (Momentum) (0) | 2026.03.14 |