refactor: modular architecture v3.0 + SEC filing viewer fix + README

Architecture (3,909-line monolith → 28 focused modules, all < 300 lines):
- config/: constants.py (company lists, row maps, Damodaran baselines), theme.py (CSS/HTML)
- utils/: prefs, formatting, ticker, dcf, charts, ui_helpers
- data/: sec_parser, sec_fetcher, sec_downloader, financials, fundamentals,
         valuation, ratios, scores, scores_ai, market
- ai/: gemini_core, gemini_sec, gemini_insights
- views/: sidebar, tab1_quant, tab1_ai, tab1_filings, tab2_dcf,
          tab3_comps, tab4_news, tab5_markets, tab6_crypto, tab7_technical
- app.py: thin orchestrator (~118 lines)
- Strict unidirectional dependency graph (no circular imports)
- All @st.cache_data TTLs and st.session_state keys preserved identically

SEC filing viewer fix:
- Rebuilt EDGAR fetch chain: company_tickers.json → CIK → submissions API
  → filings.recent.primaryDocument[] (replaces deprecated directory.item)
- Filing type selectbox (10-K, 10-Q, 8-K, 20-F, 6-K) connected to backend
- Native HTML rendered via streamlit.components.v1.html() with CSS reset
- Errors surfaced explicitly with st.error()
- DART direct links restored for Korean-listed companies

.gitignore: data/ → data/*.json + data/*.html (preserve Python modules)
README: full rewrite for master's portfolio — 7-tab layout, architecture
diagram, modular structure tree, technical challenges, design rationale

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
shawnkim1997
2026-03-19 22:11:07 +00:00
co-authored by Claude Sonnet 4.6
parent 7ce5661569
commit d337c63976
41 changed files with 4409 additions and 2942 deletions
View File
+113
View File
@@ -0,0 +1,113 @@
"""
Gemini model initialization, retry logic, streaming, text chunking, summarize/synthesize/forensic.
"""
import re
import time
from config.constants import GEMINI_MODEL, RATE_LIMIT_WAIT_SEC
def get_gemini_model(api_key: str):
import google.generativeai as genai
genai.configure(api_key=api_key)
return genai.GenerativeModel(GEMINI_MODEL)
def _is_rate_limit_error(e: Exception) -> bool:
err_msg = str(e).lower()
return "429" in err_msg or "resourcelimited" in err_msg or "resource exhausted" in err_msg or getattr(e, "code", None) == 429
def _generate_with_retry(model, content, config, max_retries: int = 3):
last_err = None
for attempt in range(max_retries + 1):
try:
return model.generate_content(content, generation_config=config)
except Exception as e:
last_err = e
if attempt < max_retries and _is_rate_limit_error(e):
time.sleep(RATE_LIMIT_WAIT_SEC)
continue
raise
raise last_err
def _generate_stream(model, content, config):
"""Yield text chunks from Gemini with stream=True. For use with st.write_stream()."""
try:
response = model.generate_content(content, generation_config=config, stream=True)
for chunk in response:
if hasattr(chunk, "text") and chunk.text:
yield chunk.text
except Exception:
raise
def _split_into_chunks(text: str, max_chars: int = 22000, min_chunk: int = 5000) -> list:
"""Split text into sequential chunks without cutting mid-sentence when possible."""
if not text or len(text) <= max_chars:
return [text] if text and text.strip() else []
chunks = []
start = 0
while start < len(text):
end = min(start + max_chars, len(text))
if end < len(text):
break_at = text.rfind("\n\n", start, end + 1)
if break_at > start + min_chunk:
end = break_at + 2
chunks.append(text[start:end].strip())
start = end
return [c for c in chunks if c]
def _gemini_summarize_segment(api_key: str, segment_text: str, ticker: str, segment_label: str) -> str:
"""Extract strategic shifts and hidden risks from one segment. No trimming."""
model = get_gemini_model(api_key)
prompt = f"""You are a senior equity analyst. The following is one segment of the 10-K for {ticker} (Item 1A Risk Factors and/or Item 7 MD&A).
Extract and list all significant: (1) strategic shifts or priorities, (2) hidden or material risks, (3) management tone cues. Use concise bullet points. Do not omit important details. Segment: {segment_label}."""
full = f"""--- 10-K Segment ---\n\n{segment_text[:50000]}\n\n---\n\n{prompt}"""
try:
r = _generate_with_retry(model, full, {"temperature": 0.2, "max_output_tokens": 2048})
return (r.text or "").strip()
except Exception:
return ""
def _gemini_synthesize_report(api_key: str, segment_summaries: list, ticker: str, sector: str, industry: str) -> str:
"""Synthesis call: turn segment summaries into Executive Insight Report."""
model = get_gemini_model(api_key)
combined = "\n\n---\n\n".join(segment_summaries)
kpi_note = f" Sector: {sector}; Industry: {industry}. Include industry-specific KPIs if mentioned." if sector and sector != "N/A" else ""
prompt = f"""You are a senior equity analyst. Use British English. Below are summarized insights from the full 10-K for {ticker} (Item 1A and Item 7). Create the final **Executive Insight Report** with these sections:
1. **Management's Tone (Sentiment)**: Overall tone and supporting evidence.
2. **Current Strategy & Priorities**: Key strategic focus, capital allocation, growth drivers.
3. **Major Hidden Risks**: The 3-4 most material risks investors might overlook.
4. **Forensic / Quality of Earnings**: Accounting caveats, one-offs, cash flow vs earnings. If none material, say so briefly.{kpi_note}
Use clear headings. Do not invent figures. Keep under 900 words."""
full = f"""--- Segment Summaries ---\n\n{combined}\n\n---\n\n{prompt}"""
try:
r = _generate_with_retry(model, full, {"temperature": 0.3, "max_output_tokens": 4096})
return (r.text or "").strip()
except Exception:
return ""
def _gemini_forensic_audit(api_key: str, item3: str, item9a: str, ticker: str) -> str:
"""Dedicated high-priority check: Material Weaknesses, lawsuits, off-balance-sheet from Item 3 and 9A."""
model = get_gemini_model(api_key)
combined = (item3 or "") + "\n\n---\n\n" + (item9a or "")
if not combined.strip():
return "No Item 3 / 9A text provided; skip forensic."
prompt = f"""From the following 10-K excerpts for {ticker} (Item 3 Legal Proceedings and Item 9A Controls/Internal Control), list any:
- Material weaknesses in internal control
- Significant legal proceedings or litigation
- Off-balance-sheet or governance red flags
If none of the above, output exactly: "No material red flags or special issues detected in Item 3 and 9A."
Be concise (under 150 words)."""
full = f"""--- Item 3 & 9A ---\n\n{combined[:30000]}\n\n---\n\n{prompt}"""
try:
r = _generate_with_retry(model, full, {"temperature": 0.1, "max_output_tokens": 512})
return (r.text or "").strip()
except Exception:
return ""
+191
View File
@@ -0,0 +1,191 @@
from typing import Optional
import streamlit as st
from data.sec_parser import smart_chunk, clean_text_for_llm
from ai.gemini_core import (
get_gemini_model, _generate_with_retry, _is_rate_limit_error,
_split_into_chunks, _gemini_summarize_segment, _gemini_synthesize_report, _gemini_forensic_audit,
)
def get_mda_chunked_insights(
api_key: str, sections: dict, ticker: str, sector: str, industry: str, progress_callback=None
) -> str:
"""Full-text analysis: chunk 1A+7, summarize each segment, synthesize report; then append forensic (Item 3, 9A). progress_callback(step: str) optional."""
def _progress(msg):
if progress_callback:
progress_callback(msg)
combined = (sections.get("item1a") or "") + "\n\n---\n\n" + (sections.get("item7") or "")
combined = combined.strip()
if not combined:
return "No 10-K text available to analyse."
chunks = _split_into_chunks(combined, max_chars=22000)
if not chunks:
return "No content extracted."
summaries = []
n = len(chunks)
for i, ch in enumerate(chunks):
_progress(f"Analyzing Segment {i+1}/{n}...")
summary = _gemini_summarize_segment(api_key, ch, ticker, f"Segment {i+1}/{n}")
if summary:
summaries.append(summary)
if not summaries:
return "Segment analysis produced no summaries."
_progress("Synthesizing final report...")
report = _gemini_synthesize_report(api_key, summaries, ticker, sector or "N/A", industry or "N/A")
_progress("Running forensic audit (Item 3 & 9A)...")
forensic = _gemini_forensic_audit(api_key, sections.get("item3") or "", sections.get("item9a") or "", ticker)
return (report or "") + "\n\n---\n\n**Forensic (Item 3 & 9A)**\n\n" + (forensic or "")
def get_mda_insights(api_key: str, item1a_text: str, item7_text: str, ticker: str) -> str:
"""Send Item 1A + Item 7 to Gemini. Analyse: 1) Management's Tone (Sentiment), 2) Key Strategic Shifts, 3) Major Hidden Risks."""
model = get_gemini_model(api_key)
combined = []
if item1a_text:
combined.append(clean_text_for_llm(item1a_text))
if item7_text:
combined.append(clean_text_for_llm(item7_text))
combined_text = "\n\n---\n\n".join(combined)
combined_text = smart_chunk(combined_text, max_chars=22000)
user_prompt = f"""You are a senior equity analyst. Use British English.
The text below is from the 10-K for {ticker}: **Item 1A (Risk Factors)** and **Item 7 (Management's Discussion and Analysis)**. HTML has been stripped; analyse only the substance.
Provide a concise report with three sections:
1. **Management's Tone (Sentiment)**: Is the overall tone positive, cautious, or negative? Quote 12 short phrases that support your view.
2. **Key Strategic Shifts**: What strategic priorities or shifts does management emphasise (e.g. capital allocation, growth drivers, new segments)? Be specific.
3. **Major Hidden Risks**: From both Risk Factors and MD&A, what are the 34 most material risks that an investor might overlook? Cite the document.
Use clear headings. Do not invent figures. Keep the response focused and under 800 words."""
full_content = f"""--- 10-K Excerpt (Item 1A + Item 7) ---\n\n{combined_text}\n\n---\n\n{user_prompt}"""
try:
response = _generate_with_retry(
model, full_content, {"temperature": 0.3, "max_output_tokens": 4096}
)
except Exception as api_err:
if _is_rate_limit_error(api_err):
raise RuntimeError("Rate limit exceeded. Please try again in a few minutes.") from api_err
raise
if not response or not response.text:
return "No analysis generated."
return response.text.strip()
def get_mda_comparative_insights(
api_key: str,
item1a_text: str,
item7_latest: str,
item7_3y_ago: Optional[str],
ticker: str,
sector: Optional[str] = None,
industry: Optional[str] = None,
) -> str:
"""Comparative analysis: if item7_3y_ago provided, compare MD&As over 3 years; else single-year. Sector-aware: extract industry-specific Non-GAAP KPIs."""
model = get_gemini_model(api_key)
sector_label = (sector or "N/A").strip()
industry_label = (industry or "N/A").strip()
kpi_instruction = (
f" Given that this company is in the **{sector_label}** sector"
+ (f" (industry: {industry_label})" if industry_label != "N/A" else "")
+ ", meticulously scan the MD&A to find and extract **industry-specific Non-GAAP KPIs** "
"(e.g. Same-Store Sales Growth for Retail, ARR/NDR for Software, DAU/MAU for Tech). Present these hidden KPIs in a **clean markdown table** with columns such as KPI name, value, and period if stated."
)
if not item7_3y_ago or not item7_3y_ago.strip():
combined = []
if item1a_text:
combined.append(clean_text_for_llm(item1a_text))
if item7_latest:
combined.append(clean_text_for_llm(item7_latest))
combined_text = "\n\n---\n\n".join(combined)
combined_text = smart_chunk(combined_text, max_chars=22000)
user_prompt = f"""You are a senior equity analyst. Use British English.
The text below is from the **latest 10-K only** for {ticker}: **Item 1A (Risk Factors)** and **Item 7 (MD&A)**. Provide a focused deep-dive report:
1. **Management's Tone (Sentiment)**: Overall tone and 12 supporting phrases.
2. **Current Strategy & Priorities**: Key strategic focus, capital allocation, growth drivers from this filing only.
3. **Major Hidden Risks**: From Item 1A and MD&A, the 34 most material risks investors might overlook.
4. **Forensic / Quality of Earnings**: Any red flags in MD&A (accounting caveats, one-offs, cash flow vs earnings, segment disclosure). If none material, say so briefly.{kpi_instruction}
**Token-saving (Item 3 / 9A):** If no material weaknesses, major lawsuits, or off-balance-sheet red flags, output exactly: "\u2705 No material red flags or special issues detected in Item 3 and 9A."
Use clear headings. Under 800 words."""
full_content = f"""--- 10-K Excerpt (Latest Year) ---\n\n{combined_text}\n\n---\n\n{user_prompt}"""
else:
latest_clean = smart_chunk(clean_text_for_llm(item7_latest), max_chars=12000)
past_clean = smart_chunk(clean_text_for_llm(item7_3y_ago), max_chars=12000)
user_prompt = f"""You are a senior equity analyst. Use British English.
Below are **Item 7 (Management's Discussion and Analysis)** from the 10-K for {ticker}: **LATEST YEAR** and **THREE YEARS AGO**. Perform a **Comparative Analysis**.
1. **Core strategy**: What has changed in the company's stated strategy, priorities, or capital allocation between then and now?
2. **Emerging risks**: What new risks appear in the latest MD&A that were absent or less prominent 3 years ago?
3. **Management's tone**: How has the overall tone (confidence, caution, optimism) shifted? Quote 12 phrases from each period if relevant.
4. **Industry-specific KPIs**:{kpi_instruction}
5. **Item 3 (Legal) & Item 9A (Internal Controls):** You must save output tokens. If there are no material weaknesses, no massive lawsuits, and no major off-balance sheet red flags, DO NOT generate a long explanation. Simply output exactly: "\u2705 No material red flags or special issues detected in Item 3 and 9A." and move on.
Use clear headings. Do not invent figures. Keep the response focused and under 900 words."""
full_content = f"""--- MD&A LATEST YEAR ---\n\n{latest_clean}\n\n--- MD&A THREE YEARS AGO ---\n\n{past_clean}\n\n---\n\n{user_prompt}"""
try:
response = _generate_with_retry(
model, full_content, {"temperature": 0.3, "max_output_tokens": 4096}
)
except Exception as api_err:
if _is_rate_limit_error(api_err):
raise RuntimeError("Rate limit exceeded. Please try again in a few minutes.") from api_err
raise
if not response or not response.text:
return "No analysis generated."
return response.text.strip()
def _run_mda_analysis_background(ticker: str, api_key: str, sec_email: str) -> None:
"""Run download + Gemini in background (latest 10-K only for speed). Store result or error in st.session_state."""
try:
from data.sec_downloader import download_and_extract_item7_and_1a
from data.fundamentals import get_sector_industry
_, item1a, item7_latest = download_and_extract_item7_and_1a(ticker, sec_email)
si = get_sector_industry(ticker)
analysis = get_mda_comparative_insights(
api_key, item1a or "", item7_latest or "", None, ticker,
sector=si.get("sector"), industry=si.get("industry"),
)
st.session_state["mda_analysis_result"] = analysis
st.session_state["mda_analysis_excerpt"] = ((item1a or "") + "\n\n---\n\n" + (item7_latest or ""))[:12000]
st.session_state["mda_analysis_error"] = None
except Exception as e:
st.session_state["mda_analysis_error"] = str(e)
st.session_state["mda_analysis_result"] = None
st.session_state["mda_analysis_excerpt"] = None
finally:
st.session_state["mda_analysis_running"] = False
st.session_state["mda_analysis_done"] = True
st.session_state["mda_analysis_ticker"] = ticker
def get_industry_outlook(api_key: str, industry_name: str, tickers: list) -> str:
"""Gemini: Wall Street macro analyst-style Industry Outlook for the selected sector (12\u201318 months)."""
model = get_gemini_model(api_key)
ticker_list_str = ", ".join(str(t).upper() for t in tickers if t)
user_prompt = f"""Act as an elite Wall Street macro analyst. Provide a concise **Industry Outlook** report for the **{industry_name}** sector, which includes leading companies like {ticker_list_str}.
Focus on:
1. **Macro trends** affecting this industry over the next 12\u201318 months.
2. **Major growth drivers** (e.g., AI, interest rates, consumer spending, regulation).
3. **Key headwinds or regulatory risks** that could impact valuations or growth.
Use clear headings. Be specific but concise. Keep the response under 600 words."""
full_content = user_prompt
try:
response = _generate_with_retry(
model, full_content, {"temperature": 0.4, "max_output_tokens": 2048}
)
except Exception as api_err:
if _is_rate_limit_error(api_err):
raise RuntimeError("Rate limit exceeded. Please try again in a few minutes.") from api_err
raise
if not response or not response.text:
return "No industry outlook generated."
return response.text.strip()
+165
View File
@@ -0,0 +1,165 @@
import json
import re
import streamlit as st
from utils.formatting import _safe_float
from data.sec_parser import smart_chunk, clean_text_for_llm
from ai.gemini_core import get_gemini_model, _generate_with_retry, _generate_stream, _gemini_forensic_audit
from config.constants import REQUIRED_FINANCIAL_KEYS
@st.cache_data(ttl=3600)
def get_sec_financials_llm(api_key: str, item8_text: str, ticker: str) -> dict:
"""Extract Current Year and Previous Year financial figures from 10-K Item 8 via Gemini. Returns dict with current_yr and previous_yr (each with 10 numeric fields). Cached by (api_key, item8_text, ticker)."""
if not (api_key or "").strip() or not (item8_text or "").strip():
return {}
payload = smart_chunk((item8_text or "").strip(), max_chars=35000)
model = get_gemini_model(api_key)
prompt = f"""You are a financial analyst. Below is Item 8 (Financial Statements and Supplementary Data) from the latest 10-K for {ticker}.
Extract the following figures for the **Current Year** (most recent fiscal year) and **Previous Year** (prior fiscal year). Use the exact numbers from the financial statements. All monetary values in millions (e.g. 50000 for $50 billion). Shares in millions.
Return ONLY a valid JSON object, no other text. Use this exact structure:
{{
"current_yr": {{
"Revenue": <number>,
"CostOfRevenue": <number>,
"OperatingExpenses": <number>,
"NetIncome": <number>,
"TotalAssets": <number>,
"CurrentAssets": <number>,
"CurrentLiabilities": <number>,
"LongTermDebt": <number>,
"OperatingCashFlow": <number>,
"SharesOutstanding": <number>
}},
"previous_yr": {{
"Revenue": <number>,
"CostOfRevenue": <number>,
"OperatingExpenses": <number>,
"NetIncome": <number>,
"TotalAssets": <number>,
"CurrentAssets": <number>,
"CurrentLiabilities": <number>,
"LongTermDebt": <number>,
"OperatingCashFlow": <number>,
"SharesOutstanding": <number>
}}
}}
If a value is not found in the document, use 0 or a reasonable estimate and still include the key. Output nothing except this JSON."""
full = f"""--- Item 8 (Financial Statements) ---\n\n{payload}\n\n---\n\n{prompt}"""
try:
r = _generate_with_retry(model, full, {"temperature": 0.0, "max_output_tokens": 2048})
raw = (r.text or "").strip()
if not raw:
return {}
raw = re.sub(r"^```\s*json\s*", "", raw)
raw = re.sub(r"^```\s*", "", raw)
raw = re.sub(r"\s*```\s*$", "", raw)
raw = raw.strip()
out = json.loads(raw)
cur = out.get("current_yr") or {}
prev = out.get("previous_yr") or {}
for key in REQUIRED_FINANCIAL_KEYS:
cur[key] = _safe_float(cur.get(key)) or 0
prev[key] = _safe_float(prev.get(key)) or 0
return {"current_yr": cur, "previous_yr": prev}
except (json.JSONDecodeError, Exception):
return {}
def get_gemini_item7_strategy(api_key: str, item7_text: str, ticker: str, sector: str, industry: str) -> str:
"""Item 7 only: business performance, strategic shifts, capital allocation."""
if not (item7_text or "").strip():
return "No Item 7 (MD&A) text available."
model = get_gemini_model(api_key)
text = smart_chunk(clean_text_for_llm(item7_text), max_chars=10000)
sector_note = f" Sector: {sector}; Industry: {industry}." if sector and sector != "N/A" else ""
prompt = f"""You are a senior equity analyst. Use British English. The text below is **Item 7 (Management's Discussion and Analysis)** from the latest 10-K for {ticker}.{sector_note}
Provide a concise **Management Strategy** report with these sections:
1. **Business performance**: Key revenue, margin, or segment highlights management emphasises.
2. **Strategic shifts**: Changes in priorities, growth drivers, or capital allocation (e.g. capex, M&A, buybacks).
3. **Capital allocation**: How management describes use of cash (dividends, debt paydown, R&D, acquisitions).
Use clear headings. Do not invent figures. Keep under 600 words. Focus only on narrative insights; ignore missing quantitative data.
Even if the source text is in another language (e.g. Korean or Japanese), analyse it and output your final report strictly in British English."""
full = f"""--- Item 7 (MD&A) ---\n\n{text}\n\n---\n\n{prompt}"""
try:
r = _generate_with_retry(model, full, {"temperature": 0.3, "max_output_tokens": 2048})
return (r.text or "").strip()
except Exception:
return ""
def get_gemini_item7_strategy_stream(api_key: str, item7_text: str, ticker: str, sector: str, industry: str):
"""Generator that yields MD&A strategy report chunks for real-time streaming (e.g. st.write_stream)."""
if not (item7_text or "").strip():
yield "No Item 7 (MD&A) text available."
return
model = get_gemini_model(api_key)
text = smart_chunk(clean_text_for_llm(item7_text), max_chars=10000)
sector_note = f" Sector: {sector}; Industry: {industry}." if sector and sector != "N/A" else ""
prompt = f"""You are a senior equity analyst. Use British English. The text below is **Item 7 (Management's Discussion and Analysis)** from the latest 10-K for {ticker}.{sector_note}
Provide a concise **Management Strategy** report with these sections:
1. **Business performance**: Key revenue, margin, or segment highlights management emphasises.
2. **Strategic shifts**: Changes in priorities, growth drivers, or capital allocation (e.g. capex, M&A, buybacks).
3. **Capital allocation**: How management describes use of cash (dividends, debt paydown, R&D, acquisitions).
Use clear headings. Do not invent figures. Keep under 600 words. Focus only on narrative insights; ignore missing quantitative data.
Even if the source text is in another language (e.g. Korean or Japanese), analyse it and output your final report strictly in British English."""
full = f"""--- Item 7 (MD&A) ---\n\n{text}\n\n---\n\n{prompt}"""
config = {"temperature": 0.3, "max_output_tokens": 2048}
yield from _generate_stream(model, full, config)
def get_gemini_item1a_risks(api_key: str, item1a_text: str, item3: str, item9a: str, ticker: str) -> str:
"""Item 1A only: legal, operational, market-related threats. Includes Forensic Audit (Item 3 & 9A) as safety check."""
if not (item1a_text or "").strip():
return "No Item 1A (Risk Factors) text available."
model = get_gemini_model(api_key)
text = smart_chunk(clean_text_for_llm(item1a_text), max_chars=10000)
prompt = f"""You are a senior equity analyst. Use British English. The text below is **Item 1A (Risk Factors)** from the latest 10-K for {ticker}.
Provide a concise **Risk Factors** report with these sections:
1. **Legal & regulatory risks**: Litigation, regulatory changes, compliance.
2. **Operational risks**: Supply chain, key person, technology, execution.
3. **Market & competitive risks**: Demand, competition, macro, currency.
Use clear headings. Do not invent figures. Keep under 500 words. Focus only on narrative insights; ignore missing quantitative data.
Even if the source text is in another language (e.g. Korean or Japanese), analyse it and output your final report strictly in British English."""
full = f"""--- Item 1A (Risk Factors) ---\n\n{text}\n\n---\n\n{prompt}"""
try:
report = _generate_with_retry(model, full, {"temperature": 0.3, "max_output_tokens": 2048})
risks = (report.text or "").strip()
except Exception:
risks = ""
forensic = _gemini_forensic_audit(api_key, item3 or "", item9a or "", ticker)
return (risks or "") + "\n\n---\n\n**Forensic Audit (Item 3 & 9A)**\n\n" + (forensic or "")
def get_gemini_item1a_risks_stream(api_key: str, item1a_text: str, ticker: str):
"""Generator that yields Risk Factors report chunks for real-time streaming. Caller appends Forensic (Item 3 & 9A) after stream."""
if not (item1a_text or "").strip():
yield "No Item 1A (Risk Factors) text available."
return
model = get_gemini_model(api_key)
text = smart_chunk(clean_text_for_llm(item1a_text), max_chars=10000)
prompt = f"""You are a senior equity analyst. Use British English. The text below is **Item 1A (Risk Factors)** from the latest 10-K for {ticker}.
Provide a concise **Risk Factors** report with these sections:
1. **Legal & regulatory risks**: Litigation, regulatory changes, compliance.
2. **Operational risks**: Supply chain, key person, technology, execution.
3. **Market & competitive risks**: Demand, competition, macro, currency.
Use clear headings. Do not invent figures. Keep under 500 words. Focus only on narrative insights; ignore missing quantitative data.
Even if the source text is in another language (e.g. Korean or Japanese), analyse it and output your final report strictly in British English."""
full = f"""--- Item 1A (Risk Factors) ---\n\n{text}\n\n---\n\n{prompt}"""
config = {"temperature": 0.3, "max_output_tokens": 2048}
yield from _generate_stream(model, full, config)