finsight-api / main.py
ManasDubey's picture
Finsight Copilot Initial commit
46d4cf5
Raw
History Blame Contribute Delete
6.72 kB
"""FinSight v3 API.
Run locally from finsight-v3/backend:
uvicorn main:app --reload --port 8000
"""
import json
import logging
import threading
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from agent import run_agent
from config import get_settings
from db import query
from diff_engine import diff_filings, list_diffable, narration_prompt
from market import get_market_overview, get_price_history, get_quote
from results import build_results
from screener import build_screener
from tools import METRICS, query_facts
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s")
def _warm_caches() -> None:
"""Pre-build the dashboard's heavy caches (market overview, screener,
results) in the background so the first page load after a restart is fast
instead of paying the full yfinance + XBRL cost on the user's request."""
for label, fn in (
("market", lambda: get_market_overview([r[0] for r in query("select ticker from companies")])),
("screener", build_screener),
("results", build_results),
):
try:
fn()
except Exception:
logging.exception("cache warm failed: %s", label)
@asynccontextmanager
async def lifespan(app: FastAPI):
threading.Thread(target=_warm_caches, name="cache-warm", daemon=True).start()
yield
app = FastAPI(title="FinSight v3 API", version="3.0.0", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=get_settings().cors_origin_list,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
class ChatMessage(BaseModel):
role: str = Field(pattern="^(user|assistant)$")
content: str
class ChatRequest(BaseModel):
messages: list[ChatMessage] = Field(min_length=1)
@app.post("/chat")
def chat_stream(request: ChatRequest):
"""SSE stream: tool_call / tool_result events, then the verified answer."""
def event_stream():
try:
for event in run_agent([m.model_dump() for m in request.messages]):
yield f"event: {event.type}\ndata: {json.dumps(event.data)}\n\n"
except Exception as exc:
logging.exception("agent failed")
yield f"event: error\ndata: {json.dumps({'message': str(exc)})}\n\n"
yield "event: done\ndata: {}\n\n"
return StreamingResponse(event_stream(), media_type="text/event-stream")
@app.post("/chat/sync")
def chat_sync(request: ChatRequest):
"""Non-streaming variant: returns the final answer payload."""
events = list(run_agent([m.model_dump() for m in request.messages]))
answer = next((e for e in reversed(events) if e.type == "answer"), None)
return {
"tool_trace": [
{"type": e.type, **e.data} for e in events if e.type in ("tool_call", "tool_result")
],
**(answer.data if answer else {"answer": None, "error": "no answer produced"}),
}
def companies():
rows = query("select ticker, name, cik, sector, country from companies order by ticker")
return {"companies": [
{"ticker": t, "name": n, "cik": c, "sector": s, "country": country}
for t, n, c, s, country in rows
]}
@app.get("/companies/{ticker}/financials")
def financials(ticker: str, annual: bool = True, years: int = 6):
core = ["revenue", "gross_profit", "operating_income", "net_income",
"eps_diluted", "operating_cash_flow", "buybacks"]
return query_facts(ticker, core, annual=annual, years=years)
@app.get("/companies/{ticker}/filings")
def company_filings(ticker: str):
rows = query(
"""
select f.form, f.filing_date::text, f.period_end::text, f.accession
from filings f join companies c on c.cik = f.cik
where c.ticker = %s order by f.filing_date desc
""",
(ticker.upper(),),
)
return {"filings": [
{"form": f, "filing_date": d, "period_end": p, "accession": a}
for f, d, p, a in rows
]}
@app.get("/quote/{ticker}")
def quote(ticker: str):
return get_quote(ticker)
@app.get("/companies/{ticker}/prices")
def prices(ticker: str, period: str = "1y"):
return get_price_history(ticker, period)
@app.get("/screener")
def screener():
return {"rows": build_screener()}
@app.get("/results/latest")
def latest_results():
"""Results Center: latest reported quarter per Indian company with
QoQ/YoY deltas, each row citing its NSE results filing."""
return {"rows": build_results()}
@app.get("/compare")
def compare(tickers: str, metrics: str = "revenue,net_income,eps_diluted", years: int = 4):
ticker_list = [t.strip().upper() for t in tickers.split(",") if t.strip()][:6]
metric_list = [m.strip() for m in metrics.split(",") if m.strip() in METRICS]
return {
"results": [query_facts(t, metric_list, annual=True, years=years) for t in ticker_list]
}
@app.get("/market/overview")
def market_overview():
universe = [row[0] for row in query("select ticker from companies")]
return get_market_overview(universe)
@app.get("/diff/{ticker}/filings")
def diffable_filings(ticker: str, form: str = "10-K"):
return {"filings": list_diffable(ticker, form)}
@app.get("/diff/{ticker}")
def filing_diff(ticker: str, form: str = "10-K",
new: str | None = None, old: str | None = None):
return diff_filings(ticker, form, accession_new=new, accession_old=old)
@app.post("/diff/{ticker}/narrate")
def filing_diff_narrate(ticker: str, form: str = "10-K",
new: str | None = None, old: str | None = None):
from agent import get_client
from google.genai import types as genai_types
diff = diff_filings(ticker, form, accession_new=new, accession_old=old)
if "error" in diff:
return diff
try:
response = get_client().models.generate_content(
model=get_settings().gemini_model,
contents=narration_prompt(diff),
config=genai_types.GenerateContentConfig(temperature=0.2),
)
return {"narration": (response.text or "").strip(), **diff}
except Exception as exc:
return {"narration": None, "narration_error": str(exc), **diff}
@app.get("/metrics")
def metrics():
return {"metrics": sorted(METRICS)}
@app.get("/health")
def health():
counts = {
table: query(f"select count(*) from {table}")[0][0]
for table in ("companies", "filings", "xbrl_facts", "chunks")
}
return {"status": "healthy", "model": get_settings().gemini_model, "data": counts}