You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

206 lines
6.8 KiB
Python

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