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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
shawnkim1997
2026-03-29 17:36:56 +01:00
parent 51cbaf7f8d
commit 8fe3aaf771
6 changed files with 1808 additions and 15 deletions
@@ -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
+139
View File
@@ -375,3 +375,142 @@ Output rules:
except Exception as exc:
logger.exception("anomaly-explain Gemini failed")
raise HTTPException(status_code=500, detail=str(exc)) from exc
# ---------------------------------------------------------------------------
# Translation endpoint
# ---------------------------------------------------------------------------
class TranslateRequest(BaseModel):
text: str = Field(..., description="Text to translate")
target_lang: str = Field("ko", description="Target language code (ko, ja, zh, etc.)")
api_key: str = ""
@router.post("/translate", summary="Translate filing text via Gemini")
async def translate_text(req: TranslateRequest):
"""Translate SEC/DART filing section text to the target language."""
api_key = req.api_key or os.getenv("GOOGLE_API_KEY", "")
if not api_key:
raise HTTPException(status_code=400, detail="Gemini API key required")
text = req.text.strip()
if not text:
raise HTTPException(status_code=400, detail="No text to translate")
# Limit input to ~12,000 chars to stay within Gemini context
if len(text) > 12_000:
from server.services.text_chunker import smart_chunk
text = smart_chunk(text, max_chars=12_000)
lang_names = {
"ko": "Korean", "ja": "Japanese", "zh": "Chinese (Simplified)",
"es": "Spanish", "fr": "French", "de": "German",
}
lang_name = lang_names.get(req.target_lang, req.target_lang)
prompt = (
f"Translate the following SEC filing text to {lang_name}. "
"Rules:\n"
"- Preserve all numbers, financial figures, dates, and ticker symbols exactly as-is.\n"
"- Keep technical financial terms (e.g., EBITDA, GAAP, P/E) in English.\n"
"- Maintain paragraph structure and formatting.\n"
"- Translate naturally, not word-for-word.\n\n"
f"---\n{text}\n---"
)
try:
result = _call_gemini(api_key, prompt, max_tokens=8192, temperature=0.2)
return {"translated_text": result}
except Exception as exc:
logger.exception("Translation failed")
raise HTTPException(status_code=500, detail=f"Translation failed: {exc}") from exc
# ---------------------------------------------------------------------------
# Institutional Analysis — Wall Street 10
# ---------------------------------------------------------------------------
class InstitutionalRequest(BaseModel):
ticker: str
api_key: str = ""
lang: str = Field("en", description="Output language: en, ko, ja")
@router.post("/institutional", summary="Wall Street 10 institutional analysis")
async def institutional_analysis(req: InstitutionalRequest):
"""Generate comprehensive institutional-grade analysis from 10 Wall Street perspectives.
Gathers all pre-computed quantitative data (DuPont, Altman Z, F-Score,
DCF, anomalies, peers) and feeds them to Gemini for multi-perspective
interpretation. The LLM interprets numbers; it never computes them.
"""
api_key = req.api_key or os.getenv("GOOGLE_API_KEY", "")
if not api_key:
raise HTTPException(status_code=400, detail="Gemini API key required. Set in Settings.")
ticker = req.ticker.upper()
# 1) Gather all quantitative data
from server.services.institutional_report import (
gather_quantitative_context,
build_institutional_prompt,
)
try:
context = gather_quantitative_context(ticker)
except Exception as exc:
logger.exception("Failed to gather quant context for %s", ticker)
raise HTTPException(status_code=500, detail=f"Data gathering failed: {exc}") from exc
if len(context) < 200:
raise HTTPException(status_code=404, detail=f"Insufficient data for {ticker}")
# Extract F-Score for prompt
fscore = 0
try:
from server.services.research_dashboard import build_research_dashboard
dash = build_research_dashboard(ticker)
if dash:
fscore = dash.fscore_total
except Exception:
pass
# 2) Build prompt and call Gemini
prompt = build_institutional_prompt(ticker, context, fscore)
# Language instruction
if req.lang == "ko":
prompt += "\n\nIMPORTANT: Write the entire analysis in Korean (한국어). Keep financial terms (P/E, EBITDA, DCF, etc.) in English."
elif req.lang == "ja":
prompt += "\n\nIMPORTANT: Write the entire analysis in Japanese (日本語). Keep financial terms in English."
try:
raw = _call_gemini(api_key, prompt, max_tokens=8192, temperature=0.3)
except HTTPException:
raise
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Gemini call failed: {exc}") from exc
# 3) Parse JSON response
try:
parsed = _parse_llm_json_object(raw)
except json.JSONDecodeError:
# Return raw text as executive_summary if JSON parsing fails
parsed = {
"executive_summary": raw[:3000] if raw else "Analysis generation failed.",
"goldman_sachs": "",
"morgan_stanley": "",
"jp_morgan": "",
"blackrock": "",
"bridgewater": "",
"berkshire": "",
"citadel": "",
"two_sigma": "",
"elliott": "",
}
return {
"ticker": ticker,
"sections": parsed,
"quant_context": context,
}
+13
View File
@@ -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,
)
+109 -15
View File
@@ -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 1A9A, 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: