docs: Update README — 3-tab system (10-K Insights, DCF, Comps) + Project Origin & Vision (English)

This commit is contained in:
shawnkim1997
2026-02-13 22:13:43 +00:00
parent fd852c7075
commit ca42f3b83d
3 changed files with 394 additions and 284 deletions
+60 -16
View File
@@ -1,22 +1,42 @@
# 10-K Financial Analyzer
# All-in-One Financial Analysis Dashboard
A web app that fetches the latest 10-K from SEC EDGAR for a given stock ticker and uses a **hybrid architecture**: **qualitative** analysis (Item 7 MD&A only) via **Google Gemini**, and **quantitative** metrics (Revenue, Net Income, Operating Cash Flow) from **yfinance**. CFA-style report and key financials in one place.
A **cost-effective** Streamlit app that unifies **qualitative AI-driven insights** and **quantitative valuation** in a single workflow. **Hybrid architecture:** Gemini powers narrative analysis (10-K MD&A and Risk Factors); all numbers—DCF inputs and peer multiples—come from **yfinance**, keeping API costs low and numerical accuracy high.
The app is organised into **three tabs:**
| Tab | Purpose |
|-----|---------|
| **1. 10-K Qualitative Insights** | SEC EDGAR 10-K → Item 1A (Risk Factors) + Item 7 (MD&A) → cleaned text → Gemini. Output: managements tone (sentiment), key strategic shifts, and major hidden risks. |
| **2. DCF Valuation** | yfinance for FCF, Debt, Cash, Shares. Sliders for WACC, terminal growth, FCF growth. **3-scenario model** (Bull / Base / Bear) with intrinsic value per share. No LLM. |
| **3. Industry Comps** | Comma-separated competitor tickers → yfinance **Forward P/E**, **EV/EBITDA**, **P/B** → comparison table. |
---
## Features
- **Hybrid processing (qualitative + quantitative):**
- **Qualitative:** Only **Item 7 (MD&A)** is sent to Gemini for analysis of managements strategy, market risks, and sentiment—no Item 8 (financial statements) to the AI, which cuts token use and avoids number hallucination.
- **Quantitative:** Financial metrics (Revenue, Net Income, Operating Cash Flow) are fetched directly from **yfinance**—fast, accurate, and no extra API tokens.
- **HTML cleansing:** Before sending Item 7 to the LLM, the app strips remaining HTML tags, collapses whitespace, and removes page numbers to compress tokens.
- **Selective extraction:** The 10-K is parsed with regex; only content from Item 7 onward is used for AI; PART I and Items 16 are dropped.
- **Smart chunking:** Long Item 7 text is trimmed to head + tail to stay within token limits.
- **Two-step progress:** Step 1 (download + extract Item 7), Step 2 (Gemini analysis + yfinance metrics).
- **Analysis only mode:** Optional hide for the metrics table (Gemini still runs once on Item 7).
- **S&P 500 reference list:** Sample table of company names and tickers at the bottom for quick lookup.
- **Tab 1 — 10-K Qualitative Insights:** Item 1A + Item 7 from SEC EDGAR; HTML cleaned; one Gemini call for tone, strategic shifts, and hidden risks.
- **Tab 2 — DCF Valuation:** FCF, Debt, Cash, Shares from yfinance; WACC / terminal growth / FCF growth sliders; Bull/Base/Bear intrinsic value per share; no LLM.
- **Tab 3 — Industry Comps:** Comma-separated tickers; Forward P/E, EV/EBITDA, P/B from yfinance; comparison table.
- **Error handling:** Try/except for SEC EDGAR and yfinance; clear messages when data is missing or requests fail.
- **UI:** Three-tab layout, sidebar for API key and SEC email, professional layout.
**Typical run time:** About **12 minutes** (one Gemini call; yfinance metrics are near-instant). If the API is rate-limited, the app waits 60 seconds and retries automatically.
**Run time:** Tab 1 ≈ 12 min (one Gemini call); Tabs 23 use yfinance (seconds). Rate limit: 60s retry.
---
## Project Origin & Vision
### The Origin — The Walk
The core idea for this all-in-one architecture came during a **quiet walk**. I was deep in thought about the inefficiencies and fragmentation of traditional equity research: narrative buried in 200-page filings, valuation models in separate spreadsheets, and comps scattered across different tools. It became clear that what we need is not more dashboards, but **one seamless workflow**—where qualitative AI insights and quantitative valuation models live in the same place, speak the same language, and serve the same decision. That moment crystallised into the design you see here: **unified, cost-conscious, and built for the analyst who thinks in both words and numbers.**
### The Vision — Commercialization
This repository is a **functional MVP (Minimum Viable Product)** and **demo**. It proves the concept: hybrid architecture works; 10-K + DCF + comps can sit in a single interface; and the unit economics (one Gemini call for narrative, free data for the rest) scale. The code is production-minded but not yet productised—it is the foundation on which a commercial product will be built.
### Future Roadmap
The **ultimate goal** is to launch this as a **fully commercialised B2C/B2B SaaS** application. We aim to serve **retail investors** who want institutional-grade structure without the complexity, and **finance professionals** (equity analysts, portfolio managers, corporate development) who want to move from filing → insight → valuation in one flow. Data-driven, transparent, and built by someone who cares as much about the quality of the analysis as the quality of the code. This project is the first step on that path.
---
@@ -61,15 +81,39 @@ For full technical notes and code references, see **[TECHNICAL_NOTES.md](./TECHN
## How to Run
**1. Go to the project folder**
```bash
cd "/Users/seonpil/Documents/FQDC Project"
```
**2. Activate the virtual environment** (required so `pip` and `streamlit` are found)
- **Mac / Linux:**
```bash
source venv/bin/activate
```
- **Windows (PowerShell):**
```powershell
venv\Scripts\Activate.ps1
```
After activation, your prompt usually shows `(venv)`.
**3. Install dependencies** (only needed once, or when requirements change)
```bash
cd "/path/to/FQDC Project"
python3 -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
pip install -r requirements.txt
```
**4. Start the app**
```bash
streamlit run app.py
```
Open the sidebar to set **Google API Key** and **SEC EDGAR Email**, then enter a ticker (e.g. `AAPL`, `MSFT`) and click **Run Analysis**. Use **Analysis only (1 API call)** if you hit rate limits.
If you dont have a `venv` folder yet, create it first:
```bash
python3 -m venv venv
source venv/bin/activate # then steps 3 and 4
```
Open the sidebar to set **Google API Key** and **SEC EDGAR Email**, then use the three tabs (10-K Insights, DCF, Comps) as needed.
---
+310 -268
View File
@@ -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 23 phrases or themes that support your view.
Provide a concise report with three sections:
Then write a "CFA INVESTMENT REPORT" section with:
- **Executive Summary**: 23 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 12 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 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": 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 25 minutes, then try again.")
else:
st.info("Wait 25 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 25 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)
+24
View File
@@ -0,0 +1,24 @@
#!/bin/bash
# Push current (English) version to GitHub
# Run from project root: bash push_to_github.sh
set -e
cd "/Users/seonpil/Documents/FQDC Project"
echo "--- Git status ---"
git status
echo ""
echo "--- Add all changes ---"
git add -A
echo ""
echo "--- Commit (README: 3-tab architecture + Project Origin & Vision) ---"
git commit -m "docs: Update README — 3-tab system (10-K Insights, DCF, Comps) + Project Origin & Vision (English)"
echo ""
echo "--- Push to origin main ---"
git push origin main
echo ""
echo "Done. Check https://github.com/shawnkim1997/10-K-summariser-project"