[7] 멀티버스 백테스트 – 파라미터 1,000개를 동시에 시험하는 법
지난 글에서 BaseStrategy를 상속받아 전략을 끼워넣는 방법을 다뤘습니다. 샘플 전략이 돌아가는 것까지 확인했죠.
그런데 한 가지 찝찝한 게 남았습니다. config.yaml에 적어둔 숫자들 — RSI 30, 이평선 5/20, 거래량 1.5배 — 이게 정말 좋은 값일까요? 솔직히 말하면 저도 처음엔 인터넷에서 흔히 보이는 값을 그대로 넣었습니다. “RSI 30이면 과매도라고 하니까 30으로 하자” 이런 식이었죠.
이번 글에서는 이 찝찝함을 해결한 방법을 다룹니다. 제가 멀티버스라고 부르는 방법입니다.
1. 멀티버스란
1-1. 왜 “멀티버스”인가
“RSI 30에서 매수하는 세계”, “RSI 25에서 매수하는 세계”, “RSI 40에서 매수하는 세계” — 파라미터가 다르면 완전히 다른 매매 결과가 나옵니다. 마치 평행 우주처럼요.
이걸 멀티버스(Multiverse)라고 부르기로 했습니다. 네, 마블 영화의 그 멀티버스 맞습니다. 제가 만든 말인데, 파라미터 조합 하나가 하나의 평행 우주고 그 우주들을 전부 돌려보는 거니까 멀티버스가 딱이지 않나 싶습니다. 퀀트에서는 보통 “파라미터 스윕”이나 “그리드 서치”라고 합니다.
1-2. 삽질의 시작
처음에는 손으로 했습니다. RSI를 30에서 25로 바꿔보고, 다시 35로 바꿔보고, 이평선도 5/20에서 10/30으로 바꿔보고… config.yaml을 수정하고 돌리고, 결과 적어놓고, 다시 수정하고 돌리고.
parameters:
ma_short_period: 5 # 왜 5? 3은? 10은?
ma_long_period: 20 # 왜 20? 15는? 30은?
rsi_oversold: 30 # 왜 30? 25는? 40은?
volume_multiplier: 1.5 # 왜 1.5? 2.0은?
risk_management:
stop_loss_pct: 0.05 # 왜 5%? 7%는?
take_profit_pct: 0.10 # 왜 10%? 15%는?
세 번째쯤에 깨달았습니다. 파라미터 6개에 값 3개씩만 해도:
3 × 3 × 3 × 3 × 3 × 3 = 729개 조합
손으로는 불가능합니다. 4편에서 “버그 하나 고치면 네 프로젝트에서 네 번 고쳤다”는 삽질과 똑같은 패턴이었죠. 반복 작업은 컴퓨터한테 시키면 됩니다.
2. 백테스트의 대원칙 — 미래를 몰라야 한다
멀티버스 이전에, 백테스트의 가장 중요한 원칙부터 짚겠습니다. 이건 제가 처음에 실수했던 부분이기도 합니다.
2-1. Look-ahead bias (미래 정보 편향)
2025년 1월 2일의 매수 판단을 시뮬레이션한다고 합시다. 이때 전략이 볼 수 있는 데이터는:
✅ 2024-12-30, 2024-12-31, 2025-01-02 (당일 포함, 과거)
❌ 2025-01-03, 2025-01-06, ... (미래 — 절대 안 됨)
당연한 것 같지만, 코드로 구현할 때 실수하기 쉽습니다. 제가 처음 백테스트를 짤 때 data["close"]를 DataFrame 전체로 넘겼는데, 그러면 미래 종가까지 다 포함되어 있는 거죠. (당연히 결과가 말도 안 되게 좋았습니다. 미래를 아니까…)
2-2. 엔진의 해결법
BacktestEngine은 매 시뮬레이션 날짜마다 해당 날짜까지의 데이터만 잘라서 전략에 넘깁니다:
mask = dates_str <= date # ← 당일까지만!
return df[mask].copy()
C++로 비유하면, std::vector에서 begin()부터 현재 인덱스까지만 const_iterator를 넘기는 것과 같습니다. 전략은 미래 데이터에 접근할 방법이 없습니다.
3. BacktestEngine — 한 번의 시뮬레이션
멀티버스를 돌리려면, 먼저 하나의 파라미터 세트로 한 번 백테스트하는 엔진이 필요합니다.
3-1. 사용법
from backtest import BacktestEngine
from strategies.sample.strategy import SampleStrategy
engine = BacktestEngine(SampleStrategy(), initial_capital=10_000_000)
result = engine.run(["005930", "000660"], daily_data=data)
print(result.summary())
# 총수익률=+12.3% 승률=58.3% MDD=8.5% 샤프=1.24 거래=48건
핵심이 두 가지입니다.
첫째, generate_signal()을 그대로 재사용합니다. 6편에서 만든 전략 코드를 한 줄도 수정하지 않고 과거 데이터로 시뮬레이션합니다.
둘째, initial_capital로 제한된 자금에서 시뮬레이션합니다. 무한한 돈으로 모든 신호에 매수하는 게 아니라, 실제처럼 1천만원 안에서 자금을 배분하고, 돈이 부족하면 매수를 못 합니다. (이게 없으면 백테스트가 현실과 완전히 동떨어집니다)
3-2. 시뮬레이션 루프
매일 반복:
1. 보유 종목 → generate_signal() → SELL이면 매도 (수수료+세금 차감)
2. 미보유 종목 → generate_signal() → BUY이면 매수 (수수료 차감)
3. 총자산 = 현금 + Σ(보유종목 × 당일종가) → equity_curve 기록
3-3. 성과 지표
| 지표 | 의미 | 좋은 값 |
|---|---|---|
| 총수익률 | 1천만원이 얼마가 됐나 | 높을수록 좋음 |
| 승률 | 이긴 거래 / 전체 거래 | 50% 이상 |
| MDD | 고점에서 최대 얼마나 빠졌나 | 20% 이하 |
| 샤프 비율 | 위험 대비 수익 | 1.0 이상이면 양호, 2.0이면 우수 |
| 손익비 | 이길 때 평균 / 질 때 평균 | 1.5 이상 |
여기까지가 하나의 우주입니다. 이제 이 우주를 수백 개 만들어야 합니다.
4. MultiverseEngine — 모든 우주를 탐색
4-1. 사용법
from backtest import MultiverseEngine
from strategies.sample.strategy import SampleStrategy
mv = MultiverseEngine(
strategy_class=SampleStrategy, # 클래스를 넘김 (인스턴스 X)
daily_data=data,
stock_codes=["005930", "000660", "035720"],
)
# 파라미터 범위 지정
mv.add_param("parameters.ma_short_period", [3, 5, 10])
mv.add_param("parameters.ma_long_period", [15, 20, 30])
mv.add_param("parameters.rsi_oversold", [25, 30, 35, 40])
mv.add_param("risk_management.stop_loss_pct", [0.03, 0.05, 0.07, 0.10])
mv.add_param("risk_management.take_profit_pct", [0.07, 0.10, 0.15])
# 실행: 3 × 3 × 4 × 4 × 3 = 432개 우주
results = mv.run(min_trades=20, n_jobs=4)
인스턴스가 아니라 클래스를 넘기는 이유: 432번 실행할 때마다 새 인스턴스를 만들어야 합니다. 이전 우주의 self.positions가 다음 우주에 오염되면 안 되니까요. C++의 팩토리 패턴과 같은 개념입니다.
4-2. 결과 확인
print(results.top(10)) # 샤프 비율 기준 Top 10
(아래는 설명을 위한 예시입니다)
| ma_short | ma_long | rsi_oversold | stop_loss | take_profit | 수익률 | 승률 | MDD | 샤프 | 거래수 | 안정성 |
|---|---|---|---|---|---|---|---|---|---|---|
| 5 | 20 | 30 | 0.07 | 0.15 | +18.5% | 62% | 8.3% | 1.82 | 34 | 안정 |
| 5 | 20 | 35 | 0.07 | 0.15 | +16.2% | 59% | 9.1% | 1.65 | 41 | 안정 |
| 10 | 30 | 25 | 0.05 | 0.10 | +21.3% | 55% | 14.2% | 1.51 | 28 | 과적합 의심 |
3번째를 보면 — 수익률은 가장 높은데 “과적합 의심”이 떴습니다. 이게 뭘 의미하는지는 다음 섹션에서 설명합니다.
4-3. 내부 동작
멀티버스 엔진이 하는 일을 정리하면:
파라미터 범위 → itertools.product → 432개 조합 생성
│
▼ (n_jobs=4이면 4개씩 병렬)
┌──────┼──────┬──────┐
▼ ▼ ▼ ▼
우주1 우주2 우주3 우주4 ← 각각 새 전략 인스턴스
│ │ │ │ ← config에 파라미터 주입
▼ ▼ ▼ ▼
백테스트 백테스트 백테스트 백테스트
│ │ │ │
▼ ▼ ▼ ▼
결과 결과 결과 결과
└──────┼──────┴──────┘
▼
min_trades >= 20 필터 → 샤프 정렬 → 안정성 분석
전략 코드는 건드리지 않고, config만 바꿔서 다른 우주를 만듭니다. 4편에서 “전략만 갈아끼우면 된다”고 했는데, 여기서는 파라미터만 갈아끼우면 됩니다.
5. 과적합 — 멀티버스의 함정
5-1. 파라미터 과적합이란
432개 조합을 돌리면, 우연히 좋은 결과가 나오는 조합이 반드시 있습니다. 로또 1등 당첨자가 매주 나오는 것처럼, 조합이 많으면 그중 하나는 과거 데이터에 절묘하게 맞아떨어집니다.
문제는 과거에 잘 된 파라미터가 미래에도 잘 된다는 보장이 없다는 겁니다. 이걸 과적합(overfitting)이라고 합니다. 저도 처음에 “RSI 27, 손절 4.3%, 익절 11.7%” 같은 아주 구체적인 값이 1등으로 나왔을 때 좋아했는데, 다음 달에 돌려보니 꼴찌더군요.
5-2. 전통적 해결법과 그 한계
교과서적 방법은 In-sample / Out-of-sample 분할입니다:
- 2/3 기간(2023~2024)으로 최적화 → “이 파라미터가 좋다”
- 나머지 1/3(2025)으로 검증 → “진짜 좋은지 확인”
그럴듯하지만, 실제로 해보면 문제가 있습니다:
- 데이터 낭비: 3년치에서 1년을 빼면, 학습에 쓸 시장 상황이 줄어듭니다
- 레짐 변화: 검증 기간이 코로나 폭락이면? 금리 인상기면? 학습 기간과 완전히 다른 시장이라 검증 자체가 의미 없어집니다
- OOS도 결국 오염됨: “OOS에서 좋은 파라미터를 고르는 행위” 자체가 전체 기간 최적화와 다를 바 없지 않나 싶습니다
5-3. 파라미터 안정성 분석
완벽한 해결책은 없습니다. 하지만 뾰족한 봉우리를 걸러내는 방법은 있습니다 — 이웃 파라미터도 괜찮은지 확인하는 겁니다.
RSI 30이 최고 성과라고 합시다.
├─ RSI 25도 괜찮은가? ← 이웃 (-1 step)
├─ RSI 35도 괜찮은가? ← 이웃 (+1 step)
│
├─ 둘 다 괜찮으면 → "안정" (진짜 좋은 영역)
└─ 급격히 나빠지면 → "과적합 의심" (우연히 좋았을 뿐)
산에 비유하면 이렇습니다:
- 넓은 고원 위라면 안전합니다. 조금 벗어나도 여전히 높죠.
- 뾰족한 봉우리 위라면 위험합니다. 한 발만 옆으로 가면 절벽입니다.
멀티버스 엔진이 이 분석을 자동으로 해줍니다:
=== 파라미터 안정성 리포트 (상위 3개) ===
#1 rsi=30, sl=0.07, tp=0.15
Sharpe: 1.82 | 이웃 평균: 1.61 (원본의 88%) → 안정 ✓
→ 넓은 고원.
#2 rsi=35, sl=0.07, tp=0.15
Sharpe: 1.65 | 이웃 평균: 1.52 (원본의 92%) → 안정 ✓
→ 넓은 고원.
#3 rsi=25, sl=0.05, tp=0.10
Sharpe: 1.51 | 이웃 평균: 0.83 (원본의 55%) → 과적합 의심 ✗
→ 뾰족한 봉우리. 이웃으로 바꾸면 성과가 반토막.
이것만으로 과적합이 완전히 해결되지는 않습니다. 하지만 뾰족한 봉우리는 확실히 걸러냅니다. 진짜 검증은 가상매매에서 합니다. (섹션 7에서 다룹니다)
6. 최적화 전략 — 조합이 너무 많을 때
6-1. 조합 폭발 문제
파라미터 5개에 값 10개씩이면 10만 개 조합입니다. 각각 2초면 56시간이죠. 현실적으로 안 됩니다.
6-2. 병렬 처리
가장 간단한 방법입니다. n_jobs=4로 설정하면 4개 스레드가 동시에 돌아갑니다. 56시간이 14시간으로 줄어들죠. 하지만 여전히 많습니다.
6-3. 2단계 탐색
제가 실제로 쓰는 방법입니다. 진입 조건과 청산 조건을 분리해서 탐색합니다.
Step 1: 진입 파라미터만 스윕 (청산은 기본값 고정)
- RSI, 이평선, 거래량 → 예: 3 × 3 × 4 = 36개 조합
- Top 20 추출
Step 2: Top 20 × 청산 파라미터 스윕
- 손절, 익절 → 예: 20 × 4 × 3 = 240개 조합
총 276개. 10만 개의 0.3%만 탐색하고도 좋은 결과를 찾을 수 있습니다. 대부분의 경우 이 방법이면 충분합니다.
원리는 간단합니다 — 진입이 나쁘면 아무리 청산을 잘 해도 수익이 안 납니다. 좋은 진입을 먼저 찾고, 그 위에서 청산을 최적화하는 거죠.
6-4. 사전 계산 (Precompute)
한 단계 더 가면, 매번 이평선과 RSI를 처음부터 다시 계산하는 대신 모든 날짜의 지표를 미리 계산해두고 필터링만 합니다.
❌ 일반적인 방식:
432개 조합 × 종목 × 날짜 × (이평선 계산 + RSI 계산 + 신호 판단)
✅ 사전 계산 방식:
1회: 종목 × 날짜 × (모든 이평선 + 모든 RSI) 미리 계산
432개 조합 × 종목 × 날짜 × (필터링만 — O(1))
지표 계산이 가장 무거운 부분인데, 이걸 1회로 줄이면 10~50배 빨라집니다. 프레임워크의 scripts/lynch_multiverse_kis.py가 이 방식을 사용하고 있습니다.
6-5. 어떤 방법을 쓸까
| 조합 수 | 추천 방법 | 예상 시간 (4코어) |
|---|---|---|
| ~100 | 그냥 돌리기 (n_jobs=4) |
1분 이내 |
| ~1,000 | 병렬 처리 | 5~10분 |
| ~10,000 | 2단계 탐색 | 10~30분 |
| ~100,000+ | 사전 계산 + 2단계 | 30분~1시간 |
처음에는 100~1,000개 조합으로 시작하는 걸 권합니다. 전략이 작동하는지 빠르게 확인한 다음, 유망한 영역을 좁혀서 세밀하게 탐색하면 됩니다.
7. 실전 적용 프로세스
전체 흐름을 정리하면:
1. 전략 작성 (6편)
└─ generate_signal() 구현
2. 백테스트 (이번 글)
└─ BacktestEngine으로 기본 동작 확인
3. 멀티버스
└─ MultiverseEngine으로 파라미터 범위 탐색
└─ 안정성 리포트 확인 — "안정" 파라미터만 선별
4. 적용
└─ config.yaml에 최적 파라미터 반영
5. 검증
└─ 가상매매(paper trading)로 실시간 확인
└─ 이게 진짜 Out-of-sample 테스트
몇 가지 주의할 점:
min_trades=20은 꼭 지켜야 합니다. 거래 3건에서 3건 다 이기면 승률 100%인데, 의미 없죠.- “과적합 의심”이 뜬 파라미터는 아무리 수익률이 높아도 쓰지 마세요. 저도 처음엔 수익률 1등을 썼다가 실전에서 박살났습니다. 넓은 고원을 찾아야 합니다.
- 가상매매에서 최소 20~30회 매매 후 결과를 점검하는 게 좋습니다. (3편에서 다뤘던 것과 같은 패턴입니다)
마무리
이번 글의 핵심:
- 멀티버스: 파라미터 조합마다 하나의 우주. 모든 우주를 시뮬레이션해서 가장 좋은 것을 찾습니다.
- 백테스트 대원칙: 미래 데이터를 절대 보여주지 않습니다. 당일까지의 데이터만 전략에 전달합니다.
- 제한된 자금:
initial_capital로 현실의 자금 제약을 그대로 반영합니다. - 과적합 방지: IS/OOS 분할 대신, 파라미터 안정성 분석으로 “넓은 고원”과 “뾰족한 봉우리”를 구분합니다.
- 최적화: 병렬 처리, 2단계 탐색, 사전 계산으로 조합 폭발을 관리합니다.
- 진짜 검증은 실시간: 멀티버스 결과를 config.yaml에 반영하고, 가상매매로 확인합니다. 이게 진짜 Out-of-sample입니다.
이 프레임워크는 GitHub에 공개되어 있습니다: kis-trading-template
참고 – 블로그 내 관련 글
- [1] 주식 자동매매 시리즈 소개 – 시리즈 흐름
- [2] 주식 단타 전략 소개 – 7가지 전략 – 전략 상세
- [3] 단타 전략 선택 가이드 – 전략 비교 & 선택
- [4] 자동매매 프레임워크 설계 – 전체 구조
- [5] 핵심 모듈 구현 – KISBroker, FundManager, StrategyLoader
- [6] 전략 끼워넣기 – BaseStrategy와 샘플 전략
댓글