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