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.

216 lines
8.4 KiB
Python

"""
Insider Transaction endpoints — SEC Form 4 data
"""
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.insider import (
Form4AggregateResponse,
Form4ByDateResponse,
Form4Entry,
Form4Response,
InsiderTransactionEntry,
InsiderTransactionResponse,
InsiderSummaryPeriod,
InsiderSummaryResponse,
)
from app.services.insider_transaction_service import InsiderTransactionService
from app.utils.cache import with_cache
router = APIRouter()
logger = logging.getLogger("app.api.v1.insider")
@router.get(
"/transactions/{symbol}",
response_model=InsiderTransactionResponse,
summary="Get insider transactions for a symbol",
description=(
"Query SEC Form 4 insider trading data. Auto-fetches from SEC EDGAR if data is missing.\n\n"
"**데이터 소스**: SEC EDGAR (무료, API 키 불필요). 첫 조회 시 자동 인덱싱.\n\n"
"**Transaction codes**: P=Purchase, S=Sale, A=Award, M=Exercise, G=Gift, F=Tax Withholding"
),
)
@with_cache(namespace="insider:transactions", ttl=None, key_params=["symbol", "days", "transaction_type", "insider_title", "limit"])
async def get_insider_transactions(
symbol: str,
response: Response,
days: int = Query(90, ge=1, le=3650, description="Days to look back (max ~10 years)"),
transaction_type: Optional[str] = Query(None, description="Filter: P=Purchase, S=Sale, A=Award, M=Exercise"),
insider_title: Optional[str] = Query(None, description="Filter by title keyword (e.g., CEO, CFO, Director)"),
limit: int = Query(50, ge=1, le=500, description="Max entries to return"),
force_refresh: bool = Query(False, description="Bypass cache and re-fetch from SEC"),
db: AsyncSession = Depends(get_db),
):
svc = InsiderTransactionService()
try:
rows, total = await svc.get_transactions(
db,
ticker=symbol,
days=days,
transaction_type=transaction_type,
insider_title=insider_title,
limit=limit,
)
entries = [InsiderTransactionEntry.from_orm_obj(r) for r in rows]
return InsiderTransactionResponse(
symbol=symbol.upper(),
transactions=entries,
total_count=total,
metadata={
"days_requested": days,
"transaction_type_filter": transaction_type,
"insider_title_filter": insider_title,
},
)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"Insider transactions error for {symbol}: {e}")
raise HTTPException(status_code=502, detail=f"Failed to fetch insider data: {e}")
@router.get(
"/summary/{symbol}",
response_model=InsiderSummaryResponse,
summary="Get insider trading summary",
description=(
"Aggregated insider buy/sell activity for 3, 6, and 12 month periods.\n\n"
"Includes net buy/sell shares and values, plus top 5 notable transactions by value."
),
)
@with_cache(namespace="insider:summary", ttl=None, key_params=["symbol"])
async def get_insider_summary(
symbol: str,
response: Response,
force_refresh: bool = Query(False, description="Bypass cache"),
db: AsyncSession = Depends(get_db),
):
svc = InsiderTransactionService()
try:
result = await svc.get_summary(db, ticker=symbol)
periods = [InsiderSummaryPeriod(**p) for p in result["periods"]]
notable = [InsiderTransactionEntry.from_orm_obj(r) for r in result["notable"]]
return InsiderSummaryResponse(
symbol=symbol.upper(),
periods=periods,
notable_transactions=notable,
metadata={"data_source": "SEC EDGAR Form 4"},
)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"Insider summary error for {symbol}: {e}")
raise HTTPException(status_code=502, detail=f"Failed to fetch insider summary: {e}")
# ------------------------------------------------------------------
# New PIT-safe Form 4 endpoints
# ------------------------------------------------------------------
@router.get(
"/form4/{ticker}",
response_model=Form4Response,
summary="PIT-safe Form 4 insider transactions",
description=(
"Returns Form 4 transactions for a ticker where **filing_date ≤ as_of** (point-in-time safe).\n\n"
"`as_of` is required to prevent lookahead in backtests.\n\n"
"`start`/`end` also filter by `filing_date` (not transaction_date)."
),
)
@with_cache(namespace="insider:form4", ttl=300,
key_params=["ticker", "start", "end", "as_of", "buy_only", "csuite_only"])
async def get_form4(
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)"),
buy_only: bool = Query(False, description="Only return buy transactions (P/A, shares > 0)"),
csuite_only: bool = Query(False, description="Only return C-suite insider transactions"),
db: AsyncSession = Depends(get_db),
):
svc = InsiderTransactionService()
try:
rows, total = await svc.get_form4_pit(
db, ticker=ticker, as_of=as_of, start=start, end=end,
buy_only=buy_only, csuite_only=csuite_only,
)
entries = [Form4Entry.from_orm_obj(r) for r in rows]
return Form4Response(
symbol=ticker.upper(),
as_of=as_of,
window={"start": start.isoformat() if start else None, "end": end.isoformat() if end else None},
transactions=entries,
total_count=total,
)
except Exception as e:
logger.error(f"Form4 error for {ticker}: {e}")
raise HTTPException(status_code=502, detail=str(e))
@router.get(
"/form4/by-date/{filing_date}",
response_model=Form4ByDateResponse,
summary="Form 4 filings by a specific date (cross-ticker)",
description="Returns all Form 4 transactions where filing_date equals the given date. Useful for pre-market screening.",
)
@with_cache(namespace="insider:form4_bydate", ttl=3600, key_params=["filing_date", "buy_only"])
async def get_form4_by_date(
filing_date: date,
response: Response,
buy_only: bool = Query(False, description="Only return buy transactions"),
db: AsyncSession = Depends(get_db),
):
svc = InsiderTransactionService()
try:
rows = await svc.get_form4_by_date(db, filing_date=filing_date, buy_only=buy_only)
entries = [Form4Entry.from_orm_obj(r) for r in rows]
return Form4ByDateResponse(
filing_date=filing_date,
buy_only=buy_only,
transactions=entries,
total_count=len(entries),
)
except Exception as e:
logger.error(f"Form4 by-date error for {filing_date}: {e}")
raise HTTPException(status_code=502, detail=str(e))
@router.get(
"/form4/aggregate/{ticker}",
response_model=Form4AggregateResponse,
summary="Aggregate Form 4 buy activity (PIT-safe)",
description=(
"Aggregated insider buy metrics within [as_of - window_days, as_of].\n\n"
"All based on `filing_date` (PIT-safe). Returns buy_count, buy_dollar_total, "
"cluster_size (unique insiders), csuite_count, avg_pct_of_holding, recency_days."
),
)
@with_cache(namespace="insider:form4_agg", ttl=300, key_params=["ticker", "as_of", "window_days"])
async def get_form4_aggregate(
ticker: str,
response: Response,
as_of: date = Query(..., description="Point-in-time cutoff. Required."),
window_days: int = Query(30, ge=1, le=365, description="Lookback window in days"),
db: AsyncSession = Depends(get_db),
):
svc = InsiderTransactionService()
try:
agg = await svc.get_form4_aggregate(db, ticker=ticker, as_of=as_of, window_days=window_days)
return Form4AggregateResponse(**agg)
except Exception as e:
logger.error(f"Form4 aggregate error for {ticker}: {e}")
raise HTTPException(status_code=502, detail=str(e))