from typing import Tuple import time import pandas as pd import requests from fastapi import HTTPException from app.config import get_settings from app.indicators import add_technical_indicators settings = get_settings() ALPHA_VANTAGE_URL = "https://www.alphavantage.co/query" _CACHE: dict[str, dict] = {} _CACHE_TTL = 60 # seconds (safe for AV free tier) # ------------------------- # Helpers # ------------------------- def is_crypto(ticker: str) -> bool: t = ticker.upper() return "-" in t or t in {"BTC", "ETH", "SOL", "BNB", "XRP", "DOGE"} def _safe_get_json(params: dict) -> dict: try: resp = requests.get(ALPHA_VANTAGE_URL, params=params, timeout=10) return resp.json() except Exception: raise HTTPException( status_code=503, detail="Market data provider unavailable", ) def _detect_ohlcv_columns(df: pd.DataFrame) -> pd.DataFrame: """ Robust OHLCV resolver for Alpha Vantage crypto. Works for: - 1a / 1b - USD / non-USD - future schema changes """ def find(keywords): for c in df.columns: name = c.lower() if all(k in name for k in keywords): return c return None open_col = find(["open"]) high_col = find(["high"]) low_col = find(["low"]) close_col = find(["close"]) volume_col = find(["volume"]) if not all([open_col, high_col, low_col, close_col, volume_col]): raise HTTPException( status_code=500, detail=f"Unsupported crypto data format from Alpha Vantage: {list(df.columns)}", ) df = df[[open_col, high_col, low_col, close_col, volume_col]] df.columns = ["Open", "High", "Low", "Close", "Volume"] return df # ------------------------- # STOCK DATA # ------------------------- def _fetch_stock_history(ticker: str) -> pd.DataFrame: params = { "function": "TIME_SERIES_DAILY", "symbol": ticker, "apikey": settings.alpha_vantage_api_key, "outputsize": "compact", # FREE tier only } data = _safe_get_json(params) if "Time Series (Daily)" not in data: if "Note" in data or "Information" in data: raise HTTPException(429, "Alpha Vantage rate limit exceeded") raise HTTPException(400, "Invalid stock ticker symbol") df = pd.DataFrame.from_dict( data["Time Series (Daily)"], orient="index" ) df.rename( columns={ "1. open": "Open", "2. high": "High", "3. low": "Low", "4. close": "Close", "5. volume": "Volume", }, inplace=True, ) df = df[["Open", "High", "Low", "Close", "Volume"]] df = df.apply(pd.to_numeric, errors="coerce").dropna() df.index = pd.to_datetime(df.index) df.sort_index(inplace=True) if len(df) < settings.history_window + 50: raise HTTPException(400, "Not enough historical stock data") return df # ------------------------- # CRYPTO DATA # ------------------------- def _fetch_crypto_history(ticker: str) -> pd.DataFrame: if "-" in ticker: symbol, market = ticker.split("-", 1) else: symbol, market = ticker, "USD" params = { "function": "DIGITAL_CURRENCY_DAILY", "symbol": symbol.upper(), "market": market.upper(), "apikey": settings.alpha_vantage_api_key, } data = _safe_get_json(params) if "Time Series (Digital Currency Daily)" not in data: if "Note" in data or "Information" in data: raise HTTPException(429, "Alpha Vantage rate limit exceeded") raise HTTPException(400, "Invalid crypto ticker symbol") df = pd.DataFrame.from_dict( data["Time Series (Digital Currency Daily)"], orient="index" ) df = _detect_ohlcv_columns(df) df = df.apply(pd.to_numeric, errors="coerce") df.dropna(subset=["Open", "High", "Low", "Close"], inplace=True) df.index = pd.to_datetime(df.index) df.sort_index(inplace=True) if len(df) < settings.history_window + 50: raise HTTPException(400, "Not enough historical crypto data") return df # ------------------------- # Cached unified fetcher # ------------------------- def fetch_raw_history(ticker: str) -> pd.DataFrame: key = ticker.upper() now = time.time() if key in _CACHE and now - _CACHE[key]["ts"] < _CACHE_TTL: return _CACHE[key]["df"] df = ( _fetch_crypto_history(key) if is_crypto(key) else _fetch_stock_history(key) ) _CACHE[key] = {"df": df, "ts": now} return df # ------------------------- # Public API # ------------------------- def get_enriched_history(ticker: str) -> Tuple[pd.DataFrame, pd.DataFrame]: df_raw = fetch_raw_history(ticker) df_tech = add_technical_indicators(df_raw) if len(df_tech) < settings.history_window: raise HTTPException( status_code=400, detail="Insufficient data after technical indicator calculation", ) return df_raw, df_tech def last_n_candles(df: pd.DataFrame, n: int) -> list[dict]: return [ { "date": idx.strftime("%Y-%m-%d"), "price": float(row["Close"]), } for idx, row in df.tail(n).iterrows() ]