XGBoost로 급락·급등 예측 모델을 만든 방법

2026. 5. 3. 14:25카테고리 없음

https://dinsightlab.com

 

Passive

미국 지수 투자자를 위한 금융 데이터 분석 사이트

dinsightlab.com

 

XGBoost로 급락·급등 예측 모델을 만든 방법 

혼자 만든 AI 주식 분석 플랫폼 Passive의 핵심 모델 구현 과정을 기록한다.
이 글은 "어떻게 만들었는가"보다 "왜 이 선택을 했는가"에 집중한다.


왜 조기경보 시스템이 필요했는가

GaussianHMM으로 시장 레짐을 분류하는 데 성공했지만, 한 가지 중요한 질문이 남았다.

"지금 강세 국면인 건 알겠는데, 곧 급락이 올 가능성은 얼마나 되는가?"

레짐 분류는 현재 상태를 알려주지만, 단기 리스크 이벤트를 사전에 감지하지는 못한다. 강세 국면 한가운데서도 갑자기 -10% 급락이 발생하는 게 시장이기 때문이다. 이 문제를 풀기 위해 XGBoost 기반 조기경보 모델을 추가로 구현했다.


문제 정의: 무엇을 예측할 것인가

조기경보 모델을 설계할 때 가장 먼저 결정한 것은 레이블 정의다.

초기 시도 — 너무 단순한 정의

처음에는 단순하게 정의했다.

내일 S&P 500이 하락하면 → 1
내일 S&P 500이 상승하면 → 0

결과는 처참했다. 정확도가 52% 수준으로 동전 던지기와 다를 게 없었다. 당연한 결과였다. 일간 등락은 노이즈가 너무 많아 어떤 모델도 유의미하게 예측하기 어렵다.

최종 레이블 정의

여러 시도 끝에 아래 기준으로 정착했다.

# 급락 레이블
def label_crash(returns, window=20, threshold=-0.10):
    """
    향후 20거래일(약 1개월) 내
    누적 수익률이 -10% 이하로 떨어지면 → 1 (급락 위험)
    그 외 → 0 (정상)
    """
    labels = []
    for i in range(len(returns)):
        future = returns.iloc[i:i+window]
        cumulative = (1 + future).prod() - 1
        labels.append(1 if cumulative <= threshold else 0)
    return pd.Series(labels, index=returns.index)

# 급등 레이블
def label_surge(returns, window=20, threshold=0.10):
    labels = []
    for i in range(len(returns)):
        future = returns.iloc[i:i+window]
        cumulative = (1 + future).prod() - 1
        labels.append(1 if cumulative >= threshold else 0)
    return pd.Series(labels, index=returns.index)

20거래일 누적 기준을 선택한 이유:

  • 너무 짧으면(5일) 노이즈가 많아 신호 가치가 떨어진다
  • 너무 길면(60일) 선행 지표와의 인과관계가 약해진다
  • 20거래일은 투자자가 실제로 포지션을 조절할 수 있는 현실적인 기간이다

클래스 불균형: 가장 먼저 마주친 벽

레이블을 정의하고 데이터를 살펴보니 심각한 문제가 있었다.

print(labels.value_counts(normalize=True))
# 정상(0): 91.3%
# 급락(1):  8.7%

급락 이벤트는 전체 데이터의 8.7%에 불과했다. 이 상태로 모델을 학습하면 모델은 "항상 0(정상)"이라고 예측해도 91.3% 정확도를 달성한다. 쓸모없는 모델이 높은 정확도를 보이는 함정이다.

시도 1 — SMOTE 오버샘플링

from imblearn.over_sampling import SMOTE

smote = SMOTE(random_state=42)
X_resampled, y_resampled = smote.fit_resample(X_train, y_train)

결과: Precision이 높아졌지만 Recall이 너무 낮았다. SMOTE가 생성한 합성 샘플이 금융 시계열의 시간적 구조를 무시해서 실제로 존재하지 않는 패턴을 학습했다.

시도 2 — scale_pos_weight 파라미터

XGBoost에는 클래스 불균형을 직접 처리하는 파라미터가 있다.

# 정상 샘플 수 / 급락 샘플 수
scale_pos_weight = (labels == 0).sum() / (labels == 1).sum()
# 결과: 약 10.5

model = XGBClassifier(
    scale_pos_weight=scale_pos_weight,
    ...
)

결과: SMOTE보다 안정적이었다. 시계열 구조를 훼손하지 않으면서 소수 클래스에 더 높은 가중치를 부여하는 방식이라 금융 데이터에 더 적합했다.

최종 선택 — scale_pos_weight + 임계값 조정

scale_pos_weight로 학습하되, 예측 임계값을 0.5에서 0.35로 낮춰 Recall을 높였다. 투자 맥락에서는 급락을 놓치는 것(False Negative)이 오신호(False Positive)보다 훨씬 비싸기 때문이다.

# 기본 임계값 0.5
pred_default = (model.predict_proba(X_test)[:, 1] >= 0.5).astype(int)

# 조정 임계값 0.35
pred_adjusted = (model.predict_proba(X_test)[:, 1] >= 0.35).astype(int)

임계값 Precision Recall F1

0.5 0.68 0.51 0.58
0.35 0.57 0.71 0.63

급락 감지 목적에서는 0.35가 더 적합했다.


Optuna 하이퍼파라미터 튜닝

XGBoost 파라미터를 수동으로 튜닝하다가 한계를 느끼고 Optuna를 도입했다.

수동 튜닝의 한계

처음에는 Grid Search로 시작했다.

param_grid = {
    'max_depth': [3, 4, 5, 6],
    'learning_rate': [0.01, 0.05, 0.1],
    'n_estimators': [100, 300, 500]
}

문제는 두 가지였다. 첫째, 조합이 너무 많아 시간이 오래 걸렸다. 둘째, 시계열 데이터에서 일반적인 K-Fold CV는 데이터 누수 문제가 있었다.

TimeSeriesSplit + Optuna 조합

import optuna
from sklearn.model_selection import TimeSeriesSplit

def objective(trial):
    params = {
        'max_depth': trial.suggest_int('max_depth', 3, 7),
        'learning_rate': trial.suggest_float('learning_rate', 0.01, 0.3, log=True),
        'n_estimators': trial.suggest_int('n_estimators', 100, 1000),
        'subsample': trial.suggest_float('subsample', 0.6, 1.0),
        'colsample_bytree': trial.suggest_float('colsample_bytree', 0.6, 1.0),
        'min_child_weight': trial.suggest_int('min_child_weight', 1, 10),
        'gamma': trial.suggest_float('gamma', 0, 1),
        'scale_pos_weight': scale_pos_weight,
        'random_state': 42
    }

    tscv = TimeSeriesSplit(n_splits=5)
    scores = []

    for train_idx, val_idx in tscv.split(X):
        X_tr, X_val = X[train_idx], X[val_idx]
        y_tr, y_val = y[train_idx], y[val_idx]

        model = XGBClassifier(**params)
        model.fit(X_tr, y_tr, eval_set=[(X_val, y_val)], verbose=False)

        prob = model.predict_proba(X_val)[:, 1]
        from sklearn.metrics import roc_auc_score
        scores.append(roc_auc_score(y_val, prob))

    return np.mean(scores)

study = optuna.create_study(direction='maximize')
study.optimize(objective, n_trials=100)

best_params = study.best_params

TimeSeriesSplit을 사용한 이유는 미래 데이터가 학습에 섞이는 데이터 누수를 방지하기 위해서다. 일반 K-Fold는 시간 순서를 무시하고 랜덤으로 데이터를 분할하기 때문에 금융 시계열에는 적합하지 않다.

100회 시도 후 최적 파라미터:

best_params = {
    'max_depth': 4,
    'learning_rate': 0.047,
    'n_estimators': 387,
    'subsample': 0.81,
    'colsample_bytree': 0.73,
    'min_child_weight': 3,
    'gamma': 0.12
}

AUC-ROC가 튜닝 전 0.71에서 튜닝 후 0.78로 개선됐다.


Platt Scaling: 확률 보정이 필요한 이유

XGBoost가 출력하는 확률값(predict_proba)은 실제 확률과 다를 수 있다. 모델이 "급락 확률 70%"라고 출력해도 실제로 그 상황에서 70%의 빈도로 급락이 발생한다는 보장이 없다.

보정 전 확인

from sklearn.calibration import calibration_curve

fraction_of_positives, mean_predicted_value = calibration_curve(
    y_test, prob_pred, n_bins=10
)

보정 전 결과: 모델이 높은 확률을 출력할 때 실제 급락 발생 비율이 과소 추정되는 경향이 있었다. 즉, 모델이 "70% 확률"이라고 해도 실제로는 55% 정도만 급락이 발생했다.

Platt Scaling 적용

from sklearn.calibration import CalibratedClassifierCV

calibrated_model = CalibratedClassifierCV(
    base_model,
    method='sigmoid',  # Platt Scaling
    cv='prefit'        # 이미 학습된 모델에 적용
)
calibrated_model.fit(X_val, y_val)

Platt Scaling은 시그모이드 함수를 사용해 모델 출력을 실제 확률에 맞게 보정한다. 보정 후 "70% 확률"이 실제로 70% 빈도에 가까워졌다.

사용자에게 "급락 확률 73%"라는 숫자를 보여줄 때, 그 숫자가 실제 확률에 가까워야 신뢰할 수 있는 서비스가 된다. 이것이 Platt Scaling을 적용한 핵심 이유다.


SHAP으로 변수 중요도 해석

모델 성능을 높이는 것만큼 "왜 이 신호가 발생했는가"를 설명하는 것도 중요했다.

import shap

explainer = shap.TreeExplainer(best_model)
shap_values = explainer.shap_values(X_test)

# 전체 변수 중요도
shap.summary_plot(shap_values, X_test, feature_names=feature_names)

SHAP 분석 결과 변수 중요도 순위:

순위 변수 기여도

1 hy_spread 가장 높음
2 vix_term_structure 높음
3 fundamental_gap 중간
4 erp_zscore 중간
5 amihud_illiquidity 낮음
6 dispersion 낮음
7 residual_correlation 낮음

HY 스프레드가 가장 중요한 변수로 나온 건 예상했다. 신용 시장이 주식 시장보다 먼저 반응한다는 것은 금융 이론에서도 잘 알려진 사실이다. 이 결과가 경제적으로 타당하다는 것이 모델 신뢰성의 근거가 됐다.


배포 후 실제로 발견한 문제들

문제 1 — 오신호 연속 발생 구간

2026년 초 시장이 빠르게 회복하는 구간에서 급락 신호가 계속 울렸다. 실제로 급락은 오지 않았고, 사용자 신뢰도 문제가 생겼다.

원인 분석: HY 스프레드가 일시적으로 확대됐다가 빠르게 수축하는 패턴이 반복됐는데, 모델이 이를 급락 전조로 과민하게 반응했다.

해결책: 신호 지속 기간 필터 + HMM 레짐과 교차 확인

def final_signal(crash_prob, regime, min_consecutive=2):
    """
    급락 확률 > 임계값이 2거래일 이상 연속이고
    HMM 레짐이 전환/약세 국면일 때만 신호 발생
    """
    if crash_prob >= 0.35 and consecutive_days >= min_consecutive:
        if regime in ['transition', 'bear']:
            return 'WARNING'
    return 'NORMAL'

HMM 레짐과 XGBoost 신호를 교차 확인하는 이중 레이어가 오신호를 크게 줄였다.

문제 2 — 모델 드리프트

시간이 지나면서 모델 성능이 서서히 떨어지는 드리프트 현상이 발생했다. 학습 당시와 시장 구조가 달라지기 때문이다.

해결책: 주간 자동 재학습 파이프라인

# GitHub Actions cron: 매주 일요일 새벽 2시
# 1. FRED API에서 최신 데이터 수집
# 2. 레이블 재생성
# 3. Optuna 튜닝 (50 trials로 축소)
# 4. 모델 재학습 + Platt Scaling
# 5. AUC-ROC 검증 후 기준치(0.65) 이상이면 배포
# 6. 기준치 미달이면 이전 모델 유지

이 프로젝트에서 배운 것

이 모델을 구현하면서 데이터 분석가로서 가장 크게 배운 것은 "평가 지표 선택이 모델 설계 전체를 결정한다"는 점이다.

처음에 Accuracy로 모델을 평가했을 때는 91%가 나왔다. 훌륭해 보였지만 완전히 쓸모없는 모델이었다. AUC-ROC로 바꾸고, 다시 Precision-Recall 커브로 평가 기준을 정교화하면서 비로소 실제로 유용한 모델이 만들어졌다.

투자 맥락에서 "급락을 놓치는 것"과 "오신호를 내는 것"의 비용이 다르다는 걸 인식하고, 임계값을 0.35로 조정한 것도 순수하게 분석적 판단이 아니라 도메인 이해에서 나온 결정이었다. 데이터 분석은 결국 도메인과 분리될 수 없다는 것을 이 프로젝트에서 다시 확인했다.

👉 Passive 바로가기: https://dinsightlab.com

 

Passive

미국 지수 투자자를 위한 금융 데이터 분석 사이트

dinsightlab.com

 


#XGBoost #조기경보시스템 #머신러닝 #데이터분석 #파이썬 #Optuna #PlattScaling #클래스불균형 #SHAP #시계열분석 #퀀트 #데이터사이언스 #Passive개발일지 #미국주식AI #주식머신러닝 #데이터분석가 #포트폴리오 #개발일지 #FastAPI #사이드프로젝트