feat: add 13-page institutional equity research report with automated PDF generation

- /report page: comprehensive 13~17 page report (Cover, TOC, Investment Snapshot,
  Company Profile, Financial Performance x4 charts, Quality Assessment, Operating
  Analysis, DCF 3-Scenario, Sensitivity Heatmap, Monte Carlo 5K, Tornado, Peer
  Comparison, Earnings Beat/Miss, Technical Summary, Disclaimer)
- Valuation engine: parallel POST to DCF / Sensitivity / Monte Carlo / Tornado /
  Reverse DCF using smart-defaults; fixed decimal vs percentage conversion for WACC
- Wall Street 10: institutional_report.py gathers DuPont, F-Score, DCF 3-scenario,
  Reverse DCF, peer comps into Gemini mega-prompt; POST /api/analysis/institutional
- SEC HTML viewer: fixed tempdir bug in sec_parser.py; full 10-K HTML now cached
  correctly; inject_sec_item_anchor_ids prefers later heading-like hosts over TOC
- Morgan Stanley Blue design system: navy/blue/gold print-optimised @media print CSS
  targeting A4 with page-break-after per section for PDF output

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
shawnkim1997
2026-03-29 17:36:56 +01:00
co-authored by Claude Sonnet 4.6
parent 51cbaf7f8d
commit 8fe3aaf771
6 changed files with 1808 additions and 15 deletions
+139
View File
@@ -375,3 +375,142 @@ Output rules:
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 = _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 = _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,
}