""" Earnings endpoints — surprise history + future calendar """ import asyncio import logging from datetime import date, datetime, timezone from typing import List, Optional from fastapi import APIRouter, Depends, HTTPException, Query from fastapi.responses import Response from sqlalchemy.ext.asyncio import AsyncSession from app.core.database import get_db from app.schemas.earnings import ( EarningsSurpriseEntry, EarningsSurpriseResponse, EarningsCalendarEntry, EarningsCalendarResponse, BulkEarningsCalendarRequest, BulkEarningsCalendarResponse, ) from app.services.earnings_service import EarningsService from app.utils.cache import with_cache router = APIRouter() logger = logging.getLogger("app.api.v1.earnings") @router.get( "/calendar/{symbol}", response_model=EarningsCalendarResponse, summary="Get upcoming earnings dates for a symbol", description=( "Upcoming earnings announcement dates with EPS estimates.\n\n" "**데이터 소스**: yfinance-plus (`Ticker.earnings_dates`). API 키 불필요.\n" "**earnings_time**: `pre_market` / `post_market` / `during_market` / `unknown`.\n\n" "**PIT (Point-in-Time) backtesting**: `as_of_date`를 지정하면 해당 날짜 기준 " "upcoming earnings를 반환합니다. 이미 보고된 earnings도 당시엔 예정이었으므로 " "`reported_eps`가 채워진 상태로 반환됩니다.\n\n" "**Note**: Revenue estimates are not available from this source." ), ) @with_cache(namespace="earnings:calendar", ttl=3600, key_params=["symbol", "days_ahead", "limit", "as_of_date"]) async def get_earnings_calendar( symbol: str, response: Response, days_ahead: int = Query(30, ge=1, le=365, description="Days to look ahead from as_of_date (or today)"), limit: int = Query(4, ge=1, le=20, description="Max earnings dates to return"), as_of_date: Optional[date] = Query(None, description="PIT date for backtesting (YYYY-MM-DD). Defaults to today."), force_refresh: bool = Query(False, description="Bypass cache and re-fetch from yfinance"), ): as_of_dt = ( datetime(as_of_date.year, as_of_date.month, as_of_date.day, tzinfo=timezone.utc) if as_of_date else None ) svc = EarningsService() try: rows = await svc.get_future_earnings( ticker=symbol, days_ahead=days_ahead, limit=limit, as_of_date=as_of_dt ) except Exception as e: logger.error(f"Earnings calendar error for {symbol}: {e}") raise HTTPException(status_code=502, detail=f"Failed to fetch earnings calendar: {e}") now = datetime.now(timezone.utc) entries = [ EarningsCalendarEntry( symbol=symbol.upper(), earnings_date=r["earnings_date"], earnings_time=r["earnings_time"], estimated_eps=r["estimated_eps"], reported_eps=r["reported_eps"], source="yfinance", fetched_at=now, ) for r in rows ] return EarningsCalendarResponse( symbol=symbol.upper(), upcoming_earnings=entries, metadata={ "data_source": "yfinance", "as_of_date": as_of_date.isoformat() if as_of_date else "today", "days_ahead": days_ahead, "limit": limit, "results_returned": len(entries), "note": "Revenue estimates not available from this source.", }, ) @router.post( "/calendar/bulk", response_model=BulkEarningsCalendarResponse, summary="Bulk future earnings calendar", description=( "Fetch upcoming earnings dates for multiple symbols (max 50).\n\n" "Returns a flat list of calendar entries sorted by `earnings_date` ascending.\n" "Useful for checking upcoming earnings of sector peers or candidates." ), ) @with_cache(namespace="earnings:calendar_bulk", ttl=3600, key_params=["request"]) async def get_bulk_earnings_calendar( request: BulkEarningsCalendarRequest, response: Response, ): as_of_dt = ( datetime(request.as_of_date.year, request.as_of_date.month, request.as_of_date.day, tzinfo=timezone.utc) if request.as_of_date else None ) svc = EarningsService() now = datetime.now(timezone.utc) semaphore = asyncio.Semaphore(10) async def process_one(sym: str) -> List[EarningsCalendarEntry]: async with semaphore: try: rows = await svc.get_future_earnings( ticker=sym, days_ahead=request.days_ahead, limit=request.limit, as_of_date=as_of_dt, ) return [ EarningsCalendarEntry( symbol=sym.upper(), earnings_date=r["earnings_date"], earnings_time=r["earnings_time"], estimated_eps=r["estimated_eps"], reported_eps=r["reported_eps"], source="yfinance", fetched_at=now, ) for r in rows ] except Exception as e: logger.warning(f"Earnings calendar bulk: {sym} failed: {e}") return [] tasks = [process_one(sym) for sym in request.symbols] try: results = await asyncio.wait_for( asyncio.gather(*tasks, return_exceptions=True), timeout=300, ) except asyncio.TimeoutError: raise HTTPException(status_code=504, detail="Bulk earnings calendar request timed out.") entries: List[EarningsCalendarEntry] = [] failed = 0 for r in results: if isinstance(r, Exception): failed += 1 elif isinstance(r, list): entries.extend(r) entries.sort(key=lambda e: e.earnings_date) return BulkEarningsCalendarResponse( entries=entries, metadata={ "symbols_requested": len(request.symbols), "symbols_failed": failed, "total_entries": len(entries), "as_of_date": request.as_of_date.isoformat() if request.as_of_date else "today", "days_ahead": request.days_ahead, "per_symbol_limit": request.limit, }, ) @router.get( "/surprise/{symbol}", response_model=EarningsSurpriseResponse, summary="Get earnings surprise history", description=( "Quarterly EPS surprise: reported vs analyst consensus estimate.\n\n" "**데이터 소스**: yfinance-plus (`Ticker.earnings_dates`). API 키 불필요.\n" "**커버리지**: ~25분기 (6년+). 첫 조회 시 자동 인덱싱.\n\n" "**surprise** = reported_eps - estimated_eps.\n" "**surprise_percentage** = (surprise / estimated) × 100.\n" "**streak**: 연속 beat (양수) 또는 miss (음수) 횟수." ), ) @with_cache(namespace="earnings:surprise", ttl=None, key_params=["symbol", "quarters"]) async def get_earnings_surprise( symbol: str, response: Response, quarters: int = Query(8, ge=1, le=40, description="Number of recent quarters (max ~25 available)"), force_refresh: bool = Query(False, description="Bypass cache and re-fetch from yfinance"), db: AsyncSession = Depends(get_db), ): svc = EarningsService() if force_refresh: try: await svc.index_earnings(db, symbol, force_refresh=True) except ValueError as e: raise HTTPException(status_code=404, detail=str(e)) try: rows, stats = await svc.get_earnings_surprise(db, ticker=symbol, quarters=quarters) except ValueError as e: raise HTTPException(status_code=404, detail=str(e)) except Exception as e: logger.error(f"Earnings surprise error for {symbol}: {e}") raise HTTPException(status_code=502, detail=f"Failed to fetch earnings data: {e}") entries = [EarningsSurpriseEntry.from_orm_obj(r) for r in rows] return EarningsSurpriseResponse( symbol=symbol.upper(), quarters=entries, streak=stats["streak"], avg_surprise_pct=stats["avg_surprise_pct"], metadata={ "data_source": "SEC_XBRL", "quarters_requested": quarters, "quarters_returned": len(entries), "note": "surprise = current_eps - previous_quarter_eps (QoQ change)", }, )