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

몬테카를로 백테스트 — MDD −16%가 −34%까지

퀀트 · 백테스트 2026-10-02 · 약 8분 읽기 · 알고랩 AlgoLab
한 줄 요약

백테스트 MDD는 "그 순서로 한 번 일어난 값"이라, 실전에서 겪을 낙폭의 하한에 가깝습니다. KODEX 200(069500)에 120일 이동평균 규칙을 2014-07-11 ~ 2025-12-30(2,816거래일) 돌리면 백테스트 MDD는 −16.47%, CAGR 10.00%입니다. 같은 일별 수익률을 20일 블록으로 섞어 10,000번 다시 이어 붙이는 몬테카를로(블록 부트스트랩)를 돌리면 MDD 중앙값은 −20.75%, 하위 5%는 −33.55%였고, 10,000개 경로 중 81.5%가 백테스트보다 깊이 빠졌습니다. 봇의 손절선·투입금은 백테스트 MDD가 아니라 이 분포의 꼬리로 정해야 합니다. (가격 기준·배당 미포함, 전환 1회당 비용 0.1% 가정, 과거≠미래)

목차

  1. 왜 백테스트 MDD 하나로는 부족한가
  2. 실험 설계 — 전략 1개, 몬테카를로 2가지
  3. 결과 — MDD 분포와 꼬리
  4. 거래 순서 섞기 — 실제 순서는 운이 좋은 편이었다
  5. 함정 4가지 — 몬테카를로를 잘못 돌리는 법
  6. 재현 코드 전체
  7. 자동매매로 옮길 때 — 숫자를 어디에 쓰나
  8. 자주 묻는 질문

1. 왜 백테스트 MDD 하나로는 부족한가

백테스트가 돌려주는 MDD(최대 낙폭)는 역사가 실제로 그 순서로 흘러갔을 때의 값입니다. 2020년 3월 급락이 2월이 아니라 9월에 왔다면, 혹은 손실 거래 다섯 번이 흩어지지 않고 연달아 왔다면 숫자는 달라집니다. 수익률(CAGR)은 순서가 바뀌어도 거의 같지만, 낙폭은 순서에 민감합니다.

상담에서 가장 자주 보는 장면이 이것입니다. "백테스트 MDD가 −15%니까 −20%에서 봇을 끄겠다"고 정해 두고, 실전에서 −21%를 만나 전략을 버립니다. 전략이 고장 난 게 아니라 처음부터 −20%대는 흔한 범위였을 수 있습니다. 몬테카를로 시뮬레이션은 그 "흔한 범위"를 숫자로 보여 주는 도구입니다.

2. 실험 설계 — 전략 1개, 몬테카를로 2가지

항목설정
종목KODEX 200 (069500) 종가 — FinanceDataReader의 fdr.DataReader("069500")
기간2014-07-11 ~ 2025-12-30 (기준일 ASOF 고정, 2,816거래일). 시작일은 라이브러리가 돌려준 첫 날짜
규칙전일 종가 > 120일 이동평균이면 보유, 아니면 현금 (shift(1)로 다음 날 반영)
비용보유↔현금 전환 1회당 0.1% 차감
몬테카를로 A거래 순서 섞기 — 거래 41건의 수익률을 numpy permutation으로 섞어 10,000번
몬테카를로 B블록 부트스트랩 — 전략 일별 수익률을 20거래일 블록으로 무작위 복원추출, 같은 길이로 10,000번
난수 시드numpy.random.default_rng(20261002)
환경Python 3.10.4 · numpy 2.0.2 · pandas 2.2.3 · FinanceDataReader 0.9.202

두 방법은 묻는 질문이 다릅니다. A는 "같은 거래가 다른 순서로 왔다면?"이고, B는 "비슷한 성격의 시장이 다르게 이어졌다면?"입니다. A는 거래 결과 자체를 바꾸지 않아 최종 수익률이 고정되고, B는 블록을 복원추출하므로 수익률까지 흔들립니다. 블록을 20일로 묶은 이유는 하루 단위로 섞으면 변동성이 몰려 오는 성질(급락 다음 날 또 급락)이 사라져 꼬리가 실제보다 얇게 나오기 때문입니다.

3. 결과 — MDD 분포와 꼬리

구분CAGRMDD
백테스트 (120일선)10.00%−16.47%
단순 보유 (참고)10.22%−38.09%
몬테카를로 B 중앙값9.54%−20.75%
몬테카를로 B 하위 5%2.90%−33.55%
몬테카를로 B 하위 1%—−40.48%
−50~45−45~40−40~35 −35~30−30~25−25~20 −20~15−15~10−10~5 742536671,4882,9873,518974 백테스트 −16.47% 하위 5% −33.55% MDD 구간(%) · 막대 = 10,000개 경로 중 해당 구간 개수 (−55~−50 구간 4개는 맨 왼쪽 막대에 포함)
블록 부트스트랩 10,000회 — 백테스트 MDD(초록)보다 오른쪽은 18.5%뿐

읽는 법은 간단합니다. 초록 선이 실제 백테스트, 막대가 "있을 법했던" 1만 개의 역사입니다. 초록 선 왼쪽, 즉 더 깊이 빠진 경로가 81.5%입니다. 기준선별로 세면 이렇습니다.

같은 방법으로 단순 보유를 돌리면 MDD 중앙값 −36.09%, 하위 5% −57.06%였습니다. 120일선 규칙이 낙폭을 줄인다는 결론 자체는 몬테카를로에서도 유지됩니다. 달라지는 것은 "얼마나 줄이느냐"의 크기입니다 — 백테스트 숫자만 보면 −38%를 −16%로 줄인 것 같지만, 분포로 보면 −36%를 −21%로 줄인 쪽에 가깝습니다.

4. 거래 순서 섞기 — 실제 순서는 운이 좋은 편이었다

몬테카를로 A는 거래 41건을 그대로 두고 순서만 섞습니다. 거래 단위 자산곡선의 MDD는 실제 순서에서 −10.55%였는데, 섞은 10,000번 중 97.1%가 이보다 깊었습니다. 중앙값 −16.46%, 하위 5% −24.99%, 최악 −34.62%.

이유는 거래 구성을 보면 보입니다. 41건 중 이긴 거래는 8건(승률 19.5%), 진 거래 33건은 평균 −1.43%로 작게 잃었습니다. 대신 가장 좋은 한 건이 +82.79%였습니다 — 2025-04-24에 진입해 기준일 2025-12-30까지 아직 청산되지 않은 마지막 거래입니다. 전체 41건을 복리로 이으면 +190.1%인데 그 한 건을 빼면 +58.7%입니다. 추세추종이 원래 이런 모양입니다 — 작은 손실을 오래 견디고 큰 추세 한두 번으로 회복합니다. 실제 역사에서는 손실 거래가 띄엄띄엄 왔지만, 순서가 조금만 나빴어도 −1%대 손실이 10번 넘게 연달아 쌓일 수 있었습니다.

실전에서 이 숫자가 뜻하는 것 — 승률 20%짜리 전략에서 연속 손절 10회는 이상 신호가 아닙니다. 봇에 "연속 손실 N회면 정지" 규칙을 넣는다면 N은 이 분포를 보고 정해야지, 백테스트에서 실제로 나왔던 최장 연패로 정하면 정상 구간에서 봇이 꺼집니다.

5. 함정 4가지 — 몬테카를로를 잘못 돌리는 법

① 하루 단위로 섞는다

일별 수익률을 한 개씩 rng.choice로 뽑으면 급락이 몰리는 구간이 흩어집니다. 꼬리가 얇아져 "생각보다 안전하다"는 잘못된 결론이 납니다. 최소한 주·월 단위 블록(5~20일)으로 묶으십시오.

② 거래 순서 섞기로 수익률 분포를 본다

순서만 섞으면 최종 수익률은 모든 경로에서 같습니다(곱셈 순서가 바뀔 뿐). A로 볼 수 있는 건 낙폭과 연패뿐입니다. 수익률의 불확실성을 보려면 B처럼 복원추출해야 합니다.

③ 과최적화된 전략에 돌린다

몬테카를로는 입력된 수익률이 미래에도 비슷한 성격일 것을 전제합니다. 파라미터를 수십 개 돌려 고른 전략이라면 입력 자체가 부풀려져 있어, 분포 전체가 낙관적으로 나옵니다. 순서는 워크포워드 검증이 먼저, 몬테카를로가 그다음입니다.

④ 비용을 빼고 돌린다

전환 비용이 빠진 수익률로 섞으면 매 경로마다 같은 오차가 누적됩니다. 이 실험에서는 전환 1회당 0.1%를 일별 수익률에서 먼저 차감한 뒤 섞었습니다. 비용 가정은 매매비용 정리 글에서 증권사·세율별로 다룹니다.

6. 재현 코드 전체

기준일 ASOF를 고정해 두었기 때문에, 같은 라이브러리 버전이면 위 숫자가 그대로 나옵니다. 최신 데이터로 보려면 ASOF = None으로 바꾸면 되지만 그때는 숫자가 달라집니다.

import numpy as np, pandas as pd, FinanceDataReader as fdr

ASOF = "2025-12-30"   # 기준일 고정 — None 이면 실행 시점까지 받아 숫자가 달라진다
SEED, N, MA, FEE, BLOCK = 20261002, 10_000, 120, 0.001, 20

px  = fdr.DataReader("069500", "2010-01-01", ASOF)["Close"].astype(float)
ret = px.pct_change().fillna(0)
sig = (px > px.rolling(MA).mean()).shift(1).fillna(False).astype(int)
sig.iloc[:MA] = 0
sr  = ret * sig - sig.diff().abs().fillna(0) * FEE   # 전환 비용 차감

def mdd(eq):  return (eq / np.maximum.accumulate(eq) - 1).min()
def cagr(eq): return eq[-1] ** (252 / len(eq)) - 1

eq = (1 + sr).cumprod().values
print(f"backtest CAGR {cagr(eq):.2%}  MDD {mdd(eq):.2%}")

rng, r, n = np.random.default_rng(SEED), sr.values, len(sr)
nb = int(np.ceil(n / BLOCK))
sims = []
for _ in range(N):
    starts = rng.integers(0, n - BLOCK, nb)
    path = np.concatenate([r[s:s + BLOCK] for s in starts])[:n]
    e = np.cumprod(1 + path)
    sims.append((mdd(e), cagr(e)))
m, c = np.array(sims).T

print(f"MDD median {np.median(m):.2%}  p5 {np.percentile(m, 5):.2%}  p1 {np.percentile(m, 1):.2%}")
print(f"P(MDD worse than backtest) {(m < mdd(eq)).mean():.1%}")
print(f"CAGR p5 {np.percentile(c, 5):.2%}  median {np.median(c):.2%}")

실행 결과(위 환경 기준):

backtest CAGR 10.00%  MDD -16.47%
MDD median -20.75%  p5 -33.55%  p1 -40.48%
P(MDD worse than backtest) 81.5%
CAGR p5 2.90%  median 9.54%

⚠️ FinanceDataReader의 Close는 분배금(배당)을 반영하지 않은 가격입니다. 배당을 넣으면 두 전략 모두 수익률이 올라가지만 이 글의 비교 구조는 바뀌지 않습니다. 샤프지수·MDD를 quantstats로 계산할 때의 차이는 샤프지수·MDD 계산 글을 참고하십시오.

7. 자동매매로 옮길 때 — 숫자를 어디에 쓰나

몬테카를로 결과는 매매 신호가 아니라 봇의 안전장치 값을 정하는 데 씁니다.

한국투자증권 KIS Open API로 이 규칙을 돌린다면 구조는 단순합니다. 장 마감 후 국내주식 기간별 시세 inquire-daily-itemchartprice(tr_id FHKST03010100)로 069500 일봉을 받아 120일 평균과 비교하고, 신호가 바뀐 날만 다음 날 현금 주문 TTTC0012U(모의 VTTC0012U)를 냅니다. 일봉 TR의 100건 제한과 연속조회는 FHKST03010100 정리 글, 주문 tr_id 변경 내역은 TTTC0012U 글에 있습니다. 봇이 기록한 실현 낙폭이 몬테카를로 하위 5%를 넘는지 매일 비교하는 로그 한 줄을 넣어 두면, "전략이 고장 났나"를 감이 아니라 숫자로 판단할 수 있습니다. 실전 투입 전 단계별 점검은 실전 투입 전 검증 3단계를 보십시오.

한계 — 몬테카를로는 과거 수익률을 다시 섞는 것이라, 과거에 없던 종류의 충격(거래 정지, 제도 변경, 유동성 붕괴)은 만들지 못합니다. 분포의 하위 1%도 "최악"이 아니라 "과거 재료로 만들 수 있는 나쁜 경우"입니다. 이 글의 수치는 특정 기간·특정 규칙의 학술적 실험이며 미래 수익이나 낙폭을 예측하지 않습니다.

8. 자주 묻는 질문

몬테카를로 시뮬레이션 백테스트가 무엇인가요?

백테스트에서 나온 수익률(일별 또는 거래별)을 무작위로 다시 섞거나 복원추출해 수천~수만 개의 가상 자산곡선을 만들고, 그 분포에서 MDD·CAGR의 범위를 보는 방법입니다. 이 글에서는 KODEX 200 120일선 전략의 일별 수익률을 20거래일 블록으로 복원추출해 10,000개 경로를 만들었습니다.

거래 순서 섞기와 블록 부트스트랩 중 무엇을 써야 하나요?

목적이 다릅니다. 거래 순서 섞기는 최종 수익률이 고정되고 낙폭과 연패만 달라지므로 손절·정지 조건을 정할 때 씁니다. 블록 부트스트랩은 복원추출이라 수익률까지 흔들려 투입금과 기대 범위를 볼 때 씁니다. 이 글의 실험에서는 거래 순서 섞기 MDD 하위 5%가 −24.99%, 블록 부트스트랩 하위 5%가 −33.55%였습니다.

몇 번 돌려야 하나요?

분포의 하위 1~5%를 보려면 최소 수천 번이 필요합니다. 이 글은 10,000번을 돌렸고 데이터 다운로드를 포함해도 이 PC에서 10초 안에 끝났습니다. 난수 시드를 고정해야 같은 숫자가 다시 나옵니다.

몬테카를로 결과가 좋으면 실전에서도 안전한가요?

아닙니다. 몬테카를로는 과거 수익률을 재배열할 뿐이라 과거에 없던 충격은 만들지 못하고, 과최적화된 전략에 돌리면 분포 전체가 낙관적으로 나옵니다. 워크포워드 검증을 먼저 하고, 그 뒤에 낙폭 범위를 보는 용도로 쓰는 편이 안전합니다. 과거 성과는 미래를 보장하지 않습니다.

낙폭 한도까지 정해 두고 지키는 봇, 만들어 드립니다

전략 규칙과 감당 가능한 손실 범위, 증권사만 알려 주시면 됩니다. 24시간 빠른 답변 가능합니다.

무료 상담 시작하기