← 기사 목록으로
March 15, 2026
5분 소요

워크포워드 최적화: 유일하게 정직한 전략 테스트

워크포워드 최적화: 유일하게 정직한 전략 테스트
#algotrading
#backtest
#walk-forward
#overfitting
#validation
#optimization

전략을 최적화했습니다. 분리 파라미터 12개, 메타 파라미터 9개 — 총 21개. 단일 페어에서 25개월 백테스트 결과 최대 레버리지에서 PnL +3342%. 에쿼티 커브는 거의 드로다운 없이 상승합니다. Sharpe는 3 이상. 모든 것이 완벽해 보입니다.

봇을 시작합니다. 2주 후, 전략은 자본의 18%를 잃습니다. 한 달 후 — 34%. 과거 데이터에서 "작동한" 파라미터는 특정 시장 이벤트 시퀀스에 피팅된 것으로 밝혀졌습니다. 패턴을 찾은 것이 아니라 — 노이즈를 기억한 것입니다.

이것은 전형적인 과적합입니다. 프로덕션에 들어가기 전에 이를 감지하는 유일한 체계적 방법은 워크포워드 최적화(WFO)입니다.

단일 훈련/테스트 분할의 함정

훈련/테스트 분할 함정 시각화

표준 접근법: 데이터를 훈련 70%와 테스트 30%로 분할. 훈련에서 최적화하고, 테스트에서 검증. 결과가 양수면 — 출시.

문제: 이것은 하나의 분할에서 한 번의 테스트입니다. 결과는 경계를 어디에 그리느냐에 따라 달라집니다. 경계를 한 달 이동하면 — 아웃오브샘플 PnL이 +40%에서 -15%로 바뀔 수 있습니다.

Data:    |===== Train (70%) =====|== Test (30%) ==|
Split 1: |===2024-01..2025-09====|==2025-10..26-01==|   OOS PnL: +38%
Split 2: |===2024-01..2025-06====|==2025-07..26-01==|   OOS PnL: -12%
Split 3: |===2024-04..2025-12====|==2026-01..26-04==|   OOS PnL: +7%

세 가지 다른 분할 — 세 가지 다른 결론. 어느 것을 믿어야 할까요? 아무것도 믿으면 안 됩니다. 단일 훈련/테스트 분할은 몬테카를로 부트스트랩에서 설명한 문제가 있는 동일한 단일 점 추정입니다. 한 번의 확인이 아닌, 연속적인 데이터 세그먼트에서의 체계적인 일련의 확인이 필요합니다.

이것이 바로 워크포워드 최적화가 존재하는 이유입니다.

워크포워드 최적화란 무엇인가

워크포워드 롤링 윈도우 다이어그램

WFO는 슬라이딩(또는 확장) 데이터 윈도우에서 전략의 순차적 최적화 및 검증 절차입니다. 핵심 아이디어: 사용 가능한 데이터에서 주기적으로 파라미터를 재최적화하고 다음 재최적화까지 거래하는 실제 트레이딩 프로세스를 시뮬레이션합니다.

각 "윈도우"는 두 부분으로 구성됩니다:

  • 인샘플(IS) — 파라미터가 최적화되는 기간
  • 아웃오브샘플(OOS) — 찾은 파라미터가 피팅 없이 테스트되는 기간

핵심 특성: OOS 기간은 겹치지 않으며 데이터의 상당 부분을 함께 커버합니다. 결과 에쿼티 커브는 OOS 세그먼트만으로 구성됩니다 — 이것이 전략의 정직한 평가입니다.

앵커드 WFO (확장 윈도우)

앵커드 WFO 확장 윈도우 시각화

앵커드 WFO에서는 훈련 기간의 시작이 고정되고, 각 윈도우마다 끝이 확장됩니다:

Window 1: Train [2024-01]         Test [2024-04]
Window 2: Train [2024-01..04]     Test [2024-07]    (growing train)
Window 3: Train [2024-01..07]     Test [2024-10]
Window 4: Train [2024-01..10]     Test [2025-01]
Window 5: Train [2024-01..2025-01]  Test [2025-04]

장점:

  • 각 후속 훈련 기간에 더 많은 데이터 포함 — 더 안정적인 최적화
  • 초기 패턴이 손실되지 않음 — 항상 훈련 세트에 포함
  • 구현이 더 쉬움

단점:

  • 오래된 데이터가 현재 패턴을 "희석"할 수 있음
  • 시장이 구조적으로 변화한 경우 — 오래된 데이터가 해로움
  • 훈련 기간이 무한정 증가하여 최적화 시간 증가

롤링 WFO (슬라이딩 윈도우)

롤링 WFO에서는 고정 길이의 훈련 기간이 데이터 위를 "슬라이드"합니다:

Window 1: Train [2024-01..06]  Test [2024-07..09]
Window 2: Train [2024-04..09]  Test [2024-10..12]
Window 3: Train [2024-07..12]  Test [2025-01..03]
Window 4: Train [2024-10..2025-03]  Test [2025-04..06]
Window 5: Train [2025-01..06]  Test [2025-07..09]

장점:

  • 현재 시장 레짐에 적응
  • 일정한 최적화 시간
  • 오래되고 관련 없는 데이터가 결과에 영향을 미치지 않음

단점:

  • 훈련 데이터가 적음 — 최적 파라미터의 분산이 높음
  • 윈도우 길이 선택에 민감
  • 드물지만 중요한 이벤트(플래시 크래시)를 "잊을" 수 있음

조합 퍼지드 교차검증 (CPCV)

조합 퍼지드 교차검증 시각화

Marcos Lopez de Prado가 제안한 고급 방법. 데이터를 NN개 그룹으로 나누고, 그 중 kk개를 테스트용으로 선택합니다. 표준 교차검증과의 핵심 차이점은 퍼징(훈련/테스트 경계의 데이터 제거)과 엠바고(데이터 누출을 방지하기 위한 추가 갭)입니다:

조합 수=(Nk)\text{조합 수} = \binom{N}{k}

N=10,k=2N = 10, k = 2인 경우: 45개의 훈련/테스트 조합. 각 조합은 OOS 결과를 생성하며, 최종 추정은 모든 조합의 평균입니다.

from itertools import combinations
import numpy as np

def cpcv_splits(n_groups: int, k_test: int, purge_pct: float = 0.01):
    """
    Generate CPCV splits with purging.

    Args:
        n_groups: number of groups
        k_test: number of test groups in each split
        purge_pct: fraction of data for purging (at the train/test boundary)
    """
    groups = list(range(n_groups))
    splits = []

    for test_groups in combinations(groups, k_test):
        train_groups = [g for g in groups if g not in test_groups]
        splits.append({
            "train": train_groups,
            "test": list(test_groups),
            "purge_groups": _get_purge_groups(train_groups, test_groups),
        })

    return splits

def _get_purge_groups(train, test):
    """Groups at the train/test boundary for purging."""
    purge = set()
    for t in test:
        if t - 1 in train:
            purge.add(t - 1)
        if t + 1 in train:
            purge.add(t + 1)
    return list(purge)

CPCV는 데이터가 부족할 때 롤링 WFO보다 우수하지만, 계산 비용이 더 높습니다. 21개 파라미터와 25개월의 데이터를 가진 전략의 경우, 먼저 롤링 WFO로 시작하고 추가 확인으로 CPCV를 사용할 것을 권장합니다.

WFO의 주요 파라미터

WFO 주요 파라미터 시각화

훈련 기간 길이

훈련이 너무 짧으면 — 신뢰할 수 있는 최적화에 충분한 데이터가 없습니다. 너무 길면 — 오래된 데이터가 현재 패턴을 희석합니다.

경험 법칙: 훈련에는 최소 200-300개의 트레이드가 포함되어야 합니다. 전략이 하루 2회 트레이드를 실행하는 경우:

Tmin=300 트레이드2 트레이드/일=150 일5 개월T_{min} = \frac{300\ \text{트레이드}}{2\ \text{트레이드/일}} = 150\ \text{일} \approx 5\ \text{개월}

레짐 전환이 있는 암호화폐의 경우, 롤링 윈도우는 6-12개월 이내를 권장합니다.

테스트 기간 길이

테스트 기간은 통계적으로 유의미한 평가에 충분해야 하지만, 너무 길면 안 됩니다 — 그렇지 않으면 파라미터가 열화될 시간이 있습니다.

규칙: 테스트 = 훈련의 20-33%. 훈련 = 6개월이면, 테스트 = 1.5-2개월.

오버랩

롤링 WFO에서 윈도우는 겹칠 수 있습니다. 오버랩은 OOS 데이터 포인트의 수를 늘리지만 추정치 간의 상관관계를 도입합니다:

Without overlap:
  Train [01..06] → Test [07..09]
  Train [07..12] → Test [01..03]

With 50% overlap:
  Train [01..06] → Test [07..09]
  Train [04..09] → Test [10..12]
  Train [07..12] → Test [01..03]

권장: 훈련 기간의 50% 오버랩 — 윈도우 수와 추정의 독립성 사이의 좋은 균형입니다.

재최적화 빈도

파라미터를 얼마나 자주 재계산할지 결정합니다. 암호화폐 시장에서 최적의 빈도는 1-3개월마다입니다. 더 빈번한 재최적화는 노이즈에 대한 과적합 위험을 높이고, 덜 빈번하면 — 파라미터 노후화 위험이 높아집니다.

워크포워드 효율성 비율과 열화율

워크포워드 효율성 비율 및 열화 시각화

워크포워드 효율성 비율 (WFER)

WFO의 핵심 지표 — OOS 수익률과 IS 수익률의 비율:

WFER=PnLOOSPnLIS\text{WFER} = \frac{\text{PnL}_{OOS}}{\text{PnL}_{IS}}

해석:

WFER 해석
> 0.8 우수한 견고성. 파라미터가 새 데이터에 이전 가능.
0.5 — 0.8 허용 가능한 견고성. 전략은 작동하지만 열화 있음.
0.3 — 0.5 경계선 사례. 부분적 과적합 가능성.
< 0.3 과적합. 파라미터가 IS 데이터에 피팅됨.
< 0 전략이 OOS에서 수익성 없음. 완전한 과적합 또는 로직 오류.

WFER < 0.5이면 — 전략이 과적합일 가능성이 높습니다. 이것이 우리의 주요 필터입니다.

열화율

최적 파라미터가 시간이 지남에 따라 얼마나 빨리 효과를 잃는지 보여줍니다:

열화율=d(OOS PnL)dt\text{열화율} = \frac{d(\text{OOS PnL})}{dt}

실제로는: 테스트 기간을 하위 구간으로 나누고 PnL 역학을 추적합니다:

def degradation_rate(oos_returns: np.ndarray, n_subperiods: int = 4) -> float:
    """
    Estimate parameter degradation rate.

    Splits the OOS period into sub-intervals and computes the slope
    of linear regression of PnL against sub-interval number.

    Returns:
        slope: negative = degradation, positive = improvement
    """
    chunk_size = len(oos_returns) // n_subperiods
    subperiod_pnls = []

    for i in range(n_subperiods):
        start = i * chunk_size
        end = start + chunk_size
        sub_pnl = np.sum(oos_returns[start:end])
        subperiod_pnls.append(sub_pnl)

    x = np.arange(n_subperiods)
    slope = np.polyfit(x, subperiod_pnls, 1)[0]

    return slope

열화율이 강하게 음수이면 — 파라미터가 빠르게 노후화되므로 더 빈번한 재최적화 또는 더 짧은 훈련 기간이 필요합니다.

Python으로 전체 WFO 파이프라인 구현

WFO 파이프라인 아키텍처 시각화

import numpy as np
import pandas as pd
from dataclasses import dataclass, field
from typing import Callable, List, Optional
import warnings

@dataclass
class WFOWindow:
    """A single walk-forward window."""
    window_id: int
    train_start: int         # train start index
    train_end: int           # train end index (exclusive)
    test_start: int          # test start index
    test_end: int            # test end index (exclusive)
    best_params: dict = field(default_factory=dict)
    is_pnl: float = 0.0     # in-sample PnL
    oos_pnl: float = 0.0    # out-of-sample PnL
    oos_returns: np.ndarray = field(default_factory=lambda: np.array([]))
    wfer: float = 0.0       # walk-forward efficiency ratio

@dataclass
class WFOResult:
    """Result of the entire WFO."""
    windows: List[WFOWindow]
    aggregate_oos_pnl: float
    aggregate_is_pnl: float
    wfer: float
    degradation_rate: float
    oos_equity: np.ndarray
    oos_sharpe: float
    oos_max_dd: float
    n_windows: int
    passed: bool             # whether the strategy passed the filter

class WalkForwardOptimizer:
    """
    Walk-Forward Optimization pipeline.

    Supports anchored (expanding) and rolling (sliding) modes.
    """

    def __init__(
        self,
        data: np.ndarray,
        optimize_fn: Callable,
        evaluate_fn: Callable,
        mode: str = "rolling",         # "rolling" or "anchored"
        train_size: int = 180,         # days
        test_size: int = 60,           # days
        step_size: int = 60,           # window step size, days
        min_trades: int = 30,          # min number of trades in OOS
        wfer_threshold: float = 0.5,   # WFER threshold for accept/reject
    ):
        self.data = data
        self.optimize_fn = optimize_fn
        self.evaluate_fn = evaluate_fn
        self.mode = mode
        self.train_size = train_size
        self.test_size = test_size
        self.step_size = step_size
        self.min_trades = min_trades
        self.wfer_threshold = wfer_threshold

    def generate_windows(self) -> List[WFOWindow]:
        """Generate walk-forward windows."""
        n = len(self.data)
        windows = []
        window_id = 0

        if self.mode == "rolling":
            start = 0
            while start + self.train_size + self.test_size <= n:
                w = WFOWindow(
                    window_id=window_id,
                    train_start=start,
                    train_end=start + self.train_size,
                    test_start=start + self.train_size,
                    test_end=min(start + self.train_size + self.test_size, n),
                )
                windows.append(w)
                start += self.step_size
                window_id += 1

        elif self.mode == "anchored":
            train_end = self.train_size
            while train_end + self.test_size <= n:
                w = WFOWindow(
                    window_id=window_id,
                    train_start=0,
                    train_end=train_end,
                    test_start=train_end,
                    test_end=min(train_end + self.test_size, n),
                )
                windows.append(w)
                train_end += self.step_size
                window_id += 1

        return windows

    def run(self) -> WFOResult:
        """Run the full WFO pipeline."""
        windows = self.generate_windows()
        all_oos_returns = []

        for w in windows:
            train_data = self.data[w.train_start:w.train_end]
            test_data = self.data[w.test_start:w.test_end]

            best_params, is_pnl = self.optimize_fn(train_data)
            w.best_params = best_params
            w.is_pnl = is_pnl

            oos_pnl, oos_returns = self.evaluate_fn(test_data, best_params)
            w.oos_pnl = oos_pnl
            w.oos_returns = oos_returns

            if is_pnl != 0:
                w.wfer = oos_pnl / is_pnl
            else:
                w.wfer = 0.0

            all_oos_returns.extend(oos_returns)

        all_oos = np.array(all_oos_returns)
        oos_equity = np.cumprod(1 + all_oos)
        peak = np.maximum.accumulate(oos_equity)
        max_dd = ((oos_equity - peak) / peak).min()

        aggregate_is = sum(w.is_pnl for w in windows)
        aggregate_oos = sum(w.oos_pnl for w in windows)
        wfer = aggregate_oos / aggregate_is if aggregate_is != 0 else 0

        if np.std(all_oos) > 0:
            oos_sharpe = np.mean(all_oos) / np.std(all_oos) * np.sqrt(252)
        else:
            oos_sharpe = 0

        deg_rate = self._degradation_rate(windows)

        passed = wfer >= self.wfer_threshold and aggregate_oos > 0

        return WFOResult(
            windows=windows,
            aggregate_oos_pnl=aggregate_oos,
            aggregate_is_pnl=aggregate_is,
            wfer=wfer,
            degradation_rate=deg_rate,
            oos_equity=oos_equity,
            oos_sharpe=oos_sharpe,
            oos_max_dd=max_dd,
            n_windows=len(windows),
            passed=passed,
        )

    def _degradation_rate(self, windows: List[WFOWindow]) -> float:
        """Slope of OOS PnL across window numbers."""
        if len(windows) < 3:
            return 0.0
        pnls = [w.oos_pnl for w in windows]
        x = np.arange(len(pnls))
        slope = np.polyfit(x, pnls, 1)[0]
        return slope

사용 예시

import numpy as np

np.random.seed(42)
prices = 100 * np.cumprod(1 + np.random.normal(0.0002, 0.02, 750))

def my_optimize(train_data):
    """
    Optimize strategy on train data.
    Returns (best_params, is_pnl).
    """
    best_pnl = -np.inf
    best_params = {}

    for fast in range(5, 30, 5):
        for slow in range(20, 100, 10):
            if fast >= slow:
                continue
            pnl, _ = _run_strategy(train_data, fast, slow)
            if pnl > best_pnl:
                best_pnl = pnl
                best_params = {"fast": fast, "slow": slow}

    return best_params, best_pnl

def my_evaluate(test_data, params):
    """
    Evaluate strategy on test data with fixed parameters.
    Returns (oos_pnl, oos_returns).
    """
    pnl, returns = _run_strategy(test_data, params["fast"], params["slow"])
    return pnl, returns

def _run_strategy(data, fast_period, slow_period):
    """Simple MA crossover strategy."""
    fast_ma = pd.Series(data).rolling(fast_period).mean().values
    slow_ma = pd.Series(data).rolling(slow_period).mean().values

    position = 0
    returns = []

    for i in range(slow_period, len(data) - 1):
        if fast_ma[i] > slow_ma[i] and position <= 0:
            position = 1
        elif fast_ma[i] < slow_ma[i] and position >= 0:
            position = -1

        daily_ret = (data[i + 1] - data[i]) / data[i]
        returns.append(position * daily_ret)

    total_pnl = np.sum(returns)
    return total_pnl, np.array(returns)

wfo = WalkForwardOptimizer(
    data=prices,
    optimize_fn=my_optimize,
    evaluate_fn=my_evaluate,
    mode="rolling",
    train_size=180,    # 6 months
    test_size=60,      # 2 months
    step_size=60,      # step = test
)

result = wfo.run()

print(f"Windows: {result.n_windows}")
print(f"OOS PnL: {result.aggregate_oos_pnl:.4f}")
print(f"IS PnL:  {result.aggregate_is_pnl:.4f}")
print(f"WFER:    {result.wfer:.3f}")
print(f"OOS Sharpe: {result.oos_sharpe:.2f}")
print(f"OOS MaxDD:  {result.oos_max_dd:.2%}")
print(f"Degradation: {result.degradation_rate:.5f}")
print(f"Passed:  {result.passed}")

for w in result.windows:
    print(f"  Window {w.window_id}: IS={w.is_pnl:.4f} OOS={w.oos_pnl:.4f} "
          f"WFER={w.wfer:.2f} params={w.best_params}")

결과 해석: 언제 신뢰하고 언제 거부할 것인가

전략이 WFO를 통과한 경우

모든 윈도우에서 WFER >= 0.5이고, OOS PnL이 양수이며 안정적인 경우:

Window 0: IS=0.0812  OOS=0.0531  WFER=0.65  params={'fast': 10, 'slow': 50}
Window 1: IS=0.0744  OOS=0.0489  WFER=0.66  params={'fast': 10, 'slow': 50}
Window 2: IS=0.0698  OOS=0.0401  WFER=0.57  params={'fast': 15, 'slow': 50}
Window 3: IS=0.0823  OOS=0.0512  WFER=0.62  params={'fast': 10, 'slow': 60}
Window 4: IS=0.0756  OOS=0.0478  WFER=0.63  params={'fast': 10, 'slow': 50}
→ Aggregate WFER: 0.63, all windows > 0.5, parameters are stable

좋은 징후:

  • WFER가 윈도우 간에 안정적 (급격한 변동 없음)
  • 윈도우 간 파라미터가 유사 (fast = 10-15, slow = 50-60)
  • 대부분의 윈도우에서 OOS PnL이 양수
  • 열화율이 0에 가까움

전략이 WFO에 실패한 경우

Window 0: IS=0.2341  OOS=-0.0312  WFER=-0.13  params={'fast': 5, 'slow': 95}
Window 1: IS=0.1987  OOS=0.0089   WFER=0.04   params={'fast': 25, 'slow': 30}
Window 2: IS=0.2156  OOS=-0.0567  WFER=-0.26  params={'fast': 10, 'slow': 90}
Window 3: IS=0.1834  OOS=0.0234   WFER=0.13   params={'fast': 20, 'slow': 40}
→ Aggregate WFER: -0.07, IS is high, OOS is near zero → overfitting

과적합 징후:

  • 높은 IS PnL, 낮은/음수 OOS PnL — 전형적인 과적합
  • 윈도우 간 파라미터가 크게 변동 — 안정적인 최적값이 없음
  • 대부분의 윈도우에서 WFER < 0.3 — 파라미터가 이전되지 않음
  • 열화율이 강하게 음수 — 빠른 열화

파라미터 안정성 분석에 대한 자세한 내용은 기사 플래토 분석을 참조하세요. 최적값이 "날카롭다면"(작은 파라미터 변경에 급격히 하락) — 이것은 추가적인 과적합 신호입니다.

암호화폐에서의 WFO 특수성

암호화폐 WFO 특수성 시각화

암호화폐는 전통 시장에는 존재하지 않는 WFO 고유의 문제를 만들어냅니다.

레짐 전환

암호화폐 시장은 근본적으로 다른 레짐 사이를 전환합니다: 상승 추세, 하락 추세, 높은/낮은 변동성의 횡보. 하나의 레짐에서 최적인 파라미터는 다른 레짐에서 수익성이 없을 수 있습니다.

해결책: 4-6개월 윈도우의 롤링 WFO(앵커드가 아닌)를 사용합니다. 이를 통해 오래된 레짐을 "잊을" 수 있습니다. 추가로 — 변동성별로 데이터를 클러스터링하고 각 클러스터에 대해 별도로 WFO를 실행합니다.

짧은 히스토리

대부분의 알트코인은 3년 미만의 거래 히스토리를 가지고 있습니다. 훈련 = 6개월, 테스트 = 2개월이면 4-5개의 윈도우만 얻게 됩니다 — 통계적으로 약한 추정입니다.

해결책: 롤링 WFO 대신 또는 롤링 WFO에 추가로 CPCV를 사용합니다. CPCV는 동일한 데이터에서 더 많은 조합을 생성합니다. 10개 그룹, k=2의 경우: 4-5개 윈도우 대신 45개 조합.

구조적 유동성 변화

암호화폐 페어의 유동성은 비정상적입니다: 페어가 6개월 동안 유동적이다가 거래량이 10배 감소할 수 있습니다. 유동적인 시장에서 최적화된 파라미터는 비유동적인 시장에서는 작동하지 않습니다.

해결책: WFO 파이프라인에 유동성 필터를 추가합니다. 평균 일일 거래량이 임계값 미만인 윈도우를 제외합니다. 테스트 기간의 유동성이 훈련 기간과 비슷한지 확인합니다.

Funding Rate 영향

레버리지 선물 전략의 경우, Funding Rate가 OOS 결과를 근본적으로 변경할 수 있습니다. 전략이 2개월 동안 +5% OOS를 보여주지만, 10배 레버리지에서 Funding Rate가 3.6%를 소모합니다.

Funding Rate 영향에 대한 자세한 분석은 기사 Funding Rate가 레버리지를 죽인다를 참조하세요. WFO에서 OOS PnL을 평가할 때 반드시 Funding Rate 비용을 고려하세요.

다중 파라미터 전략: 12개 이상의 파라미터에서 WFO가 중요한 이유

다중 파라미터 최적화에서의 차원의 저주

21개 파라미터(분리 12 + 메타 9)를 가진 전략이 단일 페어의 25개월 데이터에 대해 — 이것은 거대한 탐색 공간을 가진 모델입니다.

차원의 저주

파라미터 조합의 수는 파라미터 수에 따라 기하급수적으로 증가합니다:

조합=i=1nPi\text{조합} = \prod_{i=1}^{n} |P_i|

21개 파라미터 각각이 최소 10개의 값을 취하는 경우:

1021=10 세스틸리언 조합10^{21} = 10\ \text{세스틸리언 조합}

베이지안 최적화(자세한 내용은 좌표 하강법 vs 베이지안)를 사용하더라도 공간의 극히 작은 부분만 탐색합니다. 발견된 최적값이 실제 패턴이 아닌 노이즈 아티팩트일 확률은 파라미터 수에 따라 증가합니다.

다중 비교를 위한 Bonferroni 공식

MM개의 파라미터 조합을 테스트하면, 거짓 "발견"(우연히 좋은 결과를 찾을) 확률:

P(거짓 발견)=1(1α)M1eαMP(\text{거짓 발견}) = 1 - (1 - \alpha)^M \approx 1 - e^{-\alpha M}

α=0.05\alpha = 0.05이고 M=10000M = 10000개의 시도된 조합인 경우:

P1e5001.0P \approx 1 - e^{-500} \approx 1.0

"작동하는" 파라미터를 찾는 것은 보장됩니다 — 실제로는 노이즈에 피팅된 것입니다. WFO 없이는 진정한 엣지와 통계적 아티팩트를 구별할 방법이 없습니다.

규칙: OOS 데이터 포인트 수 vs 파라미터 수

WFO 결과를 신뢰하기 위한 경험 법칙:

OOS 트레이드파라미터>10\frac{\text{OOS 트레이드}}{\text{파라미터}} > 10

21개 파라미터의 경우 최소 210개의 OOS 트레이드가 필요합니다. WFO가 그 이하를 생성하면 — 결과를 신뢰할 수 없습니다.

+3342% PnL@ML 전략: 21개 파라미터, 25개월 데이터. 5개의 OOS 윈도우 각 60일, 하루 2회 트레이드를 가정하면 — 총 5×60×2=6005 \times 60 \times 2 = 600개의 OOS 트레이드. 비율 600/21=28.6600/21 = 28.6 — 허용 가능하지만 WFER > 0.5인 경우에만.

WFO와 Optuna 통합

Optuna와의 베이지안 최적화 통합

각 WFO 윈도우에서 파라미터를 최적화해야 합니다. 21개 파라미터의 경우 그리드 서치는 불가능하고, 좌표 하강법은 비효율적입니다. 최적의 선택은 Optuna를 통한 베이지안 최적화입니다.

import optuna
from optuna.samplers import TPESampler

def optuna_optimize(train_data: np.ndarray, n_trials: int = 500) -> tuple:
    """
    Optimize strategy parameters using Optuna.
    Used inside each WFO window.
    """

    def objective(trial):
        fast = trial.suggest_int("fast_period", 3, 50)
        slow = trial.suggest_int("slow_period", 20, 200)
        atr_period = trial.suggest_int("atr_period", 5, 50)
        atr_mult = trial.suggest_float("atr_multiplier", 0.5, 4.0)
        rsi_period = trial.suggest_int("rsi_period", 5, 30)
        rsi_upper = trial.suggest_int("rsi_upper", 60, 85)
        rsi_lower = trial.suggest_int("rsi_lower", 15, 40)
        vol_window = trial.suggest_int("vol_window", 10, 100)
        position_size = trial.suggest_float("position_size", 0.1, 1.0)
        take_profit = trial.suggest_float("take_profit", 0.005, 0.05)
        stop_loss = trial.suggest_float("stop_loss", 0.003, 0.03)
        trailing_pct = trial.suggest_float("trailing_pct", 0.002, 0.02)

        if fast >= slow:
            return -1e6  # invalid combination

        params = {
            "fast_period": fast, "slow_period": slow,
            "atr_period": atr_period, "atr_multiplier": atr_mult,
            "rsi_period": rsi_period, "rsi_upper": rsi_upper,
            "rsi_lower": rsi_lower, "vol_window": vol_window,
            "position_size": position_size,
            "take_profit": take_profit, "stop_loss": stop_loss,
            "trailing_pct": trailing_pct,
        }

        pnl, _ = run_strategy(train_data, params)

        _, returns = run_strategy(train_data, params)
        if len(returns) < 30 or np.std(returns) == 0:
            return -1e6
        sharpe = np.mean(returns) / np.std(returns) * np.sqrt(252)
        return sharpe

    optuna.logging.set_verbosity(optuna.logging.WARNING)

    study = optuna.create_study(
        direction="maximize",
        sampler=TPESampler(seed=42),
    )
    study.optimize(objective, n_trials=n_trials, show_progress_bar=False)

    best_params = study.best_params
    best_pnl, _ = run_strategy(train_data, best_params)

    return best_params, best_pnl

wfo = WalkForwardOptimizer(
    data=prices,
    optimize_fn=optuna_optimize,     # Optuna instead of grid search
    evaluate_fn=my_evaluate,
    mode="rolling",
    train_size=180,
    test_size=60,
    step_size=60,
)

result = wfo.run()

중요: WFO 내에서는 PnL이 아닌 Sharpe를 최적화하세요. PnL 최적화는 특정 트레이드 시퀀스에서 이익을 최대화하는 파라미터를 찾습니다. Sharpe 최적화는 수익 대 위험 비율이 가장 좋은 파라미터를 찾습니다 — OOS에서 더 견고합니다.

Optuna TPE와 좌표 하강법의 자세한 비교는 기사 좌표 하강법 vs 베이지안을 참조하세요.

WFO 결과 시각화

import matplotlib.pyplot as plt
import matplotlib.patches as mpatches

def plot_wfo_results(result: WFOResult, data: np.ndarray):
    """Visualize Walk-Forward Optimization results."""
    fig, axes = plt.subplots(3, 1, figsize=(16, 14))

    ax = axes[0]
    ax.plot(result.oos_equity, color='#4FC3F7', linewidth=1.5)
    ax.axhline(1.0, color='#FF5252', linestyle='--', alpha=0.5, label='Break-even')
    ax.set_title(f'OOS Equity Curve (WFER={result.wfer:.2f}, Sharpe={result.oos_sharpe:.2f})')
    ax.set_ylabel('Equity')
    ax.legend()
    ax.grid(True, alpha=0.3)

    ax = axes[1]
    wfers = [w.wfer for w in result.windows]
    colors = ['#69F0AE' if w >= 0.5 else '#FFAB40' if w >= 0.3 else '#FF5252'
              for w in wfers]
    ax.bar(range(len(wfers)), wfers, color=colors, edgecolor='#1A237E', alpha=0.8)
    ax.axhline(0.5, color='#E040FB', linestyle='--', label='Threshold (0.5)')
    ax.axhline(0, color='gray', linestyle='-', alpha=0.3)
    ax.set_title('Walk-Forward Efficiency Ratio by Window')
    ax.set_xlabel('Window')
    ax.set_ylabel('WFER')
    ax.legend()

    ax = axes[2]
    x = np.arange(len(result.windows))
    width = 0.35
    ax.bar(x - width/2, [w.is_pnl for w in result.windows],
           width, label='IS PnL', color='#7C4DFF', alpha=0.7)
    ax.bar(x + width/2, [w.oos_pnl for w in result.windows],
           width, label='OOS PnL', color='#4FC3F7', alpha=0.7)
    ax.set_title('In-Sample vs Out-of-Sample PnL')
    ax.set_xlabel('Window')
    ax.set_ylabel('PnL')
    ax.legend()
    ax.grid(True, alpha=0.3)

    plt.tight_layout()
    plt.savefig('wfo_results.png', dpi=150)
    plt.show()

실전 권장사항

프로덕션에 전략을 출시하기 전 체크리스트

1. WFO 실행 (롤링 + 앵커드)

두 모드의 결과를 비교합니다. 롤링 WFO가 실패하고 앵커드가 통과하면 — 전략이 초기 데이터에서만 작동할 가능성이 높습니다.

2. 각 윈도우의 WFER 확인

집계 WFER뿐만 아니라 각 윈도우를 개별적으로. 6개 윈도우 중 2개에서 WFER < 0이면 — 집계 > 0.5여도 문제입니다.

3. 윈도우 간 파라미터 비교

최적 파라미터가 윈도우마다 "점프"하면 — 안정적인 엣지가 없습니다. 최적값의 안정성을 검증하려면 플래토 분석을 사용하세요.

4. 열화율 확인

열화율이 강하게 음수 = 파라미터가 빠르게 효과를 잃음. 더 빈번한 재최적화 또는 전략 개편이 필요합니다.

5. OOS 결과에 몬테카를로 부트스트랩 적용

집계 OOS PnL도 단일 점 추정입니다. OOS 수익률 배열에 몬테카를로 부트스트랩을 적용하여 신뢰 구간을 얻으세요.

6. 비용 고려

OOS PnL에는 수수료, 슬리피지, Funding Rate가 포함되어야 합니다. 비용 없는 멋진 OOS PnL은 환상입니다. 자세한 내용은 — Funding Rate가 레버리지를 죽인다.

최소 데이터 요구사항

파라미터 수 최소 OOS 트레이드 최소 WFO 윈도우 최소 데이터 (하루 2 트레이드)
2-5 50 3 약 6개월
6-10 100 4 약 12개월
11-15 150 5 약 18개월
16-21 210 6 약 24개월
22+ 300+ 8+ 약 36개월 이상

21개 파라미터와 25개월 데이터의 전략

기사 서두의 질문으로 돌아갑시다: 단일 페어의 25개월 데이터에서 최적화된 21개 파라미터. PnL@ML = +3342%. 어떻게 검증할 것인가?

단계 1. 롤링 WFO: 훈련 = 8개월, 테스트 = 2개월, 스텝 = 2개월. 약 8개 윈도우 확보.

단계 2. 앵커드 WFO: 첫 훈련 = 8개월, 테스트 = 2개월. 약 8개 윈도우 확보.

단계 3. CPCV: 약 2.5개월의 10개 그룹, k = 2. 45개 조합 확보.

단계 4. 각 방법에 대해 검증:

  • WFER >= 0.5?
  • 윈도우 간 파라미터가 안정적인가?
  • 열화율이 허용 가능한가?
  • OOS 트레이드 / 파라미터 >= 10?

단계 5. 집계 OOS 수익률에 몬테카를로 부트스트랩. 5번째 백분위 PnL > 0?

이러한 테스트 중 하나라도 실패하면 — +3342% 전략은 과적합일 가능성이 높습니다. 단일 페어의 25개월에서 21개 파라미터 — 이것은 극도로 높은 파라미터 대 데이터 비율입니다. WFO를 통과하지 않으면 신뢰할 수 없습니다.

추가로 활성 시간별 PnL을 고려한 전략 효율성 확인도 권장합니다 — 이를 통해 +3342% 중 어느 부분이 포지션 보유 시간에 의한 것이고 어느 부분이 실제 엣지에 의한 것인지 드러납니다.

결론

워크포워드 최적화는 선택사항이 아닙니다 — 필수입니다. 파라미터의 새 데이터로의 이전 가능성을 체계적으로 검증하는 유일한 방법입니다. 단일 훈련/테스트 분할은 복권입니다. 전체 데이터에서의 풀 백테스트는 자기기만입니다.

핵심 요점:

  1. WFER < 0.5 = 과적합. 아웃오브샘플 PnL이 인샘플의 절반 미만이면 — 파라미터가 피팅된 것입니다.

  2. 파라미터 안정성이 최대값보다 중요합니다. 매 윈도우에서 +15%를 내는 파라미터가 하나에서 +40%, 다른 하나에서 -10%를 내는 파라미터보다 낫습니다.

  3. 암호화폐에는 롤링 WFO. 레짐 전환은 앵커드 WFO의 신뢰성을 떨어뜨립니다. 4-6개월의 롤링 윈도우가 최적의 균형입니다.

  4. 파라미터가 많을수록 — 요구사항이 엄격해집니다. 21개 파라미터에는 최소 210개의 OOS 트레이드와 6개 이상의 WFO 윈도우가 필요합니다. 이것 없이는 결과를 검증할 수 없습니다.

  5. WFO + 몬테카를로 부트스트랩 + 플래토 분석 — 과적합 방어의 3중 레이어. 각 레이어는 다른 레이어가 놓치는 것을 포착합니다.

모든 윈도우에서 WFER > 0.5, 안정적인 파라미터, 양의 5번째 백분위 부트스트랩으로 WFO를 통과한 전략 — 그것이 실제 자금을 맡길 수 있는 전략입니다. 나머지는 모두 예쁜 에쿼티 커브를 가진 커브 피팅입니다.


참고 링크

  1. Pardo, R. — The Evaluation and Optimization of Trading Strategies (Wiley)
  2. Lopez de Prado, M. — Advances in Financial Machine Learning, Chapter 12: Backtesting
  3. Bailey, D.H. et al. — The Probability of Backtest Overfitting
  4. Lopez de Prado, M. — The Combinatorial Purged Cross-Validation (CPCV)
  5. Aronson, D.R. — Evidence-Based Technical Analysis
  6. Optuna: A Next-generation Hyperparameter Optimization Framework
  7. Kevin Davey — Building Winning Algorithmic Trading Systems: Walk-Forward Analysis
  8. White, H. — A Reality Check for Data Snooping (2000)
  9. Harvey, C.R. & Liu, Y. — Backtesting (2015)
  10. NumPy — numpy.cumprod

Citation

@article{soloviov2026walkforwardoptimization,
  author = {Soloviov, Eugen},
  title = {Walk-Forward Optimization: The Only Honest Strategy Test},
  year = {2026},
  url = {https://marketmaker.cc/en/blog/post/walk-forward-optimization},
  version = {0.1.0},
  description = {Why a single train/test split does not protect against overfitting, how walk-forward optimization systematically verifies parameter robustness, and why a strategy with +3342\% PnL@ML on 21 parameters is a ticking time bomb without WFO.}
}
blog.disclaimer

MarketMaker.cc Team

퀀트 리서치 및 전략

Telegram에서 토론하기
Newsletter

시장에서 앞서 나가세요

뉴스레터를 구독하여 독점적인 AI 트레이딩 통찰력, 시장 분석 및 플랫폼 업데이트를 받아보세요.

귀하의 개인정보를 존중합니다. 언제든지 구독을 취소할 수 있습니다.