"""Valuation panel — key ratios, models, comparable companies, analyst targets, earnings history.""" import json import pandas as pd import plotly.graph_objects as go import streamlit as st import streamlit.components.v1 as components from services.data_service import ( get_company_info, get_latest_price, get_shares_outstanding, get_market_cap_computed, get_free_cash_flow_series, get_free_cash_flow_ttm, get_revenue_ttm, get_balance_sheet_bridge_items, get_analyst_price_targets, get_recommendations_summary, get_earnings_history, get_next_earnings_date, ) from services.fmp_service import ( get_key_ratios, get_peers, get_ratios_for_tickers, get_historical_ratios, get_historical_key_metrics, get_analyst_estimates, ) from services.valuation_service import ( run_dcf, run_ev_ebitda, run_ev_revenue, run_price_to_book, compute_historical_growth_rate, compute_raw_historical_growth_rate, ) from utils.formatters import fmt_ratio, fmt_pct, fmt_large, fmt_currency FINANCIAL_SECTORS = {"Financial Services"} FINANCIAL_INDUSTRY_KEYWORDS = ( "bank", "insurance", "asset management", "capital markets", "financial data", "credit services", "mortgage", "reit", ) INDUSTRY_PEER_MAP = { "consumer electronics": ["AAPL", "SONY", "DELL", "HPQ", "LOGI"], "software - infrastructure": ["MSFT", "ORCL", "CRM", "NOW", "SNOW"], "semiconductors": ["NVDA", "AMD", "AVGO", "QCOM", "INTC"], "internet content & information": ["GOOGL", "META", "PINS", "SNAP", "RDDT"], "banks - diversified": ["JPM", "BAC", "WFC", "C", "GS"], "credit services": ["V", "MA", "AXP", "DFS", "COF"], "insurance - diversified": ["BRK-B", "AIG", "ALL", "TRV", "CB"], "reit - industrial": ["PLD", "PSA", "EXR", "COLD", "REXR"], } SECTOR_PEER_MAP = { "Technology": ["AAPL", "MSFT", "NVDA", "ORCL", "ADBE"], "Communication Services": ["GOOGL", "META", "NFLX", "TMUS", "DIS"], "Consumer Cyclical": ["AMZN", "TSLA", "HD", "MCD", "NKE"], "Consumer Defensive": ["WMT", "COST", "PG", "KO", "PEP"], "Financial Services": ["JPM", "BAC", "WFC", "GS", "MS"], "Healthcare": ["LLY", "UNH", "JNJ", "MRK", "PFE"], "Industrials": ["GE", "CAT", "RTX", "UPS", "UNP"], "Energy": ["XOM", "CVX", "COP", "SLB", "EOG"], "Utilities": ["NEE", "DUK", "SO", "AEP", "XEL"], "Real Estate": ["PLD", "AMT", "EQIX", "O", "SPG"], } def _is_financial_company(info: dict) -> bool: sector = str(info.get("sector") or "").strip() industry = str(info.get("industry") or "").strip().lower() if sector in FINANCIAL_SECTORS: return True return any(keyword in industry for keyword in FINANCIAL_INDUSTRY_KEYWORDS) def _suggest_peer_tickers(ticker: str, info: dict) -> list[str]: industry = str(info.get("industry") or "").strip().lower() sector = str(info.get("sector") or "").strip() candidates = [] if industry in INDUSTRY_PEER_MAP: candidates.extend(INDUSTRY_PEER_MAP[industry]) if not candidates and sector in SECTOR_PEER_MAP: candidates.extend(SECTOR_PEER_MAP[sector]) candidates = [c.upper() for c in candidates if c.upper() != ticker.upper()] seen = set() deduped = [] for c in candidates: if c not in seen: deduped.append(c) seen.add(c) return deduped[:8] def _coerce_float(value) -> float | None: try: out = float(value) except (TypeError, ValueError): return None return None if pd.isna(out) else out def _escape_markdown_currency(value: str) -> str: return value.replace("$", r"\$") def render_valuation(ticker: str): tabs = st.tabs([ "Key Ratios", "Historical Ratios", "Models", "Comps", "Forward Estimates", "Analyst Targets", "Earnings History", ]) tab_ratios, tab_hist, tab_models, tab_comps, tab_fwd, tab_analyst, tab_earnings = tabs with tab_ratios: _render_ratios(ticker) with tab_hist: try: _render_historical_ratios(ticker) except Exception as e: st.error(f"Historical ratios unavailable: {e}") with tab_models: _render_models(ticker) with tab_comps: _render_comps(ticker) with tab_fwd: try: _render_forward_estimates(ticker) except Exception as e: st.error(f"Forward estimates unavailable: {e}") with tab_analyst: try: _render_analyst_targets(ticker) except Exception as e: st.error(f"Analyst targets unavailable: {e}") with tab_earnings: try: _render_earnings_history(ticker) except Exception as e: st.error(f"Earnings history unavailable: {e}") # ── Key Ratios ─────────────────────────────────────────────────────────────── def _render_ratios(ticker: str): ratios = get_key_ratios(ticker) info = get_company_info(ticker) if not ratios and not info: st.info("Ratio data unavailable.") return def _normalized_label(label: str) -> str: return " ".join(str(label).replace("/", " ").replace("-", " ").split()).strip().lower() def _display_value(key: str, fmt=fmt_ratio): val = ratios.get(key) if ratios else None return fmt(val) if val is not None else "—" def _company_context() -> dict: return info or {} def _display_reasoned_metric(key: str, fmt=fmt_ratio) -> str: val = ratios.get(key) if ratios else None if val is not None: return fmt(val) ctx = _company_context() if key == "peRatioTTM": trailing_pe = ctx.get("trailingPE") if trailing_pe is not None: return fmt_ratio(trailing_pe) if ratios and ratios.get("netProfitMarginTTM") is not None and ratios.get("netProfitMarginTTM") < 0: return "N/M (neg. TTM earnings)" trailing_eps = ctx.get("trailingEps") if trailing_eps is not None: try: if float(trailing_eps) <= 0: return "N/M (neg. TTM earnings)" except (TypeError, ValueError): pass return "—" if key == "priceToBookRatioTTM": book_value = ctx.get("bookValue") if book_value is not None: try: if float(book_value) <= 0: return "N/M (neg. equity)" except (TypeError, ValueError): pass return "—" if key == "enterpriseValueMultipleTTM": ebitda = ratios.get("ebitdaTTM") if ratios else None if ebitda is not None: try: if float(ebitda) <= 0: return "N/M (neg. EBITDA)" except (TypeError, ValueError): pass return "—" if key == "dividendPayoutRatioTTM": payout_ratio = ctx.get("payoutRatio") if payout_ratio is not None: try: if float(payout_ratio) <= 0: return "—" except (TypeError, ValueError): pass if ratios and ratios.get("netProfitMarginTTM") is not None and ratios.get("netProfitMarginTTM") < 0: return "N/M (neg. earnings)" return "—" if key == "returnOnEquityTTM": book_value = ctx.get("bookValue") if book_value is not None: try: if float(book_value) <= 0: return "N/M (neg. equity)" except (TypeError, ValueError): pass return "—" if key == "debtToEquityRatioTTM": book_value = ctx.get("bookValue") if book_value is not None: try: if float(book_value) <= 0: return "N/M (neg. equity)" except (TypeError, ValueError): pass return "—" if key == "interestCoverageRatioTTM": operating_margins = ctx.get("operatingMargins") if operating_margins is not None: try: if float(operating_margins) <= 0: return "N/M (neg. EBIT)" except (TypeError, ValueError): pass return "—" return "—" def _dedupe_metrics(metrics: list[tuple[str, str]]) -> list[tuple[str, str]]: deduped: list[tuple[str, str]] = [] seen_labels: set[str] = set() for label, val in metrics: norm = _normalized_label(label) if norm in seen_labels: continue seen_labels.add(norm) deduped.append((label, val)) return deduped rows = [ ("Valuation", _dedupe_metrics([ ("P/E (TTM)", _display_reasoned_metric("peRatioTTM")), ("Forward P/E", _display_value("forwardPE")), ("P/S (TTM)", _display_value("priceToSalesRatioTTM")), ("P/B", _display_reasoned_metric("priceToBookRatioTTM")), ("EV/EBITDA", _display_reasoned_metric("enterpriseValueMultipleTTM")), ("EV/Revenue", _display_value("evToSalesTTM")), ])), ("Profitability", _dedupe_metrics([ ("Gross Margin", _display_value("grossProfitMarginTTM", fmt=fmt_pct)), ("Operating Margin", _display_value("operatingProfitMarginTTM", fmt=fmt_pct)), ("Net Margin", _display_value("netProfitMarginTTM", fmt=fmt_pct)), ("ROE", _display_reasoned_metric("returnOnEquityTTM", fmt=fmt_pct)), ("ROA", _display_value("returnOnAssetsTTM", fmt=fmt_pct)), ("ROIC", _display_value("returnOnInvestedCapitalTTM", fmt=fmt_pct)), ])), ("Leverage & Liquidity", _dedupe_metrics([ ("Debt/Equity", _display_reasoned_metric("debtToEquityRatioTTM")), ("Current Ratio", _display_value("currentRatioTTM")), ("Quick Ratio", _display_value("quickRatioTTM")), ("Interest Coverage", _display_reasoned_metric("interestCoverageRatioTTM")), ("Dividend Yield", _display_value("dividendYieldTTM", fmt=fmt_pct)), ("Payout Ratio", _display_reasoned_metric("dividendPayoutRatioTTM", fmt=fmt_pct)), ])), ] for section_name, metrics in rows: st.markdown(f"**{section_name}**") cols = st.columns(6) for col, (label, val) in zip(cols, metrics): col.metric(label, val) st.write("") # ── Models ─────────────────────────────────────────────────────────────────── def _net_debt_label(value: float) -> str: return "Net Cash" if value < 0 else "Net Debt" def _build_model_context(ticker: str) -> dict: info = get_company_info(ticker) ratios_data = get_key_ratios(ticker) shares = get_shares_outstanding(ticker) current_price = get_latest_price(ticker) market_cap = get_market_cap_computed(ticker) bridge_items = get_balance_sheet_bridge_items(ticker) total_debt = float(bridge_items["total_debt"]) cash_and_equivalents = float(bridge_items["cash_and_equivalents"]) preferred_equity = float(bridge_items["preferred_equity"]) minority_interest = float(bridge_items["minority_interest"]) fcf_series_raw = get_free_cash_flow_series(ticker) if fcf_series_raw is None or fcf_series_raw.empty: fcf_series = pd.Series(dtype=float) else: try: fcf_series = fcf_series_raw.sort_index().dropna().astype(float) except Exception: fcf_series = pd.Series(dtype=float) base_fcf = _coerce_float(get_free_cash_flow_ttm(ticker)) hist_growth = compute_historical_growth_rate(fcf_series) if len(fcf_series) >= 2 else None hist_growth_raw = compute_raw_historical_growth_rate(fcf_series) if len(fcf_series) >= 2 else None ebitda = _coerce_float(ratios_data.get("ebitdaTTM")) revenue_ttm = _coerce_float(get_revenue_ttm(ticker)) if revenue_ttm is None or revenue_ttm <= 0: revenue_ttm = _coerce_float(info.get("totalRevenue")) if revenue_ttm is None or revenue_ttm <= 0: ps_ratio = _coerce_float(ratios_data.get("priceToSalesRatioTTM")) if market_cap and market_cap > 0 and ps_ratio and ps_ratio > 0: revenue_ttm = float(market_cap) / float(ps_ratio) book_value_per_share = _coerce_float(info.get("bookValue")) is_financial = _is_financial_company(info) dcf_reason = None if is_financial: dcf_reason = "Not suitable for financial companies." elif not shares or shares <= 0: dcf_reason = "Shares outstanding unavailable." elif fcf_series.empty: dcf_reason = "Free cash flow history unavailable." elif len(fcf_series) < 2: dcf_reason = "Need at least two FCF periods." elif base_fcf is None or base_fcf <= 0: dcf_reason = "Base free cash flow is zero or negative." ev_reason = None if not shares or shares <= 0: ev_reason = "Shares outstanding unavailable." elif ebitda is None: ev_reason = "EBITDA unavailable." elif ebitda <= 0: ev_reason = "EBITDA is zero or negative." ev_revenue_reason = None if is_financial: ev_revenue_reason = "Not preferred for financial companies." elif not shares or shares <= 0: ev_revenue_reason = "Shares outstanding unavailable." elif revenue_ttm is None: ev_revenue_reason = "Revenue unavailable." elif revenue_ttm <= 0: ev_revenue_reason = "Revenue is zero or negative." pb_reason = None if book_value_per_share is None: pb_reason = "Book value per share unavailable." elif book_value_per_share <= 0: pb_reason = "Book value per share is zero or negative." dcf_available = dcf_reason is None ev_available = ev_reason is None ev_revenue_available = ev_revenue_reason is None pb_available = pb_reason is None ev_value = None ev_ebitda_current = None ev_revenue_current = None other_claims = preferred_equity + minority_interest if market_cap and market_cap > 0 and ebitda and ebitda > 0: ev_value = float(market_cap) + total_debt - cash_and_equivalents + other_claims if ev_value > 0: ev_ebitda_current = ev_value / ebitda elif market_cap and market_cap > 0: ev_value = float(market_cap) + total_debt - cash_and_equivalents + other_claims if ev_value and ev_value > 0 and revenue_ttm and revenue_ttm > 0: ev_revenue_current = ev_value / revenue_ttm pb_current = None if current_price and current_price > 0 and book_value_per_share and book_value_per_share > 0: pb_current = current_price / book_value_per_share if is_financial and pb_available: summary = "P/B is the primary method here because this looks like a financial company." elif dcf_available: summary = "DCF is the primary method because the business has usable free cash flow history and positive base FCF." elif ev_available: summary = "EV/EBITDA is the best fit because EBITDA is positive while DCF is not suitable." elif ev_revenue_available: summary = "EV/Revenue is the best fit because the company has revenue but cash-flow-based models are not suitable." elif pb_available: summary = "P/B is the fallback because book value is positive while cash-flow-based models are not suitable." else: summary = "No valuation model is currently robust enough to show. Use ratios, comps, earnings history, and analyst targets instead." return { "ticker": ticker.upper(), "info": info, "ratios_data": ratios_data, "shares": shares, "current_price": current_price, "market_cap": market_cap, "bridge_items": bridge_items, "total_debt": total_debt, "cash_and_equivalents": cash_and_equivalents, "preferred_equity": preferred_equity, "minority_interest": minority_interest, "fcf_series": fcf_series, "base_fcf": base_fcf, "hist_growth": hist_growth, "hist_growth_raw": hist_growth_raw, "ebitda": ebitda, "revenue_ttm": revenue_ttm, "book_value_per_share": book_value_per_share, "is_financial": is_financial, "dcf_available": dcf_available, "dcf_reason": dcf_reason or "Usable free cash flow history and positive base FCF.", "ev_available": ev_available, "ev_reason": ev_reason or "Positive EBITDA and shares outstanding are available.", "ev_revenue_available": ev_revenue_available, "ev_revenue_reason": ev_revenue_reason or "Positive revenue and shares outstanding are available.", "pb_available": pb_available, "pb_reason": pb_reason or "Positive book value per share is available.", "ev_ebitda_current": ev_ebitda_current, "ev_revenue_current": ev_revenue_current, "pb_current": pb_current, "summary": summary, } def _render_model_availability(ctx: dict): st.markdown("**Applicable models**") cols = st.columns(4) cards = [ ("DCF", ctx["dcf_available"], ctx["dcf_reason"]), ("EV/EBITDA", ctx["ev_available"], ctx["ev_reason"]), ("EV/Revenue", ctx["ev_revenue_available"], ctx["ev_revenue_reason"]), ("P/B", ctx["pb_available"], ctx["pb_reason"]), ] for col, (label, available, reason) in zip(cols, cards): col.markdown(f"**{label}**") col.caption("Available" if available else "Not suitable") col.write(reason) _DCF_CANVAS_CSS = """ *,*::before,*::after{box-sizing:border-box} :root{ --ink-0:#0B0E13;--ink-1:#11151C;--ink-2:#181D26;--ink-3:#222934; --line-1:#232934;--line-2:#2E3645; --fg-1:#F2ECDC;--fg-2:#C7C0AE;--fg-3:#8E8676;--fg-4:#5E5849; --brass:#C2AA7A;--brass-bright:#DCC79E;--brass-deep:#8F7A50; --oxford:#1F3B5E;--oxford-light:#243E5A; --positive:#4F8C5E;--positive-bg:#15241A; --negative:#B5494B;--negative-bg:#2A1517; --font-display:'EB Garamond',Georgia,serif; --font-sans:'IBM Plex Sans',system-ui,sans-serif; --font-mono:'IBM Plex Mono',monospace; } body{margin:0;padding:0;background:transparent;font-family:var(--font-sans);color:var(--fg-2);-webkit-font-smoothing:antialiased} .num{font-family:var(--font-mono);font-variant-numeric:tabular-nums} .va-canvas{display:flex;flex-direction:column;gap:24px;padding-bottom:32px} /* Verdict */ .va-verdict{background:var(--ink-1);border:1px solid var(--line-1);border-radius:6px;position:relative;overflow:hidden;box-shadow:0 8px 24px -8px rgba(0,0,0,.5)} .va-verdict .top{display:grid;grid-template-columns:1fr auto 1fr;gap:48px;align-items:center;padding:32px 48px;position:relative;z-index:1} .va-verdict .col{display:flex;flex-direction:column;gap:6px} .va-verdict .lbl{font-family:var(--font-sans);font-size:12px;text-transform:uppercase;letter-spacing:.18em;color:var(--fg-3)} .va-verdict .big{font-family:var(--font-mono);font-variant-numeric:tabular-nums;font-size:56px;font-weight:500;color:var(--fg-1);line-height:.95;letter-spacing:-.02em} .va-verdict .big.market{color:var(--fg-2)} .va-verdict .sub{font-family:var(--font-sans);font-size:13px;color:var(--fg-3)} .va-verdict .arrow{font-family:var(--font-display);font-size:32px;color:var(--fg-4);font-style:italic;font-weight:400;text-align:center} .va-verdict .pill{display:inline-flex;align-items:center;gap:6px;font-family:var(--font-mono);font-size:13px;padding:4px 10px;border-radius:2px;align-self:flex-start;margin-top:4px} .va-verdict .pill.neg{color:var(--negative);background:var(--negative-bg);border:1px solid rgba(181,73,75,.35)} .va-verdict .pill.pos{color:var(--positive);background:var(--positive-bg);border:1px solid rgba(79,140,94,.35)} .va-verdict .band{display:flex;align-items:baseline;justify-content:space-between;border-top:1px solid var(--line-1);padding:12px 48px;font-family:var(--font-sans);font-size:13px;color:var(--fg-2);position:relative;z-index:1;background:var(--ink-1)} .va-verdict .band .reading{font-family:var(--font-display);font-style:italic;font-size:20px;color:var(--fg-1)} .va-verdict .band .mono{font-family:var(--font-mono);font-variant-numeric:tabular-nums;color:var(--fg-1)} /* Projection */ .va-projection{background:var(--ink-1);border:1px solid var(--line-1);border-radius:6px;overflow:hidden} .va-projection .head{padding:16px 24px;border-bottom:1px solid var(--line-1);display:flex;justify-content:space-between;align-items:baseline} .va-projection .head h3{font-family:var(--font-display);font-size:20px;font-weight:500;color:var(--fg-1);margin:0} .va-projection .head .units{font-family:var(--font-mono);font-size:12px;color:var(--fg-3)} .va-cf-table{width:100%;border-collapse:collapse;border-top:1px solid var(--line-1)} .va-cf-table th,.va-cf-table td{padding:8px 14px;text-align:right;font-family:var(--font-mono);font-variant-numeric:tabular-nums;font-size:12px;border-bottom:1px solid var(--line-1)} .va-cf-table th{font-family:var(--font-sans);text-transform:uppercase;font-size:11px;letter-spacing:.08em;color:var(--fg-3);font-weight:600;background:var(--ink-2)} .va-cf-table th:first-child,.va-cf-table td:first-child{text-align:left;color:var(--fg-2);font-size:12px} .va-cf-table td.brass{color:var(--brass-bright)} .va-cf-table tr:last-child td{border-bottom:none} .va-cf-table tr.total td{border-top:1px solid var(--line-2);font-weight:600;color:var(--fg-1);background:var(--ink-2)} /* Bridge */ .va-bridge{background:var(--ink-1);border:1px solid var(--line-1);border-radius:6px;padding:24px;display:flex;flex-direction:column;gap:16px} .va-bridge .bhead{display:flex;justify-content:space-between;align-items:baseline} .va-bridge .bhead h3{font-family:var(--font-display);font-size:20px;font-weight:500;color:var(--fg-1);margin:0} .va-bridge .bhead .bdate{font-family:var(--font-mono);font-size:12px;color:var(--fg-3)} .va-bridge .flow{display:grid;grid-template-columns:1fr auto 1fr auto 1fr auto 1fr;align-items:stretch;gap:12px} .va-bridge .node{display:flex;flex-direction:column;gap:4px;padding:12px 16px;background:var(--ink-2);border:1px solid var(--line-2);border-radius:4px;min-height:80px;justify-content:center} .va-bridge .node.start{border-color:var(--oxford);background:rgba(74,120,181,.06)} .va-bridge .node.result{border-color:rgba(194,170,122,.4);background:rgba(194,170,122,.06)} .va-bridge .node .lbl{font-family:var(--font-sans);font-size:11px;text-transform:uppercase;letter-spacing:.18em;color:var(--fg-3)} .va-bridge .node .v{font-family:var(--font-mono);font-variant-numeric:tabular-nums;font-size:20px;color:var(--fg-1)} .va-bridge .node.result .v{color:var(--brass-bright)} .va-bridge .op{display:flex;flex-direction:column;align-items:center;justify-content:center;font-family:var(--font-mono);font-size:16px;color:var(--fg-3);min-width:20px} .va-bridge .op .sub{font-family:var(--font-sans);font-size:10px;color:var(--fg-4);text-transform:uppercase;letter-spacing:.18em;margin-top:6px} .va-bridge .bfoot{font-family:var(--font-sans);font-size:12px;color:var(--fg-3);display:flex;gap:12px;flex-wrap:wrap} /* Recon */ .va-recon{display:grid;grid-template-columns:1.4fr 1fr 1fr 1fr;background:var(--ink-1);border:1px solid var(--line-1);border-radius:6px;overflow:hidden} .va-recon .cell{padding:16px 24px;border-right:1px solid var(--line-1);display:flex;flex-direction:column;gap:4px} .va-recon .cell:last-child{border-right:none} .va-recon .cell .lbl{font-family:var(--font-sans);font-size:11px;text-transform:uppercase;letter-spacing:.18em;color:var(--fg-3);font-weight:600} .va-recon .cell .v{font-family:var(--font-mono);font-variant-numeric:tabular-nums;font-size:28px;color:var(--fg-1);font-weight:500;line-height:1} .va-recon .cell .sub{font-family:var(--font-mono);font-size:11px;color:var(--fg-3)} .va-recon .cell.intrinsic .v{color:var(--brass-bright)} /* Cross-check */ .va-cx{background:var(--ink-1);border:1px solid var(--line-1);border-radius:6px;overflow:hidden} .va-cx-head{padding:16px 24px;border-bottom:1px solid var(--line-1);display:flex;justify-content:space-between;align-items:baseline} .va-cx-head h3{font-family:var(--font-display);font-size:20px;font-weight:500;color:var(--fg-1);margin:0} .va-cx-head .hint{font-family:var(--font-mono);font-size:12px;color:var(--fg-3)} .va-cx-grid{display:grid;grid-template-columns:1.2fr 1fr 1fr 1fr} .va-cx-cell{padding:16px 24px;border-right:1px solid var(--line-1);display:flex;flex-direction:column;gap:6px} .va-cx-cell:last-child{border-right:none} .va-cx-cell .lbl{font-family:var(--font-sans);font-size:11px;text-transform:uppercase;letter-spacing:.18em;color:var(--fg-3);font-weight:600} .va-cx-cell .v{font-family:var(--font-mono);font-variant-numeric:tabular-nums;font-size:26px;color:var(--fg-1);font-weight:500;line-height:1} .va-cx-cell.dcf{background:rgba(194,170,122,.05)} .va-cx-cell.dcf .v{color:var(--brass-bright)} .va-cx-cell.dcf .lbl{color:var(--brass)} .va-cx-cell .delta{font-family:var(--font-mono);font-variant-numeric:tabular-nums;font-size:12px} .va-cx-cell .delta.neg{color:var(--negative)} .va-cx-cell .delta.pos{color:var(--positive)} .va-cx-cell .delta.na{color:var(--fg-4)} .va-cx-cell .meta{font-family:var(--font-sans);font-size:11px;color:var(--fg-3);border-top:1px solid var(--line-1);padding-top:6px;margin-top:auto;line-height:1.4} /* Footer */ .va-foot{font-family:var(--font-sans);font-size:12px;color:var(--fg-3);line-height:1.6;padding:12px 20px;border:1px solid var(--line-1);border-radius:4px;background:var(--ink-1);display:flex;justify-content:space-between;align-items:center;gap:24px} .va-foot a{color:var(--brass-bright);text-decoration:none;white-space:nowrap;flex-shrink:0} .va-foot a:hover{color:var(--brass)} """ _DCF_RAIL_CSS = """""" def _fmt_b(v_dollars: float) -> str: b = v_dollars / 1e9 if abs(b) >= 1000: return f"${b / 1000:.2f}T" return f"${b:.2f}B" def _build_dcf_canvas_html( ctx: dict, result: dict, wacc_pct: float, tg_pct: float, yrs: int, g_pct: float, ev_ebitda_price: float | None, ev_rev_price: float | None, pb_price: float | None, ) -> str: iv = result["intrinsic_value_per_share"] market = float(ctx["current_price"] or 0) has_market = market > 0 upside_pct = (iv - market) / market * 100 if has_market else 0.0 is_pos = upside_pct >= 0 gap = iv - market # Bridge ev_b = _fmt_b(result["enterprise_value"]) net_debt_b = _fmt_b(abs(result["net_debt"])) other_claims_b = _fmt_b(ctx["preferred_equity"] + ctx["minority_interest"]) equity_b = _fmt_b(result["equity_value"]) total_debt_b = _fmt_b(ctx["total_debt"]) cash_b = _fmt_b(ctx["cash_and_equivalents"]) other_b_val = ctx["preferred_equity"] + ctx["minority_interest"] shares_b = ctx["shares"] / 1e9 source_date = ctx["bridge_items"].get("source_date", "") # Forecast sequences (capped at yrs) discounted = result["discounted_fcfs"][:yrs] projected = result["projected_fcfs"][:yrs] tv_pv = result["terminal_value_pv"] terminal_fcf = projected[-1] * (1 + tg_pct / 100) if projected else 0.0 disc_factors = [1.0 / (1 + wacc_pct / 100) ** (i + 1) for i in range(len(discounted))] disc_tv_factor = 1.0 / (1 + wacc_pct / 100) ** yrs # Plotly chart data bar_x = [f"Year {i + 1}" for i in range(len(discounted))] + ["Terminal"] bar_y = [v / 1e9 for v in discounted] + [tv_pv / 1e9] bar_colors = ["#243E5A"] * len(discounted) + ["#C2AA7A"] bar_line_colors = ["#1F3B5E"] * len(discounted) + ["#DCC79E"] bar_text = [_fmt_b(v) for v in discounted] + [_fmt_b(tv_pv)] plotly_data = json.dumps([{ "type": "bar", "x": bar_x, "y": bar_y, "marker": {"color": bar_colors, "line": {"color": bar_line_colors, "width": 1}}, "text": bar_text, "textposition": "outside", "textfont": {"family": "IBM Plex Mono", "size": 10, "color": "#C7C0AE"}, "hovertemplate": "%{x}: %{text}", "cliponaxis": False, }]) plotly_layout = json.dumps({ "paper_bgcolor": "#11151C", "plot_bgcolor": "#11151C", "margin": {"l": 48, "r": 8, "t": 28, "b": 36}, "xaxis": { "gridcolor": "rgba(0,0,0,0)", "linecolor": "#232934", "tickfont": {"family": "IBM Plex Sans", "size": 11, "color": "#8E8676"}, "fixedrange": True, }, "yaxis": { "gridcolor": "#232934", "linecolor": "rgba(0,0,0,0)", "tickfont": {"family": "IBM Plex Mono", "size": 10, "color": "#8E8676"}, "tickprefix": "$", "ticksuffix": "B", "fixedrange": True, "zeroline": False, }, "bargap": 0.35, "showlegend": False, "uniformtext": {"mode": "hide", "minsize": 8}, }) # Verdict verdict_gradient = ( "linear-gradient(110deg,transparent 35%,rgba(79,140,94,.07) 100%)" if is_pos else "linear-gradient(110deg,transparent 35%,rgba(181,73,75,.07) 100%)" ) pill_cls = "pos" if is_pos else "neg" pill_arrow = "▲" if is_pos else "▼" pill_sign = "+" if is_pos else "−" pill_text = f"{pill_arrow} {pill_sign}{abs(upside_pct):.1f}% {'upside' if is_pos else 'downside'}" reading = "Constructive" if is_pos else "Cautious" gap_dir = "above" if gap >= 0 else "below" iv_str = f"${iv:,.2f}" market_str = f"${market:,.2f}" if has_market else "—" gap_str = f"${abs(gap):,.2f}" # Cash-flow table n = len(discounted) hdr_cells = "".join(f"Yr {i + 1}" for i in range(n)) + "Terminal" fcf_cells = "".join(f"{_fmt_b(v)}" for v in projected) fcf_cells += f'{_fmt_b(terminal_fcf)}' df_cells = "".join(f"{disc_factors[i]:.3f}" for i in range(n)) df_cells += f"{disc_tv_factor:.3f}" pv_cells = "".join(f"{_fmt_b(v)}" for v in discounted) pv_cells += f'{_fmt_b(tv_pv)}' # Cross-check cells def cx_cell(cls, lbl, val_str, delta_pct, meta): if delta_pct is not None and has_market: dcls = "pos" if delta_pct >= 0 else "neg" dsign = "+" if delta_pct >= 0 else "" dhtml = f'{dsign}{delta_pct:.1f}% vs market' else: dhtml = '' return ( f'
' f'{lbl}' f'{val_str}' f"{dhtml}" f'{meta}' f"
" ) dcf_delta = upside_pct if has_market else None cx_dcf = cx_cell( "va-cx-cell dcf", "DCF · THIS MODEL", iv_str, dcf_delta, f"Firm-value DCF · {yrs}-yr explicit · WACC {wacc_pct:.1f}%", ) def _cx_multiple_cell(label, implied, market_multiple, mult_label): if implied is not None and has_market: delta = (implied - market) / market * 100 val = f"${implied:,.2f}" meta = f"Market multiple {market_multiple:.1f}× · {mult_label}" if market_multiple else mult_label else: delta = None val = "—" meta = "Unavailable for this company" return cx_cell("va-cx-cell", label, val, delta, meta) cx_ev = _cx_multiple_cell( "EV / EBITDA", ev_ebitda_price, ctx.get("ev_ebitda_current") or 0, "based on current market multiple", ) cx_rev = _cx_multiple_cell( "EV / REVENUE", ev_rev_price, ctx.get("ev_revenue_current") or 0, "based on current market multiple", ) cx_pb = _cx_multiple_cell( "P / BOOK", pb_price, ctx.get("pb_current") or 0, "based on current market multiple", ) # Recon gap cell color gap_color = "var(--positive)" if gap >= 0 else "var(--negative)" gap_sign = "+" if gap >= 0 else "" gap_display = f"{gap_sign}${gap:,.2f}" if has_market else "—" gap_pct_str = f"{upside_pct:.1f}% vs market" if has_market else "—" html = f"""
DCF Intrinsic Value {iv_str} per share · firm value method · {yrs}-yr horizon
vs
Market Price {market_str} {pill_text}
Reading · DCF implies {gap_str} {gap_dir} the current market. {reading}

Enterprise value build — present value of FCFs + terminal

USD · billions · discounted at WACC {wacc_pct:.1f}%
{hdr_cells}{fcf_cells}{df_cells}{pv_cells}
Forecast FCF
Discount factor
Present value

From enterprise to equity

Balance-sheet bridge{(' · ' + source_date) if source_date else ''}
Enterprise value{ev_b}
Net debt
Net debt{net_debt_b}
Other claims
Other claims{other_claims_b}
=
Equity value{equity_b}
Total debt {total_debt_b} · Cash & equiv. {cash_b} · Preferred + minority {_fmt_b(other_b_val)}
Intrinsic · Per Share {iv_str} Equity value ÷ shares
Market · Last {market_str}  
Gap {gap_display} {gap_pct_str}
Shares Outstanding {shares_b:.2f} B diluted

Cross-check against the multiples

Same business, different lenses · implied per-share
{cx_dcf} {cx_ev} {cx_rev} {cx_pb}
Firm-value DCF · enterprise value bridged to equity using debt & cash from the most recent balance sheet. Negative-FCF years are excluded from the base; terminal value uses Gordon Growth Model. Methodology & sources ↗
""" return html def _render_dcf_model(ctx: dict): hist_growth_raw = ctx["hist_growth_raw"] hist_growth_raw_pct = hist_growth_raw * 100 if hist_growth_raw is not None else -5.0 slider_default = float(max(-15.0, min(20.0, hist_growth_raw_pct))) st.markdown(_DCF_RAIL_CSS, unsafe_allow_html=True) rail_col, canvas_col = st.columns([1, 3], gap="medium") with rail_col: st.markdown( 'Assumptions' '
3-stage DCF
' '
Firm-value DCF — projects free cash flow, discounts to today, bridges to equity per share.
', unsafe_allow_html=True, ) wacc_pct = st.slider( "WACC (%)", min_value=4.0, max_value=15.0, value=10.0, step=0.25, key=f"dcf_wacc_{ctx['ticker']}", ) tg_pct = st.slider( "Terminal growth (%)", min_value=0.0, max_value=5.0, value=2.5, step=0.1, key=f"dcf_tg_{ctx['ticker']}", ) yrs = st.slider( "Forecast horizon (yr)", min_value=3, max_value=10, value=5, step=1, key=f"dcf_yrs_{ctx['ticker']}", ) g_pct = st.slider( "FCF growth (%)", min_value=-15.0, max_value=20.0, value=round(slider_default, 1), step=0.1, key=f"dcf_g_{ctx['ticker']}", ) st.markdown('
', unsafe_allow_html=True) net_debt_raw = ctx["total_debt"] - ctx["cash_and_equivalents"] st.markdown( '
From the filings
' f'
Base FCF (TTM){_fmt_b(ctx["base_fcf"])}
' f'
FCF · 5-yr median{hist_growth_raw_pct:+.1f}%
' f'
Net debt{_fmt_b(net_debt_raw)}
' f'
Shares outstanding{ctx["shares"] / 1e9:.2f} B
', unsafe_allow_html=True, ) st.markdown('
', unsafe_allow_html=True) btn_reset, btn_save, btn_recompute = st.columns(3) with btn_reset: if st.button("Reset", key=f"dcf_reset_{ctx['ticker']}", use_container_width=True): st.session_state[f"dcf_wacc_{ctx['ticker']}"] = 10.0 st.session_state[f"dcf_tg_{ctx['ticker']}"] = 2.5 st.session_state[f"dcf_yrs_{ctx['ticker']}"] = 5 st.session_state[f"dcf_g_{ctx['ticker']}"] = round(slider_default, 1) st.rerun() with btn_save: st.button("Save scenario", key=f"dcf_save_{ctx['ticker']}", disabled=True, use_container_width=True) with btn_recompute: if st.button("Recompute", key=f"dcf_recompute_{ctx['ticker']}", type="primary", use_container_width=True): get_free_cash_flow_ttm.clear() get_balance_sheet_bridge_items.clear() st.rerun() # Guard: WACC must exceed terminal growth if wacc_pct <= tg_pct: with canvas_col: st.warning(f"WACC ({wacc_pct:.2f}%) must be greater than terminal growth ({tg_pct:.2f}%). Adjust the sliders.") return result = run_dcf( fcf_series=ctx["fcf_series"], shares_outstanding=ctx["shares"], wacc=wacc_pct / 100, terminal_growth=tg_pct / 100, projection_years=yrs, growth_rate_override=g_pct / 100, total_debt=ctx["total_debt"], cash_and_equivalents=ctx["cash_and_equivalents"], preferred_equity=ctx["preferred_equity"], minority_interest=ctx["minority_interest"], base_fcf_override=ctx["base_fcf"], ) if not result: with canvas_col: st.warning("Insufficient data to run DCF model.") return if result.get("error"): with canvas_col: st.warning(result["error"]) return # Cross-check: run other models at their current market multiples ev_ebitda_price = None if ctx["ev_available"] and ctx.get("ev_ebitda_current"): ev_r = run_ev_ebitda( ebitda=float(ctx["ebitda"]), total_debt=ctx["total_debt"], total_cash=ctx["cash_and_equivalents"], preferred_equity=ctx["preferred_equity"], minority_interest=ctx["minority_interest"], shares_outstanding=float(ctx["shares"]), target_multiple=float(ctx["ev_ebitda_current"]), ) ev_ebitda_price = ev_r.get("implied_price_per_share") ev_rev_price = None if ctx["ev_revenue_available"] and ctx.get("ev_revenue_current") and ctx.get("revenue_ttm"): rev_r = run_ev_revenue( revenue=float(ctx["revenue_ttm"]), total_debt=ctx["total_debt"], total_cash=ctx["cash_and_equivalents"], preferred_equity=ctx["preferred_equity"], minority_interest=ctx["minority_interest"], shares_outstanding=float(ctx["shares"]), target_multiple=float(ctx["ev_revenue_current"]), ) ev_rev_price = rev_r.get("implied_price_per_share") pb_price = None if ctx["pb_available"] and ctx.get("pb_current") and ctx.get("book_value_per_share"): pb_r = run_price_to_book( book_value_per_share=float(ctx["book_value_per_share"]), target_multiple=float(ctx["pb_current"]), ) pb_price = pb_r.get("implied_price_per_share") canvas_html = _build_dcf_canvas_html( ctx, result, wacc_pct, tg_pct, yrs, g_pct, ev_ebitda_price, ev_rev_price, pb_price, ) with canvas_col: # Height: base sections + per-year table width is constant in rows canvas_height = 1620 components.html(canvas_html, height=canvas_height, scrolling=False) def _render_ev_ebitda_model(ctx: dict): st.markdown("**EV/EBITDA Valuation**") st.caption( "This is the better fallback when EBITDA is positive but free cash flow is weak, volatile, or currently negative." ) default_multiple = float(ctx["ev_ebitda_current"]) if ctx["ev_ebitda_current"] else 15.0 default_multiple = max(1.0, min(50.0, round(default_multiple, 1))) help_text = ( f"Current market multiple: {ctx['ev_ebitda_current']:.1f}x" if ctx["ev_ebitda_current"] else "Current multiple unavailable" ) target_multiple = st.slider( "Target EV/EBITDA", min_value=1.0, max_value=50.0, value=default_multiple, step=0.5, help=help_text, key=f"ev_ebitda_multiple_{ctx['ticker']}", ) ev_result = run_ev_ebitda( ebitda=float(ctx["ebitda"]), total_debt=ctx["total_debt"], total_cash=ctx["cash_and_equivalents"], preferred_equity=ctx["preferred_equity"], minority_interest=ctx["minority_interest"], shares_outstanding=float(ctx["shares"]), target_multiple=target_multiple, ) if not ev_result: st.warning("Could not compute EV/EBITDA valuation.") return imp_price = ev_result["implied_price_per_share"] current_price = ctx["current_price"] market_cap = ctx["market_cap"] market_enterprise_value = None if market_cap and market_cap > 0: market_enterprise_value = ( float(market_cap) + float(ctx["total_debt"]) - float(ctx["cash_and_equivalents"]) + float(ctx["preferred_equity"]) + float(ctx["minority_interest"]) ) st.caption( "This model applies a target EV/EBITDA multiple to current EBITDA, then bridges from enterprise value to equity value per share." ) calc_a, calc_b, calc_c, calc_d = st.columns(4) calc_a.metric("EBITDA Used", fmt_large(ctx["ebitda"])) calc_b.metric("Target Multiple", f"{target_multiple:.1f}x") calc_c.metric("Implied Enterprise Value", fmt_large(ev_result["implied_ev"])) calc_d.metric("Implied Equity Value", fmt_large(ev_result["equity_value"])) st.caption( f"EBITDA: {fmt_large(ctx['ebitda'])} · " f"{_net_debt_label(ev_result['net_debt'])}: {fmt_large(abs(ev_result['net_debt']))} · " f"Other claims: {fmt_large(ev_result['other_claims'])} · " f"Equity Value: {fmt_large(ev_result['equity_value'])}" ) source_date = ctx["bridge_items"].get("source_date") if source_date: st.caption(f"EV/EBITDA bridge source date: **{source_date}**") if market_cap and market_cap > 0: st.markdown("**Market Comparison**") compare_a, compare_b = st.columns(2) if market_enterprise_value and market_enterprise_value > 0: ev_delta = (ev_result["implied_ev"] - market_enterprise_value) / market_enterprise_value compare_a.metric( "Market Enterprise Value", fmt_large(market_enterprise_value), delta=f"{ev_delta * 100:+.1f}%", ) equity_delta = (ev_result["equity_value"] - market_cap) / market_cap compare_b.metric("Market Cap", fmt_large(market_cap), delta=f"{equity_delta * 100:+.1f}%") summary_rows = [ { "Step": "1. Start with EBITDA", "Value": fmt_large(ctx["ebitda"]), "What it means": "Current EBITDA used as the operating earnings base.", }, { "Step": "2. Apply target multiple", "Value": f"{target_multiple:.1f}x", "What it means": "Chosen EV/EBITDA multiple applied to EBITDA.", }, { "Step": "3. Arrive at enterprise value", "Value": fmt_large(ev_result["implied_ev"]), "What it means": "Implied value of the operating business before capital structure.", }, { "Step": "4. Bridge to equity value", "Value": fmt_large(ev_result["equity_value"]), "What it means": "Enterprise value less net debt and other claims.", }, { "Step": "5. Convert to value per share", "Value": fmt_currency(imp_price), "What it means": "Equity value divided by shares outstanding.", }, ] st.dataframe(pd.DataFrame(summary_rows), use_container_width=True, hide_index=True) st.markdown("**EV/EBITDA Conclusion**") ev_m1, ev_m2, ev_m3, ev_m4 = st.columns(4) ev_m1.metric("Implied Price / Share", fmt_currency(imp_price)) if current_price: ev_upside = (imp_price - current_price) / current_price ev_m2.metric("Current Price", fmt_currency(current_price)) ev_m3.metric( "Upside / Downside", f"{ev_upside * 100:+.1f}%", delta=f"{ev_upside * 100:+.1f}%", ) ev_m4.metric("Implied EV", fmt_large(ev_result["implied_ev"])) if current_price and current_price > 0: valuation_gap = imp_price - current_price market_message = "above" if valuation_gap > 0 else "below" if abs(valuation_gap) < 0.005: market_message = "roughly in line with" implied_value = _escape_markdown_currency(fmt_currency(imp_price)) gap_value = _escape_markdown_currency(fmt_currency(abs(valuation_gap))) current_value = _escape_markdown_currency(fmt_currency(current_price)) st.markdown( f"At **{target_multiple:.1f}x EBITDA**, the model implies **{implied_value} per share**, " f"which is **{gap_value} {market_message}** the current market price of " f"**{current_value}**." ) def _render_ev_revenue_model(ctx: dict): st.markdown("**EV/Revenue Valuation**") st.caption( "This is the better fallback for scaled companies that have revenue but little or no EBITDA or free cash flow." ) default_multiple = float(ctx["ev_revenue_current"]) if ctx["ev_revenue_current"] else 4.0 default_multiple = max(0.5, min(30.0, round(default_multiple, 1))) help_text = ( f"Current market multiple: {ctx['ev_revenue_current']:.2f}x" if ctx["ev_revenue_current"] else "Current multiple unavailable" ) target_multiple = st.slider( "Target EV/Revenue", min_value=0.5, max_value=30.0, value=default_multiple, step=0.1, help=help_text, key=f"ev_revenue_multiple_{ctx['ticker']}", ) ev_revenue_result = run_ev_revenue( revenue=float(ctx["revenue_ttm"]), total_debt=ctx["total_debt"], total_cash=ctx["cash_and_equivalents"], preferred_equity=ctx["preferred_equity"], minority_interest=ctx["minority_interest"], shares_outstanding=float(ctx["shares"]), target_multiple=target_multiple, ) if not ev_revenue_result: st.warning("Could not compute EV/Revenue valuation.") return implied_price = ev_revenue_result["implied_price_per_share"] current_price = ctx["current_price"] market_cap = ctx["market_cap"] market_enterprise_value = None if market_cap and market_cap > 0: market_enterprise_value = ( float(market_cap) + float(ctx["total_debt"]) - float(ctx["cash_and_equivalents"]) + float(ctx["preferred_equity"]) + float(ctx["minority_interest"]) ) st.caption( "This model applies a target EV/Revenue multiple to TTM revenue, then bridges from enterprise value to equity value per share." ) calc_a, calc_b, calc_c, calc_d = st.columns(4) calc_a.metric("Revenue Used", fmt_large(ctx["revenue_ttm"])) calc_b.metric("Target Multiple", f"{target_multiple:.1f}x") calc_c.metric("Implied Enterprise Value", fmt_large(ev_revenue_result["implied_ev"])) calc_d.metric("Implied Equity Value", fmt_large(ev_revenue_result["equity_value"])) st.caption( f"Revenue: {fmt_large(ctx['revenue_ttm'])} · " f"{_net_debt_label(ev_revenue_result['net_debt'])}: {fmt_large(abs(ev_revenue_result['net_debt']))} · " f"Other claims: {fmt_large(ev_revenue_result['other_claims'])} · " f"Equity Value: {fmt_large(ev_revenue_result['equity_value'])}" ) source_date = ctx["bridge_items"].get("source_date") if source_date: st.caption(f"EV/Revenue bridge source date: **{source_date}**") if market_cap and market_cap > 0: st.markdown("**Market Comparison**") compare_a, compare_b = st.columns(2) if market_enterprise_value and market_enterprise_value > 0: ev_delta = (ev_revenue_result["implied_ev"] - market_enterprise_value) / market_enterprise_value compare_a.metric( "Market Enterprise Value", fmt_large(market_enterprise_value), delta=f"{ev_delta * 100:+.1f}%", ) equity_delta = (ev_revenue_result["equity_value"] - market_cap) / market_cap compare_b.metric("Market Cap", fmt_large(market_cap), delta=f"{equity_delta * 100:+.1f}%") summary_rows = [ { "Step": "1. Start with TTM revenue", "Value": fmt_large(ctx["revenue_ttm"]), "What it means": "Trailing twelve-month revenue used as the operating base.", }, { "Step": "2. Apply target multiple", "Value": f"{target_multiple:.1f}x", "What it means": "Chosen EV/Revenue multiple applied to TTM revenue.", }, { "Step": "3. Arrive at enterprise value", "Value": fmt_large(ev_revenue_result["implied_ev"]), "What it means": "Implied value of the operating business before capital structure.", }, { "Step": "4. Bridge to equity value", "Value": fmt_large(ev_revenue_result["equity_value"]), "What it means": "Enterprise value less net debt and other claims.", }, { "Step": "5. Convert to value per share", "Value": fmt_currency(implied_price), "What it means": "Equity value divided by shares outstanding.", }, ] st.dataframe(pd.DataFrame(summary_rows), use_container_width=True, hide_index=True) st.markdown("**EV/Revenue Conclusion**") evr_m1, evr_m2, evr_m3, evr_m4 = st.columns(4) evr_m1.metric("Implied Price / Share", fmt_currency(implied_price)) if current_price: evr_upside = (implied_price - current_price) / current_price evr_m2.metric("Current Price", fmt_currency(current_price)) evr_m3.metric( "Upside / Downside", f"{evr_upside * 100:+.1f}%", delta=f"{evr_upside * 100:+.1f}%", ) evr_m4.metric("Implied EV", fmt_large(ev_revenue_result["implied_ev"])) if current_price and current_price > 0: valuation_gap = implied_price - current_price market_message = "above" if valuation_gap > 0 else "below" if abs(valuation_gap) < 0.005: market_message = "roughly in line with" implied_value = _escape_markdown_currency(fmt_currency(implied_price)) gap_value = _escape_markdown_currency(fmt_currency(abs(valuation_gap))) current_value = _escape_markdown_currency(fmt_currency(current_price)) st.markdown( f"At **{target_multiple:.1f}x revenue**, the model implies **{implied_value} per share**, " f"which is **{gap_value} {market_message}** the current market price of " f"**{current_value}**." ) def _render_price_to_book_model(ctx: dict): st.markdown("**Price / Book Valuation**") if ctx["is_financial"]: st.caption( "P/B is often a better anchor for financial companies than cash-flow models because book value is closer to the operating asset base." ) else: st.caption( "P/B is a useful fallback when book value is meaningful and cash-flow-based models are not reliable." ) default_multiple = float(ctx["pb_current"]) if ctx["pb_current"] else (1.2 if ctx["is_financial"] else 2.0) default_multiple = max(0.2, min(10.0, round(default_multiple, 1))) help_text = ( f"Current market multiple: {ctx['pb_current']:.2f}x" if ctx["pb_current"] else "Current multiple unavailable" ) target_multiple = st.slider( "Target P/B", min_value=0.2, max_value=10.0, value=default_multiple, step=0.1, help=help_text, key=f"pb_multiple_{ctx['ticker']}", ) pb_result = run_price_to_book( book_value_per_share=float(ctx["book_value_per_share"]), target_multiple=target_multiple, ) if not pb_result: st.warning("Could not compute P/B valuation.") return implied_price = pb_result["implied_price_per_share"] current_price = ctx["current_price"] pb_m1, pb_m2, pb_m3, pb_m4 = st.columns(4) pb_m1.metric("Implied Price / Share", fmt_currency(implied_price)) pb_m2.metric("Book Value / Share", fmt_currency(ctx["book_value_per_share"])) if current_price: pb_upside = (implied_price - current_price) / current_price pb_m3.metric("Current Price", fmt_currency(current_price)) pb_m4.metric( "Upside / Downside", f"{pb_upside * 100:+.1f}%", delta=f"{pb_upside * 100:+.1f}%", ) else: pb_m3.metric("Target P/B", fmt_ratio(target_multiple)) pb_m4.metric("Current P/B", fmt_ratio(ctx["pb_current"]) if ctx["pb_current"] else "—") st.caption( f"Book value/share: {fmt_currency(ctx['book_value_per_share'])} · " f"Target P/B: {fmt_ratio(target_multiple)}" ) if current_price and ctx["pb_current"]: st.caption(f"Current market P/B: **{ctx['pb_current']:.2f}x**") def _render_models(ticker: str): ctx = _build_model_context(ticker) st.caption(ctx["summary"]) _render_model_availability(ctx) sections: list[tuple[str, callable]] = [] if ctx["is_financial"] and ctx["pb_available"]: sections.append(("Price / Book", _render_price_to_book_model)) if ctx["dcf_available"]: sections.append(("Discounted Cash Flow", _render_dcf_model)) if ctx["ev_available"]: sections.append(("EV / EBITDA", _render_ev_ebitda_model)) if ctx["ev_revenue_available"] and not ctx["is_financial"]: sections.append(("EV / Revenue", _render_ev_revenue_model)) section_renderers = {renderer for _, renderer in sections} if ctx["pb_available"] and _render_price_to_book_model not in section_renderers: sections.append(("Price / Book", _render_price_to_book_model)) if not sections: st.info("No valuation model is currently applicable for this company.") st.caption("Use comps, ratios, earnings history, and analyst targets instead.") else: for i, (label, render_section) in enumerate(sections): if i > 0: st.divider() with st.expander(label, expanded=(i == 0)): render_section(ctx) unavailable = [] if not ctx["dcf_available"]: unavailable.append(f"- **DCF:** {ctx['dcf_reason']}") if not ctx["ev_available"]: unavailable.append(f"- **EV/EBITDA:** {ctx['ev_reason']}") if not ctx["ev_revenue_available"]: unavailable.append(f"- **EV/Revenue:** {ctx['ev_revenue_reason']}") if not ctx["pb_available"]: unavailable.append(f"- **P/B:** {ctx['pb_reason']}") if unavailable: with st.expander("Why some models are hidden", expanded=False): st.markdown("\n".join(unavailable)) # ── Comps Table ────────────────────────────────────────────────────────────── def _render_comps(ticker: str): info = get_company_info(ticker) auto_peers = get_peers(ticker) suggested_peers = _suggest_peer_tickers(ticker, info) default_peer_string = ", ".join(auto_peers or suggested_peers) manual_peer_string = st.text_input( "Peer tickers", value=default_peer_string, help="Edit the comparable-company set manually. Comma-separated tickers.", key=f"peer_input_{ticker.upper()}", ) if auto_peers: st.caption("Using FMP-discovered peers.") elif suggested_peers: st.caption("Using Prism fallback peers based on sector/industry. Edit them if you want a tighter comp set.") else: st.caption("No automatic peer set found. Enter peer tickers manually to build a comps table.") manual_peers = [p.strip().upper() for p in manual_peer_string.split(",") if p.strip()] peer_list = [] seen = {ticker.upper()} for peer in manual_peers: if peer not in seen: peer_list.append(peer) seen.add(peer) all_tickers = [ticker.upper()] + peer_list[:9] with st.spinner("Loading comps..."): ratios_list = get_ratios_for_tickers(all_tickers) if not ratios_list: st.info("Could not load ratios for the selected peer companies.") return display_cols = { "symbol": "Ticker", "peRatioTTM": "P/E", "priceToSalesRatioTTM": "P/S", "priceToBookRatioTTM": "P/B", "enterpriseValueMultipleTTM": "EV/EBITDA", "evToEBITDATTM": "EV/EBITDA", "netProfitMarginTTM": "Net Margin", "returnOnEquityTTM": "ROE", "debtToEquityRatioTTM": "D/E", } df = pd.DataFrame(ratios_list) if "enterpriseValueMultipleTTM" not in df.columns and "evToEBITDATTM" in df.columns: df["enterpriseValueMultipleTTM"] = df["evToEBITDATTM"] if "debtToEquityRatioTTM" not in df.columns and "debtEquityRatioTTM" in df.columns: df["debtToEquityRatioTTM"] = df["debtEquityRatioTTM"] available = [c for c in ["symbol", "peRatioTTM", "priceToSalesRatioTTM", "priceToBookRatioTTM", "enterpriseValueMultipleTTM", "netProfitMarginTTM", "returnOnEquityTTM", "debtToEquityRatioTTM"] if c in df.columns] df = df[available].rename(columns=display_cols) def _format_comp_value(column: str, value): if value is None: return "—" try: v = float(value) except (TypeError, ValueError): return "—" if column == "P/E": return fmt_ratio(v) if v > 0 else "N/M (neg. earnings)" if column == "P/B": return fmt_ratio(v) if v > 0 else "N/M (neg. equity)" if column == "EV/EBITDA": return fmt_ratio(v) if v > 0 else "N/M (neg. EBITDA)" if column == "D/E": return fmt_ratio(v) if v >= 0 else "N/M (neg. equity)" if column in {"Net Margin", "ROE"}: return fmt_pct(v) return fmt_ratio(v) if v > 0 else "—" for col in df.columns: if col == "Ticker": continue df[col] = df[col].apply(lambda v, c=col: _format_comp_value(c, v)) def highlight_subject(row): if row["Ticker"] == ticker.upper(): return ["background-color: rgba(79,142,247,0.15)"] * len(row) return [""] * len(row) st.dataframe( df.style.apply(highlight_subject, axis=1), use_container_width=True, hide_index=True, ) # ── Analyst Targets ────────────────────────────────────────────────────────── def _render_analyst_targets(ticker: str): targets = get_analyst_price_targets(ticker) recs = get_recommendations_summary(ticker) if not targets and (recs is None or recs.empty): st.info("Analyst data unavailable for this ticker.") return if targets: st.markdown("**Analyst Price Targets**") current = targets.get("current") mean_t = targets.get("mean") t1, t2, t3, t4, t5 = st.columns(5) t1.metric("Low", fmt_currency(targets.get("low"))) t2.metric("Mean", fmt_currency(mean_t)) t3.metric("Median", fmt_currency(targets.get("median"))) t4.metric("High", fmt_currency(targets.get("high"))) if current and mean_t: upside = (mean_t - current) / current t5.metric("Upside to Mean", f"{upside * 100:+.1f}%", delta=f"{upside * 100:+.1f}%") else: t5.metric("Current Price", fmt_currency(current)) st.write("") if recs is not None and not recs.empty: st.markdown("**Analyst Recommendations (Current Month)**") current_row = recs[recs["period"] == "0m"] if "period" in recs.columns else pd.DataFrame() if current_row.empty: current_row = recs.iloc[[0]] row = current_row.iloc[0] counts = { "Strong Buy": int(row.get("strongBuy", 0)), "Buy": int(row.get("buy", 0)), "Hold": int(row.get("hold", 0)), "Sell": int(row.get("sell", 0)), "Strong Sell": int(row.get("strongSell", 0)), } total = sum(counts.values()) cols = st.columns(5) for col, (label, count) in zip(cols, counts.items()): pct = f"{count / total * 100:.0f}%" if total > 0 else "—" col.metric(label, str(count), delta=pct, delta_color="off") st.write("") colors = ["#4F8C5E", "#4F8C5E", "#C49545", "#8F7A50", "#B5494B"] fig = go.Figure(go.Bar( x=list(counts.keys()), y=list(counts.values()), marker_color=colors, text=list(counts.values()), textposition="outside", )) fig.update_layout( title="Analyst Recommendation Distribution", yaxis_title="# Analysts", plot_bgcolor="rgba(0,0,0,0)", paper_bgcolor="rgba(0,0,0,0)", margin=dict(l=0, r=0, t=40, b=0), height=280, ) st.plotly_chart(fig, use_container_width=True) # ── Earnings History ────────────────────────────────────────────────────────── def _render_earnings_history(ticker: str): eh = get_earnings_history(ticker) next_date = get_next_earnings_date(ticker) if next_date: st.info(f"Next earnings date: **{next_date}**") if eh is None or eh.empty: st.info("Earnings history unavailable for this ticker.") return st.markdown("**Historical EPS: Actual vs. Estimate**") df = eh.copy().sort_index(ascending=False) df.index = df.index.astype(str) df.index.name = "Quarter" display = pd.DataFrame(index=df.index) display["EPS Actual"] = df["epsActual"].apply(fmt_currency) display["EPS Estimate"] = df["epsEstimate"].apply(fmt_currency) display["Surprise"] = df["epsDifference"].apply( lambda v: f"{'+' if float(v) >= 0 else ''}{fmt_currency(v)}" if pd.notna(v) else "—" ) display["Surprise %"] = df["surprisePercent"].apply( lambda v: f"{float(v) * 100:+.2f}%" if pd.notna(v) else "—" ) def highlight_surprise(row): try: pct_str = row["Surprise %"].replace("%", "").replace("+", "") val = float(pct_str) color = "rgba(46,204,113,0.15)" if val >= 0 else "rgba(231,76,60,0.15)" return ["", "", f"background-color: {color}", f"background-color: {color}"] except Exception: return [""] * len(row) st.dataframe( display.style.apply(highlight_surprise, axis=1), use_container_width=True, hide_index=False, ) st.download_button( "Download CSV", display.to_csv().encode(), file_name=f"{ticker.upper()}_earnings_history.csv", mime="text/csv", key=f"dl_earnings_{ticker}", ) # EPS chart — oldest to newest df_chart = eh.sort_index() fig = go.Figure() fig.add_trace(go.Scatter( x=df_chart.index.astype(str), y=df_chart["epsActual"], name="Actual EPS", mode="lines+markers", line=dict(color="#C2AA7A", width=2), )) fig.add_trace(go.Scatter( x=df_chart.index.astype(str), y=df_chart["epsEstimate"], name="Estimated EPS", mode="lines+markers", line=dict(color="#C49545", width=2, dash="dash"), )) fig.update_layout( title="EPS: Actual vs. Estimate", yaxis_title="EPS ($)", plot_bgcolor="rgba(0,0,0,0)", paper_bgcolor="rgba(0,0,0,0)", margin=dict(l=0, r=0, t=40, b=0), height=280, legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1), ) st.plotly_chart(fig, use_container_width=True) # ── Historical Ratios ──────────────────────────────────────────────────────── _HIST_RATIO_OPTIONS = { "P/E": ("peRatio", "priceToEarningsRatio", None), "P/B": ("priceToBookRatio", None, None), "P/S": ("priceToSalesRatio", None, None), "EV/EBITDA": ("enterpriseValueMultiple", "evToEBITDA", None), "Net Margin": ("netProfitMargin", None, "pct"), "Operating Margin": ("operatingProfitMargin", None, "pct"), "Gross Margin": ("grossProfitMargin", None, "pct"), "ROE": ("returnOnEquity", None, "pct"), "ROA": ("returnOnAssets", None, "pct"), "Debt/Equity": ("debtEquityRatio", None, None), } _CHART_COLORS = [ "#C2AA7A", "#C49545", "#4F8C5E", "#B5494B", "#9b59b6", "#1abc9c", "#f39c12", "#e67e22", ] def _extract_hist_series(rows: list[dict], primary: str, alt: str | None) -> dict[str, float]: """Extract {year: value} from FMP historical rows.""" out = {} for row in rows: date = str(row.get("date", ""))[:4] val = row.get(primary) if val is None and alt: val = row.get(alt) if val is not None: try: out[date] = float(val) except (TypeError, ValueError): pass return out def _render_historical_ratios(ticker: str): with st.spinner("Loading historical ratios…"): ratio_rows = get_historical_ratios(ticker) metric_rows = get_historical_key_metrics(ticker) if not ratio_rows and not metric_rows: st.info("Historical ratio data unavailable.") return # Merge both lists by date combined: dict[str, dict] = {} for row in ratio_rows + metric_rows: date = str(row.get("date", ""))[:4] if date: combined.setdefault(date, {}).update(row) merged_rows = [{"date": d, **v} for d, v in sorted(combined.items(), reverse=True)] selected = st.multiselect( "Metrics to plot", options=list(_HIST_RATIO_OPTIONS.keys()), default=["P/E", "EV/EBITDA", "Net Margin", "ROE"], ) if not selected: st.info("Select at least one metric to plot.") return fig = go.Figure() for i, label in enumerate(selected): primary, alt, fmt = _HIST_RATIO_OPTIONS[label] series = _extract_hist_series(merged_rows, primary, alt) if not series: continue years = sorted(series.keys()) values = [series[y] * (100 if fmt == "pct" else 1) for y in years] y_label = f"{label} (%)" if fmt == "pct" else label fig.add_trace(go.Scatter( x=years, y=values, name=y_label, mode="lines+markers", line=dict(color=_CHART_COLORS[i % len(_CHART_COLORS)], width=2), )) fig.update_layout( title="Historical Ratios & Metrics", xaxis_title="Year", plot_bgcolor="rgba(0,0,0,0)", paper_bgcolor="rgba(0,0,0,0)", margin=dict(l=0, r=0, t=40, b=0), height=380, legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1), hovermode="x unified", ) st.plotly_chart(fig, use_container_width=True) # Raw data table with st.expander("Raw data"): display_cols = {} for label in selected: primary, alt, fmt = _HIST_RATIO_OPTIONS[label] display_cols[label] = (primary, alt, fmt) def _format_hist_value(label: str, value, fmt: str | None) -> str: if value is None: return "—" try: v = float(value) except (TypeError, ValueError): return "—" if fmt == "pct": return f"{v * 100:.2f}%" if label == "P/E": return f"{v:.2f}x" if v > 0 else "N/M (neg. earnings)" if label == "EV/EBITDA": return f"{v:.2f}x" if v > 0 else "N/M (neg. EBITDA)" if label == "P/B": return f"{v:.2f}x" if v > 0 else "N/M (neg. equity)" if label == "Debt/Equity": return f"{v:.2f}x" if v >= 0 else "N/M (neg. equity)" return f"{v:.2f}x" if v > 0 else "—" table_rows = [] for row in merged_rows: r: dict = {"Year": str(row.get("date", ""))[:4]} for label, (primary, alt, fmt) in display_cols.items(): val = row.get(primary) or (row.get(alt) if alt else None) r[label] = _format_hist_value(label, val, fmt) table_rows.append(r) if table_rows: st.dataframe(pd.DataFrame(table_rows), use_container_width=True, hide_index=True) # ── Forward Estimates ──────────────────────────────────────────────────────── def _render_forward_estimates(ticker: str): with st.spinner("Loading forward estimates…"): estimates = get_analyst_estimates(ticker) annual = estimates.get("annual", []) quarterly = estimates.get("quarterly", []) if not annual and not quarterly: st.info("Forward estimates unavailable. Requires FMP API key.") return info = get_company_info(ticker) current_price = get_latest_price(ticker) tab_ann, tab_qtr = st.tabs(["Annual", "Quarterly"]) def _build_estimates_table(rows: list[dict]) -> pd.DataFrame: table = [] for row in sorted(rows, key=lambda r: str(r.get("date", ""))): date = str(row.get("date", ""))[:7] # FMP stable endpoint uses revenueAvg / epsAvg (no "estimated" prefix) rev_avg = row.get("revenueAvg") or row.get("estimatedRevenueAvg") rev_lo = row.get("revenueLow") or row.get("estimatedRevenueLow") rev_hi = row.get("revenueHigh") or row.get("estimatedRevenueHigh") eps_avg = row.get("epsAvg") or row.get("estimatedEpsAvg") eps_lo = row.get("epsLow") or row.get("estimatedEpsLow") eps_hi = row.get("epsHigh") or row.get("estimatedEpsHigh") ebitda_avg = row.get("ebitdaAvg") or row.get("estimatedEbitdaAvg") num_analysts = row.get("numAnalystsRevenue") or row.get("numAnalystsEps") or row.get("numberAnalystEstimatedRevenue") or row.get("numberAnalysts") table.append({ "Period": date, "Rev Low": fmt_large(rev_lo) if rev_lo else "—", "Rev Avg": fmt_large(rev_avg) if rev_avg else "—", "Rev High": fmt_large(rev_hi) if rev_hi else "—", "EPS Low": fmt_currency(eps_lo) if eps_lo else "—", "EPS Avg": fmt_currency(eps_avg) if eps_avg else "—", "EPS High": fmt_currency(eps_hi) if eps_hi else "—", "EBITDA Avg": fmt_large(ebitda_avg) if ebitda_avg else "—", "# Analysts": str(int(num_analysts)) if num_analysts else "—", }) return pd.DataFrame(table) def _render_eps_chart(rows: list[dict], title: str): """Overlay historical EPS actuals with forward estimates.""" eh = get_earnings_history(ticker) fwd_dates, fwd_eps = [], [] for row in sorted(rows, key=lambda r: str(r.get("date", ""))): date = str(row.get("date", ""))[:7] eps = row.get("epsAvg") or row.get("estimatedEpsAvg") eps_lo = row.get("epsLow") or row.get("estimatedEpsLow") eps_hi = row.get("epsHigh") or row.get("estimatedEpsHigh") if eps is not None: fwd_dates.append(date) fwd_eps.append(float(eps)) fig = go.Figure() if eh is not None and not eh.empty: hist = eh.sort_index() fig.add_trace(go.Scatter( x=hist.index.astype(str), y=hist["epsActual"], name="EPS Actual", mode="lines+markers", line=dict(color="#C2AA7A", width=2), )) if fwd_dates: # Low/high band fwd_lo = [float(r.get("epsLow") or r.get("estimatedEpsLow")) for r in sorted(rows, key=lambda r: str(r.get("date", ""))) if (r.get("epsLow") or r.get("estimatedEpsLow")) is not None] fwd_hi = [float(r.get("epsHigh") or r.get("estimatedEpsHigh")) for r in sorted(rows, key=lambda r: str(r.get("date", ""))) if (r.get("epsHigh") or r.get("estimatedEpsHigh")) is not None] if fwd_lo and fwd_hi and len(fwd_lo) == len(fwd_dates): fig.add_trace(go.Scatter( x=fwd_dates + fwd_dates[::-1], y=fwd_hi + fwd_lo[::-1], fill="toself", fillcolor="rgba(247,162,79,0.15)", line=dict(color="rgba(0,0,0,0)"), name="Est. Range", hoverinfo="skip", )) fig.add_trace(go.Scatter( x=fwd_dates, y=fwd_eps, name="EPS Est. (Avg)", mode="lines+markers", line=dict(color="#C49545", width=2, dash="dash"), )) fig.update_layout( title=title, yaxis_title="EPS ($)", plot_bgcolor="rgba(0,0,0,0)", paper_bgcolor="rgba(0,0,0,0)", margin=dict(l=0, r=0, t=40, b=0), height=320, hovermode="x unified", legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1), ) st.plotly_chart(fig, use_container_width=True) with tab_ann: if annual: df = _build_estimates_table(annual) st.dataframe(df, use_container_width=True, hide_index=True) st.write("") _render_eps_chart(annual, "Annual EPS: Historical Actuals + Forward Estimates") else: st.info("No annual estimates available.") with tab_qtr: if quarterly: df = _build_estimates_table(quarterly) st.dataframe(df, use_container_width=True, hide_index=True) st.write("") _render_eps_chart(quarterly, "Quarterly EPS: Historical Actuals + Forward Estimates") else: st.info("No quarterly estimates available.")