파이썬 RSI 계산 — TA-Lib·pandas-ta 값이 다른 이유
RSI(14)인데 TA-Lib·pandas-ta·TradingView 값이 서로 다른 것은 버그가 아닙니다. 원인은 딱 세 가지입니다 — ① 평활(smoothing) 방식이 다르고(Wilder의 alpha = 1/14 vs 일반 EMA의 alpha = 2/15), ② 계산을 시작한 데이터 지점이 다르고, ③ 아직 끝나지 않은 봉을 넣었기 때문입니다. 해결은 라이브러리를 바꾸는 게 아니라 Wilder 방식으로 통일 + 워밍업 구간 확보 + 확정된 봉만 사용 세 가지를 파이프라인 전체에 못 박는 것입니다.
자동매매를 만들다 보면 반드시 한 번은 겪는 장면이 있습니다. TradingView 차트에는 RSI가 28.4로 찍혀 있는데, 파이썬으로 계산해 보면 29.1이 나옵니다. talib과 pandas_ta를 둘 다 돌려 보면 이번엔 그 둘끼리도 값이 다릅니다.
이때 대부분 "내 코드가 틀렸나" 하고 계산식을 다시 짜기 시작하는데, 거의 모든 경우 계산식은 멀쩡합니다. 세 값 모두 각자의 규칙대로 정확하게 계산된 값입니다. 문제는 규칙이 셋 다 다르다는 데 있습니다.
이 글은 그 차이가 어디서 생기는지, 그리고 백테스트와 실매매의 지표를 어떻게 하나로 맞추는지를 실제 코드로 정리합니다. 백테스팅 파이썬 라이브러리 5종 비교가 "어떤 엔진을 쓸까"였다면, 이 글은 그 엔진에 넣는 숫자 자체가 흔들리는 문제를 다룹니다.
목차
- 같은 RSI(14)인데 값이 세 개 나온다
- 원인 ① 평활 방식 — Wilder와 EMA는 다른 계산이다
- 세 값을 맞추는 코드
- 원인 ② 시작점 — TA-Lib의 unstable period
- 원인 ③ 아직 끝나지 않은 봉
- 라이브러리 선택 기준
- 국내 데이터로 실제 계산해 보기
- 배포 전 체크리스트 6가지
1. 같은 RSI(14)인데 값이 세 개 나온다
먼저 현상을 재현해 봅시다. 같은 pandas.Series 하나를 놓고 세 가지 방식으로 RSI(14)를 계산합니다.
import numpy as np
import pandas as pd
import talib # TA-Lib (C 기반)
import pandas_ta as ta # 순수 파이썬 계열
close = df["close"] # 일봉 종가 Series
a = talib.RSI(close.values, timeperiod=14) # ① TA-Lib
b = ta.rsi(close, length=14) # ② pandas-ta
# ③ 지수이동평균으로 "대충" 구현한 흔한 코드
delta = close.diff()
gain = delta.clip(lower=0)
loss = -delta.clip(upper=0)
rs = gain.ewm(span=14, adjust=False).mean() / loss.ewm(span=14, adjust=False).mean()
c = 100 - 100 / (1 + rs)
print(a[-1], b.iloc[-1], c.iloc[-1])
①과 ②는 대체로 소수점 아래에서만 갈리거나 버전에 따라 일치하기도 하지만, ③은 눈에 띄게 다른 값이 나옵니다. 경계값 근처라면 신호가 나느냐 안 나느냐가 갈릴 만큼의 차이입니다.
왜 이게 치명적인가. RSI < 30에서 매수하는 전략을 생각해 보세요. 백테스트 엔진은 29.6으로 계산해 매수 신호를 냈는데, 실매매 봇은 같은 시점을 30.2로 계산해 아무 것도 하지 않습니다. 두 결과가 다른 이유를 슬리피지나 체결 지연에서 찾으면 영원히 못 찾습니다. 애초에 본 숫자가 달랐던 것이기 때문입니다.
2. 원인 ① 평활 방식 — Wilder와 EMA는 다른 계산이다
RSI는 상승분 평균과 하락분 평균의 비율입니다. 여기서 "평균"을 어떻게 내느냐가 갈림길입니다.
Welles Wilder가 원래 제시한 방식은 이렇습니다 — 첫 평균은 단순평균(SMA)으로 잡고, 그 뒤부터는 새 평균 = (직전 평균 × 13 + 이번 값) / 14로 갱신합니다. 지수평활의 일종이지만 감쇠 계수가 alpha = 1/14 ≈ 0.0714입니다.
반면 pandas의 ewm(span=14)는 일반적인 지수이동평균이라 alpha = 2/(14+1) ≈ 0.1333을 씁니다. 감쇠 계수가 거의 두 배 차이입니다. 최근 값에 훨씬 민감하게 반응하니 같은 데이터에서도 다른 숫자가 나올 수밖에 없습니다.
중요한 사실 하나. TradingView를 비롯해 대부분의 거래 화면은 RSI·ATR·ADX 계열에 Wilder 방식을 기본으로 씁니다. TradingView의 Pine Script에서 이 평활을 ta.rma()라는 별도 함수로 부르는 이유가 그것입니다. 즉 차트에서 눈으로 보고 정한 전략을 봇으로 옮길 생각이라면, 기준은 Wilder 쪽입니다.
3. 세 값을 맞추는 코드
Wilder 평활은 pandas만으로 정확히 재현할 수 있습니다. 핵심은 ewm에 span이 아니라 alpha=1/period를 넘기고 adjust=False를 주는 것입니다.
def wilder_rsi(close: pd.Series, period: int = 14) -> pd.Series:
"""TradingView·TA-Lib과 동일한 규칙의 RSI."""
delta = close.diff()
gain = delta.where(delta > 0, 0.0)
loss = -delta.where(delta < 0, 0.0)
# 첫 평균은 단순평균(SMA), 그 뒤는 alpha = 1/period 지수평활
avg_gain = gain.ewm(alpha=1/period, adjust=False, min_periods=period).mean()
avg_loss = loss.ewm(alpha=1/period, adjust=False, min_periods=period).mean()
rs = avg_gain / avg_loss
rsi = 100 - (100 / (1 + rs))
return rsi.rename(f"RSI_{period}")
# TA-Lib과 대조 — 워밍업 구간을 잘라내고 비교한다
mine = wilder_rsi(close, 14)
ref = pd.Series(talib.RSI(close.values, 14), index=close.index)
diff = (mine - ref).abs().iloc[200:]
print(diff.max()) # 워밍업 이후 구간에서는 거의 0에 수렴
검증 습관 하나만 들이세요. 새 라이브러리를 도입할 때마다 위처럼 기존 계산과 절대오차를 찍어 보는 것입니다. diff.max()가 0.001 수준이면 같은 규칙, 1을 넘어가면 다른 지표를 쓰고 있는 것입니다. 이 한 줄이 나중에 며칠치 디버깅을 아껴 줍니다.
4. 원인 ② 시작점 — TA-Lib의 unstable period
평활 방식을 맞췄는데도 값이 미세하게 다르다면, 이번엔 데이터를 어디서부터 넣었는지를 봐야 합니다.
RSI·EMA·ATR·ADX처럼 지수평활 계열 지표는 과거 값이 계속 누적되는 구조입니다. 첫 값이 이후 모든 값에 영향을 주고, 그 영향이 봉이 쌓일수록 옅어지긴 하지만 완전히 0이 되지는 않습니다. 그래서 2026년 1월부터 데이터를 넣어 계산한 8월 6일의 RSI와, 2024년부터 넣어 계산한 8월 6일의 RSI는 다릅니다.
TA-Lib은 이 성질을 unstable period라는 이름으로 문서에 명시하고 있고, 함수로 그 길이를 조정할 수도 있습니다.
import talib
from talib import MA_Type
# 앞쪽 N개를 "불안정 구간"으로 보고 NaN 처리하도록 설정
talib.set_unstable_period("RSI", 250)
talib.set_unstable_period("EMA", 250)
# 실무 규칙: 필요한 구간 앞에 워밍업을 붙여서 조회하고, 계산 후 잘라낸다
WARMUP = 250
raw = fetch_ohlcv(code="005930", start=start_date - BDay(WARMUP), end=end_date)
rsi = wilder_rsi(raw["close"], 14)
signal_df = rsi.loc[start_date:] # 워밍업 구간은 신호 판단에서 제외
가장 흔한 사고 패턴. 백테스트는 3년치 데이터를 한 번에 읽어 지표를 계산하는데, 실매매 봇은 매일 아침 최근 100봉만 조회해서 계산합니다. 평활 방식을 아무리 맞춰도 이 둘은 영원히 다른 값을 냅니다. 봇 쪽에도 반드시 같은 길이의 워밍업을 주세요. 데이터 조회 비용이 아까워 봉 수를 줄이는 순간 백테스트는 무의미해집니다.
5. 원인 ③ 아직 끝나지 않은 봉
세 번째는 계산식과 무관한, 순수한 설계 실수입니다. 장중 14시에 그날의 일봉으로 RSI를 계산하면 그 값은 15시 30분까지 계속 변합니다. 종가가 아직 확정되지 않았기 때문입니다.
백테스트는 언제나 완성된 봉만 봅니다. 그래서 이 차이는 성과 차이로 곧장 이어지고, 흔히 리페인팅이라 부르는 문제로 나타납니다. 화면에서는 신호가 떴다가 장 마감 즈음 사라지는데, 봇은 이미 주문을 낸 뒤입니다.
# ❌ 위험: 마지막 봉이 아직 진행 중일 수 있다
latest_rsi = wilder_rsi(df["close"], 14).iloc[-1]
# ✅ 안전: 확정된 직전 봉으로만 판단한다
closed = df[df["is_closed"]] # 또는 iloc[:-1]
latest_rsi = wilder_rsi(closed["close"], 14).iloc[-1]
if latest_rsi < 30:
place_order(...) # 신호는 확정 봉 기준, 주문은 다음 봉 시가에 체결된다고 가정
분봉 전략일수록 이 문제가 큽니다. 1분봉이면 최대 59초 동안 사실이 아닌 값을 보고 있는 셈이고, 진입 타이밍이 전략 성패를 좌우하는 단타·스캘핑 구간에서는 그대로 성과 왜곡이 됩니다. 이 어긋남이 성과에 어떻게 나타나는지는 백테스트와 실매매의 괴리 쪽에 따로 정리해 뒀습니다.
6. 라이브러리 선택 기준
"그래서 뭘 쓰면 되나"에 대한 답은 성능이 아니라 규칙의 명확성을 기준으로 고르는 것입니다.
| 도구 | 성격 | 설치 | 주의할 점 |
|---|---|---|---|
TA-Lib |
C 라이브러리 + 파이썬 래퍼. 사실상 업계 기준 | C 빌드가 필요해 번거로움 | unstable period 개념을 이해하고 써야 함. numpy 배열을 받으므로 인덱스가 사라짐 |
pandas-ta |
순수 파이썬. DataFrame에 바로 붙는 문법 |
pip 한 줄 |
버전에 따라 기본 계산 방식·함수 시그니처가 달라질 수 있어 버전 고정 필수 |
| 직접 구현 | 위 wilder_rsi 같은 함수 몇 개 |
불필요 | 지표가 늘어나면 관리 부담. 대신 규칙이 코드에 그대로 보임 |
backtrader |
백테스트 엔진. 자체 지표 + TA-Lib 연동 모두 지원 |
pip |
내장 지표와 TA-Lib 지표가 다른 값을 낼 수 있어 어느 쪽을 쓸지 명시해야 함 |
vectorbt |
벡터 연산 기반 대량 백테스트 | pip |
파라미터 조합을 대량으로 돌리기 쉬운 만큼 과최적화 위험도 함께 커짐 |
실무 권장. 지표는 TA-Lib 또는 직접 구현한 Wilder 함수 하나로 통일하고, 그 결과를 backtrader·vectorbt·실매매 봇이 모두 같은 함수에서 가져다 쓰게 만드세요. 엔진마다 내장 지표를 쓰는 순간 값이 갈라집니다.
7. 국내 데이터로 실제 계산해 보기
국내 주식이라면 데이터 출처가 하나 더 변수입니다. pykrx·FinanceDataReader·증권사 API가 각각 수정주가 처리 방식이 다를 수 있고, 그러면 같은 종목·같은 날짜인데 종가가 달라 RSI도 달라집니다. 출처별 차이는 파이썬 주식 데이터 수집 비교에 정리해 뒀습니다.
실매매에 쓸 값이라면 주문을 낼 곳과 같은 곳에서 데이터를 받는 것이 원칙입니다. 한국투자증권 KIS API로 일봉을 받는다면 국내주식기간별시세 tr_id: FHKST03010100을 씁니다.
import requests, pandas as pd
url = "https://openapi.koreainvestment.com:9443/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice"
headers = {
"authorization": f"Bearer {access_token}",
"appkey": APP_KEY,
"appsecret": APP_SECRET,
"tr_id": "FHKST03010100", # 국내주식 기간별시세(일/주/월/년)
}
params = {
"FID_COND_MRKT_DIV_CODE": "J", # J = 주식
"FID_INPUT_ISCD": "005930", # 삼성전자
"FID_INPUT_DATE_1": "20250101", # 워밍업 포함해 넉넉히
"FID_INPUT_DATE_2": "20260806",
"FID_PERIOD_DIV_CODE": "D", # D = 일봉
"FID_ORG_ADJ_PRC": "0", # 0 = 수정주가 반영
}
res = requests.get(url, headers=headers, params=params, timeout=5).json()
if res.get("rt_cd") != "0": # 200이어도 rt_cd로 성패를 판정한다
raise RuntimeError(res.get("msg1"))
df = pd.DataFrame(res["output2"])
df["close"] = pd.to_numeric(df["stck_clpr"])
df = df.iloc[::-1].reset_index(drop=True) # 응답은 최신순 → 시간순으로 뒤집기
rsi = wilder_rsi(df["close"], 14)
여기서 자주 나는 실수 두 개. ① KIS API 응답은 최신 날짜가 앞에 옵니다. 뒤집지 않고 계산하면 RSI가 시간을 거꾸로 흐른 값이 됩니다. ② FID_ORG_ADJ_PRC를 잘못 주면 액면분할 구간에서 가격이 튀어 지표가 무너집니다. 파라미터 규격과 응답 필드는 개정될 수 있으니 KIS Developers 공식 문서에서 현재 값을 확인하시기 바랍니다.
호출을 반복하다 EGW00201이 뜬다면 지표 문제가 아니라 초당 호출 한도 쪽입니다. 키움 REST API를 쓴다면 같은 데이터를 ka10081 계열 일봉 차트 조회로 받게 되며, 증권사별 규격 차이는 증권사 API 비교에 정리돼 있습니다.
8. 배포 전 체크리스트 6가지
- 평활 방식을 문서에서 확인했는가 — Wilder인지
EMA인지. 확인이 안 되면 짧은 표본으로 손계산과 대조. - 백테스트와 봇이 같은 함수를 호출하는가 — 엔진 내장 지표와
TA-Lib을 섞어 쓰고 있지 않은지. - 워밍업 봉 수가 같은가 — 백테스트 3년치 / 봇 100봉 같은 조합은 반드시 어긋난다.
- 확정된 봉만 쓰는가 —
iloc[-1]이 진행 중인 봉을 가리키고 있지 않은지. - 데이터 출처가 하나인가 — 백테스트는
FinanceDataReader, 실매매는 증권사 API 같은 조합은 종가부터 다를 수 있다. - 라이브러리 버전을 고정했는가 —
requirements.txt에 정확한 버전을 박아 두지 않으면 어느 날 갑자기 지표가 바뀐다.
마지막으로 짚어 둘 것. 이 글은 지표 값을 재현 가능하게 맞추는 방법을 다룰 뿐, 특정 지표나 설정이 수익을 낸다는 이야기가 아닙니다. RSI는 1970년대에 제안된 학술·실무 개념이고, 과거 데이터에서 잘 맞았다는 사실은 미래를 보장하지 않습니다. 지표 계산 규칙·데이터 스펙·API 파라미터는 제공처 사정으로 바뀔 수 있으니 각 공식 문서에서 직접 확인하시기 바랍니다.
자주 묻는 질문
TA-Lib과 pandas-ta 중 어느 쪽이 맞는 값인가요?
둘 다 맞습니다. 규칙이 다를 뿐입니다. 다만 TradingView·거래소 차트 대부분이 Wilder 방식을 기본으로 쓰므로, 화면에서 본 신호를 봇으로 옮길 계획이라면 Wilder로 통일하는 편이 안전합니다. 중요한 것은 어느 쪽이 옳은가가 아니라 백테스트와 실매매가 같은 규칙을 쓰는가입니다.
워밍업 봉은 몇 개나 넣어야 하나요?
기간의 10배 이상을 권합니다. RSI(14)라면 100~250봉 정도입니다. TA-Lib이 unstable period를 두는 이유가 정확히 이것이고, 그 구간의 값은 신호 판단에서 제외하는 게 원칙입니다.
코인은 24시간 거래인데 봉 마감을 어떻게 판단하나요?
ccxt로 fetch_ohlcv를 부르면 마지막 원소가 진행 중인 봉인 경우가 많습니다. 업비트·바이낸스 모두 마찬가지입니다. 관행적으로 마지막 하나를 버리고 쓰거나, WebSocket 캔들 스트림에서 마감 플래그가 참일 때만 신호를 계산합니다. 자세한 구현은 업비트 REST·WebSocket 쪽을 참고하세요.
MACD·볼린저밴드도 같은 문제가 있나요?
있습니다. MACD는 EMA 기반이라 워밍업 문제를 그대로 갖고, ATR·ADX는 RSI와 같은 Wilder 평활을 씁니다. 볼린저밴드는 단순이동평균과 표준편차라 상대적으로 안전하지만, 표준편차를 모집단으로 계산하느냐 표본으로 계산하느냐에서 라이브러리마다 갈립니다. 결국 쓰는 지표마다 한 번씩 대조해 보는 수밖에 없습니다.
지표를 맞추면 백테스트대로 수익이 나나요?
그렇지 않습니다. 지표를 맞추는 것은 백테스트 결과를 재현 가능하게 만드는 작업이지 성과를 보장하는 작업이 아닙니다. 슬리피지·수수료·체결 지연·시장 상황 변화는 그대로 남아 있습니다. 모의투자와 소액 운용처럼 단계를 나눠 확인하시길 권합니다.
지표부터 주문까지, 하나의 파이프라인으로
데이터 수집·지표 계산·백테스트·실매매가 같은 규칙으로 돌아가도록 알고랩이 설계·제작합니다.
24시간 빠른 답변 가능합니다.