Files
All-in-one-Financial-Analysis/atlas-terminal/server/routers/analysis.py
T

537 lines
18 KiB
Python

"""AI Analysis router -- Gemini-powered financial analysis.
Direct Gemini API calls without depending on Streamlit app module.
"""
import json
import logging
import os
import re
from typing import Any, Dict, List
import httpx
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
from server.models.schemas import AnomalyExplainRequest, AnomalyExplainResponse
logger = logging.getLogger(__name__)
router = APIRouter()
class AnalysisRequest(BaseModel):
ticker: str
question: str = ""
api_key: str = ""
sector: str = ""
industry: str = ""
class SimpleQuestionRequest(BaseModel):
ticker: str
question: str
api_key: str = ""
async def _call_gemini(
api_key: str,
prompt: str,
max_tokens: int = 4096,
temperature: float = 0.7,
) -> str:
"""Call Gemini API directly (non-blocking) and return text response."""
url = (
"https://generativelanguage.googleapis.com/v1beta/models/"
f"gemini-2.0-flash:generateContent?key={api_key}"
)
payload = {
"contents": [{"parts": [{"text": prompt}]}],
"generationConfig": {
"maxOutputTokens": max_tokens,
"temperature": temperature,
},
}
try:
async with httpx.AsyncClient(timeout=60.0) as client:
resp = await client.post(
url,
json=payload,
headers={"Content-Type": "application/json"},
)
except httpx.TimeoutException as exc:
logger.warning("Gemini API timeout: %s", exc)
raise HTTPException(status_code=504, detail="Gemini API timeout") from exc
except httpx.HTTPError as exc:
logger.exception("Gemini API request error")
raise HTTPException(status_code=502, detail=f"Gemini request failed: {exc}") from exc
if resp.status_code >= 400:
body = resp.text[:200]
logger.error("Gemini API error %d: %s", resp.status_code, body)
raise HTTPException(
status_code=resp.status_code, detail=f"Gemini API error: {body}"
)
try:
data = resp.json()
except ValueError as exc:
logger.exception("Gemini response JSON decode failed")
raise HTTPException(status_code=502, detail="Gemini response not JSON") from exc
candidates = data.get("candidates", [])
if candidates:
parts = candidates[0].get("content", {}).get("parts", [])
if parts:
return parts[0].get("text", "")
return "No response from Gemini."
def _build_anomaly_filing_context(
ticker: str,
email: str,
filing_focus: str,
display_name: str,
account_key: str,
) -> str:
from server.services.sec_parser import get_10k_sections
from server.services.text_chunker import smart_chunk
sections, _ = get_10k_sections(ticker.upper(), email.strip())
focus = (filing_focus or "10k_mda").lower()
if focus == "risk":
raw = "\n\n".join(
p
for p in (
sections.get("item1a", ""),
sections.get("item9a", ""),
sections.get("item3", ""),
)
if p
)
else:
raw = sections.get("item7", "") or ""
if not raw.strip():
return ""
hint = f"{display_name or ''} {account_key or ''}".strip()
if hint:
tokens = [t.lower() for t in re.split(r"[\s_/]+", hint) if len(t) > 2]
if tokens:
paras = [p.strip() for p in re.split(r"\n\s*\n", raw) if p.strip()]
if not paras:
paras = [raw]
scored: List[tuple[int, str]] = []
for para in paras:
pl = para.lower()
score = sum(1 for t in tokens if t in pl)
scored.append((score, para))
scored.sort(key=lambda x: (-x[0], -len(x[1])))
priority = "\n\n".join(p for _, p in scored[:15])
if priority.strip():
raw = priority
return smart_chunk(raw, max_chars=14000)
def _parse_llm_json_object(text: str) -> Dict[str, Any]:
s = (text or "").strip()
if s.startswith("```"):
s = re.sub(r"^```(?:json)?\s*", "", s, flags=re.IGNORECASE)
s = re.sub(r"\s*```\s*$", "", s)
return json.loads(s)
def _anomaly_response_from_parsed(obj: Dict[str, Any]) -> AnomalyExplainResponse:
cites = obj.get("citations") or []
norm_cites: List[Dict[str, str]] = []
if isinstance(cites, list):
for c in cites:
if isinstance(c, dict):
norm_cites.append(
{
"excerpt": str(c.get("excerpt", ""))[:2000],
"context": str(c.get("context", ""))[:500],
}
)
causes = obj.get("likely_causes") or []
if not isinstance(causes, list):
causes = []
conf = str(obj.get("confidence", "medium")).lower()
if conf not in ("high", "medium", "low"):
conf = "medium"
return AnomalyExplainResponse(
summary=str(obj.get("summary", ""))[:8000],
likely_causes=[str(x) for x in causes][:20],
citations=norm_cites[:15],
confidence=conf,
)
def _get_financial_context(ticker: str) -> str:
"""Build financial context from yfinance for AI analysis."""
try:
import yfinance as yf
t = yf.Ticker(ticker)
info = t.info or {}
ctx = f"""Company: {info.get('longName', ticker)} ({ticker})
Sector: {info.get('sector', 'N/A')} | Industry: {info.get('industry', 'N/A')}
Market Cap: ${info.get('marketCap', 0)/1e9:.1f}B
Revenue: ${info.get('totalRevenue', 0)/1e9:.1f}B | Revenue Growth: {(info.get('revenueGrowth', 0) or 0)*100:.1f}%
Profit Margin: {(info.get('profitMargins', 0) or 0)*100:.1f}% | Gross Margin: {(info.get('grossMargins', 0) or 0)*100:.1f}%
ROE: {(info.get('returnOnEquity', 0) or 0)*100:.1f}% | ROA: {(info.get('returnOnAssets', 0) or 0)*100:.1f}%
D/E: {info.get('debtToEquity', 'N/A')} | Current Ratio: {info.get('currentRatio', 'N/A')}
P/E: {info.get('trailingPE', 'N/A')} | Forward P/E: {info.get('forwardPE', 'N/A')}
Price: ${info.get('currentPrice', 'N/A')} | 52W High: ${info.get('fiftyTwoWeekHigh', 'N/A')} | 52W Low: ${info.get('fiftyTwoWeekLow', 'N/A')}
Target Mean: ${info.get('targetMeanPrice', 'N/A')} | Recommendation: {info.get('recommendationKey', 'N/A')}
Free Cash Flow: ${info.get('freeCashflow', 0)/1e9:.1f}B
"""
return ctx
except Exception:
return f"Ticker: {ticker}"
@router.post("/strategy", summary="AI financial analysis")
async def strategy_analysis(req: AnalysisRequest):
"""General AI financial analysis using Gemini."""
api_key = req.api_key
if not api_key:
raise HTTPException(status_code=400, detail="API key required. Set your Gemini key in Settings.")
context = _get_financial_context(req.ticker.upper())
question = req.question or f"Provide a comprehensive financial analysis of {req.ticker.upper()}"
prompt = f"""You are an expert financial analyst. Analyze the following company and answer the user's question.
{context}
User Question: {question}
Provide a detailed, professional analysis in markdown format. Include:
- Key financial metrics assessment
- Strengths and weaknesses
- Valuation perspective
- Risk factors
- Your overall assessment
Be specific with numbers and data. Answer in the same language as the question."""
result = await _call_gemini(api_key, prompt)
return {"ticker": req.ticker.upper(), "analysis": result}
@router.post("/risks", summary="Risk analysis")
async def risk_analysis(req: AnalysisRequest):
"""AI-powered risk analysis."""
api_key = req.api_key
if not api_key:
raise HTTPException(status_code=400, detail="API key required.")
context = _get_financial_context(req.ticker.upper())
prompt = f"""You are a risk analyst. Analyze the following company's risk factors:
{context}
Provide a detailed risk assessment including:
1. Financial risks (leverage, liquidity, profitability trends)
2. Market risks (valuation, competition, sector headwinds)
3. Operational risks
4. Regulatory risks
5. Overall risk rating (Low/Medium/High)
Be specific and use the financial data provided. Answer in markdown format."""
result = await _call_gemini(api_key, prompt)
return {"ticker": req.ticker.upper(), "analysis": result}
@router.post("/mda", summary="MD&A analysis")
async def mda_insights(req: AnalysisRequest):
"""AI management discussion analysis."""
api_key = req.api_key
if not api_key:
raise HTTPException(status_code=400, detail="API key required.")
context = _get_financial_context(req.ticker.upper())
prompt = f"""Analyze the management perspective for this company:
{context}
Provide insights on:
1. Revenue drivers and growth strategy
2. Margin trends and cost management
3. Capital allocation priorities
4. Key management concerns
5. Future outlook
Use markdown format with headers and bullet points."""
result = await _call_gemini(api_key, prompt)
return {"ticker": req.ticker.upper(), "report": result}
@router.post("/forensic", summary="Forensic audit")
async def forensic_audit(req: AnalysisRequest):
"""AI forensic audit analysis."""
api_key = req.api_key
if not api_key:
raise HTTPException(status_code=400, detail="API key required.")
context = _get_financial_context(req.ticker.upper())
prompt = f"""Perform a forensic financial audit on this company:
{context}
Check for:
1. Earnings quality (cash flow vs net income)
2. Aggressive accounting signs
3. Related party transactions
4. Off-balance sheet items
5. Revenue recognition concerns
6. Management compensation alignment
Use markdown format. Be thorough but fair."""
result = await _call_gemini(api_key, prompt)
return {"ticker": req.ticker.upper(), "forensic": result}
@router.post("/financials", summary="Extract financials via LLM")
async def extract_financials(req: AnalysisRequest):
"""Use Gemini to provide financial analysis."""
api_key = req.api_key
if not api_key:
raise HTTPException(status_code=400, detail="API key required.")
context = _get_financial_context(req.ticker.upper())
result = await _call_gemini(api_key, f"Summarize the key financial data for analysis:\n\n{context}")
return {"ticker": req.ticker.upper(), "financials": result}
@router.post(
"/anomaly-explain",
response_model=AnomalyExplainResponse,
summary="Explain YoY anomaly from 10-K text (strict JSON)",
)
async def anomaly_explain(req: AnomalyExplainRequest):
"""Use Item 7 / risk sections plus Gemini to explain a flagged line item."""
api_key = (req.api_key or "").strip()
if not api_key:
raise HTTPException(status_code=400, detail="Gemini API key required.")
email = (req.sec_email or os.getenv("SEC_EDGAR_EMAIL") or "").strip()
if not email:
raise HTTPException(
status_code=400,
detail="SEC fair-access email required (pass sec_email or set SEC_EDGAR_EMAIL).",
)
try:
ctx = _build_anomaly_filing_context(
req.ticker,
email,
req.filing_focus,
req.display_name,
req.account_key,
)
except Exception as exc:
logger.exception("anomaly-explain filing load failed")
raise HTTPException(
status_code=502,
detail=f"Could not load SEC filing text: {exc}",
) from exc
if not ctx:
raise HTTPException(
status_code=404,
detail="No 10-K section text available for this ticker.",
)
mag = float(req.magnitude_pct or 0.0)
dir_lbl = "increase" if (req.direction or "").lower() == "up" else "decrease"
prompt = f"""You are a securities analyst. The user flagged a large year-over-year change in a financial statement line item (from automated screening — do not recompute numbers).
Ticker: {req.ticker.upper()}
Line item (account key): {req.account_key or "unknown"}
Display name: {req.display_name or "unknown"}
Direction: {dir_lbl} (approx. {mag:.1f}% YoY — context only).
Below is excerpted SEC filing text. Infer plausible qualitative explanations.
--- FILING EXCERPT ---
{ctx}
--- END EXCERPT ---
Output rules:
- Single JSON object only. No markdown, no code fences, no surrounding text.
- Do not invent new numerical results.
- Required JSON shape:
{{
"summary": "2-4 sentences",
"likely_causes": ["short strings"],
"citations": [{{"excerpt": "quote from excerpt", "context": "Item 7 / Item 1A / etc."}}],
"confidence": "high" | "medium" | "low"
}}
"""
raw = ""
try:
raw = await _call_gemini(api_key, prompt, max_tokens=2048, temperature=0.2)
parsed = _parse_llm_json_object(raw)
return _anomaly_response_from_parsed(parsed)
except json.JSONDecodeError:
return AnomalyExplainResponse(
summary="The model returned non-JSON; raw output is attached in citations.",
likely_causes=[],
citations=[{"excerpt": (raw or "")[:1500], "context": "raw model output"}],
confidence="low",
)
except HTTPException:
raise
except Exception as exc:
logger.exception("anomaly-explain Gemini failed")
raise HTTPException(status_code=500, detail=str(exc)) from exc
# ---------------------------------------------------------------------------
# Translation endpoint
# ---------------------------------------------------------------------------
class TranslateRequest(BaseModel):
text: str = Field(..., description="Text to translate")
target_lang: str = Field("ko", description="Target language code (ko, ja, zh, etc.)")
api_key: str = ""
@router.post("/translate", summary="Translate filing text via Gemini")
async def translate_text(req: TranslateRequest):
"""Translate SEC/DART filing section text to the target language."""
api_key = req.api_key or os.getenv("GOOGLE_API_KEY", "")
if not api_key:
raise HTTPException(status_code=400, detail="Gemini API key required")
text = req.text.strip()
if not text:
raise HTTPException(status_code=400, detail="No text to translate")
# Limit input to ~12,000 chars to stay within Gemini context
if len(text) > 12_000:
from server.services.text_chunker import smart_chunk
text = smart_chunk(text, max_chars=12_000)
lang_names = {
"ko": "Korean", "ja": "Japanese", "zh": "Chinese (Simplified)",
"es": "Spanish", "fr": "French", "de": "German",
}
lang_name = lang_names.get(req.target_lang, req.target_lang)
prompt = (
f"Translate the following SEC filing text to {lang_name}. "
"Rules:\n"
"- Preserve all numbers, financial figures, dates, and ticker symbols exactly as-is.\n"
"- Keep technical financial terms (e.g., EBITDA, GAAP, P/E) in English.\n"
"- Maintain paragraph structure and formatting.\n"
"- Translate naturally, not word-for-word.\n\n"
f"---\n{text}\n---"
)
try:
result = await _call_gemini(api_key, prompt, max_tokens=8192, temperature=0.2)
return {"translated_text": result}
except Exception as exc:
logger.exception("Translation failed")
raise HTTPException(status_code=500, detail=f"Translation failed: {exc}") from exc
# ---------------------------------------------------------------------------
# Institutional Analysis — Wall Street 10
# ---------------------------------------------------------------------------
class InstitutionalRequest(BaseModel):
ticker: str
api_key: str = ""
lang: str = Field("en", description="Output language: en, ko, ja")
@router.post("/institutional", summary="Wall Street 10 institutional analysis")
async def institutional_analysis(req: InstitutionalRequest):
"""Generate comprehensive institutional-grade analysis from 10 Wall Street perspectives.
Gathers all pre-computed quantitative data (DuPont, Altman Z, F-Score,
DCF, anomalies, peers) and feeds them to Gemini for multi-perspective
interpretation. The LLM interprets numbers; it never computes them.
"""
api_key = req.api_key or os.getenv("GOOGLE_API_KEY", "")
if not api_key:
raise HTTPException(status_code=400, detail="Gemini API key required. Set in Settings.")
ticker = req.ticker.upper()
# 1) Gather all quantitative data
from server.services.institutional_report import (
gather_quantitative_context,
build_institutional_prompt,
)
try:
context = gather_quantitative_context(ticker)
except Exception as exc:
logger.exception("Failed to gather quant context for %s", ticker)
raise HTTPException(status_code=500, detail=f"Data gathering failed: {exc}") from exc
if len(context) < 200:
raise HTTPException(status_code=404, detail=f"Insufficient data for {ticker}")
# Extract F-Score for prompt
fscore = 0
try:
from server.services.research_dashboard import build_research_dashboard
dash = build_research_dashboard(ticker)
if dash:
fscore = dash.fscore_total
except Exception:
pass
# 2) Build prompt and call Gemini
prompt = build_institutional_prompt(ticker, context, fscore)
# Language instruction
if req.lang == "ko":
prompt += "\n\nIMPORTANT: Write the entire analysis in Korean (한국어). Keep financial terms (P/E, EBITDA, DCF, etc.) in English."
elif req.lang == "ja":
prompt += "\n\nIMPORTANT: Write the entire analysis in Japanese (日本語). Keep financial terms in English."
try:
raw = await _call_gemini(api_key, prompt, max_tokens=8192, temperature=0.3)
except HTTPException:
raise
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Gemini call failed: {exc}") from exc
# 3) Parse JSON response
try:
parsed = _parse_llm_json_object(raw)
except json.JSONDecodeError:
# Return raw text as executive_summary if JSON parsing fails
parsed = {
"executive_summary": raw[:3000] if raw else "Analysis generation failed.",
"goldman_sachs": "",
"morgan_stanley": "",
"jp_morgan": "",
"blackrock": "",
"bridgewater": "",
"berkshire": "",
"citadel": "",
"two_sigma": "",
"elliott": "",
}
return {
"ticker": ticker,
"sections": parsed,
"quant_context": context,
}