AlgoLab Blog · 퀀트 · 백테스트 검증 · 2026

백테스트 유의성 검정 — 무작위 데이터도 샤프 0.59

퀀트 · 시스템 트레이딩 2026-09-11 · 약 9분 읽기 · 알고랩 AlgoLab
한 줄 요약 백테스트 성적표에 적힌 샤프지수·승률·누적수익률은 그 자체로는 아무것도 증명하지 못합니다. 같은 전략을 KOSPI 지수 21년(5,352거래일)으로 직접 돌려 재 본 결과 — ① 거래 49건의 평균 수익률 +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년으로 길고 누구나 같은 소스로 재현할 수 있어서일 뿐입니다. 본문의 모든 수치는 과거 데이터의 통계량이며 미래 성과와 무관합니다. 통계적 유의성은 수익성과 다른 개념입니다 — 유의해도 비용·슬리피지·체결 후에는 남지 않을 수 있습니다.

이 글에서 확인할 것

  1. 성적표만으로는 왜 아무것도 모르나
  2. 계산 대상과 재현 조건
  3. 검정 ① 거래 단위 t검정 — 49건은 판정 불가
  4. 검정 ② 표본이 몇 건이어야 하나
  5. 검정 ③ 샤프지수의 표준오차와 PSR
  6. 검정 ④ 다중검정 — 무작위로도 0.59가 나온다
  7. DSR 기준선이 실증 귀무보다 느슨했던 이유
  8. 실무 체크리스트 · 한계

1. 성적표만으로는 왜 아무것도 모르나

제작 의뢰 때 가장 자주 받는 자료가 백테스트 성적표 한 장입니다 — 누적수익률 몇 배, CAGR 몇 %, MDD 몇 %, 샤프지수 얼마. 여기엔 빠진 정보 세 가지가 있습니다.

세 번째는 다중검정·데이터 스누핑으로 이미 따로 다뤘고, 데이터 자체가 오염되는 경우는 미래참조 편향생존편향에서 다뤘습니다. 이 글은 그다음 단계 — 데이터도 코드도 맞다고 할 때, 그 결과가 우연인지를 어떻게 판정하는가입니다. 샤프지수·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.30440.05 이상 → 판정 불가
이항검정 p0.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값(양측)판정
200.6630.5151판정 불가
300.8120.4232판정 불가
49 (실제)1.0380.3044판정 불가
801.3270.1885판정 불가
1201.6250.1069판정 불가
1751.9620.0513경계 (아슬아슬하게 미달)
2502.3450.0198유의
4002.9660.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.08795% 기준 미달
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조합을 전부 돌렸습니다.

순위단기장기연환산 샤프
151100.628
260900.617
3351100.614
4351200.607
5551100.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.20 0.40 0.60 0.80 1.00 연환산 샤프지수 0.590 무작위 데이터 최고 샤프 평균 95%: 0.776 DSR 기준선 0.205 실제 0.628 우연 평균보다 +0.038
순서를 섞은 무작위 데이터 200회 × 251조합 탐색 — 최고 샤프의 분포와 실제 값의 위치

예측 가능성이 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. 실무 체크리스트

백테스트 결과를 받았을 때 확인할 순서입니다.

  1. 거래 수를 먼저 묻는다. 누적수익률보다 앞입니다. 50건 미만이면 그 성적표로는 판정할 수 없습니다.
  2. 평균과 표준편차를 함께 받는다. (1.96 · s / m)²로 필요 표본 수를 즉석에서 어림합니다.
  3. 승률만 강조하면 의심한다. 추세추종은 승률 30%대가 정상이고, 반대로 승률 90%짜리는 꼬리 위험이 숨어 있을 수 있습니다.
  4. 샤프지수에 신뢰구간을 요구한다. 0을 포함하면 "0과 구분 안 됨"입니다.
  5. 시험한 파라미터 조합 수를 받는다. 안 세어 봤다면 그 자체가 답입니다.
  6. 순서 섞기 귀무분포와 비교한다. 실제 값이 그 분포의 95백분위를 넘지 못하면 근거가 없습니다.
  7. 비용을 반영한 뒤 1~6을 다시 돌린다. 비용은 평균을 깎고, 평균이 깎이면 필요 표본이 제곱으로 늘어납니다.

검정을 통과했다고 해서 끝이 아닙니다 — 백테스트와 실전의 괴리는 통계와 별개로 남고, 수익 인증의 함정은 여전히 유효합니다. 검정은 탈락시키는 도구이지 합격증이 아닙니다.

9. 한계 — 이 글이 답하지 않는 것

실계좌로 넘어가기 전 단계별 확인은 실전 투입 전 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). 과거 데이터의 통계량은 미래 성과를 보장하지 않으며, 이 글은 종목·전략 추천이 아니라 검정 절차 안내입니다. 통계 개념의 정확한 정의와 적용 조건은 학술 원문과 공식 문서로 확인하십시오.

검증까지 포함한 자동매매를 만들고 싶다면

전략 검정·비용 반영 백테스트·실계좌 전환까지 묶어 제작합니다. 24시간 빠른 답변 가능합니다.

무료 상담 시작하기