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.

226 lines
8.2 KiB
Python

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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