refactor: MCP 공통 함수/httpx 싱글톤, CORS 수정, ML 이벤트루프 블로킹 해소 (#4~#7)
This commit is contained in:
@@ -9,6 +9,7 @@ ML 앙상블 모델 서비스
|
||||
앙상블 스코어: 0~100 (높을수록 매수 신호 강함)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
@@ -275,7 +276,6 @@ class MLEnsemble:
|
||||
metrics["lgbm_accuracy"] = float(lgbm_acc)
|
||||
logger.info(f"LightGBM 검증 정확도: {lgbm_acc:.4f}")
|
||||
|
||||
# 피처 중요도
|
||||
importance = self.lgbm_model.feature_importances_
|
||||
top_features = sorted(
|
||||
zip(FEATURE_COLUMNS, importance),
|
||||
@@ -302,7 +302,6 @@ class MLEnsemble:
|
||||
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)
|
||||
@@ -342,7 +341,6 @@ class MLEnsemble:
|
||||
metrics["ensemble_accuracy"] = float(ensemble_acc)
|
||||
logger.info(f"앙상블 검증 정확도: {ensemble_acc:.4f}")
|
||||
|
||||
# 모델 저장
|
||||
self._save_models()
|
||||
self.is_trained = True
|
||||
self.training_metrics = metrics
|
||||
@@ -355,7 +353,6 @@ class MLEnsemble:
|
||||
probs_list = []
|
||||
weights_list = []
|
||||
|
||||
# LightGBM
|
||||
if self.lgbm_model is not None:
|
||||
try:
|
||||
lgbm_probs = self.lgbm_model.predict_proba(X_tabular)
|
||||
@@ -364,7 +361,6 @@ class MLEnsemble:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# LSTM
|
||||
if self.lstm_model is not None and self.lstm_model.model is not None:
|
||||
try:
|
||||
lstm_probs = self.lstm_model.predict_proba(X_seq)
|
||||
@@ -373,7 +369,6 @@ class MLEnsemble:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# RF
|
||||
if self.rf_model is not None:
|
||||
try:
|
||||
rf_probs = self.rf_model.predict_proba(X_tabular)
|
||||
@@ -385,7 +380,6 @@ class MLEnsemble:
|
||||
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)
|
||||
@@ -393,19 +387,15 @@ class MLEnsemble:
|
||||
return weighted_probs
|
||||
|
||||
def predict(self, chart_data: list) -> Dict[str, Any]:
|
||||
"""단일 종목 예측.
|
||||
"""단일 종목 예측 (동기).
|
||||
|
||||
이벤트 루프 내 async 핸들러에서는 predict_async() 를 사용하세요.
|
||||
|
||||
Args:
|
||||
chart_data: KIS API 차트 데이터 (최신순)
|
||||
|
||||
Returns:
|
||||
{
|
||||
"ensemble_score": int (0~100),
|
||||
"confidence": float (0~1),
|
||||
"signal": str ("BUY"/"HOLD"/"SELL"),
|
||||
"model_scores": {"lgbm": ..., "lstm": ..., "rf": ...},
|
||||
"probabilities": {"up": ..., "hold": ..., "down": ...}
|
||||
}
|
||||
{ensemble_score, confidence, signal, model_scores, probabilities}
|
||||
"""
|
||||
if not self.is_trained:
|
||||
return {"error": "모델이 학습되지 않았습니다", "ensemble_score": 50, "confidence": 0.0, "signal": "HOLD"}
|
||||
@@ -414,25 +404,18 @@ class MLEnsemble:
|
||||
if tabular is None or sequence is None:
|
||||
return {"error": "피처 추출 실패 (데이터 부족)", "ensemble_score": 50, "confidence": 0.0, "signal": "HOLD"}
|
||||
|
||||
# 스케일링
|
||||
tabular_scaled = self.scaler.transform(tabular.reshape(1, -1))
|
||||
sequence_batch = sequence.reshape(1, 60, len(LSTM_SEQUENCE_FEATURES))
|
||||
|
||||
# 앙상블 예측
|
||||
probs = self._ensemble_predict_proba(tabular_scaled, sequence_batch)
|
||||
if probs is None:
|
||||
return {"error": "모든 모델 예측 실패", "ensemble_score": 50, "confidence": 0.0, "signal": "HOLD"}
|
||||
|
||||
prob_down, prob_hold, prob_up = probs[0]
|
||||
|
||||
# 앙상블 스코어: UP 확률 기반 (0~100)
|
||||
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:
|
||||
@@ -440,7 +423,6 @@ class MLEnsemble:
|
||||
else:
|
||||
signal = "HOLD"
|
||||
|
||||
# 개별 모델 스코어
|
||||
model_scores = {}
|
||||
if self.lgbm_model is not None:
|
||||
try:
|
||||
@@ -473,6 +455,22 @@ class MLEnsemble:
|
||||
},
|
||||
}
|
||||
|
||||
# ─── 비동기 래퍼 (이슈 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()
|
||||
@@ -486,7 +484,6 @@ class MLEnsemble:
|
||||
if self.scaler is not None:
|
||||
joblib.dump(self.scaler, self.model_dir / "scaler.pkl")
|
||||
|
||||
# 메트릭 저장
|
||||
import json
|
||||
with open(self.model_dir / "metrics.json", "w") as f:
|
||||
json.dump(self.training_metrics, f, indent=2, ensure_ascii=False, default=str)
|
||||
@@ -519,7 +516,6 @@ class MLEnsemble:
|
||||
)
|
||||
self.lstm_model.load(lstm_path)
|
||||
|
||||
# 메트릭 로드
|
||||
metrics_path = self.model_dir / "metrics.json"
|
||||
if metrics_path.exists():
|
||||
import json
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import asyncio
|
||||
import functools
|
||||
import logging
|
||||
from typing import List, Dict, Any, Optional
|
||||
|
||||
@@ -19,6 +21,9 @@ class TensorFlowPredictor:
|
||||
- If TensorFlow or model/scaler files are missing, raises TensorFlowNotAvailable
|
||||
so callers can handle gracefully (e.g., return 503 or a friendly message).
|
||||
- Fetches recent daily closes from KIS API.
|
||||
|
||||
이슈 7: model.predict()는 CPU-bound 동기 호출이므로 predict_stock() 내에서
|
||||
asyncio.to_thread()로 워커 스레드에 위임해 이벤트 루프 블로킹을 방지한다.
|
||||
"""
|
||||
|
||||
def __init__(self, model_path: str = "models/lstm_model.h5", scaler_path: str = "models/scaler.pkl", time_steps: int = 60):
|
||||
@@ -27,7 +32,6 @@ class TensorFlowPredictor:
|
||||
self.time_steps = time_steps
|
||||
|
||||
try:
|
||||
# Local import to avoid hard dependency when not installed
|
||||
import tensorflow as tf # noqa: F401
|
||||
from tensorflow import keras # noqa: F401
|
||||
import joblib # noqa: F401
|
||||
@@ -35,7 +39,6 @@ class TensorFlowPredictor:
|
||||
logger.warning(f"TensorFlow or joblib not available: {e}")
|
||||
raise TensorFlowNotAvailable("TensorFlow/joblib not installed in backend environment")
|
||||
|
||||
# Load resources
|
||||
try:
|
||||
from tensorflow import keras
|
||||
import joblib
|
||||
@@ -47,20 +50,16 @@ class TensorFlowPredictor:
|
||||
raise TensorFlowNotAvailable(f"Model or scaler not found: {e}")
|
||||
|
||||
async def fetch_kis_closes(self, stock_code: str, days: int = 60) -> np.ndarray:
|
||||
"""Fetch recent daily closes from KIS. Returns array oldest→latest.
|
||||
Uses korea_investment_api.get_daily_chart(stock_code, "D").
|
||||
"""
|
||||
"""Fetch recent daily closes from KIS. Returns array oldest→latest."""
|
||||
try:
|
||||
resp = await korea_investment_api.get_daily_chart(stock_code, "D")
|
||||
output = resp.get("output") or []
|
||||
if not output:
|
||||
raise RuntimeError("empty_chart")
|
||||
# KIS usually returns newest first; reverse to chronological
|
||||
closes_desc = [float(item.get("stck_clpr", 0) or 0) for item in output]
|
||||
closes = list(reversed(closes_desc))
|
||||
arr = np.array(closes[-days:], dtype=float)
|
||||
if arr.size < days:
|
||||
# If fewer than requested, still return what's available
|
||||
logger.info(f"{stock_code}: only {arr.size} closes available (requested {days})")
|
||||
return arr
|
||||
except Exception as e:
|
||||
@@ -68,26 +67,34 @@ class TensorFlowPredictor:
|
||||
raise
|
||||
|
||||
async def predict_stock(self, stock_code: str) -> Dict[str, Any]:
|
||||
"""Return prediction dict with expected_return and simple signal."""
|
||||
"""Return prediction dict with expected_return and simple signal.
|
||||
|
||||
model.predict()를 asyncio.to_thread()로 워커 스레드에서 실행해
|
||||
Keras/TF의 GIL-holding 연산이 이벤트 루프를 막지 않게 한다.
|
||||
"""
|
||||
try:
|
||||
prices = await self.fetch_kis_closes(stock_code, days=self.time_steps)
|
||||
if prices.size == 0:
|
||||
raise RuntimeError("no_price_data")
|
||||
|
||||
# Normalize and shape for LSTM (1, time_steps, 1)
|
||||
scaled = self.scaler.transform(prices.reshape(-1, 1))
|
||||
if scaled.shape[0] < self.time_steps:
|
||||
# Pad at the front if needed
|
||||
pad = np.repeat(scaled[0:1], self.time_steps - scaled.shape[0], axis=0)
|
||||
scaled = np.vstack([pad, scaled])
|
||||
X_input = scaled[-self.time_steps:].reshape(1, self.time_steps, 1)
|
||||
|
||||
# Predict
|
||||
predicted_scaled = self.model.predict(X_input, verbose=0)
|
||||
# ── 이슈 7: CPU-bound model.predict → 워커 스레드로 분리 ──────
|
||||
predict_fn = functools.partial(self.model.predict, X_input, verbose=0)
|
||||
predicted_scaled = await asyncio.to_thread(predict_fn)
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
predicted_price = float(self.scaler.inverse_transform(predicted_scaled)[0][0])
|
||||
current_price = float(prices[-1])
|
||||
|
||||
expected_return = ((predicted_price - current_price) / current_price * 100.0) if current_price > 0 else 0.0
|
||||
expected_return = (
|
||||
(predicted_price - current_price) / current_price * 100.0
|
||||
if current_price > 0 else 0.0
|
||||
)
|
||||
signal = "BUY" if predicted_price > current_price * 1.02 else "HOLD"
|
||||
|
||||
return {
|
||||
@@ -101,7 +108,6 @@ class TensorFlowPredictor:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Prediction failed for {stock_code}: {e}")
|
||||
# Degrade gracefully
|
||||
return {
|
||||
"stock_code": stock_code,
|
||||
"error": str(e),
|
||||
|
||||
Reference in New Issue
Block a user