Files
Options-SideKick/backend/app/services/market_data.py
olsch01 b7d4e900cc Initial implementation of Options Sidekick
Full-stack iOS options trading assistant:
- Python FastAPI backend with SQLite, APScheduler (15-min position monitor),
  APNs push notifications, and yfinance market data integration
- Signal engine: IV Rank (rolling HV proxy), SMA-50/200, swing-based
  support/resistance, earnings detection, signal strength scoring and
  noise-resistant SHA hash for change detection
- Recommendation engine: covered call and cash-secured put strike/expiry
  selection across 0DTE, 1DTE, weekly, and monthly horizons
- REST API: /devices, /portfolio, /recommendations, /positions, /signals, /alerts
- iOS SwiftUI app (iOS 17+): dashboard, recommendations, trades, portfolio,
  and alerts tabs with push notification deep-linking
- Unit + integration tests for signal engine and API layer

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 14:38:25 -04:00

199 lines
7.0 KiB
Python

"""
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