"""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}
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 _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.")