feat: PIT dividend calendar + earnings surprise calendar endpoint
dividend calendar:
- DividendCalendar 모델 (PIT revisioned: as_of_date 컬럼으로 lookahead bias 방지)
- FINRA 5yr 데이터 backfill + yfinance 갱신 지원
- GET /dividends/calendar/{ticker}, POST /dividends/calendar/bulk
- alembic migration: f7a8b9c0d1e2
earnings:
- GET /earnings/calendar/{ticker}: ex-dividend 방식 earnings calendar 제공
- EarningsSurprise 모델에 fiscal_date 인덱스 추가
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
main
parent
2438c511c7
commit
3eb736445b
@ -0,0 +1,57 @@
|
|||||||
|
"""add dividend_calendar table
|
||||||
|
|
||||||
|
Revision ID: f7a8b9c0d1e2
|
||||||
|
Revises: e6f7a8b9c0d1
|
||||||
|
Create Date: 2026-04-03
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
revision: str = "f7a8b9c0d1e2"
|
||||||
|
down_revision: Union[str, Sequence[str], None] = "e6f7a8b9c0d1"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
|
||||||
|
if not conn.dialect.has_table(conn, "dividend_calendar"):
|
||||||
|
op.create_table(
|
||||||
|
"dividend_calendar",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("ticker", sa.String(10), nullable=False),
|
||||||
|
sa.Column("ex_dividend_date", postgresql.TIMESTAMP(timezone=True), nullable=False),
|
||||||
|
sa.Column("amount", sa.Float(), nullable=False),
|
||||||
|
sa.Column("declaration_date", postgresql.TIMESTAMP(timezone=True), nullable=True),
|
||||||
|
sa.Column("record_date", postgresql.TIMESTAMP(timezone=True), nullable=True),
|
||||||
|
sa.Column("payment_date", postgresql.TIMESTAMP(timezone=True), nullable=True),
|
||||||
|
sa.Column("currency", sa.String(10), server_default="USD"),
|
||||||
|
sa.Column("dividend_type", sa.String(20), server_default="regular"),
|
||||||
|
sa.Column("frequency", sa.String(20), nullable=True),
|
||||||
|
sa.Column("as_of_date", postgresql.TIMESTAMP(timezone=True), nullable=False),
|
||||||
|
sa.Column("source", sa.String(50), nullable=False),
|
||||||
|
sa.Column("source_file_date", postgresql.TIMESTAMP(timezone=True), nullable=True),
|
||||||
|
sa.Column("created_at", postgresql.TIMESTAMP(timezone=True)),
|
||||||
|
sa.Column("updated_at", postgresql.TIMESTAMP(timezone=True)),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"ticker", "ex_dividend_date", "as_of_date", "source",
|
||||||
|
name="uq_dividend_calendar",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index("ix_dividend_calendar_ticker", "dividend_calendar", ["ticker"])
|
||||||
|
op.create_index("idx_dividend_ticker_exdate", "dividend_calendar", ["ticker", "ex_dividend_date"])
|
||||||
|
op.create_index("idx_dividend_exdate", "dividend_calendar", ["ex_dividend_date"])
|
||||||
|
op.create_index("idx_dividend_as_of", "dividend_calendar", ["as_of_date"])
|
||||||
|
op.create_index(
|
||||||
|
"idx_dividend_pit_query",
|
||||||
|
"dividend_calendar",
|
||||||
|
["ticker", "ex_dividend_date", "as_of_date"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("dividend_calendar")
|
||||||
@ -0,0 +1,202 @@
|
|||||||
|
"""
|
||||||
|
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},
|
||||||
|
)
|
||||||
@ -0,0 +1,53 @@
|
|||||||
|
"""
|
||||||
|
PIT Ex-Dividend Calendar model — revisioned dividend announcements
|
||||||
|
|
||||||
|
Stores multiple revisions of the same (ticker, ex_dividend_date) with different
|
||||||
|
as_of_date values, enabling Point-in-Time backtesting without lookahead bias.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy import Column, String, Float, Index, UniqueConstraint
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID, TIMESTAMP
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class DividendCalendar(Base):
|
||||||
|
__tablename__ = "dividend_calendar"
|
||||||
|
|
||||||
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
ticker = Column(String(10), nullable=False, index=True)
|
||||||
|
ex_dividend_date = Column(TIMESTAMP(timezone=True), nullable=False)
|
||||||
|
amount = Column(Float, nullable=False)
|
||||||
|
declaration_date = Column(TIMESTAMP(timezone=True), nullable=True)
|
||||||
|
record_date = Column(TIMESTAMP(timezone=True), nullable=True)
|
||||||
|
payment_date = Column(TIMESTAMP(timezone=True), nullable=True)
|
||||||
|
currency = Column(String(10), default="USD")
|
||||||
|
dividend_type = Column(String(20), default="regular") # regular | special
|
||||||
|
frequency = Column(String(20), nullable=True) # quarterly | semi-annual | annual | monthly | irregular
|
||||||
|
as_of_date = Column(TIMESTAMP(timezone=True), nullable=False) # PIT key
|
||||||
|
source = Column(String(50), nullable=False) # "yfinance", "finra_orf", etc.
|
||||||
|
source_file_date = Column(TIMESTAMP(timezone=True), nullable=True)
|
||||||
|
|
||||||
|
created_at = Column(
|
||||||
|
TIMESTAMP(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||||
|
)
|
||||||
|
updated_at = Column(
|
||||||
|
TIMESTAMP(timezone=True),
|
||||||
|
default=lambda: datetime.now(timezone.utc),
|
||||||
|
onupdate=lambda: datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"ticker", "ex_dividend_date", "as_of_date", "source",
|
||||||
|
name="uq_dividend_calendar",
|
||||||
|
),
|
||||||
|
Index("idx_dividend_ticker_exdate", "ticker", "ex_dividend_date"),
|
||||||
|
Index("idx_dividend_exdate", "ex_dividend_date"),
|
||||||
|
Index("idx_dividend_as_of", "as_of_date"),
|
||||||
|
# Composite index directly supports DISTINCT ON (ticker, ex_dividend_date) ORDER BY as_of_date DESC
|
||||||
|
Index("idx_dividend_pit_query", "ticker", "ex_dividend_date", "as_of_date"),
|
||||||
|
)
|
||||||
@ -0,0 +1,76 @@
|
|||||||
|
"""
|
||||||
|
PIT Dividend Calendar schemas
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date, datetime
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
|
class DividendCalendarEntry(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
ticker: str
|
||||||
|
ex_dividend_date: date
|
||||||
|
amount: float
|
||||||
|
declaration_date: Optional[date] = None
|
||||||
|
record_date: Optional[date] = None
|
||||||
|
payment_date: Optional[date] = None
|
||||||
|
currency: str = "USD"
|
||||||
|
dividend_type: str = "regular"
|
||||||
|
frequency: Optional[str] = None
|
||||||
|
as_of_date: date
|
||||||
|
source: str
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_orm_obj(cls, obj) -> "DividendCalendarEntry":
|
||||||
|
def _to_date(val):
|
||||||
|
if val is None:
|
||||||
|
return None
|
||||||
|
return val.date() if hasattr(val, "date") else val
|
||||||
|
|
||||||
|
return cls(
|
||||||
|
ticker=obj.ticker,
|
||||||
|
ex_dividend_date=_to_date(obj.ex_dividend_date),
|
||||||
|
amount=obj.amount,
|
||||||
|
declaration_date=_to_date(obj.declaration_date),
|
||||||
|
record_date=_to_date(obj.record_date),
|
||||||
|
payment_date=_to_date(obj.payment_date),
|
||||||
|
currency=obj.currency or "USD",
|
||||||
|
dividend_type=obj.dividend_type or "regular",
|
||||||
|
frequency=obj.frequency,
|
||||||
|
as_of_date=_to_date(obj.as_of_date),
|
||||||
|
source=obj.source,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DividendUpcomingResponse(BaseModel):
|
||||||
|
"""Response for PIT upcoming dividends query."""
|
||||||
|
dividends: List[DividendCalendarEntry]
|
||||||
|
total_count: int
|
||||||
|
metadata: Dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class DividendHistoryResponse(BaseModel):
|
||||||
|
"""Response for single-symbol dividend history."""
|
||||||
|
symbol: str
|
||||||
|
dividends: List[DividendCalendarEntry]
|
||||||
|
total_count: int
|
||||||
|
annual_yield_estimate: Optional[float] = None
|
||||||
|
metadata: Dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class DividendIngestRequest(BaseModel):
|
||||||
|
"""Request body for bulk backfill ingest."""
|
||||||
|
symbols: List[str] = Field(..., min_length=1, max_length=200)
|
||||||
|
force_refresh: bool = Field(False, description="Re-ingest even if data exists")
|
||||||
|
|
||||||
|
|
||||||
|
class DividendIngestResponse(BaseModel):
|
||||||
|
"""Response for admin ingest endpoint."""
|
||||||
|
symbols_processed: int
|
||||||
|
total_records_upserted: int
|
||||||
|
failed_symbols: List[str] = Field(default_factory=list)
|
||||||
|
status: str
|
||||||
|
metadata: Dict[str, Any] = Field(default_factory=dict)
|
||||||
Loading…
Reference in New Issue