mirror of
https://github.com/shawnkim1997/All-in-one-Financial-Analysis.git
synced 2026-08-21 22:58:04 +00:00
docs: Update README — 3-tab system (10-K Insights, DCF, Comps) + Project Origin & Vision (English)
This commit is contained in:
@@ -1,13 +1,11 @@
|
||||
"""
|
||||
10-K Financial Analyzer (Google Gemini) — Hybrid Architecture
|
||||
- Download 10-K from SEC EDGAR; extract Item 7 (MD&A) only for AI.
|
||||
- Quantitative: financial metrics (Revenue, Net Income, Operating Cash Flow) from yfinance.
|
||||
- Qualitative: Item 7 only to Gemini for strategic direction, risks, and sentiment analysis.
|
||||
- HTML cleansing before sending text to LLM to minimise tokens.
|
||||
- All content in British English.
|
||||
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
|
||||
@@ -25,6 +23,11 @@ try:
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
import yfinance as yf
|
||||
except ImportError:
|
||||
yf = None
|
||||
|
||||
|
||||
def get_edgar_downloader():
|
||||
from sec_edgar_downloader import Downloader
|
||||
@@ -56,6 +59,11 @@ def extract_text_from_file(file_path: Path) -> str:
|
||||
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",
|
||||
@@ -64,7 +72,6 @@ ITEM7_PATTERNS = [
|
||||
ITEM8_PATTERNS = [
|
||||
r"Item\s+8\s*[.:]\s*Financial\s+Statements",
|
||||
r"ITEM\s+8\s*[.:]\s*Financial\s+Statements",
|
||||
r"Item\s+8\s*[.:]\s*[\w\s]+Consolidated\s+Financial",
|
||||
]
|
||||
|
||||
|
||||
@@ -77,13 +84,7 @@ def _find_section_start(text: str, patterns: list, item_num: int) -> int:
|
||||
return m.start() if m else -1
|
||||
|
||||
|
||||
def prefilter_after_item7(full_text: str) -> str:
|
||||
start = _find_section_start(full_text, ITEM7_PATTERNS, 7)
|
||||
return full_text[start:] if start >= 0 else full_text
|
||||
|
||||
|
||||
def find_item_section(text: str, item_num: int, title_keywords: list) -> str:
|
||||
patterns = ITEM7_PATTERNS if item_num == 7 else ITEM8_PATTERNS
|
||||
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(
|
||||
@@ -94,59 +95,45 @@ def find_item_section(text: str, item_num: int, title_keywords: list) -> str:
|
||||
if not match:
|
||||
return ""
|
||||
start = match.start()
|
||||
next_item = re.search(r"\n\s*Item\s+\d+\s+", text[start + 100 :], re.IGNORECASE)
|
||||
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 + 150000, len(text))
|
||||
end = min(start + max_chars, len(text))
|
||||
return text[start:end].strip()
|
||||
|
||||
|
||||
def smart_chunk(section: str, max_chars: int = 30000, head_ratio: float = 0.5) -> str:
|
||||
if len(section) <= max_chars:
|
||||
return section
|
||||
head_size = int(max_chars * head_ratio)
|
||||
tail_size = max_chars - head_size - 100
|
||||
return (
|
||||
section[:head_size]
|
||||
+ "\n\n[ ... middle omitted to stay within token limit ... ]\n\n"
|
||||
+ section[-tail_size:]
|
||||
)
|
||||
|
||||
|
||||
def clean_text_for_llm(text: str) -> str:
|
||||
"""
|
||||
Token-compression cleansing before sending to LLM: strip HTML remnants,
|
||||
collapse whitespace, remove page numbers and excessive special characters.
|
||||
"""
|
||||
if not text or not text.strip():
|
||||
return ""
|
||||
# Remove any remaining HTML tags (safe on plain text)
|
||||
text = re.sub(r"<[^>]+>", " ", text)
|
||||
# Collapse multiple spaces to one
|
||||
text = re.sub(r"[ \t]+", " ", text)
|
||||
# Normalise line endings and collapse many blank lines to at most two newlines
|
||||
text = re.sub(r"\r\n?", "\n", text)
|
||||
text = re.sub(r"\n{3,}", "\n\n", text)
|
||||
lines = []
|
||||
for line in text.split("\n"):
|
||||
line = line.strip()
|
||||
# Drop lines that are only digits (page numbers) or only punctuation/dashes
|
||||
if not line:
|
||||
lines.append("")
|
||||
continue
|
||||
if re.fullmatch(r"\d+", line) or re.fullmatch(r"[\.\-\s\-]+", line):
|
||||
continue
|
||||
# Short boilerplate lines (e.g. "Page 1 of 2") — optional: drop very short lines that look like page refs
|
||||
if re.match(r"^(page\s+\d+|\d+)\s*$", line, re.IGNORECASE) and len(line) < 20:
|
||||
continue
|
||||
lines.append(line)
|
||||
# Rejoin and collapse again
|
||||
result = "\n".join(lines)
|
||||
result = re.sub(r"\n{3,}", "\n\n", result)
|
||||
return result.strip()
|
||||
|
||||
|
||||
def smart_chunk(section: str, max_chars: int = 20000, head_ratio: float = 0.5) -> str:
|
||||
if len(section) <= max_chars:
|
||||
return section
|
||||
head_size = int(max_chars * head_ratio)
|
||||
tail_size = max_chars - head_size - 100
|
||||
return section[:head_size] + "\n\n[ ... middle omitted ... ]\n\n" + 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):
|
||||
@@ -181,13 +168,41 @@ def get_main_10k_text(filing_dir: Path) -> str:
|
||||
continue
|
||||
if not all_text:
|
||||
return ""
|
||||
main_path, main_text = max(all_text, key=lambda x: len(x[1]))
|
||||
_, main_text = max(all_text, key=lambda x: len(x[1]))
|
||||
return main_text
|
||||
|
||||
|
||||
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)."""
|
||||
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}'. Check ticker and SEC EDGAR.")
|
||||
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
|
||||
)
|
||||
text_after_7 = full_text
|
||||
start7 = _find_section_start(full_text, ITEM7_PATTERNS, 7)
|
||||
if start7 >= 0:
|
||||
text_after_7 = full_text[start7:]
|
||||
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 = smart_chunk(text_after_7[:120000], max_chars=20000)
|
||||
return full_text, item1a, item7
|
||||
|
||||
|
||||
# ---------- Gemini (qualitative only) ----------
|
||||
GEMINI_MODEL = "gemini-2.0-flash"
|
||||
RATE_LIMIT_WAIT_SEC = 60
|
||||
DELAY_BETWEEN_CALLS_SEC = 8
|
||||
|
||||
|
||||
def get_gemini_model(api_key: str):
|
||||
@@ -198,19 +213,14 @@ def get_gemini_model(api_key: str):
|
||||
|
||||
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
|
||||
)
|
||||
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, generation_config, max_retries: int = 3):
|
||||
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=generation_config)
|
||||
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):
|
||||
@@ -220,256 +230,288 @@ def _generate_with_retry(model, content, generation_config, max_retries: int = 3
|
||||
raise last_err
|
||||
|
||||
|
||||
def get_metrics_from_yfinance(ticker: str) -> pd.DataFrame:
|
||||
"""
|
||||
Quantitative data: fetch Revenue, Net Income, Operating Cash Flow from yfinance
|
||||
(no LLM; fast and accurate). Returns a DataFrame suitable for Streamlit display.
|
||||
"""
|
||||
try:
|
||||
import yfinance as yf
|
||||
except ImportError:
|
||||
return pd.DataFrame()
|
||||
try:
|
||||
t = yf.Ticker(ticker.upper())
|
||||
financials = t.financials # annual income statement
|
||||
cashflow = t.cashflow # annual cash flow
|
||||
if financials is None or financials.empty:
|
||||
return pd.DataFrame()
|
||||
# Prefer common index names (yfinance varies by region)
|
||||
rev_row = None
|
||||
for name in ("Total Revenue", "Revenue", "Net Revenue", "Operating Revenue"):
|
||||
if name in financials.index:
|
||||
rev_row = financials.loc[name]
|
||||
break
|
||||
ni_row = None
|
||||
for name in ("Net Income", "Net Income Common Stockholders", "Net Income Including Noncontrolling Interests"):
|
||||
if name in financials.index:
|
||||
ni_row = financials.loc[name]
|
||||
break
|
||||
ocf_row = None
|
||||
if cashflow is not None and not cashflow.empty:
|
||||
for name in ("Operating Cash Flow", "Cash From Operating Activities", "Cash From Operations"):
|
||||
if name in cashflow.index:
|
||||
ocf_row = cashflow.loc[name]
|
||||
break
|
||||
# Align by date (columns are often datetime)
|
||||
dates = financials.columns.tolist()
|
||||
if not dates:
|
||||
return pd.DataFrame()
|
||||
# Sort descending (most recent first) and take up to 5 years
|
||||
dates = sorted(dates, reverse=True)[:5]
|
||||
cashflow_cols = list(cashflow.columns) if cashflow is not None and not cashflow.empty else []
|
||||
data = {}
|
||||
for d in dates:
|
||||
yr = d.year if hasattr(d, "year") else int(str(d)[:4])
|
||||
rev_val = (rev_row[d] / 1e6) if rev_row is not None and d in rev_row.index else None
|
||||
ni_val = (ni_row[d] / 1e6) if ni_row is not None and d in ni_row.index else None
|
||||
ocf_val = None
|
||||
if ocf_row is not None:
|
||||
if d in ocf_row.index:
|
||||
ocf_val = ocf_row[d] / 1e6
|
||||
else:
|
||||
for c in cashflow_cols:
|
||||
cy = c.year if hasattr(c, "year") else int(str(c)[:4])
|
||||
if cy == yr:
|
||||
ocf_val = ocf_row[c] / 1e6
|
||||
break
|
||||
data[yr] = {"Revenue": rev_val, "Net Income": ni_val, "Operating Cash Flow": ocf_val}
|
||||
df = pd.DataFrame(data).T
|
||||
df.index.name = "Fiscal Year"
|
||||
df = df.astype(float).round(2)
|
||||
return df
|
||||
except Exception:
|
||||
return pd.DataFrame()
|
||||
|
||||
|
||||
def get_ai_summary_and_report(api_key: str, item7_text: str, ticker: str) -> tuple[str, str]:
|
||||
"""
|
||||
Qualitative only: send Item 7 (MD&A) to Gemini. Focus on strategic direction,
|
||||
market risks, and sentiment—not on summarising financial statement numbers.
|
||||
"""
|
||||
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)
|
||||
item7_text = clean_text_for_llm(item7_text)
|
||||
item7_text = smart_chunk(item7_text, max_chars=20000)
|
||||
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 CFA charterholder and senior equity analyst. Use British English.
|
||||
user_prompt = f"""You are a senior equity analyst. Use British English.
|
||||
|
||||
The text below is Item 7 (Management's Discussion and Analysis) only from the 10-K for company ticker: {ticker}. Do NOT ask for financial statements or numbers—this is a qualitative analysis.
|
||||
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.
|
||||
|
||||
Your task:
|
||||
1. **Strategic direction**: How does management describe its strategy, priorities, and capital allocation? What are the main growth drivers or initiatives?
|
||||
2. **Market and business risks**: What material risks (competitive, regulatory, operational, macro) does management emphasise? Be specific and cite the wording where relevant.
|
||||
3. **Tone (Sentiment)**: Overall, is the tone of MD&A more positive, cautious, or negative? Highlight 2–3 phrases or themes that support your view.
|
||||
Provide a concise report with three sections:
|
||||
|
||||
Then write a "CFA INVESTMENT REPORT" section with:
|
||||
- **Executive Summary**: 2–3 sentences on the company's narrative and management's message.
|
||||
- **Investment Thesis**: Key strengths and catalysts from the discussion.
|
||||
- **Key Risks to the Thesis**: Main downside risks from the text.
|
||||
- **Conclusion**: Balanced wrap-up.
|
||||
1. **Management's Tone (Sentiment)**: Is the overall tone positive, cautious, or negative? Quote 1–2 short phrases that support your view.
|
||||
|
||||
Keep the entire response in British English. Use clear section headers. Do not invent figures—only refer to what is in the text."""
|
||||
2. **Key Strategic Shifts**: What strategic priorities or shifts does management emphasise (e.g. capital allocation, growth drivers, new segments)? Be specific.
|
||||
|
||||
full_content = f"""--- Item 7. Management's Discussion and Analysis (MD&A) ---\n\n{item7_text}\n\n---\n\n{user_prompt}"""
|
||||
3. **Major Hidden Risks**: From both Risk Factors and MD&A, what are the 3–4 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": 8192})
|
||||
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.", "No report generated."
|
||||
|
||||
text = response.text.strip()
|
||||
detailed, report = text, ""
|
||||
if "CFA INVESTMENT REPORT" in text.upper():
|
||||
parts = re.split(r"\n\s*(?:CFA INVESTMENT REPORT|CFA Investment Report)\s*\n", text, maxsplit=1, flags=re.IGNORECASE)
|
||||
detailed = (parts[0].replace("DETAILED ANALYSIS", "").strip() if parts else "").strip() or text
|
||||
report = parts[1].strip() if len(parts) > 1 else ""
|
||||
else:
|
||||
report = "(CFA Investment Report section not clearly separated; full analysis above.)"
|
||||
|
||||
return detailed, report
|
||||
return "No analysis generated."
|
||||
return response.text.strip()
|
||||
|
||||
|
||||
def download_and_extract_sections(ticker: str, email: str) -> tuple[str, str, str]:
|
||||
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 file. Check ticker '{ticker}' and SEC EDGAR response.")
|
||||
full_text = get_main_10k_text(filing_dir)
|
||||
if not full_text:
|
||||
raise ValueError("Could not extract text from the 10-K.")
|
||||
text_from_item7 = prefilter_after_item7(full_text)
|
||||
item7 = find_item_section(text_from_item7, 7, ["Management's Discussion", "MD&A", "Analysis"])
|
||||
item8 = find_item_section(text_from_item7, 8, ["Financial Statements", "Consolidated"])
|
||||
if not item7:
|
||||
item7 = smart_chunk(text_from_item7[:120000], max_chars=20000)
|
||||
if not item8:
|
||||
remainder = text_from_item7[100000:220000] if len(text_from_item7) > 100000 else text_from_item7
|
||||
item8 = smart_chunk(remainder, max_chars=20000)
|
||||
return full_text, item7, item8
|
||||
# ---------- yfinance: DCF inputs ----------
|
||||
@st.cache_data(ttl=300)
|
||||
def get_dcf_inputs(ticker: str) -> dict:
|
||||
"""Fetch FCF, Total Debt, Cash, Shares Outstanding for DCF. Returns dict or empty on failure."""
|
||||
if not yf:
|
||||
return {}
|
||||
try:
|
||||
t = yf.Ticker(ticker.upper())
|
||||
info = t.info
|
||||
cashflow = t.cashflow
|
||||
balance = t.balance_sheet
|
||||
if cashflow is None or cashflow.empty:
|
||||
return {}
|
||||
fcf_row = None
|
||||
for name in ("Free Cash Flow", "Cash From Operations"):
|
||||
if name in cashflow.index:
|
||||
fcf_row = cashflow.loc[name]
|
||||
break
|
||||
if fcf_row is None and len(cashflow.index) > 0:
|
||||
fcf_row = cashflow.iloc[0]
|
||||
latest_fcf = None
|
||||
if fcf_row is not None and len(fcf_row) > 0:
|
||||
try:
|
||||
latest_fcf = float(fcf_row.iloc[0])
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
if latest_fcf is not None and (latest_fcf != latest_fcf or latest_fcf <= 0):
|
||||
latest_fcf = None
|
||||
total_debt = info.get("Total Debt")
|
||||
cash = info.get("Cash And Cash Equivalents") or info.get("Cash")
|
||||
shares = info.get("Shares Outstanding") or info.get("Float Shares")
|
||||
if balance is not None and not balance.empty:
|
||||
if total_debt is None and "Total Debt" in balance.index:
|
||||
try:
|
||||
total_debt = float(balance.loc["Total Debt"].iloc[0])
|
||||
except (TypeError, ValueError, KeyError):
|
||||
pass
|
||||
if cash is None and "Cash And Cash Equivalents" in balance.index:
|
||||
try:
|
||||
cash = float(balance.loc["Cash And Cash Equivalents"].iloc[0])
|
||||
except (TypeError, ValueError, KeyError):
|
||||
pass
|
||||
return {
|
||||
"fcf": latest_fcf,
|
||||
"total_debt": total_debt if total_debt is not None else 0,
|
||||
"cash": cash if cash is not None else 0,
|
||||
"shares": shares if shares is not None and shares > 0 else None,
|
||||
}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def run_analysis(ticker: str, api_key: str, email: str, analysis_only: bool = False) -> tuple[str, str, str, pd.DataFrame]:
|
||||
full_text, item7, _ = download_and_extract_sections(ticker, email)
|
||||
detailed_summary, cfa_report = get_ai_summary_and_report(api_key, item7, ticker)
|
||||
if analysis_only:
|
||||
df_metrics = pd.DataFrame()
|
||||
else:
|
||||
df_metrics = get_metrics_from_yfinance(ticker)
|
||||
return detailed_summary, cfa_report, full_text, df_metrics
|
||||
def dcf_intrinsic_value(fcf: float, wacc: float, terminal_growth: float, revenue_growth: float, years: int = 10) -> float:
|
||||
"""DCF: project FCF with revenue_growth, terminal value with terminal_growth, discount at WACC. Returns enterprise value."""
|
||||
if fcf <= 0 or wacc <= terminal_growth:
|
||||
return 0.0
|
||||
pv = 0.0
|
||||
fcft = fcf
|
||||
for t in range(1, years + 1):
|
||||
pv += fcft / ((1 + wacc) ** t)
|
||||
fcft *= (1 + revenue_growth)
|
||||
terminal_fcf = fcft
|
||||
tv = terminal_fcf * (1 + terminal_growth) / (wacc - terminal_growth)
|
||||
pv += tv / ((1 + wacc) ** years)
|
||||
return pv
|
||||
|
||||
|
||||
# ---------- yfinance: Comps (multiples) ----------
|
||||
@st.cache_data(ttl=300)
|
||||
def get_comps_data(tickers: tuple) -> pd.DataFrame:
|
||||
"""Fetch Forward P/E, EV/EBITDA, P/B for each ticker. Returns styled DataFrame."""
|
||||
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
|
||||
forward_pe = info.get("Forward PE") or info.get("Trailing PE")
|
||||
pb = info.get("Price To Book")
|
||||
ev = info.get("Enterprise Value")
|
||||
ebitda = info.get("EBITDA")
|
||||
ev_ebitda = (ev / ebitda) if (ev is not None and ebitda is not None and ebitda != 0) else None
|
||||
rows.append({
|
||||
"Ticker": sym,
|
||||
"Forward P/E": round(forward_pe, 2) if forward_pe is not None else None,
|
||||
"EV/EBITDA": round(ev_ebitda, 2) if ev_ebitda is not None else None,
|
||||
"P/B": round(pb, 2) if 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)
|
||||
|
||||
|
||||
# ---------- Streamlit UI ----------
|
||||
st.set_page_config(page_title="10-K Financial Analyzer", layout="wide")
|
||||
st.title("10-K Financial Analyzer")
|
||||
st.caption("Hybrid: 10-K Item 7 (MD&A) → Gemini for sentiment & risks; financial metrics from yfinance. British English.")
|
||||
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); yfinance for quantitative (DCF, Comps). Cost-effective personal research.")
|
||||
|
||||
with st.sidebar:
|
||||
st.header("Settings")
|
||||
google_api_key = st.text_input("Google API Key (Gemini)", type="password", value=os.environ.get("GOOGLE_API_KEY", ""), help="Obtain from https://aistudio.google.com/apikey")
|
||||
email = st.text_input("SEC EDGAR Email Address", value=os.environ.get("SEC_EDGAR_EMAIL", ""), help="Required for SEC programmatic download policy compliance.")
|
||||
analysis_only = st.checkbox("Analysis only (1 API call)", value=False, help="Skip metrics table to use only 1 API call.")
|
||||
google_api_key = st.text_input(
|
||||
"Google API Key (Gemini)",
|
||||
type="password",
|
||||
value=os.environ.get("GOOGLE_API_KEY", ""),
|
||||
help="Required for Tab 1 (10-K insights).",
|
||||
)
|
||||
sec_email = st.text_input(
|
||||
"SEC EDGAR Email",
|
||||
value=os.environ.get("SEC_EDGAR_EMAIL", ""),
|
||||
help="Required for 10-K download.",
|
||||
)
|
||||
ticker = st.text_input("Primary Ticker", value="NVDA", max_chars=10).strip().upper()
|
||||
st.session_state["google_api_key"] = google_api_key
|
||||
st.session_state["email"] = email
|
||||
st.session_state["analysis_only"] = analysis_only
|
||||
st.session_state["sec_email"] = sec_email
|
||||
st.session_state["ticker"] = ticker
|
||||
|
||||
ticker = st.text_input("Stock Ticker (e.g. AAPL, MSFT)", value="AAPL", max_chars=10).strip().upper()
|
||||
if not ticker:
|
||||
st.info("Enter a ticker and click 'Run Analysis', or pick one from the S&P 500 list below.")
|
||||
ticker = st.session_state.get("ticker", "NVDA") or "NVDA"
|
||||
|
||||
SP500_SAMPLE = [
|
||||
("Apple Inc.", "AAPL"), ("Microsoft Corporation", "MSFT"), ("Amazon.com Inc.", "AMZN"),
|
||||
("NVIDIA Corporation", "NVDA"), ("Alphabet Inc. (Google)", "GOOGL"), ("Meta Platforms Inc. (Facebook)", "META"),
|
||||
("Berkshire Hathaway Inc.", "BRK.B"), ("Tesla Inc.", "TSLA"), ("JPMorgan Chase & Co.", "JPM"),
|
||||
("Visa Inc.", "V"), ("UnitedHealth Group Inc.", "UNH"), ("Procter & Gamble Co.", "PG"),
|
||||
("Exxon Mobil Corporation", "XOM"), ("Johnson & Johnson", "JNJ"), ("Mastercard Inc.", "MA"),
|
||||
("Chevron Corporation", "CVX"), ("Home Depot Inc.", "HD"), ("Merck & Co. Inc.", "MRK"),
|
||||
("AbbVie Inc.", "ABBV"), ("Costco Wholesale Corporation", "COST"), ("PepsiCo Inc.", "PEP"),
|
||||
("Coca-Cola Company", "KO"), ("Pfizer Inc.", "PFE"), ("Walmart Inc.", "WMT"), ("Netflix Inc.", "NFLX"),
|
||||
("Adobe Inc.", "ADBE"), ("Salesforce Inc.", "CRM"), ("Comcast Corporation", "CMCSA"), ("Cisco Systems Inc.", "CSCO"),
|
||||
("Oracle Corporation", "ORCL"), ("Intel Corporation", "INTC"), ("American Express Company", "AXP"),
|
||||
("Bank of America Corp.", "BAC"), ("Wells Fargo & Company", "WFC"), ("Verizon Communications Inc.", "VZ"),
|
||||
("AT&T Inc.", "T"), ("Disney (Walt Disney Co.)", "DIS"), ("Nike Inc.", "NKE"), ("McDonald's Corporation", "MCD"),
|
||||
("Starbucks Corporation", "SBUX"), ("Goldman Sachs Group Inc.", "GS"), ("Morgan Stanley", "MS"),
|
||||
]
|
||||
tab1, tab2, tab3 = st.tabs(["10-K & MD&A Insights", "3-Scenario DCF Valuation", "Industry Analysis & Comps"])
|
||||
|
||||
st.caption("Select a ticker above or choose from the list below.")
|
||||
# ----- Tab 1: 10-K & MD&A Insights -----
|
||||
with tab1:
|
||||
st.subheader("10-K & MD&A Insights (Qualitative)")
|
||||
st.markdown("Extract **Item 1A (Risk Factors)** and **Item 7 (MD&A)** from the latest 10-K. Gemini analyses: **Management's Tone**, **Strategic Shifts**, **Hidden Risks**.")
|
||||
if st.button("Run 10-K Analysis", key="run_10k"):
|
||||
if not ticker:
|
||||
st.error("Enter a ticker in the sidebar.")
|
||||
elif not st.session_state.get("google_api_key"):
|
||||
st.error("Enter your Google API Key in the sidebar.")
|
||||
elif not st.session_state.get("sec_email"):
|
||||
st.error("Enter your SEC EDGAR email in the sidebar.")
|
||||
else:
|
||||
try:
|
||||
with st.spinner("Downloading 10-K and extracting Item 1A & Item 7..."):
|
||||
full_text, item1a, item7 = download_and_extract_item7_and_1a(ticker, st.session_state["sec_email"])
|
||||
with st.spinner("Running Gemini analysis (tone, strategy, risks)..."):
|
||||
analysis = get_mda_insights(
|
||||
st.session_state["google_api_key"], item1a, item7, ticker
|
||||
)
|
||||
st.success("Analysis complete.")
|
||||
st.markdown(analysis)
|
||||
with st.expander("View raw excerpt (Item 1A + Item 7)"):
|
||||
excerpt = (item1a or "") + "\n\n---\n\n" + (item7 or "")
|
||||
st.text(excerpt[:12000] + ("..." if len(excerpt) > 12000 else ""))
|
||||
except FileNotFoundError as e:
|
||||
st.error(str(e))
|
||||
except ValueError as e:
|
||||
st.error(str(e))
|
||||
except RuntimeError as e:
|
||||
st.error(str(e))
|
||||
except Exception as e:
|
||||
st.error("An error occurred. See details below.")
|
||||
with st.expander("Error details"):
|
||||
st.code(repr(e), language="text")
|
||||
|
||||
if st.button("Run Analysis"):
|
||||
if not ticker:
|
||||
st.error("Please enter or select a stock ticker.")
|
||||
st.stop()
|
||||
api_key = st.session_state.get("google_api_key", "")
|
||||
email = st.session_state.get("email", "")
|
||||
if not api_key:
|
||||
st.error("Please enter your Google API Key (Gemini) in Settings.")
|
||||
st.stop()
|
||||
if not email:
|
||||
st.error("Please enter your SEC EDGAR email address in Settings.")
|
||||
st.stop()
|
||||
analysis_only = st.session_state.get("analysis_only", False)
|
||||
try:
|
||||
with st.spinner("Step 1/2: Downloading 10-K and extracting Item 7 (MD&A)..."):
|
||||
full_text, item7, _ = download_and_extract_sections(ticker, email)
|
||||
with st.spinner("Step 2/2: Running Gemini (qualitative analysis) and fetching financial metrics..."):
|
||||
detailed_summary, cfa_report = get_ai_summary_and_report(api_key, item7, ticker)
|
||||
if analysis_only:
|
||||
df_metrics = pd.DataFrame()
|
||||
# ----- Tab 2: 3-Scenario DCF -----
|
||||
with tab2:
|
||||
st.subheader("3-Scenario DCF Valuation (Quantitative)")
|
||||
st.markdown("Uses **yfinance** for FCF, Debt, Cash, Shares. No Gemini. Adjust assumptions with sliders.")
|
||||
dcf_inputs = get_dcf_inputs(ticker) if ticker else {}
|
||||
if not dcf_inputs:
|
||||
st.warning("Could not fetch DCF inputs from yfinance. Check ticker or try again.")
|
||||
else:
|
||||
fcf = dcf_inputs.get("fcf") or 0
|
||||
total_debt = dcf_inputs.get("total_debt") or 0
|
||||
cash = dcf_inputs.get("cash") or 0
|
||||
shares = dcf_inputs.get("shares")
|
||||
if fcf and fcf > 0 and shares and shares > 0:
|
||||
col1, col2, col3 = st.columns(3)
|
||||
with col1:
|
||||
wacc = st.slider("WACC (%)", 4.0, 20.0, 10.0, 0.5) / 100.0
|
||||
with col2:
|
||||
term_growth = st.slider("Terminal Growth Rate (%)", -2.0, 6.0, 2.0, 0.25) / 100.0
|
||||
with col3:
|
||||
base_growth = st.slider("Base Case FCF Growth (%)", -10.0, 30.0, 8.0, 0.5) / 100.0
|
||||
bull_growth = base_growth + 0.02
|
||||
bear_growth = base_growth - 0.02
|
||||
ev_base = dcf_intrinsic_value(fcf, wacc, term_growth, base_growth)
|
||||
ev_bull = dcf_intrinsic_value(fcf, wacc, term_growth, bull_growth)
|
||||
ev_bear = dcf_intrinsic_value(fcf, wacc, term_growth, bear_growth)
|
||||
equity_base = ev_base - total_debt + cash
|
||||
equity_bull = ev_bull - total_debt + cash
|
||||
equity_bear = ev_bear - total_debt + cash
|
||||
price_base = equity_base / shares if shares else 0
|
||||
price_bull = equity_bull / shares if shares else 0
|
||||
price_bear = equity_bear / shares if shares else 0
|
||||
st.markdown("#### Intrinsic Value per Share (3 Scenarios)")
|
||||
c1, c2, c3 = st.columns(3)
|
||||
c1.metric("Bull (+2% growth)", f"${price_bull:.2f}", "Base vs Bull")
|
||||
c2.metric("Base", f"${price_base:.2f}", "—")
|
||||
c3.metric("Bear (-2% growth)", f"${price_bear:.2f}", "Base vs Bear")
|
||||
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), round(price_base, 2), round(price_bear, 2)],
|
||||
})
|
||||
st.dataframe(df_dcf, use_container_width=True, hide_index=True)
|
||||
else:
|
||||
st.info("FCF or Shares Outstanding not available for this ticker. Try another.")
|
||||
|
||||
# ----- Tab 3: Industry Comps -----
|
||||
with tab3:
|
||||
st.subheader("Industry Analysis & Comps")
|
||||
st.markdown("Enter **comma-separated competitor tickers** (e.g. `AMD, INTC, QCOM`). Multiples from **yfinance**.")
|
||||
comp_tickers = st.text_input("Competitor tickers", value="AMD, INTC, QCOM", key="comps").strip()
|
||||
if st.button("Load Comps", key="load_comps"):
|
||||
tickers_list = [t.strip().upper() for t in comp_tickers.split(",") if t.strip()]
|
||||
if ticker and ticker not in tickers_list:
|
||||
tickers_list = [ticker] + tickers_list
|
||||
if not tickers_list:
|
||||
st.warning("Enter at least one ticker.")
|
||||
else:
|
||||
df_comps = get_comps_data(tuple(tickers_list))
|
||||
if df_comps.empty:
|
||||
st.warning("Could not fetch comps from yfinance.")
|
||||
else:
|
||||
df_metrics = get_metrics_from_yfinance(ticker)
|
||||
|
||||
st.success("Analysis complete.")
|
||||
st.subheader("Detailed Analysis (Strategy, Risks, Sentiment — from Item 7 MD&A)")
|
||||
st.markdown(detailed_summary)
|
||||
st.subheader("CFA Investment Report")
|
||||
st.markdown(cfa_report)
|
||||
st.subheader("Key Financial Metrics (Revenue, Net Income, Operating Cash Flow) — from yfinance")
|
||||
if not df_metrics.empty:
|
||||
st.dataframe(df_metrics, use_container_width=True)
|
||||
st.caption("Values in millions (USD). Source: yfinance.")
|
||||
elif analysis_only:
|
||||
st.info("Metrics skipped (Analysis only mode).")
|
||||
else:
|
||||
st.info("No metrics available for this ticker from yfinance.")
|
||||
with st.expander("View excerpt of extracted 10-K text"):
|
||||
st.text(full_text[:15000] + ("..." if len(full_text) > 15000 else ""))
|
||||
|
||||
except FileNotFoundError as e:
|
||||
st.error(str(e))
|
||||
except ValueError as e:
|
||||
st.error(str(e))
|
||||
except RuntimeError as e:
|
||||
st.error(str(e))
|
||||
if analysis_only:
|
||||
st.warning("You already have Analysis only on. Wait 2–5 minutes, then try again.")
|
||||
else:
|
||||
st.info("Wait 2–5 minutes, or enable Analysis only (1 API call) in Settings.")
|
||||
except Exception as e:
|
||||
err_msg = str(e).lower()
|
||||
if "429" in err_msg or ("resource" in err_msg and "exhausted" in err_msg):
|
||||
st.error("Rate limit exceeded. Please try again in a few minutes.")
|
||||
st.info("Wait 2–5 minutes, or enable **Analysis only (1 API call)** in the sidebar.")
|
||||
elif "404" in err_msg or "not found" in err_msg:
|
||||
st.error("The selected model is not available. Check Google AI Studio for available models.")
|
||||
elif "timeout" in err_msg or "retryerror" in err_msg or "600" in err_msg:
|
||||
st.error("Request timed out. The API took too long to respond.")
|
||||
st.info("Try again, or enable **Analysis only (1 API call)** to send less data.")
|
||||
else:
|
||||
st.error("An error occurred. Please try again later.")
|
||||
st.caption("If the problem persists, check your API key and internet connection.")
|
||||
with st.expander("Error details (for troubleshooting)"):
|
||||
st.code(repr(e), language="text")
|
||||
st.dataframe(df_comps, use_container_width=True, hide_index=True)
|
||||
|
||||
st.divider()
|
||||
st.subheader("S&P 500 companies (sample) — Company name & Ticker")
|
||||
st.caption("Type a ticker from the list into the box above.")
|
||||
df_sp = pd.DataFrame(SP500_SAMPLE, columns=["Company name", "Ticker"])
|
||||
with st.expander("Show list", expanded=True):
|
||||
with st.expander("S&P 500 sample — Company & Ticker"):
|
||||
SP500_SAMPLE = [
|
||||
("NVIDIA Corporation", "NVDA"), ("Apple Inc.", "AAPL"), ("Microsoft Corporation", "MSFT"),
|
||||
("Amazon.com Inc.", "AMZN"), ("Alphabet Inc. (Google)", "GOOGL"), ("Meta Platforms Inc.", "META"),
|
||||
("AMD", "AMD"), ("Intel Corporation", "INTC"), ("Qualcomm Inc.", "QCOM"),
|
||||
]
|
||||
df_sp = pd.DataFrame(SP500_SAMPLE, columns=["Company", "Ticker"])
|
||||
st.dataframe(df_sp, use_container_width=True, hide_index=True)
|
||||
|
||||
Reference in New Issue
Block a user