aboutsummaryrefslogtreecommitdiff
path: root/components/valuation.py
diff options
context:
space:
mode:
authorTyler <tyler@tylerhoang.xyz>2026-05-13 23:50:05 -0700
committerTyler <tyler@tylerhoang.xyz>2026-05-13 23:50:05 -0700
commita82246e83146d1dd7f565493215c23ff482975e6 (patch)
tree9883a45ed69587b57a90cbdc0cdd31845371a31f /components/valuation.py
parent64ea2681ceb403f021d13c39931f67321d11425b (diff)
Add Multiples view; fix Recompute button text color
- Collapse model picker from 4 tabs → 2: DCF and Multiples, persisted in session_state["models_view"] - New Multiples view: summary band, interactive comparison grid (8 math rows × 3 methods), sensitivity strip, DCF cross-check, footer - In-canvas sliders with sector marker + typical-band shading; JS recomputes all derived values live without a Streamlit rerun - Sector medians computed from peer ratios via FMP; falls back to reasonable defaults when peer data is unavailable - DCF intrinsic stored in session_state["dcf_intrinsic"] so the cross-check on the Multiples tab reads the live DCF value - P/Book applicability shows ◐ + warning color for asset-light companies instead of the solid ● used for strong-fit methods - Fix Recompute button text: target inner <p>/<span> with color: var(--brass-ink) to override Streamlit's fg-2 default Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'components/valuation.py')
-rw-r--r--components/valuation.py746
1 files changed, 713 insertions, 33 deletions
diff --git a/components/valuation.py b/components/valuation.py
index e2e4338..53be09c 100644
--- a/components/valuation.py
+++ b/components/valuation.py
@@ -460,18 +460,27 @@ def _build_model_context(ticker: str) -> dict:
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_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'<div style="font-family:\'IBM Plex Sans\',sans-serif;font-size:12px;color:#8E8676;'
+ f'display:flex;align-items:center;gap:14px;flex-wrap:wrap;margin-bottom:4px">'
+ f'<span>Applicable</span>'
+ f'<span><span style="color:{dcf_c}">●</span>&nbsp;DCF</span>'
+ f'<span><span style="color:{ev_c}">●</span>&nbsp;EV/EBITDA</span>'
+ f'<span><span style="color:{rev_c}">●</span>&nbsp;EV/Revenue</span>'
+ f'<span><span style="color:{pb_color}">{pb_glyph}</span>&nbsp;P/Book</span>'
+ f'</div>',
+ unsafe_allow_html=True,
+ )
_DCF_CANVAS_CSS = """
@@ -588,8 +597,110 @@ _DCF_RAIL_CSS = """<style>
/* Primary button — brass bg, dark ink text */
[data-testid="stBaseButton-primary"]{color:#17120A !important;background-color:#C2AA7A !important}
button[kind="primary"]{color:#17120A !important}
+[data-testid="stBaseButton-primary"] p,[data-testid="stBaseButton-primary"] span{color:#17120A !important}
</style>"""
+_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
@@ -885,6 +996,517 @@ Plotly.newPlot('dcf-chart', data, layout, {{displayModeBar: false, responsive: t
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}&times;"
+
+ 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'<span {id_attr} class="delta na">—</span>'
+ cls = "pos" if d >= 0 else "neg"
+ arr = "▲" if d >= 0 else "▼"
+ sign = "+" if d >= 0 else ""
+ market_str = _fs(market)
+ return f'<span {id_attr} class="delta num {cls}">{arr} {sign}{d:.1f}% vs {market_str}</span>'
+
+ def _ds_span(val, id_attr=""):
+ d = _dpct(val)
+ if d is None:
+ return f'<span {id_attr} class="d na">—</span>'
+ cls = "pos" if d >= 0 else "neg"
+ arr = "▲" if d >= 0 else "▼"
+ sign = "+" if d >= 0 else ""
+ return f'<span {id_attr} class="d num {cls}">{arr} {sign}{d:.1f}%</span>'
+
+ # 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}&nbsp;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'<span class="num {cls}">{sign}{rr:.1f}%</span>'
+
+ # 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'<span class="delta num {dcf_cls}">{dcf_arr} {dcf_sign}{dcf_d:.1f}% vs market</span>'
+ else:
+ dcf_delta_html = '<span class="delta na">—</span>'
+ dcf_val_str = _fs(float(dcf_iv))
+ dcf_meta_str = f"WACC {dcf_wacc:.1f}% &middot; TG {dcf_tg:.1f}% &middot; {dcf_yrs}-yr explicit"
+ else:
+ dcf_delta_html = '<span class="delta na">Run DCF tab first</span>'
+ 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"""<!DOCTYPE html>
+<html>
+<head>
+<meta charset="utf-8">
+<style>
+{_DCF_CANVAS_CSS}
+{_MULT_CANVAS_CSS}
+</style>
+</head>
+<body>
+<div class="vm-body">
+
+<section class="vm-summary">
+ <div class="vm-summary-head">
+ <span class="eyebrow">Multiples</span>
+ <h2 class="ttl">Three relative-valuation lenses &mdash; implied per-share</h2>
+ <p class="lede">Subject multiple &times; normalized TTM metric, bridged to equity per share. Compare across columns to see which lens the market is leaning on.</p>
+ </div>
+ <div class="vm-summary-strip">
+ <div class="vm-sum-cell">
+ <span class="lbl">EV / EBITDA</span>
+ <span class="v num" id="sum-eb-val">{_fs(eb_per0)}</span>
+ {_ds_span(eb_per0, 'id="sum-eb-d"')}
+ </div>
+ <div class="vm-sum-cell">
+ <span class="lbl">EV / Revenue</span>
+ <span class="v num" id="sum-rv-val">{_fs(rv_per0)}</span>
+ {_ds_span(rv_per0, 'id="sum-rv-d"')}
+ </div>
+ <div class="vm-sum-cell">
+ <span class="lbl">P / Book</span>
+ <span class="v num" id="sum-pb-val">{_fs(pb_per0)}</span>
+ {_ds_span(pb_per0, 'id="sum-pb-d"')}
+ </div>
+ <div class="vm-sum-cell market">
+ <span class="lbl">Market &middot; last</span>
+ <span class="v num">{_fs(market) if has_market else "—"}</span>
+ <span class="d num" style="color:var(--fg-3)">{ticker} &middot; {exchange}</span>
+ </div>
+ </div>
+</section>
+
+<section class="vm-compare">
+ <div class="vm-compare-head">
+ <h3>Method comparison</h3>
+ <span class="units">USD &middot; TTM metrics &middot; balance-sheet bridge</span>
+ </div>
+
+ <div class="vm-grid head">
+ <div class="vm-row-lbl">Method</div>
+ <div class="vm-col-head">
+ <div class="vm-col-title"><span class="n">I</span><h4>EV / EBITDA</h4><span class="fit ok">Strong fit</span></div>
+ <p class="lede">Enterprise value normalized by operating cash profit. Strips depreciation and capital structure so different capex profiles compare cleanly.</p>
+ </div>
+ <div class="vm-col-head">
+ <div class="vm-col-title"><span class="n">II</span><h4>EV / Revenue</h4><span class="fit ok">Strong fit</span></div>
+ <p class="lede">Topline multiple &mdash; useful when margins are volatile or the business is reinvesting through profitability. Less sensitive to one-off charges.</p>
+ </div>
+ <div class="vm-col-head">
+ <div class="vm-col-title"><span class="n">III</span><h4>P / Book</h4><span class="fit {pb_fit_cls}">{pb_fit_lbl}</span></div>
+ <p class="lede">Equity multiple &mdash; works for balance-sheet-heavy businesses (banks, insurers, REITs). Limited signal for asset-light software &amp; services.</p>
+ </div>
+ </div>
+
+ <div class="vm-grid">
+ <div class="vm-row-lbl">Subject multiple<span class="sub">drag to flex the lens</span></div>
+ <div class="vm-cell mult">
+ <div class="mult-top"><span class="big num" id="big-eb">{_fx(eb_init)}</span><span class="sector num">sector {_fx(eb_sector)}</span></div>
+ <div class="mult-slider">
+ <div class="track">
+ <span class="band" style="left:{eb_bl_pct:.1f}%;right:{100-eb_bh_pct:.1f}%"></span>
+ <span class="marker" style="left:{eb_s_pct:.1f}%"></span>
+ </div>
+ <input type="range" id="sl-eb" min="8" max="32" step="0.1" value="{eb_init:.1f}"{' disabled' if not eb_ok else ''}>
+ </div>
+ <div class="mult-meta"><span>8&times;</span><span>typical 14&times;&ndash;26&times;</span><span>32&times;</span></div>
+ </div>
+ <div class="vm-cell mult">
+ <div class="mult-top"><span class="big num" id="big-rv">{_fx(rv_init)}</span><span class="sector num">sector {_fx(rv_sector)}</span></div>
+ <div class="mult-slider">
+ <div class="track">
+ <span class="band" style="left:{rv_bl_pct:.1f}%;right:{100-rv_bh_pct:.1f}%"></span>
+ <span class="marker" style="left:{rv_s_pct:.1f}%"></span>
+ </div>
+ <input type="range" id="sl-rv" min="4" max="20" step="0.1" value="{rv_init:.1f}"{' disabled' if not rv_ok else ''}>
+ </div>
+ <div class="mult-meta"><span>4&times;</span><span>typical 6&times;&ndash;13&times;</span><span>20&times;</span></div>
+ </div>
+ <div class="vm-cell mult">
+ <div class="mult-top"><span class="big num" id="big-pb">{_fx(pb_init)}</span><span class="sector num">sector {_fx(pb_sector)}</span></div>
+ <div class="mult-slider">
+ <div class="track">
+ <span class="band" style="left:{pb_bl_pct:.1f}%;right:{100-pb_bh_pct:.1f}%"></span>
+ <span class="marker" style="left:{pb_s_pct:.1f}%"></span>
+ </div>
+ <input type="range" id="sl-pb" min="4" max="60" step="0.1" value="{pb_init:.1f}"{' disabled' if not pb_ok else ''}>
+ </div>
+ <div class="mult-meta"><span>4&times;</span><span>typical 8&times;&ndash;14&times;</span><span>60&times;</span></div>
+ </div>
+ </div>
+
+ <div class="vm-grid">
+ <div class="vm-row-lbl">&times; Normalized metric<span class="sub">from TTM filings</span></div>
+ <div class="vm-cell"><span class="v num">{_fb(ebitda) if eb_ok else "—"}</span><span class="cap">EBITDA &middot; TTM</span></div>
+ <div class="vm-cell"><span class="v num">{_fb(revenue) if rv_ok else "—"}</span><span class="cap">Revenue &middot; TTM</span></div>
+ <div class="vm-cell"><span class="v num">{_fs(book_ps) if pb_ok else "—"}</span><span class="cap">Book value &middot; /share</span></div>
+ </div>
+
+ <div class="vm-grid">
+ <div class="vm-row-lbl">= Enterprise value</div>
+ <div class="vm-cell"><span class="v num" id="eb-ev-val">{_fb(eb_ev0)}</span><span class="cap">multiple &times; metric</span></div>
+ <div class="vm-cell"><span class="v num" id="rv-ev-val">{_fb(rv_ev0)}</span><span class="cap">multiple &times; metric</span></div>
+ <div class="vm-cell faded"><span class="v num dash">—</span><span class="cap">P/B is an equity multiple &mdash; no EV step</span></div>
+ </div>
+
+ <div class="vm-grid">
+ <div class="vm-row-lbl">&minus; Net debt</div>
+ <div class="vm-cell"><span class="v num">{_fb(net_debt)}</span><span class="cap">total {_fb(total_debt)} &minus; cash {_fb(cash)}</span></div>
+ <div class="vm-cell"><span class="v num">{_fb(net_debt)}</span><span class="cap">total {_fb(total_debt)} &minus; cash {_fb(cash)}</span></div>
+ <div class="vm-cell faded"><span class="v num dash">—</span></div>
+ </div>
+
+ <div class="vm-grid">
+ <div class="vm-row-lbl">= Equity value</div>
+ <div class="vm-cell"><span class="v num" id="eb-eq-val">{_fb(eb_eq0)}</span><span class="cap">EV &minus; net debt</span></div>
+ <div class="vm-cell"><span class="v num" id="rv-eq-val">{_fb(rv_eq0)}</span><span class="cap">EV &minus; net debt</span></div>
+ <div class="vm-cell faded"><span class="v num dash">—</span></div>
+ </div>
+
+ <div class="vm-grid">
+ <div class="vm-row-lbl">&divide; Shares outstanding</div>
+ <div class="vm-cell"><span class="v num">{shares_str}</span><span class="cap">diluted</span></div>
+ <div class="vm-cell"><span class="v num">{shares_str}</span><span class="cap">diluted</span></div>
+ <div class="vm-cell faded"><span class="v num dash">—</span></div>
+ </div>
+
+ <div class="vm-grid result">
+ <div class="vm-row-lbl strong">= Implied per share</div>
+ <div class="vm-cell result">
+ <span class="v num" id="eb-per-val">{_fs(eb_per0)}</span>
+ {_d_span(eb_per0, 'id="eb-per-d"')}
+ </div>
+ <div class="vm-cell result">
+ <span class="v num" id="rv-per-val">{_fs(rv_per0)}</span>
+ {_d_span(rv_per0, 'id="rv-per-d"')}
+ </div>
+ <div class="vm-cell result">
+ <span class="v num" id="pb-per-val">{_fs(pb_per0)}</span>
+ {_d_span(pb_per0, 'id="pb-per-d"')}
+ </div>
+ </div>
+</section>
+
+<section class="vm-sensitivity">
+ <div class="vm-sensitivity-head">
+ <h3>If the lens shifted to sector</h3>
+ <span class="hint">Same metrics, subject multiple replaced by sector median</span>
+ </div>
+ <div class="vm-sens-grid">
+
+ <div class="vm-sens-cell">
+ <span class="lbl">EV / EBITDA</span>
+ <div class="vm-sens-row">
+ <div class="col">
+ <span class="sub" id="sens-eb-subj-lbl">At subject {_fx(eb_init)}</span>
+ <span class="v num" id="sens-eb-subj-v">{_fs(eb_per0)}</span>
+ {_ds_span(eb_per0, 'id="sens-eb-subj-d"')}
+ </div>
+ <span class="arrow">&rarr;</span>
+ <div class="col">
+ <span class="sub">At sector {_fx(eb_sector)}</span>
+ <span class="v num brass">{_fs(sec_eb)}</span>
+ {_ds_span(sec_eb)}
+ </div>
+ </div>
+ <span class="meta" id="sens-eb-meta">Re-rating &Delta; {_rr(eb_per0, sec_eb)} per share if the subject converged to peers</span>
+ </div>
+
+ <div class="vm-sens-cell">
+ <span class="lbl">EV / Revenue</span>
+ <div class="vm-sens-row">
+ <div class="col">
+ <span class="sub" id="sens-rv-subj-lbl">At subject {_fx(rv_init)}</span>
+ <span class="v num" id="sens-rv-subj-v">{_fs(rv_per0)}</span>
+ {_ds_span(rv_per0, 'id="sens-rv-subj-d"')}
+ </div>
+ <span class="arrow">&rarr;</span>
+ <div class="col">
+ <span class="sub">At sector {_fx(rv_sector)}</span>
+ <span class="v num brass">{_fs(sec_rv)}</span>
+ {_ds_span(sec_rv)}
+ </div>
+ </div>
+ <span class="meta" id="sens-rv-meta">Re-rating &Delta; {_rr(rv_per0, sec_rv)} per share if the subject converged to peers</span>
+ </div>
+
+ <div class="vm-sens-cell">
+ <span class="lbl">P / Book</span>
+ <div class="vm-sens-row">
+ <div class="col">
+ <span class="sub" id="sens-pb-subj-lbl">At subject {_fx(pb_init)}</span>
+ <span class="v num" id="sens-pb-subj-v">{_fs(pb_per0)}</span>
+ {_ds_span(pb_per0, 'id="sens-pb-subj-d"')}
+ </div>
+ <span class="arrow">&rarr;</span>
+ <div class="col">
+ <span class="sub">At sector {_fx(pb_sector)}</span>
+ <span class="v num brass">{_fs(sec_pb)}</span>
+ {_ds_span(sec_pb)}
+ </div>
+ </div>
+ <span class="meta" id="sens-pb-meta">Re-rating &Delta; {_rr(pb_per0, sec_pb)} per share if the subject converged to peers</span>
+ </div>
+
+ </div>
+</section>
+
+<section class="vm-cx">
+ <div class="vm-cx-head">
+ <h3>Cross-check against DCF</h3>
+ <span class="hint">DCF intrinsic from the firm-value model on the previous tab</span>
+ </div>
+ <div class="vm-cx-grid">
+ <div class="vm-cx-cell dcf">
+ <span class="lbl">DCF &middot; firm value</span>
+ <span class="v num">{dcf_val_str}</span>
+ {dcf_delta_html}
+ <span class="meta">{dcf_meta_str}</span>
+ </div>
+ <div class="vm-cx-cell">
+ <span class="lbl">EV / EBITDA</span>
+ <span class="v num" id="cx-eb-val">{_fs(eb_per0)}</span>
+ {_d_span(eb_per0, 'id="cx-eb-d"')}
+ <span class="meta" id="cx-eb-meta">Subject {_fx(eb_init)} &middot; sector {_fx(eb_sector)}</span>
+ </div>
+ <div class="vm-cx-cell">
+ <span class="lbl">EV / Revenue</span>
+ <span class="v num" id="cx-rv-val">{_fs(rv_per0)}</span>
+ {_d_span(rv_per0, 'id="cx-rv-d"')}
+ <span class="meta" id="cx-rv-meta">Subject {_fx(rv_init)} &middot; sector {_fx(rv_sector)}</span>
+ </div>
+ <div class="vm-cx-cell">
+ <span class="lbl">P / Book</span>
+ <span class="v num" id="cx-pb-val">{_fs(pb_per0)}</span>
+ {_d_span(pb_per0, 'id="cx-pb-d"')}
+ <span class="meta" id="cx-pb-meta">Subject {_fx(pb_init)} &middot; sector {_fx(pb_sector)} &middot; low-signal</span>
+ </div>
+ </div>
+</section>
+
+<div class="va-foot">
+ <span>Multiples &middot; TTM metrics, balance sheet. Net-debt adjustment applies to EV-based methods only; P/B reads directly off book equity per share. Sector medians from peer group analysis.</span>
+ <a href="#">Methodology &amp; sources &nearr;</a>
+</div>
+
+</div>
+<script>
+var D = {data_json};
+
+function fB(n) {{ var b=n/1e9; return Math.abs(b)>=1000 ? '$'+(b/1000).toFixed(2)+'T' : '$'+b.toFixed(2)+'B'; }}
+function fS(n) {{ return '$'+n.toFixed(2); }}
+function fX(n) {{ return n.toFixed(1)+'×'; }}
+function dPct(v) {{ return D.hasMarket ? (v-D.market)/D.market*100 : 0; }}
+function dStr(d) {{
+ var cls=d>=0?'pos':'neg', arr=d>=0?'▲':'▼', sign=d>=0?'+':'';
+ return '<span class="d num '+cls+'">'+arr+' '+sign+d.toFixed(1)+'%</span>';
+}}
+function dVsStr(d) {{
+ var cls=d>=0?'pos':'neg', arr=d>=0?'▲':'▼', sign=d>=0?'+':'';
+ return '<span class="delta num '+cls+'">'+arr+' '+sign+d.toFixed(1)+'% vs '+fS(D.market)+'</span>';
+}}
+function setText(id,t) {{ var e=document.getElementById(id); if(e) e.textContent=t; }}
+function setHtml(id,h) {{ var e=document.getElementById(id); if(e) e.innerHTML=h; }}
+
+function update() {{
+ var ebX=+document.getElementById('sl-eb').value;
+ var rvX=+document.getElementById('sl-rv').value;
+ var pbX=+document.getElementById('sl-pb').value;
+
+ if (D.ebOk) {{
+ var ebEV=ebX*D.ebitda, ebEq=ebEV-D.netDebt, ebPer=ebEq/D.shares, ebD=dPct(ebPer);
+ var secEbPer=(D.ebSector*D.ebitda-D.netDebt)/D.shares;
+ var rrEb=ebPer!==0?(secEbPer-ebPer)/Math.abs(ebPer)*100:0;
+ setText('big-eb', fX(ebX));
+ setText('sum-eb-val', fS(ebPer)); setHtml('sum-eb-d', dStr(ebD));
+ setText('eb-ev-val', fB(ebEV)); setText('eb-eq-val', fB(ebEq));
+ setText('eb-per-val', fS(ebPer)); setHtml('eb-per-d', dVsStr(ebD));
+ setText('sens-eb-subj-lbl', 'At subject '+fX(ebX));
+ setText('sens-eb-subj-v', fS(ebPer)); setHtml('sens-eb-subj-d', dStr(ebD));
+ var rrCls=rrEb>=0?'pos':'neg', rrSign=rrEb>=0?'+':'';
+ setHtml('sens-eb-meta', 'Re-rating Δ <span class="num '+rrCls+'">'+rrSign+rrEb.toFixed(1)+'%</span> per share if the subject converged to peers');
+ setText('cx-eb-val', fS(ebPer)); setHtml('cx-eb-d', dVsStr(ebD));
+ setText('cx-eb-meta', 'Subject '+fX(ebX)+' · sector '+fX(D.ebSector));
+ }}
+ if (D.rvOk) {{
+ var rvEV=rvX*D.revenue, rvEq=rvEV-D.netDebt, rvPer=rvEq/D.shares, rvD=dPct(rvPer);
+ var secRvPer=(D.rvSector*D.revenue-D.netDebt)/D.shares;
+ var rrRv=rvPer!==0?(secRvPer-rvPer)/Math.abs(rvPer)*100:0;
+ setText('big-rv', fX(rvX));
+ setText('sum-rv-val', fS(rvPer)); setHtml('sum-rv-d', dStr(rvD));
+ setText('rv-ev-val', fB(rvEV)); setText('rv-eq-val', fB(rvEq));
+ setText('rv-per-val', fS(rvPer)); setHtml('rv-per-d', dVsStr(rvD));
+ setText('sens-rv-subj-lbl', 'At subject '+fX(rvX));
+ setText('sens-rv-subj-v', fS(rvPer)); setHtml('sens-rv-subj-d', dStr(rvD));
+ var rrCls=rrRv>=0?'pos':'neg', rrSign=rrRv>=0?'+':'';
+ setHtml('sens-rv-meta', 'Re-rating Δ <span class="num '+rrCls+'">'+rrSign+rrRv.toFixed(1)+'%</span> per share if the subject converged to peers');
+ setText('cx-rv-val', fS(rvPer)); setHtml('cx-rv-d', dVsStr(rvD));
+ setText('cx-rv-meta', 'Subject '+fX(rvX)+' · sector '+fX(D.rvSector));
+ }}
+ if (D.pbOk) {{
+ var pbPer=pbX*D.bookPs, pbD=dPct(pbPer);
+ var secPbPer=D.pbSector*D.bookPs;
+ var rrPb=pbPer!==0?(secPbPer-pbPer)/Math.abs(pbPer)*100:0;
+ setText('big-pb', fX(pbX));
+ setText('sum-pb-val', fS(pbPer)); setHtml('sum-pb-d', dStr(pbD));
+ setText('pb-per-val', fS(pbPer)); setHtml('pb-per-d', dVsStr(pbD));
+ setText('sens-pb-subj-lbl', 'At subject '+fX(pbX));
+ setText('sens-pb-subj-v', fS(pbPer)); setHtml('sens-pb-subj-d', dStr(pbD));
+ var rrCls=rrPb>=0?'pos':'neg', rrSign=rrPb>=0?'+':'';
+ setHtml('sens-pb-meta', 'Re-rating Δ <span class="num '+rrCls+'">'+rrSign+rrPb.toFixed(1)+'%</span> per share if the subject converged to peers');
+ setText('cx-pb-val', fS(pbPer)); setHtml('cx-pb-d', dVsStr(pbD));
+ setText('cx-pb-meta', 'Subject '+fX(pbX)+' · sector '+fX(D.pbSector)+' · low-signal');
+ }}
+}}
+
+document.getElementById('sl-eb').addEventListener('input', update);
+document.getElementById('sl-rv').addEventListener('input', update);
+document.getElementById('sl-pb').addEventListener('input', update);
+</script>
+</body>
+</html>"""
+ 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
@@ -982,6 +1604,9 @@ def _render_dcf_model(ctx: dict):
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"):
@@ -1027,6 +1652,46 @@ def _render_dcf_model(ctx: dict):
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(
+ '<span class="dcf-eyebrow">Multiples</span>'
+ '<div class="dcf-title">Three relative-valuation lenses</div>'
+ '<div class="dcf-sub">Subject multiple &times; normalized TTM metric, bridged to equity per share.</div>',
+ unsafe_allow_html=True,
+ )
+ st.markdown('<hr class="dcf-divider">', 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(
+ '<div class="dcf-filings-eyebrow">From the filings</div>'
+ f'<div class="dcf-filing-row"><span>EBITDA (TTM)</span><span class="dcf-filing-val">{ebitda_str}</span></div>'
+ f'<div class="dcf-filing-row"><span>Revenue (TTM)</span><span class="dcf-filing-val">{rev_str}</span></div>'
+ f'<div class="dcf-filing-row"><span>Book value / share</span><span class="dcf-filing-val">{bps_str}</span></div>'
+ f'<div class="dcf-filing-row"><span>Net debt</span><span class="dcf-filing-val">{_fmt_b(net_debt_raw)}</span></div>'
+ f'<div class="dcf-filing-row"><span>Shares outstanding</span><span class="dcf-filing-val">{ctx["shares"] / 1e9:.2f} B</span></div>',
+ unsafe_allow_html=True,
+ )
+
+ st.markdown('<hr class="dcf-divider">', 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(
@@ -1372,28 +2037,43 @@ def _render_models(ticker: str):
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 "models_view" not in st.session_state:
+ st.session_state["models_view"] = "dcf"
- 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.")
+ 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:
- for i, (label, render_section) in enumerate(sections):
- if i > 0:
- st.divider()
- with st.expander(label, expanded=(i == 0)):
- render_section(ctx)
+ _render_multiples_model(ctx)
unavailable = []
if not ctx["dcf_available"]:
@@ -1405,7 +2085,7 @@ def _render_models(ticker: str):
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):
+ with st.expander("Why some models are unavailable", expanded=False):
st.markdown("\n".join(unavailable))