diff options
| -rw-r--r-- | app.py | 3 | ||||
| -rw-r--r-- | components/valuation.py | 723 |
2 files changed, 526 insertions, 200 deletions
@@ -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} - 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))) +/* 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)} - st.caption( - "Firm-value DCF works best for operating companies with positive, reasonably stable free cash flow." +/* 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 = """<style> +@import url('https://fonts.googleapis.com/css2?family=EB+Garamond:ital,wght@0,400;0,500;1,400;1,500&family=IBM+Plex+Mono:wght@300;400;500;600&family=IBM+Plex+Sans:wght@300;400;500;600;700&display=swap'); +.dcf-eyebrow{font-family:'IBM Plex Sans',sans-serif;font-size:12px;text-transform:uppercase;letter-spacing:.18em;color:#8E8676;font-weight:600;line-height:1} +.dcf-title{font-family:'EB Garamond',Georgia,serif;font-size:22px;font-weight:500;letter-spacing:-.01em;color:#F2ECDC;margin:4px 0 0;line-height:1.2} +.dcf-sub{font-family:'IBM Plex Sans',sans-serif;font-size:12px;color:#8E8676;margin-top:6px;line-height:1.5} +.dcf-divider{border:none;border-top:1px solid #232934;margin:4px 0 0} +.dcf-filings-eyebrow{font-family:'IBM Plex Sans',sans-serif;font-size:11px;text-transform:uppercase;letter-spacing:.18em;color:#8E8676;font-weight:600;margin-bottom:10px} +.dcf-filing-row{display:flex;justify-content:space-between;align-items:baseline;font-family:'IBM Plex Mono',monospace;font-size:12px;color:#C7C0AE;margin-bottom:6px} +.dcf-filing-val{color:#F2ECDC;font-variant-numeric:tabular-nums} +.dcf-actions{display:flex;gap:8px;padding-top:4px} +/* Streamlit slider thumb */ +[data-baseweb="slider"] [role="slider"]{background-color:#C2AA7A !important;border:2px solid #0B0E13 !important;width:14px !important;height:14px !important} +[data-testid="stSlider"] > label > div > p{font-family:'IBM Plex Sans',sans-serif !important;font-size:13px !important;color:#C7C0AE !important} +[data-testid="stSlider"] [data-testid="stTickBarMin"],[data-testid="stSlider"] [data-testid="stTickBarMax"]{font-family:'IBM Plex Mono',monospace !important;font-size:10px !important;color:#5E5849 !important} +/* Primary button — brass bg, dark ink text */ +[data-testid="stBaseButton-primary"]{color:#17120A !important;background-color:#C2AA7A !important} +button[kind="primary"]{color:#17120A !important} +</style>""" + + +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}<extra></extra>", + "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"<th>Yr {i + 1}</th>" for i in range(n)) + "<th>Terminal</th>" + fcf_cells = "".join(f"<td>{_fmt_b(v)}</td>" for v in projected) + fcf_cells += f'<td class="brass">{_fmt_b(terminal_fcf)}</td>' + df_cells = "".join(f"<td>{disc_factors[i]:.3f}</td>" for i in range(n)) + df_cells += f"<td>{disc_tv_factor:.3f}</td>" + pv_cells = "".join(f"<td>{_fmt_b(v)}</td>" for v in discounted) + pv_cells += f'<td class="brass">{_fmt_b(tv_pv)}</td>' + + # 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'<span class="delta {dcls}">{dsign}{delta_pct:.1f}% vs market</span>' + else: + dhtml = '<span class="delta na">—</span>' + return ( + f'<div class="{cls}">' + f'<span class="lbl">{lbl}</span>' + f'<span class="v num">{val_str}</span>' + f"{dhtml}" + f'<span class="meta">{meta}</span>' + f"</div>" + ) + + dcf_delta = upside_pct if has_market else None + cx_dcf = cx_cell( + "va-cx-cell dcf", "DCF · THIS MODEL", iv_str, dcf_delta, + f"Firm-value DCF · {yrs}-yr explicit · WACC {wacc_pct:.1f}%", + ) + + def _cx_multiple_cell(label, implied, market_multiple, mult_label): + if implied is not None and has_market: + delta = (implied - market) / market * 100 + val = f"${implied:,.2f}" + meta = f"Market multiple {market_multiple:.1f}× · {mult_label}" if market_multiple else mult_label + else: + delta = None + val = "—" + meta = "Unavailable for this company" + return cx_cell("va-cx-cell", label, val, delta, meta) + + cx_ev = _cx_multiple_cell( + "EV / EBITDA", ev_ebitda_price, + ctx.get("ev_ebitda_current") or 0, "based on current market multiple", + ) + cx_rev = _cx_multiple_cell( + "EV / REVENUE", ev_rev_price, + ctx.get("ev_revenue_current") or 0, "based on current market multiple", ) + cx_pb = _cx_multiple_cell( + "P / BOOK", pb_price, + ctx.get("pb_current") or 0, "based on current market multiple", + ) + + # Recon gap cell color + gap_color = "var(--positive)" if gap >= 0 else "var(--negative)" + gap_sign = "+" if gap >= 0 else "" + gap_display = f"{gap_sign}${gap:,.2f}" if has_market else "—" + gap_pct_str = f"{upside_pct:.1f}% vs market" if has_market else "—" + + html = f"""<!DOCTYPE html> +<html> +<head> +<meta charset="UTF-8"> +<link rel="preconnect" href="https://fonts.googleapis.com"> +<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> +<link href="https://fonts.googleapis.com/css2?family=EB+Garamond:ital,wght@0,400;0,500;1,400;1,500&family=IBM+Plex+Mono:wght@300;400;500;600&family=IBM+Plex+Sans:wght@300;400;500;600;700&display=swap" rel="stylesheet"> +<script src="https://cdn.plot.ly/plotly-2.35.2.min.js" charset="utf-8"></script> +<style>{_DCF_CANVAS_CSS}</style> +</head> +<body> +<div class="va-canvas"> + + <section class="va-verdict" style="--verdict-gradient:{verdict_gradient}"> + <div style="position:absolute;inset:0;background:{verdict_gradient};pointer-events:none;z-index:0"></div> + <div class="top"> + <div class="col"> + <span class="lbl">DCF Intrinsic Value</span> + <span class="big num">{iv_str}</span> + <span class="sub">per share · firm value method · {yrs}-yr horizon</span> + </div> + <span class="arrow">vs</span> + <div class="col" style="align-items:flex-end"> + <span class="lbl">Market Price</span> + <span class="big market num">{market_str}</span> + <span class="pill {pill_cls}">{pill_text}</span> + </div> + </div> + <div class="band"> + <span>Reading · DCF implies <span class="mono">{gap_str}</span> {gap_dir} the current market.</span> + <span class="reading">{reading}</span> + </div> + </section> + + <section class="va-projection"> + <div class="head"> + <h3>Enterprise value build — present value of FCFs + terminal</h3> + <span class="units">USD · billions · discounted at WACC {wacc_pct:.1f}%</span> + </div> + <div id="dcf-chart" style="width:100%;height:260px"></div> + <table class="va-cf-table"> + <thead><tr><th></th>{hdr_cells}</tr></thead> + <tbody> + <tr><td>Forecast FCF</td>{fcf_cells}</tr> + <tr><td>Discount factor</td>{df_cells}</tr> + <tr class="total"><td>Present value</td>{pv_cells}</tr> + </tbody> + </table> + </section> + + <section class="va-bridge"> + <div class="bhead"> + <h3>From enterprise to equity</h3> + <span class="bdate">Balance-sheet bridge{(' · ' + source_date) if source_date else ''}</span> + </div> + <div class="flow"> + <div class="node start"><span class="lbl">Enterprise value</span><span class="v num">{ev_b}</span></div> + <div class="op">−<span class="sub">Net debt</span></div> + <div class="node"><span class="lbl">Net debt</span><span class="v num">{net_debt_b}</span></div> + <div class="op">−<span class="sub">Other claims</span></div> + <div class="node"><span class="lbl">Other claims</span><span class="v num">{other_claims_b}</span></div> + <div class="op">=</div> + <div class="node result"><span class="lbl">Equity value</span><span class="v num">{equity_b}</span></div> + </div> + <div class="bfoot"> + <span>Total debt {total_debt_b}</span> + <span>·</span> + <span>Cash & equiv. {cash_b}</span> + <span>·</span> + <span>Preferred + minority {_fmt_b(other_b_val)}</span> + </div> + </section> + + <section class="va-recon"> + <div class="cell intrinsic"> + <span class="lbl">Intrinsic · Per Share</span> + <span class="v num">{iv_str}</span> + <span class="sub">Equity value ÷ shares</span> + </div> + <div class="cell"> + <span class="lbl">Market · Last</span> + <span class="v num">{market_str}</span> + <span class="sub"> </span> + </div> + <div class="cell"> + <span class="lbl">Gap</span> + <span class="v num" style="color:{gap_color}">{gap_display}</span> + <span class="sub">{gap_pct_str}</span> + </div> + <div class="cell"> + <span class="lbl">Shares Outstanding</span> + <span class="v num">{shares_b:.2f} B</span> + <span class="sub">diluted</span> + </div> + </section> + + <section class="va-cx"> + <div class="va-cx-head"> + <h3>Cross-check against the multiples</h3> + <span class="hint">Same business, different lenses · implied per-share</span> + </div> + <div class="va-cx-grid"> + {cx_dcf} + {cx_ev} + {cx_rev} + {cx_pb} + </div> + </section> + + <div class="va-foot"> + <span>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.</span> + <a href="#">Methodology & sources ↗</a> + </div> - col1, col2, col3, col4 = st.columns(4) - with col1: - wacc = st.slider( +</div> +<script> +var data = {plotly_data}; +var layout = {plotly_layout}; +Plotly.newPlot('dcf-chart', data, layout, {{displayModeBar: false, responsive: true}}); +</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 + 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( + '<span class="dcf-eyebrow">Assumptions</span>' + '<div class="dcf-title">3-stage DCF</div>' + '<div class="dcf-sub">Firm-value DCF — projects free cash flow, discounts to today, bridges to equity per share.</div>', + 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.markdown('<hr class="dcf-divider">', unsafe_allow_html=True) + + net_debt_raw = ctx["total_debt"] - ctx["cash_and_equivalents"] + st.markdown( + '<div class="dcf-filings-eyebrow">From the filings</div>' + f'<div class="dcf-filing-row"><span>Base FCF (TTM)</span><span class="dcf-filing-val">{_fmt_b(ctx["base_fcf"])}</span></div>' + f'<div class="dcf-filing-row"><span>FCF · 5-yr median</span><span class="dcf-filing-val">{hist_growth_raw_pct:+.1f}%</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) - st.caption(f"Historical FCF growth (median): **{hist_growth_raw_pct:.1f}%**") + 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") - 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." - ) + 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") - 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." + canvas_html = _build_dcf_canvas_html( + ctx, result, wacc_pct, tg_pct, yrs, g_pct, + ev_ebitda_price, ev_rev_price, pb_price, ) - 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, - ) - 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**") |
