""" Earnings Surprise service — yfinance-plus Uses Ticker.earnings_dates which provides: - EPS Estimate (analyst consensus) - Reported EPS (actual) - Surprise(%) (pre-calculated) No external API key required. ~25 quarters of history per ticker. """ import logging from datetime import datetime, timezone from typing import Dict, List, Optional, Tuple from sqlalchemy import select, desc, func from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.dialects.postgresql import insert as pg_insert from app.models.earnings_surprise import EarningsSurprise logger = logging.getLogger(__name__) _CHUNK = 2000 class EarningsService: # ------------------------------------------------------------------ # Fetch from yfinance-plus # ------------------------------------------------------------------ def _fetch_earnings_from_yfinance(self, ticker: str) -> List[Dict]: """Fetch earnings dates with EPS estimate/actual from yfinance-plus. This is a synchronous call (yfinance uses requests internally). """ import warnings warnings.filterwarnings("ignore", category=DeprecationWarning) warnings.filterwarnings("ignore", category=FutureWarning) from yfinance_plus import Ticker t = Ticker(ticker.upper()) df = t.earnings_dates if df is None or df.empty: return [] rows = [] for idx, row in df.iterrows(): # idx is the earnings date (Timestamp with timezone) reported_date = idx.to_pydatetime() if reported_date.tzinfo: reported_date = reported_date.astimezone(timezone.utc) else: reported_date = reported_date.replace(tzinfo=timezone.utc) estimated_eps = _safe_float(row.get("EPS Estimate")) reported_eps = _safe_float(row.get("Reported EPS")) surprise_pct = _safe_float(row.get("Surprise(%)")) # Skip future earnings (no reported EPS yet) if reported_eps is None: continue surprise = None if reported_eps is not None and estimated_eps is not None: surprise = round(reported_eps - estimated_eps, 6) rows.append({ "reported_date": reported_date, "reported_eps": reported_eps, "estimated_eps": estimated_eps, "surprise": surprise, "surprise_percentage": surprise_pct, }) return rows # ------------------------------------------------------------------ # Index to DB # ------------------------------------------------------------------ async def index_earnings( self, db: AsyncSession, ticker: str, force_refresh: bool = False ) -> int: ticker = ticker.upper() if not force_refresh: count = await db.execute( select(func.count(EarningsSurprise.id)).where( EarningsSurprise.ticker == ticker ) ) if (count.scalar() or 0) > 0: return 0 else: await db.execute( EarningsSurprise.__table__.delete().where( EarningsSurprise.ticker == ticker ) ) await db.flush() import asyncio raw = await asyncio.to_thread(self._fetch_earnings_from_yfinance, ticker) if not raw: logger.warning(f"Earnings: no yfinance data for {ticker}") return 0 db_rows = [] for r in raw: # Use reported_date as fiscal_date_ending (earnings announcement date) db_rows.append({ "ticker": ticker, "fiscal_date_ending": r["reported_date"], "reported_date": r["reported_date"], "reported_eps": r["reported_eps"], "estimated_eps": r["estimated_eps"], "surprise": r["surprise"], "surprise_percentage": r["surprise_percentage"], "data_source": "YFINANCE", }) if not db_rows: return 0 inserted = 0 for i in range(0, len(db_rows), _CHUNK): chunk = db_rows[i:i + _CHUNK] stmt = pg_insert(EarningsSurprise).values(chunk) stmt = stmt.on_conflict_do_update( constraint="uq_earnings_surprise", set_={ "reported_date": stmt.excluded.reported_date, "reported_eps": stmt.excluded.reported_eps, "estimated_eps": stmt.excluded.estimated_eps, "surprise": stmt.excluded.surprise, "surprise_percentage": stmt.excluded.surprise_percentage, "data_source": stmt.excluded.data_source, }, ) result = await db.execute(stmt) inserted += result.rowcount await db.commit() logger.info(f"Earnings: upserted {inserted} quarters for {ticker}") return inserted # ------------------------------------------------------------------ # Query # ------------------------------------------------------------------ async def get_earnings_surprise( self, db: AsyncSession, ticker: str, quarters: int = 8 ) -> Tuple[List[EarningsSurprise], Dict]: ticker = ticker.upper() count_q = await db.execute( select(func.count(EarningsSurprise.id)).where( EarningsSurprise.ticker == ticker ) ) if (count_q.scalar() or 0) == 0: await self.index_earnings(db, ticker) result = await db.execute( select(EarningsSurprise) .where(EarningsSurprise.ticker == ticker) .order_by(desc(EarningsSurprise.fiscal_date_ending)) .limit(quarters) ) rows = result.scalars().all() # Streak: consecutive beats (surprise > 0) or misses streak = 0 if rows: first_sign = None for r in rows: if r.surprise is None: break if first_sign is None: first_sign = r.surprise > 0 if (r.surprise > 0) == first_sign: streak += 1 if first_sign else -1 else: break if first_sign is False: streak = -abs(streak) pcts = [r.surprise_percentage for r in rows if r.surprise_percentage is not None] avg_pct = round(sum(pcts) / len(pcts), 4) if pcts else None return rows, {"streak": streak, "avg_surprise_pct": avg_pct} def _safe_float(val) -> Optional[float]: if val is None: return None try: import math f = float(val) return None if math.isnan(f) else f except (ValueError, TypeError): return None