From bf8b43e7c90a719d280c1c8f812aeb16817013b2 Mon Sep 17 00:00:00 2001 From: shawnkim1997 Date: Fri, 13 Feb 2026 23:35:08 +0000 Subject: [PATCH] README: Design Rationale, detailed changelog, institutional DCF notes --- .gitignore | 1 + README.md | 74 ++++-- app.py | 655 +++++++++++++++++++++++++++++++++++++++++------------ 3 files changed, 575 insertions(+), 155 deletions(-) diff --git a/.gitignore b/.gitignore index e223540..36cf189 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ venv/ .env +.app_prefs.json __pycache__/ .DS_Store diff --git a/README.md b/README.md index bf8fd88..7a895e0 100644 --- a/README.md +++ b/README.md @@ -6,19 +6,19 @@ 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: management’s 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. | +| **1. 10-K & MD&A Insights** | SEC EDGAR 10-K → Item 1A + Item 7 → cleaned text → Gemini. Output: management’s tone (sentiment), key strategic shifts, and major hidden risks. | +| **2. 3-Scenario DCF Valuation** | **10-year 2-stage DCF** (Y1–5 growth, Y6–10 fade). Smart defaults (CAPM WACC, revenue growth). Wall Street Assumptions panel: analyst consensus + Damodaran WACC/ERP/Rf. Bull/Base/Bear intrinsic value. | +| **3. Top-Down Sector Analysis** | Industry selectbox → peer comps (P/E, EV/EBITDA, P/B); conditional formatting; **Generate Industry Outlook** (Gemini) for macro trends. | --- ## Features -- **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. +- **Tab 1 — 10-K & MD&A:** Item 1A + Item 7; DuPont, Altman Z, red flags, YoY; sector/industry badge; sector-specific metrics (Tech/Retail/Financials); Gemini comparative MD&A with sector-aware Non-GAAP KPI table. TTM fallback and N/A handling when yfinance rows are missing. +- **Tab 2 — DCF:** 10-year 2-stage model (5-year growth + 5-year fade to terminal rate). Base FCF = OCF − CapEx; shares/debt/cash from yfinance with robust fallbacks; manual inputs only as last resort. Smart slider defaults (Beta/CAPM WACC, 2.5% terminal, revenue/earnings growth). Reference panel: analyst consensus (target price, recommendation, revenue/earnings growth) and Damodaran sector WACC, ERP, 10Y risk-free rate with methodology link. +- **Tab 3 — Sector Analysis:** Predefined sectors with top 5 tickers each; comps table with N/A for missing multiples; green/red formatting; AI Industry Outlook (Gemini) for macro trends and risks. +- **Error handling:** Try/except for SEC EDGAR, yfinance, and Gemini; clear messages and optional manual overrides so the app keeps running. +- **UI:** Three-tab layout, sidebar (API key, SEC email, company/ticker search), optional "Remember API key & email" (local prefs file). **Run time:** Tab 1 ≈ 1–2 min (one Gemini call); Tabs 2–3 use yfinance (seconds). Rate limit: 60s retry. @@ -40,6 +40,27 @@ The **ultimate goal** is to launch this as a **fully commercialised B2C/B2B SaaS --- +## Design Rationale & Interview Notes + +*(Why certain features were built the way they were — useful for interviews and discussions.)* + +- **Undergraduate automation mindset** + As an undergraduate student, I realised that rather than just learning Excel and basic Python and doing everything manually, **automating the full workflow with AI and programmatic data** is far more powerful. This dashboard is the result: one place for 10-K narrative (Gemini), numbers (yfinance), DCF, and comps, so the analyst can focus on judgment instead of copy-pasting between tools. + +- **Why a 10-year DCF instead of 5 years** + A standard 5-year projection is often **too short for practical, real-world corporate analysis**. Many companies have growth that extends beyond five years, and terminal value then dominates the result, which can overstate or misstate value. The **10-year 2-stage model** (Stage 1: Years 1–5 at the chosen FCF growth rate; Stage 2: Years 6–10 with growth **linearly fading** down to the terminal growth rate) is closer to how institutional DCFs are built and avoids absurd valuations for high-growth names. + +- **Integrating Damodaran’s academic baselines** + I regularly read valuation literature and **wanted to integrate Aswath Damodaran’s academic baselines directly into the app**. The “Reference: Analyst & Macro Assumptions” panel shows sector WACC benchmarks (e.g. Software 8.5%, Retail 7.5%, Hardware 9.0%, Financials 8.0%), US equity risk premium (~4.6%), and the 10-year risk-free rate (~4.2%), with a link to his data and methodology so users can verify and align their assumptions with established research. + +- **Consensus numbers next to the DCF sliders** + Having **analyst consensus data (target price, recommendation, revenue/earnings growth) right next to the DCF sliders** makes it much easier to make informed adjustments. Instead of guessing WACC or growth, the user can compare their inputs to both consensus and Damodaran’s macro baselines in one view, like a professional equity research dashboard. + +- **Commercialization** + Once the app’s **completeness and robustness reach a higher professional standard**, my ultimate goal is to **fully commercialise it** (e.g. B2C/B2B SaaS). The current codebase is built as a production-minded MVP and demo to validate the hybrid architecture and user flow before scaling. + +--- + ## Tech Stack - **UI**: Streamlit @@ -132,16 +153,39 @@ Open the sidebar to set **Google API Key** and **SEC EDGAR Email**, then use the ## Update history (Changelog) -Updates are listed in **reverse chronological order (newest first)**. +Updates are listed in **reverse chronological order (newest first)**. Each row summarises **what** was added and **why** (where relevant). | Date (UTC) | Updates | |------------|---------| -| **2025-02-12 15:30** | **Dynamic Sector-Specific Analysis:** (1) **DuPont display:** Replace None/NaN with "N/A" in the table so missing rows (e.g. SBUX) do not break the layout. (2) **Sector & industry badge:** Auto-detect from `ticker.info` (sector/industry) and show Sector · Industry caption at top of Tab 1. (3) **Sector-specific metrics:** Technology (Rule of 40, FCF margin, R&D % of revenue), Retail/Consumer (inventory turnover, operating margin), Financials (ROE, ROA) — Tab 1 "Sector-specific metrics" block. (4) **Tab 2:** For Financial sector, add caption that FCF/EBITDA are less relevant and to refer to ROE/ROA in Tab 1. (5) **Gemini MD&A:** Add sector/industry args to `get_mda_comparative_insights`; prompt instructs extraction of industry-specific Non-GAAP KPIs (Same-Store Sales, ARR/NDR, DAU/MAU, etc.) in a markdown table. | -| **2025-02-12 11:00** | **Data robustness & TTM fallback:** (1) yfinance **KeyError/NaN handling:** `_get_row_series` with try/except for financial/balance-sheet row access; `_na(x)` for consistent "N/A" display. (2) **TTM fallback:** When annual `financials`/`balance_sheet` are missing or empty, use sum of first 4 quarters from `quarterly_financials` and latest quarter from `quarterly_balance_sheet` for DuPont/Altman. (3) **`get_sector_industry(ticker)`** added (cached sector/industry). (4) `get_dupont_altman_redflags_yoy` returns empty dict on exception; stronger column/date checks when building TTM. | -| **2025-02-12 09:00** | **Hybrid architecture:** Send only Item 7 (MD&A) to Gemini; fetch financial metrics from **yfinance**. Add **HTML cleansing** (strip tags, collapse whitespace, remove page numbers). Change prompt to focus on management strategy, risks, and **sentiment** analysis. Add **find_toc.py** (SEC EDGAR HTML URL → Table of Contents extraction). Overhaul README for hybrid flow, Tech Stack, and Technical Challenge. | -| **2025-02-12 08:45** | Add **Update history (Changelog)** to README and include `find_toc.py` in Project Structure. | -| **2025-02-01** (approx.) | Selective extraction: send only Item 7 & 8 to the API. 429 handling: retry with 60s wait, "Analysis only" option, timeout/404/error messages. Switch to Google **Gemini** and add `GOOGLE_API_KEY` (.env and sidebar). CFA Investment Report, metrics table, two-step spinners. | -| **2025-01-XX** (approx.) | Initial release: SEC EDGAR 10-K download, Item 7 & 8 extraction, LLM analysis, Streamlit UI. S&P 500 sample list (company name and ticker). | +| **2025-02-13** | **Design Rationale & README:** New section "Design Rationale & Interview Notes" (undergrad automation mindset, 10y DCF rationale, Damodaran integration, consensus-panel rationale, commercialization). README Features and tab table updated to reflect 10Y 2-stage DCF, sector analysis, and Wall Street Assumptions panel. Changelog expanded with more detailed entries. | +| **2025-02-13** | **Institutional DCF & Wall Street panel:** (1) **10-year 2-stage DCF:** Stage 1 (Y1–5) at user FCF growth; Stage 2 (Y6–10) linear fade from that rate to terminal growth (avoids absurd valuations for high-growth stocks). TV at Year 10; all FCFs + TV discounted to PV. (2) **Wall Street Assumptions panel** (expander below sliders): **Left column** — Analyst consensus from yfinance: target mean price, recommendation, revenue growth est., earnings growth est. (N/A if missing). **Right column** — Damodaran macro baseline: sector WACC map (Software 8.5%, Retail 7.5%, Hardware 9.0%, Financials 8.0%, etc.), US ERP ~4.6%, 10Y risk-free ~4.2%, plus markdown link to his WACC data page for methodology. Company sector matched via `get_sector_industry` for Damodaran WACC. | +| **2025-02-13** | **Smart DCF defaults:** Slider defaults no longer hardcoded. **WACC:** CAPM approximation using `ticker.info.get('beta')` (default 1.0), Risk-free 4%, MRP 5%; default WACC = 4 + Beta×5, rounded to 1 decimal. **Terminal growth:** Fixed at 2.5% (Damodaran-style, long-term US GDP). **FCF growth:** From `revenueGrowth` or `earningsGrowth` (e.g. 0.15 → 15%); fallback 8%. Caption above sliders: "Slider defaults are auto-generated based on the company's Beta (CAPM) and revenue growth estimates." | +| **2025-02-13** | **Robust DCF data & comps:** (1) **Shares/Debt/Cash:** Multi-step fallback (fast_info → info → balance sheet) so S&P 500 names rarely need manual input. Shares: `fast_info.shares` → `sharesOutstanding` → `impliedSharesOutstanding`; display as "X.XXB Shares (real-time, auto-fetched)". Manual number_input only when all sources fail. (2) **Tab 3 redesign — Top-down sector analysis:** Manual ticker input removed. `SECTORS` dict (e.g. Semiconductors, Software & Cloud, Consumer Retail, Financials, Healthcare) with top 5 tickers each; st.selectbox to choose industry; comps table auto-loads with spinner. yfinance keys fixed to `forwardPE`, `enterpriseToEbitda`, `priceToBook`; missing shown as N/A. Conditional formatting: lowest P/E and EV/EBITDA green, highest red. **Generate Industry Outlook** button: Gemini prompt for macro analyst-style report (12–18 month trends, growth drivers, headwinds/regulatory risks); report rendered in Markdown below table. | +| **2025-02-13** | **Bulletproof DCF & Excel-style logic:** DCF no longer fails when yfinance misses data. Base FCF = OCF − CapEx; if Shares/Debt/Cash missing, st.number_input fallbacks. Three sliders (WACC, Terminal Growth, Projected FCF Growth) drive full DCF; intrinsic value vs current price (from yfinance) and Bull/Base/Bear table. Tab 1: Interest Coverage "nan%" fixed (N/A when Interest Expense 0 or missing). | +| **2025-02-12 15:30** | **Dynamic Sector-Specific Analysis:** DuPont table None/NaN → "N/A". Sector & industry badge (Tab 1). Sector-specific metrics: Tech (Rule of 40, FCF margin, R&D % revenue), Retail (inventory turnover, operating margin), Financials (ROE, ROA). Tab 2 caption for Financials (FCF/EBITDA less relevant). Gemini MD&A: sector/industry passed in; prompt asks for industry-specific Non-GAAP KPIs in a markdown table. | +| **2025-02-12 11:00** | **Data robustness & TTM fallback:** `_get_row_series` try/except; `_na(x)` for display. TTM fallback when annual financials/balance_sheet missing (quarterly sum / latest quarter). `get_sector_industry(ticker)` added. DuPont/Altman return empty dict on exception. | +| **2025-02-12 09:00** | **Hybrid architecture:** Item 7 only to Gemini; yfinance for numbers. HTML cleansing, find_toc.py. Prompt: strategy, risks, sentiment. | +| **2025-02-12 08:45** | Changelog and find_toc.py in Project Structure. | +| **2025-02-12** | **Remember API key & email:** Optional "Remember API key & email (save locally)" checkbox; values stored in `.app_prefs.json` (in .gitignore); prefill on load; uncheck removes file. | +| **2025-02-12** | **S&P 500 sample expander removed** from sidebar (user request). | +| **2025-02-01** (approx.) | Item 7 & 8 selective extraction; 429 retry 60s; Gemini, GOOGLE_API_KEY; CFA report, metrics table. | +| **2025-01-XX** (approx.) | Initial release: SEC EDGAR 10-K, Item 7 & 8, LLM analysis, Streamlit UI, S&P 500 sample list. | + +--- + +## Push to GitHub + +From the project folder, commit and push (run these in your own terminal so authentication works): + +```bash +cd "/Users/seonpil/Documents/FQDC Project" +git add README.md app.py +git status +git commit -m "README: Design Rationale, detailed changelog, institutional DCF notes" +git push origin main +``` + +If you use another branch or remote name, replace `main` or `origin` accordingly. If the repo is not yet initialised: `git init`, then `git remote add origin ` before pushing. --- diff --git a/app.py b/app.py index 026de72..047fa0f 100644 --- a/app.py +++ b/app.py @@ -6,6 +6,7 @@ All-in-One Financial Analysis Dashboard — Hybrid Architecture - Cost-effective: Gemini only for text; all numbers from yfinance. """ +import json import os import re import tempfile @@ -13,6 +14,29 @@ import time from pathlib import Path from typing import Optional +# Local prefs file for "Remember me" (API key & email). Path is in .gitignore. +_PREFS_PATH = Path(__file__).resolve().parent / ".app_prefs.json" + + +def _load_prefs() -> dict: + """Load saved API key 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 key and email to local file (only if user opted in).""" + try: + with open(_PREFS_PATH, "w", encoding="utf-8") as f: + json.dump({"google_api_key": (google_api_key or "").strip(), "sec_email": (sec_email or "").strip()}, f, indent=2) + except Exception: + pass + import streamlit as st import pandas as pd from bs4 import BeautifulSoup @@ -50,6 +74,15 @@ COMPANY_LIST = [ COMPANY_OPTIONS = [f"{t} - {n}" for n, t in COMPANY_LIST] COMPANY_TICKER_MAP = {t: n for n, t in COMPANY_LIST} +# 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 @@ -399,6 +432,32 @@ Use clear headings. Do not invent figures. Keep the response focused and under 9 return response.text.strip() +def get_industry_outlook(api_key: str, industry_name: str, tickers: list) -> str: + """Gemini: Wall Street macro analyst-style Industry Outlook for the selected sector (12–18 months).""" + model = get_gemini_model(api_key) + ticker_list_str = ", ".join(str(t).upper() for t in tickers if t) + user_prompt = f"""Act as an elite Wall Street macro analyst. Provide a concise **Industry Outlook** report for the **{industry_name}** sector, which includes leading companies like {ticker_list_str}. + +Focus on: +1. **Macro trends** affecting this industry over the next 12–18 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() + + # ---------- yfinance: raw statements & FCF = OCF - CapEx ---------- def _safe_float(x) -> Optional[float]: if x is None or (isinstance(x, float) and (x != x or pd.isna(x))): @@ -491,54 +550,131 @@ def get_5yr_financial_trend(ticker: str) -> pd.DataFrame: 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 = OCF - CapEx from cashflow; baseline = latest year. Debt, Cash, Shares from balance sheet/info.""" - if not yf: - return {} + """FCF = OCF - CapEx. Shares: fast_info.shares → info.sharesOutstanding → impliedSharesOutstanding → balance. Debt/Cash: fast_info → info → balance. Manual input only as last resort.""" + out = {"fcf": None, "total_debt": 0.0, "cash": 0.0, "shares": None} + if not yf or not ticker: + return out try: t = yf.Ticker(ticker.upper()) - info = t.info - cashflow = t.cashflow - balance = t.balance_sheet + info = t.info or {} + fast_info = getattr(t, "fast_info", None) + cashflow = getattr(t, "cashflow", None) if cashflow is None or cashflow.empty: - return {} - 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") - if ocf is None or len(ocf) == 0: - return {} - 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 latest_date in 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 - latest_fcf = (ocf_val - capx_val) if ocf_val is not None else None - 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: - total_debt = _safe_float(balance.loc["Total Debt"].iloc[0]) - if cash is None and "Cash And Cash Equivalents" in balance.index: - cash = _safe_float(balance.loc["Cash And Cash Equivalents"].iloc[0]) - 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, - } + 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 {} + 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.""" - if fcf <= 0 or wacc <= terminal_growth: + """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 = fcf + fcft = float(fcf) for t in range(1, years + 1): pv += fcft / ((1 + wacc) ** t) fcft *= (1 + fcf_growth) @@ -548,10 +684,150 @@ def dcf_intrinsic_value(fcf: float, wacc: float, terminal_growth: float, fcf_gro return pv +def dcf_10y_2stage(fcf: float, wacc: float, term_growth: float, fcf_growth: float) -> float: + """10-Year 2-Stage DCF. Stage 1 (Y1–5): FCF grows at fcf_growth. Stage 2 (Y6–10): 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 Y1–10) + 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 for each ticker. Returns styled 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 = [] @@ -561,17 +837,19 @@ def get_comps_data(tickers: tuple) -> pd.DataFrame: 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 + 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(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, + "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}) @@ -651,7 +929,11 @@ def get_dupont_altman_redflags_yoy(ticker: str) -> dict: cl = _v(current_liab, d) current_ratio = (ca / cl) if (ca and cl and cl != 0) else None int_exp = _v(interest, d) - interest_cov = (op_inc / int_exp) if (op_inc and int_exp and int_exp != 0) else None + 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, @@ -662,7 +944,7 @@ def get_dupont_altman_redflags_yoy(ticker: str) -> dict: "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": round(interest_cov, 2) if interest_cov is not None else None, + "Interest Coverage": interest_cov, }) dupont_df = pd.DataFrame(rows) yoy = [] @@ -672,12 +954,16 @@ def get_dupont_altman_redflags_yoy(ticker: str) -> dict: 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: + 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_bps = (cur - prev) * 100 # bps for % + if pd.isna(chg_bps) or chg_bps != chg_bps: + continue yoy.append({"Ratio": col, "Latest": cur, "Prior": prev, "YoY (bps)": round(chg_bps, 0), "Comment": f"{'Improved' if chg_bps > 0 else 'Declined'} by {abs(round(chg_bps))} bps 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) @@ -800,17 +1086,35 @@ st.caption("Hybrid: Gemini for qualitative (10-K MD&A & Risks); yfinance for qua 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=os.environ.get("GOOGLE_API_KEY", ""), + 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=os.environ.get("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("**Ticker / Company search**") search_term = st.text_input("Type ticker or company name", value="", key="ticker_search", placeholder="e.g. NVDA or NVIDIA") search_upper = (search_term or "").strip().upper() @@ -862,7 +1166,9 @@ with tab1: if red_flags: st.markdown("**Red flags**") for rf in red_flags: - st.warning(f"**{rf.get('flag', 'WARNING')}** — {rf.get('metric')}: {rf.get('value')} (threshold: {rf.get('threshold')}). {rf.get('comment', '')}") + 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('flag', 'WARNING')}** — {rf.get('metric')}: {val_str} (threshold: {rf.get('threshold')}). {rf.get('comment', '')}") elif dupont_df is not None and not dupont_df.empty: st.success("No red flags triggered (Current Ratio ≥ 1.0, Interest Coverage ≥ 1.5).") sector_metrics = get_sector_specific_metrics(ticker, sector) if ticker else {} @@ -971,96 +1277,165 @@ with tab2: 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: inputs & 3-scenario output") - 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.") + st.markdown("#### DCF valuation (Excel-style): inputs & 3-scenario output") + dcf_inputs = get_dcf_inputs(ticker) if ticker 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 = 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, years=5) - ev_bull = dcf_intrinsic_value(fcf, wacc, term_growth, bull_growth, years=5) - ev_bear = dcf_intrinsic_value(fcf, wacc, term_growth, bear_growth, years=5) - 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**") - c1, c2, c3 = st.columns(3) - c1.metric("Bull (+2% growth)", f"${price_bull:.2f}", f"+{(price_bull - price_base):.2f} vs Base") - c2.metric("Base", f"${price_base:.2f}", "—") - c3.metric("Bear (−2% growth)", f"${price_bear:.2f}", f"{(price_bear - price_base):.2f} vs Base") - 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.caption("FCF or Shares not available. FCF = OCF − CapEx from yfinance.") + 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(ticker) if ticker 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, Y1–5) %", -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(ticker) if ticker 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 ticker and yf: + try: + info = yf.Ticker(ticker.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: Industry Comps (conditional formatting) ----- +# ----- Tab 3: Top-Down Sector Analysis (Industry Comps + AI Outlook) ----- with tab3: - st.subheader("Industry Analysis & Comps") - st.markdown("Enter **comma-separated competitor tickers**. Multiples from **yfinance**. Green = below peer average (undervalued), Red = above (overvalued).") - 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: + 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.") - else: - 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") - avg = s.mean() - if pd.isna(avg): - continue - def color_fn(v, avg_val=avg): - if pd.isna(v): - return "" - try: - x = float(v) - except (TypeError, ValueError): - return "" - if x < avg_val: - return "background-color: rgba(0, 200, 83, 0.3); color: #0d5c2e" - if x > avg_val: - return "background-color: rgba(255, 82, 82, 0.3); color: #b71c1c" + 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 "" - styled = styled.map(lambda v: color_fn(v), subset=[col]) - st.dataframe(styled, use_container_width=True, hide_index=True) - except Exception: - st.dataframe(df_comps, use_container_width=True, hide_index=True) + 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") -st.divider() -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)