194 lines
6.7 KiB
Python
194 lines
6.7 KiB
Python
import database
|
|
"""Stock profile endpoint — GET /api/v1/stocks/{ticker}."""
|
|
|
|
import logging
|
|
|
|
from fastapi import APIRouter, HTTPException
|
|
|
|
from cache import get_or_set
|
|
from database import execute_query, execute_one
|
|
from schemas.stock import (
|
|
Peer,
|
|
PriceEvent,
|
|
SecFiling,
|
|
InsiderTrade,
|
|
StockProfile,
|
|
StockProfileResponse,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter(prefix="/stocks", tags=["stocks"])
|
|
|
|
|
|
@router.get(
|
|
"/{ticker}",
|
|
summary="Get full stock profile with all data",
|
|
response_model=StockProfileResponse,
|
|
responses={404: {"description": "Stock not found"}},
|
|
)
|
|
async def get_stock_profile(ticker: str):
|
|
"""Get complete stock profile including price, peers, sentiment, filings, and insider trades."""
|
|
ticker = ticker.upper().strip()
|
|
|
|
# Cache key for this ticker
|
|
cache_key = f"stock:{ticker}"
|
|
|
|
async def _fetch_full_profile():
|
|
# 1. Stock profile
|
|
row = await database.execute_one(
|
|
"SELECT * FROM stock_profiles WHERE ticker = $1",
|
|
(ticker,),
|
|
)
|
|
if not row:
|
|
raise HTTPException(status_code=404, detail=f"Stock {ticker} not found")
|
|
|
|
profile = StockProfile(
|
|
ticker=row["ticker"],
|
|
name=row.get("name"),
|
|
exchange=row.get("exchange"),
|
|
sector=row.get("sector"),
|
|
industry=row.get("industry"),
|
|
marketCap=float(row["market_cap"]) if row.get("market_cap") else None,
|
|
description=row.get("description"),
|
|
website=row.get("website"),
|
|
ceo=row.get("ceo"),
|
|
employees=row.get("employees"),
|
|
peRatio=float(row["pe_ratio"]) if row.get("pe_ratio") else None,
|
|
eps=float(row["eps"]) if row.get("eps") else None,
|
|
dividendYield=float(row["dividend_yield"]) if row.get("dividend_yield") else None,
|
|
beta=float(row["beta"]) if row.get("beta") else None,
|
|
)
|
|
|
|
# 2. Latest price
|
|
price_row = await database.execute_one(
|
|
"SELECT ticker, date, open, high, low, close, volume, adjusted_close "
|
|
"FROM prices WHERE ticker = $1 ORDER BY date DESC LIMIT 1",
|
|
(ticker,),
|
|
)
|
|
price = None
|
|
if price_row:
|
|
price = PriceEvent(
|
|
ticker=ticker,
|
|
date=price_row["date"],
|
|
open=float(price_row["open"]) if price_row.get("open") else None,
|
|
high=float(price_row["high"]) if price_row.get("high") else None,
|
|
low=float(price_row["low"]) if price_row.get("low") else None,
|
|
close=float(price_row["close"]) if price_row.get("close") else None,
|
|
volume=int(price_row["volume"]) if price_row.get("volume") else None,
|
|
adjustedClose=float(price_row["adjusted_close"]) if price_row.get("adjusted_close") else None,
|
|
)
|
|
|
|
# 3. Peers (from peers.py endpoint, not inline)
|
|
peers = await _fetch_peers(ticker)
|
|
|
|
# 4. SEC filings (last 20)
|
|
filings = await _fetch_filings(ticker)
|
|
|
|
# 5. Insider trades (last 20)
|
|
insider_trades = await _fetch_insider_trades(ticker)
|
|
|
|
return StockProfileResponse(
|
|
profile=profile,
|
|
price=price,
|
|
peers=peers,
|
|
secFilings=filings,
|
|
insiderTrades=insider_trades,
|
|
)
|
|
|
|
result = await get_or_set(cache_key, _fetch_full_profile, expire=60)
|
|
return result
|
|
|
|
|
|
async def _fetch_peers(ticker: str) -> list[Peer]:
|
|
"""Fetch peer companies from peer_groups table."""
|
|
rows = await database.execute_query(
|
|
"""
|
|
SELECT pg.ticker, pg.peer_ticker, pg.similarity_score,
|
|
sp.name,
|
|
(p2.close - p2.close_1d) / NULLIF(p2.close_1d, 0) * 100 AS price_change_1d
|
|
FROM peer_groups pg
|
|
JOIN stock_profiles sp ON pg.peer_ticker = sp.ticker
|
|
LEFT JOIN LATERAL (
|
|
SELECT close FROM prices WHERE ticker = pg.peer_ticker ORDER BY date DESC LIMIT 1
|
|
) p2 ON true
|
|
LEFT JOIN LATERAL (
|
|
SELECT close FROM prices WHERE ticker = pg.peer_ticker ORDER BY date DESC LIMIT 1 OFFSET 1
|
|
) p2d ON true
|
|
WHERE pg.ticker = $1
|
|
ORDER BY pg.similarity_score DESC
|
|
LIMIT 10
|
|
""",
|
|
(ticker,),
|
|
)
|
|
return [
|
|
Peer(
|
|
ticker=r["peer_ticker"],
|
|
name=r.get("name"),
|
|
similarityScore=float(r["similarity_score"]) if r.get("similarity_score") else None,
|
|
relativeStrength=None,
|
|
priceChange1d=float(r["price_change_1d"]) if r.get("price_change_1d") else None,
|
|
)
|
|
for r in rows
|
|
]
|
|
|
|
|
|
async def _fetch_filings(ticker: str, limit: int = 20) -> list[SecFiling]:
|
|
"""Fetch recent SEC filings for a ticker."""
|
|
rows = await database.execute_query(
|
|
"""
|
|
SELECT ticker, cik, form_type, filing_date, report_date,
|
|
accession_number, url, content_summary, sentiment_score, tags
|
|
FROM sec_filings
|
|
WHERE ticker = $1
|
|
ORDER BY filing_date DESC
|
|
LIMIT $2
|
|
""",
|
|
(ticker, limit),
|
|
)
|
|
return [
|
|
SecFiling(
|
|
ticker=r["ticker"],
|
|
cik=r.get("cik"),
|
|
formType=r["form_type"],
|
|
filingDate=r["filing_date"],
|
|
reportDate=r["report_date"],
|
|
accessionNumber=r.get("accession_number"),
|
|
url=r.get("url"),
|
|
contentSummary=r.get("content_summary"),
|
|
sentimentScore=float(r["sentiment_score"]) if r.get("sentiment_score") else None,
|
|
tags=r.get("tags"),
|
|
)
|
|
for r in rows
|
|
]
|
|
|
|
|
|
async def _fetch_insider_trades(ticker: str, limit: int = 20) -> list[InsiderTrade]:
|
|
"""Fetch recent insider trades for a ticker."""
|
|
rows = await database.execute_query(
|
|
"""
|
|
SELECT ticker, insider_name, insider_title, transaction_date,
|
|
transaction_type, shares, price_per_share, total_value,
|
|
shares_owned_after, filing_date
|
|
FROM insider_trades
|
|
WHERE ticker = $1
|
|
ORDER BY transaction_date DESC
|
|
LIMIT $2
|
|
""",
|
|
(ticker, limit),
|
|
)
|
|
return [
|
|
InsiderTrade(
|
|
ticker=r["ticker"],
|
|
insiderName=r.get("insider_name"),
|
|
insiderTitle=r.get("insider_title"),
|
|
transactionDate=r["transaction_date"],
|
|
transactionType=r.get("transaction_type"),
|
|
shares=int(r["shares"]) if r.get("shares") else None,
|
|
pricePerShare=float(r["price_per_share"]) if r.get("price_per_share") else None,
|
|
totalValue=float(r["total_value"]) if r.get("total_value") else None,
|
|
sharesOwnedAfter=int(r["shares_owned_after"]) if r.get("shares_owned_after") else None,
|
|
filingDate=r.get("filing_date"),
|
|
)
|
|
for r in rows
|
|
]
|