|
|
"""
|
|
|
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."
|
|
|
),
|
|
|
)
|
|
|
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)",
|
|
|
},
|
|
|
)
|