샤프지수·MDD 계산 — quantstats 함정 5가지
ddof(pandas는 1, numpy는 0),
② 연율화 계수(주식 252, 코인 365),
③ 무위험수익률 rf 처리,
④ 결측일을 0으로 채우는지,
⑤ 입력이 가격인지 수익률인지 자동으로 판별하는지.
quantstats의 기본값은 rf=0.0, periods=252,
std(ddof=1)입니다.
아래 실측에서는 같은 곡선으로 1.5967과 1.9216이 동시에 나왔습니다.
자동매매 봇이 잘 돌아가는지 판단할 때 결국 보는 숫자는 셋입니다 —
샤프지수, MDD(최대낙폭), CAGR(연평균 수익률).
문제는 같은 거래 기록인데도 백테스트 프로그램이 뱉은 값, quantstats로 다시 돌린 값,
엑셀로 계산한 값이 전부 다르다는 것입니다.
버그가 아니라 정의의 차이입니다. 그런데 정의가 다르면 두 전략을 비교하는 행위 자체가 무의미해집니다. 이 글은 그 차이가 어디서 생기는지 수치로 확인한 것입니다. 어느 라이브러리를 쓸지는 백테스팅 파이썬 라이브러리 5종 비교에 따로 정리했습니다.
이 글에서 다루는 것
- 실측 — 같은 곡선에서 나온 다섯 개의 샤프지수
- 먼저 자산곡선을 만든다 (키움
kt00002· KISTTTC8715R) - 함정 1 — 표준편차의
ddof - 함정 2 — 연율화 계수 252
- 함정 3 —
rf는 연율값이고, CAGR에는 반영되지 않는다 - 함정 4 — 결측일을 0으로 채운다
- 함정 5 — 입력이 가격인지 수익률인지 값으로 추측한다
- MDD와 CAGR — 직접 짜면 어긋나는 두 곳
- 봇 계좌에 쓸 때 — 입출금이 섞이면 전부 틀린다
실측 — 같은 곡선에서 나온 다섯 개의 샤프지수
먼저 결론부터 보겠습니다. 동일한 자산곡선 하나를 놓고 조건만 바꿔 가며 계산한 값입니다. 자산곡선은 원금 1,000만원으로 251거래일을 돌린 가상의 곡선이고, 누적 +30.68%, 최종 평가액 약 1,303만원입니다.
| 조건 | 샤프지수 | 기준 대비 |
|---|---|---|
ddof=1 · periods=252 · rf=0 (quantstats 기본값) | 1.5967 | 기준 |
ddof=0으로만 바꿈 (numpy 기본값) | 1.5998 | +0.20% |
periods=365로만 바꿈 (코인 기준) | 1.9216 | +20.3% |
rf=3%로만 바꿈 | 1.4308 | −10.4% |
같은 곡선을 월간 12개로 묶어 계산 (ddof=1) | 1.4968 | −6.3% |
월간 12개 + ddof=0 | 1.5634 | 월간 기준 +4.45% |
1.43부터 1.92까지, 하나의 곡선에서 나온 값입니다. "우리 전략 샤프 1.9입니다"라는 말은 조건을 같이 듣지 않으면 아무 정보가 아닙니다. 이 문제는 수익 인증의 함정에서 다룬 "숫자는 맞는데 의미가 없는" 경우의 전형입니다.
먼저 자산곡선을 만든다 (키움 kt00002 · KIS TTTC8715R)
성과지표는 자산곡선에서 나옵니다. 돌고 있는 봇의 성과를 재려면 증권사 API에서 일별 자산을 직접 받아야 합니다.
키움 REST API에는 kt00002(일별추정예탁자산현황요청)가 있습니다.
start_dt·end_dt를 YYYYMMDD로 넣으면 날짜별 자산 상태가 옵니다.
응답 필드 중 성과 계산에 쓰는 것은 dt(일자)와 prsm_dpst_aset_amt(추정예탁자산)입니다.
# 키움 REST API — 일별추정예탁자산현황 (api-id: kt00002, /api/dostk/acnt)
# 공식 예제 examples/국내주식/계좌/get_domestic_daily_estimated_deposit_asset.py 기준
body = {
"start_dt": "20260101",
"end_dt" : "20260831",
}
# 응답 COLUMNS (공식 예제 매핑)
# "dt" : 일자
# "entr" : 예수금
# "grnt_use_amt" : 담보대출금
# "crd_loan" : 신용융자금
# "ls_grnt" : 대주담보금
# "repl_amt" : 대용금
# "prsm_dpst_aset_amt" : 추정예탁자산 ← 자산곡선은 이 값
# "prsm_dpst_aset_amt_bncr_skip" : 추정예탁자산수익증권제외
한국투자증권(KIS) 쪽은 접근 방식이 조금 다릅니다.
TTTC8715R(기간별매매손익)로 실현손익을 받아 누적하는 형태이고,
이건 평가금액 곡선이 아니라 매매로 확정된 손익입니다.
두 값은 같지 않습니다 — 자세한 차이는
KIS API 기간별매매손익 TTTC8715R과
키움 REST API 실현손익 ka10072에 정리해 두었습니다.
성과지표를 재려면 "평가금액 곡선"이 필요합니다.
실현손익만 누적하면 아직 안 판 종목의 평가손실이 빠집니다.
물려 있는 종목을 안 팔면 손실이 곡선에 안 나타나므로 MDD가 실제보다 얕게 나옵니다.
키움은 kt00002, 그 밖의 증권사는 일별 잔고평가 계열 조회를 쓰십시오.
증권사마다 지원 범위가 다르므로 붙이기 전에 확인하십시오.
함정 1 — 표준편차의 ddof
샤프지수의 분모는 수익률의 표준편차입니다. 그런데 표준편차는 두 가지입니다.
ddof=1 (표본) | ddof=0 (모집단) | |
|---|---|---|
| pandas | Series.std() 기본값 | .std(ddof=0) |
| numpy | np.std(x, ddof=1) | np.std(x) 기본값 |
| 엑셀 | STDEV.S | STDEV.P |
quantstats sharpe() | returns.std(ddof=1) 고정 | — |
pandas와 numpy의 기본값이 정반대입니다.
df['ret'].std()로 쓰다가 np.std(arr)로 바꾸면 그 순간 값이 바뀝니다.
두 값의 비율은 표본 수 n에 대해 sqrt(n / (n-1))입니다.
import numpy as np, pandas as pd
r = eq.pct_change().dropna() # 일간 수익률 251개
print(r.std()) # 0.011229 ← pandas 기본 ddof=1
print(np.std(r)) # ddof=0 — 더 작다
print(np.sqrt(len(r) / (len(r) - 1))) # 1.001998 ← 정확히 이 비율만큼 차이
# 일간 251개 → 샤프 1.5967 vs 1.5998 (+0.20%)
# 월간 12개 → 샤프 1.4968 vs 1.5634 (+4.45%)
표본이 작을수록 차이가 커집니다. 일간 251개에서는 0.2%라 무시해도 되지만, 월간 12개로 계산하면 4.45%입니다. "1년 운용해서 샤프 1.50"과 "1.56"은 보고서에서 전혀 다르게 읽힙니다. 월간·분기 단위로 성과를 정리하는 습관이 있다면 어느 쪽을 썼는지 반드시 명시하십시오.
함정 2 — 연율화 계수 252
일간 수익률로 계산한 샤프지수를 연 단위로 바꿀 때 sqrt(연간 기간 수)를 곱합니다.
quantstats의 기본값은 periods=252입니다.
국내 주식 시장의 연간 거래일 수에서 온 관행적인 값입니다.
# quantstats/stats.py — sharpe()
def sharpe(returns, rf=0.0, periods=252, annualize=True, smart=False):
...
divisor = returns.std(ddof=1)
res = returns.mean() / divisor
if annualize:
return res * _np.sqrt(1 if periods is None else periods)
return res
문제는 암호화폐입니다. 업비트나 바이낸스에서 돌리는 봇은
주말도 공휴일도 없이 365일 수익률이 생깁니다.
그런데 periods를 안 건드리면 sqrt(252)가 곱해집니다.
| 자산 | 연율화 계수 | 같은 곡선의 샤프 |
|---|---|---|
| 국내·미국 주식 (일간) | 252 | 1.5967 |
| 암호화폐 (일간) | 365 | 1.9216 |
| 월간 데이터 | 12 | 1.4968 |
| 주간 데이터 | 52 | 계수만 바꿔 계산 |
20% 넘게 벌어집니다. 여기서 중요한 건 "어느 쪽이 옳으냐"가 아닙니다. 비교 대상과 같은 계수를 썼느냐입니다. 주식 전략(252)과 코인 전략(365)의 샤프지수를 한 표에 나란히 놓는 순간 그 표는 거짓말이 됩니다. 지표는 절대값이 아니라 같은 조건에서의 상대 비교로만 의미가 있습니다.
함정 3 — rf는 연율값이고, CAGR에는 반영되지 않는다
무위험수익률 rf는 기본값이 0.0입니다.
즉 아무것도 안 넣으면 예금 금리를 0으로 놓고 계산합니다.
rf=0.03(연 3%)을 넣으면 위 실측에서 샤프가 1.5967 → 1.4308로 10.4% 내려갔습니다.
rf는 연율값으로 넣고 라이브러리가 일간으로 환산합니다.
환산식은 단순 나눗셈이 아니라 복리 기준입니다.
# quantstats/utils.py — to_excess_returns()
if nperiods is not None:
rf = _np.power(1 + rf, 1.0 / nperiods) - 1.0 # 연 3% → 일 0.0117%
df = returns - rf
여기에 함정이 하나 더 있습니다 — cagr()은 rf를 무시합니다.
quantstats의 cagr()은 시그니처에 rf를 받고 문서에도
"excess returns의 CAGR"이라고 적혀 있지만, 내부에서 rf를 적용하지 않는 함수 목록
(unnecessary_function_calls)에 "cagr"이 들어 있습니다.
즉 cagr(returns, rf=0.03)과 cagr(returns)는 같은 값입니다.
샤프지수만 rf가 빠진 값이 되고 CAGR은 그대로라서,
두 지표를 나란히 보면 앞뒤가 안 맞는 표가 만들어집니다.
함정 4 — 결측일을 0으로 채운다
quantstats는 입력을 전처리하면서 결측값을 0으로 채웁니다.
# quantstats/utils.py — _prepare_returns()
data = data.replace([_np.inf, -_np.inf], float("NaN"))
data = data.fillna(0).replace([_np.inf, -_np.inf], float("NaN")) # ← 결측 = 0% 수익
편의 기능처럼 보이지만 변동성을 낮추는 방향으로 작용합니다. "수익률 0%인 날"이 늘어나면 표준편차가 희석되기 때문입니다. 위 곡선에서 20영업일이 비었다고 가정하고 두 방식으로 계산해 봤습니다.
| 결측 20일 처리 | 일간 표준편차 | 샤프지수 |
|---|---|---|
빠진 날을 제외 (dropna) | — | 2.0281 |
0으로 채움 (fillna(0)) | 0.010787 | 1.9447 |
| (참고) 원본 251일 | 0.011229 | 1.5967 |
결측 처리 방식만 바꿔도 4.1% 차이가 납니다. 그리고 데이터가 비면 지표가 좋아 보이는 방향으로 움직인다는 점이 특히 위험합니다. 자동매매 봇의 실계좌 곡선은 휴장일, 서버 다운, API 오류로 구멍이 잘 납니다. 구멍을 0으로 메운 채 샤프지수를 보고 있으면 실제보다 안정적인 전략처럼 보입니다.
실무 규칙 하나 — 결측은 채우기 전에 세십시오.
returns.isna().sum()이 전체의 몇 %인지 먼저 찍어 보고,
그 비율이 5%를 넘으면 지표를 계산하기 전에 데이터 수집부터 고치는 게 맞습니다.
백테스트와 실전이 왜 갈라지는지는
백테스트와 실전의 간극에 따로 정리했습니다.
함정 5 — 입력이 가격인지 수익률인지 값으로 추측한다
quantstats는 넣은 시리즈가 가격인지 수익률인지 물어보지 않습니다.
값의 범위를 보고 스스로 판단합니다.
# quantstats/utils.py — _prepare_returns()
elif data.min() >= 0 and data.max() > 1:
data = data.pct_change(fill_method=None) # ← 가격으로 보고 수익률로 변환
판별식은 "최솟값이 0 이상이고 최댓값이 1을 넘으면 가격" 하나뿐입니다.
자산곡선(1,000만원대)은 조건을 만족해 알아서 pct_change()가 걸리고,
일간 수익률(−0.035 ~ 0.030)은 음수가 있으니 그대로 씁니다.
어긋나는 건 수익률을 % 단위로 넣을 때입니다.
1.5가 1.5%를 뜻하도록 100을 곱해 뒀는데 그 구간에 마이너스 날이 하나도 없으면
최솟값 ≥ 0, 최댓값 > 1이 성립해 조용히 가격으로 취급됩니다.
에러 없이 값만 이상해집니다.
규칙 하나만 지키면 됩니다 — 수익률은 항상 소수로.
1.5%는 0.015로 넣습니다. 그리고 함수에 넣기 직전에
print(s.min(), s.max())를 한 번 찍어 보십시오.
최댓값이 1을 넘는 "수익률"이 있다면 그건 수익률이 아닙니다.
MDD와 CAGR — 직접 짜면 어긋나는 두 곳
MDD — 첫날 손실을 놓친다
MDD 계산은 보통 세 줄입니다. 직전 고점(cummax) 대비 하락률의 최솟값입니다.
cummax = eq.cummax()
dd = eq / cummax - 1
mdd = dd.min() # 위 곡선에서 -12.36% (2025-12-11, 직전 고점 10,857,702원)
대부분 맞습니다. 그런데 첫 값이 스스로 고점이 된다는 맹점이 있습니다.
원금 100에서 시작한 곡선이 [92, 95, 100, 110]이라면
cummax는 [92, 95, 100, 110]이 되고 MDD는 0%가 나옵니다.
실제로는 원금 대비 8% 빠졌던 구간인데 없는 일이 됩니다.
quantstats의 max_drawdown()은 이 경우를 위해
곡선 앞에 기준선 한 칸을 붙여 계산합니다(소스 주석의 "phantom baseline").
직접 짤 거라면 자산곡선을 원금부터 시작하도록 만들거나,
앞에 원금 한 줄을 붙이면 같은 결과가 됩니다.
# 첫날 손실을 놓치지 않는 형태
eq2 = pd.concat([pd.Series([초기원금], index=[eq.index[0] - pd.Timedelta(days=1)]), eq])
mdd = (eq2 / eq2.cummax() - 1).min()
CAGR — 연수를 달력이 아니라 데이터 개수로 센다
quantstats의 cagr()은 운용 기간을 len(returns) / periods로 계산합니다.
달력 날짜를 보지 않고 데이터가 몇 개인지로 셉니다.
# quantstats/stats.py — cagr()
years = len(returns) / periods # 251 / 252 = 0.9960년
res = abs(total + 1.0) ** (1.0 / years) - 1
| 연수 계산 방식 | 연수 | CAGR |
|---|---|---|
len / 252 (quantstats) | 0.9960년 | 30.82% |
| 실제 달력 일수 / 365.25 | 0.9610년 | 32.10% |
1.3%p 차이입니다. 데이터에 구멍이 있으면 이 차이가 커집니다.
3년치를 돌렸는데 수집 실패로 데이터가 600개뿐이면 quantstats는
2.38년 운용한 것으로 계산합니다. 분모가 작아지니 CAGR이 실제보다 높게 나옵니다.
봇 계좌에 쓸 때 — 입출금이 섞이면 전부 틀린다
여기까지는 백테스트 이야기였습니다. 실계좌는 문제가 하나 더 있습니다 — 입금과 출금입니다.
kt00002로 받은 prsm_dpst_aset_amt 곡선을 그대로 pct_change() 하면
1,000만원 계좌에 500만원을 입금한 날이 +50% 수익률로 잡힙니다.
그 하루 때문에 표준편차가 폭발하고 샤프지수는 의미를 잃습니다.
# ❌ 입출금이 섞인 평가금액 곡선
r = asset.pct_change() # 입금일 = +50%, 출금일 = -30% …
# ✅ 시간가중수익률(TWR) — 입출금액을 제거하고 계산
# flow[t] = 그날의 순입금액 (입금 +, 출금 -)
r = (asset - flow) / asset.shift(1) - 1
그래서 입출금 내역을 반드시 같이 보관해야 합니다. 평가금액 곡선만 남기고 입출금 기록을 안 남기면 나중에 복원할 방법이 없습니다. 봇을 돌리기 시작하는 날부터 일별 평가금액 + 그날의 순입출금 두 컬럼을 매일 적재해 두십시오. 이게 없으면 1년 뒤에 "내 봇 성과가 얼마였나"에 답할 수 없습니다.
정리 — 성과지표를 적을 때 같이 적어야 하는 것
숫자 하나만 적으면 안 됩니다. 아래 다섯 줄을 같이 적으면 그 숫자는 재현 가능해집니다.
- 기간과 표본 수 — "2026-01-02 ~ 2026-08-28, 일간 163개"
- 연율화 계수 — 252 / 365 / 12 중 무엇인지
ddof— 0인지 1인지rf— 0인지, 연 몇 %인지- 결측·입출금 처리 — 몇 개를 어떻게 처리했는지
그리고 여러 파라미터를 돌려 본 뒤 가장 좋은 하나를 고른 것이라면 그 샤프지수는 이미 과대평가돼 있습니다. 거래비용을 빼먹었을 때 벌어지는 일은 백테스트 수수료·세금 반영에서 다뤘습니다.
자주 묻는 것
샤프지수 몇 이상이면 좋은 전략인가요?
기준선을 말하기 어렵습니다. 위에서 본 것처럼 같은 곡선이 조건에 따라 1.43도 되고 1.92도 되기 때문입니다. 게다가 기간이 짧을수록 값이 크게 흔들립니다. 의미 있게 쓰려면 같은 조건으로 계산한 두 전략을 비교하거나, 같은 전략의 인샘플과 아웃오브샘플을 비교하는 용도로 쓰는 편이 낫습니다. 이 글은 특정 수치가 좋은 성과라고 주장하지 않으며, 과거 성과는 미래를 보장하지 않습니다.
backtrader나 vectorbt가 뱉는 값을 그냥 믿으면 안 되나요?
믿어도 되지만 어떤 정의로 계산했는지는 확인해야 합니다.
라이브러리마다 연율화 계수 기본값과 rf·결측 처리가 다릅니다.
실무에서는 백테스트 엔진과 성과지표 도구를 분리하는 편이 낫습니다.
엔진은 자산곡선까지만 만들고, 지표는 quantstats나 pyfolio로
항상 같은 설정으로 다시 계산하면 전략끼리 비교가 됩니다.
수익률을 로그수익률로 쓰면 달라지나요?
달라집니다. 로그수익률은 합산이 되는 대신 값 자체가 산술수익률보다 작습니다.
표준편차도 평균도 바뀌므로 샤프지수가 달라집니다.
quantstats는 산술수익률을 전제로 만들어져 있으므로
로그수익률을 넣으면 정의가 어긋납니다. 섞어 쓰지 마십시오.
실계좌 성과를 매일 자동으로 기록하려면요?
증권사 API로 일별 평가금액과 순입출금을 받아 CSV나 DB에 하루 한 줄씩 적재하는 배치를 만들면 됩니다.
키움은 kt00002가 그 역할을 하고, 토큰 발급부터의 흐름은
키움 REST API 자동매매 가이드에 정리돼 있습니다.
지표 계산은 나중에 언제든 다시 할 수 있지만, 안 받아 둔 과거 데이터는 복원되지 않습니다.
2026-08-31 기준으로 quantstats 저장소
ranaroussi/quantstats의 quantstats/stats.py·quantstats/utils.py 원문과,
키움증권 공식 저장소 Kiwoom-Securities/Kiwoom-REST-API의
examples/국내주식/계좌/get_domestic_daily_estimated_deposit_asset.py 원문을 기준으로 작성했습니다.
본문의 수치는 가상의 자산곡선 하나(251거래일)로 각 정의를 그대로 재현해 계산한 값이며,
특정 전략의 성과가 아닙니다. 라이브러리 기본값과 API 필드는 버전·공지에 따라 바뀝니다 —
쓰기 전에 설치된 버전의 소스와 증권사 개발자 포털의 현재 명세로 대조하십시오.
샤프지수·MDD·CAGR은 과거 데이터를 요약하는 학술적 개념이며 미래 수익을 보장하지 않습니다.
이 글은 수익이나 시장 방향을 예측하지 않으며 투자 권유가 아닙니다.
봇 성과를 매일 자동으로 기록해 드립니다
증권사 API에서 일별 평가금액과 입출금을 받아 자산곡선을 쌓고, 샤프지수·MDD·CAGR을 같은 정의로 매일 계산해 텔레그램으로 보내는 리포트까지 묶어서 만들어 드립니다. 24시간 빠른 답변 가능합니다.
무료 상담 시작하기