Truncated EMA

Quant
Python
공개

2025-04-09

초록

금융 데이터를 분석할 때 지수 이동 평균을 종종 사용한다. 하지만 데이터 길이 의존성이라는 거슬리는 특성이 있다. 이를 제거한 Truncated EMA 라는 지표를 정의하고, 계산법을 확인한다.

import sys; print(sys.version)
import numpy as np; print(np.__version__)
import pandas as pd; print(pd.__version__)
import talib; print(talib.__version__)
3.12.1 (main, Oct 16 2024, 18:21:33) [GCC 9.4.0]
2.1.2
2.2.3
0.6.3

EMA

지수 이동 평균(Exponential Moving Average)을 정의하는 것으로 시작하자.

\begin{equation} \begin{split} y_0 &= x_0\\ y_t &= (1 - \alpha) y_{t-1} + \alpha x_t \end{split} \end{equation}

x_t 는 시간 t 에서의 데이터 값이고, y_t 는 시간 t 에서의 지수 이동 평균이다. 최근 값의 기여도를 결정하는 파라미터 \alpha0 \lt \alpha \le 1 를 만족한다.

일반항으로 표현하면 이렇게된다.

\begin{equation} \begin{split} y_0 &= x_0\\ y_t &= (1 - \alpha)^t x_0 + \alpha \sum\limits_{i=1}^t (1-\alpha)^{t-i} x_i \end{split} \end{equation}

계수의 합은 항상 1이 된다. 따라서 식 (2) 는 가중 평균이다.

Span

여러 문헌에서 EMA(N) 이라는 표기법이 자주 사용된다. 비슷한 표기법은 단순 이동 평균에 대해서도 사용되고 있다. 가령 SMA(3) 은 최근 3개의 데이터를 평균한다는 뜻이다. 즉 N 은 이동 평균에 사용하는 데이터의 범위(span)다. 하지만 지수 이동 평균은 무한한1 항을 사용하는 가중 평균이기 때문에, 범위가 고정되지 않는다.

EMA(N)N 은 일종의 “유효(effective) 범위”다. EMA(N)SMA(N) 이 가중치 분포의 어떤 특성을 공유하도록 만들어서, SMA(N)EMA(N) 을 바꿔 사용하기 편리하도록 만들려는 것이다. 이 “어떤 특성” 이 “무게 중심(Center of Mass)”이다.2 무게 중심이란 대략 가중치가 어디를 중심으로 분포하는지에 대한 측정이다. 가중 평균에 사용되는 과거 데이터들과의 거리를 같은 가중치로 평균한 것이다.

가령 SMA(N) 의 가충치는 모두 \frac{1}{N} 이고, 거리는 N-1, N-2, ..., 1, 0 이다. 따라서 무게 중심은 이렇게 구할 수 있다.

\begin{equation*} \frac{1}{N} \sum\limits_{i=0}^{N-1} i = \frac{N-1}{2} \end{equation*}

마찬가지로, EMA 의 무게 중심은 이렇게 된다.

\begin{split} C_t &= (1 - \alpha)^t t + \alpha \sum\limits_{i=1}^t (1-\alpha)^{t-i} (t-i)\\ &= \frac{1-\alpha}{\alpha} \Bigl( 1-(1-\alpha)^t \Bigr) \end{split}

흔히 사용되는 값은 C_t 의 극한값이다.

\begin{equation*} C_\infty = \lim_{t\to\infty} C_t = \frac{1-\alpha}{\alpha} \end{equation*}

C_\inftySMA(N) 의 무게 중심과 일치하도록 만드는 \alpha 를 구하면

\begin{equation*} \alpha = \frac{1}{C_\infty + 1} = \frac{2}{N+1} \end{equation*}

따라서 EMA(N) 을 상기한 \alpha 값을 갖는 EMA 로 정의하면 (어느 정도는) SMA(N) 과 교환적으로 사용할 수 있다.

TA-Lib

TA-Lib 를 사용하면 간단하게 계산할 수 있다.3

close = pd.Series([0, 1, 2, 3, 4])

talib.set_compatibility(1)  # EMA 종류 선택
talib.EMA(close, timeperiod=3)
0       NaN
1       NaN
2    1.2500
3    2.1250
4    3.0625
dtype: float64

timeperiod 는 앞 절에서 소개한 span(N) 이다. 따라서 이 계산에 사용된 \alpha0.5 다.

계산에 최소 timeperiod 개의 데이터를 쓸 수 없는 timeperiod - 1 개의 초깃값들은 NaN 으로 채워졌다.4

Pandas

Pandas 역시 함수를 제공한다.5

close.ewm(span=3, adjust=False).mean()
0    0.0000
1    0.5000
2    1.2500
3    2.1250
4    3.0625
dtype: float64

ewm() 함수는 다양한 옵션을 제공하는데, 상기한 호출 방식이 식 (1) 과 TA-Lib 와 유사하다. timeperiodspan 은 정확히 같은 역할을 수행하는 값이다.

TA-Lib 와는 달리 초깃값들을 NaN 으로 채우지 않고 식 (1) 이 정의하는 모든 값들을 결과로 제공한다.6

데이터 길이 의존성

앞에서도 언급 했듯이 EMA 는 무한항을 사용하기 때문에, 계산에 사용할 데이터를 얼마나 로드하는지에 따라 결과가 달라진다.

데이터의 길이를 두배로 늘려보자.

close = pd.Series([0, 1, 2, 3, 4, 0, 1, 2, 3, 4])

df = pd.DataFrame(
    {
        "x": close,
        "SMA(3)": talib.SMA(close, timeperiod=3),
        "EMA(3)": talib.EMA(close, timeperiod=3),
    }
)
df
x SMA(3) EMA(3)
0 0 NaN NaN
1 1 NaN NaN
2 2 1.000000 1.250000
3 3 2.000000 2.125000
4 4 3.000000 3.062500
5 0 2.333333 1.531250
6 1 1.666667 1.265625
7 2 1.000000 1.632812
8 3 2.000000 2.316406
9 4 3.000000 3.158203

EMA 에서는 SMA 에서와 달리 인덱스 값이 2,3,4 일 때와 7,8,9 일 때의 이동 평균 값이 달라진다. 식 (1) 에서 x_0 는 데이터의 처음을 뜻한다. 이는 우리가 어느 시점 부터의 데이터를 로드해서 계산할 것인지에 따라 y_t 가 달라진다는 뜻이다. 가령 최근 한달간의 주식 가격 데이터로 계산한 어제 EMA 와 최근 일년간의 주식 가격 데이터로 계산한 어제 EMA 가 다르다는 뜻이다. 큰 차이는 나지 않겠지만, 최근 10년 데이터로 백테스팅할 때와 라이브 데이터로 알고리즘 트레이딩할 때 달라질 수 있다는 것은 숙면을 방해하는 성질이다.

Truncated EMA

식 (2) 를 변형하여, n 개의 항만 포함하도록 만들자.

y_{n-1}n 개의 항으로 이루어진다. 각 항의 계수를 w_k 라고 하면

\begin{split} w_0 &= (1 - \alpha)^{n-1} \\ w_k &= \alpha (1 - \alpha)^{n-1-k} \end{split}

\begin{equation*} \sum\limits_{k=0}^{n-1} w_k = 1 \end{equation*}

이제 Truncated EMA 는 이 가중치를 사용한 가중 이동 평균으로 정의한다.

\begin{equation*} z_t = \sum\limits_{i=t-n+1}^t w_{i-t+n-1} x_i \end{equation*}

가중치를 사용하면 Pandas 에서 쉽게 Truncated EMA 를 계산할 수 있다.

가중치를 계산하는 함수는 이런식으로 구성할 수 있다.

def truncated_ema_weights(n: int, alpha: float = None) -> list[float]:
    if alpha is None:
        alpha = 2 / (n + 1)
    b = 1 - alpha
    c = b ** (n - 1)
    w = [c]
    c *= alpha
    for i in range(1, n):
        c /= b
        w.append(c)
    return w


truncated_ema_weights(3)
[0.25, 0.25, 0.5]

\alpha\frac{2}{n+1} 로 고정해야 할 필요는 없다. 하지만 지정하지 않으면 EMA 의 관례를 따르도록 한다.

이제 Pandas 의 Rolling apply 에 전달할 수 있는 함수 팩토리를 만든다.7

def truncated_ema(n: int, alpha: float = None):
    weights = np.array(truncated_ema_weights(n, alpha))

    return weights.dot


df["Truncated EMA(3)"] = close.rolling(3).apply(truncated_ema(3), raw=True)
df
x SMA(3) EMA(3) Truncated EMA(3)
0 0 NaN NaN NaN
1 1 NaN NaN NaN
2 2 1.000000 1.250000 1.25
3 3 2.000000 2.125000 2.25
4 4 3.000000 3.062500 3.25
5 0 2.333333 1.531250 1.75
6 1 1.666667 1.265625 1.50
7 2 1.000000 1.632812 1.25
8 3 2.000000 2.316406 2.25
9 4 3.000000 3.158203 3.25

맺음말

시작할 때 생각했던 것과는 조금 다른 곳에 도착했다.

원래는 EMA 를 먼저 계산한 다음, n 시간 이전 값과의 재귀적 관계를 활용하여 후처리하는 방식을 사용해 왔다. 이 방법에 대한 기록을 남겨, 미래의 기억을 보조하려는 것이 애초의 계획이었다. 하지만 결정적으로 방향이 바뀌게된 것은, Polars 의 rolling_mean()weights 인자가 있음을 발견했을 때다. 그럼에도 Pandas 를 위주로 작업한 것은, 인터페이스 불안정성 때문이다. 2023년 후반에 한 지자체를 위해 Polars 로 작업한 적이 있다. 한 10개월쯤 후에 AS 요청이 있어 들여다보니, Polars 의 최신 버전과 호환되지 않는 부분이 상당함을 발견했다. 프로젝트 당시의 버전이 1.0 이전이라, 예상할 수 있는 문제이기는 하지만, 그 후로 선뜻 선택하지 못하고 있다. 내년에는 트라우마를 극복할 수 있지 않을까?

각주

  1. 데이터가 유한하기 때문에, 실질적으로는 유한항 항으로 계산한다.↩︎

  2. 엄밀하게 옮기자면 “질량 중심” 이라고 해야겠지만, 그런 구분을 염두에 둔 용례가 아니다. 더 흔히 쓰는 표현을 사용한다.↩︎

  3. TA-Lib 에서는 EMA 의 여러 변종을 제공한다. 그 중 식 (1) 의 정의와 일치하는 것을 talib.set_compatibility(1) 으로 선택할 수 있다. 기본형은 NaN 이 아닌 첫번째 값을 단순 이동 평균(SMA)으로 채운다.↩︎

  4. NaN 으로 채워진 부분도 상기한 식 (1) 에서는 모두 정의된다. 식 (1) 로 계산한 후에 NaN 으로 채워넣었다고 보면 된다. 뒤에 살펴볼 Pandas 함수는 NaN 으로 채우지 않고 초깃값들을 모두 제공한다.↩︎

  5. Polars 역시 ewm_mean() 이라는 유사한 함수를 제공한다.↩︎

  6. min_periods=3 옵션을 추가하면 Ta-Lib 와 같은 결과를 준다.↩︎

  7. Polars 에서는 rolling_mean() 함수를 사용할 수 있는데, 가중치를 weights 인자로 전달하면된다. ↩︎