워크포워드 최적화 실측 — 최적 이평선 11.5%가 4.0%로
"백테스트에서 가장 좋았던 이동평균 기간"은 다음 해에 거의 평균 이하였습니다. 코스피(KS11) 종가가 N일 이동평균 위면 보유·아래면 현금인 규칙을 2015-01-02 ~ 2026-08-31(11.36년), N = 5~200일 40개로 돌리면 사후 최적은 N=115, CAGR 11.46%입니다. 같은 규칙을 워크포워드(매년 1월, 직전 3년에서 가장 좋았던 N을 골라 그해에만 사용)로 다시 돌리면 CAGR 4.02%, MDD −44.54%였습니다. 매년 고른 N은 그해 40개 중 평균 29.3등이었고, 상위 절반에 든 해는 12년 중 2번뿐이었습니다. 최적값 하나보다 "몰랐던 구간에서의 성적"이 실전에 가까운 숫자입니다. (가격지수·배당 미포함, 편도 비용 0.05% 가정, 과거≠미래)
목차
- 실험 설계 — 규칙 한 줄, 후보 40개
- 결과 — 세 줄 비교표
- '최적값'은 다음 해 몇 등이었나
- 반으로 잘라 보면 최적값이 바뀐다
- 학습 창 길이도 파라미터다
- 2026년 여름 — 휩소가 지수보다 더 잃은 6주
- 재현 코드 전체
- 자동매매로 옮길 때 — KIS API로 지수 신호 받기
- 자주 묻는 질문
1. 실험 설계 — 규칙 한 줄, 후보 40개
전략은 일부러 가장 단순한 것을 골랐습니다. "코스피 종가가 N일 이동평균보다 위면 지수를 보유, 아래면 현금." 추세추종 봇 제작 상담에서 가장 먼저 나오는 형태이고, 파라미터가 N 하나뿐이라 최적화의 효과만 깔끔하게 볼 수 있습니다.
| 항목 | 설정 |
|---|---|
| 데이터 | FinanceDataReader의 KS11(코스피 지수) 일별 종가. 가격지수라 배당 미포함 |
| 평가 구간 | 2015-01-02 ~ 2026-08-31 (2,862거래일 · 11.36년). 워밍업을 위해 2010-01부터 수신 |
| 후보 파라미터 | N = 5, 10, 15, …, 200 (40개) |
| 체결 | 오늘 종가로 신호 → 다음 거래일 수익률부터 적용(pos.shift(1)). 미래참조 방지 |
| 비용 | 진입·청산 1회당 0.05% 가정(수수료+슬리피지). 세금·ETF 보수 미반영 |
| 워크포워드 | 매년 1월, 직전 3년 CAGR 1위 N을 선택 → 그해 12월까지 고정 → 다음 해 반복 |
핵심은 두 번째 줄의 차이입니다. 사후 최적은 2015~2026년 전체를 다 본 뒤 가장 좋은 N을 고른 값이고, 워크포워드는 "그 시점까지 알 수 있었던 데이터"로만 고른 값입니다. 앞의 것은 실전에서 불가능하고, 뒤의 것만 가능합니다.
2. 결과 — 세 줄 비교표
| 방식 | CAGR | MDD | 비고 |
|---|---|---|---|
① 사후 최적 (N=115) | 11.46% | −30.39% | 결과를 보고 고른 값 — 실전 불가 |
| ② 워크포워드 (3년 학습 → 1년 적용) | 4.02% | −44.54% | 실전에서 가능한 유일한 방식 |
| ③ 코스피 보유 | 11.83% | −43.90% | 2018-01-29 → 2020-03-19 낙폭 |
참고: 40개 N의 CAGR 중앙값 | 8.55% | — | 아무 N이나 골랐을 때의 가운데 |
①과 ②의 차이 7.44%p가 "최적화가 만든 착시"의 크기입니다. 더 불편한 사실은 ②가 40개 중 아무 값이나 고른 경우의 중앙값(8.55%)보다도 낮다는 점입니다. 이 데이터에서는 매년 열심히 최적화한 것이 아무 생각 없이 하나를 정해 두는 것보다 나빴습니다.
주의 — 이것은 "이동평균 전략이 나쁘다"는 결론이 아닙니다. 코스피는 이 구간에 2025년 +75.6%, 2026년 1~8월 +61.8%처럼 추세추종이 따라가기 어려운 급등과 급락이 섞여 있었습니다. 이 글이 보여 주는 것은 "과거 최적값을 고르는 행위"가 실전 성과를 얼마나 과대평가하는가입니다.
3. '최적값'은 다음 해 몇 등이었나
워크포워드가 매년 고른 N이 그해 실제로 40개 중 몇 등이었는지를 적으면 이렇습니다. 1등이 가장 좋은 값입니다.
| 연도 | 고른 N | 그해 순위 | 고른 N 수익률 | 그해 1등 N | 코스피 |
|---|---|---|---|---|---|
| 2015 | 50 | 31 | −4.4% | 5 | +2.4% |
| 2016 | 5 | 33 | −9.6% | 15 | +3.3% |
| 2017 | 115 | 22 | +19.1% | 145 | +21.8% |
| 2018 | 40 | 38 | −15.0% | 145 | −17.3% |
| 2019 | 145 | 31 | −3.6% | 10 | +7.7% |
| 2020 | 15 | 7 | +38.6% | 30 | +30.8% |
| 2021 | 5 | 32 | +0.2% | 95 | +3.6% |
| 2022 | 75 | 37 | −12.1% | 15 | −24.9% |
| 2023 | 30 | 27 | +3.3% | 15 | +18.7% |
| 2024 | 95 | 16 | −6.8% | 120 | −9.6% |
| 2025 | 15 | 40 | +32.0% | 50 | +75.6% |
| 2026(~8월) | 120 | 38 | +19.9% | 75 | +61.8% |
두 가지가 눈에 띕니다. 첫째, 고른 값이 5일에서 145일까지 널뛴다는 것. 직전 3년의 1등이 매번 전혀 다른 값이었습니다. 둘째, 그해 1등 N도 매년 다르다는 것. 1등 자리에 5·10·15·30·50·75·95·120·145가 돌아가며 앉습니다. 최적값이 "발견되는 진실"이 아니라 그 구간의 소음에 맞춘 값이라는 증거입니다. 백테스트를 수백 번 돌려 최고만 보여 주는 문제는 데이터 스누핑·다중검정의 함정에서 따로 다뤘습니다.
4. 반으로 잘라 보면 최적값이 바뀐다
워크포워드 없이도 같은 현상을 확인하는 가장 쉬운 방법은 구간을 반으로 자르는 것입니다.
| 구간 | 최적 N | 그 N의 CAGR | 다른 구간에서 그 N의 순위 |
|---|---|---|---|
| 앞 6년 2015~2020 | 15 | 8.12% | 뒤 구간에서 40개 중 40등 (6.20%) |
| 뒤 5.7년 2021~2026-08 | 115 | 19.25% | 앞 구간에서 40개 중 18등 |
2020년 말에 "지난 6년 최적은 15일선"이라는 백테스트를 믿고 봇을 만들었다면, 그 뒤 5년 8개월 동안 40개 후보 중 꼴찌를 돌린 셈입니다. 반대로 전체 최적인 115일선은 앞 구간에서 중간(18등)이었습니다. 전체 기간 최적값이 좋아 보이는 이유의 상당 부분은 2025~2026년 급등장 한 구간에서 나왔습니다.
인접 값도 확인해 보면 봉우리가 뾰족합니다. N=105 10.61% · 110 10.70% · 115 11.46% · 120 9.01% · 125 8.65%. 5일 차이로 2.45%p가 움직입니다. 이런 뾰족한 봉우리는 실전에서 거의 재현되지 않습니다. 파라미터 표에서 봐야 할 것은 최고점이 아니라 완만한 고원이 있는가입니다.
5. 학습 창 길이도 파라미터다
여기서 "워크포워드 학습 창을 바꾸면 되지 않나?"라는 질문이 나옵니다. 1·2·3·5년으로 바꿔 봤습니다.
| 학습 창 | 워크포워드 CAGR | MDD | 매년 고른 N (2015→2026) |
|---|---|---|---|
| 1년 | 11.03% | −21.78% | 105, 5, 15, 145, 145, 10, 30, 95, 15, 15, 120, 50 |
| 2년 | 4.25% | −44.54% | 100, 95, 15, 40, 145, 10, 10, 75, 95, 15, 10, 120 |
| 3년 | 4.02% | −44.54% | 50, 5, 115, 40, 145, 15, 5, 75, 30, 95, 15, 120 |
| 5년 | 7.70% | −30.39% | 30, 20, 15, 25, 135, 15, 15, 80, 80, 15, 115, 115 |
1년 창이 11.03%로 가장 좋아 보입니다. 그런데 이걸 보고 1년 창을 고르면 방금 피한 함정에 한 층 위에서 다시 빠집니다. 창 길이 4개 중 최고를 고른 것 역시 사후 선택이기 때문입니다. 워크포워드는 과최적화를 "없애는" 도구가 아니라 과최적화가 얼마나 끼어 있는지를 재는 도구입니다. 창 길이·재최적화 주기·후보 범위는 데이터를 보기 전에 정해서 적어 두는 것이 원칙입니다.
실무 규칙 3줄
① 파라미터와 근거를 백테스트 전에 문서로 남긴다(예: "약 6개월 추세 = 120일").
② 최적값 대신 인접 값 ±20% 범위의 결과 폭을 보고한다.
③ 워크포워드 결과를 "기대치의 상한"이 아니라 "현실적 하한 쪽의 추정"으로 읽는다.
6. 2026년 여름 — 휩소가 지수보다 더 잃은 6주
워크포워드의 MDD −44.54%는 오래된 사건이 아니라 2026-06-22 → 2026-08-31 구간에서 나왔습니다. 이 구간에 코스피는 9,114.55(06-22)에서 5,593.56(07-30)까지 −38.6% 내려갔다가 6,820.02(08-31)로 끝났습니다(FinanceDataReader KS11 종가 기준). 같은 기간 지수 보유는 −25.2%였는데, 그해 워크포워드가 고른 N=120은 −44.2%였습니다.
이유는 두 단계입니다. 먼저 120일선은 느려서 9,114에서 6,516(07-20)까지 −28.5%를 그대로 보유한 뒤에야 빠졌습니다. 그 다음 6주 동안 지수가 120일선 근처(6,600~6,900)에서 오르내리며 07-20부터 08-28까지 신호가 11번 바뀌었고, 하루 −10.8%(07-28) 직전에 들어가고 +17.9%(07-31) 직전에 나와 있었습니다. 추세추종이 가장 약한 장면 — 느리게 빠지고, 빠진 뒤에는 휩소 — 이 한 번에 나온 사례입니다.
사후 최적 N=115도 같은 구간에서 −29.9%였습니다. 이 규칙의 MDD −30.39% 역시 이 구간에서 나왔습니다. 백테스트 기간을 2026년 6월 이전으로 끊었다면 두 숫자 모두 훨씬 좋게 보였을 것입니다. 끝나는 날짜 하나로 결론이 바뀌는 문제는 미래참조 편향 탐지와 함께 점검할 항목입니다.
7. 재현 코드 전체
위 숫자는 아래 코드로 재현됩니다. pandas·numpy·FinanceDataReader만 필요합니다. 기준일을 ASOF로 고정해 두었기 때문에 언제 실행해도 같은 구간을 계산합니다(최신까지 보려면 ASOF = None). 빈 폴더에서 처음부터 실행해 위 표의 숫자가 그대로 나오는 것을 확인했습니다. 파일: /downloads/walkforward_ma_backtest.py
# walkforward_ma_backtest.py — 이동평균 기간 '최적값' vs 워크포워드 (알고랩)
# 규칙: 코스피 종가 > N일 이동평균이면 보유, 아니면 현금. 신호는 종가, 체결은 다음 거래일.
# 필요: pip install finance-datareader pandas numpy
import numpy as np
import pandas as pd
import FinanceDataReader as fdr
# 기준일 고정 — 이 글의 숫자를 재현하려면 그대로 둔다.
# 고정하지 않으면 실행하는 날의 데이터까지 받아 글과 다른 숫자가 나온다.
# 최신 데이터로 보려면 ASOF = None
ASOF = "2026-08-31"
START = "2010-01-01" # 200일 이동평균 워밍업 포함
COST = 0.0005 # 편도 비용 가정 0.05% (수수료+슬리피지)
GRID = list(range(5, 205, 5)) # 후보 N: 5, 10, ..., 200 (40개)
TRAIN_YEARS = 3 # 학습 구간 3년 → 다음 1년에 적용
TEST_FROM, TEST_TO = 2015, 2026 # 평가 구간 2015-01 ~ ASOF
px = fdr.DataReader("KS11", START)["Close"].astype(float)
px = px[:ASOF] if ASOF else px # 받은 뒤 자른다 — 캐시를 쓰든 새로 받든 같은 구간
ret = px.pct_change().fillna(0.0)
def daily_returns(n):
"""N일선 위면 1, 아니면 0. 오늘 신호 → 내일 수익률에 적용, 전환 시 비용 차감."""
pos = (px > px.rolling(n).mean()).astype(float)
pos[px.rolling(n).mean().isna()] = 0.0
held = pos.shift(1).fillna(0.0)
turn = held.diff().abs().fillna(0.0)
return held * ret - turn * COST
ALL = pd.DataFrame({n: daily_returns(n) for n in GRID})
def cagr(r):
years = len(r) / 252
return (1 + r).prod() ** (1 / years) - 1
def mdd(r):
eq = (1 + r).cumprod()
return (eq / eq.cummax() - 1).min()
test = ALL.loc[f"{TEST_FROM}-01-01":]
bh = ret.loc[f"{TEST_FROM}-01-01":]
# ① 사후 최적: 평가 구간 전체를 보고 가장 좋았던 N (현실에선 불가능)
full_scores = test.apply(cagr)
best_n = int(full_scores.idxmax())
# ② 워크포워드: 매년 1월, 직전 3년에서 최고였던 N을 그해에만 쓴다
wf_parts, rows = [], []
for y in range(TEST_FROM, TEST_TO + 1):
train = ALL.loc[f"{y - TRAIN_YEARS}-01-01":f"{y - 1}-12-31"]
pick = int(train.apply(cagr).idxmax())
yr = ALL.loc[f"{y}-01-01":f"{y}-12-31"]
if yr.empty:
continue
yr_tot = (1 + yr).prod() - 1 # 그해 40개 N 각각의 수익률
rank = int(yr_tot.rank(ascending=False)[pick]) # 고른 N이 그해 몇 등이었나
wf_parts.append(yr[pick])
rows.append({"year": y, "picked_N": pick, "rank_of_40": rank,
"picked_ret": yr_tot[pick], "best_N_that_year": int(yr_tot.idxmax()),
"hindsight_N_ret": yr_tot[best_n], "kospi_ret": (1 + bh.loc[str(y)]).prod() - 1})
wf = pd.concat(wf_parts)
table = pd.DataFrame(rows)
print(f"기간 {test.index[0].date()} ~ {test.index[-1].date()} ({len(test)/252:.2f}년)")
print(f"① 사후 최적 N={best_n} CAGR {cagr(test[best_n]):.2%} MDD {mdd(test[best_n]):.2%}")
print(f"② 워크포워드 CAGR {cagr(wf):.2%} MDD {mdd(wf):.2%}")
print(f"③ 코스피 보유 CAGR {cagr(bh):.2%} MDD {mdd(bh):.2%}")
print(f" 40개 N 중앙값 CAGR {full_scores.median():.2%}")
print(table.to_string(index=False, formatters={
"picked_ret": "{:.1%}".format, "hindsight_N_ret": "{:.1%}".format, "kospi_ret": "{:.1%}".format}))
출력의 첫 네 줄이 2장 표, 아래 표가 3장 표입니다. TRAIN_YEARS를 1·2·5로 바꾸면 5장 표가, test.loc[:"2020-12-31"]처럼 구간을 잘라 idxmax()를 보면 4장 표가 나옵니다. 같은 실험을 vectorbt로 옮기면 40개 N을 한 번에 브로드캐스팅할 수 있고, backtrader라면 optstrategy로 돌린 뒤 구간별로 잘라 붙이면 됩니다. 어느 도구든 핵심은 같습니다 — 파라미터를 고르는 데이터와 채점하는 데이터를 섞지 않는 것.
8. 자동매매로 옮길 때 — KIS API로 지수 신호 받기
신호: 지수 일봉 한 번이면 된다
한국투자증권(KIS) Open API에서 코스피 지수의 기간별 일봉은 inquire-daily-indexchartprice, tr_id FHKUP03500100입니다. 종목 차트(FHKST03010100)와 경로도 tr_id도 다르고, 파라미터에 FID_COND_MRKT_DIV_CODE=U(업종)와 FID_INPUT_ISCD=0001(코스피)을 넣습니다. 코스닥은 1001, 코스피200은 2001입니다. 지수 API 전체는 KIS 코스피 지수 API 정리에, 키움 REST는 키움 업종지수 ka20006에 있습니다.
params = {
"FID_COND_MRKT_DIV_CODE": "U", # 업종(지수)
"FID_INPUT_ISCD": "0001", # 코스피
"FID_INPUT_DATE_1": "20260101", # 시작일 — N일 평균에 필요한 만큼 넉넉히
"FID_INPUT_DATE_2": "20260831", # 종료일
"FID_PERIOD_DIV_CODE": "D", # 일봉
}
headers["tr_id"] = "FHKUP03500100"
# 응답 헤더 tr_cont 가 "M" 이면 이어서 더 받고 "F" 면 끝 (공식 예제 기준)
# 한 번에 오는 행 수는 공식 문서 확인 — 120일선이면 넉넉히 받아 둔다
signal = close.iloc[-1] > close.tail(120).mean() # True = 보유, False = 현금
주문: 지수 대신 ETF를 산다
지수 자체는 살 수 없으니 KODEX 200(069500) 같은 지수 ETF를 현금 주문으로 매매합니다. 실전 주문 tr_id는 TTTC0012U, 모의는 VTTC0012U처럼 앞 글자가 T와 V로 갈립니다(KIS 현재가 조회 FHKST01010100 참고). 봇이 할 일은 하루 한 번, 장 마감 뒤 신호를 계산하고 다음 날 장 시작 후 필요할 때만 주문하는 것입니다. 호출 수가 적어 레이트리밋 부담은 거의 없습니다.
백테스트와 실전이 달라지는 지점
- 지수 ≠ ETF — ETF는 보수·추적오차·호가 스프레드가 있고, 반대로 분배금이 있습니다. 이 글의 가격지수 결과와 ETF 계좌 결과는 다릅니다.
- 체결 시점 — 백테스트는 "다음 날 종가 대 종가" 수익률을 썼습니다. 다음 날 시가에 사면 갭만큼 차이가 납니다.
- 파라미터 고정 — 봇에
N을 넣는 순간 그 값은 문서로 남긴 근거가 있어야 합니다. "요즘 잘 맞는 값"으로 수시로 바꾸면 그게 바로 사람이 손으로 하는 워크포워드이고, 결과는 3장 표와 비슷해집니다.
실전 투입 전에 모의투자·소액으로 무엇을 확인할지는 실전 투입 전 검증 3단계, 백테스트 전체 흐름은 자동매매 백테스트 완전 가이드에 정리했습니다.
9. 자주 묻는 질문
워크포워드 최적화가 무엇인가요?
학습 구간에서만 파라미터를 고르고 바로 다음 구간에 적용해 성적을 기록한 뒤, 창을 밀며 반복하는 방법입니다. 이 글은 매년 1월 직전 3년의 최고 N을 골라 그해에만 썼습니다. 고를 때 몰랐던 데이터로만 채점한 결과가 됩니다.
전체 기간에서 가장 좋았던 이동평균 기간을 쓰면 안 되나요?
결과를 다 본 뒤 고른 값이라 재현되지 않습니다. 전체 최적은 N=115(11.46%)였지만 앞 6년 최적 N=15는 뒤 구간에서 40개 중 40등이었습니다.
워크포워드 결과가 나쁘면 그 전략은 버려야 하나요?
숫자 하나로 정할 일은 아닙니다. 학습 창을 1·2·3·5년으로 바꾸면 11.03%·4.25%·4.02%·7.70%로 달라졌습니다. 창 길이도 파라미터라 최고를 고르면 같은 함정입니다. 기준은 파라미터를 바꿔도 결과가 무너지지 않는가입니다.
그럼 이동평균 기간은 어떻게 정해야 하나요?
백테스트 전에 근거와 함께 하나를 정하고(예: 6개월 추세 = 120일), 인접 값에서의 흔들림을 함께 봅니다. 이 데이터에서 N=115와 N=120은 2.45%p 차이였습니다.
한국투자증권 API로 이 규칙을 자동화할 수 있나요?
가능합니다. 지수 일봉은 FHKUP03500100(U + 0001), 매매는 지수 ETF를 현금 주문(실전 TTTC0012U / 모의 VTTC0012U)으로 합니다. 필드와 연속조회 규칙은 공식 문서로 확인하십시오.
고지 — 알고랩(퀀트웍스)은 투자자문업·투자일임업을 영위하지 않으며, 고객의 규칙을 코드로 옮기는 도구 제공만 합니다. 이 글은 특정 종목·ETF나 매매 전략을 추천하지 않고 수익을 보장하지 않습니다. 워크포워드 분석은 공개된 학술·실무 개념이며, 수치는 코스피 가격지수(배당 미포함)와 편도 0.05% 비용 가정으로 계산한 과거 백테스트입니다. 세금·ETF 보수·추적오차는 반영하지 않았고, 과거 결과는 미래 성과를 보장하지 않습니다. API 사양은 한국투자증권 공식 문서로 확인하십시오.