09. 백테스트는 거짓말쟁이다

백테스트는 거짓말쟁이다

80/20의 함정

머신러닝을 배울 때 가장 먼저 배우는 것 중 하나가 "데이터를 80%와 20%로 나눠라"다. 학습 데이터 80%, 테스트 데이터 20%. 간단하고 직관적이다. 나도 v2.1부터 v2.5까지 이 방법을 썼다.

문제는, 시계열 데이터에서 80/20 랜덤 스플릿은 미래를 학습할 수 있다는 것이다.

주식이나 코인 가격 데이터는 시간 순서가 있다. 2024년 1월 데이터로 학습하고 2023년 12월 데이터로 테스트하면, 모델이 "미래"를 이미 본 상태에서 "과거"를 예측하는 꼴이 된다. Temporal split으로 순서를 보장해도, 단일 분할점에 의존하면 그 특정 시점의 시장 상황에 과적합될 수 있다.

v2.5까지 나는 이게 별 문제가 아니라고 생각했다. 정확도 60%대가 꾸준히 나오니까. 하지만 실전에 넣어보니 -62.83%의 손실이 났다. 백테스트에서는 괜찮았는데 실전에서 망한 것이다.

그때 깨달았다. 내 백테스트가 거짓말을 하고 있었다는 걸.


Walk-Forward: 시간을 따라 걷기

Walk-Forward 검증은 이 문제에 대한 정직한 답이다. 핵심 개념은 간단하다. 학습 윈도우를 점진적으로 확장하면서, 항상 "미래"로만 테스트한다.

Expanding Window vs Sliding Window

시계열 교차검증에는 두 가지 주요 방식이 있다. 수학적으로 정의하면:

Expanding Window (내가 사용한 방식):

$$\text{Fold } k: \quad \text{Train} = \{x_1, \ldots, x_{t_k}\}, \quad \text{Test} = \{x_{t_k+1}, \ldots, x_{t_{k+1}}\}$$

학습 데이터가 Fold가 진행될수록 누적된다. Fold 1의 학습 데이터 $\subset$ Fold 2의 학습 데이터 $\subset$ Fold 3의 학습 데이터.

Sliding Window:

$$\text{Fold } k: \quad \text{Train} = \{x_{t_k - W}, \ldots, x_{t_k}\}, \quad \text{Test} = \{x_{t_k+1}, \ldots, x_{t_{k+1}}\}$$

학습 데이터의 크기가 고정폭 $W$로 유지된다. 오래된 데이터가 탈락하고 새 데이터가 들어온다.

나는 Expanding Window를 선택했다. 이유는 크립토 시장의 특성 때문이다. 2018년의 하락장, 2020~2021년의 상승장, 2022년의 또 다른 하락장 - 이런 다양한 시장 레짐을 모두 학습에 포함시키고 싶었다. Sliding Window를 쓰면 오래된 레짐의 정보가 사라진다. 물론 Expanding Window는 오래된 데이터가 최근 패턴과 다를 수 있다는 단점이 있지만, XGBoost 같은 트리 모델은 관련 없는 데이터를 자연스럽게 무시하는 능력이 있다.

시험공부에 비유하면, Expanding Window는 1학년부터 현재 학기까지 배운 것을 전부 복습하고 다음 시험을 치르는 방식이고, Sliding Window는 직전 한 학기 내용만 복습하는 방식이다. 과목 특성상 이전 학기 내용이 계속 출제되면 전자가 유리하다.

v2.7에서 적용한 구조:

데이터: 2017-01 ~ 2026-01 (5분봉, 약 95만 행)
최근 200,000 샘플 사용

Fold 1: Train [~2023-07]      -> Test [2023-07 ~ 2023-12]
Fold 2: Train [~2024-01]      -> Test [2024-01 ~ 2024-06]
Fold 3: Train [~2024-07]      -> Test [2024-07 ~ 2024-12]

Final:  Train [~2025-07]      -> Test [2025-07 ~ 2026-01]

각 Fold에서 학습 데이터가 점점 늘어난다(Expanding). Fold 1은 2023년 7월 이전 데이터로 학습하고 하반기를 테스트한다. Fold 2는 2024년 1월 이전 데이터(Fold 1의 테스트 기간 포함)로 학습하고 상반기를 테스트한다. 이런 식으로.

fold_boundaries = [
    ('2023-07-01', '2023-12-31'),
    ('2024-01-01', '2024-06-30'),
    ('2024-07-01', '2024-12-31'),
    ('2025-01-01', '2025-06-30'),
    ('2025-07-01', '2026-02-01'),
]

for test_start, test_end in fold_boundaries:
    train_mask = timestamps < test_start  # 항상 과거만
    test_mask = (timestamps >= test_start) & (timestamps <= test_end)

핵심은 테스트 데이터가 항상 학습 데이터보다 미래라는 것이다. 모델이 절대 미래를 미리 볼 수 없다.


평가 지표의 수학적 정의

Walk-Forward 결과를 해석하기 위해서는 각 평가 지표가 정확히 무엇을 측정하는지 이해해야 한다.

Confusion Matrix와 파생 지표

3-Class 분류(LONG, SHORT, NO_TRADE)에서, 각 클래스에 대한 예측 결과는 Confusion Matrix로 요약된다. 이진 분류의 관점에서 각 클래스별로:

  • True Positive (TP): 해당 클래스로 예측했고, 실제로도 해당 클래스
  • False Positive (FP): 해당 클래스로 예측했지만, 실제로는 다른 클래스
  • True Negative (TN): 해당 클래스가 아니라고 예측했고, 실제로도 아닌 경우
  • False Negative (FN): 해당 클래스가 아니라고 예측했지만, 실제로는 해당 클래스

Precision과 Recall

Precision (정밀도):

$$\text{Precision} = \frac{TP}{TP + FP}$$

"이 클래스로 예측한 것 중 실제로 맞는 비율"이다. 트레이딩 맥락에서 LONG Precision이 높다는 것은, "LONG이라고 예측했을 때 실제로 상승한 비율이 높다"는 뜻이다. 이것이 낮으면 잘못된 진입 신호가 많다는 의미이므로, 실전에서는 불필요한 손실로 이어진다.

Recall (재현율):

$$\text{Recall} = \frac{TP}{TP + FN}$$

"실제 해당 클래스 중 올바르게 예측한 비율"이다. LONG Recall이 높다는 것은, "실제로 상승한 구간 중 모델이 LONG으로 예측한 비율이 높다"는 뜻이다. 이것이 낮으면 수익 기회를 많이 놓친다는 의미다.

Precision과 Recall은 트레이드오프 관계에 있다. 임계값을 낮추면 Recall은 올라가지만 Precision은 떨어진다 (더 많이 거래하지만 정확도가 낮아짐). 임계값을 높이면 Precision은 올라가지만 Recall은 떨어진다 (신중하게 거래하지만 기회를 놓침).

공항 보안검색에 비유하면, Precision은 "경보가 울린 사람 중 실제 위험인물 비율"이고 Recall은 "전체 위험인물 중 검색대에서 잡아낸 비율"이다. 검색을 느슨하게 하면 위험인물을 더 많이 잡지만(높은 Recall) 무고한 사람도 많이 걸리고(낮은 Precision), 검색을 빡빡하게 하면 오탐은 줄지만 놓치는 위험인물이 늘어난다.

F1 Score

$$F1 = 2 \cdot \frac{\text{Precision} \cdot \text{Recall}}{\text{Precision} + \text{Recall}}$$

F1은 Precision과 Recall의 조화평균(Harmonic Mean)이다. 산술평균이 아닌 조화평균을 사용하는 이유는, 둘 중 하나라도 극단적으로 낮으면 F1도 낮아지도록 하기 위함이다. Precision 0.99, Recall 0.01이면 산술평균은 0.50이지만 F1은 0.0198이다. 조화평균은 두 지표의 균형을 더 엄격하게 요구한다.

학교 체육 성적을 매기는데, 100m 달리기와 윗몸일으키기 두 과목이라 하자. 산술평균이면 달리기 100점, 윗몸일으키기 0점이어도 평균 50점이다. 하지만 조화평균은 0점짜리가 있으면 전체 점수가 0에 수렴한다. "한쪽만 잘하면 안 돼, 둘 다 해"라는 엄격한 채점 방식이 F1이다.

F1(macro)

$$F1_{macro} = \frac{1}{K} \sum_{k=1}^{K} F1_k$$

$K$개 클래스의 F1을 단순 평균한 것이다. 우리의 경우 $K = 3$ (LONG, SHORT, NO_TRADE). Macro 평균이 중요한 이유는 클래스 불균형에 둔감하지 않기 때문이다. 우리 데이터에서 NO_TRADE가 62.9%를 차지하므로, 만약 모든 샘플을 NO_TRADE로 예측하면 Accuracy는 62.9%가 나온다. 하지만 F1(macro)는 LONG과 SHORT의 F1이 0이므로 매우 낮게 나온다. 모델이 세 클래스를 모두 균형 있게 맞추는지를 확인하는 데 F1(macro)가 적합하다.

학교 내신으로 비유하면, Accuracy는 전교 석차(전체에서 몇 개 맞았나)이고 F1(macro)는 과목별 등급 평균이다. 국어 1등급인데 수학/영어 9등급이면 전교 석차는 그럭저럭 나올 수 있지만, 과목별 등급 평균은 처참하다. 대학은 과목별 균형을 본다.


각 Fold가 보여준 것

v2.7의 Walk-Forward 결과:

Fold 테스트 기간 Accuracy F1(macro) Train Acc Overfit Gap
1 2023 하반기 80.71% 0.6904 94.64% +13.93%
2 2024 상반기 84.54% 0.7000 90.31% +5.77%
3 2024 하반기 88.02% 0.6896 89.07% +1.05%
평균 84.42% 0.6933 6.92%

여기서 봐야 할 숫자는 Overfit Gap이다. 이것은 학습 정확도와 테스트 정확도의 차이로, 과적합의 정도를 정량적으로 측정한다.

$$\text{Overfit Gap} = \text{Acc}_{train} - \text{Acc}_{test}$$

이 값이 크면 모델이 학습 데이터의 노이즈까지 외워버린 것이고, 작으면 모델이 일반화 가능한 패턴을 학습한 것이다. 기계학습 이론에서 이것은 Bias-Variance 트레이드오프와 직결된다.

  • High Bias, Low Variance (과소적합): 학습 정확도와 테스트 정확도 모두 낮음. Gap이 작지만 절대 성능도 낮음.
  • Low Bias, High Variance (과적합): 학습 정확도는 높지만 테스트 정확도는 낮음. Gap이 큼.
  • Low Bias, Low Variance (이상적): 학습 정확도도 높고 테스트 정확도도 높음. Gap이 작고 절대 성능도 높음.

요리사에 비유하면, High Bias는 레시피를 너무 대충 외워서 어떤 주방에서든 맛이 없는 요리사다. High Variance는 자기 주방에서만 완벽하고 다른 주방 가면 망하는 요리사다. 이상적인 요리사는 어떤 주방에서든 일관되게 맛있는 음식을 만든다.

Fold 1은 갭이 13.93%다. 학습 데이터가 적어서 과적합이 심하다. 이것은 전형적인 High Variance 상태다. 하지만 Fold가 진행되면서 학습 데이터가 쌓이고, Fold 3에서는 갭이 1.05%까지 줄어든다. 학습 89.07%, 테스트 88.02%. 거의 차이가 없다.

이것이 Walk-Forward의 진짜 가치다. 모델이 일관되게 작동하는지 확인할 수 있다. 단일 80/20 스플릿에서는 Fold 3만 보이기 때문에, "와, 88% 정확도!"라고 기뻐할 수 있다. 하지만 Walk-Forward에서는 Fold 1의 80.71%도 보인다.

학습 곡선(Learning Curve) 관점에서 해석하면, Fold 1에서 Fold 3으로 가면서 학습 정확도는 94.64% -> 89.07%로 감소하고 테스트 정확도는 80.71% -> 88.02%로 증가한다. 이것은 전형적인 "데이터가 늘어나면서 모델이 안정화되는" 패턴이다. 학습 곡선에서 두 곡선(train, test)이 수렴하면 모델의 용량(capacity)이 데이터에 적합하다는 신호다.

F1(macro)의 안정성

Accuracy보다 더 중요한 건 F1(macro)이다. 3개 클래스(LONG/SHORT/NO_TRADE)의 균형 잡힌 성능을 나타내는 지표다.

Fold 1: 0.6904
Fold 2: 0.7000
Fold 3: 0.6896
std:    0.005

표준편차 0.005. 세 Fold 모두 0.69 근처에서 안정적이다. 이건 모델이 특정 시기의 시장 상황에 의존하는 게 아니라, 일관된 패턴을 학습했다는 뜻이다. Fold간 F1(macro)의 변동계수(CV)를 계산하면:

$$CV = \frac{\sigma}{\mu} = \frac{0.005}{0.6933} \approx 0.72\%$$

1% 미만의 변동계수는 모델의 안정성이 매우 높다는 것을 의미한다.


단일 스플릿에서는 보이지 않는 것

내가 v2.5에서 단일 80/20 스플릿을 쓸 때는, 테스트 정확도 60.16%에 만족했다. 하지만 이 숫자 뒤에 숨어 있던 것은:

  • 특정 시장 구간에서만 잘 맞는 모델이었을 수 있다
  • 2024년 상반기의 강한 추세장에서는 잘 맞고, 하반기 횡보장에서는 엉망일 수 있다
  • 하지만 하나의 테스트 구간만 보면 이걸 알 수 없다

Walk-Forward는 이런 편향을 드러낸다. Fold마다 다른 시장 환경을 테스트하기 때문이다.

v2.5를 Walk-Forward로 테스트했다면, 아마 Fold간 성능 차이가 극심했을 것이다. 실전에서 -62.83%가 나온 건, 테스트 데이터의 시장 환경이 학습 데이터와 달랐기 때문이다. Walk-Forward는 이런 상황을 미리 시뮬레이션해준다.


최종 모델: 가장 최근 6개월

Walk-Forward로 일관성을 확인한 후, 최종 프로덕션 모델은 이렇게 학습했다:

학습: ~ 2025년 7월 (모든 과거 데이터)
테스트: 2025년 7월 ~ 2026년 1월 (가장 최근 6개월)

가장 최근 데이터를 테스트로 남겨두는 건 의도적이다. 실전에 투입할 모델은 가장 최근의 시장 환경에서도 작동해야 한다. 2023년 데이터에서 잘 맞는 모델이 2025년 말에도 잘 맞을 거라는 보장은 없다.

최종 테스트 결과:

  • Accuracy: 88.02%
  • F1(macro): 0.6896
  • Overfit Gap: +1.05%

Overfit Gap 1.05%는 매우 양호하다. 학습에서 89.07%, 테스트에서 88.02%. 과적합이 거의 없다는 뜻이다.


스케일링에서 주의할 점: Information Leakage

Walk-Forward에서 흔히 간과하는 실수가 하나 있다. 스케일링을 각 Fold마다 새로 해야 한다.

StandardScaler의 변환 공식은:

$$z = \frac{x - \mu}{\sigma}$$

여기서 $\mu$는 평균, $\sigma$는 표준편차다. 이 변환은 데이터를 평균 0, 표준편차 1로 정규화한다.

문제는 $\mu$와 $\sigma$를 어디서 계산하느냐다.

for fold_idx, (train_idx, test_idx) in enumerate(folds):
    # 올바른 방법: 각 Fold마다 train에서 fit, test에서 transform만
    scaler = StandardScaler()
    X_train = scaler.fit_transform(X_train_raw)  # train의 mu, sigma 사용
    X_test = scaler.transform(X_test_raw)         # 같은 mu, sigma로 변환

전체 데이터로 한 번 fit_transform하면 어떻게 되는가? 테스트 데이터의 $\mu$와 $\sigma$가 학습 과정에 반영된다. 테스트 기간에 BTC가 급등했다면, 전체 $\mu$가 높아져서 학습 데이터의 스케일링 결과가 달라진다. 이것이 Information Leakage (정보 누수)다.

수학적으로, 올바른 방법에서 테스트 데이터의 변환은:

$$z_{test} = \frac{x_{test} - \mu_{train}}{\sigma_{train}}$$

잘못된 방법에서는:

$$z_{test} = \frac{x_{test} - \mu_{all}}{\sigma_{all}}$$

$\mu_{all}$과 $\sigma_{all}$에는 테스트 데이터의 통계량이 포함되어 있으므로, 모델이 간접적으로 미래 정보에 접근하게 된다. 나는 처음에 이 실수를 했다가 정확도가 비현실적으로 높게 나와서 의심하게 됐다. 비현실적으로 좋은 결과는 거의 항상 버그의 신호다.

기말고사 비유가 딱 맞다. 시험 범위(테스트 데이터)를 몰라야 정상인데, 전체 데이터로 스케일링하면 "이번 기말에 미적분 비중이 높다"는 힌트를 미리 알고 공부하는 꼴이다. 성적이 잘 나오겠지만 그건 실력이 아니라 커닝이다.


교훈: 백테스트를 의심하라

Walk-Forward를 거치고 나서도, 나는 여전히 백테스트 결과를 100% 신뢰하지 않는다. v2.7의 백테스트 수익률 +6,214%는 분명 인상적이지만, 실전에서는 이것보다 낮을 것이다. 슬리피지, 유동성 부족, API 지연, 시장 미시구조 변화 같은 요소가 백테스트에는 반영되지 않기 때문이다.

하지만 Walk-Forward는 최소한 "이 모델이 다양한 시장 환경에서 일관되게 작동하는가"에 대한 답을 준다. 단일 80/20 스플릿은 "이 특정 6개월에 잘 맞는가"에 대한 답만 줄 뿐이다.

백테스트 수익률은 거짓말할 수 있지만, Walk-Forward는 정직하다.

정직하다는 건 "항상 맞다"는 뜻이 아니다. "문제가 있으면 보여준다"는 뜻이다. 단일 스플릿은 문제를 숨기지만, Walk-Forward는 각 Fold의 성능 차이로 문제를 드러낸다.

v2.7을 실전에 넣을 때, 나는 이전보다 훨씬 더 자신감이 있었다. 정확도 88% 때문이 아니라, 세 개의 서로 다른 시장 환경에서 일관되게 작동한다는 걸 확인했기 때문이다. F1(macro)의 표준편차 0.005, Overfit Gap이 Fold 3에서 1.05%까지 수렴한다. 이 숫자들이 Walk-Forward의 진짜 가치다.


Walk-Forward 너머: Dry-Run이라는 세 번째 검증

하지만 Walk-Forward로도 부족했다. 백테스트는 어디까지나 과거 데이터로 시뮬레이션한 것이다. 실제 시장에서 API를 호출하고, 실시간 데이터를 받아서, 모델이 예측하고, 주문 직전까지 가보는, 돈은 걸지 않되 나머지는 실전과 동일한 환경이 필요했다. 그래서 Dry-Run 시스템을 만들었다.

검증의 3단계 파이프라인

내 검증 체계는 결국 세 단계로 정리됐다.

1단계: Walk-Forward (과거 데이터, 오프라인)
  → "다양한 시장 환경에서 일관되게 작동하는가?"

2단계: Dry-Run (실시간 데이터, 주문 없음)
  → "실전 환경에서 모델이 제대로 판단하는가?"

3단계: 실전 (실시간 데이터, 실제 주문)
  → "진짜 돈을 벌 수 있는가?"

2단계 Dry-Run은 바이낸스 API에서 실시간 5분봉을 받아오고, 피처를 계산하고, 모델이 예측하고, "이 시점에 이 방향으로 진입했다면"이라는 가상 거래를 DB에 기록한다. 실제 주문만 빠진 완전한 파이프라인이다.

Dry-Run이 보여준 것들

Dry-Run을 돌려보니 백테스트에서는 보이지 않던 것들이 드러났다.

첫째, 모델의 진입 필터가 너무 까다로웠다. 처음 설정에서는 MACD 조건까지 포함한 다중 필터를 걸었는데, 실시간에서는 거래 기회가 너무 적었다. 하루에 1~2건밖에 시그널이 안 나왔다. 백테스트에서는 수백 건의 거래가 있었지만, 실시간 시장의 미세한 타이밍 차이로 필터를 통과하지 못하는 경우가 많았다.

결국 진입 필터를 최소화하고 MACD 조건을 제거했다. 모델의 Triple-Barrier 예측 자체를 신뢰하고, 부가 필터는 줄이는 방향이었다. 과감한 결정이었지만, Dry-Run 데이터가 근거를 줬다. MACD 필터가 걸러낸 거래 중 상당수가 실제로는 수익 거래였기 때문이다.

둘째, 타임프레임 데이터의 동기화 문제가 있었다. 4시간봉 피처가 아직 완성되지 않은 시점에 모델이 예측을 시도하면, 직전 4시간봉의 값이 사용된다. 백테스트에서는 모든 봉이 완성된 상태이므로 이 문제가 발생하지 않는다. Dry-Run에서 이걸 발견하고 피처 계산 타이밍을 조정했다.

셋째, Celeron J4125에서의 예측 지연이 체감됐다. 8편에서 이야기한 모델 경량화(TensorFlow 제거, XGB+LGB만 사용)가 Dry-Run 단계에서 필요성이 확인된 것이다. LSTM이 포함된 원본 모델은 예측에 3~5초가 걸렸고, 경량화 후에는 0.3초로 줄었다.

Walk-Forward가 "지도에서 경로를 확인하는 것"이라면, Dry-Run은 "실제 도로에서 시운전을 해보는 것"이다. 지도에는 없는 공사 구간, 신호등 타이밍, 출퇴근 정체 같은 것들은 직접 달려봐야 안다.

검증은 끝나지 않는다

Walk-Forward → Dry-Run → 실전. 이 3단계를 거치면서 깨달은 건, 검증은 모델을 "통과"시키는 게 아니라 "드러내는" 과정이라는 것이다. 각 단계에서 이전 단계에서는 보이지 않던 문제가 나왔다. Walk-Forward에서는 과적합 여부를, Dry-Run에서는 실전 환경의 마찰을, 실전에서는 심리적 압박을 드러냈다.

완벽한 검증이란 없다. 하지만 검증을 한 단계 더 거칠 때마다, "이 모델이 실전에서 작동할 것인가"에 대한 확신이 조금씩 쌓인다. 그 조금씩이 결국 실전에서 시스템을 끄지 않고 버틸 수 있는 힘이 된다.