diff options
| author | Tyler <tyler@tylerhoang.xyz> | 2026-05-13 23:50:05 -0700 |
|---|---|---|
| committer | Tyler <tyler@tylerhoang.xyz> | 2026-05-13 23:50:05 -0700 |
| commit | a82246e83146d1dd7f565493215c23ff482975e6 (patch) | |
| tree | 9883a45ed69587b57a90cbdc0cdd31845371a31f | |
| parent | 64ea2681ceb403f021d13c39931f67321d11425b (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>
| -rw-r--r-- | app.py | 7 | ||||
| -rw-r--r-- | components/valuation.py | 746 |
2 files changed, 720 insertions, 33 deletions
@@ -166,6 +166,13 @@ button[kind="primary"]:hover, border: none !important; } +[data-testid="stBaseButton-primary"] p, +[data-testid="stBaseButton-primary"] span, +button[kind="primary"] p, +button[kind="primary"] span { + color: var(--brass-ink) !important; +} + button[kind="secondary"] { background: var(--ink-3) !important; color: var(--fg-2) !important; 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> DCF</span>' + f'<span><span style="color:{ev_c}">●</span> EV/EBITDA</span>' + f'<span><span style="color:{rev_c}">●</span> EV/Revenue</span>' + f'<span><span style="color:{pb_color}">{pb_glyph}</span> 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}×" + + 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} 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}% · TG {dcf_tg:.1f}% · {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 — implied per-share</h2> + <p class="lede">Subject multiple × 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 · last</span> + <span class="v num">{_fs(market) if has_market else "—"}</span> + <span class="d num" style="color:var(--fg-3)">{ticker} · {exchange}</span> + </div> + </div> +</section> + +<section class="vm-compare"> + <div class="vm-compare-head"> + <h3>Method comparison</h3> + <span class="units">USD · TTM metrics · 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 — 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 — works for balance-sheet-heavy businesses (banks, insurers, REITs). Limited signal for asset-light software & 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×</span><span>typical 14×–26×</span><span>32×</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×</span><span>typical 6×–13×</span><span>20×</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×</span><span>typical 8×–14×</span><span>60×</span></div> + </div> + </div> + + <div class="vm-grid"> + <div class="vm-row-lbl">× 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 · TTM</span></div> + <div class="vm-cell"><span class="v num">{_fb(revenue) if rv_ok else "—"}</span><span class="cap">Revenue · TTM</span></div> + <div class="vm-cell"><span class="v num">{_fs(book_ps) if pb_ok else "—"}</span><span class="cap">Book value · /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 × metric</span></div> + <div class="vm-cell"><span class="v num" id="rv-ev-val">{_fb(rv_ev0)}</span><span class="cap">multiple × metric</span></div> + <div class="vm-cell faded"><span class="v num dash">—</span><span class="cap">P/B is an equity multiple — no EV step</span></div> + </div> + + <div class="vm-grid"> + <div class="vm-row-lbl">− Net debt</div> + <div class="vm-cell"><span class="v num">{_fb(net_debt)}</span><span class="cap">total {_fb(total_debt)} − cash {_fb(cash)}</span></div> + <div class="vm-cell"><span class="v num">{_fb(net_debt)}</span><span class="cap">total {_fb(total_debt)} − 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 − net debt</span></div> + <div class="vm-cell"><span class="v num" id="rv-eq-val">{_fb(rv_eq0)}</span><span class="cap">EV − 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">÷ 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">→</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 Δ {_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">→</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 Δ {_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">→</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 Δ {_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 · 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)} · 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)} · 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)} · sector {_fx(pb_sector)} · low-signal</span> + </div> + </div> +</section> + +<div class="va-foot"> + <span>Multiples · 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 & sources ↗</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 × 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)) |
