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