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
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
지수 이동 평균(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 에서의 지수 이동 평균이다. 최근 값의 기여도를 결정하는 파라미터 \alpha 는 0 \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) 는 가중 평균이다.
여러 문헌에서 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_\infty 가 SMA(N)
의 무게 중심과 일치하도록 만드는 \alpha 를 구하면
\begin{equation*} \alpha = \frac{1}{C_\infty + 1} = \frac{2}{N+1} \end{equation*}
따라서 EMA(N)
을 상기한 \alpha 값을 갖는 EMA 로 정의하면 (어느 정도는) SMA(N)
과 교환적으로 사용할 수 있다.
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) 이다. 따라서 이 계산에 사용된 \alpha 는 0.5
다.
계산에 최소 timeperiod
개의 데이터를 쓸 수 없는 timeperiod - 1
개의 초깃값들은 NaN
으로 채워졌다.4
Pandas 역시 함수를 제공한다.5
ewm()
함수는 다양한 옵션을 제공하는데, 상기한 호출 방식이 식 (1) 과 TA-Lib 와 유사하다. timeperiod
와 span
은 정확히 같은 역할을 수행하는 값이다.
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년 데이터로 백테스팅할 때와 라이브 데이터로 알고리즘 트레이딩할 때 달라질 수 있다는 것은 숙면을 방해하는 성질이다.
식 (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 이전이라, 예상할 수 있는 문제이기는 하지만, 그 후로 선뜻 선택하지 못하고 있다. 내년에는 트라우마를 극복할 수 있지 않을까?
데이터가 유한하기 때문에, 실질적으로는 유한항 항으로 계산한다.↩︎
엄밀하게 옮기자면 “질량 중심” 이라고 해야겠지만, 그런 구분을 염두에 둔 용례가 아니다. 더 흔히 쓰는 표현을 사용한다.↩︎
TA-Lib 에서는 EMA 의 여러 변종을 제공한다. 그 중 식 (1) 의 정의와 일치하는 것을 talib.set_compatibility(1)
으로 선택할 수 있다. 기본형은 NaN
이 아닌 첫번째 값을 단순 이동 평균(SMA)으로 채운다.↩︎
NaN
으로 채워진 부분도 상기한 식 (1) 에서는 모두 정의된다. 식 (1) 로 계산한 후에 NaN
으로 채워넣었다고 보면 된다. 뒤에 살펴볼 Pandas 함수는 NaN
으로 채우지 않고 초깃값들을 모두 제공한다.↩︎
Polars 역시 ewm_mean()
이라는 유사한 함수를 제공한다.↩︎
min_periods=3
옵션을 추가하면 Ta-Lib 와 같은 결과를 준다.↩︎
Polars 에서는 rolling_mean()
함수를 사용할 수 있는데, 가중치를 weights
인자로 전달하면된다. ↩︎