From 64ea2681ceb403f021d13c39931f67321d11425b Mon Sep 17 00:00:00 2001 From: Tyler Date: Wed, 13 May 2026 23:22:55 -0700 Subject: Redesign DCF tab — inspector rail layout with HTML canvas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the flat column layout with a two-column inspector design: left rail holds four st.slider inputs + "From the filings" reference panel + Reset/Recompute actions; right canvas renders verdict hero, projection card (Plotly.js bar chart + cash-flow table), EV bridge, per-share recon, and cross-check via a single components.v1.html block. Also fixes primary button text color app-wide by targeting the modern Streamlit selector (stBaseButton-primary) alongside the legacy one. Co-Authored-By: Claude Sonnet 4.6 --- app.py | 3 + components/valuation.py | 725 ++++++++++++++++++++++++++++++++++-------------- 2 files changed, 527 insertions(+), 201 deletions(-) diff --git a/app.py b/app.py index d5e082a..243bdcf 100644 --- a/app.py +++ b/app.py @@ -144,6 +144,7 @@ html, body, [class*="css"] { /* ── Buttons ────────────────────────────────────────────────────────────── */ button[kind="primary"], +[data-testid="stBaseButton-primary"], [data-testid="stFormSubmitButton"] button { background: var(--brass) !important; color: var(--brass-ink) !important; @@ -158,8 +159,10 @@ button[kind="primary"], } button[kind="primary"]:hover, +[data-testid="stBaseButton-primary"]:hover, [data-testid="stFormSubmitButton"] button:hover { background: var(--brass-bright) !important; + color: var(--brass-ink) !important; border: none !important; } diff --git a/components/valuation.py b/components/valuation.py index a141846..e2e4338 100644 --- a/components/valuation.py +++ b/components/valuation.py @@ -1,7 +1,9 @@ """Valuation panel — key ratios, models, comparable companies, analyst targets, earnings history.""" +import json import pandas as pd import plotly.graph_objects as go import streamlit as st +import streamlit.components.v1 as components from services.data_service import ( get_company_info, get_latest_price, @@ -472,67 +474,498 @@ def _render_model_availability(ctx: dict): col.write(reason) -def _render_dcf_model(ctx: dict): - st.markdown("**Discounted Cash Flow (DCF)**") +_DCF_CANVAS_CSS = """ +*,*::before,*::after{box-sizing:border-box} +:root{ + --ink-0:#0B0E13;--ink-1:#11151C;--ink-2:#181D26;--ink-3:#222934; + --line-1:#232934;--line-2:#2E3645; + --fg-1:#F2ECDC;--fg-2:#C7C0AE;--fg-3:#8E8676;--fg-4:#5E5849; + --brass:#C2AA7A;--brass-bright:#DCC79E;--brass-deep:#8F7A50; + --oxford:#1F3B5E;--oxford-light:#243E5A; + --positive:#4F8C5E;--positive-bg:#15241A; + --negative:#B5494B;--negative-bg:#2A1517; + --font-display:'EB Garamond',Georgia,serif; + --font-sans:'IBM Plex Sans',system-ui,sans-serif; + --font-mono:'IBM Plex Mono',monospace; +} +body{margin:0;padding:0;background:transparent;font-family:var(--font-sans);color:var(--fg-2);-webkit-font-smoothing:antialiased} +.num{font-family:var(--font-mono);font-variant-numeric:tabular-nums} +.va-canvas{display:flex;flex-direction:column;gap:24px;padding-bottom:32px} + +/* Verdict */ +.va-verdict{background:var(--ink-1);border:1px solid var(--line-1);border-radius:6px;position:relative;overflow:hidden;box-shadow:0 8px 24px -8px rgba(0,0,0,.5)} +.va-verdict .top{display:grid;grid-template-columns:1fr auto 1fr;gap:48px;align-items:center;padding:32px 48px;position:relative;z-index:1} +.va-verdict .col{display:flex;flex-direction:column;gap:6px} +.va-verdict .lbl{font-family:var(--font-sans);font-size:12px;text-transform:uppercase;letter-spacing:.18em;color:var(--fg-3)} +.va-verdict .big{font-family:var(--font-mono);font-variant-numeric:tabular-nums;font-size:56px;font-weight:500;color:var(--fg-1);line-height:.95;letter-spacing:-.02em} +.va-verdict .big.market{color:var(--fg-2)} +.va-verdict .sub{font-family:var(--font-sans);font-size:13px;color:var(--fg-3)} +.va-verdict .arrow{font-family:var(--font-display);font-size:32px;color:var(--fg-4);font-style:italic;font-weight:400;text-align:center} +.va-verdict .pill{display:inline-flex;align-items:center;gap:6px;font-family:var(--font-mono);font-size:13px;padding:4px 10px;border-radius:2px;align-self:flex-start;margin-top:4px} +.va-verdict .pill.neg{color:var(--negative);background:var(--negative-bg);border:1px solid rgba(181,73,75,.35)} +.va-verdict .pill.pos{color:var(--positive);background:var(--positive-bg);border:1px solid rgba(79,140,94,.35)} +.va-verdict .band{display:flex;align-items:baseline;justify-content:space-between;border-top:1px solid var(--line-1);padding:12px 48px;font-family:var(--font-sans);font-size:13px;color:var(--fg-2);position:relative;z-index:1;background:var(--ink-1)} +.va-verdict .band .reading{font-family:var(--font-display);font-style:italic;font-size:20px;color:var(--fg-1)} +.va-verdict .band .mono{font-family:var(--font-mono);font-variant-numeric:tabular-nums;color:var(--fg-1)} + +/* Projection */ +.va-projection{background:var(--ink-1);border:1px solid var(--line-1);border-radius:6px;overflow:hidden} +.va-projection .head{padding:16px 24px;border-bottom:1px solid var(--line-1);display:flex;justify-content:space-between;align-items:baseline} +.va-projection .head h3{font-family:var(--font-display);font-size:20px;font-weight:500;color:var(--fg-1);margin:0} +.va-projection .head .units{font-family:var(--font-mono);font-size:12px;color:var(--fg-3)} +.va-cf-table{width:100%;border-collapse:collapse;border-top:1px solid var(--line-1)} +.va-cf-table th,.va-cf-table td{padding:8px 14px;text-align:right;font-family:var(--font-mono);font-variant-numeric:tabular-nums;font-size:12px;border-bottom:1px solid var(--line-1)} +.va-cf-table th{font-family:var(--font-sans);text-transform:uppercase;font-size:11px;letter-spacing:.08em;color:var(--fg-3);font-weight:600;background:var(--ink-2)} +.va-cf-table th:first-child,.va-cf-table td:first-child{text-align:left;color:var(--fg-2);font-size:12px} +.va-cf-table td.brass{color:var(--brass-bright)} +.va-cf-table tr:last-child td{border-bottom:none} +.va-cf-table tr.total td{border-top:1px solid var(--line-2);font-weight:600;color:var(--fg-1);background:var(--ink-2)} + +/* Bridge */ +.va-bridge{background:var(--ink-1);border:1px solid var(--line-1);border-radius:6px;padding:24px;display:flex;flex-direction:column;gap:16px} +.va-bridge .bhead{display:flex;justify-content:space-between;align-items:baseline} +.va-bridge .bhead h3{font-family:var(--font-display);font-size:20px;font-weight:500;color:var(--fg-1);margin:0} +.va-bridge .bhead .bdate{font-family:var(--font-mono);font-size:12px;color:var(--fg-3)} +.va-bridge .flow{display:grid;grid-template-columns:1fr auto 1fr auto 1fr auto 1fr;align-items:stretch;gap:12px} +.va-bridge .node{display:flex;flex-direction:column;gap:4px;padding:12px 16px;background:var(--ink-2);border:1px solid var(--line-2);border-radius:4px;min-height:80px;justify-content:center} +.va-bridge .node.start{border-color:var(--oxford);background:rgba(74,120,181,.06)} +.va-bridge .node.result{border-color:rgba(194,170,122,.4);background:rgba(194,170,122,.06)} +.va-bridge .node .lbl{font-family:var(--font-sans);font-size:11px;text-transform:uppercase;letter-spacing:.18em;color:var(--fg-3)} +.va-bridge .node .v{font-family:var(--font-mono);font-variant-numeric:tabular-nums;font-size:20px;color:var(--fg-1)} +.va-bridge .node.result .v{color:var(--brass-bright)} +.va-bridge .op{display:flex;flex-direction:column;align-items:center;justify-content:center;font-family:var(--font-mono);font-size:16px;color:var(--fg-3);min-width:20px} +.va-bridge .op .sub{font-family:var(--font-sans);font-size:10px;color:var(--fg-4);text-transform:uppercase;letter-spacing:.18em;margin-top:6px} +.va-bridge .bfoot{font-family:var(--font-sans);font-size:12px;color:var(--fg-3);display:flex;gap:12px;flex-wrap:wrap} + +/* Recon */ +.va-recon{display:grid;grid-template-columns:1.4fr 1fr 1fr 1fr;background:var(--ink-1);border:1px solid var(--line-1);border-radius:6px;overflow:hidden} +.va-recon .cell{padding:16px 24px;border-right:1px solid var(--line-1);display:flex;flex-direction:column;gap:4px} +.va-recon .cell:last-child{border-right:none} +.va-recon .cell .lbl{font-family:var(--font-sans);font-size:11px;text-transform:uppercase;letter-spacing:.18em;color:var(--fg-3);font-weight:600} +.va-recon .cell .v{font-family:var(--font-mono);font-variant-numeric:tabular-nums;font-size:28px;color:var(--fg-1);font-weight:500;line-height:1} +.va-recon .cell .sub{font-family:var(--font-mono);font-size:11px;color:var(--fg-3)} +.va-recon .cell.intrinsic .v{color:var(--brass-bright)} + +/* Cross-check */ +.va-cx{background:var(--ink-1);border:1px solid var(--line-1);border-radius:6px;overflow:hidden} +.va-cx-head{padding:16px 24px;border-bottom:1px solid var(--line-1);display:flex;justify-content:space-between;align-items:baseline} +.va-cx-head h3{font-family:var(--font-display);font-size:20px;font-weight:500;color:var(--fg-1);margin:0} +.va-cx-head .hint{font-family:var(--font-mono);font-size:12px;color:var(--fg-3)} +.va-cx-grid{display:grid;grid-template-columns:1.2fr 1fr 1fr 1fr} +.va-cx-cell{padding:16px 24px;border-right:1px solid var(--line-1);display:flex;flex-direction:column;gap:6px} +.va-cx-cell:last-child{border-right:none} +.va-cx-cell .lbl{font-family:var(--font-sans);font-size:11px;text-transform:uppercase;letter-spacing:.18em;color:var(--fg-3);font-weight:600} +.va-cx-cell .v{font-family:var(--font-mono);font-variant-numeric:tabular-nums;font-size:26px;color:var(--fg-1);font-weight:500;line-height:1} +.va-cx-cell.dcf{background:rgba(194,170,122,.05)} +.va-cx-cell.dcf .v{color:var(--brass-bright)} +.va-cx-cell.dcf .lbl{color:var(--brass)} +.va-cx-cell .delta{font-family:var(--font-mono);font-variant-numeric:tabular-nums;font-size:12px} +.va-cx-cell .delta.neg{color:var(--negative)} +.va-cx-cell .delta.pos{color:var(--positive)} +.va-cx-cell .delta.na{color:var(--fg-4)} +.va-cx-cell .meta{font-family:var(--font-sans);font-size:11px;color:var(--fg-3);border-top:1px solid var(--line-1);padding-top:6px;margin-top:auto;line-height:1.4} + +/* Footer */ +.va-foot{font-family:var(--font-sans);font-size:12px;color:var(--fg-3);line-height:1.6;padding:12px 20px;border:1px solid var(--line-1);border-radius:4px;background:var(--ink-1);display:flex;justify-content:space-between;align-items:center;gap:24px} +.va-foot a{color:var(--brass-bright);text-decoration:none;white-space:nowrap;flex-shrink:0} +.va-foot a:hover{color:var(--brass)} +""" + +_DCF_RAIL_CSS = """""" + + +def _fmt_b(v_dollars: float) -> str: + b = v_dollars / 1e9 + if abs(b) >= 1000: + return f"${b / 1000:.2f}T" + return f"${b:.2f}B" + + +def _build_dcf_canvas_html( + ctx: dict, + result: dict, + wacc_pct: float, + tg_pct: float, + yrs: int, + g_pct: float, + ev_ebitda_price: float | None, + ev_rev_price: float | None, + pb_price: float | None, +) -> str: + iv = result["intrinsic_value_per_share"] + market = float(ctx["current_price"] or 0) + has_market = market > 0 + + upside_pct = (iv - market) / market * 100 if has_market else 0.0 + is_pos = upside_pct >= 0 + gap = iv - market + + # Bridge + ev_b = _fmt_b(result["enterprise_value"]) + net_debt_b = _fmt_b(abs(result["net_debt"])) + other_claims_b = _fmt_b(ctx["preferred_equity"] + ctx["minority_interest"]) + equity_b = _fmt_b(result["equity_value"]) + total_debt_b = _fmt_b(ctx["total_debt"]) + cash_b = _fmt_b(ctx["cash_and_equivalents"]) + other_b_val = ctx["preferred_equity"] + ctx["minority_interest"] + + shares_b = ctx["shares"] / 1e9 + source_date = ctx["bridge_items"].get("source_date", "") + + # Forecast sequences (capped at yrs) + discounted = result["discounted_fcfs"][:yrs] + projected = result["projected_fcfs"][:yrs] + tv_pv = result["terminal_value_pv"] + terminal_fcf = projected[-1] * (1 + tg_pct / 100) if projected else 0.0 + disc_factors = [1.0 / (1 + wacc_pct / 100) ** (i + 1) for i in range(len(discounted))] + disc_tv_factor = 1.0 / (1 + wacc_pct / 100) ** yrs + + # Plotly chart data + bar_x = [f"Year {i + 1}" for i in range(len(discounted))] + ["Terminal"] + bar_y = [v / 1e9 for v in discounted] + [tv_pv / 1e9] + bar_colors = ["#243E5A"] * len(discounted) + ["#C2AA7A"] + bar_line_colors = ["#1F3B5E"] * len(discounted) + ["#DCC79E"] + bar_text = [_fmt_b(v) for v in discounted] + [_fmt_b(tv_pv)] + + plotly_data = json.dumps([{ + "type": "bar", + "x": bar_x, + "y": bar_y, + "marker": {"color": bar_colors, "line": {"color": bar_line_colors, "width": 1}}, + "text": bar_text, + "textposition": "outside", + "textfont": {"family": "IBM Plex Mono", "size": 10, "color": "#C7C0AE"}, + "hovertemplate": "%{x}: %{text}", + "cliponaxis": False, + }]) + plotly_layout = json.dumps({ + "paper_bgcolor": "#11151C", + "plot_bgcolor": "#11151C", + "margin": {"l": 48, "r": 8, "t": 28, "b": 36}, + "xaxis": { + "gridcolor": "rgba(0,0,0,0)", + "linecolor": "#232934", + "tickfont": {"family": "IBM Plex Sans", "size": 11, "color": "#8E8676"}, + "fixedrange": True, + }, + "yaxis": { + "gridcolor": "#232934", + "linecolor": "rgba(0,0,0,0)", + "tickfont": {"family": "IBM Plex Mono", "size": 10, "color": "#8E8676"}, + "tickprefix": "$", + "ticksuffix": "B", + "fixedrange": True, + "zeroline": False, + }, + "bargap": 0.35, + "showlegend": False, + "uniformtext": {"mode": "hide", "minsize": 8}, + }) + + # Verdict + verdict_gradient = ( + "linear-gradient(110deg,transparent 35%,rgba(79,140,94,.07) 100%)" + if is_pos else + "linear-gradient(110deg,transparent 35%,rgba(181,73,75,.07) 100%)" + ) + pill_cls = "pos" if is_pos else "neg" + pill_arrow = "▲" if is_pos else "▼" + pill_sign = "+" if is_pos else "−" + pill_text = f"{pill_arrow} {pill_sign}{abs(upside_pct):.1f}% {'upside' if is_pos else 'downside'}" + reading = "Constructive" if is_pos else "Cautious" + gap_dir = "above" if gap >= 0 else "below" + + iv_str = f"${iv:,.2f}" + market_str = f"${market:,.2f}" if has_market else "—" + gap_str = f"${abs(gap):,.2f}" + + # Cash-flow table + n = len(discounted) + hdr_cells = "".join(f"Yr {i + 1}" for i in range(n)) + "Terminal" + fcf_cells = "".join(f"{_fmt_b(v)}" for v in projected) + fcf_cells += f'{_fmt_b(terminal_fcf)}' + df_cells = "".join(f"{disc_factors[i]:.3f}" for i in range(n)) + df_cells += f"{disc_tv_factor:.3f}" + pv_cells = "".join(f"{_fmt_b(v)}" for v in discounted) + pv_cells += f'{_fmt_b(tv_pv)}' + + # Cross-check cells + def cx_cell(cls, lbl, val_str, delta_pct, meta): + if delta_pct is not None and has_market: + dcls = "pos" if delta_pct >= 0 else "neg" + dsign = "+" if delta_pct >= 0 else "" + dhtml = f'{dsign}{delta_pct:.1f}% vs market' + else: + dhtml = '' + return ( + f'
' + f'{lbl}' + f'{val_str}' + f"{dhtml}" + f'{meta}' + f"
" + ) - hist_growth = ctx["hist_growth"] - hist_growth_raw = ctx["hist_growth_raw"] - hist_growth_pct = hist_growth * 100 if hist_growth is not None else 5.0 - hist_growth_raw_pct = hist_growth_raw * 100 if hist_growth_raw is not None else hist_growth_pct - slider_default = float(max(-20.0, min(30.0, hist_growth_pct))) + dcf_delta = upside_pct if has_market else None + cx_dcf = cx_cell( + "va-cx-cell dcf", "DCF · THIS MODEL", iv_str, dcf_delta, + f"Firm-value DCF · {yrs}-yr explicit · WACC {wacc_pct:.1f}%", + ) - st.caption( - "Firm-value DCF works best for operating companies with positive, reasonably stable free cash flow." + def _cx_multiple_cell(label, implied, market_multiple, mult_label): + if implied is not None and has_market: + delta = (implied - market) / market * 100 + val = f"${implied:,.2f}" + meta = f"Market multiple {market_multiple:.1f}× · {mult_label}" if market_multiple else mult_label + else: + delta = None + val = "—" + meta = "Unavailable for this company" + return cx_cell("va-cx-cell", label, val, delta, meta) + + cx_ev = _cx_multiple_cell( + "EV / EBITDA", ev_ebitda_price, + ctx.get("ev_ebitda_current") or 0, "based on current market multiple", + ) + cx_rev = _cx_multiple_cell( + "EV / REVENUE", ev_rev_price, + ctx.get("ev_revenue_current") or 0, "based on current market multiple", + ) + cx_pb = _cx_multiple_cell( + "P / BOOK", pb_price, + ctx.get("pb_current") or 0, "based on current market multiple", ) - col1, col2, col3, col4 = st.columns(4) - with col1: - wacc = st.slider( + # Recon gap cell color + gap_color = "var(--positive)" if gap >= 0 else "var(--negative)" + gap_sign = "+" if gap >= 0 else "" + gap_display = f"{gap_sign}${gap:,.2f}" if has_market else "—" + gap_pct_str = f"{upside_pct:.1f}% vs market" if has_market else "—" + + html = f""" + + + + + + + + + + +
+ +
+
+
+
+ DCF Intrinsic Value + {iv_str} + per share · firm value method · {yrs}-yr horizon +
+ vs +
+ Market Price + {market_str} + {pill_text} +
+
+
+ Reading · DCF implies {gap_str} {gap_dir} the current market. + {reading} +
+
+ +
+
+

Enterprise value build — present value of FCFs + terminal

+ USD · billions · discounted at WACC {wacc_pct:.1f}% +
+
+ + {hdr_cells} + + {fcf_cells} + {df_cells} + {pv_cells} + +
Forecast FCF
Discount factor
Present value
+
+ +
+
+

From enterprise to equity

+ Balance-sheet bridge{(' · ' + source_date) if source_date else ''} +
+
+
Enterprise value{ev_b}
+
Net debt
+
Net debt{net_debt_b}
+
Other claims
+
Other claims{other_claims_b}
+
=
+
Equity value{equity_b}
+
+
+ Total debt {total_debt_b} + · + Cash & equiv. {cash_b} + · + Preferred + minority {_fmt_b(other_b_val)} +
+
+ +
+
+ Intrinsic · Per Share + {iv_str} + Equity value ÷ shares +
+
+ Market · Last + {market_str} +   +
+
+ Gap + {gap_display} + {gap_pct_str} +
+
+ Shares Outstanding + {shares_b:.2f} B + diluted +
+
+ +
+
+

Cross-check against the multiples

+ Same business, different lenses · implied per-share +
+
+ {cx_dcf} + {cx_ev} + {cx_rev} + {cx_pb} +
+
+ +
+ Firm-value DCF · enterprise value bridged to equity using debt & cash from the most recent balance sheet. Negative-FCF years are excluded from the base; terminal value uses Gordon Growth Model. + 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 + slider_default = float(max(-15.0, min(20.0, hist_growth_raw_pct))) + + st.markdown(_DCF_RAIL_CSS, unsafe_allow_html=True) + + rail_col, canvas_col = st.columns([1, 3], gap="medium") + + with rail_col: + st.markdown( + 'Assumptions' + '
3-stage DCF
' + '
Firm-value DCF — projects free cash flow, discounts to today, bridges to equity per share.
', + unsafe_allow_html=True, + ) + + wacc_pct = st.slider( "WACC (%)", - min_value=5.0, - max_value=20.0, - value=10.0, - step=0.5, + min_value=4.0, max_value=15.0, value=10.0, step=0.25, key=f"dcf_wacc_{ctx['ticker']}", - ) / 100 - with col2: - terminal_growth = st.slider( - "Terminal Growth (%)", - min_value=0.5, - max_value=5.0, - value=2.5, - step=0.5, - key=f"dcf_terminal_{ctx['ticker']}", - ) / 100 - with col3: - projection_years = st.slider( - "Projection Years", - min_value=3, - max_value=10, - value=5, - step=1, - key=f"dcf_years_{ctx['ticker']}", ) - with col4: - fcf_growth_pct = st.slider( - "FCF Growth (%)", - min_value=-20.0, - max_value=30.0, - value=round(slider_default, 1), - step=0.5, - help=f"Historical median: {hist_growth_raw_pct:.1f}%. Drag to override.", - key=f"dcf_growth_{ctx['ticker']}", + tg_pct = st.slider( + "Terminal growth (%)", + min_value=0.0, max_value=5.0, value=2.5, step=0.1, + key=f"dcf_tg_{ctx['ticker']}", + ) + yrs = st.slider( + "Forecast horizon (yr)", + min_value=3, max_value=10, value=5, step=1, + key=f"dcf_yrs_{ctx['ticker']}", + ) + g_pct = st.slider( + "FCF growth (%)", + min_value=-15.0, max_value=20.0, value=round(slider_default, 1), step=0.1, + key=f"dcf_g_{ctx['ticker']}", ) - st.caption(f"Historical FCF growth (median): **{hist_growth_raw_pct:.1f}%**") + st.markdown('
', unsafe_allow_html=True) + + net_debt_raw = ctx["total_debt"] - ctx["cash_and_equivalents"] + st.markdown( + '
From the filings
' + f'
Base FCF (TTM){_fmt_b(ctx["base_fcf"])}
' + f'
FCF · 5-yr median{hist_growth_raw_pct:+.1f}%
' + f'
Net debt{_fmt_b(net_debt_raw)}
' + f'
Shares outstanding{ctx["shares"] / 1e9:.2f} B
', + unsafe_allow_html=True, + ) + + st.markdown('
', unsafe_allow_html=True) + + btn_reset, btn_save, btn_recompute = st.columns(3) + with btn_reset: + if st.button("Reset", key=f"dcf_reset_{ctx['ticker']}", use_container_width=True): + st.session_state[f"dcf_wacc_{ctx['ticker']}"] = 10.0 + st.session_state[f"dcf_tg_{ctx['ticker']}"] = 2.5 + st.session_state[f"dcf_yrs_{ctx['ticker']}"] = 5 + st.session_state[f"dcf_g_{ctx['ticker']}"] = round(slider_default, 1) + st.rerun() + with btn_save: + st.button("Save scenario", key=f"dcf_save_{ctx['ticker']}", disabled=True, use_container_width=True) + with btn_recompute: + if st.button("Recompute", key=f"dcf_recompute_{ctx['ticker']}", type="primary", use_container_width=True): + get_free_cash_flow_ttm.clear() + get_balance_sheet_bridge_items.clear() + st.rerun() + + # Guard: WACC must exceed terminal growth + if wacc_pct <= tg_pct: + with canvas_col: + st.warning(f"WACC ({wacc_pct:.2f}%) must be greater than terminal growth ({tg_pct:.2f}%). Adjust the sliders.") + return result = run_dcf( fcf_series=ctx["fcf_series"], shares_outstanding=ctx["shares"], - wacc=wacc, - terminal_growth=terminal_growth, - projection_years=projection_years, - growth_rate_override=fcf_growth_pct / 100, + wacc=wacc_pct / 100, + terminal_growth=tg_pct / 100, + projection_years=yrs, + growth_rate_override=g_pct / 100, total_debt=ctx["total_debt"], cash_and_equivalents=ctx["cash_and_equivalents"], preferred_equity=ctx["preferred_equity"], @@ -541,168 +974,58 @@ def _render_dcf_model(ctx: dict): ) if not result: - st.warning("Insufficient data to run DCF model.") + with canvas_col: + st.warning("Insufficient data to run DCF model.") return if result.get("error"): - st.warning(result["error"]) + with canvas_col: + st.warning(result["error"]) return - iv = result["intrinsic_value_per_share"] - current_price = ctx["current_price"] - market_cap = ctx["market_cap"] - market_enterprise_value = None - if market_cap and market_cap > 0: - market_enterprise_value = ( - float(market_cap) - + float(ctx["total_debt"]) - - float(ctx["cash_and_equivalents"]) - + float(ctx["preferred_equity"]) - + float(ctx["minority_interest"]) + # Cross-check: run other models at their current market multiples + ev_ebitda_price = None + if ctx["ev_available"] and ctx.get("ev_ebitda_current"): + ev_r = run_ev_ebitda( + ebitda=float(ctx["ebitda"]), + total_debt=ctx["total_debt"], + total_cash=ctx["cash_and_equivalents"], + preferred_equity=ctx["preferred_equity"], + minority_interest=ctx["minority_interest"], + shares_outstanding=float(ctx["shares"]), + target_multiple=float(ctx["ev_ebitda_current"]), ) + ev_ebitda_price = ev_r.get("implied_price_per_share") + + ev_rev_price = None + if ctx["ev_revenue_available"] and ctx.get("ev_revenue_current") and ctx.get("revenue_ttm"): + rev_r = run_ev_revenue( + revenue=float(ctx["revenue_ttm"]), + total_debt=ctx["total_debt"], + total_cash=ctx["cash_and_equivalents"], + preferred_equity=ctx["preferred_equity"], + minority_interest=ctx["minority_interest"], + shares_outstanding=float(ctx["shares"]), + target_multiple=float(ctx["ev_revenue_current"]), + ) + ev_rev_price = rev_r.get("implied_price_per_share") - st.caption( - "This model projects free cash flow, discounts those cash flows back to today, " - "adds a terminal value, and then bridges from enterprise value to equity value per share." - ) - - calc_a, calc_b, calc_c, calc_d = st.columns(4) - calc_a.metric("Base FCF", fmt_large(result["base_fcf"])) - calc_b.metric("Forecast FCF PV", fmt_large(result["fcf_pv_sum"])) - calc_c.metric("Terminal Value PV", fmt_large(result["terminal_value_pv"])) - calc_d.metric("Implied Enterprise Value", fmt_large(result["enterprise_value"])) + pb_price = None + if ctx["pb_available"] and ctx.get("pb_current") and ctx.get("book_value_per_share"): + pb_r = run_price_to_book( + book_value_per_share=float(ctx["book_value_per_share"]), + target_multiple=float(ctx["pb_current"]), + ) + pb_price = pb_r.get("implied_price_per_share") - source_date = ctx["bridge_items"].get("source_date") - st.caption( - "DCF is modeled on firm-level free cash flow, so enterprise value is bridged to equity value " - "using debt and cash from the most recent balance sheet before calculating per-share value." - ) - if source_date: - st.caption(f"Balance-sheet bridge source date: **{source_date}**") - - years = [f"Year {y}" for y in result["years"]] - discounted = result["discounted_fcfs"] - terminal_pv = result["terminal_value_pv"] - - fig = go.Figure(go.Bar( - x=years + ["Terminal Value"], - y=[(v / 1e9) for v in discounted] + [terminal_pv / 1e9], - marker_color=["#C2AA7A"] * len(years) + ["#C49545"], - text=[f"${v / 1e9:.2f}B" for v in discounted] + [f"${terminal_pv / 1e9:.2f}B"], - textposition="outside", - )) - fig.update_layout( - title="Enterprise Value Build: PV of Forecast FCFs + Terminal Value (Billions)", - yaxis_title="USD (Billions)", - plot_bgcolor="rgba(0,0,0,0)", - paper_bgcolor="rgba(0,0,0,0)", - margin=dict(l=0, r=0, t=40, b=0), - height=360, + canvas_html = _build_dcf_canvas_html( + ctx, result, wacc_pct, tg_pct, yrs, g_pct, + ev_ebitda_price, ev_rev_price, pb_price, ) - st.plotly_chart(fig, use_container_width=True) - - st.markdown("**Enterprise Value To Equity Value Bridge**") - st.caption("Enterprise value is adjusted for net debt or net cash and other claims to arrive at equity value.") - - bridge_a, bridge_b, bridge_c, bridge_d = st.columns(4) - bridge_a.metric("Enterprise Value", fmt_large(result["enterprise_value"])) - bridge_b.metric(_net_debt_label(result["net_debt"]), fmt_large(abs(result["net_debt"]))) - bridge_c.metric("Other Claims", fmt_large(ctx["preferred_equity"] + ctx["minority_interest"])) - bridge_d.metric("Equity Value", fmt_large(result["equity_value"])) - detail_a, detail_b, detail_c = st.columns(3) - detail_a.metric("Total Debt", fmt_large(ctx["total_debt"])) - detail_b.metric("Cash & Equivalents", fmt_large(ctx["cash_and_equivalents"])) - detail_c.metric("Preferred + Minority", fmt_large(ctx["preferred_equity"] + ctx["minority_interest"])) - - if market_cap and market_cap > 0: - st.markdown("**Market Comparison**") - compare_a, compare_b = st.columns(2) - if market_enterprise_value and market_enterprise_value > 0: - ev_delta = (result["enterprise_value"] - market_enterprise_value) / market_enterprise_value - compare_a.metric( - "Market Enterprise Value", - fmt_large(market_enterprise_value), - delta=f"{ev_delta * 100:+.1f}%", - ) - equity_delta = (result["equity_value"] - market_cap) / market_cap - compare_b.metric("Market Cap", fmt_large(market_cap), delta=f"{equity_delta * 100:+.1f}%") - - summary_rows = [ - { - "Step": "1. Start with base free cash flow", - "Value": fmt_large(result["base_fcf"]), - "What it means": "Trailing-twelve-month free cash flow used as the starting point.", - }, - { - "Step": "2. Project and discount forecast cash flows", - "Value": fmt_large(result["fcf_pv_sum"]), - "What it means": f"Present value of {projection_years} years of projected FCF.", - }, - { - "Step": "3. Add discounted terminal value", - "Value": fmt_large(result["terminal_value_pv"]), - "What it means": "Present value of cash flows beyond the explicit forecast period.", - }, - { - "Step": "4. Arrive at enterprise value", - "Value": fmt_large(result["enterprise_value"]), - "What it means": "Value of the operations before debt, cash, and other claims.", - }, - { - "Step": "5. Bridge to equity value", - "Value": fmt_large(result["equity_value"]), - "What it means": "Enterprise value less net debt, preferred equity, and minority interest.", - }, - { - "Step": "6. Convert to value per share", - "Value": fmt_currency(iv), - "What it means": "Equity value divided by shares outstanding.", - }, - ] - st.dataframe(pd.DataFrame(summary_rows), use_container_width=True, hide_index=True) - - st.write("") - - st.markdown("**DCF Conclusion**") - conclusion_a, conclusion_b, conclusion_c, conclusion_d = st.columns(4) - conclusion_a.metric("Equity Value / Share", fmt_currency(iv)) - if current_price: - upside = (iv - current_price) / current_price - conclusion_b.metric("Current Price", fmt_currency(current_price)) - conclusion_c.metric("Upside / Downside", f"{upside * 100:+.1f}%", delta=f"{upside * 100:+.1f}%") - else: - conclusion_b.metric("FCF Growth Used", f"{result['growth_rate_used'] * 100:.1f}%") - conclusion_d.metric("Shares Outstanding", f"{ctx['shares'] / 1e6:,.1f}M") - - if current_price: - assumption_a, assumption_b = st.columns(2) - assumption_a.metric("FCF Growth Used", f"{result['growth_rate_used'] * 100:.1f}%") - assumption_b.metric("Equity Value", fmt_large(result["equity_value"])) - - if current_price and current_price > 0: - valuation_gap = iv - current_price - market_message = "above" if valuation_gap > 0 else "below" - if abs(valuation_gap) < 0.005: - market_message = "roughly in line with" - implied_value = _escape_markdown_currency(fmt_currency(iv)) - gap_value = _escape_markdown_currency(fmt_currency(abs(valuation_gap))) - current_value = _escape_markdown_currency(fmt_currency(current_price)) - st.markdown( - f"The DCF implies **{implied_value} per share**, which is **{gap_value} " - f"{market_message}** the current market price of **{current_value}**." - ) - - with st.expander("Methodology & sources", expanded=False): - st.markdown( - "- **TTM ratios:** computed from raw quarterly financial statements where possible.\n" - "- **Enterprise Value:** computed as market cap + total debt - cash & equivalents + preferred equity + minority interest.\n" - "- **Market cap:** computed as latest price × shares outstanding when available.\n" - "- **Shares outstanding:** pulled from yfinance shares fields.\n" - "- **DCF bridge:** uses the most recent quarterly balance sheet for debt, cash, preferred equity, and minority interest.\n" - "- **Base FCF:** computed as trailing-twelve-month free cash flow from the last four quarterly cash flow statements.\n" - "- **Historical ratios:** computed from annual statements plus price history, with guards against nonsensical EV/EBITDA values.\n" - "- **Forward metrics:** analyst-driven items such as Forward P/E and estimates still depend on vendor data." - ) + with canvas_col: + # Height: base sections + per-year table width is constant in rows + canvas_height = 1620 + components.html(canvas_html, height=canvas_height, scrolling=False) def _render_ev_ebitda_model(ctx: dict): st.markdown("**EV/EBITDA Valuation**") -- cgit v1.3-2-g0d8e