2026. 5. 3. 14:25ㆍ카테고리 없음
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 #사이드프로젝트