Insights·2026-08-07

RSI·MACD·볼린저밴드 — 퀀트 보조지표 5종

보조지표는 시가·고가·저가·종가·거래량이라는 1차 데이터에서 한 번 더 계산해 뽑은 2차 값이다. 종류는 수백 개지만 AI 트레이딩 에이전트가 툴 하나로 바로 호출하는 것은 RSI(14)·MACD(12/26/9)·볼린저밴드(20, 2σ)·SMA(20/50/200)·EMA(20) 다섯이고, 이 다섯이 과열·추세전환·변동성·장기추세를 덮는다. 다만 같은 이름이 같은 숫자를 뜻하지는 않는다. RSI는 평활을 Wilder 방식(ewm alpha=1/14)으로 하느냐 단순이동평균(rolling 14)으로 하느냐에 따라, 볼린저밴드는 표준편차를 ddof=1로 재느냐 ddof=0으로 재느냐에 따라 값이 갈린다. 지표를 붙이기 전에 확인할 것은 임계값이 아니라 계산식이다.

보조지표 다섯(RSI 14, MACD 12/26/9, 볼린저 20·2σ, SMA·EMA)의 역할을 정리하고 rolling 단순이동평균과 Wilder ewm 두 계산 방식을 나란히 비교한 요약 도식
에이전트가 툴 하나로 부르는 보조지표 다섯과, 같은 이름이 다른 값을 내는 두 계산 방식

보조지표란 무엇인가

차트를 켜면 캔들 위아래로 선이 여러 개 깔린다. 그게 보조지표다. 영어로는 technical indicator라고 하고, 가격과 거래량에서 한 번 더 계산해 뽑은 2차 값이라는 뜻에서 보조라는 말이 붙었다.

원본 데이터는 하나뿐이다. 봉 하나마다 시가(open), 고가(high), 저가(low), 종가(close), 거래량(volume)이 있고 이 다섯을 묶어 OHLCV라고 부른다. 보조지표는 전부 이 OHLCV에서 나온다. 새로운 정보를 더하는 게 아니라, 사람 눈이 캔들만 봐서는 못 읽는 성질을 숫자로 바꿔 주는 장치다.

그 성질이 크게 넷이다. 지금 너무 올랐거나 너무 빠졌는가(과열), 방향이 바뀌고 있는가(추세 전환), 얼마나 흔들리고 있는가(변동성), 큰 흐름은 어느 쪽인가(장기 추세). 보조지표는 종류가 수백 개지만 대부분 이 넷 중 하나를 다른 방식으로 재는 것이다.

그래서 처음부터 수십 개를 볼 필요가 없다. 넷을 각각 하나씩 덮는 것부터 시작하면 된다.

에이전트가 바로 호출하는 지표는 다섯이다

기준을 하나 잡아 보자. HKUDS(홍콩대 데이터인텔리전스랩)가 공개한 오픈소스 트레이딩 에이전트 Vibe-Trading은 자연어로 지시하면 데이터를 받아 백테스트까지 돌려 주는 도구다. 이 저장소를 열어 보면 에이전트가 툴 하나로 즉시 부르는 지표가 정확히 다섯 개다.

agent/src/tools/technical_indicator_tool.py 파일에 그 다섯이 상수로 박혀 있다. RSI는 14, MACD는 12·26·9, 볼린저밴드는 20일에 표준편차 2배, SMA는 20·50·200, EMA는 20이다. 괄호 안 숫자는 며칠치를 보느냐, 즉 계산 창의 길이다.

이 다섯이 앞의 넷을 정확히 덮는다. RSI가 과열, MACD가 추세 전환, 볼린저밴드가 변동성, SMA와 EMA가 추세를 맡는다. 차트 화면에는 KDJ(9)가 하나 더 붙지만 에이전트가 부르는 도구 쪽에는 없다.

왜 다섯뿐인가. 지표를 늘려도 원본이 OHLCV 하나라 서로 강하게 겹치기 때문이다. 예를 들어 스토캐스틱과 RSI는 재는 대상이 사실상 같고, 여러 개를 겹쳐 놓으면 확신만 늘고 정보는 안 는다. 도구가 다섯에서 멈춘 것은 기능 부족이 아니라 선택으로 읽는 편이 맞다.

지표기본 파라미터재는 것값의 범위
RSI14과매수·과매도0 ~ 100
MACD12 / 26 / 9추세 전환 시점가격 단위(제한 없음)
볼린저밴드20, 2σ변동성 통로가격 단위(상단·중심·하단)
SMA20 / 50 / 200추세선가격 단위
EMA20최근 가중 추세선가격 단위

RSI(14) — 과열을 0에서 100으로 재는 자

RSI는 Relative Strength Index, 상대강도지수다. 최근 14일 동안 오른 날의 힘과 내린 날의 힘을 견줘 하나의 숫자로 만든다.

계산은 네 단계다. 첫째, 전일 대비 변화량을 구한다. 둘째, 그 변화량을 오른 날의 상승폭과 내린 날의 하락폭 두 갈래로 나눈다. 셋째, 각각을 14일 기준으로 평균 낸다. 넷째, 평균 상승폭을 평균 하락폭으로 나눈 값을 RS라 하고 RSI = 100 - 100 / (1 + RS)에 넣는다.

식의 모양 때문에 값은 항상 0과 100 사이에 갇힌다. 14일 내내 오르기만 했으면 평균 하락폭이 0이라 RSI는 100이고, 반대면 0이다. 실제로는 대부분 30에서 70 사이를 오간다.

읽는 법은 관행이다. 70 위는 과매수, 30 아래는 과매도로 본다. 이 두 숫자는 계산식에서 나온 것이 아니라 창시자 J. Welles Wilder가 정해 놓은 관행이고, 그래서 종목이나 시장에 따라 조정해서 쓰는 게 보통이다.

함정이 하나 있다. 강한 상승 추세에서는 RSI가 70 위에 몇 주씩 머문다. 70을 넘었다고 바로 파는 규칙을 걸면 상승장 초입에서 통째로 놓친다. RSI는 방향을 알려 주는 지표가 아니라 강도를 알려 주는 지표라, 추세 지표와 같이 봐야 한다.

MACD(12/26/9) — 두 평균의 거리로 전환을 잡는다

MACD는 Moving Average Convergence Divergence, 이동평균 수렴확산이다. 이름이 길지만 하는 일은 단순하다. 빠른 평균과 느린 평균의 거리를 재는 것이다.

값은 세 개가 나온다. MACD선은 12일 EMA에서 26일 EMA를 뺀 것이다. 단기 평균이 장기 평균보다 위에 있으면 양수, 아래면 음수다. 시그널선은 그 MACD선을 다시 9일 EMA로 평활한 것이다. 히스토그램은 MACD선에서 시그널선을 뺀 값이고, 막대그래프로 그려 두 선의 간격을 눈에 보이게 만든 것이다.

읽는 법은 교차다. MACD선이 시그널선을 아래에서 위로 뚫으면 골든크로스, 매수 쪽 신호로 본다. 위에서 아래로 내려가면 데드크로스다. 히스토그램은 그 교차보다 조금 먼저 0에 가까워지므로 미리 알아채는 데 쓴다.

MACD선이 0을 지나는 것도 따로 본다. 0을 위로 통과했다는 것은 12일 평균이 26일 평균을 추월했다는 뜻이라, 추세 자체가 뒤집혔다는 신호로 읽는다.

함정은 후행성이다. 이동평균은 이미 지나간 가격의 평균이라 실제 전환보다 늦게 반응한다. 그래서 급등락이 잦은 자산에서는 신호가 뒤늦게 나오고, 옆으로 기는 구간에서는 교차가 무의미하게 반복된다. 창 길이를 줄이면 빨라지지만 잡음도 같이 늘어난다.

볼린저밴드(20, 2σ) — 변동성으로 통로를 그린다

볼린저밴드는 세 줄로 이루어진다. 중심선은 20일 단순이동평균이고, 상단은 거기에 20일 표준편차의 2배를 더한 것, 하단은 뺀 것이다. 가격이 그 사이를 지나는 통로처럼 보인다.

핵심은 통로의 폭이 고정이 아니라는 점이다. 표준편차는 변동성의 크기이므로, 가격이 심하게 흔들리면 밴드가 벌어지고 조용하면 좁아진다. 즉 이 지표가 진짜로 보여 주는 것은 가격 위치가 아니라 변동성이다.

그래서 실전에서 가장 많이 보는 것이 스퀴즈다. 밴드 폭이 최근 구간에서 유난히 좁아지면 변동성이 죽었다는 뜻이고, 대개 그다음에 한쪽으로 크게 움직인다. 방향은 알려 주지 않고 크기만 예고한다.

표준편차 2배라는 숫자에도 근거가 있다. 값이 정규분포를 따른다면 평균 ± 2 표준편차 안에 약 95%가 들어온다. 다만 실제 가격 변화는 정규분포가 아니라 꼬리가 두껍기 때문에, 밴드 밖으로 나가는 일이 이론값보다 훨씬 자주 일어난다.

그래서 밴드 터치 자체는 신호가 아니다. 상단을 찍었다고 파는 규칙은 강한 상승장에서 계속 틀린다. 밴드를 타고 오르는 현상에 밴드 워크라는 이름이 따로 있을 정도다. 밴드는 위치 판단이 아니라 변동성 국면 판단에 쓰는 편이 맞다.

SMA(20/50/200)·EMA(20) — 가장 오래되고 가장 많이 쓰는 추세선

SMA는 Simple Moving Average, 단순이동평균이다. 최근 N일 종가를 그냥 더해서 N으로 나눈다. 20일선은 한 달, 50일선은 한 분기, 200일선은 대략 1년의 거래일에 해당한다.

200일선이 특별대우를 받는 이유는 계산식이 아니라 관행이다. 많은 기관이 이 선을 장기 추세의 기준으로 쓰기 때문에, 가격이 200일선 위에 있느냐 아래에 있느냐가 그 자체로 시장 참여자들의 행동에 영향을 준다.

EMA는 Exponential Moving Average, 지수이동평균이다. SMA가 20일치를 똑같은 무게로 취급하는 반면 EMA는 최근 가격에 더 큰 무게를 준다. 오래된 가격의 영향력이 지수적으로 줄어들기 때문에 방향 전환에 더 빨리 반응한다. 대신 잡음에도 더 빨리 반응한다.

두 선을 겹쳐 쓰는 관행이 골든크로스와 데드크로스다. 짧은 평균선이 긴 평균선을 위로 뚫으면 골든크로스, 아래로 뚫으면 데드크로스다. 흔히 쓰는 조합이 50일선과 200일선이다.

함정은 앞의 MACD와 같다. 평균은 지나간 값이라 반드시 늦는다. 골든크로스는 상승이 이미 상당히 진행된 뒤에 나오는 경우가 많고, 횡보 구간에서는 두 선이 얽히며 신호가 연달아 뒤집힌다.

pandas로 다섯 개 전부 직접 계산하기

라이브러리를 따로 안 깔아도 pandas만으로 다섯 개가 다 나온다. 입력은 종가 하나뿐이고, 함수 다섯 개가 전부 열 줄 안쪽이다.

아래 코드를 indicators.py로 저장하고 종가 시계열(pandas Series)을 넣으면 된다. 종가는 yfinance나 pykrx 같은 라이브러리로 받아도 되고, 증권사에서 받은 CSV를 pandas로 읽어도 된다.

ewm의 adjust=False가 중요하다. 기본값인 True는 초기 구간을 보정하는 다른 식을 쓰기 때문에, 이 값을 빼먹으면 앞쪽 몇십 개 봉의 EMA가 다른 도구와 안 맞는다.

함수를 다 만들었으면 값이 맞는지 한 번 확인하는 편이 좋다. 같은 종목을 증권사 앱이나 TradingView에 띄워 같은 날짜의 RSI를 비교해 보면 된다. 이때 숫자가 안 맞을 수 있는데, 그 이유가 다음 절이다.

indicators.py
import pandas as pd


def rsi(close: pd.Series, period: int = 14) -> pd.Series:
    delta = close.diff()
    gain = delta.clip(lower=0)
    loss = (-delta).clip(lower=0)
    avg_gain = gain.ewm(alpha=1 / period, adjust=False).mean()
    avg_loss = loss.ewm(alpha=1 / period, adjust=False).mean()
    return 100 - 100 / (1 + avg_gain / avg_loss)


def macd(close, fast=12, slow=26, signal=9):
    line = (close.ewm(span=fast, adjust=False).mean()
            - close.ewm(span=slow, adjust=False).mean())
    sig = line.ewm(span=signal, adjust=False).mean()
    return pd.DataFrame({"macd": line, "signal": sig, "hist": line - sig})


def bollinger(close, period=20, num_std=2.0, ddof=0):
    mid = close.rolling(period).mean()
    sd = close.rolling(period).std(ddof=ddof)
    return pd.DataFrame({"upper": mid + num_std * sd,
                         "middle": mid,
                         "lower": mid - num_std * sd})


def sma(close, period):
    return close.rolling(period).mean()


def ema(close, period):
    return close.ewm(span=period, adjust=False).mean()
사용
import yfinance as yf
from indicators import rsi, macd, bollinger, sma, ema

close = yf.download("AAPL", period="1y")["Close"].squeeze()

print("RSI(14) :", round(rsi(close).iloc[-1], 2))
print("MACD    :", macd(close).iloc[-1].round(4).to_dict())
print("BB(20,2):", bollinger(close).iloc[-1].round(2).to_dict())
print("SMA200  :", round(sma(close, 200).iloc[-1], 2))
print("EMA20   :", round(ema(close, 20).iloc[-1], 2))

같은 이름이 같은 숫자가 아니다 — RSI 평활 두 갈래

여기가 이 글에서 제일 중요한 대목이다. RSI 14라고 적혀 있어도 도구마다 값이 다르게 나올 수 있다. 세 번째 단계, 즉 상승폭과 하락폭을 평균 내는 방식이 두 갈래이기 때문이다.

하나는 Wilder 방식이다. 창시자가 원래 제시한 방법이고, 지수가중이동평균에 alpha = 1 / 14를 준다. 과거 값이 완전히 사라지지 않고 계속 조금씩 남는다. TA-Lib이 이 방식이다 — 소스 ta_RSI.c의 주석이 "Wilder's approach"라고 적고, 직전 평균에 period-1을 곱해 오늘 값을 더한 뒤 period로 나눈다(2026-08-07 소스 확인).

다른 하나는 단순이동평균 방식이다. 최근 14일만 똑같은 무게로 평균 내고 15일 전 값은 버린다. Cutler's RSI라는 별도의 이름이 있을 만큼 다른 지표로 취급된다. 값은 Wilder 방식보다 더 민감하게 튀고, 계산 창을 벗어나는 값이 갑자기 빠지면서 값이 점프하기도 한다.

이게 남 얘기가 아니라는 것을 앞서 본 Vibe-Trading 저장소가 보여 준다. 지표 툴 technical_indicator_tool.py의 RSI 함수는 rolling(14).mean()으로 계산한다. 그런데 같은 저장소의 전략 스킬 문서 technical-basic/SKILL.md는 RSI와 ADX가 Wilder EWM, 즉 ewm(alpha=1/period)을 쓴다고 명시한다. 한 저장소 안에서 두 방식이 공존한다(2026-08-07 소스 확인).

어느 쪽이 틀렸다는 얘기가 아니다. 둘 다 실제로 쓰이는 정의다. 문제는 이 차이를 모른 채 한쪽에서 백테스트하고 다른 쪽에서 실매매하면, 같은 규칙인데 진입 시점이 달라진다는 데 있다. RSI가 69.4냐 71.2냐가 매매 조건이라면 그 차이가 곧 다른 결과다.

두 갈래를 나란히 계산해 차이를 본다
wilder = gain.ewm(alpha=1 / 14, adjust=False).mean()   # Wilder 원본 · TA-Lib ta_RSI.c
cutler = gain.rolling(14).mean()                       # Cutler's RSI · 단순이동평균

# 같은 종가로 두 RSI를 만들어 마지막 값을 비교해 본다
print(round(rsi_wilder(close).iloc[-1], 2),
      round(rsi_cutler(close).iloc[-1], 2))

볼린저밴드도 같다 — 표준편차 자유도 ddof

표준편차를 구할 때 편차 제곱의 합을 무엇으로 나누느냐가 두 갈래다. 표본 수 n으로 나누면 모표준편차(ddof=0), n - 1로 나누면 표본표준편차(ddof=1)다. 통계학에서는 표본으로 모집단을 추정할 때 n - 1을 쓰는 것이 관행이다.

pandas의 rolling(20).std()는 기본값이 ddof=1이다. 아무 인자도 안 주면 표본표준편차가 나온다는 뜻이다.

TA-Lib은 다르다. 소스의 ta_BBANDS.c를 보면 밴드 폭 계산이 ta_VAR.c의 분산 식으로 내려가는데, 그 식이 variance = sum(x²)/n - mean²이다. n으로 나누는 모분산이고 따라서 ddof=0이다(2026-08-07 소스 확인).

차이는 계산할 수 있다. 표본표준편차는 모표준편차에 sqrt(n / (n - 1))을 곱한 값이다. n이 20이면 sqrt(20 / 19) = 1.0260이므로, pandas 기본값으로 그린 밴드는 TA-Lib으로 그린 밴드보다 반폭이 2.60% 넓다.

2.60%가 작아 보이지만 밴드 터치를 매매 조건으로 걸어 두면 얘기가 달라진다. 가격이 밴드 근처에 있는 날에는 한쪽에서는 조건이 걸리고 다른 쪽에서는 안 걸린다. 백테스트 결과가 실매매와 안 맞을 때 전략을 의심하기 전에 여기부터 볼 필요가 있는 이유다.

맞추는 방법은 간단하다. 어느 쪽을 쓰든 상관없지만 백테스트와 실매매가 같아야 하므로, 코드에 ddof를 명시해 둔다. 앞 절의 bollinger 함수에 ddof=0을 기본값으로 넣어 둔 것이 그 뜻이다.

ddof 차이를 눈으로 확인
sd_sample = close.rolling(20).std()          # pandas 기본값 · ddof=1
sd_pop = close.rolling(20).std(ddof=0)       # TA-Lib과 같은 모표준편차

print(round(sd_sample.iloc[-1] / sd_pop.iloc[-1], 4))   # 1.026 = sqrt(20 / 19)

지표를 붙이기 전 체크리스트

첫째, 창 길이를 명시한다. RSI 14, MACD 12/26/9, 볼린저 20 같은 값은 기본값일 뿐이고 도구마다 다를 수 있다. 코드에 인자로 적어 두면 나중에 값이 안 맞을 때 원인을 좁힐 수 있다.

둘째, 평활 방식을 확인한다. RSI와 ADX처럼 Wilder가 만든 지표는 ewm(alpha=1/period)이 원래 정의다. 라이브러리 문서에 뭐라 적혀 있든 소스에서 rolling인지 ewm인지 직접 본다. 1분이면 된다.

셋째, ddof를 명시한다. 표준편차를 쓰는 모든 지표에 해당한다. 볼린저밴드뿐 아니라 변동성 자체를 지표로 쓰는 경우에도 같은 문제가 생긴다.

넷째, EMA의 adjust를 확인한다. pandas ewm의 기본값 adjust=True는 초기 구간을 다르게 계산한다. 다른 도구와 맞추려면 adjust=False다.

다섯째, 지표 하나로 매매 규칙을 만들지 않는다. 앞서 본 Vibe-Trading의 technical-basic 스킬도 추세(EMA와 ADX), 평균회귀(볼린저밴드와 RSI), 거래량(OBV와 거래량비율) 세 축을 각각 계산한 뒤 투표로 합쳐 하나의 신호를 만든다. 세 축이 같은 방향을 가리킬 때만 진입하고 엇갈리면 관망하는 구조다.

정리하면 이렇다. 보조지표를 배우는 일의 절반은 각 지표가 무엇을 재는지 아는 것이고, 나머지 절반은 내가 쓰는 도구가 그것을 어떻게 계산하는지 아는 것이다. 두 번째를 건너뛰면 숫자는 나오는데 그 숫자가 무슨 뜻인지 모르는 상태가 된다.