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