Initial commit: invest-copilot app
This commit is contained in:
@@ -0,0 +1,193 @@
|
||||
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
|
||||
]
|
||||
Reference in New Issue
Block a user