백테스트 시작일 민감도 — 승률 0/12가 12/12로
백테스트 결과는 전략만큼이나 "언제 시작해서 언제 끝냈나"가 정합니다. 코스피200 지수(KS200)에 120일 이동평균 규칙(위면 보유, 아래면 현금)을 걸고 시작일만 매달 바꿔 5년씩 139번 돌렸습니다. 2010~2012년에 시작한 5년은 36번 모두 단순 보유에 졌고, 2017년에 시작한 5년은 12번 모두 이겼습니다. 전체로 보면 수익률(CAGR)로 이긴 비율은 46.0%, 낙폭(MDD)이 더 얕았던 비율은 73.4%입니다. 시작일을 하나만 보여 주는 백테스트는 결론이 아니라 표본 1개입니다. (가격 지수·배당 미포함, 전환 1회당 비용 0.1%, 기준일 2026-09-17 고정, 과거≠미래)
목차
- 왜 시작일 하나로 결론을 내면 안 되나
- 실험 설계 — 전략은 고정, 날짜만 바꾼다
- 결과 ① 시작 연도별 5년 승패
- 결과 ② 같은 해, 시작 월 12개
- 결과 ③ 끝나는 날짜만 바꿔도 뒤집힌다
- 함정 — 데이터를 시작일부터 받으면 생기는 일
- '좋은 날짜를 고른 백테스트' 알아보는 법
- 재현 코드
- 자동매매로 옮길 때
- 자주 묻는 질문
1. 왜 시작일 하나로 결론을 내면 안 되나
제작 상담에서 받는 백테스트 캡처에는 거의 항상 시작일이 하나뿐입니다. "2020년부터 돌렸더니 연 20%"처럼요. 그런데 그 날짜가 왜 2020년 1월인지, 2019년 7월이나 2021년 3월이면 어땠는지는 적혀 있지 않습니다. 사람은 숫자가 예쁘게 나온 날짜에서 멈추기 쉽고, 그 자체가 데이터 스누핑의 가장 흔한 형태입니다.
지난주에 워크포워드로 "파라미터를 고른 운"을, 몬테카를로로 "사건이 일어난 순서의 운"을 걷어냈습니다. 이번 글은 세 번째 운, "언제 시작하고 언제 끝냈나"를 숫자로 봅니다. 전략은 하나도 건드리지 않고 날짜만 바꿉니다.
2. 실험 설계 — 전략은 고정, 날짜만 바꾼다
| 항목 | 설정 |
|---|---|
| 데이터 | 코스피200 지수 종가 — FinanceDataReader fdr.DataReader("KS200"), 2002-01-02 ~ 2026-09-17 (6,097거래일). ASOF 고정 |
| 전략 | 전일 종가 > 120일 이동평균이면 보유, 아니면 현금. shift(1)로 다음 날 반영 |
| 비교 | 같은 구간 단순 보유(Buy & Hold) |
| 비용 | 보유↔현금 전환 1회당 0.1% |
| 실험 B (3장) | 2010-01 ~ 2021-09 매월 첫 거래일 시작, 5년(1,260거래일)씩 → 데이터가 다 차는 139개 구간 |
| 실험 A (4장) | 2020년 1~12월 각 월 시작 → 기준일까지 (시작 월 12개) |
| 실험 D (5장) | 2020-01-02 시작 고정, 끝나는 날짜만 3가지 |
| 환경 | Python 3.10.4 · pandas 2.2.3 · numpy 2.0.2 · FinanceDataReader 0.9.202 |
지수를 쓴 이유는 기간 때문입니다. KODEX 200(069500)은 FinanceDataReader에서 2014년 7월 이후만 받아져 2010년대 초반 구간이 빠집니다. 지수는 거래할 수 없으니 실제 봇은 ETF로 돌린다는 전제이고, 분기 말 종가로 대조해 보면 두 시리즈는 거의 100배 관계로 함께 움직였습니다.
가장 중요한 설정은 신호를 전체 이력으로 먼저 만들고, 그다음에 구간을 자른다는 점입니다. 그래야 시작일마다 이동평균이 이미 계산된 상태에서 출발합니다. 이걸 거꾸로 하면 생기는 문제는 6장에서 따로 봅니다.
3. 결과 ① 시작 연도별 5년 승패
139개 구간 전체 분포부터 보면, 같은 전략의 5년 CAGR이 −7.49%에서 +29.87%까지 퍼졌습니다. 최악은 2011-05-02 시작(보유 −3.26%), 최고는 2021-05-03 시작(보유 +26.59%)입니다.
| 지표 (139구간) | 최소 | 1사분위 | 중앙값 | 3사분위 | 최대 |
|---|---|---|---|---|---|
| 120일선 CAGR | −7.49% | 0.40% | 3.12% | 6.88% | 29.87% |
| 보유 CAGR | −3.26% | 0.97% | 3.46% | 6.27% | 26.59% |
| 120일선 MDD | −44.77% | −22.63% | −17.31% | −15.65% | −14.74% |
| 보유 MDD | −41.19% | −41.05% | −36.11% | −24.82% | −18.73% |
중앙값만 보면 둘은 비슷합니다(3.12% vs 3.46%). 그런데 시작 연도로 묶으면 그림이 완전히 갈립니다.
| 시작 연도 | 120일선 승 | 120일선 CAGR 중앙값 | 보유 CAGR 중앙값 |
|---|---|---|---|
| 2010 | 0/12 | −3.41% | 2.28% |
| 2011 | 0/12 | −5.73% | −1.77% |
| 2012 | 0/12 | −1.28% | 4.63% |
| 2013 | 4/12 | 1.15% | 3.27% |
| 2014 | 3/12 | 1.12% | 1.82% |
| 2015 | 3/12 | 2.68% | 4.25% |
| 2016 | 4/12 | 9.86% | 11.37% |
| 2017 | 12/12 | 6.74% | 0.90% |
| 2018 | 11/12 | 5.08% | 1.62% |
| 2019 | 11/12 | 6.70% | 5.23% |
| 2020 | 10/12 | 11.36% | 9.59% |
| 2021 | 6/7 | 21.39% | 19.66% |
이유는 전략이 아니라 시장의 모양입니다. 2011~2016년 코스피는 오랫동안 좁은 박스권에 머물렀고, 이동평균 규칙은 박스권에서 들락날락하며 비용과 손절을 쌓습니다. 반대로 2017년 이후 시작한 5년에는 2018년 하락·2020년 3월 급락처럼 길고 깊은 하락이 한 번 이상 들어 있어, 현금으로 피한 효과가 박스권 비용을 넘었습니다.
여기서 얻을 결론은 "120일선이 좋다/나쁘다"가 아닙니다. "2012년부터 돌려 봤더니 추세추종은 안 된다"와 "2017년부터 돌려 봤더니 추세추종이 보유를 이긴다"가 똑같은 코드에서 나온다는 것입니다. 두 사람 다 거짓말을 하지 않았고, 두 결론 모두 표본 1개짜리입니다.
바뀌지 않은 것도 있다 — 낙폭은 139개 중 102개(73.4%)에서 120일선이 더 얕았습니다. 수익률 우위는 시작일에 따라 뒤집혔지만, "큰 하락을 줄인다"는 성질은 비교적 일관됐습니다. 시작일을 흔들어도 살아남는 쪽이 그 전략의 진짜 성질에 가깝습니다.
4. 결과 ② 같은 해, 시작 월 12개
이번에는 끝을 기준일(2026-09-17)로 고정하고 2020년 안에서 시작 월만 바꿨습니다.
| 시작일 | 120일선 CAGR | 보유 CAGR | 차이 |
|---|---|---|---|
| 2020-01-02 | 17.89% | 21.90% | −4.01%p |
| 2020-02-03 | 18.47% | 22.55% | −4.08%p |
| 2020-03-02 | 19.33% | 23.88% | −4.55%p |
| 2020-04-01 | 19.62% | 27.81% | −8.19%p |
| 2020-05-04 | 19.89% | 26.13% | −6.24%p |
| 2020-06-01 | 20.16% | 24.72% | −4.56%p |
| 2020-07-01 | 21.09% | 24.59% | −3.50%p |
| 2020-08-03 | 20.10% | 23.63% | −3.53%p |
| 2020-09-01 | 19.71% | 23.27% | −3.56%p |
| 2020-10-05 | 19.77% | 23.39% | −3.62%p |
| 2020-11-02 | 20.57% | 24.26% | −3.69%p |
| 2020-12-01 | 17.91% | 21.57% | −3.66%p |
보유 쪽은 시작 월에 따라 21.57%~27.81%, 6%p 넘게 흔들립니다. 가장 높은 4월 시작은 2020년 3월 급락 바로 다음 달입니다. 바닥 직후에 시작한 백테스트는 거의 무엇이든 좋아 보입니다. 반대로 120일선은 3월 급락을 현금으로 피했기 때문에 시작 월에 덜 민감했습니다(17.89%~21.09%).
이 구간에서는 12개 모두 보유가 이겼습니다. 3장의 2020년 시작 5년 구간(10/12 승)과 반대 결론입니다. 차이는 끝나는 날짜 하나뿐입니다 — 그래서 다음 실험이 필요합니다.
5. 결과 ③ 끝나는 날짜만 바꿔도 뒤집힌다
시작은 2020-01-02로 고정하고 끝만 바꿨습니다.
| 끝나는 날 | 120일선 CAGR / MDD | 보유 CAGR / MDD | 승자 |
|---|---|---|---|
| 2025-12-30 | 15.02% / −15.65% | 13.42% / −36.11% | 120일선 |
| 2026-06-22 | 31.19% / −21.13% | 29.50% / −36.11% | 120일선 |
| 2026-09-17 | 17.89% / −46.88% | 21.90% / −40.94% | 보유 |
마지막 줄이 이 글에서 가장 불편한 숫자입니다. 2026-06-22 고점 이후 지수가 빠르게 내려왔는데, 직전 상승이 가팔랐던 탓에 120일선이 한참 아래에 있어 규칙은 7월 말까지 하락을 그대로 맞았습니다. 그 뒤로는 가격이 120일선 근처를 오르내리며 9월 중순까지 11번 전환했고, 반등 구간 일부를 현금으로 놓쳤습니다. 그 결과 120일선의 MDD가 보유보다 깊어졌습니다(−46.88%, 2026-06-22 → 09-14). 석 달 사이에 "낙폭을 줄이는 전략"이 "낙폭이 더 큰 전략"으로 바뀐 것입니다.
끝나는 날짜도 고르는 것이다 — "최근까지 돌렸다"는 백테스트도 실행한 날이 언제냐에 따라 결론이 다릅니다. 같은 사람이 6월에 돌렸다면 "120일선 승, MDD −21%"를, 9월에 돌렸다면 "보유 승, MDD −47%"를 봤을 겁니다. 공개 코드에 ASOF를 고정하는 이유가 이것입니다.
6. 함정 — 데이터를 시작일부터 받으면 생기는 일
시작일 민감도를 직접 돌릴 때 가장 많이 하는 실수입니다. 시작일마다 fdr.DataReader("KS200", start)로 그날부터 데이터를 다시 받으면, 처음 120거래일은 이동평균이 없어서 규칙이 강제로 현금에 머뭅니다. 반년 가까이 전략이 아니라 "아무것도 안 함"을 측정하는 셈입니다.
[C] warm-up trap: download from start date only
2020-01-01 full-history signal CAGR 17.89% | start-only download CAGR 19.39% (first 120 days forced cash)
2020-04-01 full-history signal CAGR 19.62% | start-only download CAGR 18.34% (first 120 days forced cash)
1월 시작은 강제 현금 기간이 3월 급락을 우연히 피해 1.5%p 좋아졌고, 4월 시작은 반등을 놓쳐 1.3%p 나빠졌습니다. 방향조차 일정하지 않습니다. 해결은 단순합니다 — 지표 계산에 필요한 기간만큼 앞에서 더 받아 두고(여기서는 2002년부터), 신호를 만든 뒤에 구간을 자릅니다. backtrader라면 fromdate를 앞당기고 next()에서 날짜로 거래 시작을 막고, vectorbt라면 신호 배열을 만든 뒤 .loc[start:]로 자르는 순서입니다.
7. '좋은 날짜를 고른 백테스트' 알아보는 법
제작 의뢰 전에 받은 백테스트, 또는 판매 페이지의 성과표를 볼 때 확인할 다섯 가지입니다.
- 시작일이 큰 저점 직후인가 — 2009년 3월, 2020년 3~4월, 2022년 10월 같은 날짜에서 시작하면 거의 모든 전략이 좋아 보입니다.
- 끝나는 날이 큰 고점인가 — 끝을 고점에 맞추면 최근 하락이 통째로 빠집니다. 5장처럼 석 달 차이로 승패가 바뀝니다.
- 시작일을 왜 그날로 했는지 설명이 있는가 — "데이터가 있는 첫날", "상장일" 같은 이유는 괜찮습니다. 이유가 없다면 여러 날짜 중 고른 것일 수 있습니다.
- 롤링 구간 결과를 보여 줄 수 있는가 — 3장처럼 매월 시작 N년 구간의 분포(최소·중앙·최대)를 요청하십시오. 중앙값과 최악값이 하나의 숫자보다 훨씬 많은 것을 알려 줍니다.
- 낙폭은 같은 방식으로 흔들어도 유지되는가 — 수익률 우위보다 MDD 같은 성질이 시작일에 덜 민감한 경우가 많습니다. 흔들어도 남는 쪽을 근거로 삼습니다.
이 다섯 가지는 미래참조 편향·생존편향 점검과 함께 보면 좋습니다. 그 둘이 "데이터가 틀렸나"를 묻는다면, 시작일 점검은 "데이터는 맞는데 운 좋은 구간만 봤나"를 묻습니다.
8. 재현 코드
아래는 3장(5년 롤링 139구간)을 만드는 핵심 부분입니다. 실험 A~D 전체(워밍업 함정 실험 C 포함)는 backtest_start_date_sensitivity.py로 받을 수 있습니다. ASOF를 고정해 두었기 때문에 같은 라이브러리 버전이면 위 숫자가 그대로 나옵니다. 최신 데이터로 보려면 ASOF = None으로 바꾸면 되지만, 5장에서 본 것처럼 숫자와 결론이 달라질 수 있습니다.
import numpy as np, pandas as pd, FinanceDataReader as fdr
ASOF = "2026-09-17" # 기준일 고정 — None 이면 실행 시점까지 받아 숫자가 달라진다
MA, FEE, YEARS = 120, 0.001, 5
px = fdr.DataReader("KS200", "2002-01-01", ASOF)["Close"].astype(float).dropna()
px = px[px.index <= ASOF] # 캐시든 새 다운로드든 같은 자르기
ret = px.pct_change().fillna(0)
sig = (px > px.rolling(MA).mean()).shift(1, fill_value=False).astype(int)
sig.iloc[:MA] = 0
SR = ret * sig - sig.diff().abs().fillna(0) * FEE # 신호는 '전체 이력'으로 만든다
def stats(r):
eq = (1 + r).cumprod().values
return eq[-1] ** (252 / len(eq)) - 1, (eq / np.maximum.accumulate(eq) - 1).min()
rows, n = [], 252 * YEARS
for st in pd.date_range("2010-01-01", "2021-09-01", freq="MS"):
i = px.index.get_loc(px.index[px.index >= st][0])
s, b = SR.iloc[i + 1:i + 1 + n], ret.iloc[i + 1:i + 1 + n]
if len(s) < n: continue
rows.append((st.year, *stats(s), *stats(b)))
B = pd.DataFrame(rows, columns=["year", "sC", "sM", "bC", "bM"])
print(f"windows {len(B)} strat wins {(B.sC > B.bC).mean():.1%} shallower MDD {(B.sM > B.bM).mean():.1%}")
print((B.sC > B.bC).groupby(B.year).agg(lambda w: f"{w.sum()}/{len(w)}").to_string())
실행 결과(위 환경, 빈 폴더에서 처음부터 실행):
windows 139 strat wins 46.0% shallower MDD 73.4%
year
2010 0/12
2011 0/12
2012 0/12
2013 4/12
2014 3/12
2015 3/12
2016 4/12
2017 12/12
2018 11/12
2019 11/12
2020 10/12
2021 6/7
⚠️ KS200은 배당을 포함하지 않는 가격 지수입니다. 배당을 넣으면 보유 쪽이 상대적으로 유리해집니다(현금 구간에는 배당이 없으므로). MDD·샤프지수를 quantstats로 계산할 때 주의점은 샤프지수·MDD 계산 글에 있습니다.
9. 자동매매로 옮길 때
시작일 민감도는 매매 신호가 아니라 기대치를 정하는 도구입니다. 봇을 켜는 날도 하나의 "시작일"이기 때문입니다. 실전 봇 운영에는 이렇게 씁니다.
- 첫 1~2년 성적을 해석하는 기준: 3장의 1사분위(
CAGR0.40%)~3사분위(6.88%)가 "정상 범위"입니다. 켜고 나서 2년째 보유보다 뒤처진다고 바로 전략을 버리면, 2011년에 시작한 사람이 그랬듯 정상 구간에서 끄게 됩니다. - 분할 시작: 시작 월에 따라 결과가 6%p씩 갈린다면, 투입금을 몇 달에 나눠 넣어 시작일 운을 평균 내는 방법도 있습니다.
- 데이터 수집: 한국투자증권
KISOpen API라면 국내주식 기간별 시세inquire-daily-itemchartprice(tr_idFHKST03010100)로069500일봉을 받아 120일 평균을 계산합니다. 1회 100건 제한이 있어 120일 워밍업을 채우려면 최소 두 번 나눠 받아야 합니다 — 6장 함정의 실전판입니다. 자세한 방법은 FHKST03010100 정리 글에 있습니다. - 기록: 봇이 켜진 날짜와 그날 기준 롤링 분포상의 위치를 로그로 남겨 두면, 나중에 "전략이 고장 났나, 시작일이 나빴나"를 숫자로 가를 수 있습니다. 실전 투입 순서는 실전 투입 전 검증 3단계를 보십시오.
한계 — 이 실험은 지수 1개·규칙 1개·특정 기간의 학술적 실험입니다. 롤링 구간은 서로 겹치므로 139개가 139개의 독립 표본은 아닙니다. 지수는 직접 거래할 수 없고, ETF 추적오차·호가 단위·세금은 반영하지 않았습니다. 어떤 수치도 미래 수익이나 낙폭을 예측하지 않으며, 과거 성과는 미래를 보장하지 않습니다.
10. 자주 묻는 질문
백테스트 시작일은 어떻게 정해야 하나요?
하나로 정하지 않는 것이 가장 안전합니다. 데이터가 있는 첫날을 기본으로 하되, 매월 시작하는 N년 롤링 구간을 모두 돌려 최소·중앙값·최대를 함께 봅니다. 이 글에서는 매월 시작 5년 구간 139개를 돌렸고, 같은 전략의 5년 CAGR이 −7.49%에서 +29.87%까지 퍼졌습니다.
롤링 백테스트는 몇 년 구간으로 돌려야 하나요?
전략이 한 번의 상승·하락 국면을 겪을 수 있는 길이가 필요합니다. 일봉 추세추종이라면 3~5년이 흔한 선택이고, 이 글은 5년(1,260거래일)을 썼습니다. 구간이 길수록 결과는 안정되지만 구간 개수가 줄고, 겹치는 구간은 독립 표본이 아니라는 점을 감안해야 합니다.
끝나는 날짜도 결과에 영향을 주나요?
줍니다. 2020-01-02 시작을 고정하고 끝만 바꾸면 2025-12-30·2026-06-22까지는 120일선이 이기고 MDD도 −15.65%·−21.13%였지만, 2026-09-17까지 늘리면 보유가 이기고 120일선 MDD가 −46.88%로 깊어졌습니다. 공개 코드에 기준일(ASOF)을 고정하는 이유입니다.
시작일을 바꿔도 결과가 비슷하면 좋은 전략인가요?
필요조건일 뿐 충분조건은 아닙니다. 시작일에 둔감하더라도 파라미터를 고른 운이나 데이터 오류가 남아 있을 수 있으므로 워크포워드·생존편향·미래참조 점검을 함께 거쳐야 합니다. 과거 성과는 미래를 보장하지 않습니다.