mirror of
https://github.com/shawnkim1997/All-in-one-Financial-Analysis.git
synced 2026-08-03 22:07:45 +00:00
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:
@@ -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() {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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 ``<h*>``, ``<p>``, or ``<div>`` (not a ``<td>``
|
||||
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:
|
||||
|
||||
Reference in New Issue
Block a user