From a82246e83146d1dd7f565493215c23ff482975e6 Mon Sep 17 00:00:00 2001 From: Tyler Date: Wed, 13 May 2026 23:50:05 -0700 Subject: Add Multiples view; fix Recompute button text color MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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

/ with color: var(--brass-ink) to override Streamlit's fg-2 default Co-Authored-By: Claude Sonnet 4.6 --- components/valuation.py | 748 +++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 714 insertions(+), 34 deletions(-) (limited to 'components') 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'

' + f'Applicable' + f' DCF' + f' EV/EBITDA' + f' EV/Revenue' + f'{pb_glyph} P/Book' + f'
', + unsafe_allow_html=True, + ) _DCF_CANVAS_CSS = """ @@ -588,8 +597,110 @@ _DCF_RAIL_CSS = """""" +_MULT_CANVAS_CSS = """ +.vm-body{display:flex;flex-direction:column;gap:24px;padding:24px 32px 48px} + +/* Summary band */ +.vm-summary{background:var(--ink-1);border:1px solid var(--line-1);border-radius:6px;overflow:hidden;display:grid;grid-template-columns:1.4fr 2fr} +.vm-summary-head{padding:24px;display:flex;flex-direction:column;gap:8px;border-right:1px solid var(--line-1)} +.vm-summary-head .eyebrow{font-family:var(--font-sans);font-size:12px;text-transform:uppercase;letter-spacing:.18em;color:var(--fg-3);font-weight:600} +.vm-summary-head .ttl{font-family:var(--font-display);font-size:22px;font-weight:500;color:var(--fg-1);margin:0;line-height:1.2} +.vm-summary-head .lede{font-family:var(--font-sans);font-size:13px;color:var(--fg-2);line-height:1.5;margin:0} +.vm-summary-strip{background:var(--ink-2);display:grid;grid-template-columns:repeat(4,1fr)} +.vm-sum-cell{padding:16px;display:flex;flex-direction:column;gap:4px;border-right:1px solid var(--line-1)} +.vm-sum-cell:last-child{border-right:none} +.vm-sum-cell.market{background:rgba(74,120,181,.05);border-left:1px solid var(--line-2)} +.vm-sum-cell .lbl{font-family:var(--font-sans);font-size:11px;text-transform:uppercase;letter-spacing:.12em;color:var(--fg-3)} +.vm-sum-cell .v{font-family:var(--font-mono);font-size:20px;color:var(--fg-1);font-variant-numeric:tabular-nums} +.vm-sum-cell.market .v{color:var(--fg-2)} +.vm-sum-cell .d{font-family:var(--font-mono);font-size:11px} +.d.pos{color:var(--positive)}.d.neg{color:var(--negative)}.d.na{color:var(--fg-4)} + +/* Comparison card */ +.vm-compare{background:var(--ink-1);border:1px solid var(--line-1);border-radius:6px;overflow:hidden} +.vm-compare-head{padding:16px 24px;border-bottom:1px solid var(--line-1);display:flex;align-items:baseline;gap:12px} +.vm-compare-head h3{font-family:var(--font-display);font-size:20px;font-weight:500;color:var(--fg-1);margin:0} +.vm-compare-head .units{font-family:var(--font-sans);font-size:11px;color:var(--fg-3)} +.vm-grid{display:grid;grid-template-columns:220px 1fr 1fr 1fr;border-bottom:1px solid var(--line-1)} +.vm-grid:last-child{border-bottom:none} +.vm-row-lbl{padding:12px 16px;background:var(--ink-2);border-right:1px solid var(--line-1);font-family:var(--font-sans);font-size:11px;text-transform:uppercase;letter-spacing:.12em;color:var(--fg-3);display:flex;flex-direction:column;gap:4px;align-items:flex-start;justify-content:center} +.vm-row-lbl .sub{font-family:var(--font-sans);font-size:10px;color:var(--fg-4);text-transform:none;letter-spacing:0} +.vm-row-lbl.strong{color:var(--brass);background:rgba(194,170,122,.06)} +.vm-cell{padding:12px 16px;display:flex;flex-direction:column;gap:4px;justify-content:center} +.vm-cell .v{font-family:var(--font-mono);font-size:20px;color:var(--fg-1);font-variant-numeric:tabular-nums} +.vm-cell .v.dash{color:var(--fg-4)} +.vm-cell .cap{font-family:var(--font-sans);font-size:11px;color:var(--fg-3)} +.vm-cell.faded{background:rgba(255,255,255,.005)} +.vm-cell.faded .v{color:var(--fg-4)} +.vm-col-head{padding:16px} +.vm-col-title{display:flex;align-items:center;gap:8px;margin-bottom:8px} +.vm-col-title .n{font-family:var(--font-display);font-style:italic;font-size:16px;color:var(--brass)} +.vm-col-title h4{font-family:var(--font-sans);font-size:14px;font-weight:600;color:var(--fg-1);margin:0} +.vm-col-title .fit{font-family:var(--font-sans);font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.1em;padding:2px 6px;border-radius:2px} +.vm-col-title .fit.ok{color:var(--positive);background:var(--positive-bg)} +.vm-col-title .fit.warn{color:var(--warning);background:var(--warning-bg)} +.vm-col-head .lede{font-family:var(--font-sans);font-size:12px;color:var(--fg-3);line-height:1.5;margin:0} +.vm-grid.result .vm-row-lbl{color:var(--brass);background:rgba(194,170,122,.06)} +.vm-grid.result .vm-cell{background:rgba(194,170,122,.04)} +.vm-grid.result .vm-cell .v{font-size:28px;color:var(--brass-bright)} +.vm-grid.result .vm-cell .delta{font-family:var(--font-mono);font-size:12px} +.delta.pos{color:var(--positive)}.delta.neg{color:var(--negative)}.delta.na{color:var(--fg-4)} + +/* Subject multiple slider */ +.vm-cell.mult{gap:6px} +.mult-top{display:flex;align-items:baseline;gap:8px} +.mult-top .big{font-family:var(--font-mono);font-size:24px;color:var(--brass-bright);font-variant-numeric:tabular-nums} +.mult-top .sector{font-family:var(--font-mono);font-size:11px;color:var(--fg-3)} +.mult-slider{position:relative;height:18px;margin:2px 0} +.mult-slider .track{position:absolute;inset:7px 0;background:var(--ink-3);border-radius:999px;pointer-events:none} +.mult-slider .track .band{position:absolute;inset:0;background:rgba(74,120,181,.18)} +.mult-slider .track .marker{position:absolute;top:-3px;bottom:-3px;width:2px;background:var(--oxford-light);border-radius:1px} +.mult-slider input[type=range]{position:absolute;inset:0;width:100%;height:18px;background:transparent;-webkit-appearance:none;appearance:none;cursor:pointer;outline:none} +.mult-slider input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:14px;height:14px;border-radius:50%;background:var(--brass);border:2px solid #0B0E13;box-shadow:0 0 0 1px var(--brass-deep);cursor:pointer} +.mult-slider input[type=range]::-moz-range-thumb{width:14px;height:14px;border-radius:50%;background:var(--brass);border:2px solid #0B0E13;box-shadow:0 0 0 1px var(--brass-deep);cursor:pointer;border:none} +.mult-slider input[type=range]::-webkit-slider-runnable-track{background:transparent} +.mult-slider input[type=range]::-moz-range-track{background:transparent;height:4px} +.mult-meta{display:flex;justify-content:space-between;font-family:var(--font-mono);font-size:10px;color:var(--fg-4)} + +/* Sensitivity strip */ +.vm-sensitivity{background:var(--ink-1);border:1px solid var(--line-1);border-radius:6px;overflow:hidden} +.vm-sensitivity-head{padding:16px 24px;border-bottom:1px solid var(--line-1);display:flex;align-items:baseline;gap:12px} +.vm-sensitivity-head h3{font-family:var(--font-display);font-size:20px;font-weight:500;color:var(--fg-1);margin:0} +.vm-sensitivity-head .hint{font-family:var(--font-sans);font-size:11px;color:var(--fg-3)} +.vm-sens-grid{display:grid;grid-template-columns:repeat(3,1fr)} +.vm-sens-cell{padding:16px;border-right:1px solid var(--line-1)} +.vm-sens-cell:last-child{border-right:none} +.vm-sens-cell>.lbl{font-family:var(--font-sans);font-size:11px;text-transform:uppercase;letter-spacing:.12em;color:var(--fg-3);display:block;margin-bottom:10px} +.vm-sens-row{display:grid;grid-template-columns:1fr auto 1fr;gap:8px;align-items:center;margin-bottom:10px;padding-bottom:10px;border-bottom:1px solid var(--line-1)} +.vm-sens-row .col{display:flex;flex-direction:column;gap:2px} +.vm-sens-row .col .sub{font-family:var(--font-mono);font-size:10px;color:var(--fg-3)} +.vm-sens-row .col .v{font-family:var(--font-mono);font-size:18px;color:var(--fg-1);font-variant-numeric:tabular-nums} +.vm-sens-row .col .v.brass{color:var(--brass-bright)} +.vm-sens-row .col .d{font-family:var(--font-mono);font-size:11px} +.vm-sens-row .arrow{font-family:var(--font-display);font-style:italic;font-size:20px;color:var(--fg-4);text-align:center} +.vm-sens-cell>.meta{font-family:var(--font-sans);font-size:11px;color:var(--fg-3)} +.vm-sens-cell>.meta .num{font-family:var(--font-mono);color:var(--fg-2)} + +/* Cross-check vs DCF */ +.vm-cx{background:var(--ink-1);border:1px solid var(--line-1);border-radius:6px;overflow:hidden} +.vm-cx-head{padding:16px 24px;border-bottom:1px solid var(--line-1);display:flex;align-items:baseline;gap:12px} +.vm-cx-head h3{font-family:var(--font-display);font-size:20px;font-weight:500;color:var(--fg-1);margin:0} +.vm-cx-head .hint{font-family:var(--font-sans);font-size:11px;color:var(--fg-3)} +.vm-cx-grid{display:grid;grid-template-columns:1.2fr 1fr 1fr 1fr} +.vm-cx-cell{padding:16px 24px;border-right:1px solid var(--line-1);display:flex;flex-direction:column;gap:4px} +.vm-cx-cell:last-child{border-right:none} +.vm-cx-cell .lbl{font-family:var(--font-sans);font-size:11px;text-transform:uppercase;letter-spacing:.12em;color:var(--fg-3)} +.vm-cx-cell .v{font-family:var(--font-mono);font-size:24px;color:var(--fg-1);font-variant-numeric:tabular-nums} +.vm-cx-cell .delta{font-family:var(--font-mono);font-size:12px} +.vm-cx-cell .meta{font-family:var(--font-sans);font-size:11px;color:var(--fg-4);margin-top:4px} +.vm-cx-cell.dcf{background:rgba(194,170,122,.05)} +.vm-cx-cell.dcf .lbl{color:var(--brass-deep)} +.vm-cx-cell.dcf .v{color:var(--brass-bright)} +""" + def _fmt_b(v_dollars: float) -> str: b = v_dollars / 1e9 @@ -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'' + cls = "pos" if d >= 0 else "neg" + arr = "▲" if d >= 0 else "▼" + sign = "+" if d >= 0 else "" + market_str = _fs(market) + return f'{arr} {sign}{d:.1f}% vs {market_str}' + + def _ds_span(val, id_attr=""): + d = _dpct(val) + if d is None: + return f'' + cls = "pos" if d >= 0 else "neg" + arr = "▲" if d >= 0 else "▼" + sign = "+" if d >= 0 else "" + return f'{arr} {sign}{d:.1f}%' + + # Initial computed values + if eb_ok: + eb_ev0 = eb_init * ebitda + eb_eq0 = eb_ev0 - net_debt + eb_per0 = eb_eq0 / shares + else: + eb_ev0 = eb_eq0 = eb_per0 = None + + if rv_ok: + rv_ev0 = rv_init * revenue + rv_eq0 = rv_ev0 - net_debt + rv_per0 = rv_eq0 / shares + else: + rv_ev0 = rv_eq0 = rv_per0 = None + + pb_per0 = pb_init * book_ps if pb_ok else None + + # Sector reference values (static) + sec_eb = (eb_sector * ebitda - net_debt) / shares if eb_ok else None + sec_rv = (rv_sector * revenue - net_debt) / shares if rv_ok else None + sec_pb = pb_sector * book_ps if pb_ok else None + + # Slider CSS % positions + def _pct(v, lo, hi): + return (v - lo) / (hi - lo) * 100 + + eb_s_pct = _pct(eb_sector, 8, 32) + eb_bl_pct = _pct(14, 8, 32) + eb_bh_pct = _pct(26, 8, 32) + rv_s_pct = _pct(rv_sector, 4, 20) + rv_bl_pct = _pct(6, 4, 20) + rv_bh_pct = _pct(13, 4, 20) + pb_s_pct = _pct(pb_sector, 4, 60) + pb_bl_pct = _pct(8, 4, 60) + pb_bh_pct = _pct(14, 4, 60) + + shares_str = f"{shares / 1e9:.2f} B" if shares > 0 else "—" + + # P/Book fit badge depends on whether company is financial + pb_fit_cls = "ok" if ctx.get("is_financial") else "warn" + pb_fit_lbl = "Strong fit" if ctx.get("is_financial") else "Limited fit" + + # Sensitivity re-rating strings (static sector side) + def _rr(subj_per, sect_per): + if subj_per is None or sect_per is None or subj_per == 0: + return "—" + rr = (sect_per - subj_per) / abs(subj_per) * 100 + sign = "+" if rr >= 0 else "" + cls = "pos" if rr >= 0 else "neg" + return f'{sign}{rr:.1f}%' + + # DCF cross-check cell + if dcf_iv is not None: + dcf_d = _dpct(float(dcf_iv)) + if dcf_d is not None: + dcf_cls = "pos" if dcf_d >= 0 else "neg" + dcf_arr = "▲" if dcf_d >= 0 else "▼" + dcf_sign = "+" if dcf_d >= 0 else "" + dcf_delta_html = f'{dcf_arr} {dcf_sign}{dcf_d:.1f}% vs market' + else: + dcf_delta_html = '' + dcf_val_str = _fs(float(dcf_iv)) + dcf_meta_str = f"WACC {dcf_wacc:.1f}% · TG {dcf_tg:.1f}% · {dcf_yrs}-yr explicit" + else: + dcf_delta_html = 'Run DCF tab first' + dcf_val_str = "—" + dcf_meta_str = "Switch to DCF tab to compute" + + ticker = ctx["ticker"] + exchange = (ctx.get("info") or {}).get("exchange") or "—" + + data_json = json.dumps({ + "market": market, "shares": shares, "netDebt": net_debt, + "totalDebt": total_debt, "cash": cash, + "ebitda": ebitda, "revenue": revenue, "bookPs": book_ps, + "ebOk": eb_ok, "rvOk": rv_ok, "pbOk": pb_ok, "hasMarket": has_market, + "ebSector": eb_sector, "rvSector": rv_sector, "pbSector": pb_sector, + }) + + html = f""" + + + + + + +
+ +
+
+ Multiples +

Three relative-valuation lenses — implied per-share

+

Subject multiple × normalized TTM metric, bridged to equity per share. Compare across columns to see which lens the market is leaning on.

+
+
+
+ EV / EBITDA + {_fs(eb_per0)} + {_ds_span(eb_per0, 'id="sum-eb-d"')} +
+
+ EV / Revenue + {_fs(rv_per0)} + {_ds_span(rv_per0, 'id="sum-rv-d"')} +
+
+ P / Book + {_fs(pb_per0)} + {_ds_span(pb_per0, 'id="sum-pb-d"')} +
+
+ Market · last + {_fs(market) if has_market else "—"} + {ticker} · {exchange} +
+
+
+ +
+
+

Method comparison

+ USD · TTM metrics · balance-sheet bridge +
+ +
+
Method
+
+
I

EV / EBITDA

Strong fit
+

Enterprise value normalized by operating cash profit. Strips depreciation and capital structure so different capex profiles compare cleanly.

+
+
+
II

EV / Revenue

Strong fit
+

Topline multiple — useful when margins are volatile or the business is reinvesting through profitability. Less sensitive to one-off charges.

+
+
+
III

P / Book

{pb_fit_lbl}
+

Equity multiple — works for balance-sheet-heavy businesses (banks, insurers, REITs). Limited signal for asset-light software & services.

+
+
+ +
+
Subject multipledrag to flex the lens
+
+
{_fx(eb_init)}sector {_fx(eb_sector)}
+
+
+ + +
+ +
+
typical 14×–26×32×
+
+
+
{_fx(rv_init)}sector {_fx(rv_sector)}
+
+
+ + +
+ +
+
typical 6×–13×20×
+
+
+
{_fx(pb_init)}sector {_fx(pb_sector)}
+
+
+ + +
+ +
+
typical 8×–14×60×
+
+
+ +
+
× Normalized metricfrom TTM filings
+
{_fb(ebitda) if eb_ok else "—"}EBITDA · TTM
+
{_fb(revenue) if rv_ok else "—"}Revenue · TTM
+
{_fs(book_ps) if pb_ok else "—"}Book value · /share
+
+ +
+
= Enterprise value
+
{_fb(eb_ev0)}multiple × metric
+
{_fb(rv_ev0)}multiple × metric
+
P/B is an equity multiple — no EV step
+
+ +
+
− Net debt
+
{_fb(net_debt)}total {_fb(total_debt)} − cash {_fb(cash)}
+
{_fb(net_debt)}total {_fb(total_debt)} − cash {_fb(cash)}
+
+
+ +
+
= Equity value
+
{_fb(eb_eq0)}EV − net debt
+
{_fb(rv_eq0)}EV − net debt
+
+
+ +
+
÷ Shares outstanding
+
{shares_str}diluted
+
{shares_str}diluted
+
+
+ +
+
= Implied per share
+
+ {_fs(eb_per0)} + {_d_span(eb_per0, 'id="eb-per-d"')} +
+
+ {_fs(rv_per0)} + {_d_span(rv_per0, 'id="rv-per-d"')} +
+
+ {_fs(pb_per0)} + {_d_span(pb_per0, 'id="pb-per-d"')} +
+
+
+ +
+
+

If the lens shifted to sector

+ Same metrics, subject multiple replaced by sector median +
+
+ +
+ EV / EBITDA +
+
+ At subject {_fx(eb_init)} + {_fs(eb_per0)} + {_ds_span(eb_per0, 'id="sens-eb-subj-d"')} +
+ +
+ At sector {_fx(eb_sector)} + {_fs(sec_eb)} + {_ds_span(sec_eb)} +
+
+ Re-rating Δ {_rr(eb_per0, sec_eb)} per share if the subject converged to peers +
+ +
+ EV / Revenue +
+
+ At subject {_fx(rv_init)} + {_fs(rv_per0)} + {_ds_span(rv_per0, 'id="sens-rv-subj-d"')} +
+ +
+ At sector {_fx(rv_sector)} + {_fs(sec_rv)} + {_ds_span(sec_rv)} +
+
+ Re-rating Δ {_rr(rv_per0, sec_rv)} per share if the subject converged to peers +
+ +
+ P / Book +
+
+ At subject {_fx(pb_init)} + {_fs(pb_per0)} + {_ds_span(pb_per0, 'id="sens-pb-subj-d"')} +
+ +
+ At sector {_fx(pb_sector)} + {_fs(sec_pb)} + {_ds_span(sec_pb)} +
+
+ Re-rating Δ {_rr(pb_per0, sec_pb)} per share if the subject converged to peers +
+ +
+
+ +
+
+

Cross-check against DCF

+ DCF intrinsic from the firm-value model on the previous tab +
+
+
+ DCF · firm value + {dcf_val_str} + {dcf_delta_html} + {dcf_meta_str} +
+
+ EV / EBITDA + {_fs(eb_per0)} + {_d_span(eb_per0, 'id="cx-eb-d"')} + Subject {_fx(eb_init)} · sector {_fx(eb_sector)} +
+
+ EV / Revenue + {_fs(rv_per0)} + {_d_span(rv_per0, 'id="cx-rv-d"')} + Subject {_fx(rv_init)} · sector {_fx(rv_sector)} +
+
+ P / Book + {_fs(pb_per0)} + {_d_span(pb_per0, 'id="cx-pb-d"')} + Subject {_fx(pb_init)} · sector {_fx(pb_sector)} · low-signal +
+
+
+ +
+ 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. + Methodology & sources ↗ +
+ +
+ + +""" + return html + + def _render_dcf_model(ctx: dict): hist_growth_raw = ctx["hist_growth_raw"] hist_growth_raw_pct = hist_growth_raw * 100 if hist_growth_raw is not None else -5.0 @@ -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( + 'Multiples' + '
Three relative-valuation lenses
' + '
Subject multiple × normalized TTM metric, bridged to equity per share.
', + unsafe_allow_html=True, + ) + st.markdown('
', unsafe_allow_html=True) + + net_debt_raw = ctx["total_debt"] - ctx["cash_and_equivalents"] + ebitda_str = _fmt_b(ctx["ebitda"]) if ctx.get("ebitda") and ctx["ebitda"] > 0 else "—" + rev_str = _fmt_b(ctx["revenue_ttm"]) if ctx.get("revenue_ttm") and ctx["revenue_ttm"] > 0 else "—" + bps_str = f"${ctx['book_value_per_share']:.2f}" if ctx.get("book_value_per_share") and ctx["book_value_per_share"] > 0 else "—" + + st.markdown( + '
From the filings
' + f'
EBITDA (TTM){ebitda_str}
' + f'
Revenue (TTM){rev_str}
' + f'
Book value / share{bps_str}
' + f'
Net debt{_fmt_b(net_debt_raw)}
' + f'
Shares outstanding{ctx["shares"] / 1e9:.2f} B
', + unsafe_allow_html=True, + ) + + st.markdown('
', unsafe_allow_html=True) + + if st.button("Refresh data", key=f"mult_refresh_{ctx['ticker']}", type="primary", use_container_width=True): + get_balance_sheet_bridge_items.clear() + st.rerun() + + canvas_html = _build_multiples_canvas_html(ctx) + with canvas_col: + components.html(canvas_html, height=1620, scrolling=False) + + def _render_ev_ebitda_model(ctx: dict): st.markdown("**EV/EBITDA Valuation**") st.caption( @@ -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 not sections: - st.info("No valuation model is currently applicable for this company.") - st.caption("Use comps, ratios, earnings history, and analyst targets instead.") + if "models_view" not in st.session_state: + st.session_state["models_view"] = "dcf" + + st.markdown(_DCF_RAIL_CSS, unsafe_allow_html=True) + + _pc1, _pc2 = st.columns(2) + with _pc1: + if st.button( + "Discounted Cash Flow", + key=f"pick_dcf_{ticker}", + type="primary" if st.session_state["models_view"] == "dcf" else "secondary", + use_container_width=True, + ): + st.session_state["models_view"] = "dcf" + st.rerun() + with _pc2: + if st.button( + "Multiples", + key=f"pick_mult_{ticker}", + type="primary" if st.session_state["models_view"] == "multiples" else "secondary", + use_container_width=True, + ): + st.session_state["models_view"] = "multiples" + st.rerun() + + st.markdown("---") + + view = st.session_state.get("models_view", "dcf") + if view == "dcf": + if ctx["dcf_available"]: + _render_dcf_model(ctx) + else: + st.warning(f"DCF model not available: {ctx['dcf_reason']}") + if st.expander("Show available alternatives", expanded=True): + _render_multiples_model(ctx) else: - 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)) -- cgit v1.3-2-g0d8e