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
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 open-market purchases (transaction_code=P, shares > 0). Excludes awards/grants."),
|
|
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 open-market purchases (transaction_code=P). Excludes awards/grants."),
|
|
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))
|