지난 글에서 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)으로 검증 → “진짜 좋은지 확인”

그럴듯하지만, 실제로 해보면 문제가 있습니다:

  1. 데이터 낭비: 3년치에서 1년을 빼면, 학습에 쓸 시장 상황이 줄어듭니다
  2. 레짐 변화: 검증 기간이 코로나 폭락이면? 금리 인상기면? 학습 기간과 완전히 다른 시장이라 검증 자체가 의미 없어집니다
  3. 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편에서 다뤘던 것과 같은 패턴입니다)

마무리

이번 글의 핵심:

  1. 멀티버스: 파라미터 조합마다 하나의 우주. 모든 우주를 시뮬레이션해서 가장 좋은 것을 찾습니다.
  2. 백테스트 대원칙: 미래 데이터를 절대 보여주지 않습니다. 당일까지의 데이터만 전략에 전달합니다.
  3. 제한된 자금: initial_capital로 현실의 자금 제약을 그대로 반영합니다.
  4. 과적합 방지: IS/OOS 분할 대신, 파라미터 안정성 분석으로 “넓은 고원”과 “뾰족한 봉우리”를 구분합니다.
  5. 최적화: 병렬 처리, 2단계 탐색, 사전 계산으로 조합 폭발을 관리합니다.
  6. 진짜 검증은 실시간: 멀티버스 결과를 config.yaml에 반영하고, 가상매매로 확인합니다. 이게 진짜 Out-of-sample입니다.

이 프레임워크는 GitHub에 공개되어 있습니다: kis-trading-template


참고 – 블로그 내 관련 글