백테스트 유의성 검정 — 무작위 데이터도 샤프 0.59
+3.73%는 t = 1.038, p = 0.3044로 0과 구분되지 않았고,
② 연환산 샤프지수 0.374의 95% 신뢰구간은 [-0.056, 0.803]으로 0을 포함했으며,
③ 이동평균 251조합을 전부 돌려 얻은 최고 샤프 0.628은,
수익률 순서를 무작위로 섞어 예측 가능성을 없앤 데이터에서 같은 탐색을 했을 때 나오는
최고 샤프 평균 0.590과 사실상 같았습니다.
판정에 필요한 것은 숫자 하나가 아니라 표본 수 · 표준오차 · 시험한 조합 수 세 가지입니다.
먼저 밝혀 둡니다.
이 글은 특정 지수·종목·전략의 수익성을 평가하거나 권하는 글이 아닙니다.
KS11(KOSPI 지수)과 20일/60일 이동평균 크로스를 고른 이유는
데이터가 21년으로 길고 누구나 같은 소스로 재현할 수 있어서일 뿐입니다.
본문의 모든 수치는 과거 데이터의 통계량이며 미래 성과와 무관합니다.
통계적 유의성은 수익성과 다른 개념입니다 — 유의해도 비용·슬리피지·체결 후에는 남지 않을 수 있습니다.
이 글에서 확인할 것
- 성적표만으로는 왜 아무것도 모르나
- 계산 대상과 재현 조건
- 검정 ① 거래 단위 t검정 — 49건은 판정 불가
- 검정 ② 표본이 몇 건이어야 하나
- 검정 ③ 샤프지수의 표준오차와 PSR
- 검정 ④ 다중검정 — 무작위로도 0.59가 나온다
- DSR 기준선이 실증 귀무보다 느슨했던 이유
- 실무 체크리스트 · 한계
1. 성적표만으로는 왜 아무것도 모르나
제작 의뢰 때 가장 자주 받는 자료가 백테스트 성적표 한 장입니다 — 누적수익률 몇 배, CAGR 몇 %, MDD 몇 %, 샤프지수 얼마. 여기엔 빠진 정보 세 가지가 있습니다.
- 표본이 몇 건인가 — 거래 12건에서 나온 승률 75%와 거래 400건에서 나온 승률 55%는 전혀 다른 물건입니다.
- 그 숫자의 오차가 얼마인가 — 샤프지수도 표본에서 계산한 추정치라 표준오차가 있습니다. 오차를 모르면 0.4와 0.8을 비교할 수 없습니다.
- 이 숫자에 도달하기까지 몇 개를 시험했는가 — 파라미터를 200개 돌려 최고를 고른 결과와 처음부터 하나만 돌린 결과는 같은 숫자여도 의미가 다릅니다.
세 번째는 다중검정·데이터 스누핑으로 이미 따로 다뤘고, 데이터 자체가 오염되는 경우는 미래참조 편향과 생존편향에서 다뤘습니다. 이 글은 그다음 단계 — 데이터도 코드도 맞다고 할 때, 그 결과가 우연인지를 어떻게 판정하는가입니다. 샤프지수·MDD 계산법은 알아도 그 값의 신뢰구간을 계산해 본 사람은 드뭅니다.
2. 계산 대상과 재현 조건
2차 인용이 아니라 오늘 직접 돌린 숫자입니다. 재현 조건을 그대로 적습니다.
| 항목 | 값 |
|---|---|
| 데이터 | FinanceDataReader 0.9.202 · DataReader('KS11') (KOSPI 지수) |
| 기간 | 2005-01-03 ~ 2026-09-10 · 5,352거래일 |
| 규칙 | 20일 이동평균 > 60일 이동평균이면 보유, 아니면 현금 |
| 체결 가정 | 신호 발생 다음 거래일 종가 (shift(1)로 미래참조 차단) |
| 비용 | 진입·청산 각 15bp (왕복 30bp) |
| 라이브러리 | pandas 2.2.3 · numpy 2.0.2 · scipy 1.14.1 |
| 난수 시드 | 20260911 (부트스트랩 · 순서 섞기 공통) |
# 거래 표본 만들기 — 신호는 다음 날 반영(shift(1))
import FinanceDataReader as fdr, pandas as pd, numpy as np, scipy.stats as st
px = fdr.DataReader('KS11', '2005-01-01', '2026-09-10')['Close'].dropna()
pos = (px.rolling(20).mean() > px.rolling(60).mean()).astype(int).shift(1).fillna(0)
chg = pos.diff().fillna(pos)
ents, exs = list(px.index[chg == 1]), list(px.index[chg == -1])
if exs[-1] < ents[-1]: exs.append(px.index[-1])
r = np.array([px[x] / px[e] - 1 - 0.0015 * 2 for e, x in zip(ents, exs)])
print(len(r), r.mean(), r.std(ddof=1))
# 49 0.0372603... 0.2512122...
21년을 돌렸는데 거래가 49건입니다. 이게 이 글의 출발점입니다. 스윙 이상 주기의 전략은 데이터를 아무리 길게 잡아도 표본이 빠르게 늘지 않습니다. 매매 주기가 길수록 같은 기간에서 얻는 거래 수가 줄어들고, 그만큼 판정이 어려워집니다.
3. 검정 ① 거래 단위 t검정 — 49건은 판정 불가
가장 기본적인 질문은 "거래별 수익률의 평균이 0보다 크다고 말할 수 있는가"입니다.
이건 단일표본 t검정(scipy.stats.ttest_1samp) 한 줄로 끝납니다.
t, p = st.ttest_1samp(r, 0.0)
wins = int((r > 0).sum())
binom = st.binomtest(wins, len(r), 0.5, alternative='greater')
print(f"N={len(r)} 평균={r.mean():.4%} 표준편차={r.std(ddof=1):.4%}")
print(f"승률={wins}/{len(r)} t={t:.3f} p={p:.4f} 이항검정 p={binom.pvalue:.4f}")
# N=49 평균=3.7260% 표준편차=25.1212%
# 승률=14/49 t=1.038 p=0.3044 이항검정 p=0.9993
| 지표 | 값 | 읽는 법 |
|---|---|---|
| 거래 수 | 49건 | 21년치 전부 |
| 평균 거래수익률 | +3.726% | 성적표에 실릴 숫자 |
| 표준편차 | 25.121% | 평균의 6.7배 — 흩어짐이 압도적 |
| 승률 | 14/49 = 28.6% | 추세추종 특유의 낮은 승률 |
| t통계량 | 1.038 | 보통 2.0 근처를 넘겨야 유의 |
| p값(양측) | 0.3044 | 0.05 이상 → 판정 불가 |
| 이항검정 p | 0.9993 | 승률로는 아무것도 못 잡는다 |
승률 28.6%인데 평균이 +3.73% — 작게 여러 번 지고 크게 몇 번 이기는 구조입니다. 그래서 승률 기준 이항검정은 이 전략에서 무의미합니다(p = 0.9993, 오히려 50%보다 낮은 쪽). 승률만 자랑하는 성적표를 믿으면 안 되는 이유가 여기 있습니다.
부트스트랩으로 평균의 신뢰구간을 직접 그려 보면 더 분명합니다.
rng = np.random.default_rng(20260911)
bs = np.array([rng.choice(r, len(r), replace=True).mean() for _ in range(20000)])
print(np.percentile(bs, [2.5, 97.5]))
# [-0.01682 0.11856] → 95% CI = [-1.68%, +11.86%], 0을 포함
평균이 -1.68%일 수도 있고 +11.86%일 수도 있다는 뜻입니다. 이 폭 안에서는 "이 전략은 돈을 번다"와 "이 전략은 돈을 잃는다"가 둘 다 데이터와 양립합니다.
4. 검정 ② 표본이 몇 건이어야 하나
"그럼 몇 건이면 되나"는 고정된 숫자가 아니라 평균과 표준편차에서 계산됩니다.
거칠게는 n ≈ (t · s / m)² — 위 값(m = 3.726%, s = 25.121%)을 넣으면
약 175건이 나옵니다. 평균·편차가 그대로 유지된다는 가정에서 표본 수만 바꿔 보면 이렇습니다.
| 거래 수 | t통계량 | p값(양측) | 판정 |
|---|---|---|---|
| 20 | 0.663 | 0.5151 | 판정 불가 |
| 30 | 0.812 | 0.4232 | 판정 불가 |
| 49 (실제) | 1.038 | 0.3044 | 판정 불가 |
| 80 | 1.327 | 0.1885 | 판정 불가 |
| 120 | 1.625 | 0.1069 | 판정 불가 |
| 175 | 1.962 | 0.0513 | 경계 (아슬아슬하게 미달) |
| 250 | 2.345 | 0.0198 | 유의 |
| 400 | 2.966 | 0.0032 | 유의 |
여기서 실무적으로 중요한 건 250이라는 숫자가 아닙니다.
(t · s / m)²는 평균 m의 제곱에 반비례합니다.
평균 거래수익률이 절반으로 줄면 필요한 거래 수는 4배가 됩니다.
수수료·세금·슬리피지를 제대로 반영하면
평균이 깎이는데, 그 순간 필요 표본 수가 폭증합니다.
비용을 빼먹은 백테스트가 위험한 이유는 수익률이 부풀려져서만이 아니라,
"검증 가능한 전략"처럼 보이게 만들기 때문입니다.
5. 검정 ③ 샤프지수의 표준오차와 PSR
거래 단위 대신 일간 수익률로 보면 표본이 5,352개로 늘어납니다. 같은 전략인데 관측 단위를 바꾼 것뿐인데 판정이 달라집니다.
# Lo(2002)/Mertens — 왜도·첨도를 반영한 샤프지수 표준오차
sr = x.mean() / x.std(ddof=1) # 일간 샤프
g3, g4 = st.skew(x), st.kurtosis(x, fisher=False) # 왜도, 첨도(비초과)
se = np.sqrt((1 + 0.5*sr**2 - g3*sr + (g4-3)/4*sr**2) / (len(x)-1))
# 연환산 샤프=0.374 왜도=-0.698 첨도=23.328
# 표준오차(연환산)=0.219 t=1.707 p(양측)=0.0879
# 95% CI = [-0.056, 0.803]
| 지표 | 값 | 의미 |
|---|---|---|
| 관측 수 | 5,352거래일 | 거래 단위(49)의 109배 |
| 연환산 샤프지수 | 0.374 | 성적표에 실릴 숫자 |
| 왜도 / 첨도 | -0.698 / 23.328 | 정규분포(첨도 3)에서 크게 벗어남 |
| 표준오차(연환산) | 0.219 | 샤프 자체의 오차 — 성적표엔 없다 |
| 95% 신뢰구간 | [-0.056, 0.803] | 0을 포함 |
| p값(양측) | 0.0879 | 5% 기준 미달 |
| PSR(벤치 0) | 95.61% | 단측 확률 — 아래 설명 |
PSR(Probabilistic Sharpe Ratio)은 "관측된 샤프지수가 기준치보다 진짜로 높을 확률"을 표본 수와 왜도·첨도까지 반영해 계산한 값입니다.
def psr(x, sr_bench_ann=0.0, freq=252):
n = len(x)
sr = x.mean() / x.std(ddof=1)
srb = sr_bench_ann / np.sqrt(freq)
g3, g4 = st.skew(x), st.kurtosis(x, fisher=False)
z = (sr - srb) * np.sqrt(n - 1) / np.sqrt(1 - g3*sr + (g4-1)/4 * sr**2)
return st.norm.cdf(z)
# psr(x) = 0.9561
PSR 95.61%와 p값 0.0879는 모순이 아닙니다. PSR은 단측 확률이고 p값은 양측입니다 — 1 − 0.9561 = 0.0439가 단측 p값이고, 그 두 배가 0.0879입니다. 같은 숫자를 다르게 부른 것뿐인데 "PSR 96%"는 통과처럼 들리고 "p = 0.088"은 탈락처럼 들립니다. 성적표에 PSR만 적혀 있다면 단측인지 양측인지, 벤치마크 샤프를 얼마로 뒀는지를 반드시 확인하십시오.
6. 검정 ④ 다중검정 — 무작위로도 0.59가 나온다
실제로는 20일/60일을 처음부터 고르지 않습니다. 단기·장기 조합을 격자로 훑고 제일 좋은 걸 씁니다. 그래서 단기 5~60일(5 간격) × 장기 20~240일(10 간격) = 251조합을 전부 돌렸습니다.
| 순위 | 단기 | 장기 | 연환산 샤프 |
|---|---|---|---|
| 1 | 5 | 110 | 0.628 |
| 2 | 60 | 90 | 0.617 |
| 3 | 35 | 110 | 0.614 |
| 4 | 35 | 120 | 0.607 |
| 5 | 55 | 110 | 0.593 |
| — | 251조합 중앙값 | 0.449 | |
"샤프 0.628"이 성적표에 실립니다. 이게 실력인지 확인하려면 실력이 0인 데이터에서 같은 탐색을 했을 때 얼마가 나오는지를 재면 됩니다. 일간 수익률의 순서만 무작위로 섞으면 분포는 그대로인데 예측 가능성은 사라집니다. 이 가짜 데이터로 똑같은 251조합 탐색을 200회 반복했습니다.
rng = np.random.default_rng(20260911)
GRID = [(f, s) for f in range(5, 61, 5) for s in range(20, 241, 10) if s > f] # 251개
maxes = []
for _ in range(200):
fake = rng.permutation(ret) # 순서를 섞어 예측 가능성 제거
fp = pd.Series(np.cumprod(1 + fake))
maxes.append(max(sharpe_from_pos(signal(fp, f, s), fake) for f, s in GRID))
# 최고 샤프 — 평균 0.590 · 중앙 0.587 · 95백분위 0.776 · 최대 0.832
예측 가능성이 0인 데이터에서도 251개 중 최고를 고르면 샤프 0.590이 그냥 나옵니다. 실제 데이터의 최고 조합 0.628은 그 우연의 평균보다 0.038 높을 뿐이고, 무작위 분포의 95백분위 0.776에는 한참 못 미칩니다. 즉 이 격자 탐색 결과는 "KOSPI에서 이동평균 크로스가 통한다"를 전혀 지지하지 않습니다.
이 값은 이론으로도 맞춰집니다. 5,352일 표본에서 샤프의 표준오차가 약 0.219이고,
평균 0인 분포에서 251개를 뽑아 최댓값을 취하면 기댓값이 대략 표준오차의 2.7~2.9배 —
0.219 × 2.7 ≈ 0.59. 실측과 이론이 같은 자리를 가리킵니다.
7. DSR 기준선이 실증 귀무보다 느슨했던 이유
DSR(Deflated Sharpe Ratio)은 시험한 조합 수를 반영해 기준선을 끌어올리는 보정입니다. 이 격자에 적용한 결과는 이렇습니다.
| 기준 | 값 | 판정 |
|---|---|---|
| 최고 조합의 PSR (보정 전) | 99.78% | 통과처럼 보임 |
| DSR이 계산한 우연 기준선 | 연환산 샤프 0.205 | — |
| DSR (251조합 보정 후) | 97.22% | 깎이지만 여전히 통과 |
| 순서 섞기로 잰 실증 기준선 | 연환산 샤프 0.590 | 탈락 |
이론 기준선(0.205)과 실측 기준선(0.590)이 2.9배 벌어졌습니다. 이유는 계산 구조에 있습니다 — DSR은 시험한 조합들의 샤프 분산으로 기준선을 세우는데, 이 격자에서 그 값은 0.0723(연환산)이었습니다. 반면 실제 표본오차는 0.219입니다. 격자 조합들이 같은 지수를 같은 방식으로 거래해 서로 강하게 겹치기 때문에, 조합 간 흩어짐이 진짜 추정 오차보다 훨씬 작게 잡힌 것입니다.
실무 결론: 파라미터 격자처럼 시행끼리 상관이 높은 탐색에서는 DSR 하나만 믿지 말고 순서 섞기(permutation) 기반 실증 귀무분포를 함께 재십시오. 코드는 위에 적은 열 줄이 전부이고, 200회 반복은 노트북에서 몇 분이면 끝납니다. 이 검정은 "우리 전략이 우연보다 나은가"에 직접 답하는 유일한 검정입니다.
8. 실무 체크리스트
백테스트 결과를 받았을 때 확인할 순서입니다.
- 거래 수를 먼저 묻는다. 누적수익률보다 앞입니다. 50건 미만이면 그 성적표로는 판정할 수 없습니다.
- 평균과 표준편차를 함께 받는다.
(1.96 · s / m)²로 필요 표본 수를 즉석에서 어림합니다. - 승률만 강조하면 의심한다. 추세추종은 승률 30%대가 정상이고, 반대로 승률 90%짜리는 꼬리 위험이 숨어 있을 수 있습니다.
- 샤프지수에 신뢰구간을 요구한다. 0을 포함하면 "0과 구분 안 됨"입니다.
- 시험한 파라미터 조합 수를 받는다. 안 세어 봤다면 그 자체가 답입니다.
- 순서 섞기 귀무분포와 비교한다. 실제 값이 그 분포의 95백분위를 넘지 못하면 근거가 없습니다.
- 비용을 반영한 뒤 1~6을 다시 돌린다. 비용은 평균을 깎고, 평균이 깎이면 필요 표본이 제곱으로 늘어납니다.
검정을 통과했다고 해서 끝이 아닙니다 — 백테스트와 실전의 괴리는 통계와 별개로 남고, 수익 인증의 함정은 여전히 유효합니다. 검정은 탈락시키는 도구이지 합격증이 아닙니다.
9. 한계 — 이 글이 답하지 않는 것
- 통계적 유의성 ≠ 수익성. 유의해도 실제 체결·세금·거래 규모 앞에서 사라질 수 있습니다.
- 한 종목·한 지수의 결과입니다. KOSPI 지수 하나로 잰 것이고, 다른 자산에서는 표본오차도 분포도 달라집니다.
- 독립성 가정. t검정은 거래 간 독립을 가정하는데, 같은 국면에서 연달아 난 거래는 독립이 아닙니다. 이 점에서 위 p값은 오히려 낙관적일 수 있습니다.
- 순서 섞기는 자기상관을 없앱니다. 추세추종이 노리는 신호 자체를 제거하므로 보수적인 귀무분포입니다. 이 성질을 이해하고 써야 합니다.
- 0.05라는 기준은 관습이고, 여러 검정을 동시에 쓰면 그 자체가 다중검정이 됩니다.
- 백테스트 엔진에 따라 체결·비용 처리가 달라 숫자가 미세하게 바뀝니다 — 백테스팅 라이브러리 비교를 참고하십시오.
실계좌로 넘어가기 전 단계별 확인은 실전 투입 전 3단계 검증에, 증권사별로 어떤 데이터를 어디까지 받을 수 있는지는 국내 증권사 API 비교와 pykrx·FinanceDataReader·KIS 데이터 수집에 정리해 두었습니다.
자주 묻는 질문
백테스트 거래가 몇 건이면 통계적으로 판정할 수 있나요?
고정된 숫자는 없고 평균과 표준편차에서 계산됩니다.
이 글의 계산 대상은 21년에 거래 49건, 평균 3.73%, 표준편차 25.12%였고,
이 값이 유지된다는 가정에서 약 175건에서 경계(p = 0.0513), 250건부터 유의였습니다.
(1.96 · s / m)²로 어림잡을 수 있으며, 평균이 절반이 되면 필요 표본은 4배가 됩니다.
샤프지수 0.374는 좋은 건가요?
단독 숫자로는 판정할 수 없습니다. 표준오차가 0.219라
95% 신뢰구간이 [-0.056, 0.803]으로 0을 포함합니다(양측 p = 0.0879).
왜도 -0.698·첨도 23.328처럼 정규분포에서 벗어난 분포에서는
Lo(2002)의 보정식이나 PSR로 계산해야 오차가 제대로 잡힙니다.
파라미터를 여러 개 돌려 최고를 고르면 왜 안 되나요?
고른다는 행위 자체가 결과를 부풀리기 때문입니다. 실측에서 순서를 섞은 무작위 데이터에 251조합 탐색을 하니 최고 샤프가 평균 0.590, 95백분위 0.776이었습니다. 실제 데이터의 0.628은 이 우연의 평균보다 0.038 높을 뿐입니다. 시험한 조합 수를 반드시 기록하고 그 수에 맞는 기준선과 비교하십시오.
PSR과 DSR은 무엇이 다른가요?
PSR은 표본 수·왜도·첨도를 반영해 샤프가 기준치보다 높을 확률을 계산하고, DSR은 여기에 시험한 조합 수를 더해 기준선을 끌어올립니다. 이 글에서는 PSR 99.78% → DSR 97.22%로 깎였고 DSR 기준선은 0.205였지만, 순서 섞기로 직접 잰 기준선은 0.590이었습니다. 격자 조합끼리 강하게 겹쳐 시행 간 분산이 실제 표본오차보다 작게 잡히기 때문입니다.
이 글의 숫자를 그대로 믿어도 되나요?
재현 조건을 전부 적어 두었으니 직접 돌려 확인하시는 편이 좋습니다.
FinanceDataReader 0.9.202로 KS11을 2005-01-03 ~ 2026-09-10(5,352거래일) 받아
왕복 30bp 비용을 반영했고, 난수 시드는 20260911입니다
(pandas 2.2.3 · numpy 2.0.2 · scipy 1.14.1).
과거 데이터의 통계량은 미래 성과를 보장하지 않으며,
이 글은 종목·전략 추천이 아니라 검정 절차 안내입니다.
통계 개념의 정확한 정의와 적용 조건은 학술 원문과 공식 문서로 확인하십시오.