Files
auto-stock/backend/app/services/ml_ensemble.py
shuim 43b4b1a7aa fix(ml): ML 앙상블 전면 미작동 — 시계열 역방향 + 시퀀스 워밍업 NaN 크래시
9/23~9/28 닷새간 stock_analysis.ml_score 가 200/200 NULL 이었고 로그는 0줄이었다.
원인 3건이 겹쳐 있었다.

1. 시계열 역방향 (추론 ≠ 학습)
   market_analyzer 는 chart_data 를 oldest-first 로 정규화해 넘기는데
   chart_data_to_dataframe() 은 "입력은 최신순" 가정으로 무조건 뒤집는다.
   → ML 피처가 역방향 시계열에서 계산됐다. 학습은 db_data_to_dataframe
   (trade_date ASC, 정방향)이므로 추론이 학습과 정반대 방향이었다.
   ensemble_score 관측 범위가 18~50 에 갇혀 ML BUY 가 0건이던 것의 직접 원인.
   → chart_data_to_dataframe_v2(): stck_bsop_date 오름차순 정렬로 방향 의존성 제거.

2. LSTM 시퀀스 워밍업 NaN → int(nan) ValueError
   탭뷸러는 NaN 검사를 받지만 시퀀스는 검사되지 않았다. 시퀀스 피처
   volatility_20d 의 첫 유효 인덱스가 20 이므로 요구 봉수는 60+20=80 인데
   게이트가 <61 이어서 61~79 구간이 크래시 밴드였다.
   NaN → LSTM 이 [nan,nan,nan] 반환(예외 아님) → 가중평균 전체 NaN
   → ml_ensemble.py int(prob_up*100) 에서 ValueError.
   → MIN_BARS_FOR_PREDICT=80 + 시퀀스 유한성 검사. NaN 을 0 으로 채우지 않고
     사유를 담은 error 로 반환한다.
   prepare_training_data 도 idx 60~79 샘플 시퀀스에 NaN 을 넣고 있었다
   (valid_mask[:60]=False 만 존재) → X_seq.mean 이 NaN → seq_mean/std 오염.

3. except Exception: pass 가 1·2 를 조용히 삼켰다
   → _run_ml_ensemble() 분리 + 사유별 집계(get/reset_ml_skip_stats,
     log_ml_skip_summary). 최초 1건 + 이후 50건마다 warning, 사이클 종료 시
     사유별 1줄 요약. batch_daily_analysis 에 배선.

부수 수정
- 네이버 ML 차트 80일창(75봉) → 200일창(154봉). 룰 입력은 _trim_to_legacy_window
  로 기존 창과 동일 배열로 절단 — adx_wilder 는 Wilder 재귀라 배열 길이에
  민감하므로 절단 없이는 SIDEWAYS 히스테리시스 판정이 바뀐다(실측 14.21→14.55).
  전 지표 동일성 확인: rsi/ma/bb/atr/adx/stochastic 모두 일치.
- predict() 가 어떤 입력에도 예외를 던지지 않도록 얇은 가드 + _predict_impl 분리.
  비유한 확률을 내는 모델은 warning 과 함께 앙상블에서 제외(degraded_models).
- training_metrics 가 항상 {} 였던 원인: self.training_metrics = metrics 가
  _save_models() 뒤에 있어 직전 값이 기록됨. 순서 정정 + per-class 지표 추가
  ("전부 HOLD" 모델을 정확도만으로 판별 못하는 문제 대응).
- endpoints/ml_ensemble.py: get_daily_chart 는 output2 를 채우는데 output 을
  읽어 항상 400 이었다. 게이트 61→MIN_BARS_FOR_PREDICT, async 핸들러의
  동기 predict() → predict_async().

미변경: 모델 파일, 아키텍처·하이퍼파라미터, 임계값(60/0.3/0.7, 가중치
0.4/0.35/0.25), torch.load(weights_only=True), 지표 계산식, KIS 호출량.

검증: 579 passed / 0 failed (소켓·KIS 차단 하, 기준선 559 + 신규 20).
신규 tests/test_ml_feature_direction.py 는 결함 역주입으로 검출력 증명 —
방향 역전 2건 + 크래시 밴드 61/70/79 3건 + 게이트 1건 = 6건 실패 확인.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-28 11:51:36 +09:00

722 lines
29 KiB
Python

"""
ML 앙상블 모델 서비스
3개 모델을 결합하여 주식 매수/매도 신호를 예측합니다:
- LightGBM (40%): 탭뷸러 피처 기반 분류
- PyTorch LSTM (35%): 시계열 패턴 학습
- Random Forest (25%): 피처 조합 기반 분류
앙상블 스코어: 0~100 (높을수록 매수 신호 강함)
"""
import asyncio
import logging
import os
from pathlib import Path
from typing import Dict, Any, Optional, Tuple
import numpy as np
import joblib
from app.services.feature_engineering import (
FEATURE_COLUMNS,
LSTM_SEQUENCE_FEATURES,
LSTM_SEQUENCE_LENGTH,
MIN_BARS_FOR_PREDICT,
extract_single_features,
extract_single_features_v2,
chart_data_to_dataframe,
extract_features,
prepare_training_data,
)
logger = logging.getLogger(__name__)
# 모델 저장 디렉토리
MODEL_DIR = Path(__file__).parent.parent.parent / "models" / "ensemble"
_CLASS_NAMES = ("down", "hold", "up")
def _per_class_metrics(y_true: np.ndarray, y_pred: np.ndarray) -> Dict[str, Dict[str, float]]:
"""3클래스 precision/recall/support (sklearn 의존 없이 계산).
모델 교체 판단의 근거로 metrics.json 에 기록한다.
정확도만으로는 "전부 HOLD" 모델이 좋아 보이는 문제를 잡을 수 없다.
"""
y_true = np.asarray(y_true, dtype=int)
y_pred = np.asarray(y_pred, dtype=int)
out: Dict[str, Dict[str, float]] = {}
for cls, name in enumerate(_CLASS_NAMES):
tp = int(np.sum((y_pred == cls) & (y_true == cls)))
fp = int(np.sum((y_pred == cls) & (y_true != cls)))
fn = int(np.sum((y_pred != cls) & (y_true == cls)))
precision = tp / (tp + fp) if (tp + fp) > 0 else 0.0
recall = tp / (tp + fn) if (tp + fn) > 0 else 0.0
f1 = (2 * precision * recall / (precision + recall)) if (precision + recall) > 0 else 0.0
out[name] = {
"precision": round(float(precision), 4),
"recall": round(float(recall), 4),
"f1": round(float(f1), 4),
"support": int(np.sum(y_true == cls)),
"predicted": int(np.sum(y_pred == cls)),
}
return out
# ─── PyTorch LSTM 모델 정의 ───
def _get_torch():
"""PyTorch lazy import"""
try:
import torch
import torch.nn as nn
return torch, nn
except ImportError:
return None, None
class StockLSTM:
"""PyTorch LSTM 모델 래퍼"""
def __init__(self, input_size: int = 5, hidden_size: int = 64, num_layers: int = 2,
num_classes: int = 3, dropout: float = 0.3):
self.input_size = input_size
self.hidden_size = hidden_size
self.num_layers = num_layers
self.num_classes = num_classes
self.dropout = dropout
self.model = None
self.device = None
# 정규화 파라미터 — train_model() 또는 load() 전까지 None
self.seq_mean: Optional[np.ndarray] = None
self.seq_std: Optional[np.ndarray] = None
self._build()
def _build(self):
torch, nn = _get_torch()
if torch is None:
logger.warning("PyTorch not available, LSTM model disabled")
return
self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
class LSTMNet(nn.Module):
def __init__(self_, input_size, hidden_size, num_layers, num_classes, dropout):
super().__init__()
self_.lstm = nn.LSTM(
input_size=input_size,
hidden_size=hidden_size,
num_layers=num_layers,
batch_first=True,
dropout=dropout if num_layers > 1 else 0,
)
self_.fc1 = nn.Linear(hidden_size, 32)
self_.relu = nn.ReLU()
self_.drop = nn.Dropout(dropout)
self_.fc2 = nn.Linear(32, num_classes)
def forward(self_, x):
out, _ = self_.lstm(x)
out = out[:, -1, :] # 마지막 타임스텝
out = self_.drop(self_.relu(self_.fc1(out)))
out = self_.fc2(out)
return out
self.model = LSTMNet(
self.input_size, self.hidden_size, self.num_layers,
self.num_classes, self.dropout,
).to(self.device)
def train_model(self, X_seq: np.ndarray, y: np.ndarray,
epochs: int = 50, lr: float = 0.001, batch_size: int = 32) -> Dict[str, float]:
"""LSTM 모델 학습"""
torch, nn = _get_torch()
if torch is None or self.model is None:
return {"error": "PyTorch not available"}
from torch.utils.data import TensorDataset, DataLoader
# 시퀀스 피처 정규화 (채널별)
self.seq_mean = X_seq.mean(axis=(0, 1))
self.seq_std = X_seq.std(axis=(0, 1)) + 1e-8
X_norm = (X_seq - self.seq_mean) / self.seq_std
X_tensor = torch.FloatTensor(X_norm.copy()).to(self.device)
y_tensor = torch.LongTensor(y.copy()).to(self.device)
dataset = TensorDataset(X_tensor, y_tensor)
loader = DataLoader(dataset, batch_size=batch_size, shuffle=True)
criterion = nn.CrossEntropyLoss()
optimizer = torch.optim.Adam(self.model.parameters(), lr=lr)
self.model.train()
best_loss = float("inf")
for epoch in range(epochs):
total_loss = 0.0
correct = 0
total = 0
for X_batch, y_batch in loader:
optimizer.zero_grad()
outputs = self.model(X_batch)
loss = criterion(outputs, y_batch)
loss.backward()
optimizer.step()
total_loss += loss.item() * len(y_batch)
_, predicted = torch.max(outputs, 1)
correct += (predicted == y_batch).sum().item()
total += len(y_batch)
avg_loss = total_loss / total
accuracy = correct / total
if avg_loss < best_loss:
best_loss = avg_loss
if (epoch + 1) % 10 == 0:
logger.info(f"LSTM Epoch [{epoch+1}/{epochs}] Loss: {avg_loss:.4f} Acc: {accuracy:.4f}")
return {"final_loss": best_loss, "final_accuracy": accuracy}
def predict_proba(self, X_seq: np.ndarray) -> np.ndarray:
"""예측 확률 반환 (N, 3)"""
torch, nn = _get_torch()
if torch is None or self.model is None or self.seq_mean is None or self.seq_std is None:
return np.full((len(X_seq), 3), 1.0 / 3)
self.model.eval()
X_norm = (X_seq - self.seq_mean) / self.seq_std
X_tensor = torch.FloatTensor(X_norm.copy()).to(self.device)
with torch.no_grad():
outputs = self.model(X_tensor)
probs = torch.softmax(outputs, dim=1).cpu().numpy()
return probs
def save(self, path: Path):
torch, _ = _get_torch()
if torch is None or self.model is None:
return
torch.save({
"model_state": self.model.state_dict(),
# numpy 배열을 그대로 저장하면 weights_only=True 로드가 거부된다
# (2026-09-23: LSTM이 매번 로드 실패해 앙상블에서 조용히 빠져 있었음) → list 로 저장
"seq_mean": self.seq_mean.tolist() if self.seq_mean is not None else None,
"seq_std": self.seq_std.tolist() if self.seq_std is not None else None,
"config": {
"input_size": self.input_size,
"hidden_size": self.hidden_size,
"num_layers": self.num_layers,
"num_classes": self.num_classes,
"dropout": self.dropout,
},
}, path)
def load(self, path: Path) -> bool:
torch, _ = _get_torch()
if torch is None:
return False
try:
# [이슈5 수정] weights_only=True로 변경하여 임의 코드 실행(pickle RCE) 방지.
# 단, 구버전 PyTorch(<2.0)에서는 weights_only 미지원 — except로 폴백.
try:
checkpoint = torch.load(path, map_location=self.device, weights_only=True)
except TypeError:
# PyTorch < 2.0: weights_only 파라미터 미지원
checkpoint = torch.load(path, map_location=self.device) # noqa: S614
except Exception as _wo_err:
# 구 체크포인트(numpy 배열 포함)는 weights_only=True 로 못 읽는다.
# 우리 학습 산출물(MODEL_DIR 내부)일 때만 완화해서 재시도한다.
try:
_inside = Path(path).resolve().is_relative_to(MODEL_DIR.resolve())
except Exception:
_inside = False
if not _inside:
raise
logger.warning(
f"LSTM 체크포인트 weights_only 로드 실패 → 자체 학습 산출물로 재시도: {_wo_err}"
)
checkpoint = torch.load(path, map_location=self.device, weights_only=False) # noqa: S614
self.model.load_state_dict(checkpoint["model_state"])
_mean, _std = checkpoint["seq_mean"], checkpoint["seq_std"]
self.seq_mean = None if _mean is None else np.asarray(_mean, dtype=np.float32)
self.seq_std = None if _std is None else np.asarray(_std, dtype=np.float32)
self.model.eval()
return True
except Exception as e:
logger.error(f"Failed to load LSTM model: {e}")
return False
# ─── 앙상블 모델 클래스 ───
class MLEnsemble:
"""3개 모델(LightGBM + LSTM + RF)을 앙상블하여 주식 매수/매도 예측"""
def __init__(self, model_dir: Path = MODEL_DIR,
weights: Tuple[float, float, float] = (0.40, 0.35, 0.25)):
self.model_dir = model_dir
self.weights = weights # (lgbm, lstm, rf)
self.lgbm_model = None
self.lstm_model = None
self.rf_model = None
self.scaler = None # tabular feature scaler
self.is_trained = False
self.training_metrics: Dict[str, Any] = {}
def _ensure_dir(self):
self.model_dir.mkdir(parents=True, exist_ok=True)
def train(self, X_tabular: np.ndarray, X_seq: np.ndarray, y: np.ndarray) -> Dict[str, Any]:
"""전체 앙상블 학습
Args:
X_tabular: (N, 20) 탭뷸러 피처
X_seq: (N, 60, 5) LSTM 시퀀스
y: (N,) 레이블 (0=DOWN, 1=HOLD, 2=UP)
Returns:
학습 메트릭
"""
from datetime import datetime, timezone, timedelta
self._ensure_dir()
metrics: Dict[str, Any] = {}
if len(X_tabular) < 50:
return {"error": f"학습 데이터 부족: {len(X_tabular)}개 (최소 50개 필요)"}
logger.info(f"앙상블 학습 시작: {len(X_tabular)}개 샘플, 클래스 분포: {np.bincount(y)}")
# ─── 학습 메타데이터 (모델 품질·교체 판단의 근거) ───
_KST = timezone(timedelta(hours=9))
_class_counts = np.bincount(np.asarray(y, dtype=int), minlength=3)
metrics["trained_at"] = datetime.now(_KST).isoformat()
metrics["n_samples"] = int(len(X_tabular))
metrics["n_features"] = int(X_tabular.shape[1]) if X_tabular.ndim > 1 else 0
metrics["sequence_shape"] = list(np.asarray(X_seq).shape[1:]) if len(X_seq) else []
metrics["class_distribution"] = {
"down": int(_class_counts[0]), "hold": int(_class_counts[1]), "up": int(_class_counts[2]),
}
metrics["class_ratio"] = {
k: round(float(v) / max(1, len(y)), 4)
for k, v in zip(("down", "hold", "up"), _class_counts[:3])
}
metrics["weights"] = {"lgbm": self.weights[0], "lstm": self.weights[1], "rf": self.weights[2]}
# 탭뷸러 피처 스케일링
from sklearn.preprocessing import StandardScaler
self.scaler = StandardScaler()
# 학습/검증 분리 (시계열이므로 뒤쪽 20%를 검증으로)
split_idx = int(len(X_tabular) * 0.8)
# S2 fix: scaler는 학습 데이터에만 fit하여 data leakage 방지
self.scaler.fit(X_tabular[:split_idx])
X_scaled = self.scaler.transform(X_tabular)
X_train, X_val = X_scaled[:split_idx], X_scaled[split_idx:]
y_train, y_val = y[:split_idx], y[split_idx:]
X_seq_train, X_seq_val = X_seq[:split_idx], X_seq[split_idx:]
metrics["n_train"] = int(len(X_train))
metrics["n_val"] = int(len(X_val))
_val_counts = np.bincount(np.asarray(y_val, dtype=int), minlength=3)
metrics["val_class_distribution"] = {
"down": int(_val_counts[0]), "hold": int(_val_counts[1]), "up": int(_val_counts[2]),
}
# 1. LightGBM
logger.info(">>> LightGBM 학습 중...")
try:
import lightgbm as lgb
self.lgbm_model = lgb.LGBMClassifier(
n_estimators=200,
max_depth=6,
learning_rate=0.05,
num_leaves=31,
min_child_samples=10,
subsample=0.8,
colsample_bytree=0.8,
reg_alpha=0.1,
reg_lambda=0.1,
random_state=42,
verbose=-1,
num_class=3,
objective="multiclass",
)
self.lgbm_model.fit(
X_train, y_train,
eval_set=[(X_val, y_val)],
)
lgbm_val_pred = self.lgbm_model.predict(X_val)
lgbm_acc = np.mean(lgbm_val_pred == y_val)
metrics["lgbm_accuracy"] = float(lgbm_acc)
metrics["lgbm_per_class"] = _per_class_metrics(y_val, lgbm_val_pred)
logger.info(f"LightGBM 검증 정확도: {lgbm_acc:.4f}")
importance = self.lgbm_model.feature_importances_
top_features = sorted(
zip(FEATURE_COLUMNS, importance),
key=lambda x: x[1], reverse=True
)[:5]
metrics["lgbm_top_features"] = [
{"name": name, "importance": int(imp)} for name, imp in top_features
]
except Exception as e:
logger.error(f"LightGBM 학습 실패: {e}")
metrics["lgbm_error"] = str(e)
# 2. PyTorch LSTM
logger.info(">>> PyTorch LSTM 학습 중...")
try:
self.lstm_model = StockLSTM(
input_size=len(LSTM_SEQUENCE_FEATURES),
hidden_size=64,
num_layers=2,
num_classes=3,
dropout=0.3,
)
lstm_metrics = self.lstm_model.train_model(
X_seq_train, y_train,
epochs=50, lr=0.001, batch_size=32,
)
lstm_val_probs = self.lstm_model.predict_proba(X_seq_val)
lstm_val_pred = np.argmax(lstm_val_probs, axis=1)
lstm_acc = np.mean(lstm_val_pred == y_val)
metrics["lstm_accuracy"] = float(lstm_acc)
metrics["lstm_per_class"] = _per_class_metrics(y_val, lstm_val_pred)
metrics["lstm_training"] = lstm_metrics
# seq_std 가 0 에 수렴하면(예: 전부 동일값) 정규화가 폭주한다 → 기록해 둔다
if self.lstm_model.seq_std is not None:
metrics["lstm_seq_std_min"] = float(np.min(self.lstm_model.seq_std))
metrics["lstm_seq_mean_abs_max"] = float(np.max(np.abs(self.lstm_model.seq_mean)))
logger.info(f"LSTM 검증 정확도: {lstm_acc:.4f}")
except Exception as e:
logger.error(f"LSTM 학습 실패: {e}")
metrics["lstm_error"] = str(e)
# 3. Random Forest
logger.info(">>> Random Forest 학습 중...")
try:
from sklearn.ensemble import RandomForestClassifier
self.rf_model = RandomForestClassifier(
n_estimators=100,
max_depth=8,
min_samples_split=10,
min_samples_leaf=5,
random_state=42,
n_jobs=-1,
)
self.rf_model.fit(X_train, y_train)
rf_val_pred = self.rf_model.predict(X_val)
rf_acc = np.mean(rf_val_pred == y_val)
metrics["rf_accuracy"] = float(rf_acc)
metrics["rf_per_class"] = _per_class_metrics(y_val, rf_val_pred)
logger.info(f"Random Forest 검증 정확도: {rf_acc:.4f}")
except Exception as e:
logger.error(f"Random Forest 학습 실패: {e}")
metrics["rf_error"] = str(e)
# 앙상블 검증
_degraded: list = []
ensemble_probs = self._ensemble_predict_proba(X_val, X_seq_val, degraded=_degraded)
if _degraded:
metrics["degraded_models"] = _degraded
if ensemble_probs is not None:
ensemble_pred = np.argmax(ensemble_probs, axis=1)
ensemble_acc = np.mean(ensemble_pred == y_val)
metrics["ensemble_accuracy"] = float(ensemble_acc)
metrics["ensemble_per_class"] = _per_class_metrics(y_val, ensemble_pred)
_pred_counts = np.bincount(np.asarray(ensemble_pred, dtype=int), minlength=3)
metrics["ensemble_pred_distribution"] = {
"down": int(_pred_counts[0]), "hold": int(_pred_counts[1]), "up": int(_pred_counts[2]),
}
logger.info(f"앙상블 검증 정확도: {ensemble_acc:.4f}")
else:
metrics["ensemble_error"] = "모든 모델 예측 실패"
# ⚠️ 순서 중요: _save_models() 가 self.training_metrics 를 metrics.json 으로
# 쓴다. 예전에는 저장이 대입보다 **먼저** 실행돼 항상 이전 값({})이 기록됐고,
# 그래서 운영 metrics.json 이 빈 dict 였다. (2026-09-28 수정)
self.is_trained = True
self.training_metrics = metrics
self._save_models()
logger.info(f"앙상블 학습 완료: {metrics}")
return metrics
def _ensemble_predict_proba(self, X_tabular: np.ndarray, X_seq: np.ndarray,
degraded: Optional[list] = None) -> Optional[np.ndarray]:
"""3개 모델의 가중 평균 확률.
Args:
degraded: (선택) 제외된 모델 사유를 담을 리스트. 호출부가 넘기면
"lgbm:non-finite" 같은 문자열이 추가된다.
비유한(NaN/inf) 확률을 뱉는 모델은 앙상블에서 **제외**한다.
예전에는 NaN 이 그대로 가중평균에 섞여 최종 확률 전체를 NaN 으로 만들고
`int(prob_up*100)` 에서 예외로 터졌다. 제외 사실은 로그와 degraded 로
드러내며, 조용히 넘기지 않는다.
"""
probs_list = []
weights_list = []
def _record(name: str, reason: str):
logger.warning("[ML] %s 모델 제외: %s", name, reason)
if degraded is not None:
degraded.append(f"{name}:{reason}")
def _add(name: str, probs, weight: float):
arr = np.asarray(probs, dtype=float)
if not np.all(np.isfinite(arr)):
_record(name, "non-finite")
return
probs_list.append(arr)
weights_list.append(weight)
if self.lgbm_model is not None:
try:
_add("lgbm", self.lgbm_model.predict_proba(X_tabular), self.weights[0])
except Exception as e:
_record("lgbm", f"{type(e).__name__}: {e}")
if self.lstm_model is not None and self.lstm_model.model is not None:
try:
_add("lstm", self.lstm_model.predict_proba(X_seq), self.weights[1])
except Exception as e:
_record("lstm", f"{type(e).__name__}: {e}")
if self.rf_model is not None:
try:
_add("rf", self.rf_model.predict_proba(X_tabular), self.weights[2])
except Exception as e:
_record("rf", f"{type(e).__name__}: {e}")
if not probs_list:
return None
total_weight = sum(weights_list)
weighted_probs = sum(
p * (w / total_weight) for p, w in zip(probs_list, weights_list)
)
return weighted_probs
def predict(self, chart_data: list) -> Dict[str, Any]:
"""단일 종목 예측 (동기).
이벤트 루프 내 async 핸들러에서는 predict_async() 를 사용하세요.
Args:
chart_data: KIS API 차트 데이터 (최신순)
Returns:
{ensemble_score, confidence, signal, model_scores, probabilities}
또는 {"error": 사유, ...} — **어떤 입력에도 예외를 던지지 않는다.**
주의: 호출부는 반드시 `"error" in result` 를 확인해야 한다.
error 응답의 ensemble_score=50 은 "중립 플레이스홀더"이며 모델 판단이 아니다.
"""
try:
return self._predict_impl(chart_data)
except Exception as e:
# predict() 는 절대 예외를 밖으로 내지 않는다. 호출부의
# `except Exception: pass` 에 삼켜져 5일간 무증상이던 것이 9/23 사고였다.
logger.warning("[ML] predict 예외 (%s): %s", type(e).__name__, e, exc_info=True)
return {
"error": f"예측 예외 {type(e).__name__}: {e}",
"ensemble_score": 50, "confidence": 0.0, "signal": "HOLD",
}
def _predict_impl(self, chart_data: list) -> Dict[str, Any]:
"""predict() 의 실제 구현 (예외 가드는 predict() 가 담당)."""
if not self.is_trained:
return {"error": "모델이 학습되지 않았습니다", "ensemble_score": 50, "confidence": 0.0, "signal": "HOLD"}
if self.scaler is None:
return {"error": "스케일러 없음", "ensemble_score": 50, "confidence": 0.0, "signal": "HOLD"}
tabular, sequence, reason = extract_single_features_v2(chart_data)
if tabular is None or sequence is None:
return {
"error": f"피처 추출 실패: {reason or '데이터 부족'}",
"ensemble_score": 50, "confidence": 0.0, "signal": "HOLD",
}
tabular_scaled = np.asarray(self.scaler.transform(tabular.reshape(1, -1)), dtype=float)
if not np.all(np.isfinite(tabular_scaled)):
# 스케일러 분산 0 등으로 스케일링 결과가 깨진 경우 — 점수를 만들지 않는다
return {
"error": "스케일링 결과 비유한(NaN/inf)",
"ensemble_score": 50, "confidence": 0.0, "signal": "HOLD",
}
sequence_batch = sequence.reshape(1, LSTM_SEQUENCE_LENGTH, len(LSTM_SEQUENCE_FEATURES))
degraded: list = []
probs = self._ensemble_predict_proba(tabular_scaled, sequence_batch, degraded=degraded)
if probs is None:
return {
"error": "모든 모델 예측 실패" + (f" ({','.join(degraded)})" if degraded else ""),
"ensemble_score": 50, "confidence": 0.0, "signal": "HOLD",
}
probs = np.asarray(probs, dtype=float)
if probs.shape[0] < 1 or probs.shape[1] < 3 or not np.all(np.isfinite(probs[0])):
# NaN 을 0 이나 그럴듯한 값으로 치환하지 않는다 — 0점은 "ML이 매수 반대"로
# 오해되므로, 정직하게 error 를 돌려 호출부가 ML 없이 진행하게 한다.
return {
"error": "앙상블 확률 비유한(NaN/inf)",
"ensemble_score": 50, "confidence": 0.0, "signal": "HOLD",
}
prob_down, prob_hold, prob_up = probs[0][:3]
ensemble_score = int(prob_up * 100)
max_prob = max(prob_down, prob_hold, prob_up)
confidence = float(max_prob)
if prob_up > prob_down and prob_up > prob_hold and prob_up > 0.4:
signal = "BUY"
elif prob_down > prob_up and prob_down > prob_hold and prob_down > 0.4:
signal = "SELL"
else:
signal = "HOLD"
model_scores = {}
if self.lgbm_model is not None:
try:
lp = self.lgbm_model.predict_proba(tabular_scaled)[0]
model_scores["lgbm"] = {"up": float(lp[2]), "hold": float(lp[1]), "down": float(lp[0])}
except Exception:
pass
if self.lstm_model is not None and self.lstm_model.model is not None:
try:
lp = self.lstm_model.predict_proba(sequence_batch)[0]
model_scores["lstm"] = {"up": float(lp[2]), "hold": float(lp[1]), "down": float(lp[0])}
except Exception:
pass
if self.rf_model is not None:
try:
lp = self.rf_model.predict_proba(tabular_scaled)[0]
model_scores["rf"] = {"up": float(lp[2]), "hold": float(lp[1]), "down": float(lp[0])}
except Exception:
pass
return {
"ensemble_score": ensemble_score,
"confidence": round(confidence, 4),
"signal": signal,
"model_scores": model_scores,
# 비유한 확률/예외로 앙상블에서 빠진 모델 — 비어 있어야 정상
"degraded_models": degraded,
"probabilities": {
"up": round(float(prob_up), 4),
"hold": round(float(prob_hold), 4),
"down": round(float(prob_down), 4),
},
}
# ─── 비동기 래퍼 (이슈 7: 이벤트 루프 블로킹 방지) ──────────────────
async def predict_async(self, chart_data: list) -> Dict[str, Any]:
"""asyncio.to_thread로 CPU 추론을 워커 스레드에서 실행.
async 핸들러/스케줄러에서는 이 메서드를 사용하세요.
무거운 RandomForest 배치 학습에는 ProcessPoolExecutor도 고려 가능.
"""
return await asyncio.to_thread(self.predict, chart_data)
async def train_async(
self, X_tabular: np.ndarray, X_seq: np.ndarray, y: np.ndarray
) -> Dict[str, Any]:
"""asyncio.to_thread로 CPU 학습을 워커 스레드에서 실행."""
return await asyncio.to_thread(self.train, X_tabular, X_seq, y)
# ─────────────────────────────────────────────────────────────────────
def _save_models(self):
"""모델 파일 저장"""
self._ensure_dir()
if self.lgbm_model is not None:
joblib.dump(self.lgbm_model, self.model_dir / "lgbm_model.pkl")
if self.rf_model is not None:
joblib.dump(self.rf_model, self.model_dir / "rf_model.pkl")
if self.lstm_model is not None:
self.lstm_model.save(self.model_dir / "lstm_model.pt")
if self.scaler is not None:
joblib.dump(self.scaler, self.model_dir / "scaler.pkl")
from app.core.json_helpers import _dumps
with open(self.model_dir / "metrics.json", "w") as f:
f.write(_dumps(self.training_metrics))
logger.info(f"모델 저장 완료: {self.model_dir}")
def load_models(self) -> bool:
"""저장된 모델 로드"""
try:
scaler_path = self.model_dir / "scaler.pkl"
if not scaler_path.exists():
logger.warning("저장된 앙상블 모델이 없습니다")
return False
self.scaler = joblib.load(scaler_path)
lgbm_path = self.model_dir / "lgbm_model.pkl"
if lgbm_path.exists():
self.lgbm_model = joblib.load(lgbm_path)
rf_path = self.model_dir / "rf_model.pkl"
if rf_path.exists():
self.rf_model = joblib.load(rf_path)
lstm_path = self.model_dir / "lstm_model.pt"
if lstm_path.exists():
self.lstm_model = StockLSTM(
input_size=len(LSTM_SEQUENCE_FEATURES),
hidden_size=64, num_layers=2, num_classes=3, dropout=0.3,
)
self.lstm_model.load(lstm_path)
metrics_path = self.model_dir / "metrics.json"
if metrics_path.exists():
import json
with open(metrics_path) as f:
self.training_metrics = json.load(f)
self.is_trained = True
logger.info("앙상블 모델 로드 완료")
return True
except Exception as e:
logger.error(f"앙상블 모델 로드 실패: {e}")
return False
def get_status(self) -> Dict[str, Any]:
"""모델 상태 조회"""
return {
"is_trained": self.is_trained,
"models": {
"lgbm": self.lgbm_model is not None,
"lstm": self.lstm_model is not None and self.lstm_model.model is not None,
"rf": self.rf_model is not None,
},
"weights": {"lgbm": self.weights[0], "lstm": self.weights[1], "rf": self.weights[2]},
"metrics": self.training_metrics,
"model_dir": str(self.model_dir),
}
# ─── 싱글톤 인스턴스 ───
_ensemble_instance: Optional[MLEnsemble] = None
def get_ensemble() -> MLEnsemble:
"""앙상블 모델 싱글톤 인스턴스 반환"""
global _ensemble_instance
if _ensemble_instance is None:
_ensemble_instance = MLEnsemble()
_ensemble_instance.load_models()
return _ensemble_instance