본문 바로가기
Project/🚀 AI Finance 2026

#21 [모듈3] Machine Learning

by 로띠 2026. 3. 13.

1. 모듈 개요

M3 머신러닝 모듈은 M2에서 생성한 75개 피처를 입력으로 3종 ML 분류기(RandomForest, XGBoost, LightGBM)2종 DL 시계열 분류기(LSTM, Transformer)를 학습하고, Optuna로 하이퍼파라미터를 자동 튜닝한 뒤, 종목별 BUY/SELL/HOLD 시그널을 생성하는 파이프라인이다. 학습부터 예측까지 전 과정이 자동화되어 있으며, 모델 버전 관리와 성능 이력을 DB에 기록한다.

항목 수치
알고리즘 RandomForest, XGBoost, LightGBM + LSTM, Transformer (5종)
입력 피처 최대 75개 (NaN 50% 초과 피처 자동 제외)
타겟 변수 target_class_1d (1일), target_class_5d (5일) — 이진 분류
튜닝 Optuna 50 trials, F1 Score maximize, YAML 기반 탐색 공간
시그널 BUY (prob ≥ 0.6) / SELL (prob ≤ 0.4) / HOLD
평가 지표 Accuracy, Precision, Recall, F1, AUC-ROC
DB 테이블 4개 (feature_store, ml_model, ml_training_log, ml_prediction)

2. 아키텍처 — ML 파이프라인

M3 파이프라인은 크게 학습(Train)예측(Predict) 두 경로로 나뉜다. 학습 스케줄러가 데이터 수집부터 모델 학습까지 5단계를 자동 오케스트레이션하며, 예측 파이프라인은 활성 모델을 로드하여 실시간 시그널을 생성한다.

학습 경로 (Train)

TrainingScheduler — 5-Step 오케스트레이션
가격수집 → KIS수집 → DART수집 → 피처계산 → 학습
feature_store — 종목×일자별 75개 피처 + 4개 타겟
ModelTrainer — trainer.py (413줄)
시계열 분할 → NaN 필터 → Impute → RobustScaler → Optuna 튜닝 → 학습 → 평가
joblib.dump()
RandomForest
sklearn
XGBoost
xgboost
LightGBM
lightgbm
model + scaler + features + imputer 번들
ml_model
메타데이터 + 성능지표
ml_training_log
학습 이력 + Optuna 결과
saved_models/
.joblib 파일

예측 경로 (Predict)

feature_store (최신 행) — 종목의 가장 최근 피처
Predictor — predictor.py (224줄)
모델 로드 → 피처 추출 → NaN 처리 → 스케일링 → predict_proba → 시그널 생성
SignalGenerator
BUY / SELL / HOLD + confidence
ml_prediction
예측 결과 DB 저장

3. 학습 파이프라인

ModelTrainer.train()은 데이터 로드부터 모델 저장까지 11단계를 순차 실행한다. 시계열 데이터의 특성을 반영하여 미래 데이터 누수(data leakage)를 방지하는 것이 핵심이다.

3-1. 데이터 분할 — 시계열 순서 보장

일반 ML과 달리 shuffle=False로 시간 순서를 유지한다. 과거 데이터로 학습하고 미래 데이터로 검증하는 구조다.

구간 비율 용도
Train 85% 모델 학습 (fit)
Validation 15% Optuna 튜닝 + 평가
Test 나머지 최종 평가 (있으면 사용, 없으면 val로 대체)
# trainer.py — _split_data()
n = len(df)
train_end = int(n * train_ratio)      # 85%
val_end = int(n * (train_ratio + val_ratio))  # 100%

train_df = df.iloc[:train_end].copy()     # 과거 → 학습
val_df   = df.iloc[train_end:val_end].copy()  # 중간 → 검증
test_df  = df.iloc[val_end:].copy()       # 최신 → 평가
Update: _split_data()는 현재 BaseTrainer에서 상속받아 사용한다. ML/DL/RL Trainer 모두 공통 분할 로직을 base_trainer.py에서 상속받으며, 단일 85/15 분할 외에 tune_hyperparameters_cv()를 통한 Purged Expanding Window CV 방식도 사용할 수 있다.

3-2. NaN 처리 전략

피처별 NaN 비율이 50% 이상이면 해당 피처를 자동 제외하고, 나머지 NaN은 알고리즘별로 처리한다.

단계 처리 상세
피처 필터 NaN ≥ 50% 제외 _filter_features_by_nan(threshold=0.5)
RandomForest 중앙값 대체 SimpleImputer(strategy="median")
XGBoost / LightGBM 내장 NaN 처리 트리 분기 시 NaN을 최적 방향으로 자동 할당

3-3. 스케일링

RobustScaler를 사용한다. 중앙값과 IQR(사분위 범위) 기반이라 이상치(극단적 수익률, 급등/급락)에 강건하다. StandardScaler 대비 금융 데이터에 적합하다.

3-4. 학습 → 평가 → 저장

# 학습 11-Step 핵심 흐름

① 학습 이력 생성 (ml_training_log, status=running)
② 데이터 로드 (feature_store → DataFrame, 최소 100행 검증)
③ NaN 비율 높은 피처 자동 제외 (threshold=0.5)
④ 시계열 분할 (85% train / 15% val)
⑤ NaN Impute (RandomForest만 — median)
⑥ RobustScaler 적용
⑦ Optuna 하이퍼파라미터 튜닝 (50 trials, F1 maximize)
⑧ 최적 파라미터로 최종 학습 (model.fit)
⑨ 평가 (accuracy, precision, recall, F1, AUC-ROC)
⑩ 모델 번들 저장 (joblib — model+scaler+features+imputer)
⑪ DB 저장 (ml_model + ml_training_log 업데이트)

3-5. 평가 지표

지표 의미 용도
Accuracy 전체 정확도 전반적 성능
Precision 양성 예측 정확도 BUY 시그널 신뢰도
Recall 양성 검출률 상승 종목 포착률
F1 Score Precision×Recall 조화평균 Optuna 최적화 목표
AUC-ROC 확률 기반 판별력 모델 전반 판별 능력

4. Optuna 하이퍼파라미터 튜닝

하이퍼파라미터 탐색 공간을 ml_config.yaml에 선언하고, tuner.py가 이를 읽어 제네릭 objective 함수로 모든 알고리즘을 동일한 로직으로 튜닝한다. 알고리즘 추가 시 YAML만 수정하면 된다.

4-1. 제네릭 설계

# tuner.py — 핵심 흐름

def _generic_objective(trial, algorithm, X_train, y_train, X_val, y_val):
    # 1. YAML에서 탐색 파라미터 생성
    params = _build_params_from_space(trial, algorithm)
    # 2. YAML 기본값과 병합
    defaults = get_algorithm_defaults(algorithm).copy()
    defaults.update(params)
    # 3. 분류기 동적 생성 + 학습
    clf_class = get_classifier_class(algorithm)
    model = clf_class(**defaults)
    model.fit(X_train, y_train)
    y_pred = model.predict(X_val)
    return f1_score(y_val, y_pred, average="binary")

study = optuna.create_study(direction="maximize")
study.optimize(objective, n_trials=50)
Update: 단일 train/val 분할 기반의 tune_hyperparameters() 외에, tune_hyperparameters_cv()가 추가되었다. 이 메서드는 Purged Expanding Window CV 방식으로 시계열 교차 검증을 수행하여, 단일 분할보다 과적합에 강건한 하이퍼파라미터를 탐색한다. 각 fold에서 학습 윈도우가 확장되며, fold 간 purge gap을 두어 데이터 누수를 방지한다.

4-2. 알고리즘별 탐색 공간

알고리즘 탐색 파라미터 범위
RandomForest n_estimators 100 ~ 500 (step 50)
max_depth 5 ~ 20
min_samples_split 2 ~ 20
min_samples_leaf 1 ~ 10
XGBoost n_estimators 100 ~ 500 (step 50)
max_depth 3 ~ 10
learning_rate 0.01 ~ 0.3 (log scale)
subsample / colsample_bytree 0.6 ~ 1.0
scale_pos_weight 0.5 ~ 2.0
LightGBM n_estimators 100 ~ 500 (step 50)
num_leaves 15 ~ 63
learning_rate 0.01 ~ 0.3 (log scale)
feature_fraction / bagging_fraction 0.6 ~ 1.0
bagging_freq 1 ~ 7

4-3. 클래스 불균형 보정

주가 상승/하락이 정확히 50:50은 아니므로, 각 알고리즘이 내장 불균형 보정을 사용한다.

알고리즘 보정 방식 설정
RandomForest class_weight "balanced" — 클래스 빈도 역수로 자동 가중
XGBoost scale_pos_weight Optuna가 0.5~2.0 범위에서 최적값 탐색
LightGBM is_unbalance true — 내장 불균형 자동 처리

5. 예측 파이프라인

Predictor 클래스는 학습된 모델을 로드하여 실시간 예측을 수행한다. 단일 종목(predict_single)과 마켓 전체(predict_market) 두 가지 모드를 지원한다.

5-1. 예측 6단계

# predictor.py — _run_prediction()

① 모델 파일 로드 (joblib — model + scaler + features + imputer 번들)
② 피처 추출 (feature_store 행에서 학습 시 사용한 피처만 추출)
③ NaN 처리 (imputer 있으면 transform, 없으면 nan_to_num(0.0))
④ 스케일링 (저장된 RobustScaler.transform)
⑤ 예측 (predict + predict_proba → probability_up, probability_down)
⑥ 시그널 생성 (generate_signal → BUY/SELL/HOLD + confidence)

5-2. 마켓 전체 예측

predict_market(market)은 해당 시장의 전 종목을 순회하며 활성 모델 전부로 예측을 실행한다. 결과를 ml_prediction 테이블에 upsert하고, BUY/SELL/HOLD 시그널 분포를 반환한다.

# 반환 예시
{
  "total": 950,         # 전체 종목 수
  "predicted": 832,     # 예측 성공
  "failed": 118,        # 피처 없음 등 실패
  "signals": {
    "BUY": 287,
    "SELL": 214,
    "HOLD": 331
  }
}

5-3. 타겟 날짜 계산

예측 결과에 실제 도달할 영업일을 기록한다. target_class_1d면 1영업일 후, target_class_5d면 5영업일 후를 core.market_calendar.next_trading_day()로 계산한다. 시장 캘린더 조회 실패 시 단순 +N일로 fallback한다.

6. 시그널 생성 (BUY / SELL / HOLD)

모델의 predict_proba가 반환하는 상승 확률(probability_up)을 3구간으로 분할하여 트레이딩 시그널로 변환한다.

# signal_generator.py

def generate_signal(probability_up, buy_threshold=0.6, sell_threshold=0.4):
    if probability_up >= 0.6:    signal = "BUY"
    elif probability_up <= 0.4:  signal = "SELL"
    else:                        signal = "HOLD"

    confidence = max(probability_up, 1 - probability_up)
    return signal, confidence
시그널 조건 confidence 범위 해석
BUY prob_up ≥ 0.6 0.6 ~ 1.0 상승 확률 우위 — 매수 권고
SELL prob_up ≤ 0.4 0.6 ~ 1.0 하락 확률 우위 — 매도 권고
HOLD 0.4 < prob_up < 0.6 0.5 ~ 0.6 불확실 구간 — 관망

confidencemax(prob_up, 1 - prob_up)으로 계산한다. 상승 확률이 0.85면 confidence=0.85(BUY), 0.15면 confidence=0.85(SELL)이다. 0.5에 가까울수록 확신도가 낮다.

7. 모델 버전 관리 & DB 스키마

동일 알고리즘+마켓+타겟 조합에 대해 새 모델이 학습되면, 기존 모델은 비활성화(is_active=False)되고 새 모델이 활성화된다. 버전은 model_name 기준 자동 증가하며, 이전 버전은 삭제하지 않고 이력으로 보관한다.

7-1. 모델 네이밍 규칙

# {알고리즘 약어}_class_{마켓}_{타겟일수}d_v{버전}

ra_class_kospi_1d_v3    # RandomForest, KOSPI, 1일, 버전 3
xg_class_kosdaq_5d_v1   # XGBoost, KOSDAQ, 5일, 버전 1
li_class_kospi_1d_v2    # LightGBM, KOSPI, 1일, 버전 2

7-2. Joblib 번들 구조

모델 파일 하나에 예측에 필요한 모든 객체를 묶어서 저장한다. 예측 시 이 파일 하나만 로드하면 된다.

# joblib.dump(save_data, model_path)

save_data = {
    "model":           학습된 분류기 객체,
    "scaler":          RobustScaler (fit 완료 상태),
    "feature_columns": ["return_1d", "rsi_14", ...],
    "algorithm":       "random_forest",
    "target_column":   "target_class_1d",
    "imputer":         SimpleImputer (RandomForest만),
}

7-3. DB 테이블 — 4개

테이블 역할 주요 컬럼
feature_store 종목별 일별 피처 저장소 market, code, date, 69개 피처 + 4개 타겟
ml_model 모델 메타데이터 + 성능지표 model_name, algorithm, version, is_active, accuracy~auc_roc, model_path
ml_training_log 학습 이력 (성공/실패) status, metrics_json, feature_importance_json, optuna_trials, best_trial_value
ml_prediction 예측 결과 model_id, code, prediction_date, target_date, signal, confidence, probability_up

7-4. 버전 관리 흐름

# 동일 알고리즘+타겟의 기존 활성 모델 비활성화 후 새 버전 등록

① _get_next_version("ra_class_kospi_1d")  →  기존 v2 → 다음 v3
② deactivate_models(KOSPI, classification, target_class_1d, random_forest)
   → ra_class_kospi_1d_v2.is_active = False
③ save_model(ra_class_kospi_1d, version=3, is_active=True)
④ 예측 시 get_active_models() → 최신 활성 모델만 사용

8. YAML 설정 시스템

알고리즘 추가·수정 시 코드 변경 없이 YAML만 편집하면 되는 선언형 설정 시스템이다. ml_config_loader.py가 싱글톤으로 캐싱하여 한 번만 읽는다.

# ml_config.yaml — 알고리즘 선언 구조

algorithms:
  random_forest:
    classifier: sklearn.ensemble.RandomForestClassifier  # 동적 import 대상
    defaults:
      class_weight: "balanced"
      random_state: 42
      n_jobs: -1
    search_space:                       # Optuna 탐색 공간
      n_estimators: { type: int, low: 100, high: 500, step: 50 }
      max_depth: { type: int, low: 5, high: 20 }

  xgboost:
    classifier: xgboost.XGBClassifier
    defaults:
      eval_metric: "logloss"
      verbosity: 0
    search_space:
      learning_rate: { type: float, low: 0.01, high: 0.3, log: true }
      ...

  # ── 딥러닝 (Phase 8A) ──
  lstm:
    model_class: ml.deep_learning.architectures.LSTMClassifier
    type: deep_learning               # DL 라우팅 판별 키
    defaults:
      seq_len: 20
      hidden_size: 128
      num_layers: 2
      bidirectional: true

  transformer:
    model_class: ml.deep_learning.architectures.TransformerClassifier
    type: deep_learning
    defaults:
      seq_len: 20
      d_model: 128
      nhead: 8
      num_layers: 3

8-1. 동적 클래스 로딩

get_classifier_class()importlib으로 분류기 클래스를 동적 로딩한다. YAML의 classifier 필드에 패키지 경로를 적으면 학습기/튜너가 자동으로 해당 클래스를 사용한다.

# ml_config_loader.py

def get_classifier_class(algorithm):
    config = get_algorithm_config(algorithm)
    module_path, class_name = config["classifier"].rsplit(".", 1)
    # "sklearn.ensemble.RandomForestClassifier"
    # → module="sklearn.ensemble", class="RandomForestClassifier"
    module = importlib.import_module(module_path)
    return getattr(module, class_name)

8-2. 알고리즘 추가 절차

새 알고리즘(예: CatBoost)을 추가하려면 YAML에 블록을 추가하기만 하면 된다. trainer, tuner, predictor 코드 변경이 불필요하다.

# ml_config.yaml 에 추가하면 끝
catboost:
  classifier: catboost.CatBoostClassifier
  defaults:
    verbose: 0
    random_seed: 42
    auto_class_weights: "Balanced"
  search_space:
    iterations: { type: int, low: 100, high: 500, step: 50 }
    depth: { type: int, low: 4, high: 10 }
    learning_rate: { type: float, low: 0.01, high: 0.3, log: true }

9. 학습 스케줄러 & 자동화

ML 학습은 scheduler_service.pyStep 기반 파이프라인으로 실행된다. STEP_REGISTRY에 등록된 10개 핸들러가 순서대로 실행되며, ML 학습은 Step 10에 해당한다. 어드민 대시보드에서 수동 트리거하거나, 스케줄러가 주기적으로 호출한다.

9-1. 10-Step 핸들러 레지스트리

Step step_type 핸들러 설명
1 price _handle_price 주가 수집 (M0)
2 fundamental _handle_fundamental KIS/DART 재무 수집 (M0)
3 market_investor _handle_market_investor 투자자별 매매동향 (M0)
4 macro _handle_macro 거시경제 지표 수집 (M0)
5 news _handle_news 뉴스 수집 + NLP (M0/M1)
6 disclosure _handle_disclosure DART 공시 수집 (M0)
7 supply _handle_supply KRX 수급 수집 (M0)
8 alternative _handle_alternative 대안 데이터 수집 (M0)
9 feature _handle_feature 75개 피처 계산 (M2)
10 ml _handle_ml ML 모델 학습 (M3)

각 Step은 JobStep 테이블에 정의되며, 잡 생성 시 필요한 Step만 선택적으로 활성화할 수 있다. _execute_pipeline()이 활성화된 Step을 step_order 순으로 실행하며, 컨텍스트(ctx)를 통해 Step 간 데이터를 전달한다.

9-2. 조합별 순차 학습

Step 10(_handle_ml)에서는 JobStep.config JSON에 지정된 마켓 × 타겟일수 × 알고리즘 모든 조합을 순차 학습한다.

# JobStep config 예시
{
    "markets":    ["KOSPI", "KOSDAQ"],
    "algorithms": ["random_forest", "xgboost", "lightgbm"],
    "target_days": [1, 5],
    "optuna_trials": 50
}

# → 2 마켓 × 2 타겟 × 3 알고리즘 = 12개 모델 순차 학습 (DL 추가 시 5 알고리즘 = 20개)
for market in config["markets"]:
    for target in config["target_days"]:
        for algo in config["algorithms"]:
            trainer.train(market, algo, f"target_class_{target}d",
                          optuna_trials=50)

9-3. 피처 계산과 ML 학습의 분리

이전에는 training_scheduler.py가 데이터 수집부터 학습까지 직접 관리했으나, Step 기반 전환 후 데이터 수집(Step 1~8)과 피처 계산(Step 9)은 별도 핸들러가 담당한다. _handle_ml은 학습만 수행하며, 피처 데이터가 이미 feature_store에 준비된 상태에서 호출된다.

# scheduler_service.py — _handle_ml()
def _handle_ml(market, days_back, config, ctx):
    from ml.training_scheduler import run_training_schedule
    ml_result = run_training_schedule(
        markets=config.get("markets", [market]),
        algorithms=config.get("algorithms"),
        target_days=config.get("target_days"),
        include_price_collect=False,     # Step 1~8에서 이미 수집 완료
        include_kis_collect=False,
        include_dart_collect=False,
        include_feature_compute=False,  # Step 9에서 이미 계산 완료
        optuna_trials=config.get("optuna_trials", 50),
    )
    return {"saved": ml_result.get("trained", 0)}

10. 코드 구조 & 통계

파일 줄 수 역할
ml/trainer.py 413 학습 파이프라인 (로드→분할→튜닝→학습→평가→저장)
ml/predictor.py 224 예측 파이프라인 (로드→피처추출→예측→시그널)
ml/training_scheduler.py 202 5-Step 학습 오케스트레이션
ml/tuner.py 91 Optuna 하이퍼파라미터 튜닝 (제네릭 objective)
ml/signal_generator.py 36 BUY/SELL/HOLD 시그널 변환
ml/deep_learning/ ~1,030 DL 서브패키지 (architectures, dataset, dl_trainer, dl_tuner, dl_predictor)
ml/ml_config_loader.py 42 YAML 설정 싱글톤 로더
ml/ml_config.yaml 95 ML/DL 알고리즘 선언 (defaults + search_space)
models/ml.py 254 DB 모델 4개 (FeatureStore, MLModel, MLTrainingLog, MLPrediction)
repositories/ml_repository.py 249 피처/모델/이력/예측 CRUD + Upsert
api/routes/ml.py ~150 REST API (학습/예측/모델/이력 엔드포인트)
admin/pages/ml_*.py ~689 어드민 3페이지 (모델관리, 예측결과, 학습매니저)
합계 ~3,424 M3 모듈 전체

의존성

패키지 용도
scikit-learn RandomForestClassifier, RobustScaler, SimpleImputer, 평가 metrics
xgboost XGBClassifier
lightgbm LGBMClassifier
optuna 하이퍼파라미터 자동 튜닝 (Bayesian optimization)
torch (PyTorch) LSTM, Transformer 딥러닝 모델 (nn.Module, DataLoader, GradScaler)
joblib 모델 직렬화 (번들 저장/로드)
PyYAML ml_config.yaml 파싱

11. ADR (Architecture Decision Records)

M3 머신러닝 모듈의 핵심 아키텍처 의사결정을 기록한다. 각 ADR은 맥락(Context) → 결정(Decision) → 근거(Rationale) → 결과(Consequences) 구조로 작성되었다.

ADR-M3-001

RobustScaler 채택 — 주가 이상치 대응

Context

75개 피처를 ML 모델에 입력하기 전에 스케일링이 필요하다. 주가 데이터는 상한가(+30%), 하한가(-30%), 거래량 급등(100x) 등 극단적 이상치가 빈번하게 발생한다. 스케일러 선택이 모델 학습 안정성을 좌우한다.

Decision

sklearn.preprocessing.RobustScaler를 사용한다. 중앙값(median)과 사분위 범위(IQR)를 기준으로 스케일링한다.

Rationale

스케일러 기준값 이상치 영향 주가 데이터 적합성
StandardScaler (X - mean) / std 높음 (mean 왜곡) 상한가/하한가로 mean 편향
MinMaxScaler (X - min) / (max - min) 매우 높음 극단값 1개가 전체 범위 결정
RobustScaler (채택) (X - median) / IQR 낮음 (median 안정) 극단값이 IQR 밖으로 격리

Consequences

긍정: 급등/급락 이벤트가 다른 피처의 스케일링을 왜곡하지 않는다. Median/IQR은 이상치에 대한 breakdown point가 25%로 StandardScaler(0%)보다 월등히 견고하다.
부정: 스케일링된 값이 0~1 범위에 한정되지 않아 일부 시각화에서 직관적이지 않다. 학습/예측 시 동일한 scaler 객체를 유지해야 하므로 joblib 번들에 함께 저장한다.

ADR-M3-002

Optuna TPE — 하이퍼파라미터 자동 튜닝

Context

3종 ML + 2종 DL = 5개 알고리즘 × 2 마켓 × 2 타겟 = 20개 모델의 하이퍼파라미터를 튜닝해야 한다. 수동 튜닝은 비현실적이며, 알고리즘별 탐색 공간이 다르다.

Decision

Optuna의 TPE(Tree-structured Parzen Estimator) sampler를 사용하여 알고리즘별 50 trials 자동 튜닝한다. 탐색 공간은 ml_config.yaml에 선언하고, tuner.py의 제네릭 objective 함수가 처리한다.

Rationale

방법 탐색 효율 Trial 수 기각 사유
GridSearchCV 전수 탐색 조합 폭발 (5^6 = 15,625) 시간 비용 과다
RandomSearchCV 무작위 50 (산발적) 좋은 영역에 수렴하지 않음
Bayesian (sklearn) 가우시안 프로세스 50 고차원에서 느림, API 불편
Optuna TPE (채택) 베이지안 + Pruning 50 (집중 탐색)

Consequences

긍정: 50 trials로 GridSearch 대비 동등하거나 더 나은 F1 달성 (baseline 0.62 → 0.65~0.68). Pruning으로 성능 나쁜 trial을 조기 종료하여 실제 계산량 ~30% 절감. YAML 기반 탐색 공간으로 코드 변경 없이 범위 조정 가능.
부정: optuna 패키지 의존성 추가. trial 결과의 재현성을 위해 seed 관리 필요 (현재 random_state=42로 고정).

ADR-M3-003

YAML 기반 알고리즘 설정 — 코드/설정 분리

Context

5개 알고리즘의 클래스 경로, 기본 파라미터, Optuna 탐색 공간을 관리해야 한다. 하드코딩하면 새 알고리즘 추가/파라미터 변경 시마다 Python 코드를 수정해야 하고, 비개발자가 조정할 수 없다.

Decision

ml_config.yaml에 알고리즘 정의를 선언하고, ml_config_loader.py가 동적 import + 파라미터 매핑을 수행한다.

# ml_config.yaml — 알고리즘 선언
algorithms:
  random_forest:
    classifier: sklearn.ensemble.RandomForestClassifier
    defaults:
      class_weight: balanced
      random_state: 42
      n_jobs: -1
    search_space:
      n_estimators: {type: int, low: 100, high: 500, step: 50}
      max_depth:    {type: int, low: 5, high: 20}

# ml_config_loader.py — 동적 import
def get_classifier_class(algorithm):
    config = get_algorithm_config(algorithm)
    module_path, class_name = config["classifier"].rsplit(".", 1)
    return importlib.import_module(module_path).__dict__[class_name]

Consequences

긍정: 새 알고리즘(예: CatBoost) 추가 시 YAML에 8줄 추가만 하면 된다. Python 코드 변경 불필요. Optuna 탐색 범위도 YAML 수정으로 조정 가능.
부정: 동적 import(importlib)를 사용하므로 IDE 자동완성이 동작하지 않는다. YAML 오타 시 런타임 에러가 발생한다. 이를 완화하기 위해 trainer.py 초기화 시 YAML 유효성 검증을 수행한다.

ADR-M3-004

시계열 순차 분할 — 미래 정보 누수 방지

Context

금융 데이터는 시간 순서에 강한 의존성이 있다. sklearn의 train_test_split(shuffle=True)을 사용하면 2024년 12월 데이터로 학습한 모델이 2024년 6월을 예측하는 상황이 발생하여, 미래 정보가 학습에 포함되는 data leakage가 일어난다.

Decision

시간 순서를 유지한 순차 분할을 사용한다. shuffle=False로 과거 85%를 학습, 최신 15%를 검증에 사용한다.

# trainer.py — _split_data()
n = len(df)
train_end = int(n * 0.85)

train_df = df.iloc[:train_end]        # 과거 85% → 학습
val_df   = df.iloc[train_end:]        # 최신 15% → 검증/평가

# ✗ df.sample(frac=0.85)  → 미래 데이터 혼입
# ✗ KFold(shuffle=True)   → 교차 검증에 미래 포함

# ※ _split_data()는 현재 BaseTrainer로 이동 (base_trainer.py)
# ※ Purged Expanding Window CV도 tune_hyperparameters_cv()로 지원

Rationale

방법 Data Leakage 실전 정확도
Random Split 발생 백테스트 과적합 → 실전 하락
KFold (shuffle) 발생 교차 검증 성능 부풀림
TimeSeriesSplit 없음 정확하지만 구현 복잡도 증가
순차 분할 (채택) 없음 단순하고 실전 환경 모사

Consequences

긍정: 백테스트 결과와 실전 성능 간 괴리가 최소화된다. 모델이 "과거 패턴으로 미래를 예측"하는 실제 운용 환경을 정확히 모사한다.
부정: 검증 셋이 시간적으로 편향되어 있어, 특정 시기의 시장 특성(불장/약장)에 성능이 좌우될 수 있다. Walk-forward validation으로 확장하면 이 문제를 완화할 수 있다.

ADR-M3-005

Median Imputation — NaN 처리 전략

Context

M2의 Graceful NULL 전략(ADR-M2-003)에 따라 feature_store에는 NULL 값이 존재한다. Tree 모델(RF/XGB/LGBM)은 NaN을 네이티브로 처리할 수 있지만, RobustScaler는 NaN 입력을 지원하지 않으며, DL 모델(LSTM/Transformer)도 NaN 연산이 불가능하다.

Decision

SimpleImputer(strategy="median")을 스케일링 전 단계에서 적용한다. Imputer, Scaler, Model을 하나의 joblib 번들로 저장하여 예측 시에도 동일한 통계치를 사용한다.

# trainer.py — NaN 처리 + 스케일링 파이프라인
imputer = SimpleImputer(strategy="median")
X_train = imputer.fit_transform(X_train)   # 학습 데이터 기준 median 계산
X_val   = imputer.transform(X_val)         # 동일 median 적용

scaler = RobustScaler()
X_train = scaler.fit_transform(X_train)
X_val   = scaler.transform(X_val)

# 번들 저장 — 예측 시 동일 통계치 재사용
save_data = {
    "model": model,
    "scaler": scaler,
    "imputer": imputer,
    "feature_columns": features
}

Rationale

전략 이상치 민감도 적합성
Mean Imputation 높음 (이상치가 mean 왜곡) RobustScaler 철학과 불일치
KNN Imputation 낮음 계산 비용 O(n²), 대규모 피처에 비실용적
Median Imputation (채택) 낮음 (이상치 무관) RobustScaler와 동일한 견고성 철학

Consequences

긍정: RobustScaler + Median Imputer가 일관된 이상치 방어 체계를 구성한다. joblib 번들로 imputer/scaler/model을 함께 저장하여 학습-예측 간 통계치 불일치를 원천 차단한다.
부정: Median imputation은 MCAR(Missing Completely At Random) 가정을 전제한다. 금융 데이터에서 NaN은 "데이터 없음"(소형주 뉴스 부재)일 수 있어 이 가정이 엄밀히 성립하지 않지만, feature_store에서 NaN 비율 50% 초과 피처를 사전 제거하여 실질적 영향을 최소화한다.

ADR-M3-006

시그널 임계값 설계 — BUY 60% / SELL 40%

Context

ML 분류기의 predict_proba() 출력(상승 확률 0.0~1.0)을 BUY/SELL/HOLD 시그널로 변환해야 한다. 임계값 설정이 시그널 빈도와 정밀도 사이의 트레이드오프를 결정한다.

Decision

BUY ≥ 0.6, SELL ≤ 0.4, 나머지 HOLD로 설정한다. Confidence는 max(prob, 1-prob)로 계산한다.

# signal_generator.py
def generate_signal(prob_up: float):
    if prob_up >= 0.6:
        signal = "BUY"
    elif prob_up <= 0.4:
        signal = "SELL"
    else:
        signal = "HOLD"
    confidence = max(prob_up, 1 - prob_up)
    return signal, confidence

Rationale

임계값 BUY/SELL 빈도 정밀도 특성
0.5 / 0.5 높음 (HOLD 없음) 낮음 모든 예측이 시그널 → 노이즈 과다
0.6 / 0.4 (채택) 중간 중~상 확신 있을 때만 시그널 → 실용적
0.7 / 0.3 낮음 높음 시그널 희소 → 거래 기회 부족

Consequences

긍정: HOLD 구간(0.4~0.6)이 버퍼 역할을 하여 모델 불확실 영역에서의 잘못된 시그널을 억제한다. 약 30~40%의 예측이 HOLD로 분류되어 과매매를 방지한다.
부정: 고정 임계값이므로 시장 변동성에 따른 적응이 없다. 향후 동적 임계값(모델 calibration 기반) 또는 포트폴리오 최적화 단계에서 조정 가능.