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.

93 lines
3.5 KiB
Python

"""
Activist Ownership endpoints — SC 13D / 13G
"""
import logging
from datetime import date
from typing import 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.ownership import (
ActivistEventEntry,
ActivistEventsResponse,
ActivistActiveResponse,
)
from app.services.activist_ownership_service import ActivistOwnershipService
from app.utils.cache import with_cache
router = APIRouter()
logger = logging.getLogger("app.api.v1.ownership")
@router.get(
"/13dg/active",
response_model=ActivistActiveResponse,
summary="Active activist positions as-of a date",
description=(
"Returns the latest SC 13D/13G filing per (filer, issuer) pair where **filing_date ≤ as_of** "
"and **ownership_pct ≥ min_ownership_pct**.\n\n"
"Positions with `ownership_pct = null` (not yet enriched) are excluded.\n\n"
"`as_of` is required."
),
)
@with_cache(namespace="ownership:13dg_active", ttl=300,
key_params=["as_of", "min_ownership_pct"])
async def get_13dg_active(
response: Response,
as_of: date = Query(..., description="Point-in-time cutoff. Required."),
min_ownership_pct: float = Query(5.0, ge=0.0, le=100.0, description="Minimum ownership %"),
db: AsyncSession = Depends(get_db),
):
svc = ActivistOwnershipService()
try:
rows = await svc.get_active_positions(db, as_of=as_of, min_ownership_pct=min_ownership_pct)
entries = [ActivistEventEntry.from_orm_obj(r) for r in rows]
return ActivistActiveResponse(
as_of=as_of,
min_ownership_pct=min_ownership_pct,
positions=entries,
total_count=len(entries),
)
except Exception as e:
logger.error(f"13D/G active positions error: {e}")
raise HTTPException(status_code=502, detail=str(e))
@router.get(
"/13dg/{ticker}",
response_model=ActivistEventsResponse,
summary="SC 13D/13G activist ownership events for a ticker",
description=(
"Returns SC 13D and SC 13G filings (including amendments) where **filing_date ≤ as_of**.\n\n"
"`as_of` is required for PIT safety in backtests.\n\n"
"Note: `ownership_pct` / `shares_owned` will be `null` until background enrichment runs (~30 min)."
),
)
@with_cache(namespace="ownership:13dg", ttl=300, key_params=["ticker", "start", "end", "as_of"])
async def get_13dg_events(
ticker: str,
response: Response,
as_of: date = Query(..., description="Point-in-time cutoff (filing_date ≤ as_of). Required."),
start: Optional[date] = Query(None, description="Window start (filing_date ≥ start)"),
end: Optional[date] = Query(None, description="Window end (filing_date ≤ end)"),
db: AsyncSession = Depends(get_db),
):
svc = ActivistOwnershipService()
try:
rows = await svc.get_events(db, ticker=ticker, as_of=as_of, start=start, end=end)
entries = [ActivistEventEntry.from_orm_obj(r) for r in rows]
return ActivistEventsResponse(
symbol=ticker.upper(),
as_of=as_of,
window={"start": start.isoformat() if start else None, "end": end.isoformat() if end else None},
events=entries,
total_count=len(entries),
)
except Exception as e:
logger.error(f"13D/G events error for {ticker}: {e}")
raise HTTPException(status_code=502, detail=str(e))