"""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):
dcf_ok = ctx["dcf_available"]
ev_ok = ctx["ev_available"]
rev_ok = ctx["ev_revenue_available"]
pb_ok = ctx["pb_available"]
pb_limited = pb_ok and not ctx["is_financial"]
pb_color = "#C49545" if pb_limited else ("#4F8C5E" if pb_ok else "#5E5849")
pb_glyph = "◐" if pb_limited else "●"
dcf_c = "#4F8C5E" if dcf_ok else "#5E5849"
ev_c = "#4F8C5E" if ev_ok else "#5E5849"
rev_c = "#4F8C5E" if rev_ok else "#5E5849"
st.markdown(
f'
'
f'Applicable '
f'● DCF '
f'● EV/EBITDA '
f'● EV/Revenue '
f'{pb_glyph} P/Book '
f'
',
unsafe_allow_html=True,
)
_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 = """"""
_MULT_CANVAS_CSS = """
.vm-body{display:flex;flex-direction:column;gap:24px;padding:24px 32px 48px}
/* Summary band */
.vm-summary{background:var(--ink-1);border:1px solid var(--line-1);border-radius:6px;overflow:hidden;display:grid;grid-template-columns:1.4fr 2fr}
.vm-summary-head{padding:24px;display:flex;flex-direction:column;gap:8px;border-right:1px solid var(--line-1)}
.vm-summary-head .eyebrow{font-family:var(--font-sans);font-size:12px;text-transform:uppercase;letter-spacing:.18em;color:var(--fg-3);font-weight:600}
.vm-summary-head .ttl{font-family:var(--font-display);font-size:22px;font-weight:500;color:var(--fg-1);margin:0;line-height:1.2}
.vm-summary-head .lede{font-family:var(--font-sans);font-size:13px;color:var(--fg-2);line-height:1.5;margin:0}
.vm-summary-strip{background:var(--ink-2);display:grid;grid-template-columns:repeat(4,1fr)}
.vm-sum-cell{padding:16px;display:flex;flex-direction:column;gap:4px;border-right:1px solid var(--line-1)}
.vm-sum-cell:last-child{border-right:none}
.vm-sum-cell.market{background:rgba(74,120,181,.05);border-left:1px solid var(--line-2)}
.vm-sum-cell .lbl{font-family:var(--font-sans);font-size:11px;text-transform:uppercase;letter-spacing:.12em;color:var(--fg-3)}
.vm-sum-cell .v{font-family:var(--font-mono);font-size:20px;color:var(--fg-1);font-variant-numeric:tabular-nums}
.vm-sum-cell.market .v{color:var(--fg-2)}
.vm-sum-cell .d{font-family:var(--font-mono);font-size:11px}
.d.pos{color:var(--positive)}.d.neg{color:var(--negative)}.d.na{color:var(--fg-4)}
/* Comparison card */
.vm-compare{background:var(--ink-1);border:1px solid var(--line-1);border-radius:6px;overflow:hidden}
.vm-compare-head{padding:16px 24px;border-bottom:1px solid var(--line-1);display:flex;align-items:baseline;gap:12px}
.vm-compare-head h3{font-family:var(--font-display);font-size:20px;font-weight:500;color:var(--fg-1);margin:0}
.vm-compare-head .units{font-family:var(--font-sans);font-size:11px;color:var(--fg-3)}
.vm-grid{display:grid;grid-template-columns:220px 1fr 1fr 1fr;border-bottom:1px solid var(--line-1)}
.vm-grid:last-child{border-bottom:none}
.vm-row-lbl{padding:12px 16px;background:var(--ink-2);border-right:1px solid var(--line-1);font-family:var(--font-sans);font-size:11px;text-transform:uppercase;letter-spacing:.12em;color:var(--fg-3);display:flex;flex-direction:column;gap:4px;align-items:flex-start;justify-content:center}
.vm-row-lbl .sub{font-family:var(--font-sans);font-size:10px;color:var(--fg-4);text-transform:none;letter-spacing:0}
.vm-row-lbl.strong{color:var(--brass);background:rgba(194,170,122,.06)}
.vm-cell{padding:12px 16px;display:flex;flex-direction:column;gap:4px;justify-content:center}
.vm-cell .v{font-family:var(--font-mono);font-size:20px;color:var(--fg-1);font-variant-numeric:tabular-nums}
.vm-cell .v.dash{color:var(--fg-4)}
.vm-cell .cap{font-family:var(--font-sans);font-size:11px;color:var(--fg-3)}
.vm-cell.faded{background:rgba(255,255,255,.005)}
.vm-cell.faded .v{color:var(--fg-4)}
.vm-col-head{padding:16px}
.vm-col-title{display:flex;align-items:center;gap:8px;margin-bottom:8px}
.vm-col-title .n{font-family:var(--font-display);font-style:italic;font-size:16px;color:var(--brass)}
.vm-col-title h4{font-family:var(--font-sans);font-size:14px;font-weight:600;color:var(--fg-1);margin:0}
.vm-col-title .fit{font-family:var(--font-sans);font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.1em;padding:2px 6px;border-radius:2px}
.vm-col-title .fit.ok{color:var(--positive);background:var(--positive-bg)}
.vm-col-title .fit.warn{color:var(--warning);background:var(--warning-bg)}
.vm-col-head .lede{font-family:var(--font-sans);font-size:12px;color:var(--fg-3);line-height:1.5;margin:0}
.vm-grid.result .vm-row-lbl{color:var(--brass);background:rgba(194,170,122,.06)}
.vm-grid.result .vm-cell{background:rgba(194,170,122,.04)}
.vm-grid.result .vm-cell .v{font-size:28px;color:var(--brass-bright)}
.vm-grid.result .vm-cell .delta{font-family:var(--font-mono);font-size:12px}
.delta.pos{color:var(--positive)}.delta.neg{color:var(--negative)}.delta.na{color:var(--fg-4)}
/* Subject multiple slider */
.vm-cell.mult{gap:6px}
.mult-top{display:flex;align-items:baseline;gap:8px}
.mult-top .big{font-family:var(--font-mono);font-size:24px;color:var(--brass-bright);font-variant-numeric:tabular-nums}
.mult-top .sector{font-family:var(--font-mono);font-size:11px;color:var(--fg-3)}
.mult-slider{position:relative;height:18px;margin:2px 0}
.mult-slider .track{position:absolute;inset:7px 0;background:var(--ink-3);border-radius:999px;pointer-events:none}
.mult-slider .track .band{position:absolute;inset:0;background:rgba(74,120,181,.18)}
.mult-slider .track .marker{position:absolute;top:-3px;bottom:-3px;width:2px;background:var(--oxford-light);border-radius:1px}
.mult-slider input[type=range]{position:absolute;inset:0;width:100%;height:18px;background:transparent;-webkit-appearance:none;appearance:none;cursor:pointer;outline:none}
.mult-slider input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:14px;height:14px;border-radius:50%;background:var(--brass);border:2px solid #0B0E13;box-shadow:0 0 0 1px var(--brass-deep);cursor:pointer}
.mult-slider input[type=range]::-moz-range-thumb{width:14px;height:14px;border-radius:50%;background:var(--brass);border:2px solid #0B0E13;box-shadow:0 0 0 1px var(--brass-deep);cursor:pointer;border:none}
.mult-slider input[type=range]::-webkit-slider-runnable-track{background:transparent}
.mult-slider input[type=range]::-moz-range-track{background:transparent;height:4px}
.mult-meta{display:flex;justify-content:space-between;font-family:var(--font-mono);font-size:10px;color:var(--fg-4)}
/* Sensitivity strip */
.vm-sensitivity{background:var(--ink-1);border:1px solid var(--line-1);border-radius:6px;overflow:hidden}
.vm-sensitivity-head{padding:16px 24px;border-bottom:1px solid var(--line-1);display:flex;align-items:baseline;gap:12px}
.vm-sensitivity-head h3{font-family:var(--font-display);font-size:20px;font-weight:500;color:var(--fg-1);margin:0}
.vm-sensitivity-head .hint{font-family:var(--font-sans);font-size:11px;color:var(--fg-3)}
.vm-sens-grid{display:grid;grid-template-columns:repeat(3,1fr)}
.vm-sens-cell{padding:16px;border-right:1px solid var(--line-1)}
.vm-sens-cell:last-child{border-right:none}
.vm-sens-cell>.lbl{font-family:var(--font-sans);font-size:11px;text-transform:uppercase;letter-spacing:.12em;color:var(--fg-3);display:block;margin-bottom:10px}
.vm-sens-row{display:grid;grid-template-columns:1fr auto 1fr;gap:8px;align-items:center;margin-bottom:10px;padding-bottom:10px;border-bottom:1px solid var(--line-1)}
.vm-sens-row .col{display:flex;flex-direction:column;gap:2px}
.vm-sens-row .col .sub{font-family:var(--font-mono);font-size:10px;color:var(--fg-3)}
.vm-sens-row .col .v{font-family:var(--font-mono);font-size:18px;color:var(--fg-1);font-variant-numeric:tabular-nums}
.vm-sens-row .col .v.brass{color:var(--brass-bright)}
.vm-sens-row .col .d{font-family:var(--font-mono);font-size:11px}
.vm-sens-row .arrow{font-family:var(--font-display);font-style:italic;font-size:20px;color:var(--fg-4);text-align:center}
.vm-sens-cell>.meta{font-family:var(--font-sans);font-size:11px;color:var(--fg-3)}
.vm-sens-cell>.meta .num{font-family:var(--font-mono);color:var(--fg-2)}
/* Cross-check vs DCF */
.vm-cx{background:var(--ink-1);border:1px solid var(--line-1);border-radius:6px;overflow:hidden}
.vm-cx-head{padding:16px 24px;border-bottom:1px solid var(--line-1);display:flex;align-items:baseline;gap:12px}
.vm-cx-head h3{font-family:var(--font-display);font-size:20px;font-weight:500;color:var(--fg-1);margin:0}
.vm-cx-head .hint{font-family:var(--font-sans);font-size:11px;color:var(--fg-3)}
.vm-cx-grid{display:grid;grid-template-columns:1.2fr 1fr 1fr 1fr}
.vm-cx-cell{padding:16px 24px;border-right:1px solid var(--line-1);display:flex;flex-direction:column;gap:4px}
.vm-cx-cell:last-child{border-right:none}
.vm-cx-cell .lbl{font-family:var(--font-sans);font-size:11px;text-transform:uppercase;letter-spacing:.12em;color:var(--fg-3)}
.vm-cx-cell .v{font-family:var(--font-mono);font-size:24px;color:var(--fg-1);font-variant-numeric:tabular-nums}
.vm-cx-cell .delta{font-family:var(--font-mono);font-size:12px}
.vm-cx-cell .meta{font-family:var(--font-sans);font-size:11px;color:var(--fg-4);margin-top:4px}
.vm-cx-cell.dcf{background:rgba(194,170,122,.05)}
.vm-cx-cell.dcf .lbl{color:var(--brass-deep)}
.vm-cx-cell.dcf .v{color:var(--brass-bright)}
"""
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 = 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 = 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},
})
data_json = json.dumps({
"baseFcf": result["base_fcf"],
"netDebt": result["net_debt"],
"otherClaims": ctx["preferred_equity"] + ctx["minority_interest"],
"shares": ctx["shares"],
"market": float(ctx["current_price"] or 0),
})
# 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
if dcf_delta is not None and has_market:
dcf_dcls = "pos" if dcf_delta >= 0 else "neg"
dcf_dsign = "+" if dcf_delta >= 0 else ""
dcf_dhtml = f'{dcf_dsign}{dcf_delta:.1f}% vs market '
else:
dcf_dhtml = '— '
cx_dcf = (
f''
f'DCF · THIS MODEL '
f'{iv_str} '
f"{dcf_dhtml}"
f'Firm-value DCF · {yrs}-yr explicit · WACC {wacc_pct:.1f}% '
f"
"
)
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"""
WACC must exceed terminal growth — adjust the sliders
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}
Forecast FCF {fcf_cells}
Discount factor {df_cells}
Present value {pv_cells}
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}
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}
"""
return html
def _build_multiples_canvas_html(ctx: dict) -> str:
market = float(ctx["current_price"] or 0)
shares = float(ctx["shares"] or 0)
total_debt = float(ctx["total_debt"] or 0)
cash = float(ctx["cash_and_equivalents"] or 0)
net_debt = total_debt - cash
ebitda = float(ctx["ebitda"]) if ctx.get("ebitda") and ctx["ebitda"] > 0 else 0.0
revenue = float(ctx["revenue_ttm"]) if ctx.get("revenue_ttm") and ctx["revenue_ttm"] > 0 else 0.0
book_ps = float(ctx["book_value_per_share"]) if ctx.get("book_value_per_share") and ctx["book_value_per_share"] > 0 else 0.0
eb_ok = ebitda > 0 and shares > 0
rv_ok = revenue > 0 and shares > 0
pb_ok = book_ps > 0
has_market = market > 0
def _clamp(v, lo, hi):
try:
return max(lo, min(hi, float(v)))
except (TypeError, ValueError):
return lo
eb_init = _clamp(ctx.get("ev_ebitda_current") or 15.0, 8.0, 32.0)
rv_init = _clamp(ctx.get("ev_revenue_current") or 5.0, 4.0, 20.0)
pb_init = _clamp(ctx.get("pb_current") or 5.0, 4.0, 60.0)
# Sector medians — try peers, fall back to defaults
eb_sector, rv_sector, pb_sector = 12.0, 3.0, 4.0
try:
info = ctx.get("info") or {}
peers = get_peers(ctx["ticker"]) or _suggest_peer_tickers(ctx["ticker"], info)
if peers:
pr = get_ratios_for_tickers(peers[:6])
if pr:
import statistics as _stats
eb_vs = [float(r["enterpriseValueMultipleTTM"]) for r in pr.values()
if r and r.get("enterpriseValueMultipleTTM") and 2 < r["enterpriseValueMultipleTTM"] < 100]
rv_vs = [float(r["priceToSalesRatioTTM"]) for r in pr.values()
if r and r.get("priceToSalesRatioTTM") and 0.1 < r["priceToSalesRatioTTM"] < 50]
pb_vs = [float(r["priceToBookRatioTTM"]) for r in pr.values()
if r and r.get("priceToBookRatioTTM") and 0.5 < r["priceToBookRatioTTM"] < 200]
if eb_vs:
eb_sector = _stats.median(eb_vs)
if rv_vs:
rv_sector = _stats.median(rv_vs)
if pb_vs:
pb_sector = _stats.median(pb_vs)
except Exception:
pass
eb_sector = _clamp(eb_sector, 8.0, 32.0)
rv_sector = _clamp(rv_sector, 4.0, 20.0)
pb_sector = _clamp(pb_sector, 4.0, 60.0)
dcf_iv = st.session_state.get("dcf_intrinsic")
dcf_wacc = st.session_state.get(f"dcf_wacc_{ctx['ticker']}", 10.0)
dcf_tg = st.session_state.get(f"dcf_tg_{ctx['ticker']}", 2.5)
dcf_yrs = st.session_state.get(f"dcf_yrs_{ctx['ticker']}", 5)
def _fb(v):
if v is None or not (isinstance(v, (int, float)) and v == v):
return "—"
b = v / 1e9
if abs(b) >= 1000:
return f"${b / 1000:.2f}T"
return f"${b:.2f}B"
def _fs(v):
if v is None or not (isinstance(v, (int, float)) and v == v):
return "—"
return f"${v:.2f}"
def _fx(v):
return f"{v:.1f}×"
def _dpct(v):
if not has_market or v is None:
return None
return (v - market) / market * 100
def _d_span(val, id_attr=""):
d = _dpct(val)
if d is None:
return f'— '
cls = "pos" if d >= 0 else "neg"
arr = "▲" if d >= 0 else "▼"
sign = "+" if d >= 0 else ""
market_str = _fs(market)
return f'{arr} {sign}{d:.1f}% vs {market_str} '
def _ds_span(val, id_attr=""):
d = _dpct(val)
if d is None:
return f'— '
cls = "pos" if d >= 0 else "neg"
arr = "▲" if d >= 0 else "▼"
sign = "+" if d >= 0 else ""
return f'{arr} {sign}{d:.1f}% '
# Initial computed values
if eb_ok:
eb_ev0 = eb_init * ebitda
eb_eq0 = eb_ev0 - net_debt
eb_per0 = eb_eq0 / shares
else:
eb_ev0 = eb_eq0 = eb_per0 = None
if rv_ok:
rv_ev0 = rv_init * revenue
rv_eq0 = rv_ev0 - net_debt
rv_per0 = rv_eq0 / shares
else:
rv_ev0 = rv_eq0 = rv_per0 = None
pb_per0 = pb_init * book_ps if pb_ok else None
# Sector reference values (static)
sec_eb = (eb_sector * ebitda - net_debt) / shares if eb_ok else None
sec_rv = (rv_sector * revenue - net_debt) / shares if rv_ok else None
sec_pb = pb_sector * book_ps if pb_ok else None
# Slider CSS % positions
def _pct(v, lo, hi):
return (v - lo) / (hi - lo) * 100
eb_s_pct = _pct(eb_sector, 8, 32)
eb_bl_pct = _pct(14, 8, 32)
eb_bh_pct = _pct(26, 8, 32)
rv_s_pct = _pct(rv_sector, 4, 20)
rv_bl_pct = _pct(6, 4, 20)
rv_bh_pct = _pct(13, 4, 20)
pb_s_pct = _pct(pb_sector, 4, 60)
pb_bl_pct = _pct(8, 4, 60)
pb_bh_pct = _pct(14, 4, 60)
shares_str = f"{shares / 1e9:.2f} B" if shares > 0 else "—"
# P/Book fit badge depends on whether company is financial
pb_fit_cls = "ok" if ctx.get("is_financial") else "warn"
pb_fit_lbl = "Strong fit" if ctx.get("is_financial") else "Limited fit"
# Sensitivity re-rating strings (static sector side)
def _rr(subj_per, sect_per):
if subj_per is None or sect_per is None or subj_per == 0:
return "—"
rr = (sect_per - subj_per) / abs(subj_per) * 100
sign = "+" if rr >= 0 else ""
cls = "pos" if rr >= 0 else "neg"
return f'{sign}{rr:.1f}% '
# DCF cross-check cell
if dcf_iv is not None:
dcf_d = _dpct(float(dcf_iv))
if dcf_d is not None:
dcf_cls = "pos" if dcf_d >= 0 else "neg"
dcf_arr = "▲" if dcf_d >= 0 else "▼"
dcf_sign = "+" if dcf_d >= 0 else ""
dcf_delta_html = f'{dcf_arr} {dcf_sign}{dcf_d:.1f}% vs market '
else:
dcf_delta_html = '— '
dcf_val_str = _fs(float(dcf_iv))
dcf_meta_str = f"WACC {dcf_wacc:.1f}% · TG {dcf_tg:.1f}% · {dcf_yrs}-yr explicit"
else:
dcf_delta_html = 'Run DCF tab first '
dcf_val_str = "—"
dcf_meta_str = "Switch to DCF tab to compute"
ticker = ctx["ticker"]
exchange = (ctx.get("info") or {}).get("exchange") or "—"
data_json = json.dumps({
"market": market, "shares": shares, "netDebt": net_debt,
"totalDebt": total_debt, "cash": cash,
"ebitda": ebitda, "revenue": revenue, "bookPs": book_ps,
"ebOk": eb_ok, "rvOk": rv_ok, "pbOk": pb_ok, "hasMarket": has_market,
"ebSector": eb_sector, "rvSector": rv_sector, "pbSector": pb_sector,
})
html = f"""
Multiples
Three relative-valuation lenses — implied per-share
Subject multiple × normalized TTM metric, bridged to equity per share. Compare across columns to see which lens the market is leaning on.
EV / EBITDA
{_fs(eb_per0)}
{_ds_span(eb_per0, 'id="sum-eb-d"')}
EV / Revenue
{_fs(rv_per0)}
{_ds_span(rv_per0, 'id="sum-rv-d"')}
P / Book
{_fs(pb_per0)}
{_ds_span(pb_per0, 'id="sum-pb-d"')}
Market · last
{_fs(market) if has_market else "—"}
{ticker} · {exchange}
Method comparison
USD · TTM metrics · balance-sheet bridge
Method
I
EV / EBITDA Strong fit
Enterprise value normalized by operating cash profit. Strips depreciation and capital structure so different capex profiles compare cleanly.
II
EV / Revenue Strong fit
Topline multiple — useful when margins are volatile or the business is reinvesting through profitability. Less sensitive to one-off charges.
III
P / Book {pb_fit_lbl}
Equity multiple — works for balance-sheet-heavy businesses (banks, insurers, REITs). Limited signal for asset-light software & services.
Subject multipledrag to flex the lens
{_fx(eb_init)} sector {_fx(eb_sector)}
8× typical 14×–26× 32×
{_fx(rv_init)} sector {_fx(rv_sector)}
4× typical 6×–13× 20×
{_fx(pb_init)} sector {_fx(pb_sector)}
4× typical 8×–14× 60×
× Normalized metricfrom TTM filings
{_fb(ebitda) if eb_ok else "—"} EBITDA · TTM
{_fb(revenue) if rv_ok else "—"} Revenue · TTM
{_fs(book_ps) if pb_ok else "—"} Book value · /share
= Enterprise value
{_fb(eb_ev0)} multiple × metric
{_fb(rv_ev0)} multiple × metric
— P/B is an equity multiple — no EV step
− Net debt
{_fb(net_debt)} total {_fb(total_debt)} − cash {_fb(cash)}
{_fb(net_debt)} total {_fb(total_debt)} − cash {_fb(cash)}
—
= Equity value
{_fb(eb_eq0)} EV − net debt
{_fb(rv_eq0)} EV − net debt
—
÷ Shares outstanding
{shares_str} diluted
{shares_str} diluted
—
= Implied per share
{_fs(eb_per0)}
{_d_span(eb_per0, 'id="eb-per-d"')}
{_fs(rv_per0)}
{_d_span(rv_per0, 'id="rv-per-d"')}
{_fs(pb_per0)}
{_d_span(pb_per0, 'id="pb-per-d"')}
If the lens shifted to sector
Same metrics, subject multiple replaced by sector median
EV / EBITDA
At subject {_fx(eb_init)}
{_fs(eb_per0)}
{_ds_span(eb_per0, 'id="sens-eb-subj-d"')}
→
At sector {_fx(eb_sector)}
{_fs(sec_eb)}
{_ds_span(sec_eb)}
Re-rating Δ {_rr(eb_per0, sec_eb)} per share if the subject converged to peers
EV / Revenue
At subject {_fx(rv_init)}
{_fs(rv_per0)}
{_ds_span(rv_per0, 'id="sens-rv-subj-d"')}
→
At sector {_fx(rv_sector)}
{_fs(sec_rv)}
{_ds_span(sec_rv)}
Re-rating Δ {_rr(rv_per0, sec_rv)} per share if the subject converged to peers
P / Book
At subject {_fx(pb_init)}
{_fs(pb_per0)}
{_ds_span(pb_per0, 'id="sens-pb-subj-d"')}
→
At sector {_fx(pb_sector)}
{_fs(sec_pb)}
{_ds_span(sec_pb)}
Re-rating Δ {_rr(pb_per0, sec_pb)} per share if the subject converged to peers
Cross-check against DCF
DCF intrinsic from the firm-value model on the previous tab
DCF · firm value
{dcf_val_str}
{dcf_delta_html}
{dcf_meta_str}
EV / EBITDA
{_fs(eb_per0)}
{_d_span(eb_per0, 'id="cx-eb-d"')}
Subject {_fx(eb_init)} · sector {_fx(eb_sector)}
EV / Revenue
{_fs(rv_per0)}
{_d_span(rv_per0, 'id="cx-rv-d"')}
Subject {_fx(rv_init)} · sector {_fx(rv_sector)}
P / Book
{_fs(pb_per0)}
{_d_span(pb_per0, 'id="cx-pb-d"')}
Subject {_fx(pb_init)} · sector {_fx(pb_sector)} · low-signal
"""
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,
)
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)
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()
# Read assumptions from session state (set by in-canvas JS sliders, defaulting on first load)
wacc_pct = float(st.session_state.get(f"dcf_wacc_{ctx['ticker']}", 10.0))
tg_pct = float(st.session_state.get(f"dcf_tg_{ctx['ticker']}", 2.5))
yrs = int(st.session_state.get(f"dcf_yrs_{ctx['ticker']}", 5))
g_pct = round(float(st.session_state.get(f"dcf_g_{ctx['ticker']}", round(slider_default, 1))), 1)
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
st.session_state["dcf_intrinsic"] = result["intrinsic_value_per_share"]
st.session_state[f"dcf_params_{ctx['ticker']}"] = {"wacc": wacc_pct, "tg": tg_pct, "yrs": yrs}
# 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_multiples_model(ctx: dict):
st.markdown(_DCF_RAIL_CSS, unsafe_allow_html=True)
rail_col, canvas_col = st.columns([1, 4], gap="medium")
with rail_col:
st.markdown(
'Multiples '
'Three relative-valuation lenses
'
'Subject multiple × normalized TTM metric, bridged to equity per share.
',
unsafe_allow_html=True,
)
st.markdown(' ', unsafe_allow_html=True)
net_debt_raw = ctx["total_debt"] - ctx["cash_and_equivalents"]
ebitda_str = _fmt_b(ctx["ebitda"]) if ctx.get("ebitda") and ctx["ebitda"] > 0 else "—"
rev_str = _fmt_b(ctx["revenue_ttm"]) if ctx.get("revenue_ttm") and ctx["revenue_ttm"] > 0 else "—"
bps_str = f"${ctx['book_value_per_share']:.2f}" if ctx.get("book_value_per_share") and ctx["book_value_per_share"] > 0 else "—"
st.markdown(
'From the filings
'
f'EBITDA (TTM) {ebitda_str}
'
f'Revenue (TTM) {rev_str}
'
f'Book value / share {bps_str}
'
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)
if st.button("Refresh data", key=f"mult_refresh_{ctx['ticker']}", type="primary", use_container_width=True):
get_balance_sheet_bridge_items.clear()
st.rerun()
canvas_html = _build_multiples_canvas_html(ctx)
with canvas_col:
components.html(canvas_html, height=1620, 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)
if "models_view" not in st.session_state:
st.session_state["models_view"] = "dcf"
st.markdown(_DCF_RAIL_CSS, unsafe_allow_html=True)
_pc1, _pc2 = st.columns(2)
with _pc1:
if st.button(
"Discounted Cash Flow",
key=f"pick_dcf_{ticker}",
type="primary" if st.session_state["models_view"] == "dcf" else "secondary",
use_container_width=True,
):
st.session_state["models_view"] = "dcf"
st.rerun()
with _pc2:
if st.button(
"Multiples",
key=f"pick_mult_{ticker}",
type="primary" if st.session_state["models_view"] == "multiples" else "secondary",
use_container_width=True,
):
st.session_state["models_view"] = "multiples"
st.rerun()
st.markdown("---")
view = st.session_state.get("models_view", "dcf")
if view == "dcf":
if ctx["dcf_available"]:
_render_dcf_model(ctx)
else:
st.warning(f"DCF model not available: {ctx['dcf_reason']}")
if st.expander("Show available alternatives", expanded=True):
_render_multiples_model(ctx)
else:
_render_multiples_model(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 unavailable", 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.")