Files
All-in-one-Financial-Analysis/atlas-terminal/server/services/institutional_report.py
T
shawnkim1997andClaude Sonnet 4.6 8fe3aaf771 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>
2026-03-29 17:36:56 +01:00

326 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Institutional Report — gather all quantitative data for Wall Street 10 analysis.
Collects DuPont, Altman Z, F-Score, DCF, anomalies, and yfinance info
into a single rich context string that can be fed to Gemini for
institutional-grade multi-perspective analysis.
All numbers are pre-computed in Python (ATLAS hybrid principle: LLM never computes).
"""
from __future__ import annotations
import logging
from typing import Any, Dict, List, Optional
from server.utils.safe_float import _safe_float
logger = logging.getLogger(__name__)
def _fmt(v: Any, suffix: str = "", prefix: str = "") -> str:
"""Format a value for human-readable context."""
if v is None:
return "N/A"
if isinstance(v, float):
if abs(v) >= 1e9:
return f"{prefix}{v / 1e9:.1f}B{suffix}"
if abs(v) >= 1e6:
return f"{prefix}{v / 1e6:.1f}M{suffix}"
return f"{prefix}{v:.2f}{suffix}"
return str(v)
def gather_quantitative_context(ticker: str) -> str:
"""Build a comprehensive quantitative context string (~2500-3500 words).
This is the core data payload that gets injected into the institutional
analysis prompt. The LLM interprets these pre-computed numbers — it does
NOT compute anything itself.
"""
parts: List[str] = []
ticker = ticker.upper()
# ── 1. Basic Company Info (yfinance) ──────────────────────────────
try:
import yfinance as yf
t = yf.Ticker(ticker)
info = t.info or {}
except Exception:
info = {}
parts.append(f"""=== COMPANY PROFILE ===
Company: {info.get('longName', ticker)} ({ticker})
Sector: {info.get('sector', 'N/A')} | Industry: {info.get('industry', 'N/A')}
Market Cap: {_fmt(info.get('marketCap'), prefix='$')}
Enterprise Value: {_fmt(info.get('enterpriseValue'), prefix='$')}
Current Price: ${info.get('currentPrice', 'N/A')}
52W High: ${info.get('fiftyTwoWeekHigh', 'N/A')} | 52W Low: ${info.get('fiftyTwoWeekLow', 'N/A')}
Beta: {info.get('beta', 'N/A')}
Employees: {info.get('fullTimeEmployees', 'N/A')}""")
# ── 2. Key Financial Metrics ──────────────────────────────────────
rev = info.get('totalRevenue')
ni = info.get('netIncomeToCommon')
gm = info.get('grossMargins')
om = info.get('operatingMargins')
pm = info.get('profitMargins')
roe = info.get('returnOnEquity')
roa = info.get('returnOnAssets')
de = info.get('debtToEquity')
cr = info.get('currentRatio')
fcf = info.get('freeCashflow')
ocf = info.get('operatingCashflow')
rev_growth = info.get('revenueGrowth')
earn_growth = info.get('earningsGrowth')
parts.append(f"""
=== KEY FINANCIALS (TTM) ===
Revenue: {_fmt(rev, prefix='$')} | Revenue Growth: {f'{rev_growth*100:.1f}%' if rev_growth else 'N/A'}
Net Income: {_fmt(ni, prefix='$')} | Earnings Growth: {f'{earn_growth*100:.1f}%' if earn_growth else 'N/A'}
Gross Margin: {f'{gm*100:.1f}%' if gm else 'N/A'} | Operating Margin: {f'{om*100:.1f}%' if om else 'N/A'} | Net Margin: {f'{pm*100:.1f}%' if pm else 'N/A'}
ROE: {f'{roe*100:.1f}%' if roe else 'N/A'} | ROA: {f'{roa*100:.1f}%' if roa else 'N/A'}
D/E: {de if de else 'N/A'} | Current Ratio: {cr if cr else 'N/A'}
Free Cash Flow: {_fmt(fcf, prefix='$')} | Operating Cash Flow: {_fmt(ocf, prefix='$')}
FCF Yield: {f'{fcf/info.get("marketCap")*100:.1f}%' if fcf and info.get("marketCap") else 'N/A'}""")
# ── 3. Valuation Multiples ────────────────────────────────────────
pe = info.get('trailingPE')
fpe = info.get('forwardPE')
ps = info.get('priceToSalesTrailing12Months')
pb = info.get('priceToBook')
ev_ebitda = info.get('enterpriseToEbitda')
ev_rev = info.get('enterpriseToRevenue')
peg = info.get('pegRatio')
div_yield = info.get('dividendYield')
payout = info.get('payoutRatio')
parts.append(f"""
=== VALUATION MULTIPLES ===
P/E (TTM): {f'{pe:.1f}x' if pe else 'N/A'} | Forward P/E: {f'{fpe:.1f}x' if fpe else 'N/A'}
P/S: {f'{ps:.1f}x' if ps else 'N/A'} | P/B: {f'{pb:.1f}x' if pb else 'N/A'}
EV/EBITDA: {f'{ev_ebitda:.1f}x' if ev_ebitda else 'N/A'} | EV/Revenue: {f'{ev_rev:.1f}x' if ev_rev else 'N/A'}
PEG Ratio: {f'{peg:.2f}' if peg else 'N/A'}
Dividend Yield: {f'{div_yield*100:.2f}%' if div_yield else 'N/A'} | Payout Ratio: {f'{payout*100:.0f}%' if payout else 'N/A'}""")
# ── 4. Analyst Consensus ──────────────────────────────────────────
target_mean = info.get('targetMeanPrice')
target_high = info.get('targetHighPrice')
target_low = info.get('targetLowPrice')
rec = info.get('recommendationKey')
num_analysts = info.get('numberOfAnalystOpinions')
cur_price = info.get('currentPrice') or info.get('regularMarketPrice')
upside = None
if target_mean and cur_price and cur_price > 0:
upside = (target_mean - cur_price) / cur_price * 100
parts.append(f"""
=== ANALYST CONSENSUS ===
Target Mean: ${target_mean or 'N/A'} | High: ${target_high or 'N/A'} | Low: ${target_low or 'N/A'}
Implied Upside: {f'{upside:+.1f}%' if upside is not None else 'N/A'}
Recommendation: {rec or 'N/A'} | # Analysts: {num_analysts or 'N/A'}""")
# ── 5. Shareholder Returns ────────────────────────────────────────
buyback = info.get('sharesOutstanding')
shares_float = info.get('floatShares')
parts.append(f"""
=== SHAREHOLDER RETURNS ===
Shares Outstanding: {_fmt(buyback)} | Float: {_fmt(shares_float)}
Dividend Yield: {f'{div_yield*100:.2f}%' if div_yield else 'None'}
Payout Ratio: {f'{payout*100:.0f}%' if payout else 'N/A'}
Free Cash Flow: {_fmt(fcf, prefix='$')} (available for buybacks/dividends)""")
# ── 6. DuPont Decomposition + Altman Z + Red Flags ────────────────
try:
from server.services.financial_metrics import get_dupont_altman_redflags_yoy
health = get_dupont_altman_redflags_yoy(ticker)
if health:
dupont_df = health.get("dupont")
if dupont_df is not None and not dupont_df.empty:
rows_str = dupont_df.to_string(index=False)
parts.append(f"""
=== DUPONT ROE DECOMPOSITION (3-Year) ===
ROE = Net Profit Margin × Asset Turnover × Equity Multiplier
{rows_str}""")
altman = health.get("altman_z")
if altman is not None:
zone = "Safe (>2.99)" if altman > 2.99 else ("Gray Zone (1.81-2.99)" if altman > 1.81 else "Distress (<1.81)")
parts.append(f"""
=== ALTMAN Z-SCORE ===
Z-Score: {altman:.2f}{zone}""")
red_flags = health.get("red_flags", [])
if red_flags:
flags_str = "\n".join(f" ⚠ {rf.get('flag', rf) if isinstance(rf, dict) else rf}" for rf in red_flags[:10])
parts.append(f"""
=== RED FLAGS ===
{flags_str}""")
yoy_data = health.get("yoy", [])
if yoy_data:
yoy_str = "\n".join(f" {y.get('Ratio', '')}: {y.get('Comment', '')}" for y in yoy_data)
parts.append(f"""
=== YOY RATIO CHANGES ===
{yoy_str}""")
except Exception as e:
logger.warning("DuPont/Altman failed for %s: %s", ticker, e)
# ── 7. Piotroski F-Score ──────────────────────────────────────────
try:
from server.services.research_dashboard import build_research_dashboard
dash = build_research_dashboard(ticker)
if dash and dash.fscore_total is not None:
score = dash.fscore_total
criteria_str = ""
for c in dash.fscore_criteria:
latest = c.history[0] if c.history else None
status = "✓" if (latest and latest.pass_flag) else "✗"
criteria_str += f" {status} {c.label}\n"
parts.append(f"""
=== PIOTROSKI F-SCORE: {score}/9 ===
{criteria_str.rstrip()}""")
# Anomalies
if dash.anomalies:
anom_str = "\n".join(
f" {'▲' if a.direction == 'up' else '▼'} {a.display_name}: "
f"{f'{a.change_pct:+.1f}%' if a.change_pct else 'N/A'} YoY"
for a in dash.anomalies[:8]
)
parts.append(f"""
=== YOY ANOMALIES (>30% change) ===
{anom_str}""")
except Exception as e:
logger.warning("F-Score/anomalies failed for %s: %s", ticker, e)
# ── 8. DCF Valuation (Smart Defaults) ─────────────────────────────
try:
from server.services.dcf_engine import dcf_10y_2stage, reverse_dcf
base_fcf = _safe_float(info.get("freeCashflow"))
total_debt = _safe_float(info.get("totalDebt")) or 0
cash = _safe_float(info.get("totalCash")) or 0
shares = _safe_float(info.get("sharesOutstanding")) or 1
if base_fcf and base_fcf > 0 and shares and shares > 0:
beta_val = info.get("beta", 1.0) or 1.0
wacc = 0.04 + beta_val * 0.05 # CAPM approximation
wacc = max(0.06, min(0.15, wacc))
tg = 0.025
growth = min(0.25, max(-0.05, (rev_growth or 0.08)))
# 3 scenarios
scenarios = {}
for label, g_mult, w_adj in [("Bear", 0.5, 0.02), ("Base", 1.0, 0), ("Bull", 1.5, -0.01)]:
g = growth * g_mult
w = wacc + w_adj
ev = dcf_10y_2stage(base_fcf, w, tg, g)
eq = ev - total_debt + cash
vps = eq / shares if shares > 0 else 0
scenarios[label] = round(vps, 2)
# Reverse DCF
try:
implied_g = reverse_dcf(
current_price=cur_price or 0,
shares=shares,
total_debt=total_debt,
cash=cash,
wacc=wacc,
term_growth=tg,
fcf_base=base_fcf,
)
except Exception:
implied_g = None
parts.append(f"""
=== DCF VALUATION (ATLAS Engine) ===
Base FCF: {_fmt(base_fcf, prefix='$')} | WACC: {wacc*100:.1f}% | Terminal Growth: {tg*100:.1f}%
FCF Growth (Base): {growth*100:.1f}%
Bear Case: ${scenarios.get('Bear', 'N/A')}/share
Base Case: ${scenarios.get('Base', 'N/A')}/share
Bull Case: ${scenarios.get('Bull', 'N/A')}/share
Current Price: ${cur_price or 'N/A'}
Reverse DCF Implied Growth: {f'{implied_g*100:.1f}%' if implied_g is not None else 'N/A'}""")
except Exception as e:
logger.warning("DCF failed for %s: %s", ticker, e)
# ── 9. Peer Comparison ────────────────────────────────────────────
try:
from server.services.peer_comparison_service import build_peer_comparison
peer_data = build_peer_comparison(ticker)
peers = peer_data.get("peers", []) if peer_data else []
if peers:
peer_lines = []
for p in peers[:6]:
name = p.get("ticker", p.get("symbol", "?"))
p_pe = p.get("pe", p.get("trailingPE"))
p_ps = p.get("ps", p.get("priceToSales"))
p_pb = p.get("pb", p.get("priceToBook"))
peer_lines.append(
f" {name}: P/E={f'{p_pe:.1f}' if p_pe else 'N/A'} "
f"P/S={f'{p_ps:.1f}' if p_ps else 'N/A'} "
f"P/B={f'{p_pb:.1f}' if p_pb else 'N/A'}"
)
if peer_lines:
parts.append(f"""
=== PEER VALUATION ===
{chr(10).join(peer_lines)}""")
except Exception as e:
logger.warning("Peer comparison failed for %s: %s", ticker, e)
return "\n".join(parts)
# ---------------------------------------------------------------------------
# Wall Street 10 Prompt Builder
# ---------------------------------------------------------------------------
WALL_STREET_10_PROMPT = """You are a team of 10 elite Wall Street analysts, each representing a different institutional perspective. Analyze {ticker} using the comprehensive quantitative data below.
ALL numbers are pre-computed by our quantitative engine. DO NOT recalculate or invent new numbers. Your job is to INTERPRET these numbers from each firm's unique analytical lens.
{context}
═══════════════════════════════════════════════════════════════
Produce a JSON object with exactly these 10 keys. Each value is a markdown string (2-4 paragraphs with bullet points). Be specific — cite the actual numbers from the data above.
{{
"executive_summary": "2-3 sentence overall verdict with a conviction rating (Strong Buy / Buy / Hold / Sell / Strong Sell) and 12-month outlook",
"goldman_sachs": "**Goldman Sachs — Investment Conviction Framework**\\nConviction rating, key thesis, catalysts, and price target rationale. Reference DCF valuation, analyst consensus, and current multiples.",
"morgan_stanley": "**Morgan Stanley — Scenario Analysis**\\nBull/Base/Bear cases with specific price targets from DCF. Probability-weight each scenario. Key swing factors.",
"jp_morgan": "**JP Morgan — Sector Relative Value**\\nHow does {ticker} compare to sector peers on P/E, P/S, EV/EBITDA? Premium/discount justified? Sector rotation implications.",
"blackrock": "**BlackRock — Risk Factor Decomposition**\\nSystematic vs. idiosyncratic risk. Altman Z interpretation, leverage analysis, red flags assessment. Downside protection.",
"bridgewater": "**Bridgewater — Macro Overlay**\\nRate sensitivity (via beta, D/E), currency exposure, inflation hedge characteristics. Where in the economic cycle does this company perform best?",
"berkshire": "**Berkshire Hathaway — Intrinsic Value & Moat**\\nDurable competitive advantage? Pricing power (gross margin trend)? Management quality (capital allocation via FCF, buybacks, ROE). Would Buffett buy this?",
"citadel": "**Citadel — Alpha Signal Identification**\\nYoY anomalies, earnings quality (OCF vs NI via F-Score), accounting signals. Where is the market mispricing this stock?",
"two_sigma": "**Two Sigma — Quantitative Quality Score**\\nF-Score {fscore}/9 assessment. DuPont decomposition quality. Trend stability. Statistical edge in current valuation.",
"elliott": "**Elliott Management — Shareholder Value & Activism**\\nCapital return efficiency (FCF yield, dividend, buybacks). Is management maximizing shareholder value? What would an activist push for?"
}}
CRITICAL RULES:
- Output ONLY the JSON object. No markdown fences, no commentary before/after.
- Each section must reference specific numbers from the data.
- Be analytical and actionable, not generic.
- Answer in English.
"""
def build_institutional_prompt(ticker: str, context: str, fscore: int = 0) -> str:
"""Build the Wall Street 10 mega-prompt with pre-computed data injected."""
return WALL_STREET_10_PROMPT.format(
ticker=ticker.upper(),
context=context,
fscore=fscore,
)