Files
All-in-one-Financial-Analysis/app.py
T
2026-02-14 14:42:39 +00:00

2812 lines
142 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
All-in-One Financial Analysis Dashboard — Hybrid Architecture
- Tab 1: 10-K & MD&A Insights (Item 7 + Item 1A → Gemini, qualitative only).
- Tab 2: 3-Scenario DCF Valuation (yfinance + sliders, no LLM).
- Tab 3: Industry Comps (yfinance multiples: Forward P/E, EV/EBITDA, P/B).
- Cost-effective: Gemini only for text; all numbers from yfinance.
"""
import json
import os
import re
import tempfile
import threading
import time
from pathlib import Path
from typing import Optional, Any
# Local prefs file for "Remember me" (API key & email). Path is in .gitignore.
_PREFS_PATH = Path(__file__).resolve().parent / ".app_prefs.json"
# Persistent 10-K cache: data/ticker_latest.json (Item 1A, 3, 7, 9A cleaned text).
_DATA_DIR = Path(__file__).resolve().parent / "data"
def _load_prefs() -> dict:
"""Load saved API keys and email from local file. Keys: google_api_key, sec_email."""
try:
if _PREFS_PATH.exists():
with open(_PREFS_PATH, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
pass
return {}
def _save_prefs(google_api_key: str, sec_email: str) -> None:
"""Save API keys and email to local file (only if user opted in)."""
try:
data = {
"google_api_key": (google_api_key or "").strip(),
"sec_email": (sec_email or "").strip(),
}
with open(_PREFS_PATH, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2)
except Exception:
pass
import streamlit as st
import pandas as pd
from bs4 import BeautifulSoup
try:
import plotly.express as px
import plotly.graph_objects as go
except ImportError:
px = None
go = None
try:
from dotenv import load_dotenv
load_dotenv()
except ImportError:
pass
try:
import yfinance as yf
except ImportError:
yf = None
try:
from yahooquery import Ticker as YQTicker
from yahooquery import search as yq_search
except ImportError:
YQTicker = None
yq_search = None
# Company name → ticker for search/autocomplete (expand as needed)
COMPANY_LIST = [
("NVIDIA Corporation", "NVDA"), ("Apple Inc.", "AAPL"), ("Microsoft Corporation", "MSFT"),
("Amazon.com Inc.", "AMZN"), ("Alphabet Inc.", "GOOGL"), ("Meta Platforms Inc.", "META"),
("AMD", "AMD"), ("Intel Corporation", "INTC"), ("Qualcomm Inc.", "QCOM"), ("Tesla Inc.", "TSLA"),
("Berkshire Hathaway", "BRK.B"), ("JPMorgan Chase", "JPM"), ("Visa Inc.", "V"), ("UnitedHealth", "UNH"),
("Procter & Gamble", "PG"), ("Exxon Mobil", "XOM"), ("Johnson & Johnson", "JNJ"), ("Mastercard", "MA"),
("Chevron", "CVX"), ("Home Depot", "HD"), ("Merck", "MRK"), ("AbbVie", "ABBV"), ("Costco", "COST"),
("PepsiCo", "PEP"), ("Coca-Cola", "KO"), ("Pfizer", "PFE"), ("Walmart", "WMT"), ("Netflix", "NFLX"),
("Adobe", "ADBE"), ("Salesforce", "CRM"), ("Comcast", "CMCSA"), ("Cisco", "CSCO"), ("Oracle", "ORCL"),
("American Express", "AXP"), ("Bank of America", "BAC"), ("Wells Fargo", "WFC"), ("Verizon", "VZ"),
("AT&T", "T"), ("Walt Disney", "DIS"), ("Nike", "NKE"), ("McDonald's", "MCD"), ("Starbucks", "SBUX"),
("Goldman Sachs", "GS"), ("Morgan Stanley", "MS"), ("Target", "TGT"), ("Boeing", "BA"), ("IBM", "IBM"),
]
COMPANY_OPTIONS = [f"{t} - {n}" for n, t in COMPANY_LIST]
COMPANY_TICKER_MAP = {t: n for n, t in COMPANY_LIST}
MARKET_OPTIONS = [
"US (S&P/Dow/Nasdaq)",
"South Korea (KOSPI/KOSDAQ)",
"Japan (Nikkei)",
"UK (LSE)",
]
def get_global_ticker(ticker: str, market: str) -> str:
"""Format ticker for Yahoo Finance by market. US: as-is. South Korea: .KS or .KQ. Japan: .T. UK: .L. If ticker already has suffix, return as-is."""
if not (ticker or "").strip():
return (ticker or "").strip()
t = (ticker or "").strip()
if t.upper().endswith((".KS", ".KQ", ".T", ".L")):
return t
m = (market or "").strip()
if "US" in m or not m:
return t
if "Korea" in m or "KOSPI" in m or "KOSDAQ" in m:
return t + ".KS"
if "Japan" in m or "Nikkei" in m:
return t + ".T"
if "UK" in m or "LSE" in m:
return t + ".L"
return t
def infer_market_from_ticker(ticker: str) -> str:
"""Infer market label from ticker suffix (for Deep-Dive routing when no Market selector)."""
if not (ticker or "").strip():
return MARKET_OPTIONS[0]
t = (ticker or "").strip().upper()
if t.endswith(".KS") or t.endswith(".KQ"):
return "South Korea (KOSPI/KOSDAQ)"
if t.endswith(".T"):
return "Japan (Nikkei)"
if t.endswith(".L"):
return "UK (LSE)"
return "US (S&P/Dow/Nasdaq)"
# Top-down sector analysis: industry → top 5 S&P 500 / NASDAQ tickers
SECTORS = {
"Semiconductors & Hardware": ["NVDA", "AMD", "INTC", "TSM", "AVGO"],
"Software & Cloud": ["MSFT", "ADBE", "CRM", "PANW", "CRWD"],
"Consumer Retail": ["AMZN", "SBUX", "MCD", "WMT", "HD"],
"Financial Services": ["JPM", "BAC", "GS", "MS", "V"],
"Healthcare": ["LLY", "UNH", "JNJ", "ABBV", "MRK"],
}
def get_edgar_downloader():
from sec_edgar_downloader import Downloader
return Downloader
def _slice_html_items_1a_to_9a(raw_html: str) -> str:
"""Fast string slice: keep only Item 1A through end of Item 9A to avoid parsing 50MB+ full file. Uses .find()/regex on raw string only."""
if not raw_html or len(raw_html) < 5000:
return raw_html
start = -1
for needle in ("Item 1A", "ITEM 1A", "Item 1a"):
i = raw_html.find(needle)
if i != -1 and (start == -1 or i < start):
start = i
if start == -1:
m = re.search(r"Item\s+1A\s", raw_html, re.IGNORECASE)
start = m.start() if m else 0
else:
start = max(0, start - 200)
search_region = raw_html[start:]
end_match = re.search(r"Item\s+10\s|Item\s+12\s|Part\s+III\b|PART\s+III\b", search_region, re.IGNORECASE)
end = start + end_match.start() if end_match else len(raw_html)
end = min(end, start + 8_000_000)
return raw_html[start:end]
def _extract_text_from_html_string(html_str: str) -> str:
"""Parse HTML string with lxml; drop table/img/svg/style/script immediately to reduce memory and speed."""
if not html_str or not html_str.strip():
return ""
try:
soup = BeautifulSoup(html_str, "lxml")
except Exception:
soup = BeautifulSoup(html_str, "html.parser")
for tag in soup.find_all(["table", "img", "svg", "style", "script"]):
tag.decompose()
return soup.get_text(separator="\n", strip=True)
def extract_text_from_html(html_path: Path) -> str:
try:
with open(html_path, "r", encoding="utf-8", errors="replace") as f:
raw = f.read()
except Exception:
with open(html_path, "r", encoding="latin-1", errors="replace") as f:
raw = f.read()
chunk = _slice_html_items_1a_to_9a(raw)
return _extract_text_from_html_string(chunk)
def extract_text_from_file(file_path: Path) -> str:
suf = file_path.suffix.lower()
if suf in (".htm", ".html"):
return extract_text_from_html(file_path)
if suf == ".txt":
with open(file_path, "r", encoding="utf-8", errors="replace") as f:
text = f.read()
text = re.sub(r"<[^>]+>", " ", text)
text = re.sub(r"\s+", " ", text)
return text
return ""
# Section patterns for 10-K items
ITEM1A_PATTERNS = [
r"Item\s+1A\s*[.:]\s*Risk\s+Factors",
r"ITEM\s+1A\s*[.:]\s*Risk\s+Factors",
]
ITEM7_PATTERNS = [
r"Item\s+7\s*[.:]\s*Management['\u2019]s\s+Discussion\s+and\s+Analysis",
r"ITEM\s+7\s*[.:]\s*Management['\u2019]s\s+Discussion",
r"Item\s+7\s*[.:]\s*[\w\s]+MD&A",
]
ITEM8_PATTERNS = [
r"Item\s+8\s*[.:]\s*Financial\s+Statements",
r"ITEM\s+8\s*[.:]\s*Financial\s+Statements",
]
ITEM3_PATTERNS = [
r"Item\s+3\s*[.:]\s*Legal\s+Proceedings",
r"ITEM\s+3\s*[.:]\s*Legal\s+Proceedings",
]
ITEM9A_PATTERNS = [
r"Item\s+9A\s*[.:]\s*Controls\s+and\s+Procedures",
r"Item\s+9A\s*[.:]\s*Internal\s+Control",
r"ITEM\s+9A\s*[.:]\s*Controls",
]
def _find_section_start(text: str, patterns: list, item_num: int) -> int:
for pat in patterns:
m = re.search(pat, text, re.IGNORECASE)
if m:
return m.start()
m = re.search(r"\bItem\s+" + str(item_num) + r"\b", text, re.IGNORECASE)
return m.start() if m else -1
def find_item_section_generic(text: str, patterns: list, item_num: int, title_keywords: list, max_chars: int = 120000) -> str:
start = _find_section_start(text, patterns, item_num)
if start == -1:
pattern = re.compile(
r"\bItem\s+" + str(item_num) + r"\b[.\s]*[^\n]*(" + "|".join(re.escape(k) for k in title_keywords) + r")?",
re.IGNORECASE,
)
match = pattern.search(text)
if not match:
return ""
start = match.start()
next_item = re.search(r"\n\s*Item\s+\d+[A-Z]?\s+", text[start + 100:], re.IGNORECASE)
if next_item:
end = start + 100 + next_item.start()
else:
end = min(start + max_chars, len(text))
return text[start:end].strip()
def clean_text_for_llm(html_content: str) -> str:
"""Aggressive cleaning for LLM: strip tables/code, collapse whitespace, drop non-ASCII. Uses lxml for speed; drops table/img/svg/style/script."""
if not html_content or not html_content.strip():
return ""
try:
soup = BeautifulSoup(html_content, "lxml")
for tag in soup.find_all(["table", "img", "style", "script", "svg", "math"]):
tag.decompose()
text = soup.get_text(separator=" ")
except Exception:
text = re.sub(r"<[^>]+>", " ", html_content)
text = re.sub(r"\s+", " ", text)
text = " ".join(text.split())
text = re.sub(r"[^\x20-\x7E\n]", " ", text)
text = re.sub(r"\s+", " ", text).strip()
lines = []
for line in text.split("\n"):
line = line.strip()
if not line:
continue
if re.fullmatch(r"\d+", line) or re.fullmatch(r"[\.\-\s\-]+", line):
continue
if re.match(r"^(page\s+\d+|\d+)\s*$", line, re.IGNORECASE) and len(line) < 20:
continue
lines.append(line)
result = " ".join(lines)
result = re.sub(r"\s+", " ", result).strip()
return result
def smart_chunk(section: str, max_chars: int = 10000, head_ratio: float = 0.5) -> str:
"""Limit payload for Gemini; 10k chars ≈ 2.5k tokens for fast response."""
if not section or len(section) <= max_chars:
return section
head_size = int(max_chars * head_ratio)
tail_size = max_chars - head_size - 100
return section[:head_size] + " [ ... middle omitted ... ] " + section[-tail_size:]
def find_downloaded_10k_path(download_root: Path, ticker: str) -> Optional[Path]:
ticker_upper = ticker.upper()
for base in (download_root / "sec-edgar-filings", download_root):
path_10k = base / ticker_upper / "10-K"
if path_10k.exists():
subdirs = sorted([d for d in path_10k.iterdir() if d.is_dir()], key=lambda x: x.name, reverse=True)
if subdirs:
return subdirs[0]
for base in (download_root / "sec-edgar-filings", download_root):
if not base.exists():
continue
for company_dir in base.iterdir():
if not company_dir.is_dir():
continue
path_10k = company_dir / "10-K"
if path_10k.exists():
subdirs = sorted([d for d in path_10k.iterdir() if d.is_dir()], key=lambda x: x.name, reverse=True)
if subdirs:
return subdirs[0]
return None
def find_all_10k_filing_dirs(download_root: Path, ticker: str) -> list:
"""Return list of 10-K filing dirs sorted newest first (for multi-year comparison)."""
ticker_upper = ticker.upper()
for base in (download_root / "sec-edgar-filings", download_root):
path_10k = base / ticker_upper / "10-K"
if path_10k.exists():
subdirs = sorted([d for d in path_10k.iterdir() if d.is_dir()], key=lambda x: x.name, reverse=True)
return subdirs
return []
def get_main_10k_text(filing_dir: Path) -> str:
all_text = []
for ext in ("*.htm", "*.html", "*.txt"):
for path in filing_dir.rglob(ext):
try:
t = extract_text_from_file(path)
if len(t) > 1000:
all_text.append((path, t))
except Exception:
continue
if not all_text:
return ""
_, main_text = max(all_text, key=lambda x: len(x[1]))
return main_text
def _get_10k_cache_path(ticker: str) -> Path:
"""Path for cached 10-K sections: data/TICKER_latest.json."""
_DATA_DIR.mkdir(parents=True, exist_ok=True)
return _DATA_DIR / f"{ticker.upper()}_latest.json"
def _load_10k_from_cache(ticker: str) -> Optional[dict]:
"""Load Item 1A, 3, 7, 8, 9A (plain text) from data/ticker_latest.json. Returns None if missing. When present, no re-download or re-parse."""
path = _get_10k_cache_path(ticker)
if not path.exists():
return None
try:
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
return None
def _save_10k_to_cache(ticker: str, data: dict) -> None:
"""Save cleaned 10-K sections to data/ticker_latest.json."""
path = _get_10k_cache_path(ticker)
_DATA_DIR.mkdir(parents=True, exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=0)
def _extract_item_from_full(text: str, patterns: list, item_num: int, keywords: list, max_chars: int = 60000) -> str:
"""Extract one item section from full 10-K text."""
start = _find_section_start(text, patterns, item_num)
if start < 0:
pattern = re.compile(r"\bItem\s+" + str(item_num) + r"[A-Z]?\b[.\s]*[^\n]*", re.IGNORECASE)
match = pattern.search(text)
start = match.start() if match else -1
if start < 0:
return ""
next_item = re.search(r"\n\s*Item\s+\d+[A-Z]?\s+", text[start + 100:], re.IGNORECASE)
end = start + 100 + next_item.start() if next_item else min(start + max_chars, len(text))
return text[start:end].strip()
def download_and_extract_all_items(ticker: str, email: str) -> dict:
"""Download latest 10-K, extract Item 1A, 3, 7, 9A; clean and return (and optionally cache)."""
Downloader = get_edgar_downloader()
with tempfile.TemporaryDirectory() as tmpdir:
download_root = Path(tmpdir)
dl = Downloader("FQDC-10K-Analyzer", email, str(download_root))
dl.get("10-K", ticker.upper(), limit=1, download_details=True)
filing_dir = find_downloaded_10k_path(download_root, ticker)
if not filing_dir:
raise FileNotFoundError(f"Could not find 10-K for ticker '{ticker}'.")
full_text = get_main_10k_text(filing_dir)
if not full_text:
raise ValueError("Could not extract text from the 10-K.")
item1a = find_item_section_generic(full_text, ITEM1A_PATTERNS, 1, ["Risk", "Factors"], max_chars=80000)
item3 = _extract_item_from_full(full_text, ITEM3_PATTERNS, 3, ["Legal", "Proceedings"], max_chars=40000)
item9a = _extract_item_from_full(full_text, ITEM9A_PATTERNS, 9, ["Controls", "Procedures", "Internal"], max_chars=40000)
start7 = _find_section_start(full_text, ITEM7_PATTERNS, 7)
text_after_7 = full_text[start7:] if start7 >= 0 else full_text
item7 = find_item_section_generic(text_after_7, ITEM7_PATTERNS, 7, ["Management's Discussion", "MD&A", "Analysis"], max_chars=100000)
if not item7 and text_after_7:
item7 = text_after_7[:120000]
item8 = _extract_item_from_full(full_text, ITEM8_PATTERNS, 8, ["Financial Statements", "Supplementary Data"], max_chars=200000)
data = {
"item1a": clean_text_for_llm(item1a or ""),
"item3": clean_text_for_llm(item3 or ""),
"item9a": clean_text_for_llm(item9a or ""),
"item7": clean_text_for_llm(item7 or ""),
"item8": clean_text_for_llm(item8 or ""),
}
_save_10k_to_cache(ticker, data)
return data
def get_10k_sections(ticker: str, email: str) -> tuple[dict, str]:
"""Return (sections dict, status). status = 'cache' if loaded from file else 'downloaded'. Cached ticker skips download and parsing entirely (item1a, item3, item7, item8, item9a)."""
cached = _load_10k_from_cache(ticker)
if cached is not None:
return cached, "cache"
return download_and_extract_all_items(ticker, email), "downloaded"
def download_and_extract_item7_and_1a(ticker: str, email: str) -> tuple[str, str, str]:
"""Fetch 10-K from SEC EDGAR and return full_text, Item 1A (Risk Factors), Item 7 (MD&A). Uses cache when available."""
sections, _ = get_10k_sections(ticker, email)
return "", sections.get("item1a", "") or "", sections.get("item7", "") or ""
def download_item7_latest_and_3y_ago(ticker: str, email: str) -> tuple[Optional[str], Optional[str], Optional[str], bool]:
"""Download up to 5 10-Ks; extract Item 1A (latest only) and Item 7 from latest and from 3 years ago.
Returns (item1a_latest, item7_latest, item7_3y_ago, has_comparison). If < 4 filings, item7_3y_ago is None."""
Downloader = get_edgar_downloader()
with tempfile.TemporaryDirectory() as tmpdir:
download_root = Path(tmpdir)
dl = Downloader("FQDC-10K-Analyzer", email, str(download_root))
dl.get("10-K", ticker.upper(), limit=5, download_details=True)
filing_dirs = find_all_10k_filing_dirs(download_root, ticker)
if not filing_dirs:
raise FileNotFoundError(f"Could not find 10-K for ticker '{ticker}'.")
full_latest = get_main_10k_text(filing_dirs[0])
if not full_latest:
raise ValueError("Could not extract text from the latest 10-K.")
item1a = find_item_section_generic(
full_latest, ITEM1A_PATTERNS, 1, ["Risk", "Factors"], max_chars=80000
)
text_after_7 = full_latest[_find_section_start(full_latest, ITEM7_PATTERNS, 7):] if _find_section_start(full_latest, ITEM7_PATTERNS, 7) >= 0 else full_latest
item7_latest = find_item_section_generic(
text_after_7, ITEM7_PATTERNS, 7, ["Management's Discussion", "MD&A", "Analysis"], max_chars=100000
)
if not item7_latest and text_after_7:
item7_latest = smart_chunk(text_after_7[:120000], max_chars=20000)
item7_3y_ago = None
has_comparison = False
if len(filing_dirs) >= 4:
full_3y = get_main_10k_text(filing_dirs[3])
if full_3y:
text_3y = full_3y[_find_section_start(full_3y, ITEM7_PATTERNS, 7):] if _find_section_start(full_3y, ITEM7_PATTERNS, 7) >= 0 else full_3y
item7_3y_ago = find_item_section_generic(
text_3y, ITEM7_PATTERNS, 7, ["Management's Discussion", "MD&A", "Analysis"], max_chars=100000
)
if not item7_3y_ago and text_3y:
item7_3y_ago = smart_chunk(text_3y[:120000], max_chars=20000)
has_comparison = bool(item7_3y_ago)
return item1a or "", item7_latest or "", item7_3y_ago, has_comparison
# ---------- Gemini (qualitative only) ----------
GEMINI_MODEL = "gemini-2.0-flash"
RATE_LIMIT_WAIT_SEC = 60
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 34 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 ""
_REQUIRED_FINANCIAL_KEYS = [
"Revenue", "CostOfRevenue", "OperatingExpenses", "NetIncome",
"TotalAssets", "CurrentAssets", "CurrentLiabilities", "LongTermDebt",
"OperatingCashFlow", "SharesOutstanding",
]
@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)
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: "✅ 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: "✅ 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:
_, 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 (1218 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 1218 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()
# ---------- Financial data helpers (yahooquery primary, yfinance fallback) ----------
def _safe_float(x) -> Optional[float]:
if x is None or (isinstance(x, float) and (x != x or pd.isna(x))):
return None
try:
return float(x)
except (TypeError, ValueError):
return None
# ---------- yahooquery: map to our index/column shape (index=line items, columns=dates) ----------
# yahooquery returns DataFrame: rows = periods, columns = asOfDate, TotalRevenue, NetIncome, ...
# Use tuples for alternate column names so F-Score and Radar get correct values.
_INCOME_ROW_MAP = [
("Total Revenue", ("TotalRevenue", "OperatingRevenue", "TotalRevenue")),
("Cost Of Revenue", ("CostOfRevenue", "ReconciledCostOfRevenue")),
("Gross Profit", ("GrossProfit",)),
("Operating Income", ("OperatingIncome", "EBIT", "TotalOperatingIncomeAsReported")),
("Net Income", ("NetIncome", "NetIncomeCommonStockholders", "NetIncomeContinuousOperations", "DilutedNIAvailtoComStockholders")),
("Operating Expense", ("OperatingExpense", "OperatingExpenses", "TotalExpenses")),
("Interest Expense", ("InterestExpense", "InterestExpenseNonOperating")),
("Research And Development Expenses", ("ResearchAndDevelopment", "ResearchAndDevelopmentExpenses")),
]
_BALANCE_ROW_MAP = [
("Total Assets", ("TotalAssets",)),
("Total Stockholder Equity", ("StockholdersEquity", "CommonStockEquity", "TotalEquityGrossMinorityInterest")),
("Total Liabilities", ("TotalLiabilitiesNetMinorityInterest", "TotalLiabilities")),
("Current Assets", ("CurrentAssets",)),
("Current Liabilities", ("CurrentLiabilities",)),
("Long Term Debt", ("LongTermDebt", "LongTermDebtAndCapitalLeaseObligation")),
("Total Debt", ("TotalDebt",)),
("Share Issued", ("OrdinarySharesNumber", "ShareIssued", "BasicAverageShares", "DilutedAverageShares")),
("Cash And Cash Equivalents", ("CashAndCashEquivalents", "CashCashEquivalentsAndShortTermInvestments", "EndCashPosition")),
("Retained Earnings", ("RetainedEarnings",)),
]
_CASHFLOW_ROW_MAP = [
("Operating Cash Flow", ("OperatingCashFlow", "CashFromOperatingActivities")),
("Capital Expenditure", ("CapitalExpenditure", "CapitalExpenditures")),
]
def _yq_df_to_our_shape(df: pd.DataFrame, row_map: list, date_col: str = "asOfDate") -> Optional[pd.DataFrame]:
"""Convert yahooquery DataFrame (rows=periods, columns=line items) to our shape: index=line names, columns=dates."""
if df is None or df.empty or date_col not in df.columns:
return None
df = df.dropna(subset=[date_col]).sort_values(date_col, ascending=False).head(5)
if df.empty:
return None
dates = df[date_col].astype(str).str[:10].tolist()
data = {}
for our_name, yq_col in row_map:
cols = (yq_col,) if isinstance(yq_col, str) else yq_col
val_col = next((c for c in cols if c in df.columns), None)
if val_col is None:
data[our_name] = [None] * len(dates)
continue
data[our_name] = [_safe_float(v) for v in df[val_col].tolist()]
out = pd.DataFrame(data, index=dates).T
out.columns = dates
return out
def _share_issued_from_yq_balance(df_bal: pd.DataFrame) -> Optional[pd.Series]:
"""Try OrdinarySharesNumber then ShareIssued for shares outstanding in yahooquery balance."""
if df_bal is None or df_bal.empty:
return None
for col in ("OrdinarySharesNumber", "ShareIssued"):
if col in df_bal.columns and "asOfDate" in df_bal.columns:
s = df_bal.set_index("asOfDate")[col].sort_index(ascending=False)
s.index = s.index.astype(str).str[:10]
return s.reindex(s.index) # keep as series with date index
return None
@st.cache_data(ttl=300)
def _get_annual_financials_balance_cashflow_yahooquery(ticker: str) -> tuple:
"""Fetch income, balance, cash flow from yahooquery. Return (fin_df, bal_df, cf_df) with index=line items, columns=dates. TTM fallback if annual insufficient."""
if not YQTicker or not ticker:
return (None, None, None)
try:
yq = YQTicker(ticker.upper())
inc_a = yq.income_statement(frequency="a", trailing=False)
bal_a = yq.balance_sheet(frequency="a", trailing=False)
cf_a = yq.cash_flow(frequency="a", trailing=False)
if inc_a is None or inc_a.empty or bal_a is None or bal_a.empty:
inc_q = yq.income_statement(frequency="q", trailing=False)
bal_q = yq.balance_sheet(frequency="q", trailing=False)
cf_q = yq.cash_flow(frequency="q", trailing=False)
# Build TTM: need at least 2 periods for Piotroski/Radar; use last 4Q and previous 4Q when 8+ quarters
if inc_q is not None and not inc_q.empty and len(inc_q) >= 4:
ttm0 = inc_q.head(4).sum(numeric_only=True)
row0 = ttm0.to_dict() if hasattr(ttm0, "to_dict") else dict(ttm0)
row0["asOfDate"] = inc_q["asOfDate"].iloc[0] if "asOfDate" in inc_q.columns else "TTM0"
rows_inc = [row0]
if len(inc_q) >= 8:
ttm1 = inc_q.iloc[4:8].sum(numeric_only=True)
row1 = ttm1.to_dict() if hasattr(ttm1, "to_dict") else dict(ttm1)
row1["asOfDate"] = inc_q["asOfDate"].iloc[4] if "asOfDate" in inc_q.columns else "TTM1"
rows_inc.append(row1)
inc_a = pd.DataFrame(rows_inc)
if bal_q is not None and not bal_q.empty:
bal_a = bal_q.head(2) if (bal_a is None or bal_a.empty) else bal_a
if cf_q is not None and not cf_q.empty and len(cf_q) >= 4 and (cf_a is None or cf_a.empty):
ttm0_cf = cf_q.head(4).sum(numeric_only=True)
row0_cf = ttm0_cf.to_dict() if hasattr(ttm0_cf, "to_dict") else dict(ttm0_cf)
row0_cf["asOfDate"] = cf_q["asOfDate"].iloc[0] if "asOfDate" in cf_q.columns else "TTM0"
rows_cf = [row0_cf]
if len(cf_q) >= 8:
ttm1_cf = cf_q.iloc[4:8].sum(numeric_only=True)
row1_cf = ttm1_cf.to_dict() if hasattr(ttm1_cf, "to_dict") else dict(ttm1_cf)
row1_cf["asOfDate"] = cf_q["asOfDate"].iloc[4] if "asOfDate" in cf_q.columns else "TTM1"
rows_cf.append(row1_cf)
cf_a = pd.DataFrame(rows_cf)
fin_df = _yq_df_to_our_shape(inc_a, _INCOME_ROW_MAP)
bal_df = _yq_df_to_our_shape(bal_a, _BALANCE_ROW_MAP)
if bal_df is not None and "Share Issued" not in bal_df.index and bal_a is not None and not bal_a.empty:
for sh_col in ("OrdinarySharesNumber", "ShareIssued"):
if sh_col in bal_a.columns:
row = {"Share Issued": [_safe_float(bal_a[sh_col].iloc[0])]}
if bal_df is not None and not bal_df.empty:
d = str(bal_a["asOfDate"].iloc[0])[:10] if "asOfDate" in bal_a.columns else bal_df.columns[0]
extra = pd.DataFrame(row, index=[d]).T
extra.columns = [d]
bal_df = pd.concat([bal_df, extra], axis=0)
break
cf_df = _yq_df_to_our_shape(cf_a, _CASHFLOW_ROW_MAP)
return (fin_df, bal_df, cf_df)
except Exception:
return (None, None, None)
# ---------- Raw statements & FCF = OCF - CapEx ----------
def _get_row_series(df: pd.DataFrame, *names: str) -> Optional[pd.Series]:
if df is None or df.empty:
return None
for name in names:
try:
if name in df.index:
return df.loc[name].copy()
except (KeyError, TypeError):
continue
return None
def _fin_or_bal_empty(df) -> bool:
"""True if DataFrame is missing, empty, or has no columns (e.g. yfinance returned empty)."""
return df is None or df.empty or (hasattr(df, "columns") and len(df.columns) == 0)
@st.cache_data(ttl=300)
def _get_annual_financials_balance_cashflow(ticker: str) -> tuple:
"""Return (fin_df, bal_df, cf_df). Uses yahooquery first; if missing/fail, falls back to yfinance with TTM when needed."""
if not ticker:
return (None, None, None)
fin_df, bal_df, cf_df = _get_annual_financials_balance_cashflow_yahooquery(ticker)
if fin_df is not None and not fin_df.empty and bal_df is not None and not bal_df.empty:
return (fin_df, bal_df, cf_df)
if not yf:
return (None, None, None)
try:
t = yf.Ticker(ticker.upper())
fin = getattr(t, "financials", None)
bal = getattr(t, "balance_sheet", None)
cf = getattr(t, "cashflow", None)
if _fin_or_bal_empty(fin):
qf = getattr(t, "quarterly_financials", None)
if qf is not None and not qf.empty:
n = len(qf.columns)
if n >= 8:
c0 = qf.iloc[:, :4].sum(axis=1)
c1 = qf.iloc[:, 4:8].sum(axis=1)
fin = pd.concat([c0, c1], axis=1)
fin.columns = ["TTM0", "TTM1"]
elif n >= 5:
c0 = qf.iloc[:, :4].sum(axis=1)
c1 = qf.iloc[:, 4:n].sum(axis=1)
fin = pd.concat([c0, c1], axis=1)
fin.columns = ["TTM0", "TTM1"]
else:
fin = qf.iloc[:, : min(4, n)].sum(axis=1).to_frame("TTM0")
if _fin_or_bal_empty(bal):
qb = getattr(t, "quarterly_balance_sheet", None)
if qb is not None and not qb.empty:
n = len(qb.columns)
bal = qb.iloc[:, : min(2, n)].copy()
if bal.shape[1] == 1:
bal.columns = ["B0"]
else:
bal.columns = ["B0", "B1"]
if _fin_or_bal_empty(cf):
qc = getattr(t, "quarterly_cashflow", None)
if qc is not None and not qc.empty:
n = len(qc.columns)
cf = qc.iloc[:, : min(4, n)].sum(axis=1).to_frame("TTM0")
return (fin, bal, cf)
except Exception:
return (None, None, None)
@st.cache_data(ttl=300)
def get_sector_industry(ticker: str) -> dict:
"""Return sector and industry from yfinance. Fallback to N/A."""
if not yf:
return {"sector": "N/A", "industry": "N/A"}
try:
t = yf.Ticker(ticker.upper())
info = t.info or {}
sector = (info.get("sector") or info.get("sectorDisp") or "N/A").strip() or "N/A"
industry = (info.get("industry") or info.get("industryDisp") or "N/A").strip() or "N/A"
return {"sector": sector, "industry": industry}
except Exception:
return {"sector": "N/A", "industry": "N/A"}
@st.cache_data(ttl=300)
def get_5yr_financial_trend(ticker: str) -> pd.DataFrame:
"""Extract up to 5 years: Revenue, Net Income, Operating Margin, FCF (OCF - CapEx). Handles missing years."""
if not yf:
return pd.DataFrame()
try:
t = yf.Ticker(ticker.upper())
financials = t.financials # annual
cashflow = t.cashflow
if financials is None or financials.empty or cashflow is None or cashflow.empty:
return pd.DataFrame()
dates = sorted(financials.columns.tolist(), reverse=True)[:5]
ocf = _get_row_series(cashflow, "Operating Cash Flow", "Cash From Operating Activities", "Cash From Operations")
capx = _get_row_series(cashflow, "Capital Expenditure", "Capital Expenditures", "Purchase Of Property Plant And Equipment")
revenue = _get_row_series(financials, "Total Revenue", "Revenue", "Net Revenue")
ni = _get_row_series(financials, "Net Income", "Net Income Common Stockholders")
op_income = _get_row_series(financials, "Operating Income", "EBIT")
rows = []
cashflow_cols = list(cashflow.columns) if cashflow is not None else []
for d in dates:
yr = d.year if hasattr(d, "year") else int(str(d)[:4])
rev = _safe_float(revenue.get(d)) if revenue is not None and d in revenue.index else None
net_i = _safe_float(ni.get(d)) if ni is not None and d in ni.index else None
op_i = _safe_float(op_income.get(d)) if op_income is not None and d in op_income.index else None
oper_margin = (op_i / rev * 100) if (op_i is not None and rev and rev != 0) else ((net_i / rev * 100) if (net_i is not None and rev and rev != 0) else None)
ocf_val = _safe_float(ocf.get(d)) if ocf is not None and d in ocf.index else None
if ocf_val is None and ocf is not None and cashflow_cols:
for c in cashflow_cols:
if (getattr(c, "year", None) or int(str(c)[:4])) == yr:
ocf_val = _safe_float(ocf.get(c))
break
capx_val = _safe_float(capx.get(d)) if capx is not None and d in capx.index else None
if capx_val is None and capx is not None and cashflow_cols:
for c in cashflow_cols:
if (getattr(c, "year", None) or int(str(c)[:4])) == yr:
capx_val = _safe_float(capx.get(c))
break
if ocf_val is not None and capx_val is not None:
fcf = ocf_val - capx_val
elif ocf_val is not None:
fcf = ocf_val
else:
fcf = None
rows.append({
"Year": yr,
"Revenue": rev,
"Net Income": net_i,
"Operating Margin %": round(oper_margin, 2) if oper_margin is not None else None,
"FCF": fcf,
})
return pd.DataFrame(rows)
except Exception:
return pd.DataFrame()
def _format_shares_display(shares: float) -> str:
"""Format share count for UI, e.g. 15.42B Shares or 1.2B Shares."""
if shares is None or shares <= 0:
return "N/A"
s = float(shares)
if s >= 1e9:
return f"{s / 1e9:.2f}B Shares"
if s >= 1e6:
return f"{s / 1e6:.2f}M Shares"
if s >= 1e3:
return f"{s / 1e3:.2f}K Shares"
return f"{s:.0f} Shares"
@st.cache_data(ttl=300)
def get_dcf_inputs(ticker: str) -> dict:
"""FCF, Cash, Total Debt, Shares: from yahooquery (via _get_annual_financials) or yfinance fallback."""
out = {"fcf": None, "total_debt": 0.0, "cash": 0.0, "shares": None}
if not ticker:
return out
try:
fin, bal, cf = _get_annual_financials_balance_cashflow(ticker)
if bal is not None and not bal.empty and cf is not None and not cf.empty:
sh = _get_row_series(bal, "Share Issued")
out["shares"] = _safe_float(sh.iloc[0]) if sh is not None and len(sh) > 0 else None
td = _get_row_series(bal, "Total Debt")
out["total_debt"] = float(td.iloc[0] or 0) if td is not None and len(td) > 0 else 0.0
cash_s = _get_row_series(bal, "Cash And Cash Equivalents")
out["cash"] = float(cash_s.iloc[0] or 0) if cash_s is not None and len(cash_s) > 0 else 0.0
ocf = _get_row_series(cf, "Operating Cash Flow")
capx = _get_row_series(cf, "Capital Expenditure")
if ocf is not None and len(ocf) > 0:
ocf_val = _safe_float(ocf.iloc[0])
capx_val = _safe_float(capx.iloc[0]) if capx is not None and len(capx) > 0 else 0.0
if ocf_val is not None:
out["fcf"] = ocf_val - (capx_val or 0)
if out.get("fcf") is not None or out.get("shares") is not None:
return out
except Exception:
pass
if not yf:
return out
try:
t = yf.Ticker(ticker.upper())
info = t.info or {}
fast_info = getattr(t, "fast_info", None)
cashflow = getattr(t, "cashflow", None)
if cashflow is None or cashflow.empty:
cashflow = getattr(t, "quarterly_cashflow", None)
balance = getattr(t, "balance_sheet", None)
if balance is None or balance.empty:
balance = getattr(t, "quarterly_balance_sheet", None)
# ----- Shares Outstanding: multi-step fallback (no manual by default) -----
shares = None
if fast_info is not None:
try:
s = getattr(fast_info, "shares", None)
if s is None and hasattr(fast_info, "get"):
s = fast_info.get("shares")
if s is not None and float(s) > 0:
shares = float(s)
except (TypeError, ValueError, AttributeError):
pass
if shares is None:
for key in ("sharesOutstanding", "Shares Outstanding", "impliedSharesOutstanding", "Float Shares"):
s = info.get(key)
if s is not None and float(s) > 0:
shares = float(s)
break
if shares is None and balance is not None and not balance.empty:
try:
if "Share Issued" in balance.index:
shares = _safe_float(balance.loc["Share Issued"].iloc[0])
if (shares is None or shares <= 0) and "Ordinary Shares Number" in balance.index:
shares = _safe_float(balance.loc["Ordinary Shares Number"].iloc[0])
except (KeyError, TypeError, IndexError):
pass
out["shares"] = shares if (shares is not None and shares > 0) else None
# ----- Total Debt: fast_info → info → balance -----
total_debt = None
if fast_info is not None:
try:
d = getattr(fast_info, "total_debt", None) or (fast_info.get("total_debt") if hasattr(fast_info, "get") else None)
if d is not None and float(d) >= 0:
total_debt = float(d)
except (TypeError, ValueError, AttributeError):
pass
if total_debt is None:
total_debt = info.get("Total Debt")
if total_debt is None and balance is not None and not balance.empty:
try:
if "Total Debt" in balance.index:
total_debt = _safe_float(balance.loc["Total Debt"].iloc[0])
except (KeyError, TypeError, IndexError):
pass
out["total_debt"] = float(total_debt) if total_debt is not None else 0.0
# ----- Cash: fast_info → info → balance -----
cash = None
if fast_info is not None:
try:
c = getattr(fast_info, "cash", None) or (fast_info.get("cash") if hasattr(fast_info, "get") else None)
if c is not None and float(c) >= 0:
cash = float(c)
except (TypeError, ValueError, AttributeError):
pass
if cash is None:
cash = info.get("Cash And Cash Equivalents") or info.get("Cash")
if cash is None and balance is not None and not balance.empty:
try:
for row in ("Cash And Cash Equivalents", "Cash Cash Equivalents And Short Term Investments", "Cash"):
if row in balance.index:
cash = _safe_float(balance.loc[row].iloc[0])
if cash is not None:
break
except (KeyError, TypeError, IndexError):
pass
out["cash"] = float(cash) if cash is not None else 0.0
# ----- Base FCF = OCF - CapEx -----
ocf = _get_row_series(cashflow, "Operating Cash Flow", "Cash From Operating Activities", "Cash From Operations") if cashflow is not None else None
capx = _get_row_series(cashflow, "Capital Expenditure", "Capital Expenditures", "Purchase Of Property Plant And Equipment") if cashflow is not None else None
if ocf is not None and len(ocf) > 0:
latest_date = ocf.index[0]
ocf_val = _safe_float(ocf.iloc[0])
capx_val = _safe_float(capx.get(latest_date)) if (capx is not None and hasattr(capx, "index") and latest_date in getattr(capx, "index", [])) else (_safe_float(capx.iloc[0]) if capx is not None and len(capx) > 0 else None)
if capx_val is None:
capx_val = 0.0
if ocf_val is not None:
latest_fcf = ocf_val - capx_val
if latest_fcf == latest_fcf and not (isinstance(latest_fcf, float) and pd.isna(latest_fcf)):
out["fcf"] = latest_fcf
return out
except Exception:
return out
def dcf_intrinsic_value(fcf: float, wacc: float, terminal_growth: float, fcf_growth: float, years: int = 5) -> float:
"""5-year DCF: project FCF with fcf_growth, then terminal value; discount at WACC. Returns enterprise value. Robust: avoids div by zero."""
if fcf is None or fcf <= 0:
return 0.0
if wacc <= terminal_growth or wacc <= 0:
return 0.0
pv = 0.0
fcft = float(fcf)
for t in range(1, years + 1):
pv += fcft / ((1 + wacc) ** t)
fcft *= (1 + fcf_growth)
terminal_fcf = fcft
tv = terminal_fcf * (1 + terminal_growth) / (wacc - terminal_growth)
pv += tv / ((1 + wacc) ** years)
return pv
def dcf_10y_2stage(fcf: float, wacc: float, term_growth: float, fcf_growth: float) -> float:
"""10-Year 2-Stage DCF. Stage 1 (Y15): FCF grows at fcf_growth. Stage 2 (Y610): growth linearly fades from fcf_growth to term_growth by Y10. TV at Y10; discount all to PV."""
if fcf is None or fcf <= 0:
return 0.0
if wacc <= term_growth or wacc <= 0:
return 0.0
pv = 0.0
fcft = float(fcf)
for t in range(1, 6):
pv += fcft / ((1 + wacc) ** t)
fcft *= (1 + fcf_growth)
for t in range(6, 11):
fade = (t - 6) / 4.0
g_t = fcf_growth + fade * (term_growth - fcf_growth)
fcft *= (1 + g_t)
pv += fcft / ((1 + wacc) ** t)
tv = fcft * (1 + term_growth) / (wacc - term_growth)
pv += tv / ((1 + wacc) ** 10)
return pv
def excel_style_dcf(fcf_base: float, wacc: float, term_growth: float, fcf_growth: float, total_debt: float, cash: float, shares: float) -> dict:
"""10Y 2-Stage DCF: EV = PV(FCF Y110) + PV(TV); Equity = EV - Debt + Cash; Value per share = Equity / Shares."""
ev = dcf_10y_2stage(fcf_base, wacc, term_growth, fcf_growth)
equity = ev - total_debt + cash
shares_safe = float(shares) if (shares is not None and float(shares) > 0) else None
value_per_share = (equity / shares_safe) if shares_safe else None
return {"ev": ev, "equity_value": equity, "value_per_share": value_per_share, "shares": shares_safe}
# Aswath Damodaran sector WACC (approx. 2024/2025 baseline). Used for reference in DCF panel.
DAMODARAN_WACC = {
"Software": 8.5,
"Retail": 7.5,
"Hardware": 9.0,
"Financials": 8.0,
"Healthcare": 7.2,
"Consumer": 7.5,
"Technology": 8.5,
"Industrial": 7.8,
"Energy": 8.2,
"Utilities": 6.5,
}
DAMODARAN_ERP_PCT = 4.6
DAMODARAN_RF_PCT = 4.2
def _damodaran_wacc_for_sector(sector: str) -> float:
"""Map yfinance sector string to closest Damodaran WACC. Default 8.0%."""
if not sector:
return 8.0
s = (sector or "").lower()
if "software" in s or "technology" in s or "internet" in s:
return DAMODARAN_WACC.get("Software", 8.5)
if "hardware" in s or "semiconductor" in s:
return DAMODARAN_WACC.get("Hardware", 9.0)
if "retail" in s or "consumer" in s or "cyclical" in s:
return DAMODARAN_WACC.get("Retail", 7.5)
if "financial" in s or "bank" in s or "insurance" in s:
return DAMODARAN_WACC.get("Financials", 8.0)
if "health" in s or "pharma" in s:
return DAMODARAN_WACC.get("Healthcare", 7.2)
if "industrial" in s:
return DAMODARAN_WACC.get("Industrial", 7.8)
if "energy" in s or "oil" in s:
return DAMODARAN_WACC.get("Energy", 8.2)
if "utilities" in s:
return DAMODARAN_WACC.get("Utilities", 6.5)
return 8.0
@st.cache_data(ttl=300)
def get_analyst_consensus(ticker: str) -> dict:
"""Fetch analyst consensus from yfinance: targetMeanPrice, recommendationKey, revenueGrowth, earningsGrowth. Missing → N/A."""
out = {"targetMeanPrice": "N/A", "recommendationKey": "N/A", "revenueGrowth": "N/A", "earningsGrowth": "N/A"}
if not yf or not ticker:
return out
try:
t = yf.Ticker(ticker.upper())
info = t.info or {}
tp = info.get("targetMeanPrice")
if tp is not None:
try:
out["targetMeanPrice"] = f"${float(tp):.2f}"
except (TypeError, ValueError):
out["targetMeanPrice"] = str(tp)
rec = info.get("recommendationKey") or info.get("recommendation")
if rec is not None:
out["recommendationKey"] = str(rec)
rg = info.get("revenueGrowth")
if rg is not None:
try:
out["revenueGrowth"] = f"{float(rg) * 100:.1f}%"
except (TypeError, ValueError):
out["revenueGrowth"] = str(rg)
eg = info.get("earningsGrowth")
if eg is not None:
try:
out["earningsGrowth"] = f"{float(eg) * 100:.1f}%"
except (TypeError, ValueError):
out["earningsGrowth"] = str(eg)
return out
except Exception:
return out
@st.cache_data(ttl=300)
def get_dcf_smart_defaults(ticker: str) -> dict:
"""Smart default assumptions: WACC from CAPM (Beta), Terminal Growth = 2.5%, FCF Growth from revenueGrowth/earningsGrowth or 8%."""
out = {"wacc_pct": 10.0, "term_growth_pct": 2.5, "fcf_growth_pct": 8.0}
if not yf or not ticker:
return out
try:
t = yf.Ticker(ticker.upper())
info = t.info or {}
beta = info.get("beta")
if beta is None:
beta = 1.0
else:
try:
beta = float(beta)
except (TypeError, ValueError):
beta = 1.0
risk_free = 4.0
market_risk_premium = 5.0
calculated_wacc = risk_free + (beta * market_risk_premium)
out["wacc_pct"] = round(min(20.0, max(4.0, calculated_wacc)), 1)
out["term_growth_pct"] = 2.5
rev_growth = info.get("revenueGrowth") or info.get("earningsGrowth")
if rev_growth is not None:
try:
g = float(rev_growth)
out["fcf_growth_pct"] = round(min(30.0, max(-10.0, g * 100)), 1)
except (TypeError, ValueError):
pass
return out
except Exception:
return out
# ---------- yfinance: Comps (multiples) ----------
@st.cache_data(ttl=300)
def get_comps_data(tickers: tuple) -> pd.DataFrame:
"""Fetch Forward P/E, EV/EBITDA, P/B using forwardPE, enterpriseToEbitda, priceToBook. Missing → None (display as N/A). Robust per-ticker error handling."""
if not yf:
return pd.DataFrame()
rows = []
for sym in tickers:
sym = str(sym).strip().upper()
if not sym:
continue
try:
t = yf.Ticker(sym)
info = t.info or {}
forward_pe = info.get("forwardPE") or info.get("Forward PE") or info.get("trailingPE") or info.get("Trailing PE")
ev_ebitda = info.get("enterpriseToEbitda")
if ev_ebitda is None:
ev, ebitda = info.get("enterpriseValue"), info.get("ebitda")
if ev is not None and ebitda is not None and ebitda != 0:
ev_ebitda = ev / ebitda
pb = info.get("priceToBook") or info.get("Price To Book")
rows.append({
"Ticker": sym,
"Forward P/E": round(float(forward_pe), 2) if forward_pe is not None and _safe_float(forward_pe) is not None else None,
"EV/EBITDA": round(float(ev_ebitda), 2) if ev_ebitda is not None and _safe_float(ev_ebitda) is not None else None,
"P/B": round(float(pb), 2) if pb is not None and _safe_float(pb) is not None else None,
})
except Exception:
rows.append({"Ticker": sym, "Forward P/E": None, "EV/EBITDA": None, "P/B": None})
if not rows:
return pd.DataFrame()
return pd.DataFrame(rows)
# ---------- DuPont, Altman Z, Red Flags, YoY (23 years) ----------
def _na(x):
"""Return N/A for None/NaN, else value (for display)."""
if x is None or (isinstance(x, float) and (pd.isna(x) or x != x)):
return "N/A"
return x
@st.cache_data(ttl=300)
def get_dupont_altman_redflags_yoy(ticker: str) -> dict:
"""Returns DuPont (3-step ROE), Altman Z-Score, red flags, YoY. Uses yahooquery then yfinance with TTM fallback."""
try:
fin, bal, _ = _get_annual_financials_balance_cashflow(ticker)
if fin is None or fin.empty or bal is None or bal.empty:
return {}
t = yf.Ticker(ticker.upper())
info = t.info or {}
# TTM columns: keep order TTM0 (current), TTM1 (prior). Else use date sort (newest first).
col_list = fin.columns.tolist()
if col_list and str(col_list[0]).startswith("TTM"):
dates = col_list[:3]
else:
dates = sorted(col_list, reverse=True)[:3]
if not dates:
return {}
rev = _get_row_series(fin, "Total Revenue", "Revenue", "Net Revenue")
ni = _get_row_series(fin, "Net Income", "Net Income Common Stockholders")
ebit = _get_row_series(fin, "Operating Income", "EBIT")
gross = _get_row_series(fin, "Gross Profit")
interest = _get_row_series(fin, "Interest Expense", "Interest Expense Net")
total_assets = _get_row_series(bal, "Total Assets")
total_equity = _get_row_series(bal, "Total Stockholder Equity", "Stockholders Equity", "Total Equity Gross Minority Interest")
current_assets = _get_row_series(bal, "Current Assets")
current_liab = _get_row_series(bal, "Current Liabilities")
retained = _get_row_series(bal, "Retained Earnings")
total_liab = _get_row_series(bal, "Total Liabilities")
market_cap = info.get("marketCap") or info.get("Market Cap")
def _v(s, d):
if s is None or d not in s.index:
return None
return _safe_float(s.get(d))
rows = []
for i, d in enumerate(dates):
yr = int(str(d)[:4]) if (isinstance(d, str) and str(d)[:4].isdigit()) else (d.year if hasattr(d, "year") else (2024 - i))
r = _v(rev, d)
net_i = _v(ni, d)
ta = _v(total_assets, d)
te = _v(total_equity, d)
if ta and ta > 0 and te and te > 0 and r and r != 0:
npm = (net_i / r * 100) if net_i is not None else None
at = r / ta if r and ta else None
em = ta / te if ta and te else None
roe = (net_i / te * 100) if (net_i and te) else (npm * at * em / 100 if (npm and at and em) else None)
else:
npm = at = em = roe = None
gross_p = _v(gross, d)
gross_margin = (gross_p / r * 100) if (gross_p and r and r != 0) else None
op_inc = _v(ebit, d)
op_margin = (op_inc / r * 100) if (op_inc and r and r != 0) else None
ca = _v(current_assets, d)
cl = _v(current_liab, d)
current_ratio = (ca / cl) if (ca and cl and cl != 0) else None
int_exp = _v(interest, d)
if op_inc is not None and int_exp is not None and int_exp != 0:
_ic = op_inc / int_exp
interest_cov = round(_ic, 2) if (_ic == _ic and not (isinstance(_ic, float) and (pd.isna(_ic) or _ic != _ic))) else None
else:
interest_cov = None # N/A when Interest Expense is 0 or missing (avoid nan%)
rows.append({
"Year": yr,
"Revenue": r, "Net Income": net_i,
"NPM %": round(npm, 2) if npm is not None else None,
"Asset Turnover": round(at, 4) if at is not None else None,
"Equity Mult.": round(em, 2) if em is not None else None,
"ROE %": round(roe, 2) if roe is not None else None,
"Gross Margin %": round(gross_margin, 2) if gross_margin is not None else None,
"Operating Margin %": round(op_margin, 2) if op_margin is not None else None,
"Current Ratio": round(current_ratio, 2) if current_ratio is not None else None,
"Interest Coverage": interest_cov,
})
dupont_df = pd.DataFrame(rows)
yoy = []
if len(dupont_df) >= 2:
for col in ["NPM %", "ROE %", "Gross Margin %", "Operating Margin %", "Current Ratio", "Interest Coverage"]:
if col not in dupont_df.columns:
continue
cur = dupont_df[col].iloc[0]
prev = dupont_df[col].iloc[1]
if cur is not None and prev is not None and prev != 0 and not (pd.isna(cur) or pd.isna(prev)):
if "Margin" in col or "NPM" in col or "ROE" in col:
chg_pp = (cur - prev) # percentage point change (e.g. 7.0 = 7%)
if pd.isna(chg_pp) or chg_pp != chg_pp:
continue
yoy.append({"Ratio": col, "Latest": cur, "Prior": prev, "YoY (pp)": round(chg_pp, 2), "Comment": f"{'Improved' if chg_pp > 0 else 'Declined'} by {abs(chg_pp):.1f}% YoY"})
else:
pct = (cur - prev) / abs(prev) * 100
if pd.isna(pct) or pct != pct:
continue
yoy.append({"Ratio": col, "Latest": cur, "Prior": prev, "YoY %": round(pct, 1), "Comment": f"{'Up' if pct > 0 else 'Down'} {abs(round(pct, 1))}% YoY"})
latest_bal_d = bal.columns[0]
wc = (_v(current_assets, latest_bal_d) or 0) - (_v(current_liab, latest_bal_d) or 0)
ta_l = _v(total_assets, latest_bal_d)
re_l = _v(retained, latest_bal_d)
tl_l = _v(total_liab, latest_bal_d)
ebit_l = _v(ebit, fin.columns[0])
sales_l = _v(rev, fin.columns[0])
altman_z = None
if ta_l and ta_l > 0 and market_cap is not None and tl_l and tl_l != 0 and sales_l:
a = wc / ta_l
b = (re_l or 0) / ta_l
c = (ebit_l or 0) / ta_l
d = market_cap / tl_l
e = sales_l / ta_l
altman_z = 1.2 * a + 1.4 * b + 3.3 * c + 0.6 * d + 1.0 * e
red_flags = []
if len(dupont_df) > 0:
row0 = dupont_df.iloc[0]
cr = row0.get("Current Ratio")
if cr is not None and cr < 1.0:
red_flags.append({"metric": "Current Ratio", "value": cr, "threshold": 1.0, "flag": "WARNING", "comment": "Current assets do not cover current liabilities; liquidity risk."})
ic = row0.get("Interest Coverage")
if ic is not None and ic < 1.5:
red_flags.append({"metric": "Interest Coverage", "value": ic, "threshold": 1.5, "flag": "WARNING", "comment": "EBIT barely covers interest; default risk."})
return {
"dupont": dupont_df,
"yoy": yoy,
"altman_z": round(altman_z, 2) if altman_z is not None else None,
"red_flags": red_flags,
}
except (KeyError, TypeError, ZeroDivisionError, IndexError) as e:
return {}
except Exception:
return {}
@st.cache_data(ttl=300)
def get_quarterly_momentum(ticker: str) -> dict:
"""Last 4 quarters Revenue and Net Income from quarterly_financials; QoQ growth for most recent quarter. Returns {df, qoq_revenue_pct, qoq_ni_pct} or empty."""
out = {"df": None, "qoq_revenue_pct": None, "qoq_ni_pct": None}
if not yf or not ticker:
return out
try:
t = yf.Ticker(ticker.upper())
qfin = getattr(t, "quarterly_financials", None)
if qfin is None or qfin.empty or len(qfin.columns) < 2:
return out
rev = _get_row_series(qfin, "Total Revenue", "Revenue", "Net Revenue")
ni = _get_row_series(qfin, "Net Income", "Net Income Common Stockholders")
if rev is None and ni is None:
return out
cols = list(qfin.columns)[:4]
rows = []
for c in cols:
try:
if hasattr(c, "strftime"):
q = (c.month - 1) // 3 + 1 if hasattr(c, "month") else 1
label = c.strftime("%Y") + f"-Q{q}"
else:
label = str(c)[:12]
except Exception:
label = str(c)[:12]
r_val = _safe_float(rev.loc[c]) if rev is not None and c in rev.index else None
n_val = _safe_float(ni.loc[c]) if ni is not None and c in ni.index else None
rows.append({"Quarter": label, "Revenue": r_val, "Net Income": n_val})
out["df"] = pd.DataFrame(rows)
if len(rows) >= 2:
r0, r1 = rows[0].get("Revenue"), rows[1].get("Revenue")
n0, n1 = rows[0].get("Net Income"), rows[1].get("Net Income")
if r0 is not None and r1 is not None and r1 != 0:
out["qoq_revenue_pct"] = round((r0 - r1) / abs(r1) * 100, 1)
if n0 is not None and n1 is not None and n1 != 0:
out["qoq_ni_pct"] = round((n0 - n1) / abs(n1) * 100, 1)
return out
except Exception:
return out
@st.cache_data(ttl=300)
def get_quarterly_ratio_changes(ticker: str) -> list:
"""QoQ ratio changes: NPM %, ROE %, Gross Margin %, Operating Margin %, Current Ratio, Interest Coverage. Latest quarter vs previous. Returns list of {Metric, Current, Change, Trend}."""
out = []
if not yf or not ticker:
return out
try:
t = yf.Ticker(ticker.upper())
qf = getattr(t, "quarterly_financials", None)
qb = getattr(t, "quarterly_balance_sheet", None)
if qf is None or qf.empty or qb is None or qb.empty or len(qf.columns) < 2 or len(qb.columns) < 2:
return out
rev = _get_row_series(qf, "Total Revenue", "Revenue", "Net Revenue")
ni = _get_row_series(qf, "Net Income", "Net Income Common Stockholders")
gross = _get_row_series(qf, "Gross Profit")
ebit = _get_row_series(qf, "Operating Income", "EBIT")
interest = _get_row_series(qf, "Interest Expense", "Interest Expense Net")
ta = _get_row_series(qb, "Total Assets")
te = _get_row_series(qb, "Total Stockholder Equity", "Stockholders Equity", "Total Equity Gross Minority Interest")
ca = _get_row_series(qb, "Current Assets")
cl = _get_row_series(qb, "Current Liabilities")
def v(s, col):
if s is None or col not in s.index:
return None
return _safe_float(s.get(col))
c0, c1 = qf.columns[0], qf.columns[1]
b0, b1 = qb.columns[0], qb.columns[1]
r0, r1 = v(rev, c0), v(rev, c1)
n0, n1 = v(ni, c0), v(ni, c1)
g0, g1 = v(gross, c0), v(gross, c1)
e0, e1 = v(ebit, c0), v(ebit, c1)
i0, i1 = v(interest, c0), v(interest, c1)
ta0, ta1 = v(ta, b0), v(ta, b1)
te0, te1 = v(te, b0), v(te, b1)
ca0, ca1 = v(ca, b0), v(ca, b1)
cl0, cl1 = v(cl, b0), v(cl, b1)
npm0 = (n0 / r0 * 100) if (n0 is not None and r0 and r0 != 0) else None
npm1 = (n1 / r1 * 100) if (n1 is not None and r1 and r1 != 0) else None
roe0 = (n0 / te0 * 100) if (n0 is not None and te0 and te0 != 0) else None
roe1 = (n1 / te1 * 100) if (n1 is not None and te1 and te1 != 0) else None
gm0 = (g0 / r0 * 100) if (g0 is not None and r0 and r0 != 0) else None
gm1 = (g1 / r1 * 100) if (g1 is not None and r1 and r1 != 0) else None
om0 = (e0 / r0 * 100) if (e0 is not None and r0 and r0 != 0) else None
om1 = (e1 / r1 * 100) if (e1 is not None and r1 and r1 != 0) else None
cr0 = (ca0 / cl0) if (ca0 is not None and cl0 and cl0 != 0) else None
cr1 = (ca1 / cl1) if (ca1 is not None and cl1 and cl1 != 0) else None
ic0 = (e0 / i0) if (e0 is not None and i0 and i0 != 0) else None
ic1 = (e1 / i1) if (e1 is not None and i1 and i1 != 0) else None
def row(metric, cur, prev, is_pct_point=False):
if cur is None:
return None
if prev is None or (is_pct_point and prev != prev):
return {"Metric": metric, "Current Value": round(cur, 2), "Change": "—", "Trend": "—"}
if is_pct_point:
chg = cur - prev
else:
chg = ((cur - prev) / abs(prev) * 100) if prev != 0 else 0
trend = "↑" if chg > 0 else ("↓" if chg < 0 else "—")
chg_str = f"{chg:+.1f}%" if not is_pct_point else f"{chg:+.1f} pp"
return {"Metric": metric, "Current Value": round(cur, 2), "Change": chg_str, "Trend": trend}
for name, cur, prev, is_pp in [
("NPM %", npm0, npm1, True), ("ROE %", roe0, roe1, True), ("Gross Margin %", gm0, gm1, True),
("Operating Margin %", om0, om1, True), ("Current Ratio", cr0, cr1, False), ("Interest Coverage", ic0, ic1, False),
]:
r = row(name, cur, prev, is_pp)
if r:
out.append(r)
return out
except Exception:
return out
@st.cache_data(ttl=300)
def get_income_statement_sankey_data(ticker: str) -> dict:
"""Latest year (or TTM): Revenue, COGS, Gross Profit, OpEx, Operating Income, Tax/Interest/Other, Net Income. Uses yahooquery then yfinance."""
out = {"revenue": 0, "cogs": 0, "gross_profit": 0, "opex": 0, "operating_income": 0, "tax_interest_other": 0, "net_income": 0}
fin, _, _ = _get_annual_financials_balance_cashflow(ticker)
if fin is None or fin.empty:
return out
try:
rev = _get_row_series(fin, "Total Revenue", "Revenue", "Net Revenue")
cogs = _get_row_series(fin, "Cost Of Revenue", "Cost Of Goods Sold")
gross = _get_row_series(fin, "Gross Profit")
op_inc = _get_row_series(fin, "Operating Income", "EBIT")
ni = _get_row_series(fin, "Net Income", "Net Income Common Stockholders")
if rev is None or len(rev) == 0:
return out
d = rev.index[0]
revenue = abs(_safe_float(rev.get(d)) or 0)
cogs_val = abs(_safe_float(cogs.get(d)) if cogs is not None and d in cogs.index else 0) or 0
gross_val = _safe_float(gross.get(d)) if gross is not None and d in gross.index else None
if gross_val is None and revenue and cogs_val is not None:
gross_val = revenue - cogs_val
elif gross_val is None:
gross_val = revenue
gross_val = abs(gross_val) if gross_val is not None else 0
op_inc_val = _safe_float(op_inc.get(d)) if op_inc is not None and d in op_inc.index else None
op_inc_val = op_inc_val if op_inc_val is not None else 0
ni_val = _safe_float(ni.get(d)) if ni is not None and d in ni.index else None
ni_val = ni_val if ni_val is not None else 0
opex_val = max(0, gross_val - op_inc_val) if (gross_val >= op_inc_val) else 0
tax_interest_other = max(0, op_inc_val - ni_val) if (op_inc_val - ni_val) > 0 else abs(min(0, op_inc_val - ni_val))
out["revenue"] = max(revenue, 1)
out["cogs"] = min(cogs_val, revenue - 1e-6)
out["gross_profit"] = gross_val
out["opex"] = opex_val
out["operating_income"] = op_inc_val
out["tax_interest_other"] = tax_interest_other
out["net_income"] = ni_val
return out
except Exception:
return out
def _build_sankey_figure(data: dict) -> "go.Figure":
"""Sankey: Revenue -> COGS + Gross Profit; Gross Profit -> OpEx + OpInc; OpInc -> Tax/Interest/Other + Net Income. Profit=green, Expense=red/grey."""
if go is None:
return None
rev, cogs, gp, opex, opinc, tax_other, ni = (
data["revenue"], data["cogs"], data["gross_profit"], data["opex"],
data["operating_income"], data["tax_interest_other"], data["net_income"],
)
if rev <= 0:
return None
nodes = ["Total Revenue", "Cost of Revenue", "Gross Profit", "Operating Expenses", "Operating Income", "Tax/Interest/Other", "Net Income"]
node_colors = ["rgba(0,150,80,0.8)", "rgba(180,60,60,0.7)", "rgba(0,150,80,0.8)", "rgba(120,120,120,0.7)", "rgba(0,150,80,0.8)", "rgba(120,120,120,0.7)", "rgba(0,180,90,0.9)"]
source = [0, 0, 2, 2, 4, 4]
target = [1, 2, 3, 4, 5, 6]
value = [cogs, gp, opex, opinc, tax_other, ni]
value = [max(0, float(v)) for v in value]
fig = go.Figure(data=[go.Sankey(
node=dict(label=nodes, color=node_colors, pad=15, thickness=20),
link=dict(source=source, target=target, value=value),
)])
fig.update_layout(title="Income Statement Flow (Latest Year)", height=400, margin=dict(t=40, b=20, l=20, r=20), font=dict(size=12))
return fig
def sankey_data_from_ai(ai_dict: dict) -> dict:
"""Build Sankey input dict from get_sec_financials_llm result (current_yr). Gross Profit = Revenue - CostOfRevenue; Operating Income = Gross Profit - OperatingExpenses."""
out = {"revenue": 0, "cogs": 0, "gross_profit": 0, "opex": 0, "operating_income": 0, "tax_interest_other": 0, "net_income": 0}
cur = (ai_dict or {}).get("current_yr") or {}
revenue = max(0, (cur.get("Revenue") or 0))
cogs = max(0, min(cur.get("CostOfRevenue") or 0, revenue - 1e-6))
gross_profit = revenue - cogs
opex = max(0, cur.get("OperatingExpenses") or 0)
operating_income = gross_profit - opex
net_income = cur.get("NetIncome") or 0
tax_interest_other = max(0, operating_income - net_income) if operating_income > net_income else abs(min(0, operating_income - net_income))
out["revenue"] = max(revenue, 1)
out["cogs"] = cogs
out["gross_profit"] = gross_profit
out["opex"] = opex
out["operating_income"] = operating_income
out["tax_interest_other"] = tax_interest_other
out["net_income"] = net_income
return out
def piotroski_from_ai(ai_dict: dict) -> dict:
"""Piotroski F-Score (0-9) from AI-extracted current_yr vs previous_yr. Returns {score, criteria, used_ttm: True}."""
out = {"score": 0, "criteria": [], "used_ttm": True}
cur = (ai_dict or {}).get("current_yr") or {}
prev = (ai_dict or {}).get("previous_yr") or {}
if not cur:
return out
def v(d, k): return (d.get(k) or 0)
ni0, ni1 = v(cur, "NetIncome"), v(prev, "NetIncome")
ocf0 = v(cur, "OperatingCashFlow")
ta0, ta1 = v(cur, "TotalAssets"), v(prev, "TotalAssets")
roa0 = (ni0 / ta0 * 100) if ta0 and ta0 != 0 else None
roa1 = (ni1 / ta1 * 100) if ta1 and ta1 != 0 else None
c1 = ni0 > 0
c2 = ocf0 > 0
c3 = (roa0 is not None and roa1 is not None and roa0 > roa1)
c4 = ocf0 > ni0
lt0, lt1 = v(cur, "LongTermDebt"), v(prev, "LongTermDebt")
c5 = (ta0 and ta1 and (lt0 / ta0) < (lt1 / ta1)) if ta0 and ta1 else False
ca0, ca1 = v(cur, "CurrentAssets"), v(prev, "CurrentAssets")
cl0, cl1 = v(cur, "CurrentLiabilities"), v(prev, "CurrentLiabilities")
cr0 = (ca0 / cl0) if cl0 and cl0 != 0 else None
cr1 = (ca1 / cl1) if cl1 and cl1 != 0 else None
c6 = (cr0 is not None and cr1 is not None and cr0 > cr1)
sh0, sh1 = v(cur, "SharesOutstanding"), v(prev, "SharesOutstanding")
c7 = (sh0 <= sh1) if (sh0 and sh1) else True
rev0, rev1 = v(cur, "Revenue"), v(prev, "Revenue")
gm0 = ((rev0 - v(cur, "CostOfRevenue")) / rev0 * 100) if rev0 and rev0 != 0 else None
gm1 = ((rev1 - v(prev, "CostOfRevenue")) / rev1 * 100) if rev1 and rev1 != 0 else None
c8 = (gm0 is not None and gm1 is not None and gm0 > gm1)
at0 = (rev0 / ta0) if rev0 and ta0 and ta0 != 0 else None
at1 = (rev1 / ta1) if rev1 and ta1 and ta1 != 0 else None
c9 = (at0 is not None and at1 is not None and at0 > at1)
criteria = [
("Net Income > 0 (profitability)", c1),
("Operating Cash Flow > 0 (cash generative)", c2),
("ROA increased vs prior period (improving returns)", c3),
("OCF > Net Income (earnings quality, less accruals)", c4),
("Leverage decreased: LT Debt/Assets lower (less debt)", c5),
("Current Ratio improved (better liquidity)", c6),
("No dilution: shares unchanged or lower (no equity raise)", c7),
("Gross Margin improved (pricing power)", c8),
("Asset Turnover improved (efficiency)", c9),
]
out["score"] = sum(1 for _, p in criteria if p)
out["criteria"] = criteria
return out
def radar_metrics_from_ai(ai_dict: dict) -> dict:
"""ROE, Current Ratio, Asset Turnover, Equity Mult, Revenue YoY from AI dict; normalized 0-100 for radar. Equity proxy: TotalAssets - CurrentLiabilities - LongTermDebt."""
cur = (ai_dict or {}).get("current_yr") or {}
prev = (ai_dict or {}).get("previous_yr") or {}
if not cur:
return {}
eq0 = (cur.get("TotalAssets") or 0) - (cur.get("CurrentLiabilities") or 0) - (cur.get("LongTermDebt") or 0)
if eq0 <= 0:
eq0 = (cur.get("TotalAssets") or 0) * 0.5
roe = (cur.get("NetIncome") or 0) / eq0 * 100 if eq0 else 0
ca, cl = cur.get("CurrentAssets") or 0, cur.get("CurrentLiabilities") or 0
current_ratio = (ca / cl) if cl and cl != 0 else 0
ta = cur.get("TotalAssets") or 1
asset_turnover = (cur.get("Revenue") or 0) / ta
equity_mult = (cur.get("TotalAssets") or 0) / eq0 if eq0 else 0
rev0, rev1 = cur.get("Revenue") or 0, prev.get("Revenue") or 0
rev_yoy = ((rev0 - rev1) / rev1 * 100) if rev1 and rev1 != 0 else 0
return {
"theta": ["Profitability (ROE)", "Liquidity (Curr.Ratio)", "Efficiency (Asset Turn.)", "Solvency (Equity Mult.)", "Growth (Rev YoY)"],
"r": _radar_norm(roe, current_ratio, asset_turnover, equity_mult, rev_yoy),
"labels": ["Profitability (ROE)", "Liquidity (Curr.Ratio)", "Efficiency (Asset Turn.)", "Solvency (Equity Mult.)", "Growth (Rev YoY)"],
}
@st.cache_data(ttl=300)
def get_radar_metrics_normalized(ticker: str) -> dict:
"""ROE, Current Ratio, Asset Turnover, Equity Mult, Revenue YoY. Normalized to 0-100 for radar. Returns {theta: [...], r: [...], labels: [...]} or empty."""
if not ticker:
return {}
q = get_dupont_altman_redflags_yoy(ticker)
if not q:
return {}
dupont_df = q.get("dupont")
if dupont_df is None or dupont_df.empty or len(dupont_df) < 2:
return {}
row0 = dupont_df.iloc[0]
row1 = dupont_df.iloc[1]
roe = row0.get("ROE %") or 0
cr = row0.get("Current Ratio") or 0
at = row0.get("Asset Turnover") or 0
em = row0.get("Equity Mult.") or 0
rev0 = dupont_df["Revenue"].iloc[0] if "Revenue" in dupont_df.columns else None
rev1 = dupont_df["Revenue"].iloc[1] if "Revenue" in dupont_df.columns else None
rev_yoy = ((rev0 - rev1) / rev1 * 100) if (rev0 and rev1 and rev1 != 0) else 0
def norm_roe(x):
if x is None: return 50
return min(100, max(0, (x + 10) / 40 * 100))
def norm_cr(x):
if x is None: return 50
return min(100, max(0, x / 3 * 100))
def norm_at(x):
if x is None: return 50
return min(100, max(0, x * 50))
def norm_em(x):
if x is None: return 50
return min(100, max(0, (x - 0.5) / 2.5 * 100))
def norm_yoy(x):
if x is None: return 50
return min(100, max(0, (x + 20) / 50 * 100))
return {
"theta": ["Profitability (ROE)", "Liquidity (Curr.Ratio)", "Efficiency (Asset Turn.)", "Solvency (Equity Mult.)", "Growth (Rev YoY)"],
"r": [norm_roe(roe), norm_cr(cr), norm_at(at), norm_em(em), norm_yoy(rev_yoy)],
"labels": ["Profitability (ROE)", "Liquidity (Curr.Ratio)", "Efficiency (Asset Turn.)", "Solvency (Equity Mult.)", "Growth (Rev YoY)"],
}
def _build_radar_figure(ticker: str) -> "go.Figure":
"""Plotly line_polar radar chart; fill with translucent color."""
if go is None:
return None
data = get_radar_metrics_normalized(ticker)
if not data or not data.get("r"):
return None
theta = data["theta"] + [data["theta"][0]]
r = data["r"] + [data["r"][0]]
fig = go.Figure(data=go.Scatterpolar(r=r, theta=theta, fill="toself", fillcolor="rgba(30, 120, 200, 0.4)", line=dict(color="rgb(30,120,200)", width=2)))
fig.update_layout(polar=dict(radialaxis=dict(visible=True, range=[0, 100]), angularaxis=dict(tickfont=dict(size=11))), title="Financial Health Radar", height=400, showlegend=False)
return fig
def _build_radar_figure_from_metrics(metrics: dict) -> "go.Figure":
"""Build radar chart from precomputed metrics dict (theta, r, labels). Used for SEC Item 8 AI path."""
if go is None or not metrics or not metrics.get("r"):
return None
theta = metrics["theta"] + [metrics["theta"][0]]
r = metrics["r"] + [metrics["r"][0]]
fig = go.Figure(data=go.Scatterpolar(r=r, theta=theta, fill="toself", fillcolor="rgba(30, 120, 200, 0.4)", line=dict(color="rgb(30,120,200)", width=2)))
fig.update_layout(polar=dict(radialaxis=dict(visible=True, range=[0, 100]), angularaxis=dict(tickfont=dict(size=11))), title="Financial Health Radar (from 10-K Item 8)", height=400, showlegend=False)
return fig
def _radar_norm(roe_pct, current_ratio, asset_turnover, equity_mult, rev_yoy_pct):
"""Normalize 5 raw metrics to 0100 for radar (same logic as get_radar_metrics_normalized)."""
def n_roe(x): return min(100, max(0, (x + 10) / 40 * 100)) if x is not None else 50
def n_cr(x): return min(100, max(0, x / 3 * 100)) if x is not None else 50
def n_at(x): return min(100, max(0, x * 50)) if x is not None else 50
def n_em(x): return min(100, max(0, (x - 0.5) / 2.5 * 100)) if x is not None else 50
def n_yoy(x): return min(100, max(0, (x + 20) / 50 * 100)) if x is not None else 50
return [n_roe(roe_pct), n_cr(current_ratio), n_at(asset_turnover), n_em(equity_mult), n_yoy(rev_yoy_pct)]
def _build_radar_from_manual(roe_pct, current_ratio, asset_turnover, equity_mult, rev_yoy_pct) -> "go.Figure":
"""Build radar chart from 5 manually entered ratios (fallback when ticker data missing)."""
if go is None:
return None
theta = ["Profitability (ROE)", "Liquidity (Curr.Ratio)", "Efficiency (Asset Turn.)", "Solvency (Equity Mult.)", "Growth (Rev YoY)"]
r = _radar_norm(roe_pct, current_ratio, asset_turnover, equity_mult, rev_yoy_pct)
theta_closed = theta + [theta[0]]
r_closed = r + [r[0]]
fig = go.Figure(data=go.Scatterpolar(r=r_closed, theta=theta_closed, fill="toself", fillcolor="rgba(30, 120, 200, 0.4)", line=dict(color="rgb(30,120,200)", width=2)))
fig.update_layout(polar=dict(radialaxis=dict(visible=True, range=[0, 100]), angularaxis=dict(tickfont=dict(size=11))), title="Financial Health Radar (Manual)", height=400, showlegend=False)
return fig
@st.cache_data(ttl=300)
def get_piotroski_fscore(ticker: str) -> dict:
"""Piotroski F-Score (0-9) from last 2 periods. Uses yahooquery then yfinance with TTM fallback. Returns score + criteria + used_ttm."""
out = {"score": 0, "criteria": [], "used_ttm": False}
fin, bal, cf = _get_annual_financials_balance_cashflow(ticker)
if fin is None or fin.empty or bal is None or bal.empty:
return out
if cf is None or cf.empty:
cf = pd.DataFrame()
try:
ncol = min(2, len(fin.columns))
rev = _get_row_series(fin, "Total Revenue", "Revenue")
ni = _get_row_series(fin, "Net Income", "Net Income Common Stockholders")
gross = _get_row_series(fin, "Gross Profit")
ta = _get_row_series(bal, "Total Assets")
lt_debt = _get_row_series(bal, "Long Term Debt")
ca = _get_row_series(bal, "Current Assets")
cl = _get_row_series(bal, "Current Liabilities")
ocf = _get_row_series(cf, "Operating Cash Flow", "Cash From Operating Activities") if not cf.empty else None
shares = _get_row_series(bal, "Share Issued") or _get_row_series(bal, "Ordinary Shares Number")
if shares is None and yf:
t = yf.Ticker(ticker.upper())
info = getattr(t, "info", None) or {}
sh_info = info.get("sharesOutstanding") or info.get("Shares Outstanding")
if sh_info is not None:
try:
sh_float = float(sh_info)
shares = pd.Series([sh_float] * ncol, index=fin.columns[:ncol])
except (TypeError, ValueError):
pass
def v0(s):
if s is None or len(s) == 0:
return None
x = _safe_float(s.iloc[0])
return x if (x is not None and x == x and not (isinstance(x, float) and pd.isna(x))) else None
def v1(s):
if s is None or len(s) < 2:
return None
x = _safe_float(s.iloc[1])
return x if (x is not None and x == x and not (isinstance(x, float) and pd.isna(x))) else None
ni0, ni1 = v0(ni), v1(ni)
ocf0 = v0(ocf) if ocf is not None else None
ta0, ta1 = v0(ta), v1(ta)
roa0 = (ni0 / ta0 * 100) if (ni0 is not None and ta0 is not None and ta0 != 0) else None
roa1 = (ni1 / ta1 * 100) if (ni1 is not None and ta1 is not None and ta1 != 0) else None
c1 = (ni0 is not None and ni0 > 0)
c2 = (ocf0 is not None and ocf0 > 0)
c3 = (roa0 is not None and roa1 is not None and roa0 > roa1)
c4 = (ocf0 is not None and ni0 is not None and ocf0 > ni0)
lt0 = v0(lt_debt) or 0
lt1 = v1(lt_debt) or 0
c5 = (ta0 is not None and ta0 != 0 and ta1 is not None and ta1 != 0 and (lt0 / ta0) < (lt1 / ta1))
cl0, cl1 = v0(cl), v1(cl)
ca0, ca1 = v0(ca), v1(ca)
cr0 = (ca0 / cl0) if (ca0 is not None and cl0 is not None and cl0 != 0) else None
cr1 = (ca1 / cl1) if (ca1 is not None and cl1 is not None and cl1 != 0) else None
c6 = (cr0 is not None and cr1 is not None and cr0 > cr1)
sh0, sh1 = v0(shares), v1(shares)
c7 = (sh0 is not None and sh1 is not None and sh0 <= sh1) if (sh0 is not None and sh1 is not None) else True
rev0, rev1 = v0(rev), v1(rev)
gm0 = (v0(gross) / rev0 * 100) if (gross is not None and rev0 is not None and rev0 != 0) else None
gm1 = (v1(gross) / rev1 * 100) if (gross is not None and rev1 is not None and rev1 != 0) else None
c8 = (gm0 is not None and gm1 is not None and gm0 > gm1)
at0 = (rev0 / ta0) if (rev0 is not None and ta0 is not None and ta0 != 0) else None
at1 = (rev1 / ta1) if (rev1 is not None and ta1 is not None and ta1 != 0) else None
c9 = (at0 is not None and at1 is not None and at0 > at1)
criteria = [
("Net Income > 0 (profitability)", c1),
("Operating Cash Flow > 0 (cash generative)", c2),
("ROA increased vs prior period (improving returns)", c3),
("OCF > Net Income (earnings quality, less accruals)", c4),
("Leverage decreased: LT Debt/Assets lower (less debt)", c5),
("Current Ratio improved (better liquidity)", c6),
("No dilution: shares unchanged or lower (no equity raise)", c7),
("Gross Margin improved (pricing power)", c8),
("Asset Turnover improved (efficiency)", c9),
]
score = sum(1 for _, p in criteria if p)
out["score"] = score
out["criteria"] = criteria
out["used_ttm"] = bool(fin is not None and hasattr(fin, "columns") and len(fin.columns) > 0 and any(str(c).startswith("TTM") for c in fin.columns))
return out
except Exception:
out["used_ttm"] = False
return out
@st.cache_data(ttl=300)
def get_sector_specific_metrics(ticker: str, sector: str) -> dict:
"""Technology: Rule of 40, R&D % revenue. Retail/Consumer: Inventory Turnover, Operating Margin. Financials: ROE, ROA."""
if not yf:
return {}
try:
t = yf.Ticker(ticker.upper())
info = t.info or {}
fin = t.financials
bal = t.balance_sheet
if fin is None or fin.empty:
fin = getattr(t, "quarterly_financials", None)
if fin is not None and not fin.empty:
fin = fin.iloc[:, :4].sum(axis=1).to_frame()
if bal is None or bal.empty:
bal = getattr(t, "quarterly_balance_sheet", None)
out = {}
sector_lower = (sector or "").lower()
if "technology" in sector_lower or "software" in sector_lower or "tech" in sector_lower:
rev = _get_row_series(fin, "Total Revenue", "Revenue", "Net Revenue")
ocf = _get_row_series(t.cashflow or getattr(t, "quarterly_cashflow", None), "Operating Cash Flow", "Cash From Operating Activities")
capx = _get_row_series(t.cashflow or getattr(t, "quarterly_cashflow", None), "Capital Expenditure", "Capital Expenditures")
rd = _get_row_series(fin, "Research And Development", "Research And Development Expense")
if rev is not None and len(rev) > 0:
r0 = _safe_float(rev.iloc[0])
if ocf is not None and len(ocf) > 0 and capx is not None and len(capx) > 0:
fcf = _safe_float(ocf.iloc[0]) - _safe_float(capx.iloc[0])
out["FCF Margin %"] = round(fcf / r0 * 100, 2) if r0 and fcf is not None else None
if rd is not None and len(rd) > 0:
out["R&D % of Revenue"] = round(_safe_float(rd.iloc[0]) / r0 * 100, 2) if r0 else None
rev_growth = None
if rev is not None and len(rev) >= 2:
cur, prev = _safe_float(rev.iloc[0]), _safe_float(rev.iloc[1])
if prev and prev != 0:
rev_growth = (cur - prev) / prev * 100
if rev_growth is not None and "FCF Margin %" in out and out["FCF Margin %"] is not None:
out["Rule of 40 (Rev Growth + FCF Margin)"] = round(rev_growth + out["FCF Margin %"], 1)
if "consumer" in sector_lower or "retail" in sector_lower or "cyclical" in sector_lower:
inv = _get_row_series(bal, "Inventory", "Total Inventory")
cogs = _get_row_series(fin, "Cost Of Revenue", "Cost Of Goods Sold", "Cost of Goods Sold")
rev = _get_row_series(fin, "Total Revenue", "Revenue", "Net Revenue")
op_inc = _get_row_series(fin, "Operating Income", "EBIT")
if inv is not None and len(inv) > 0 and cogs is not None and len(cogs) > 0:
inv0 = _safe_float(inv.iloc[0])
cogs0 = _safe_float(cogs.iloc[0])
out["Inventory Turnover"] = round(cogs0 / inv0, 2) if inv0 else None
if rev is not None and len(rev) > 0 and op_inc is not None and len(op_inc) > 0:
r0 = _safe_float(rev.iloc[0])
op0 = _safe_float(op_inc.iloc[0])
out["Operating Margin %"] = round(op0 / r0 * 100, 2) if r0 else None
if "financial" in sector_lower or "bank" in sector_lower or "insurance" in sector_lower:
ni = _get_row_series(fin, "Net Income", "Net Income Common Stockholders")
te = _get_row_series(bal, "Total Stockholder Equity", "Stockholders Equity", "Total Equity Gross Minority Interest")
ta = _get_row_series(bal, "Total Assets")
if ni is not None and te is not None and len(ni) > 0 and len(te) > 0:
te0 = _safe_float(te.iloc[0])
ni0 = _safe_float(ni.iloc[0])
out["ROE %"] = round(ni0 / te0 * 100, 2) if te0 else None
if ni is not None and ta is not None and len(ni) > 0 and len(ta) > 0:
ta0 = _safe_float(ta.iloc[0])
ni0 = _safe_float(ni.iloc[0])
out["ROA %"] = round(ni0 / ta0 * 100, 2) if ta0 else None
return out
except Exception:
return {}
# ---------- Streamlit UI ----------
st.set_page_config(page_title="Financial Analysis Dashboard", layout="wide", initial_sidebar_state="expanded")
# Professional styling
st.markdown("""
<style>
.stTabs [data-baseweb="tab-list"] { gap: 8px; }
.stTabs [data-baseweb="tab"] { padding: 12px 24px; font-weight: 600; }
div[data-testid="stMetricValue"] { font-size: 1.4rem; }
.block-container { padding-top: 1.5rem; max-width: 1200px; }
</style>
""", unsafe_allow_html=True)
st.title("All-in-One Financial Analysis Dashboard")
st.caption("Hybrid: Gemini for qualitative (10-K MD&A & Risks); yahooquery + yfinance for quantitative (DCF, Comps). No API key needed for fundamentals.")
with st.sidebar:
st.header("Settings")
_prefs = _load_prefs()
_default_key = _prefs.get("google_api_key") or os.environ.get("GOOGLE_API_KEY", "")
_default_email = _prefs.get("sec_email") or os.environ.get("SEC_EDGAR_EMAIL", "")
google_api_key = st.text_input(
"Google API Key (Gemini)",
type="password",
value=_default_key,
help="Required for Tab 1 (10-K insights).",
key="input_google_api_key",
)
sec_email = st.text_input(
"SEC EDGAR Email",
value=_default_email,
help="Required for 10-K download.",
key="input_sec_email",
)
remember_me = st.checkbox(
"Remember API key & email (save locally)",
value=bool(_prefs),
help="Store in .app_prefs.json in this project. Uncheck to clear and stop saving.",
key="remember_me",
)
if remember_me and (google_api_key or sec_email):
_save_prefs(google_api_key, sec_email)
elif not remember_me and _PREFS_PATH.exists():
try:
_PREFS_PATH.unlink()
except Exception:
pass
st.markdown("**🔍 Company search**")
search_query = st.text_input(
"Search Company Name (e.g., Apple, 삼성, Mitsubishi)",
value=st.session_state.get("company_search_input", ""),
key="company_search_input",
placeholder="e.g. Apple, 삼성, Mitsubishi",
)
if st.button("Search Company", key="search_company_btn"):
query = (search_query or "").strip()
if not query:
st.warning("Enter a company name to search.")
elif yq_search is None:
st.warning("yahooquery is not installed; search is unavailable.")
else:
try:
raw_results = yq_search(query)
if not isinstance(raw_results, dict):
raw_results = {}
quotes = raw_results.get("quotes", []) or []
skip_types = ("INDEX", "MUTUALFUND")
quotes = [
q for q in quotes
if q.get("symbol") and q.get("shortname")
and (q.get("quoteType") or "EQUITY") not in skip_types
]
if not quotes:
st.session_state["company_search_options"] = []
st.session_state["company_search_symbols"] = []
st.warning("No valid equities found. Try typing the English name (e.g., 'Samsung' instead of '삼성').")
else:
options = []
symbols = []
for q in quotes[:50]:
sym = (q.get("symbol") or "").strip()
options.append(f"[{q.get('exchange', 'N/A')}] {q.get('symbol')} - {q.get('shortname', 'Unknown')}")
symbols.append(sym)
st.session_state["company_search_options"] = options
st.session_state["company_search_symbols"] = symbols
st.session_state["ticker"] = symbols[0]
st.success(f"Found {len(options)} result(s). Select below.")
except Exception:
st.warning("No valid equities found. Try typing the English name (e.g., 'Samsung' instead of '삼성').")
st.session_state["company_search_options"] = []
st.session_state["company_search_symbols"] = []
search_options = st.session_state.get("company_search_options") or []
search_symbols = st.session_state.get("company_search_symbols") or []
placeholder = "— Click the search button above —"
options_for_select = [placeholder] if not search_options else search_options
current_ticker = st.session_state.get("ticker", "NVDA")
default_idx = 0
if search_symbols and current_ticker:
for i, sym in enumerate(search_symbols):
if sym == current_ticker:
default_idx = i
break
selected_option = st.selectbox(
"Select company (ticker - name)",
options=options_for_select,
index=0 if not search_options else min(default_idx, len(search_options) - 1),
key="company_select",
)
if search_options and selected_option and selected_option != placeholder and " - " in selected_option:
first_part = selected_option.split(" - ", 1)[0].strip()
sym = first_part.split("]", 1)[-1].strip() if "]" in first_part else first_part
st.session_state["ticker"] = sym
ticker = st.session_state.get("ticker") or (search_symbols[0] if search_symbols else "NVDA")
st.session_state["google_api_key"] = google_api_key
st.session_state["sec_email"] = sec_email
st.session_state["ticker"] = ticker
st.session_state["market"] = infer_market_from_ticker(ticker)
st.caption("Search by name (any language), then select. Ticker suffix is set automatically.")
tab1, tab2, tab3 = st.tabs(["10-K & MD&A Insights", "3-Scenario DCF Valuation", "Industry Analysis & Comps"])
# ----- Tab 1: Qualitative (MD&A) + Quantitative (DuPont, Altman Z, Red Flags, YoY) -----
with tab1:
market = st.session_state.get("market") or MARKET_OPTIONS[0]
quant_ticker = get_global_ticker(ticker, market) if ticker else ""
st.subheader("10-K & MD&A Insights — Qualitative and Quantitative")
if ticker:
si = get_sector_industry(quant_ticker)
sector, industry = si.get("sector", "N/A"), si.get("industry", "N/A")
st.caption(f"Sector: **{sector}** · Industry: **{industry}**" + (f" · Ticker: **{quant_ticker}**" if quant_ticker != ticker else ""))
if ticker:
google_api_key = (st.session_state.get("google_api_key") or "").strip()
sec_email = (st.session_state.get("sec_email") or "").strip()
ai_data = {}
if market and "US" in market and google_api_key and sec_email:
with st.spinner("SEC 10-K 원본에서 재무제표 데이터를 해독하여 그래프를 생성 중입니다... (약 30~60초 소요)"):
sections, _ = get_10k_sections(ticker, sec_email)
item8 = (sections or {}).get("item8") or ""
if item8.strip():
ai_data = get_sec_financials_llm(google_api_key, item8, ticker)
q = get_dupont_altman_redflags_yoy(quant_ticker)
dupont_df = (q or {}).get("dupont") if q else None
if q or ai_data:
st.markdown("---")
st.markdown("#### 📊 Financial Health (Tables & Charts)")
c1, c2 = st.columns(2)
with c1:
if ai_data and ai_data.get("current_yr"):
sankey_data = sankey_data_from_ai(ai_data)
else:
sankey_data = get_income_statement_sankey_data(quant_ticker)
if sankey_data.get("revenue", 0) > 0:
fig_sankey = _build_sankey_figure(sankey_data)
if fig_sankey is not None:
st.plotly_chart(fig_sankey, use_container_width=True)
else:
st.caption("Income Statement flow: data not available.")
with c2:
if ai_data and ai_data.get("current_yr"):
radar_metrics = radar_metrics_from_ai(ai_data)
fig_radar = _build_radar_figure_from_metrics(radar_metrics) if radar_metrics else None
else:
fig_radar = _build_radar_figure(quant_ticker)
if fig_radar is not None:
st.plotly_chart(fig_radar, use_container_width=True)
else:
st.caption("Financial radar: need 2+ years of data.")
with st.expander("Manual Data Entry (Radar Chart Fallback)", expanded=False):
st.caption("Enter 5 key ratios to plot a custom radar. ROE %, Current Ratio, Asset Turnover, Equity Mult., Revenue YoY %.")
roe_man = st.number_input("ROE %", value=15.0, min_value=-50.0, max_value=100.0, step=1.0, key="radar_roe")
cr_man = st.number_input("Current Ratio", value=1.5, min_value=0.0, max_value=10.0, step=0.1, key="radar_cr")
at_man = st.number_input("Asset Turnover", value=0.8, min_value=0.0, max_value=5.0, step=0.1, key="radar_at")
em_man = st.number_input("Equity Mult.", value=2.0, min_value=0.5, max_value=10.0, step=0.1, key="radar_em")
yoy_man = st.number_input("Revenue YoY %", value=10.0, min_value=-50.0, max_value=200.0, step=1.0, key="radar_yoy")
if st.button("Plot Radar", key="radar_plot_btn"):
fig_man = _build_radar_from_manual(roe_man, cr_man, at_man, em_man, yoy_man)
if fig_man is not None:
st.session_state["radar_manual_fig"] = fig_man
if st.session_state.get("radar_manual_fig") is not None:
st.plotly_chart(st.session_state["radar_manual_fig"], use_container_width=True)
with st.expander("Debug: Raw YahooQuery Data", expanded=False):
if YQTicker and quant_ticker:
try:
yq_ticker = YQTicker(quant_ticker.upper())
inc_raw = yq_ticker.income_statement(trailing=False)
bal_raw = yq_ticker.balance_sheet(trailing=False)
if inc_raw is not None and not inc_raw.empty:
st.caption("Income statement (last 2 periods) — check column names for mapping.")
st.dataframe(inc_raw.tail(2), use_container_width=True, hide_index=True)
else:
st.caption("Income statement: no data.")
if bal_raw is not None and not bal_raw.empty:
st.caption("Balance sheet (last 2 periods) — check column names for mapping.")
st.dataframe(bal_raw.tail(2), use_container_width=True, hide_index=True)
else:
st.caption("Balance sheet: no data.")
except Exception as e:
st.error(f"YahooQuery debug failed: {e}")
else:
st.caption("YahooQuery not available or no ticker selected.")
if ai_data and ai_data.get("current_yr"):
piot = piotroski_from_ai(ai_data)
else:
piot = get_piotroski_fscore(quant_ticker)
st.markdown("**Piotroski F-Score (9-point checklist)**")
score = piot.get("score", 0)
legend = "**Score 89: Excellent** · 47: Average · 03: High Risk"
st.metric("F-Score", f"{score} / 9", legend)
if ai_data and ai_data.get("current_yr"):
st.caption("*(from SEC 10-K Item 8)*")
elif piot.get("used_ttm"):
st.caption("*(Estimated via TTM Data)*")
st.caption("✅ = Good (passes criterion). ❌ = Fails criterion.")
criteria = piot.get("criteria", [])
if criteria:
cols = st.columns(3)
for i, (label, passed) in enumerate(criteria):
with cols[i % 3]:
st.caption(("✅ " if passed else "❌ ") + label)
az = (q or {}).get("altman_z")
if az is not None:
st.caption(f"**Altman Z-Score:** {az} (Safe > 2.99 · Grey 1.812.99 · Distress < 1.81)")
red_flags = (q or {}).get("red_flags") or []
if red_flags:
for rf in red_flags:
val = rf.get("value")
val_str = "N/A" if (val is None or (isinstance(val, float) and (pd.isna(val) or val != val))) else val
st.warning(f"**{rf.get('metric')}:** {val_str} (threshold: {rf.get('threshold')})")
elif dupont_df is not None and not dupont_df.empty:
st.success("No red flags (Current Ratio ≥ 1.0, Interest Coverage ≥ 1.5).")
sector_metrics = get_sector_specific_metrics(quant_ticker, sector) if quant_ticker else {}
if sector_metrics:
st.markdown("**Sector-specific metrics**")
cols = st.columns(min(len(sector_metrics), 4))
for i, (k, v) in enumerate(sector_metrics.items()):
with cols[i % len(cols)]:
disp = f"{v}" if v is not None else "N/A"
st.metric(k, disp, None)
def _style_change_column(df: pd.DataFrame):
"""Green for improvement (+), red for decline (-) in Change column."""
change_col = "Change (%)" if "Change (%)" in df.columns else "Change"
if change_col not in df.columns or df.empty:
return df.style
def _cell_style(v):
if v is None or (isinstance(v, float) and pd.isna(v)):
return ""
s = str(v).strip()
if s == "—":
return ""
if s.startswith("+") or "↑" in s:
return "background-color: #d4edda; color: #155724"
if s.startswith("-") or "↓" in s:
return "background-color: #f8d7da; color: #721c24"
return ""
return df.style.apply(lambda col: [_cell_style(v) for v in col], subset=[change_col])
yoy_list = (q or {}).get("yoy") or []
if yoy_list:
st.markdown("**YoY ratio changes**")
rows_yoy = []
for item in yoy_list:
cur = item.get("Latest")
chg_pp = item.get("YoY (pp)")
chg_pct = item.get("YoY %")
if chg_pp is not None:
chg_str = f"{chg_pp:+.1f}%"
elif chg_pct is not None:
chg_str = f"{chg_pct:+.1f}%"
else:
chg_str = "—"
status = "↑" if (chg_pp is not None and chg_pp > 0) or (chg_pct is not None and chg_pct > 0) else ("↓" if (chg_pp is not None and chg_pp < 0) or (chg_pct is not None and chg_pct < 0) else "—")
rows_yoy.append({"Metric": item.get("Ratio"), "Current Value": cur, "Change (%)": chg_str, "Status": status})
if rows_yoy:
df_yoy = pd.DataFrame(rows_yoy)
st.dataframe(_style_change_column(df_yoy), use_container_width=True, hide_index=True)
st.markdown("**Quarter ratio changes**")
qmom = get_quarterly_momentum(quant_ticker)
qoq_rows = get_quarterly_ratio_changes(quant_ticker)
qoq_r, qoq_n = qmom.get("qoq_revenue_pct"), qmom.get("qoq_ni_pct")
build = []
if qoq_r is not None:
build.append({"Metric": "Revenue", "Current Value": "—", "Change (%)": f"{qoq_r:+.1f}%", "Status": "↑" if qoq_r > 0 else "↓"})
if qoq_n is not None:
build.append({"Metric": "Net Income", "Current Value": "—", "Change (%)": f"{qoq_n:+.1f}%", "Status": "↑" if qoq_n > 0 else "↓"})
for r in qoq_rows:
r_copy = dict(r)
if "Change" in r_copy and "Change (%)" not in r_copy:
r_copy["Change (%)"] = r_copy.pop("Change", "—")
if "Trend" in r_copy:
r_copy["Status"] = r_copy.pop("Trend", "—")
build.append(r_copy)
if build:
df_q = pd.DataFrame(build)
if "Change" in df_q.columns and "Change (%)" not in df_q.columns:
df_q = df_q.rename(columns={"Change": "Change (%)"})
if "Trend" in df_q.columns:
df_q = df_q.rename(columns={"Trend": "Status"})
st.dataframe(_style_change_column(df_q), use_container_width=True, hide_index=True)
elif not qmom.get("df") or qmom["df"].empty:
st.caption("Quarterly data not available for this ticker.")
else:
st.info("Quantitative data not available for this ticker.")
st.markdown("---")
st.markdown("#### 🔍 Deep-Dive Analysis (AI)")
st.caption("10-K sections are cached in **data/**; repeat runs use cache for instant AI analysis. First run may take 2060 s to fetch 10-K; Gemini then streams in ~510 s.")
if not ticker:
st.caption("Enter a ticker in the sidebar to enable analysis.")
else:
api_ok = bool(st.session_state.get("google_api_key"))
email_ok = bool(st.session_state.get("sec_email"))
err_msg = []
if not api_ok:
err_msg.append("Google API Key")
if not email_ok:
err_msg.append("SEC EDGAR Email")
if err_msg:
st.caption(f"Set **{' and '.join(err_msg)}** in the sidebar to run analysis.")
col_a, col_b = st.columns(2)
is_us = market and "US" in market
is_korea = market and ("Korea" in market or "KOSPI" in market or "KOSDAQ" in market)
is_japan_uk = market and ("Japan" in market or "Nikkei" in market or "UK" in market or "LSE" in market)
# --- Button A: Management Strategy ---
with col_a:
if st.button("Analyze Management Strategy (MD&A)", key="run_mda_strategy"):
if is_korea:
st.warning("DART API integration for Korean MD&A is currently under construction. Please check back in Phase 2.")
elif is_japan_uk:
st.warning("EDINET/LSE document parsing is currently under development.")
elif not api_ok or not email_ok:
st.error("Set API Key and SEC Email in the sidebar.")
else:
try:
with st.status("Loading 10-K (cache or download)...", expanded=True) as status:
sections, _ = get_10k_sections(ticker, st.session_state["sec_email"])
si = get_sector_industry(quant_ticker)
status.update(label="10-K loaded. Calling Gemini…", state="running")
# Stream OUTSIDE the status box so user sees text as it arrives
st.markdown("### Management Strategy (Item 7)")
st.caption("Streaming from Gemini (first words in ~510 sec, then flows in real time).")
stream_gen = get_gemini_item7_strategy_stream(
st.session_state["google_api_key"],
sections.get("item7") or "",
ticker,
si.get("sector") or "N/A",
si.get("industry") or "N/A",
)
# write_stream returns the full concatenated string after it finishes streaming
full_response = st.write_stream(stream_gen)
st.session_state["mda_strategy_result"] = full_response
st.session_state["mda_strategy_ticker"] = ticker
st.session_state["mda_strategy_error"] = None
except Exception as e:
st.session_state["mda_strategy_error"] = str(e)
st.error(f"Strategy analysis failed: {str(e)}")
# --- Button B: Risk Factors & Forensic ---
with col_b:
if st.button("Analyze Risk Factors (Item 1A)", key="run_mda_risk"):
if is_korea:
st.warning("DART API integration for Korean MD&A is currently under construction. Please check back in Phase 2.")
elif is_japan_uk:
st.warning("EDINET/LSE document parsing is currently under development.")
elif not api_ok or not email_ok:
st.error("Set API Key and SEC Email in the sidebar.")
else:
try:
with st.status("Loading 10-K (cache or download)...", expanded=True) as status:
sections, _ = get_10k_sections(ticker, st.session_state["sec_email"])
status.update(label="10-K loaded. Running forensic audit…", state="running")
# Run forensic silently IN THE BACKGROUND first
forensic = _gemini_forensic_audit(
st.session_state["google_api_key"],
sections.get("item3") or "",
sections.get("item9a") or "",
ticker,
)
status.update(label="Done.", state="complete")
# Stream the risk factors OUTSIDE the status box
st.markdown("### Risk Factors (Item 1A)")
st.caption("Streaming from Gemini (first words in ~510 sec, then flows in real time).")
stream_gen = get_gemini_item1a_risks_stream(
st.session_state["google_api_key"],
sections.get("item1a") or "",
ticker,
)
risk_response = st.write_stream(stream_gen)
# Combine both for the final result
final_out = risk_response
if forensic and forensic.strip():
st.markdown("### Forensic Audit (Item 3 & 9A)")
st.markdown(forensic.strip())
final_out += f"\n\n---\n\n### Forensic Audit (Item 3 & 9A)\n\n{forensic.strip()}"
st.session_state["mda_risk_result"] = final_out
st.session_state["mda_risk_ticker"] = ticker
st.session_state["mda_risk_error"] = None
except Exception as e:
st.session_state["mda_risk_error"] = str(e)
st.error(f"Risk analysis failed: {str(e)}")
# --- Display Saved Results if User Switches Tabs ---
st.markdown("---")
if st.session_state.get("mda_strategy_ticker") == ticker:
if st.session_state.get("mda_strategy_error"):
st.error("Strategy Error: " + st.session_state["mda_strategy_error"])
elif st.session_state.get("mda_strategy_result"):
with st.expander("View Previous Strategy Analysis", expanded=True):
st.markdown(st.session_state["mda_strategy_result"])
if st.session_state.get("mda_risk_ticker") == ticker:
if st.session_state.get("mda_risk_error"):
st.error("Risk Error: " + st.session_state["mda_risk_error"])
elif st.session_state.get("mda_risk_result"):
with st.expander("View Previous Risk & Forensic Analysis", expanded=True):
st.markdown(st.session_state["mda_risk_result"])
# ----- Tab 2: 5-Year Trend + 3-Scenario DCF -----
with tab2:
market_t2 = st.session_state.get("market") or MARKET_OPTIONS[0]
quant_ticker_t2 = get_global_ticker(ticker, market_t2) if ticker else ""
st.subheader("5-Year Financial Trend & DCF Valuation")
if ticker:
si_t2 = get_sector_industry(quant_ticker_t2)
sector_t2 = (si_t2.get("sector") or "").lower()
is_financial = "financial" in sector_t2 or "bank" in sector_t2 or "insurance" in sector_t2
else:
is_financial = False
df_trend = get_5yr_financial_trend(quant_ticker_t2) if quant_ticker_t2 else pd.DataFrame()
if not df_trend.empty and len(df_trend) >= 1:
st.markdown("#### Key metrics (YoY % change)")
latest = df_trend.iloc[0]
prev = df_trend.iloc[1] if len(df_trend) >= 2 else None
def _yoy_pct(cur, prev_val):
if prev_val is None or cur is None or prev_val == 0:
return None
return (cur - prev_val) / abs(prev_val) * 100
rev_yoy = _yoy_pct(latest.get("Revenue"), prev.get("Revenue") if prev is not None else None)
ni_yoy = _yoy_pct(latest.get("Net Income"), prev.get("Net Income") if prev is not None else None)
om_prev = prev.get("Operating Margin %") if prev is not None else None
om_cur = latest.get("Operating Margin %")
om_yoy = (om_cur - om_prev) if (om_cur is not None and om_prev is not None) else None
fcf_yoy = _yoy_pct(latest.get("FCF"), prev.get("FCF") if prev is not None else None)
m1, m2, m3, m4 = st.columns(4)
rev_val = latest.get("Revenue")
m1.metric("Revenue (latest yr)", f"${rev_val/1e9:.2f}B" if rev_val and rev_val >= 1e9 else (f"${rev_val/1e6:.0f}M" if rev_val else "—"), f"{rev_yoy:+.1f}% YoY" if rev_yoy is not None else None)
ni_val = latest.get("Net Income")
m2.metric("Net Income", f"${ni_val/1e9:.2f}B" if ni_val and abs(ni_val) >= 1e9 else (f"${ni_val/1e6:.0f}M" if ni_val is not None else "—"), f"{ni_yoy:+.1f}% YoY" if ni_yoy is not None else None)
om_val = latest.get("Operating Margin %")
m3.metric("Operating Margin %", f"{om_val:.1f}%" if om_val is not None else "—", f"{om_yoy:+.1f}pp YoY" if om_yoy is not None else None)
fcf_val = latest.get("FCF")
m4.metric("FCF", f"${fcf_val/1e9:.2f}B" if fcf_val and abs(fcf_val) >= 1e9 else (f"${fcf_val/1e6:.0f}M" if fcf_val is not None else "—"), f"{fcf_yoy:+.1f}% YoY" if fcf_yoy is not None else None)
st.caption("FCF = Operating Cash Flow Capital Expenditure." + (" For Financials, FCF/EBITDA are less relevant; see ROE/ROA in Tab 1 sector-specific metrics." if is_financial else ""))
if len(df_trend) >= 2 and px is not None:
st.markdown("#### 5-year trend: Revenue & FCF")
df_plot = df_trend.copy()
df_plot["Revenue_M"] = (df_plot["Revenue"] / 1e6).round(1)
df_plot["FCF_M"] = (df_plot["FCF"] / 1e6).round(1)
fig = px.line(df_plot, x="Year", y=["Revenue_M", "FCF_M"], title="Revenue & Free Cash Flow ($M)")
fig.update_layout(yaxis_title="$M", legend_title="", hovermode="x unified")
fig.update_traces(line=dict(width=2))
st.plotly_chart(fig, use_container_width=True)
elif ticker:
st.caption("5-year trend not available for this ticker. DCF section below uses latest FCF from yfinance.")
st.markdown("---")
st.markdown("#### DCF valuation (Excel-style): inputs & 3-scenario output")
dcf_inputs = get_dcf_inputs(quant_ticker_t2) if quant_ticker_t2 else {"fcf": None, "total_debt": 0.0, "cash": 0.0, "shares": None}
fcf_fetched = dcf_inputs.get("fcf")
total_debt = float(dcf_inputs.get("total_debt") or 0.0)
cash = float(dcf_inputs.get("cash") or 0.0)
shares_fetched = dcf_inputs.get("shares")
# Base FCF
if fcf_fetched is None or fcf_fetched <= 0:
fcf = st.number_input("Base FCF (manual — only if yfinance missing)", value=0.0, min_value=-1e12, step=1e8, format="%.0f", key="dcf_fcf_manual")
else:
fcf = float(fcf_fetched)
st.caption(f"Base FCF (OCF CapEx): **${fcf/1e9:.2f}B**" if abs(fcf) >= 1e9 else f"Base FCF (OCF CapEx): **${fcf/1e6:.0f}M**")
# Shares: auto-fetched (fast_info → info → balance); manual only as last resort
if shares_fetched is not None and shares_fetched > 0:
shares = float(shares_fetched)
st.caption(f"Shares Outstanding: **{_format_shares_display(shares)}** (real-time, auto-fetched)")
else:
shares = st.number_input("Shares Outstanding (manual — only if all API sources failed)", value=1e9, min_value=1.0, step=1e7, format="%.0f", key="dcf_shares_manual")
# Total Debt & Cash: manual only when both API sources completely failed
if total_debt == 0 and cash == 0:
c1, c2 = st.columns(2)
with c1:
total_debt = st.number_input("Total Debt (manual — only if all sources failed)", value=0.0, min_value=0.0, step=1e8, format="%.0f", key="dcf_debt_manual")
with c2:
cash = st.number_input("Cash & Equivalents (manual — only if all sources failed)", value=0.0, min_value=0.0, step=1e8, format="%.0f", key="dcf_cash_manual")
else:
st.caption(f"Total Debt: **${total_debt/1e9:.2f}B**" if total_debt >= 1e9 else f"Total Debt: **${total_debt/1e6:.0f}M**" if total_debt >= 1e6 else f"Total Debt: **${total_debt:,.0f}**")
st.caption(f"Cash & Equivalents: **${cash/1e9:.2f}B**" if cash >= 1e9 else f"Cash & Equivalents: **${cash/1e6:.0f}M**" if cash >= 1e6 else f"Cash & Equivalents: **${cash:,.0f}**")
dcf_defaults = get_dcf_smart_defaults(quant_ticker_t2) if quant_ticker_t2 else {"wacc_pct": 10.0, "term_growth_pct": 2.5, "fcf_growth_pct": 8.0}
st.markdown("**Assumptions (sliders)**")
st.caption("💡 Slider defaults are auto-generated based on the company's Beta (CAPM) and revenue growth estimates.")
col1, col2, col3 = st.columns(3)
with col1:
wacc = st.slider("WACC (Discount Rate) %", 4.0, 20.0, float(dcf_defaults["wacc_pct"]), 0.5, key="dcf_wacc") / 100.0
with col2:
term_growth = st.slider("Terminal Growth Rate %", -2.0, 6.0, float(dcf_defaults["term_growth_pct"]), 0.25, key="dcf_term") / 100.0
with col3:
base_growth = st.slider("Projected FCF Growth (Stage 1, Y15) %", -10.0, 30.0, float(dcf_defaults["fcf_growth_pct"]), 0.5, key="dcf_fcf_growth") / 100.0
bull_growth = base_growth + 0.02
bear_growth = base_growth - 0.02
with st.expander("Reference: Analyst & Macro Assumptions", expanded=False):
left_col, right_col = st.columns(2)
with left_col:
st.markdown("**Analyst consensus (yfinance)**")
analyst = get_analyst_consensus(quant_ticker_t2) if quant_ticker_t2 else {}
st.markdown(f"- **Target mean price:** {analyst.get('targetMeanPrice', 'N/A')}")
st.markdown(f"- **Recommendation:** {analyst.get('recommendationKey', 'N/A')}")
st.markdown(f"- **Revenue growth est.:** {analyst.get('revenueGrowth', 'N/A')}")
st.markdown(f"- **Earnings growth est.:** {analyst.get('earningsGrowth', 'N/A')}")
with right_col:
st.markdown("**Aswath Damodaran — macro baseline**")
sector_name = get_sector_industry(ticker).get("sector", "N/A") if ticker else "N/A"
damodaran_wacc = _damodaran_wacc_for_sector(sector_name) if ticker else 8.0
st.markdown(f"- **Sector WACC (ref.):** {damodaran_wacc:.1f}% (closest: {sector_name})")
st.markdown(f"- **US equity risk premium (ERP):** {DAMODARAN_ERP_PCT}%")
st.markdown(f"- **10Y risk-free rate:** {DAMODARAN_RF_PCT}%")
st.markdown("[Data & methodology (Damodaran)](https://pages.stern.nyu.edu/~adamodar/New_Home_Page/datafile/wacc.htm) so users can verify.")
res_base = excel_style_dcf(fcf, wacc, term_growth, base_growth, total_debt, cash, shares)
res_bull = excel_style_dcf(fcf, wacc, term_growth, bull_growth, total_debt, cash, shares)
res_bear = excel_style_dcf(fcf, wacc, term_growth, bear_growth, total_debt, cash, shares)
price_base = res_base.get("value_per_share") or 0.0
price_bull = res_bull.get("value_per_share") or 0.0
price_bear = res_bear.get("value_per_share") or 0.0
current_price = None
if quant_ticker_t2 and yf:
try:
info = yf.Ticker(quant_ticker_t2.upper()).info or {}
current_price = info.get("currentPrice") or info.get("regularMarketPrice") or info.get("previousClose")
except Exception:
pass
st.markdown("**Intrinsic value vs current price**")
if current_price is not None and current_price > 0:
st.metric("Current price", f"${current_price:.2f}", None)
st.metric("Base case intrinsic value per share", f"${price_base:.2f}" if price_base else "N/A", f"vs current: {(price_base - current_price):.2f}" if (current_price and price_base) else None)
c1, c2, c3 = st.columns(3)
c1.metric("Bull (+2% FCF growth)", f"${price_bull:.2f}" if price_bull else "N/A", f"vs Base: +{(price_bull - price_base):.2f}" if (price_bull and price_base) else None)
c2.metric("Base", f"${price_base:.2f}" if price_base else "N/A", "—")
c3.metric("Bear (2% FCF growth)", f"${price_bear:.2f}" if price_bear else "N/A", f"vs Base: {(price_bear - price_base):.2f}" if (price_bear and price_base) else None)
df_dcf = pd.DataFrame({
"Scenario": ["Bull", "Base", "Bear"],
"FCF Growth %": [f"{bull_growth*100:.1f}", f"{base_growth*100:.1f}", f"{bear_growth*100:.1f}"],
"Intrinsic Value ($)": [round(price_bull, 2) if price_bull else "N/A", round(price_base, 2) if price_base else "N/A", round(price_bear, 2) if price_bear else "N/A"],
})
st.dataframe(df_dcf, use_container_width=True, hide_index=True)
# ----- Tab 3: Top-Down Sector Analysis (Industry Comps + AI Outlook) -----
with tab3:
st.subheader("Top-Down Sector Analysis")
st.markdown("Select an **industry** to load peer multiples (Forward P/E, EV/EBITDA, P/B). Green = lowest (undervalued), Red = highest. Optionally generate an **AI Industry Outlook**.")
sector_options = list(SECTORS.keys())
selected_industry = st.selectbox("Select industry", sector_options, key="sector_select")
tickers_list = list(SECTORS.get(selected_industry, []))
if not tickers_list:
st.warning("No tickers defined for this industry.")
else:
with st.spinner("Fetching market data..."):
df_comps = get_comps_data(tuple(tickers_list))
if df_comps.empty:
st.warning("Could not fetch comps from yfinance. One or more tickers may have failed; try again later.")
else:
df_display = df_comps.copy()
for col in ["Forward P/E", "EV/EBITDA", "P/B"]:
if col not in df_display.columns:
continue
df_display[col] = df_display[col].apply(
lambda x: "N/A" if (x is None or (isinstance(x, float) and pd.isna(x))) else x
)
try:
styled = df_comps.style
for col in ["Forward P/E", "EV/EBITDA", "P/B"]:
if col not in df_comps.columns:
continue
s = pd.to_numeric(df_comps[col], errors="coerce")
valid = s.dropna()
if len(valid) < 2:
continue
lo, hi = valid.min(), valid.max()
if lo == hi:
continue
def color_fn(v, lo_val=lo, hi_val=hi):
if pd.isna(v):
return ""
try:
x = float(v)
except (TypeError, ValueError):
return ""
if x <= lo_val:
return "background-color: rgba(0, 200, 83, 0.35); color: #0d5c2e"
if x >= hi_val:
return "background-color: rgba(255, 82, 82, 0.35); color: #b71c1c"
return ""
styled = styled.map(color_fn, subset=[col])
styled = styled.format(subset=["Forward P/E", "EV/EBITDA", "P/B"], formatter=lambda x: "N/A" if (pd.isna(x) or x is None) else f"{x:.2f}")
st.dataframe(styled, use_container_width=True, hide_index=True)
except Exception:
st.dataframe(df_display, use_container_width=True, hide_index=True)
st.caption("Lowest multiple in each column = green (relatively undervalued); highest = red.")
st.markdown("---")
st.markdown("#### AI Industry Outlook")
if st.button("Generate Industry Outlook", key="industry_outlook_btn"):
if not tickers_list:
st.error("Select an industry above first.")
elif not st.session_state.get("google_api_key"):
st.error("Enter your Google API Key in the sidebar.")
else:
try:
with st.spinner("Generating industry outlook with Gemini..."):
report = get_industry_outlook(
st.session_state["google_api_key"],
selected_industry,
tickers_list,
)
st.success("Done.")
st.markdown(report)
except RuntimeError as e:
st.error(str(e))
except Exception as e:
st.error("Failed to generate outlook. See details below.")
with st.expander("Error details"):
st.code(repr(e), language="text")