refactor: MCP 공통 함수/httpx 싱글톤, CORS 수정, ML 이벤트루프 블로킹 해소 (#4~#7)

This commit is contained in:
2026-02-24 18:25:36 +09:00
parent 24f3082693
commit b36b4a381f
6 changed files with 333 additions and 308 deletions

View File

@@ -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),