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>
This commit is contained in:
198
backend/app/services/market_data.py
Normal file
198
backend/app/services/market_data.py
Normal file
@@ -0,0 +1,198 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user