from typing import List import numpy as np from fastapi import HTTPException from tensorflow.keras.models import load_model from datetime import datetime, timedelta from app.config import get_settings from app.indicators import classify_signal, classify_volatility from app.services.market_data import get_enriched_history, last_n_candles settings = get_settings() _model = None def get_model(): """ Lazy-load model to avoid HF Space startup crashes """ global _model if _model is None: try: _model = load_model(settings.model_path) except Exception as e: print(f"[Prediction] Failed to load model: {e}") raise HTTPException( status_code=500, detail="Prediction model not available", ) return _model def is_model_available() -> bool: """ Used by health endpoint. Does NOT force model loading failure to crash the app. """ try: get_model() return True except Exception: return False # Normalization def _normalize_window(window: np.ndarray): """ window shape: (history_window, 5) [Close, RSI, MACD, Log_Volume, MA50] """ start_p = window[0, 0] if start_p <= 0: raise HTTPException( status_code=500, detail="Invalid price normalization window", ) vol_mean = np.mean(window[:, 3]) if vol_mean <= 0: vol_mean = 1.0 # safety guard norm = np.zeros_like(window) norm[:, 0] = (window[:, 0] / start_p) - 1 norm[:, 1] = window[:, 1] / 100.0 norm[:, 2] = window[:, 2] / start_p norm[:, 3] = (window[:, 3] / vol_mean) - 1 norm[:, 4] = (window[:, 4] / start_p) - 1 return norm, start_p # Prediction def predict_path(ticker: str, days: int): model = get_model() if days > settings.max_forecast_days: days = settings.max_forecast_days df_raw, df_tech = get_enriched_history(ticker) if len(df_tech) < settings.history_window: raise HTTPException( status_code=400, detail=( f"Not enough clean data after indicators " f"(required={settings.history_window}, got={len(df_tech)})" ), ) features = df_tech[ ["Close", "RSI", "MACD", "Log_Volume", "MA50"] ].values last_window = features[-settings.history_window:] current_price = float(last_window[-1, 0]) temp_window = last_window.copy() forecast_points: List[dict] = [] for i in range(days): norm, start_p = _normalize_window(temp_window) pred_pct = model.predict( norm.reshape(1, settings.history_window, 5), verbose=0, )[0][0] # 🔒 Safety clamp pred_pct = float(np.clip(pred_pct, -0.2, 0.2)) next_price = float(start_p * (1 + pred_pct)) volatility = 0.015 * (i + 1) lower_bound = float(next_price * (1 - volatility)) upper_bound = float(next_price * (1 + volatility)) forecast_points.append( { "day": i + 1, "price": next_price, "lower": lower_bound, "upper": upper_bound, } ) new_row = temp_window[-1].copy() new_row[0] = next_price temp_window = np.vstack([temp_window[1:], new_row]) # Summary target_price = forecast_points[-1]["price"] predicted_change = ((target_price - current_price) / current_price) * 100 vol_label = classify_volatility(df_raw) signal = classify_signal(predicted_change) rsi_latest = float(df_tech["RSI"].iloc[-1]) macd_latest = float(df_tech["MACD"].iloc[-1]) macd_label = "Bullish" if macd_latest >= 0 else "Bearish" historical = last_n_candles(df_raw, settings.history_window) prediction_payload = { "ticker": ticker.upper(), "currentPrice": current_price, "targetPrice": target_price, "predictedChange": predicted_change, "signal": signal, "rsi": rsi_latest, "macd": macd_label, "volatility": vol_label, "historicalPrices": historical, "predictedPrice": target_price, "forecastData": [ { "day": p["day"], "price": p["price"], "changePct": ((p["price"] - current_price) / current_price) * 100, } for p in forecast_points ], } # Chart payload last_date = datetime.strptime(historical[-1]["date"], "%Y-%m-%d") chart_forecast_payload = { "ticker": ticker.upper(), "points": [ { "date": historical[-1]["date"], "price": current_price, "lower": current_price, "upper": current_price, } ], } for i, p in enumerate(forecast_points, start=1): d = last_date + timedelta(days=i) chart_forecast_payload["points"].append( { "date": d.strftime("%Y-%m-%d"), "price": p["price"], "lower": p["lower"], "upper": p["upper"], } ) return prediction_payload, chart_forecast_payload