하네스 규칙 검사 전역 위반 117→0, 회차별 검증(code-verifier opus 4도메인)으로 Critical/High 해소. 주문·리스크 - MDD 강제축소 반복 매도(종목별 완료 집합·재시도 상한·날짜 롤오버·잔고 오류 보류·epoch·장외 미주문) - KIS 거부 FAILED 기록, 결과 불명(전송 후) 재전송 금지·PENDING, 미전송 실패만 재시도(msg_cd 분류) - 국내 전량·손실 매도 시장가, 부분 익절 직전 현재가, 지정가만 미체결 취소(cancel_order_v2: TR 0013U·조직번호) + 0체결 롤백 - 누적 4%·현금·레버리지(신규분 포함)·사이클 내 매수 누적, 수동매수 공용 가드(buy_guards), 수동매도 DT 차감·락 - 가드 우회 엔드포인트 차단(/recommendations/auto-trade 위임, US /execute 매수 410·매도 가드) - 매수 슬롯=성공 매수, 평가 상한·회전, 업무거부/계좌 쿨다운, 프로세스 내 in-flight 주문 중복 차단 데이트레이딩 - 진입·청산 shield, 청산 진행 중 표식(소유 토큰), 2단계 청산 체크, 결과 불명 → 보유 감소 판정·비DT 차감 상한 - 전일 포지션 재시도(일시 실패 미산정), 분할매도 영속, ORB 구간 손절, /cycle 락, 사이클 내 현금 차감, 체결 수량 보정 스윙 - 손절 공백(판정 예외 시 하드 바닥), 단일 손절 소스·범위 검증, 매도 직전 KIS 잔고 상한(min(주문가능,보유)−DT) - 결과 불명 체결 대조·장부 반영, 대체가(나이·진입 이후·ATR 캐시), 분할 단계 기록, 국면 실패 fail-closed - 듀얼 플레이북·서킷브레이커 게이트, 평균회귀 우선·듀얼 독립, 시간손절 거래일 - 거래 확대(사용자 결정, 백테스트 미검증): RSI 46/35 정본 경로, 모멘텀 v2 ON, SIDEWAYS ADX AND, 트레일링 1차 익절 후, 20일 시간손절 ON 인프라·보안 - 계좌·손익 GET 인증, 세션 is_active·관리자 경로, 서명키 파생, realip, WS 토큰 마스킹 - KIS 토큰 즉시 실패(sleep 제거)·무효 시 캐시 삭제, 주문 5xx+JSON 본문 반환 - db_worker 종료 drain(멱등 재실행·커밋 불명 격리·예산·밴 커밋), stop_grace 40s, Redis 타임아웃·volatile-lru, /health DLQ·캐시 - KRX 휴장일, py3.11 비호환 f-string 수정 + Jenkins 이미지 내 compileall·헬스 실패 시 빌드 실패 하네스: rules_check 에 PEP 701 f-string 검사, 마커(allow-*), deploy 스모크 401 기준 CLAUDE.md: 실제 동작·플래그·백로그(§8.5-27~38) 갱신 backend 1451 passed / hooks 112 passed / tsc ok / py3.9 compile 0 errors Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
724 lines
30 KiB
Python
724 lines
30 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,
|
|
)
|
|
|
|
from app.core.log_throttle import warn_throttled # noqa: E402
|
|
|
|
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 as _swallowed:
|
|
warn_throttled(logger, "ml_ensemble:591", "[ML] lgbm predict_proba 실패: %s", _swallowed)
|
|
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 as _swallowed:
|
|
warn_throttled(logger, "ml_ensemble:597", "[ML] lstm predict_proba 실패: %s", _swallowed)
|
|
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 as _swallowed:
|
|
warn_throttled(logger, "ml_ensemble:603", "[ML] rf predict_proba 실패: %s", _swallowed)
|
|
|
|
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
|