파이썬 볼린저밴드 — pandas std는 밴드가 2.6% 넓다
파이썬에서 close.rolling(20).std()로 볼린저밴드를 만들면 pandas 기본값 ddof=1(N−1로 나눔) 때문에 표준편차가 √(20/19) = 1.0260배 커져, TA-Lib BBANDS(N으로 나눔)보다 밴드 폭이 2.6% 넓어집니다. 이 차이만으로 삼성전자(005930) 10년 일봉의 상단 돌파일이 184일에서 161일로 줄었습니다. 고치는 법은 rolling(20).std(ddof=0) 한 줄입니다. 여기에 기본 기간 함정이 둘 더 있습니다 — TA-Lib C 라이브러리는 0.7.1까지 BBANDS 기본 기간이 5였다가 0.8.1(2026-09-12)에서 20으로 바뀌었고, pandas-ta 0.4.71b0의 bbands()는 지금도 기본 5이며 TA-Lib 설치 여부에 따라 계산 경로가 갈립니다. 기간·배수·ddof를 전부 명시하면 세 함정이 모두 사라집니다.
목차
- 같은 종가, 다른 밴드 — 10년 실측
- 원인 ① ddof — pandas와 TA-Lib이 나누는 수가 다르다
- 원인 ② 기본 기간 5 — 상단 돌파가 수학적으로 불가능
- 원인 ③ pandas-ta는 TA-Lib 설치 여부로 갈린다
- HTS·차트 값과 맞추는 순서
- 자주 묻는 질문
1. 같은 종가, 다른 밴드 — 10년 실측
볼린저밴드는 "20일 단순이동평균 ± 2 × 20일 표준편차"입니다. 공식은 한 줄인데, 파이썬으로 옮기는 방법이 여럿이고 그 방법들이 서로 다른 숫자를 냅니다. 백테스트는 pandas로, 실거래 봇은 TA-Lib으로 짜면 검증한 전략과 돌아가는 전략이 다른 전략이 됩니다. 얼마나 다른지 직접 재 봤습니다.
조건: FinanceDataReader 0.9.202로 받은 일봉 종가, 기간 2016-10-17 ~ 2026-10-16(기준일 고정, 2,452거래일), pandas 2.2.3, 20일·2σ. 같은 데이터에 표준편차만 ddof=0과 ddof=1로 바꿨습니다.
import FinanceDataReader as fdr
ASOF = "2026-10-16" # 기준일 고정 — 바꾸면 숫자가 달라진다
c = fdr.DataReader("005930", "2016-10-17", ASOF)["Close"].astype(float)
ma = c.rolling(20).mean()
s1 = c.rolling(20).std() # pandas 기본 = ddof=1 (N-1로 나눔)
s0 = c.rolling(20).std(ddof=0) # TA-Lib BBANDS와 같은 방식 (N으로 나눔)
up1, up0 = ma + 2 * s1, ma + 2 * s0
print((s1 / s0).dropna().agg(["min", "max"]))
print("상단 돌파일 ddof=0:", (c > up0).sum(), " ddof=1:", (c > up1).sum())
세 종목 결과입니다(실행 출력 그대로).
005930 2452 rows 2016-10-17 2026-10-16
ratio s1/s0 min/max 1.025978 1.025978 sqrt(20/19)= 1.025978
upper break days ddof0 184 ddof1 161 diff 23
lower break days ddof0 123 ddof1 106 diff 17
upper gap won mean/max 149.0 1735.8
069500 2452 rows 2016-10-17 2026-10-16
upper break days ddof0 180 ddof1 170 diff 10
lower break days ddof0 146 ddof1 131 diff 15
035720 2452 rows 2016-10-17 2026-10-16
upper break days ddof0 172 ddof1 154 diff 18
lower break days ddof0 137 ddof1 124 diff 13
| 종목 | 상단 돌파일 ddof=0 → 1 | 하단 이탈일 ddof=0 → 1 | 하단 첫 이탈(진입 횟수) |
|---|---|---|---|
삼성전자 005930 | 184 → 161 (−12.5%) | 123 → 106 | 59 → 55 |
KODEX 200 069500 | 180 → 170 | 146 → 131 | 73 → 67 |
카카오 035720 | 172 → 154 | 137 → 124 | 68 → 65 |
비율은 모든 날짜에서 정확히 1.025978로 같습니다. 데이터와 무관하게 √(N/(N−1))만큼 고정으로 벌어진다는 뜻입니다. 금액으로는 삼성전자 상단선이 평균 149원, 가장 크게는 1,735.8원 위에 그려졌습니다. "하단 이탈 시 매수" 규칙이라면 10년간 진입이 4~6번 사라지고, 사라진 진입은 대개 밴드에 겨우 닿는 애매한 날이라 성과에 주는 영향이 한쪽으로 치우칩니다. 백테스트 숫자가 실거래와 어긋나는 이유 목록에 이 항목을 넣어 두십시오(백테스트와 실거래가 벌어지는 7가지 이유).
2. 원인 ① ddof — pandas와 TA-Lib이 나누는 수가 다르다
pandas 문서는 Rolling.std의 ddof 기본값을 1이라고 적고, 나누는 수를 N - ddof라고 설명합니다. 같은 문서 Notes에 "numpy.std()의 기본값 0과 다르다"는 문장도 있습니다. 즉 np.std(x)와 pd.Series(x).std()는 같은 데이터에서 다른 값을 냅니다.
TA-Lib은 소스에서 확인됩니다. BBANDS의 표준편차는 중심선이 SMA일 때 내부 함수 INT_stddev_using_precalc_ma, 그 밖에는 STDDEV(=VAR의 제곱근)로 구하는데, 두 경로 모두 제곱합을 기간 N으로 나눕니다. ta_VAR.c(0.6.4 태그)의 핵심 줄은 이렇습니다.
meanValue1 = periodTotal1 / optInTimePeriod; /* 평균 */
meanValue2 = periodTotal2 / optInTimePeriod; /* 제곱의 평균 */
outReal[outIdx++] = meanValue2-meanValue1*meanValue1;
"제곱의 평균 − 평균의 제곱", 기간 N으로 나누는 모집단 분산입니다. 2026-10 현재 main도 variance = periodTotal2 * invPeriod - meanValue1 * meanValue1로 같은 식입니다. TA-Lib에는 ddof 인자 자체가 없습니다.
어느 쪽이 "정답"인지는 이 글이 판정하지 않습니다. 중요한 건 백테스트 코드와 봇 코드가 같은 쪽이어야 한다는 점입니다. 사이트의 다른 지표 글에서도 같은 구조의 문제를 다뤘습니다 — RSI는 평활 방식(Wilder) 차이로 TA-Lib과 pandas-ta 값이 갈립니다(파이썬 RSI 계산 차이).
3. 원인 ② 기본 기간 5 — 상단 돌파가 수학적으로 불가능
두 번째 함정은 기간을 안 적었을 때입니다. TA-Lib C 라이브러리의 ta_BBANDS.c에서 기본값을 정하는 줄을 버전별로 확인했습니다.
| TA-Lib C 버전 | 공개일 | optInTimePeriod 기본값 |
|---|---|---|
| 0.4.0 · 0.6.4 | ~ 2025-01-11 | 5 |
| 0.7.1 | 2026-07-03 | 5 |
| 0.8.1 | 2026-09-12 | 20 — CHANGELOG (#133) BBANDS default optInTimePeriod changed from 5 to 20 |
파이썬 래퍼(PyPI TA-Lib 0.8.2)의 시그니처는 BBANDS(real, timeperiod=-2**31, nbdevup=-4e37, nbdevdn=-4e37, matype=0)입니다. 이 음수 값들은 "기본값을 써라"라는 신호라서, talib.BBANDS(close)의 결과는 그 PC에 깔린 C 라이브러리 버전이 정합니다. 개발 PC와 봇 서버의 설치 시점이 다르면 같은 코드가 5일 밴드와 20일 밴드를 각각 그립니다.
5일 밴드는 단순히 "짧은 밴드"가 아닙니다. 값 n개 중 하나가 평균에서 떨어질 수 있는 최대 거리는 모집단 표준편차의 √(n−1)배입니다(사무엘슨 부등식). n=5면 정확히 2.0배라서 2σ 상단을 넘을 수가 없고, 나머지 네 값이 모두 같을 때 겨우 닿습니다. ddof=1이면 최대 (n−1)/√n ≈ 1.79배라 닿지도 못합니다. 같은 세 종목 10년 일봉에서 5일·2σ 상단 돌파를 세어 보니 세 종목 모두 0회였습니다.
ma5 = c.rolling(5).mean(); s5 = c.rolling(5).std(ddof=0)
print((c > ma5 + 2 * s5).sum()) # 005930: 0 069500: 0 035720: 0
"돌파 매수" 봇이 몇 달째 주문을 한 건도 안 낸다면 로직보다 기간 인자를 먼저 의심하십시오. 에러도 경고도 없이 조용히 0건이 나오는 종류의 버그입니다.
4. 원인 ③ pandas-ta는 TA-Lib 설치 여부로 갈린다
pandas-ta 0.4.71b0(PyPI 최신, Python 3.12 이상)의 pandas_ta/volatility/bbands.py를 열어 보면 계산 경로가 두 갈래입니다.
length = v_pos_default(length, 5)
ddof = int(ddof) if isinstance(ddof, int) and 0 <= ddof < length else 1
...
if Imports["talib"] and mode_tal:
from talib import BBANDS
upper, mid, lower = BBANDS(close, length, upper_std, lower_std, tal_ma(mamode))
else:
std_dev = stdev(close=close, length=length, ddof=ddof, talib=mode_tal)
- 기간 기본값은 지금도 5입니다.
ta.bbands(close)는 20일 밴드가 아닙니다. - TA-Lib이 깔려 있으면(
talib인자 기본True) TA-LibBBANDS를 부르고ddof는 무시됩니다 → 모집단(N). - TA-Lib이 없으면 자체
stdev를 쓰고,ddof를 안 넘기면 코드상 1이 됩니다 → 표본(N−1). docstring에는Default: 0과Default: 1이 둘 다 적혀 있어 문서만 보고는 알 수 없습니다.
그래서 ta.bbands(close, length=20) 한 줄이 TA-Lib을 설치했느냐에 따라 2.6% 다른 밴드를 냅니다. 로컬은 TA-Lib 설치에 성공했고 클라우드 서버는 빌드에 실패해 빠진 경우가 흔합니다. 라이브러리 선택 기준 자체는 백테스팅 파이썬 라이브러리 5종 비교에 정리했습니다.
안전한 한 줄 정리 — 어느 라이브러리든 기간·배수·ddof를 전부 적는다. pandas면 c.rolling(20).std(ddof=0), TA-Lib이면 talib.BBANDS(c, timeperiod=20, nbdevup=2, nbdevdn=2, matype=0), pandas-ta면 ta.bbands(c, length=20, lower_std=2, upper_std=2, ddof=0, talib=False). 그리고 봇 시작 시 마지막 값 하나를 로그로 남겨 백테스트 값과 대조하십시오.
5. HTS·차트 값과 맞추는 순서
"HTS에 뜨는 볼린저밴드와 내 파이썬 값이 다르다"는 질문의 대부분은 아래 순서로 풀립니다. 증권사 HTS의 정확한 산식은 공개 범위가 제각각이니, 마지막 판정은 해당 증권사 도움말이나 고객센터로 확인하십시오.
- 기간·배수 — 20·2가 기본이지만 차트 설정에서 바뀌어 있을 수 있습니다.
- 가격 기준 — 종가인지, (고가+저가+종가)/3 같은 대표가격인지.
- 중심선 종류 — 단순이동평균(SMA)인지 지수이동평균인지. TA-Lib
matype=0이 SMA입니다. - ddof — 마지막 5개 값을
ddof=0·ddof=1로 각각 계산해 HTS 숫자와 대조하면 어느 쪽인지 바로 드러납니다. - 수정주가 — 액면분할·권리락 이전 구간은 수정 여부에 따라 종가 자체가 다릅니다.
- 현재 봉 포함 여부 — 장중에는 미완성 봉의 현재가가 계속 바뀌므로 확정 종가 기준 값과 다르게 보입니다.
대조용 일봉은 증권사 API로 받는 것이 가장 깔끔합니다. 한국투자증권은 FHKST03010100(기간별 시세, FID_PERIOD_DIV_CODE D)로 받고(KIS 일봉 FHKST03010100 3년치 받는 법), 키움 REST는 ka10081(주식일봉차트조회)입니다(키움 ka10081·ka10080 응답 키). 두 응답 모두 숫자가 문자열로 오니 pd.to_numeric으로 바꾼 뒤 rolling에 넣으십시오.
봇에서 쓸 때 하나 더 — 당일 종가가 확정되기 전에 당일 밴드로 판단하면 백테스트는 종가를 알고 매매한 셈이 됩니다. 장중 판단이면 전일까지의 밴드(shift(1))와 현재가를 비교하는 식으로 맞추십시오. 이 함정은 미래참조 편향 탐지 코드에서 자세히 다뤘습니다. 기간·배수를 여러 개 돌려 "최고 조합"을 고르는 순간 생기는 착시는 워크포워드 최적화 실측을 참고하십시오.
이 글의 수치는 2016-10-17 ~ 2026-10-16 일봉을 기준으로 한 과거 실측이며, 볼린저밴드 돌파·이탈이 앞으로의 수익이나 방향을 알려 준다는 뜻이 아닙니다. 특정 종목이나 전략을 권하지 않습니다. 라이브러리 기본값은 버전마다 바뀌므로 설치한 버전의 소스와 공식 문서로 확인하십시오.
6. 자주 묻는 질문
볼린저밴드 표준편차는 ddof=0과 ddof=1 중 무엇을 써야 하나요?
TA-Lib BBANDS와 맞추려면 ddof=0(N으로 나누는 모집단 표준편차)입니다. TA-Lib의 VAR·STDDEV 소스는 제곱합을 기간으로 나눈 뒤 평균의 제곱을 빼는 방식이라 N으로 나눕니다. pandas의 rolling().std()는 기본이 ddof=1이라 20일 기준 표준편차가 √(20/19), 약 1.026배 커집니다. 어느 쪽이 옳다기보다 백테스트와 실거래 봇이 같은 값을 쓰는 것이 중요합니다.
talib.BBANDS(close)를 기간 없이 부르면 몇 일 밴드가 나오나요?
설치된 TA-Lib C 라이브러리 버전에 따라 다릅니다. 0.6.4와 0.7.1 소스는 기본 기간이 5이고, 2026-09-12에 나온 0.8.1 CHANGELOG(#133)에서 20으로 바뀌었습니다. 같은 코드가 PC마다 다른 밴드를 그릴 수 있으므로 timeperiod=20, nbdevup=2, nbdevdn=2를 항상 적으십시오.
5일 볼린저밴드에서 종가가 상단을 돌파하지 않는 이유는 무엇인가요?
수학적으로 불가능하기 때문입니다. 표본 n개 중 한 값이 평균에서 떨어질 수 있는 최대 거리는 모집단 표준편차의 √(n−1)배입니다. n=5면 정확히 2배라서 2σ 밴드를 넘을 수 없고, ddof=1이면 최대 약 1.79배라 닿지도 못합니다. 실제로 삼성전자·KODEX 200·카카오 10년 일봉에서 5일·2σ 상단 돌파는 0회였습니다.
증권사 HTS 볼린저밴드 값과 파이썬 값이 다르면 무엇부터 확인하나요?
기간·배수·가격 기준(종가)·이동평균 종류·표준편차 방식·수정주가 여부 순으로 맞춥니다. 같은 종목의 일봉을 KIS FHKST03010100이나 키움 ka10081로 받아 마지막 다섯 개 값을 ddof=0과 ddof=1로 각각 계산해 HTS 숫자와 대조하면 대부분 어느 쪽인지 바로 드러납니다. HTS의 정확한 산식은 해당 증권사 도움말에서 확인하십시오.
백테스트 값과 봇 값이 같은지, 같이 맞춰 드립니다
쓰시는 지표와 조건을 알려 주시면 계산식을 한 벌로 통일해 백테스트와 실거래 봇에 똑같이 넣어 드립니다. 24시간 빠른 답변 가능합니다.
무료 상담 시작하기