Development/Indicator Lab

EMA (Exponential Moving Average, 지수이동평균)

MildChoco 2026. 3. 4. 11:21

 

이전 글에서 SMA를 다뤘는데, 가장 큰 한계는 후행성이었습니다. 모든 가격을 동일하게 평균하다 보니 최근 변화에 느리게 반응하는 거죠. EMA는 이 문제를 개선하기 위해 최근 가격에 더 높은 가중치를 부여하는 이동평균입니다.


지표 소개

EMA는 최근 가격일수록 더 큰 비중을 두고, 과거 가격일수록 비중이 지수적으로 줄어드는 방식으로 계산됩니다.

EMA = 전일 EMA + α × (당일 종가 - 전일 EMA)

여기서 α(승수)는 다음과 같이 결정됩니다.

α = 2 / (N + 1)
  • N: 기간 (Period)
  • α: 평활 계수 (Smoothing Factor)

N이 20이면 α = 2/21 ≈ 0.095, 즉 당일 종가에 약 9.5%의 가중치를 주고 나머지는 이전 EMA 값을 이어받습니다. SMA가 N개의 가격을 동등하게 취급하는 것과 달리, EMA는 최근 가격 변화에 더 빠르게 반응합니다.

다시 말해 SMA와 같은 기간을 사용하더라도, EMA는 추세 전환을 좀 더 빨리 감지할 수 있다는 것이 핵심입니다.

아래는 같은 기간(50)의 SMA와 EMA를 하나의 차트에 겹쳐본 모습입니다.

(SMA 50 vs EMA 50 비교 차트 캡처 추가 예정)

오렌지 선이 SMA, 퍼플 선이 EMA입니다. 가격이 하락으로 전환되는 10월 이후 구간을 보면, EMA가 SMA보다 가격 변화에 더 빠르게 따라가는 것을 확인할 수 있습니다. SMA는 과거 가격을 동등하게 반영하기 때문에 방향 전환이 느리고, EMA는 최근 가격에 가중치를 두기 때문에 더 빠르게 꺾입니다.


가설 설정

SMA 글과 동일한 조건으로 테스트합니다.

  • 골든크로스 (매수) — 단기 EMA가 장기 EMA를 위로 돌파
  • 데드크로스 (매도) — 단기 EMA가 장기 EMA를 아래로 돌파

같은 전략, 같은 기간 설정으로 SMA와 비교했을 때 EMA가 실제로 더 빠르게 반응하는지, 그리고 그것이 수익률 개선으로 이어지는지를 확인합니다.


백테스트 조건

항목 설정
종목 BTC/USDT (Binance Spot)
기간 2018.01.01 ~ 2025.12.31
타임프레임 일봉 (1D)
단기 EMA 20
장기 EMA 60
진입 골든크로스 시 매수
청산 데드크로스 시 매도
초기 자본 $10,000
수수료 / 슬리피지 미적용

코드 구현

SMA 코드와 거의 동일하고, 이동평균 계산 부분만 rolling().mean() 대신 ewm().mean()으로 바뀝니다.

import pandas as pd
import mplfinance as mpf

# ===== 설정 =====
DATA_FILE = "BTCUSDT_1d.csv"
EMA_SHORT = 20
EMA_LONG = 60
INITIAL_CAPITAL = 10000
ZOOM_DAYS = 180
# ================

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

# 2. EMA 계산
df["ema_short"] = df["close"].ewm(span=EMA_SHORT, adjust=False).mean()
df["ema_long"] = df["close"].ewm(span=EMA_LONG, adjust=False).mean()

# 3. 신호 생성
df["signal"] = 0
df.loc[df["ema_short"] > df["ema_long"], "signal"] = 1
df["position"] = df["signal"].diff()

# 4. 백테스트
capital = INITIAL_CAPITAL
holdings = 0
buy_price = 0
trades = []

for i in range(len(df)):
    if df["position"].iloc[i] == 1:
        buy_price = df["close"].iloc[i]
        holdings = capital / buy_price
        capital = 0
    elif df["position"].iloc[i] == -1 and holdings > 0:
        sell_price = df["close"].iloc[i]
        capital = holdings * sell_price
        pnl = (sell_price - buy_price) / buy_price * 100
        trades.append({
            "buy": buy_price,
            "sell": sell_price,
            "pnl_pct": round(pnl, 2)
        })
        holdings = 0

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

# 5. 결과 출력
final_capital = capital
total_return = (final_capital - INITIAL_CAPITAL) / INITIAL_CAPITAL * 100
win_trades = [t for t in trades if t["pnl_pct"] > 0]
lose_trades = [t for t in trades if t["pnl_pct"] <= 0]

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("=" * 40)
print(f"EMA {EMA_SHORT}/{EMA_LONG} 백테스트 결과")
print("=" * 40)
print(f"초기 자본:    ${INITIAL_CAPITAL:,.0f}")
print(f"최종 자본:    ${final_capital:,.0f}")
print(f"총 수익률:    {total_return:+.2f}%")
print(f"총 거래 수:   {len(trades)}회")
print(f"승리:         {len(win_trades)}회")
print(f"패배:         {len(lose_trades)}회")
if trades:
    print(f"승률:         {len(win_trades)/len(trades)*100:.1f}%")
print("-" * 40)
print(f"바이앤홀드:   ${bnh_capital:,.0f} ({bnh_return:+.2f}%)")
print("=" * 40)

# 6. 차트 1 — 전체 기간 라인 차트
ap_full = [
    mpf.make_addplot(df["ema_short"], color="#FF6F00", width=1.5, label=f"EMA {EMA_SHORT}"),
    mpf.make_addplot(df["ema_long"], color="#7B1FA2", width=1.5, label=f"EMA {EMA_LONG}"),
]

mpf.plot(
    df,
    type="line",
    style="charles",
    title=f"BTC/USDT  EMA {EMA_SHORT}/{EMA_LONG} (Full)",
    addplot=ap_full,
    volume=False,
    figsize=(14, 6),
    linecolor="#333333",
)

# 7. 차트 2 — 최근 구간 캔들 차트 (확대)
df_zoom = df.tail(ZOOM_DAYS).copy()

ap_zoom = [
    mpf.make_addplot(df_zoom["ema_short"], color="#FF6F00", width=1.5, label=f"EMA {EMA_SHORT}"),
    mpf.make_addplot(df_zoom["ema_long"], color="#7B1FA2", width=1.5, label=f"EMA {EMA_LONG}"),
]

mpf.plot(
    df_zoom,
    type="candle",
    style="charles",
    title=f"BTC/USDT  EMA {EMA_SHORT}/{EMA_LONG} (Last {ZOOM_DAYS}D)",
    addplot=ap_zoom,
    volume=False,
    figsize=(14, 6),
)

사용법

EMA_SHORTEMA_LONG 값을 수정하면 다른 기간 조합도 테스트할 수 있습니다.


결과 분석

SMA vs EMA 비교

항목 SMA 20/60 EMA 20/60
최종 자본 $151,873 $120,526
총 수익률 +1418.73% +1105.26%
총 거래 수 28회 24회
승률 46.4% 37.5%

 

EMA가 SMA보다 빠르게 반응한다고 했는데, 수익률은 오히려 SMA가 높습니다. 예상과 다른 결과입니다.

 

거래 횟수는 28회에서 24회로 줄었습니다. EMA가 가격에 더 빠르게 반응하면서, SMA에서 발생했던 크로스 중 일부가 걸러진 것으로 보입니다. 여기까지는 예상대로입니다.

 

하지만 승률이 46.4%에서 37.5%로 오히려 떨어졌습니다. 거래는 줄었는데 승률까지 낮아진 건, EMA의 빠른 반응이 양날의 검이었다는 뜻입니다. 추세 전환을 빠르게 잡아주기도 하지만, 일시적인 가격 변동에도 민감하게 반응해서 "아직 추세가 바뀌지 않았는데" 신호가 나오는 경우도 있었던 겁니다. SMA는 느린 대신 이런 노이즈를 무시하고 큰 추세에 더 오래 머물러서, 결과적으로 더 높은 수익을 냈습니다.

 

이건 중요한 교훈입니다. "빠르게 반응한다"가 항상 "더 좋은 결과"를 의미하지는 않습니다. 이동평균의 속도는 트레이드오프입니다. 빠르면 진입/청산이 빨라지지만 거짓 신호도 늘어나고, 느리면 거짓 신호는 줄지만 타이밍이 늦어집니다. 어느 쪽이 유리한지는 시장 환경에 따라 다릅니다.

 

차트만 각각 보면 SMA와 EMA의 차이가 잘 드러나지 않습니다. 두 지표의 차이는 가격이 급변하는 전환점에서 나타나는데, 전체 기간 차트에서는 스케일이 커서 눈에 잘 띄지 않기 때문입니다. 하지만 백테스트 결과에서 보듯, 그 미세한 차이가 8년간 누적되면 +1418% vs +1105%라는 의미 있는 격차로 벌어집니다.


정리

EMA는 SMA의 후행성을 개선하기 위해 최근 가격에 가중치를 부여하는 이동평균입니다. 이론적으로는 더 빠른 반응이 유리할 것 같지만, 이번 테스트에서는 SMA가 더 높은 수익률을 보여줬습니다. 빠른 반응이 노이즈에 대한 민감성으로 이어질 수 있다는 점을 확인했습니다.

 

물론 이건 20/60 크로스, BTC/USDT, 2018~2025라는 특정 조건에서의 결과입니다. 기간이나 설정이 바뀌면 EMA가 앞설 수도 있습니다. 중요한 건 "어떤 MA가 최고"라는 결론이 아니라, 각 MA의 특성을 이해하고 상황에 맞게 선택하는 것입니다.

그렇다면 이 가중치를 더 강하게 주면 어떻게 될까요? 다음 글에서는 EMA를 한 단계 더 발전시킨 DEMA(이중지수이동평균)를 다뤄보겠습니다.


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