XGBoost로 급락·급등 예측 모델을 만든 방법
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 #사이드프로젝트