From 8fe3aaf771dc62278fbd620ea61aa293d2f4b4b8 Mon Sep 17 00:00:00 2001 From: shawnkim1997 Date: Sun, 29 Mar 2026 17:36:56 +0100 Subject: [PATCH] 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 --- .../apps/web/src/app/components/sidebar.tsx | 1 + .../apps/web/src/app/report/page.tsx | 1221 +++++++++++++++++ atlas-terminal/server/routers/analysis.py | 139 ++ atlas-terminal/server/routers/edgar.py | 13 + .../server/services/institutional_report.py | 325 +++++ atlas-terminal/server/services/sec_parser.py | 124 +- 6 files changed, 1808 insertions(+), 15 deletions(-) create mode 100644 atlas-terminal/apps/web/src/app/report/page.tsx create mode 100644 atlas-terminal/server/services/institutional_report.py diff --git a/atlas-terminal/apps/web/src/app/components/sidebar.tsx b/atlas-terminal/apps/web/src/app/components/sidebar.tsx index 9773a77..2ed3376 100644 --- a/atlas-terminal/apps/web/src/app/components/sidebar.tsx +++ b/atlas-terminal/apps/web/src/app/components/sidebar.tsx @@ -16,6 +16,7 @@ const NAV_ITEMS = [ { href: "/screener", label: "Screener", icon: "🎯" }, { href: "/portfolio", label: "Portfolio", icon: "💼" }, { href: "/filings", label: "Filings", icon: "📑" }, + { href: "/report", label: "Report", icon: "🏦" }, ]; export function Sidebar() { diff --git a/atlas-terminal/apps/web/src/app/report/page.tsx b/atlas-terminal/apps/web/src/app/report/page.tsx new file mode 100644 index 0000000..513e6be --- /dev/null +++ b/atlas-terminal/apps/web/src/app/report/page.tsx @@ -0,0 +1,1221 @@ +"use client"; + +import { useCallback, useState } from "react"; +import { + BarChart, Bar, LineChart, Line, XAxis, YAxis, CartesianGrid, + Tooltip, ResponsiveContainer, Cell, Legend, ReferenceLine, +} from "recharts"; +import { useTicker } from "../lib/use-ticker"; + +/* ═══════════════════════════════════════════════════════════════════ + Design Tokens — "Morgan Stanley Blue" + ═══════════════════════════════════════════════════════════════════ */ +const C = { + navy: "#1B2A4A", blue: "#2E5B9A", gold: "#C4A35A", + green: "#2D8B5E", red: "#C0392B", gray: "#6B7B8D", + lightGray: "#E8ECF0", bg: "#FFFFFF", text: "#1A1A2E", muted: "#6B7B8D", +}; + +/* ═══════════════════════════════════════════════════════════════════ + Types + ═══════════════════════════════════════════════════════════════════ */ +interface FinancialPeriod { [key: string]: any } + +interface ResearchDash { + ticker: string; fscore_total: number; + fscore_criteria: { key: string; label: string; history: { year: number; pass_flag: boolean }[] }[]; + dupont_tree?: { root: { value: number }; npm: { value: number; trend: string }; asset_turnover: { value: number; trend: string }; equity_mult: { value: number; trend: string } }; + sankey: any; waterfall: { id: string; label: string; value: number; cumulative: number; step_type: string }[]; + anomalies: { account_key: string; display_name: string; change_pct: number; direction: string }[]; +} + +interface InstitutionalData { ticker: string; sections: Record; quant_context: string } + +interface DCFResult { base: number | null; bull: number | null; bear: number | null; current_price: number | null; scenarios: Record } +interface SensitivityResult { wacc_values: number[]; tg_values: number[]; matrix: (number | null)[][] } +interface MonteCarloResult { histogram: { counts: number[]; bin_edges: number[] }; mean: number | null; median: number | null; percentile_5: number | null; percentile_95: number | null; upside_pct: number | null; current_price: number | null } +interface TornadoItem { variable: string; low: number; high: number; base: number } +interface ReverseDCFResult { implied_growth: number | null; current_price: number | null } +interface ConsensusData { target_mean: number | null; target_high: number | null; target_low: number | null; target_median: number | null; recommendation: string; num_analysts: number } +interface PeerData { ticker: string; sector: string; industry: string; averages: Record; peers: Record[] } +interface EarningsHistory { date: string; eps_actual: number | null; eps_estimate: number | null; surprise: number } +interface QuarterlyEarnings { period: string; revenue: number | null; earnings: number | null } +interface HealthData { dupont: Record; altman_z: number | null; current_ratio: number | null; interest_coverage: number | null; debt_to_equity: number | null; red_flags: string[] } +interface TechnicalData { [key: string]: any } + +/* ═══════════════════════════════════════════════════════════════════ + Utility + ═══════════════════════════════════════════════════════════════════ */ +function fmtB(v: number | null | undefined): string { + if (v == null || isNaN(v)) return "N/A"; + if (Math.abs(v) >= 1e12) return `$${(v / 1e12).toFixed(1)}T`; + if (Math.abs(v) >= 1e9) return `$${(v / 1e9).toFixed(1)}B`; + if (Math.abs(v) >= 1e6) return `$${(v / 1e6).toFixed(0)}M`; + return `$${v.toFixed(0)}`; +} +function fmtPct(v: number | null | undefined): string { + if (v == null || isNaN(v)) return "N/A"; + return `${v >= 0 ? "+" : ""}${v.toFixed(1)}%`; +} +function fmtPrice(v: number | null | undefined): string { + if (v == null || isNaN(v)) return "N/A"; + return `$${v.toFixed(2)}`; +} +function getValue(data: Record, key: string): number | null { + for (const k of key.split("|")) { const v = data[k.trim()]; if (v != null && typeof v === "number" && !isNaN(v)) return v; } + return null; +} +async function fetchJson(url: string, opts?: RequestInit) { + try { const r = await fetch(url, opts); return r.ok ? r.json() : null; } catch { return null; } +} +function renderMarkdown(text: string) { + return text.replace(/\*\*(.*?)\*\*/g, "$1").replace(/\n- /g, "
• ").replace(/\n\* /g, "
• ").replace(/\n/g, "
"); +} + +/* ═══════════════════════════════════════════════════════════════════ + SECTION COMPONENTS + ═══════════════════════════════════════════════════════════════════ */ + +function CoverPage({ ticker, info, consensus, dcf }: { ticker: string; info: Record; consensus: ConsensusData | null; dcf: DCFResult | null }) { + const now = new Date(); + const dateStr = now.toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" }); + const price = info.currentPrice || info.regularMarketPrice || 0; + const target = consensus?.target_mean ?? info.targetMeanPrice ?? 0; + const upside = price > 0 && target > 0 ? ((target - price) / price) * 100 : 0; + const rec = consensus?.recommendation?.toUpperCase() || "N/A"; + const recColor = rec.includes("BUY") || rec.includes("STRONG") ? C.green : rec.includes("SELL") ? C.red : C.gold; + + return ( +
+
+
ATLAS TERMINAL
+
Institutional Equity Research
+
+
+

{info.longName || ticker}

+

{ticker} — {info.exchange || ""}

+

{info.sector || ""} • {info.industry || ""}

+ + {/* Rating Badge */} +
+
Consensus
+
{rec}
+
+
+
Target
+
{fmtPrice(target)}
+
+
+
Upside
+
= 0 ? C.green : C.red }}>{fmtPct(upside)}
+
+
+ +
+ {[ + { label: "Price", value: fmtPrice(price) }, + { label: "Market Cap", value: fmtB(info.marketCap) }, + { label: "DCF Fair Value", value: dcf?.base != null ? fmtPrice(dcf.base) : "N/A" }, + { label: "Analysts", value: consensus ? `${consensus.num_analysts}` : "N/A" }, + ].map((k) => ( +
+
{k.label}
+
{k.value}
+
+ ))} +
+
+

{dateStr}

+
+ ); +} + +function TableOfContents({ hasInstitutional }: { hasInstitutional: boolean }) { + const sections = [ + "Investment Snapshot & Key Metrics", + "Company Profile", + "Financial Performance", + "Balance Sheet & Cash Flow", + "Quality Assessment — F-Score & DuPont", + "Operating Income Bridge", + "YoY Anomalies", + "Valuation — DCF 3-Scenario Analysis", + "Sensitivity Matrix & Monte Carlo", + "Peer Comparison", + "Earnings Analysis", + ...(hasInstitutional ? [ + "Executive Summary (AI)", + "Goldman Sachs / Morgan Stanley / JP Morgan", + "BlackRock / Bridgewater / Berkshire Hathaway", + "Citadel / Two Sigma / Elliott Mgmt / ARK Invest", + ] : []), + "Disclaimer", + ]; + return ( +
+
Table of Contents
+
+ {sections.map((s, i) => ( +
+ {i + 1}. + {s} + + {i + 2} +
+ ))} +
+
+ ); +} + +function KpiRow({ info }: { info: Record }) { + const kpis = [ + { label: "Revenue", value: fmtB(info.totalRevenue), sub: fmtPct((info.revenueGrowth || 0) * 100) }, + { label: "Net Income", value: fmtB(info.netIncomeToCommon), sub: `Margin ${((info.profitMargins || 0) * 100).toFixed(1)}%` }, + { label: "Free Cash Flow", value: fmtB(info.freeCashflow), sub: `Yield ${info.freeCashflow && info.marketCap ? ((info.freeCashflow / info.marketCap) * 100).toFixed(1) : "N/A"}%` }, + { label: "ROE", value: `${((info.returnOnEquity || 0) * 100).toFixed(1)}%`, sub: `ROA ${((info.returnOnAssets || 0) * 100).toFixed(1)}%` }, + { label: "P/E (TTM)", value: info.trailingPE ? `${info.trailingPE.toFixed(1)}x` : "N/A", sub: `Fwd ${info.forwardPE ? info.forwardPE.toFixed(1) + "x" : "N/A"}` }, + { label: "EV/EBITDA", value: info.enterpriseToEbitda ? `${info.enterpriseToEbitda.toFixed(1)}x` : "N/A", sub: `D/E ${info.debtToEquity ?? "N/A"}` }, + ]; + return ( +
+ {kpis.map((k) => ( +
+
{k.label}
+
{k.value}
+
{k.sub}
+
+ ))} +
+ ); +} + +function CompanyProfile({ info }: { info: Record }) { + const desc = info.longBusinessSummary || info.description || ""; + if (!desc) return null; + const stats = [ + { l: "Employees", v: info.fullTimeEmployees?.toLocaleString() || "N/A" }, + { l: "Country", v: info.country || "N/A" }, + { l: "Founded", v: info.companyOfficers?.[0]?.fiscalYear || "N/A" }, + { l: "52W High", v: fmtPrice(info.fiftyTwoWeekHigh) }, + { l: "52W Low", v: fmtPrice(info.fiftyTwoWeekLow) }, + { l: "Beta", v: info.beta ? info.beta.toFixed(2) : "N/A" }, + { l: "Avg Volume", v: info.averageVolume ? `${(info.averageVolume / 1e6).toFixed(1)}M` : "N/A" }, + { l: "Dividend Yield", v: info.dividendYield ? `${(info.dividendYield * 100).toFixed(2)}%` : "N/A" }, + ]; + return ( +
+

Company Profile

+

{desc.length > 800 ? desc.slice(0, 800) + "..." : desc}

+
+ {stats.map((s) => ( +
+ {s.l} + {s.v} +
+ ))} +
+
+ ); +} + +function FinancialCharts({ statements }: { statements: { income_statement?: FinancialPeriod[]; balance_sheet?: FinancialPeriod[]; cash_flow?: FinancialPeriod[] } }) { + const is = statements.income_statement; + if (!is || is.length === 0) return null; + + const chartData = is.slice(0, 5).reverse().map((p) => { + const rev = getValue(p, "TotalRevenue|Total Revenue|Revenue"); + const ni = getValue(p, "NetIncome|Net Income|Net Income Common Stockholders"); + const gp = getValue(p, "GrossProfit|Gross Profit"); + const op = getValue(p, "OperatingIncome|Operating Income"); + const yr = p.asOfDate || p.fiscalYear || p.year || ""; + return { + year: typeof yr === "string" ? yr.slice(0, 4) : String(yr), + revenue: rev ? rev / 1e9 : 0, netIncome: ni ? ni / 1e9 : 0, + grossMargin: rev && gp ? (gp / rev) * 100 : 0, + opMargin: rev && op ? (op / rev) * 100 : 0, + netMargin: rev && ni ? (ni / rev) * 100 : 0, + }; + }); + + return ( +
+

Income Statement Trends

+
+
+

Revenue & Net Income ($B)

+ + + + + + + + + + + +
+
+

Margin Trends (%)

+ + + + + + + + + + + + +
+
+
+ ); +} + +function BalanceSheetCashFlow({ statements }: { statements: { balance_sheet?: FinancialPeriod[]; cash_flow?: FinancialPeriod[] } }) { + const bs = statements.balance_sheet; + const cf = statements.cash_flow; + if ((!bs || bs.length === 0) && (!cf || cf.length === 0)) return null; + + const bsData = (bs || []).slice(0, 5).reverse().map((p) => { + const ta = getValue(p, "TotalAssets|Total Assets"); + const tl = getValue(p, "TotalLiabilitiesNetMinorityInterest|Total Liabilities Net Minority Interest|TotalLiab|Total Liabilities"); + const te = getValue(p, "StockholdersEquity|Stockholders Equity|TotalStockholderEquity|Total Stockholder Equity"); + const cash = getValue(p, "CashAndCashEquivalents|Cash And Cash Equivalents|CashCashEquivalentsAndShortTermInvestments"); + const yr = p.asOfDate || p.fiscalYear || ""; + return { year: typeof yr === "string" ? yr.slice(0, 4) : String(yr), assets: ta ? ta / 1e9 : 0, liabilities: tl ? tl / 1e9 : 0, equity: te ? te / 1e9 : 0, cash: cash ? cash / 1e9 : 0 }; + }); + + const cfData = (cf || []).slice(0, 5).reverse().map((p) => { + const ocf = getValue(p, "OperatingCashFlow|Operating Cash Flow|CashFlowFromContinuingOperatingActivities"); + const capex = getValue(p, "CapitalExpenditure|Capital Expenditure"); + const fcf = getValue(p, "FreeCashFlow|Free Cash Flow") ?? (ocf != null && capex != null ? ocf + capex : null); + const yr = p.asOfDate || p.fiscalYear || ""; + return { year: typeof yr === "string" ? yr.slice(0, 4) : String(yr), ocf: ocf ? ocf / 1e9 : 0, capex: capex ? Math.abs(capex) / 1e9 : 0, fcf: fcf ? fcf / 1e9 : 0 }; + }); + + return ( +
+
+ {bsData.length > 0 && ( +
+

Balance Sheet ($B)

+ + + + + + + + + + + + +
+ )} + {cfData.length > 0 && ( +
+

Cash Flow ($B)

+ + + + + + + + + + + + +
+ )} +
+
+ ); +} + +function QualityScores({ research, health }: { research: ResearchDash | null; health: HealthData | null }) { + if (!research && !health) return null; + const { fscore_total = 0, fscore_criteria = [], dupont_tree } = research || {}; + const altmanZ = health?.altman_z; + const zLabel = altmanZ == null ? "N/A" : altmanZ > 2.99 ? "Safe" : altmanZ > 1.81 ? "Gray Zone" : "Distress"; + const zColor = altmanZ == null ? C.muted : altmanZ > 2.99 ? C.green : altmanZ > 1.81 ? C.gold : C.red; + + return ( +
+

Quality Assessment

+
+ {/* F-Score */} +
+

Piotroski F-Score: {fscore_total}/9

+
+ {Array.from({ length: 9 }).map((_, i) => ( +
+ {i + 1} +
+ ))} +
+
+ {fscore_criteria.map((c) => ( +
+ {c.history[0]?.pass_flag ? "\u2713" : "\u2717"} + {c.label} +
+ ))} +
+
+ + {/* Altman Z + Risk */} +
+

Financial Health

+
+
+
Altman Z-Score
+
{altmanZ?.toFixed(2) ?? "N/A"}
+
{zLabel}
+
+
+ {[ + { l: "Current Ratio", v: health?.current_ratio?.toFixed(2) }, + { l: "Interest Coverage", v: health?.interest_coverage?.toFixed(1) }, + { l: "Debt/Equity", v: health?.debt_to_equity?.toFixed(2) }, + ].map((r) => ( +
+ {r.l} + {r.v ?? "N/A"} +
+ ))} +
+
+ {health?.red_flags && health.red_flags.length > 0 && ( +
+
Red Flags
+ {health.red_flags.slice(0, 4).map((f, i) => ( +
• {f}
+ ))} +
+ )} +
+ + {/* DuPont */} + {dupont_tree && ( +
+

DuPont ROE Decomposition

+
+
+
Return on Equity
+
{dupont_tree.root.value.toFixed(1)}%
+
+
+ {[ + { label: "Net Margin", val: `${dupont_tree.npm.value.toFixed(1)}%`, trend: dupont_tree.npm.trend }, + { label: "Asset T/O", val: `${dupont_tree.asset_turnover.value.toFixed(2)}x`, trend: dupont_tree.asset_turnover.trend }, + { label: "Equity Mult", val: `${dupont_tree.equity_mult.value.toFixed(2)}x`, trend: dupont_tree.equity_mult.trend }, + ].map((d) => ( +
+
{d.label}
+
{d.val}
+
+ {d.trend === "up" ? "\u25B2" : d.trend === "down" ? "\u25BC" : "\u25C6"} +
+
+ ))} +
+
+
+ )} +
+
+ ); +} + +function WaterfallChart({ waterfall }: { waterfall: ResearchDash["waterfall"] }) { + if (!waterfall || waterfall.length === 0) return null; + const data = waterfall.map((w) => ({ name: w.label.length > 14 ? w.label.slice(0, 14) + ".." : w.label, value: w.value / 1e9, fill: w.step_type === "total" ? C.navy : w.value >= 0 ? C.green : C.red })); + return ( +
+

Operating Income Bridge ($B)

+ + + + + + + {data.map((d, i) => )} + + +
+ ); +} + +function AnomalyTable({ anomalies }: { anomalies: ResearchDash["anomalies"] }) { + if (!anomalies || anomalies.length === 0) return null; + return ( +
+

YoY Anomalies (>30% Change)

+ + + + + + + + + + {anomalies.slice(0, 12).map((a) => ( + + + + + + ))} + +
Line ItemYoY ChangeDirection
{a.display_name}{a.change_pct != null ? `${a.change_pct > 0 ? "+" : ""}${a.change_pct.toFixed(1)}%` : "N/A"}{a.direction === "up" ? "\u25B2" : "\u25BC"}
+
+ ); +} + +/* ── Valuation Section ── */ +function DCFValuationSection({ dcf, reverseDcf, info }: { dcf: DCFResult | null; reverseDcf: ReverseDCFResult | null; info: Record }) { + if (!dcf) return null; + const price = dcf.current_price || info.currentPrice || 0; + const scenarios = [ + { label: "Bear", value: dcf.bear, color: C.red, upside: dcf.scenarios?.bear?.upside }, + { label: "Base", value: dcf.base, color: C.blue, upside: dcf.scenarios?.base?.upside }, + { label: "Bull", value: dcf.bull, color: C.green, upside: dcf.scenarios?.bull?.upside }, + ]; + const chartData = scenarios.map((s) => ({ name: s.label, value: s.value || 0, fill: s.color })); + + return ( +
+

DCF 3-Scenario Valuation

+
+
+ + + + `$${v}`} /> + + fmtPrice(v)} /> + {chartData.map((d, i) => )} + {price > 0 && } + + +
+
+
+ {scenarios.map((s) => ( +
+
+
{s.label} Case
+
{s.value != null ? fmtPrice(s.value) : "N/A"}
+
+
+
vs Current
+
= 0 ? C.green : C.red }}> + {s.upside != null ? fmtPct(s.upside) : "N/A"} +
+
+
+ ))} +
+ {reverseDcf?.implied_growth != null && ( +
+
Market Implied Growth Rate (Reverse DCF)
+
{reverseDcf.implied_growth.toFixed(1)}%
+
+ )} +
+
+
+ ); +} + +function SensitivityHeatmap({ sensitivity, currentPrice }: { sensitivity: SensitivityResult | null; currentPrice: number }) { + if (!sensitivity || !sensitivity.matrix || sensitivity.matrix.length === 0) return null; + const { wacc_values, tg_values, matrix } = sensitivity; + const getColor = (v: number | null) => { + if (v == null) return C.lightGray; + const diff = currentPrice > 0 ? (v - currentPrice) / currentPrice : 0; + if (diff > 0.3) return "#1a7a3e"; + if (diff > 0.1) return "#4CAF50"; + if (diff > -0.1) return "#FFF9C4"; + if (diff > -0.3) return "#FF8A65"; + return "#E53935"; + }; + return ( +
+

Sensitivity Matrix — WACC vs Terminal Growth

+
+ + + + + {tg_values.map((tg) => ( + + ))} + + + + {matrix.map((row, ri) => ( + + + {row.map((cell, ci) => ( + + ))} + + ))} + +
WACC \ TG{tg.toFixed(1)}%
{wacc_values[ri]?.toFixed(1)}% 0.1 ? "#fff" : C.navy }}> + {cell != null ? `$${cell.toFixed(0)}` : "-"} +
+
+

Green = above current price ({fmtPrice(currentPrice)}), Red = below current price

+
+ ); +} + +function MonteCarloSection({ mc }: { mc: MonteCarloResult | null }) { + if (!mc || !mc.histogram) return null; + const { counts, bin_edges } = mc.histogram; + const data = counts.map((c, i) => ({ + range: `$${bin_edges[i]?.toFixed(0)}`, + count: c, + fill: mc.current_price != null && bin_edges[i] != null && bin_edges[i]! >= mc.current_price ? C.green : C.red, + })); + return ( +
+

Monte Carlo Simulation (5,000 runs)

+
+
+ + + + + + + {data.map((d, i) => )} + {mc.current_price != null && } + + +
+
+ {[ + { l: "Mean Value", v: fmtPrice(mc.mean) }, + { l: "Median Value", v: fmtPrice(mc.median) }, + { l: "5th Percentile", v: fmtPrice(mc.percentile_5) }, + { l: "95th Percentile", v: fmtPrice(mc.percentile_95) }, + { l: "Current Price", v: fmtPrice(mc.current_price) }, + { l: "P(Upside)", v: mc.upside_pct != null ? `${mc.upside_pct.toFixed(1)}%` : "N/A" }, + ].map((s) => ( +
+
{s.l}
+
{s.v}
+
+ ))} +
+
+
+ ); +} + +function TornadoSection({ tornado }: { tornado: TornadoItem[] | null }) { + if (!tornado || tornado.length === 0) return null; + const data = tornado.slice(0, 6).map((t) => ({ name: t.variable, low: t.low - t.base, high: t.high - t.base })); + return ( +
+

Tornado Sensitivity (±10% Variable Shift)

+ + + + `$${v > 0 ? "+" : ""}${v.toFixed(0)}`} /> + + `$${v > 0 ? "+" : ""}${v.toFixed(2)}`} /> + + + + + +
+ ); +} + +function PeerComparisonSection({ peers }: { peers: PeerData | null }) { + if (!peers || !peers.peers || peers.peers.length === 0) return null; + return ( +
+

Peer Valuation Comparison — {peers.sector} / {peers.industry}

+
+ + + + {["Ticker", "Mkt Cap", "Trailing P/E", "Forward P/E", "P/B", "P/S", "EV/EBITDA"].map((h) => ( + + ))} + + + + {peers.peers.slice(0, 10).map((p, i) => { + const isSelf = p.ticker === peers.ticker; + return ( + + + + + + + + + + ); + })} + {/* Averages */} + + + + + + + + +
{h}
{p.ticker}{fmtB(p.market_cap)}{p.trailing_pe != null ? `${p.trailing_pe.toFixed(1)}x` : "-"}{p.forward_pe != null ? `${p.forward_pe.toFixed(1)}x` : "-"}{p.pb != null ? `${p.pb.toFixed(1)}x` : "-"}{p.ps != null ? `${p.ps.toFixed(1)}x` : "-"}{p.ev_ebitda != null ? `${p.ev_ebitda.toFixed(1)}x` : "-"}
Avg + {peers.averages.pe != null ? `${peers.averages.pe.toFixed(1)}x` : "-"} + {peers.averages.pb != null ? `${peers.averages.pb.toFixed(1)}x` : "-"}{peers.averages.ps != null ? `${peers.averages.ps.toFixed(1)}x` : "-"}{peers.averages.ev_ebitda != null ? `${peers.averages.ev_ebitda.toFixed(1)}x` : "-"}
+
+
+ ); +} + +function EarningsSection({ history, quarterly }: { history: EarningsHistory[] | null; quarterly: QuarterlyEarnings[] | null }) { + if ((!history || history.length === 0) && (!quarterly || quarterly.length === 0)) return null; + + const epsData = (history || []).slice(0, 12).reverse().map((h) => ({ + date: h.date?.slice(0, 7) || "", + actual: h.eps_actual, estimate: h.eps_estimate, + surprise: h.surprise, + fill: h.surprise >= 0 ? C.green : C.red, + })); + + const qData = (quarterly || []).slice(0, 8).reverse().map((q) => ({ + period: q.period?.slice(0, 7) || "", + revenue: q.revenue ? q.revenue / 1e9 : 0, + earnings: q.earnings ? q.earnings / 1e9 : 0, + })); + + const beatCount = epsData.filter((d) => d.surprise >= 0).length; + const beatRate = epsData.length > 0 ? (beatCount / epsData.length * 100).toFixed(0) : "N/A"; + + return ( +
+

Earnings Analysis

+
+ {epsData.length > 0 && ( +
+

EPS Beat/Miss History (Beat Rate: {beatRate}%)

+ + + + + + + + {epsData.map((d, i) => )} + + + + + +
+ )} + {qData.length > 0 && ( +
+

Quarterly Revenue & Earnings ($B)

+ + + + + + + + + + + +
+ )} +
+
+ ); +} + +function TechnicalSnapshot({ technical, info }: { technical: TechnicalData | null; info: Record }) { + if (!technical) return null; + const rsi = typeof technical.rsi_14 === "number" ? technical.rsi_14 : null; + const sma50 = technical.sma?.sma_50 ?? null; + const sma200 = technical.sma?.sma_200 ?? null; + const macdVal = typeof technical.macd === "object" ? technical.macd?.macd : technical.macd; + const macdSig = typeof technical.macd === "object" ? technical.macd?.signal : technical.macd_signal; + const bbUpper = technical.bollinger_bands?.upper ?? technical.bb_upper ?? null; + const bbLower = technical.bollinger_bands?.lower ?? technical.bb_lower ?? null; + const atr = typeof technical.atr_14 === "number" ? technical.atr_14 : typeof technical.atr === "number" ? technical.atr : null; + + const metrics = [ + { l: "RSI (14)", v: rsi?.toFixed(1), color: rsi != null && rsi > 70 ? C.red : rsi != null && rsi < 30 ? C.green : C.navy }, + { l: "SMA 50", v: fmtPrice(sma50), color: C.navy }, + { l: "SMA 200", v: fmtPrice(sma200), color: C.navy }, + { l: "MACD", v: typeof macdVal === "number" ? macdVal.toFixed(3) : "N/A", color: typeof macdVal === "number" && macdVal > 0 ? C.green : C.red }, + { l: "Signal", v: typeof macdSig === "number" ? macdSig.toFixed(3) : "N/A", color: C.muted }, + { l: "BB Upper", v: fmtPrice(bbUpper), color: C.navy }, + { l: "BB Lower", v: fmtPrice(bbLower), color: C.navy }, + { l: "ATR", v: typeof atr === "number" ? atr.toFixed(2) : "N/A", color: C.navy }, + ]; + const price = info.currentPrice || info.regularMarketPrice || 0; + const trend = sma50 && sma200 && sma50 > sma200 ? "Bullish (Golden Cross)" : sma50 && sma200 ? "Bearish (Death Cross)" : "Neutral"; + const trendColor = trend.includes("Bullish") ? C.green : trend.includes("Bearish") ? C.red : C.gold; + + return ( +
+

Technical Summary

+
+
+ {metrics.map((m) => ( +
+ {m.l} + {m.v ?? "N/A"} +
+ ))} +
+
+
+
Overall Trend
+
{trend}
+
+ Price {fmtPrice(price)} is {price > (sma50 || 0) ? "above" : "below"} SMA50 ({fmtPrice(sma50)}) + and {price > (sma200 || 0) ? "above" : "below"} SMA200 ({fmtPrice(sma200)}) +
+
+ RSI at {rsi?.toFixed(1) ?? "N/A"} — {rsi != null && rsi > 70 ? "Overbought" : rsi != null && rsi < 30 ? "Oversold" : "Neutral"} +
+
+
+
+
+ ); +} + +/* ── Wall Street 10 ── */ +function WallStreet10Section({ sections }: { sections: Record }) { + const order: [string, string, string][] = [ + ["executive_summary", "Executive Summary", "\uD83C\uDFAF"], + ["goldman_sachs", "Goldman Sachs", "\uD83C\uDFDB\uFE0F"], + ["morgan_stanley", "Morgan Stanley", "\uD83D\uDCCA"], + ["jp_morgan", "JP Morgan", "\uD83C\uDFE2"], + ["blackrock", "BlackRock", "\uD83D\uDEE1\uFE0F"], + ["bridgewater", "Bridgewater", "\uD83C\uDF0D"], + ["berkshire", "Berkshire Hathaway", "\uD83E\uDDCA"], + ["citadel", "Citadel", "\u26A1"], + ["two_sigma", "Two Sigma", "\uD83D\uDD22"], + ["elliott", "Elliott Management", "\uD83D\uDCB0"], + ["ark_invest", "ARK Invest", "\uD83D\uDE80"], + ]; + return ( + <> + {order.map(([key, name, icon]) => { + const content = sections[key]; + if (!content) return null; + const isExec = key === "executive_summary"; + return ( +
+

{icon} {name}

+
+
+ ); + })} + + ); +} + +function Disclaimer() { + return ( +
+
+

Disclaimer

+

+ This report was generated by ATLAS Terminal's AI analysis engine using Gemini AI and pre-computed + quantitative data from public sources (SEC EDGAR, Yahoo Finance). This is NOT investment advice. + All financial data is sourced from public filings and market data providers and may contain + errors or be outdated. The AI-generated perspectives are simulated institutional viewpoints + and do not represent the actual views of any named financial institution. Past performance + does not guarantee future results. Always consult a qualified financial advisor before making + investment decisions. +

+
+
ATLAS TERMINAL
+
Advanced Terminal for Liquid Asset Surveillance
+
Generated {new Date().toISOString().slice(0, 16).replace("T", " ")} UTC
+
+
+ ); +} + +/* ═══════════════════════════════════════════════════════════════════ + MAIN REPORT PAGE + ═══════════════════════════════════════════════════════════════════ */ +export default function ReportPage() { + const { ticker } = useTicker(); + const [info, setInfo] = useState>({}); + const [stmts, setStmts] = useState<{ income_statement?: FinancialPeriod[]; balance_sheet?: FinancialPeriod[]; cash_flow?: FinancialPeriod[] }>({}); + const [research, setResearch] = useState(null); + const [health, setHealth] = useState(null); + const [consensus, setConsensus] = useState(null); + const [peers, setPeers] = useState(null); + const [epsHistory, setEpsHistory] = useState(null); + const [quarterly, setQuarterly] = useState(null); + const [technical, setTechnical] = useState(null); + const [dcf, setDcf] = useState(null); + const [sensitivity, setSensitivity] = useState(null); + const [monteCarlo, setMonteCarlo] = useState(null); + const [tornado, setTornado] = useState(null); + const [reverseDcf, setReverseDcf] = useState(null); + const [institutional, setInstitutional] = useState(null); + const [loading, setLoading] = useState(false); + const [progress, setProgress] = useState(""); + const [error, setError] = useState(""); + + const generateReport = useCallback(async () => { + setLoading(true); setError(""); setProgress("Phase 1/4 — Gathering market data (9 parallel requests)..."); + const apiKey = localStorage.getItem("atlas_gemini_key") || ""; + + try { + /* ── Phase 1: All GET endpoints ── */ + const [rOv, rSec, rHi, rSt, rRs, rHl, rCo, rPr, rEh, rEq, rTe] = await Promise.allSettled([ + fetchJson(`/api/market/overview/${ticker}`), + fetchJson(`/api/market/sector/${ticker}`), + fetchJson(`/api/financials/${ticker}/highlights`), + fetchJson(`/api/financials/${ticker}/statements`), + fetchJson(`/api/research/dashboard/${ticker}`), + fetchJson(`/api/market/health/${ticker}`), + fetchJson(`/api/valuation/consensus/${ticker}`), + fetchJson(`/api/market/peers/${ticker}`), + fetchJson(`/api/earnings/${ticker}/history`), + fetchJson(`/api/earnings/${ticker}/quarterly`), + fetchJson(`/api/technical/${ticker}/indicators`), + ]); + + // Merge overview + sector + highlights into a single info object + const ov = rOv.status === "fulfilled" ? rOv.value : null; + const sec = rSec.status === "fulfilled" ? rSec.value : null; + const hi = rHi.status === "fulfilled" ? rHi.value : null; + const mergedInfo: Record = {}; + if (ov?.data) Object.assign(mergedInfo, { + longName: ov.data.name, sector: ov.data.sector, industry: ov.data.industry, + marketCap: ov.data.market_cap, trailingPE: ov.data.pe_ratio, + dividendYield: ov.data.dividend_yield ? ov.data.dividend_yield / 100 : 0, + beta: ov.data.beta, fiftyTwoWeekHigh: ov.data.high_52w, fiftyTwoWeekLow: ov.data.low_52w, + currentPrice: ov.data.price, longBusinessSummary: ov.data.description, + }); + if (sec) Object.assign(mergedInfo, { + sector: sec.sector || mergedInfo.sector, industry: sec.industry || mergedInfo.industry, + marketCap: sec.market_cap || mergedInfo.marketCap, trailingPE: sec.pe_ratio || mergedInfo.trailingPE, + forwardPE: sec.forward_pe, currentPrice: sec.current_price || mergedInfo.currentPrice, + targetMeanPrice: sec.target_mean_price, fiftyTwoWeekHigh: sec.fifty_two_week_high || mergedInfo.fiftyTwoWeekHigh, + fiftyTwoWeekLow: sec.fifty_two_week_low || mergedInfo.fiftyTwoWeekLow, + fullTimeEmployees: sec.employees, exchange: sec.exchange, + debtToEquity: sec.debt_to_equity, + }); + if (hi) Object.assign(mergedInfo, { + totalRevenue: hi.revenue, profitMargins: hi.profit_margin, + returnOnEquity: hi.roe, returnOnAssets: hi.roa, freeCashflow: hi.free_cash_flow, + netIncomeToCommon: hi.revenue && hi.profit_margin ? hi.revenue * hi.profit_margin : undefined, + enterpriseToEbitda: hi.ebitda && hi.revenue ? undefined : undefined, + revenueGrowth: hi.revenue_growth, debtToEquity: hi.debt_to_equity ?? mergedInfo.debtToEquity, + operatingMargins: hi.operating_margin, grossMargins: hi.gross_margin, + }); + if (Object.keys(mergedInfo).length > 0) setInfo(mergedInfo); + + const st = rSt.status === "fulfilled" ? rSt.value : null; + if (st) setStmts(st); + const rs = rRs.status === "fulfilled" ? rRs.value : null; + if (rs) setResearch(rs); + const hl = rHl.status === "fulfilled" ? rHl.value : null; + if (hl) setHealth(hl); + const co = rCo.status === "fulfilled" ? rCo.value : null; + if (co) setConsensus(co); + const pr = rPr.status === "fulfilled" ? rPr.value : null; + if (pr) setPeers(pr); + const eh = rEh.status === "fulfilled" ? rEh.value : null; + if (eh?.history) setEpsHistory(eh.history); + const eq = rEq.status === "fulfilled" ? rEq.value : null; + if (eq?.quarterly) setQuarterly(eq.quarterly); + const te = rTe.status === "fulfilled" ? rTe.value : null; + if (te) setTechnical(te); + + /* ── Phase 2: DCF Inputs ── */ + setProgress("Phase 2/4 — Loading DCF inputs..."); + const [dcfInputs, smartDefaults] = await Promise.allSettled([ + fetchJson(`/api/valuation/dcf-inputs/${ticker}`), + fetchJson(`/api/valuation/smart-defaults/${ticker}`), + ]); + const di = dcfInputs.status === "fulfilled" ? dcfInputs.value : null; + const sd = smartDefaults.status === "fulfilled" ? smartDefaults.value : null; + + if (di && sd && di.fcf && di.shares) { + // smart-defaults returns percentage (e.g. 10 = 10%), but POST endpoints expect decimal (0.10) + const waccDec = (sd.wacc || 9) / 100; + const tgDec = (sd.terminal_growth || 2.5) / 100; + const growthDec = (sd.fcf_growth || 10) / 100; + const params = { + ticker, fcf: di.fcf, base_fcf: di.fcf, shares: di.shares, + total_debt: di.total_debt || 0, cash: di.cash || 0, + wacc: waccDec, terminal_growth: tgDec, fcf_growth: growthDec, + }; + const mcParams = { + ...params, wacc_mean: waccDec, wacc_std: 0.015, + growth_mean: growthDec, growth_std: 0.03, + term_growth: tgDec, n_simulations: 5000, + }; + + /* ── Phase 3: DCF Calculations (5 parallel) ── */ + setProgress("Phase 3/4 — Running valuation models (DCF, Sensitivity, Monte Carlo, Tornado, Reverse DCF)..."); + const [rDcf, rSens, rMc, rTor, rRev] = await Promise.allSettled([ + fetchJson("/api/valuation/dcf", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(params) }), + fetchJson("/api/valuation/sensitivity", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(params) }), + fetchJson("/api/valuation/monte-carlo", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(mcParams) }), + fetchJson("/api/valuation/tornado", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(params) }), + fetchJson("/api/valuation/reverse-dcf", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(params) }), + ]); + + if (rDcf.status === "fulfilled" && rDcf.value) setDcf(rDcf.value); + if (rSens.status === "fulfilled" && rSens.value) setSensitivity(rSens.value); + if (rMc.status === "fulfilled" && rMc.value) setMonteCarlo(rMc.value); + if (rTor.status === "fulfilled" && rTor.value?.data) setTornado(rTor.value.data); + if (rRev.status === "fulfilled" && rRev.value) setReverseDcf(rRev.value); + } + + /* ── Phase 4: Gemini AI ── */ + if (!apiKey) { + setError("Quantitative report generated (20+ sections). Add Gemini API key in Settings for Wall Street 10 AI analysis."); + } else { + setProgress("Phase 4/4 — Generating Wall Street 10 analysis (Gemini AI, ~15s)..."); + const instRes = await fetch("/api/analysis/institutional", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ticker, api_key: apiKey }), + }); + if (instRes.ok) { setInstitutional(await instRes.json()); } + else { const err = await instRes.json().catch(() => ({})); setError(err.detail || "AI analysis failed. Quantitative data available below."); } + } + + setProgress(""); + } catch { setError("Report generation failed. Check your connection."); } + setLoading(false); + }, [ticker]); + + const handlePrint = useCallback(() => { window.print(); }, []); + + const hasReport = institutional && institutional.sections; + const hasData = Object.keys(info).length > 0 || Object.keys(stmts).length > 0 || research != null || dcf != null; + const currentPrice = info.currentPrice || info.regularMarketPrice || dcf?.current_price || 0; + + return ( + <> + {/* ─── Screen UI (hidden when printing) ─── */} +
+
+
+

{ticker} Institutional Report

+

Wall Street 10 — Automated institutional-grade equity research with 20+ sections

+
+
+ {(hasReport || hasData) && ( + + )} + +
+
+ + {error &&
{error}
} + + {loading && ( +
+
{progress}
+
+
+
+
+ )} + + {!hasData && !loading && ( +
+
📊
+

Wall Street 10 Institutional Report

+

+ Generate a comprehensive 20+ page equity research report featuring DCF valuation (3 scenarios), + sensitivity matrix, Monte Carlo simulation, peer comparison, earnings analysis, financial deep-dive, + and perspectives from 10 institutional frameworks. +

+
+ {["DCF 3-Scenario", "Sensitivity Matrix", "Monte Carlo 5K", "Tornado Chart", "Peer Comparison", "Earnings Analysis", "F-Score & DuPont", "Wall Street 10 AI"].map((f) => ( +
{f}
+ ))} +
+

Gemini API key optional — quantitative sections work without it.

+
+ )} +
+ + {/* ─── Printable Report ─── */} + {(hasReport || hasData) && ( +
+ {/* Page 1: Cover */} + + + {/* Page 2: TOC */} + + + {/* Page 3: Investment Snapshot */} +
+
Investment Snapshot & Key Metrics
+ + {institutional?.sections?.executive_summary && ( +
+
+
+ )} + +
+ + {/* Page 4: Financial Performance */} +
+
Financial Performance
+ + +
+ + {/* Page 5: Quality & Risk */} +
+
Quality & Risk Assessment
+ +
+ + {/* Page 6: Waterfall + Anomalies */} + {(research?.waterfall?.length || research?.anomalies?.length) ? ( +
+
Operating Analysis
+ + +
+ ) : null} + + {/* Page 7: DCF Valuation */} + {dcf && ( +
+
Valuation — DCF Analysis
+ +
+ )} + + {/* Page 8: Sensitivity + Monte Carlo */} + {(sensitivity || monteCarlo) && ( +
+
Valuation — Sensitivity & Monte Carlo
+ + +
+ )} + + {/* Page 9: Tornado */} + {tornado && tornado.length > 0 && ( +
+
Valuation — Tornado Sensitivity
+ +
+ )} + + {/* Page 10: Peer Comparison */} + {peers && peers.peers?.length > 0 && ( +
+
Peer Comparison
+ +
+ )} + + {/* Page 11: Earnings */} + {(epsHistory?.length || quarterly?.length) ? ( +
+
Earnings Analysis
+ +
+ ) : null} + + {/* Page 12: Technical */} + {technical && ( +
+
Technical Summary
+ +
+ )} + + {/* Pages 13+: Wall Street 10 (split across pages for readability) */} + {institutional?.sections && Object.keys(institutional.sections).length > 0 && (() => { + const keys = Object.keys(institutional.sections).filter((k) => k !== "executive_summary" && institutional.sections[k]); + const pages: string[][] = []; + for (let i = 0; i < keys.length; i += 3) pages.push(keys.slice(i, i + 3)); + return pages.map((pageKeys, pi) => ( +
+ {pi === 0 &&
Wall Street 10 — Institutional Perspectives
} + {pi > 0 &&
Wall Street 10 — (continued)
} + [k, institutional!.sections[k]]))} /> +
+ )); + })()} + + {/* Last page: Disclaimer */} + +
+ )} + + {/* ─── Print Styles ─── */} + + + ); +} diff --git a/atlas-terminal/server/routers/analysis.py b/atlas-terminal/server/routers/analysis.py index e80bb7b..643fcf1 100644 --- a/atlas-terminal/server/routers/analysis.py +++ b/atlas-terminal/server/routers/analysis.py @@ -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, + } diff --git a/atlas-terminal/server/routers/edgar.py b/atlas-terminal/server/routers/edgar.py index 1ec79c0..5d55041 100644 --- a/atlas-terminal/server/routers/edgar.py +++ b/atlas-terminal/server/routers/edgar.py @@ -34,6 +34,7 @@ async def get_sections( from server.services.sec_parser import ( download_and_extract_all_items, get_10k_sections, + get_sec_filing_url, load_10k_html_slice, ) @@ -45,6 +46,17 @@ async def get_sections( sections = download_and_extract_all_items(ticker.upper(), email) status = "downloaded" html_payload = load_10k_html_slice(ticker.upper()) or "" + + # Resolve actual filing document URL from SEC EDGAR + filing_url = get_sec_filing_url(ticker.upper()) + links = {} + if filing_url: + links["View Original 10-K Filing"] = filing_url + links["SEC EDGAR Filings"] = ( + f"https://www.sec.gov/cgi-bin/browse-edgar?action=getcompany" + f"&CIK={ticker.upper()}&type=10-K&dateb=&owner=include&count=5" + ) + return EdgarSectionsResponse( status=status, item1a=sections.get("item1a", ""), @@ -53,6 +65,7 @@ async def get_sections( item8=sections.get("item8", ""), item9a=sections.get("item9a", ""), html=html_payload, + links=links, ) except FileNotFoundError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc diff --git a/atlas-terminal/server/services/institutional_report.py b/atlas-terminal/server/services/institutional_report.py new file mode 100644 index 0000000..5db8ead --- /dev/null +++ b/atlas-terminal/server/services/institutional_report.py @@ -0,0 +1,325 @@ +"""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, + ) diff --git a/atlas-terminal/server/services/sec_parser.py b/atlas-terminal/server/services/sec_parser.py index 6e5570a..d7e2ea6 100644 --- a/atlas-terminal/server/services/sec_parser.py +++ b/atlas-terminal/server/services/sec_parser.py @@ -7,22 +7,88 @@ local JSON cache under ``data/``. """ import json +import logging import re import tempfile from pathlib import Path -from typing import Dict, List, Optional +from typing import Any, Dict, List, Optional + +import httpx from bs4 import BeautifulSoup from bs4.element import Comment, Tag from server.services.text_chunker import clean_text_for_llm, smart_chunk +logger = logging.getLogger(__name__) + # --------------------------------------------------------------------------- # Paths # --------------------------------------------------------------------------- _DATA_DIR: Path = Path(__file__).resolve().parents[3] / "data" +# --------------------------------------------------------------------------- +# SEC EDGAR Filing URL Resolver +# --------------------------------------------------------------------------- + +_CIK_CACHE: Dict[str, int] = {} +_SEC_HEADERS = {"User-Agent": "ATLAS-Terminal admin@atlas.local"} + + +def _resolve_cik(ticker: str) -> Optional[int]: + """Resolve ticker → CIK via SEC's company_tickers.json.""" + t = ticker.upper().strip() + if t in _CIK_CACHE: + return _CIK_CACHE[t] + try: + resp = httpx.get( + "https://www.sec.gov/files/company_tickers.json", + headers=_SEC_HEADERS, + timeout=15, + ) + resp.raise_for_status() + data = resp.json() + for entry in data.values(): + tk = entry.get("ticker", "") + cik = entry.get("cik_str") + if tk: + _CIK_CACHE[tk.upper()] = int(cik) + return _CIK_CACHE.get(t) + except Exception: + logger.warning("Failed to resolve CIK for %s", t) + return None + + +def get_sec_filing_url(ticker: str) -> Optional[str]: + """Return the URL of the latest 10-K filing document on SEC EDGAR.""" + cik = _resolve_cik(ticker) + if cik is None: + return None + cik_padded = str(cik).zfill(10) + try: + resp = httpx.get( + f"https://data.sec.gov/submissions/CIK{cik_padded}.json", + headers=_SEC_HEADERS, + timeout=15, + ) + resp.raise_for_status() + data: Dict[str, Any] = resp.json() + recent = data.get("filings", {}).get("recent", {}) + forms = recent.get("form", []) + accessions = recent.get("accessionNumber", []) + docs = recent.get("primaryDocument", []) + for i, form in enumerate(forms): + if form in ("10-K", "10-K/A"): + acc_no_dash = accessions[i].replace("-", "") + return ( + f"https://www.sec.gov/Archives/edgar/data" + f"/{cik_padded}/{acc_no_dash}/{docs[i]}" + ) + except Exception: + logger.warning("Failed to get filing URL for %s", ticker) + return None + # --------------------------------------------------------------------------- # Section-header regex patterns # --------------------------------------------------------------------------- @@ -352,8 +418,15 @@ def _best_anchor_parent_for_text_node(text_node) -> Optional[Tag]: def inject_sec_item_anchor_ids(soup: BeautifulSoup) -> None: - """Set ``id=\"sec-item-*\"`` on heading-like nodes for Item 1A, 3, 7, 8, 9A.""" - assigned: set[str] = set() + """Set ``id=\"sec-item-*\"`` on heading-like nodes for Item 1A, 3, 7, 8, 9A. + + Strategy: collect *all* candidate matches per Item, then prefer a match + that lives outside the first table-of-contents table — specifically one + whose host element is an ````, ``

``, or ``

`` (not a ```` + in the TOC). Falls back to the last candidate if no heading match exists. + """ + # Collect all candidates per el_id: list of (text_node, host_tag) + candidates: dict[str, list[tuple]] = {spec[0]: [] for spec in _SEC_ITEM_INJECT_SPECS} for text in soup.find_all(string=True): if isinstance(text, Comment): continue @@ -361,27 +434,46 @@ def inject_sec_item_anchor_ids(soup: BeautifulSoup) -> None: if not text_val.strip(): continue for el_id, regexes in _SEC_ITEM_INJECT_SPECS: - if el_id in assigned: - continue if not any(rx.search(text_val) for rx in regexes): continue host = _best_anchor_parent_for_text_node(text) - if host is None: - continue - host["id"] = el_id - assigned.add(el_id) - break + if host is not None: + candidates[el_id].append((text, host)) + break # only match first spec for this text node + + assigned: set[str] = set() + for el_id, _ in _SEC_ITEM_INJECT_SPECS: + cands = candidates.get(el_id, []) + if not cands: + continue + # Prefer a heading-like host (h1-h6, p, div) that is NOT inside the TOC table + best = None + for _text, host in cands: + host_name = (host.name or "").lower() + if host_name in ("h1", "h2", "h3", "h4", "h5", "h6", "p", "div"): + best = host + # Don't break — prefer later (actual section header) over earlier (TOC) + if best is None and len(cands) > 1: + # If no heading host, use the last match (skip the first/TOC one) + best = cands[-1][1] + elif best is None: + best = cands[0][1] + best["id"] = el_id + assigned.add(el_id) def prepare_native_html_fragment_from_10k_raw(raw_html: str) -> str: - """Slice Items 1A–9A, sanitize, inject ``sec-item-*`` anchors, return body HTML fragment.""" + """Sanitize full 10-K HTML, inject ``sec-item-*`` anchors, return body HTML fragment. + + The entire document is preserved (table of contents, all Items, tables, etc.) + so the user sees the original formatted filing inside the app. + """ if not raw_html or len(raw_html) < 100: return "" - sliced = _slice_html_items_1a_to_9a(raw_html) try: - soup = BeautifulSoup(sliced, "lxml") + soup = BeautifulSoup(raw_html, "lxml") except Exception: - soup = BeautifulSoup(sliced, "html.parser") + soup = BeautifulSoup(raw_html, "html.parser") _sanitize_sec_html_soup(soup) inject_sec_item_anchor_ids(soup) if soup.body: @@ -483,6 +575,7 @@ def _save_10k_to_cache(ticker: str, data: Dict[str, str]) -> None: def download_and_extract_all_items(ticker: str, email: str) -> Dict[str, str]: """Download latest 10-K, extract Items 1A/3/7/8/9A, clean and cache.""" Downloader = _get_edgar_downloader() + raw_html: Optional[str] = None with tempfile.TemporaryDirectory() as tmpdir: download_root = Path(tmpdir) dl = Downloader("FQDC-10K-Analyzer", email, str(download_root)) @@ -493,6 +586,8 @@ def download_and_extract_all_items(ticker: str, email: str) -> Dict[str, str]: full_text = get_main_10k_text(filing_dir) if not full_text: raise ValueError("Could not extract text from the 10-K.") + # Read raw HTML while tempdir still exists + raw_html = read_main_10k_html_raw(filing_dir) item1a = find_item_section_generic(full_text, ITEM1A_PATTERNS, 1, ["Risk", "Factors"], max_chars=80_000) item3 = _extract_item_from_full(full_text, ITEM3_PATTERNS, 3, ["Legal", "Proceedings"], max_chars=40_000) @@ -514,7 +609,6 @@ def download_and_extract_all_items(ticker: str, email: str) -> Dict[str, str]: } _save_10k_to_cache(ticker, data) - raw_html = read_main_10k_html_raw(filing_dir) if raw_html: fragment = prepare_native_html_fragment_from_10k_raw(raw_html) if fragment: