""" market_data.py — yfinance wrapper with 15-minute in-memory cache. All other services call through this module. Never import yfinance directly outside of here — keeps caching and error handling centralized. """ import logging from datetime import datetime, date, timedelta from functools import lru_cache from typing import Optional import time import pandas as pd import yfinance as yf logger = logging.getLogger(__name__) # ─── Simple time-bucketed cache ─────────────────────────────────────────────── # Key: (ticker, cache_bucket) where cache_bucket = int(time() // 900) _CACHE: dict = {} _CACHE_TTL_SECONDS = 900 # 15 minutes def _bucket() -> int: return int(time.time() // _CACHE_TTL_SECONDS) def _cache_get(key: tuple): return _CACHE.get((key, _bucket())) def _cache_set(key: tuple, value): _CACHE[(key, _bucket())] = value # Prune old entries to avoid unbounded growth current = _bucket() stale = [k for k in list(_CACHE) if k[1] < current - 1] for k in stale: del _CACHE[k] # ─── Price history ───────────────────────────────────────────────────────────── def get_price_history(ticker: str, period: str = "1y") -> Optional[pd.DataFrame]: """ Return OHLCV DataFrame for the past year. Columns: Open, High, Low, Close, Volume Index: DatetimeIndex Returns None on failure. """ cache_key = ("price_history", ticker, period) cached = _cache_get(cache_key) if cached is not None: return cached try: df = yf.download(ticker, period=period, auto_adjust=True, progress=False) if df.empty: logger.warning(f"Empty price history for {ticker}") return None # Flatten multi-level columns if present (yfinance sometimes returns them) if isinstance(df.columns, pd.MultiIndex): df.columns = df.columns.get_level_values(0) _cache_set(cache_key, df) return df except Exception as e: logger.error(f"Failed to fetch price history for {ticker}: {e}") return None # ─── Current price ───────────────────────────────────────────────────────────── def get_current_price(ticker: str) -> Optional[float]: cache_key = ("current_price", ticker) cached = _cache_get(cache_key) if cached is not None: return cached try: t = yf.Ticker(ticker) price = t.fast_info.get("last_price") or t.fast_info.get("previousClose") if price is None: hist = get_price_history(ticker, period="5d") if hist is not None and not hist.empty: price = float(hist["Close"].iloc[-1]) if price is not None: price = float(price) _cache_set(cache_key, price) return price except Exception as e: logger.error(f"Failed to fetch current price for {ticker}: {e}") return None # ─── Options chain ───────────────────────────────────────────────────────────── def get_option_expirations(ticker: str) -> list[str]: """Return list of available expiration date strings (YYYY-MM-DD).""" cache_key = ("expirations", ticker) cached = _cache_get(cache_key) if cached is not None: return cached try: t = yf.Ticker(ticker) expirations = list(t.options) _cache_set(cache_key, expirations) return expirations except Exception as e: logger.error(f"Failed to fetch expirations for {ticker}: {e}") return [] def get_options_chain(ticker: str, expiration: str) -> Optional[dict]: """ Return {'calls': DataFrame, 'puts': DataFrame} for the given expiry. Columns include: strike, lastPrice, bid, ask, volume, openInterest, impliedVolatility, delta, theta, gamma (where available). Returns None on failure. """ cache_key = ("options_chain", ticker, expiration) cached = _cache_get(cache_key) if cached is not None: return cached try: t = yf.Ticker(ticker) chain = t.option_chain(expiration) result = {"calls": chain.calls.copy(), "puts": chain.puts.copy()} # yfinance does not always include Greeks — add NaN columns if missing for df in result.values(): for col in ("delta", "theta", "gamma"): if col not in df.columns: df[col] = float("nan") _cache_set(cache_key, result) return result except Exception as e: logger.error(f"Failed to fetch options chain for {ticker} exp={expiration}: {e}") return None def get_nearest_expiry(ticker: str, target_date: date) -> Optional[str]: """Return the closest available expiry on or after target_date.""" expirations = get_option_expirations(ticker) if not expirations: return None target_str = str(target_date) future = [e for e in sorted(expirations) if e >= target_str] return future[0] if future else None def get_same_day_expiry(ticker: str) -> Optional[str]: """Return today's expiry string if it exists, else None.""" today_str = str(date.today()) expirations = get_option_expirations(ticker) return today_str if today_str in expirations else None # ─── Earnings / dividends ────────────────────────────────────────────────────── def get_earnings_date(ticker: str) -> Optional[date]: """Return the next earnings date or None.""" cache_key = ("earnings", ticker) cached = _cache_get(cache_key) if cached is not None: return cached try: t = yf.Ticker(ticker) cal = t.calendar if cal is None: return None # calendar can be a dict or DataFrame depending on yfinance version if isinstance(cal, dict): earnings_info = cal.get("Earnings Date", []) else: # DataFrame — look for "Earnings Date" row or column try: earnings_info = cal.loc["Earnings Date"].tolist() except Exception: earnings_info = [] result = None for item in (earnings_info if isinstance(earnings_info, list) else [earnings_info]): try: d = pd.Timestamp(item).date() if d >= date.today(): result = d break except Exception: continue _cache_set(cache_key, result) return result except Exception as e: logger.error(f"Failed to fetch earnings date for {ticker}: {e}") return None