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.

203 lines
7.4 KiB
Python

"""
PIT Ex-Dividend Calendar endpoints
"""
import logging
from datetime import date, datetime, timedelta, 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.dividend import (
DividendCalendarEntry,
DividendUpcomingResponse,
DividendHistoryResponse,
DividendIngestRequest,
DividendIngestResponse,
)
from app.services.dividend_service import DividendService
from app.utils.cache import with_cache
router = APIRouter()
logger = logging.getLogger("app.api.v1.dividends")
@router.get(
"/upcoming",
response_model=DividendUpcomingResponse,
summary="PIT upcoming ex-dividend calendar",
description=(
"Point-in-Time 배당락 캘린더. `as_of_date` 기준으로 당시 알려져 있었던 배당 일정 중 "
"`from_ex_date` ~ `to_ex_date` 범위의 ex-date를 반환.\n\n"
"**PIT 의미**: 같은 (ticker, ex_date)에 여러 revision이 있으면 "
"`as_of_date <= query_as_of_date` 조건 내에서 가장 최신 revision만 반환.\n\n"
"**데이터 소스**: yfinance-plus. API 키 불필요. "
"symbols 파라미터 없이 조회 시 이미 인덱싱된 종목 전체 반환.\n\n"
"**백필**: `POST /dividends/admin/ingest` 로 원하는 종목 선인덱싱 가능."
),
)
@with_cache(
namespace="dividend:upcoming",
ttl=3600,
key_params=["as_of_date", "from_ex_date", "to_ex_date", "symbols", "limit"],
)
async def get_upcoming_dividends(
response: Response,
as_of_date: Optional[date] = Query(
None, description="PIT 기준일 (YYYY-MM-DD). 생략 시 오늘."
),
from_ex_date: Optional[date] = Query(
None, description="Ex-date 시작 (YYYY-MM-DD). 생략 시 오늘."
),
to_ex_date: Optional[date] = Query(
None, description="Ex-date 끝 (YYYY-MM-DD). 생략 시 오늘 + 60일."
),
symbols: Optional[List[str]] = Query(
None, description="종목 필터 (e.g. ?symbols=AAPL&symbols=MSFT). 생략 시 전체."
),
limit: int = Query(500, ge=1, le=5000, description="최대 반환 개수"),
force_refresh: bool = Query(False, description="캐시 무시"),
db: AsyncSession = Depends(get_db),
):
today = date.today()
as_of = as_of_date or today
from_ex = from_ex_date or today
to_ex = to_ex_date or (today + timedelta(days=60))
if from_ex > to_ex:
raise HTTPException(status_code=400, detail="from_ex_date must be <= to_ex_date")
as_of_dt = datetime(as_of.year, as_of.month, as_of.day, 23, 59, 59, tzinfo=timezone.utc)
from_ex_dt = datetime(from_ex.year, from_ex.month, from_ex.day, tzinfo=timezone.utc)
to_ex_dt = datetime(to_ex.year, to_ex.month, to_ex.day, 23, 59, 59, tzinfo=timezone.utc)
svc = DividendService()
try:
rows, total = await svc.get_upcoming_dividends(
db,
as_of_date=as_of_dt,
from_ex_date=from_ex_dt,
to_ex_date=to_ex_dt,
symbols=symbols,
limit=limit,
)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"Dividend upcoming query error: {e}")
raise HTTPException(status_code=502, detail=f"Failed to query dividends: {e}")
entries = [DividendCalendarEntry.from_orm_obj(r) for r in rows]
return DividendUpcomingResponse(
dividends=entries,
total_count=total,
metadata={
"as_of_date": as_of.isoformat(),
"from_ex_date": from_ex.isoformat(),
"to_ex_date": to_ex.isoformat(),
"symbols_filter": symbols,
"results_returned": len(entries),
"pit_semantics": "DISTINCT ON (ticker, ex_date) ORDER BY as_of_date DESC",
},
)
@router.get(
"/history/{symbol}",
response_model=DividendHistoryResponse,
summary="종목별 배당 이력",
description=(
"단일 종목의 전체 배당 이력. yfinance 데이터가 없으면 자동 인덱싱.\n\n"
"각 ex-date별 최신 revision을 반환 (ex-date 내림차순).\n\n"
"`annual_yield_estimate`: 최근 12개월 배당 합산액 (주가 대비 yield는 클라이언트 계산 필요)."
),
)
@with_cache(
namespace="dividend:history",
ttl=None,
key_params=["symbol", "limit"],
)
async def get_dividend_history(
symbol: str,
response: Response,
limit: int = Query(100, ge=1, le=1000, description="최대 반환 개수"),
force_refresh: bool = Query(False, description="캐시 무시 + yfinance 재조회"),
db: AsyncSession = Depends(get_db),
):
svc = DividendService()
if force_refresh:
try:
await svc.index_dividends(db, symbol, force_refresh=True)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"Dividend force refresh error for {symbol}: {e}")
raise HTTPException(status_code=502, detail=f"Re-fetch failed: {e}")
try:
rows, total, annual_yield = await svc.get_dividend_history(
db, ticker=symbol, limit=limit
)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"Dividend history error for {symbol}: {e}")
raise HTTPException(status_code=502, detail=f"Failed to fetch dividend history: {e}")
entries = [DividendCalendarEntry.from_orm_obj(r) for r in rows]
return DividendHistoryResponse(
symbol=symbol.upper(),
dividends=entries,
total_count=total,
annual_yield_estimate=annual_yield,
metadata={
"data_source": "yfinance",
"limit": limit,
"results_returned": len(entries),
"annual_yield_note": "TTM: ex_date가 최근 365일 이내인 배당 합산액",
},
)
@router.post(
"/admin/ingest",
response_model=DividendIngestResponse,
summary="배당 데이터 벌크 인제스트",
description=(
"yfinance에서 지정 종목 배당 이력을 가져와 DB에 저장.\n\n"
"**예시**:\n"
"- `{\"symbols\": [\"AAPL\", \"MSFT\", \"JNJ\"]}` — 신규 종목 인덱싱\n"
"- `{\"symbols\": [...], \"force_refresh\": true}` — 기존 데이터 재인제스트\n\n"
"종목당 약 25년치 이력. 100종목 기준 5~10분 소요 (yfinance rate limit).\n\n"
"이미 인덱싱된 종목은 `force_refresh: false`일 때 건너뜀 (멱등성)."
),
)
async def ingest_dividends(
body: DividendIngestRequest,
db: AsyncSession = Depends(get_db),
):
svc = DividendService()
try:
result = await svc.bulk_ingest(
db,
symbols=[s.upper().strip() for s in body.symbols],
force_refresh=body.force_refresh,
)
except Exception as e:
logger.error(f"Dividend bulk ingest error: {e}")
raise HTTPException(status_code=502, detail=f"Bulk ingest failed: {e}")
return DividendIngestResponse(
symbols_processed=result["symbols_processed"],
total_records_upserted=result["total_records_upserted"],
failed_symbols=result["failed_symbols"],
status="completed",
metadata={"force_refresh": body.force_refresh},
)