aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorTyler <tyler@tylerhoang.xyz>2026-05-13 23:22:55 -0700
committerTyler <tyler@tylerhoang.xyz>2026-05-13 23:22:55 -0700
commit64ea2681ceb403f021d13c39931f67321d11425b (patch)
treeebf242c6739ae4d58f7794916b3cb40534eeff39
parenta457bea95358825e55dbc7f48d57183004121109 (diff)
Redesign DCF tab — inspector rail layout with HTML canvas
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 <noreply@anthropic.com>
-rw-r--r--app.py3
-rw-r--r--components/valuation.py723
2 files changed, 526 insertions, 200 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}
- 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 &middot; firm value method &middot; {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 &middot; 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 &mdash; present value of FCFs + terminal</h3>
+ <span class="units">USD &middot; billions &middot; 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{(' &middot; ' + 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">&minus;<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">&minus;<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>&middot;</span>
+ <span>Cash &amp; equiv. {cash_b}</span>
+ <span>&middot;</span>
+ <span>Preferred + minority {_fmt_b(other_b_val)}</span>
+ </div>
+ </section>
+
+ <section class="va-recon">
+ <div class="cell intrinsic">
+ <span class="lbl">Intrinsic &middot; Per Share</span>
+ <span class="v num">{iv_str}</span>
+ <span class="sub">Equity value &divide; shares</span>
+ </div>
+ <div class="cell">
+ <span class="lbl">Market &middot; Last</span>
+ <span class="v num">{market_str}</span>
+ <span class="sub">&nbsp;</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 &middot; 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 &middot; enterprise value bridged to equity using debt &amp; 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 &amp; sources &nearr;</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**")