aboutsummaryrefslogtreecommitdiff
path: root/components/valuation.py
diff options
context:
space:
mode:
authorTyler <tyler@tylerhoang.xyz>2026-05-14 00:00:45 -0700
committerTyler <tyler@tylerhoang.xyz>2026-05-14 00:00:45 -0700
commit1072357a6997ab273deb0cb383aa081aab448fe0 (patch)
tree78966fd1a5c497580e47b965fc1fc2429e8b52aa /components/valuation.py
parenta82246e83146d1dd7f565493215c23ff482975e6 (diff)
Make DCF sliders live — no page reruns on drag
Move all four DCF assumptions (WACC, TG, horizon, FCF growth) from st.slider widgets into the canvas iframe as native range inputs. A JavaScript runDCF() engine recomputes the full projection in the browser on every drag event, updating the verdict, bar chart (Plotly.react), cash-flow table, bridge, recon strip, and cross-check cell in place without a Streamlit round-trip. Python still runs run_dcf() once on page load (using session-state defaults) to populate dcf_intrinsic for the Multiples cross-check. The Recompute button in the rail clears API caches and reruns when fresh filing data is needed. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'components/valuation.py')
-rw-r--r--components/valuation.py263
1 files changed, 198 insertions, 65 deletions
diff --git a/components/valuation.py b/components/valuation.py
index 53be09c..8d52ae0 100644
--- a/components/valuation.py
+++ b/components/valuation.py
@@ -755,7 +755,7 @@ def _build_dcf_canvas_html(
bar_line_colors = ["#1F3B5E"] * len(discounted) + ["#DCC79E"]
bar_text = [_fmt_b(v) for v in discounted] + [_fmt_b(tv_pv)]
- plotly_data = json.dumps([{
+ plotly_data_json = json.dumps([{
"type": "bar",
"x": bar_x,
"y": bar_y,
@@ -766,7 +766,7 @@ def _build_dcf_canvas_html(
"hovertemplate": "%{x}: %{text}<extra></extra>",
"cliponaxis": False,
}])
- plotly_layout = json.dumps({
+ plotly_layout_json = json.dumps({
"paper_bgcolor": "#11151C",
"plot_bgcolor": "#11151C",
"margin": {"l": 48, "r": 8, "t": 28, "b": 36},
@@ -790,6 +790,14 @@ def _build_dcf_canvas_html(
"uniformtext": {"mode": "hide", "minsize": 8},
})
+ data_json = json.dumps({
+ "baseFcf": result["base_fcf"],
+ "netDebt": result["net_debt"],
+ "otherClaims": ctx["preferred_equity"] + ctx["minority_interest"],
+ "shares": ctx["shares"],
+ "market": float(ctx["current_price"] or 0),
+ })
+
# Verdict
verdict_gradient = (
"linear-gradient(110deg,transparent 35%,rgba(79,140,94,.07) 100%)"
@@ -835,9 +843,19 @@ def _build_dcf_canvas_html(
)
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}%",
+ if dcf_delta is not None and has_market:
+ dcf_dcls = "pos" if dcf_delta >= 0 else "neg"
+ dcf_dsign = "+" if dcf_delta >= 0 else ""
+ dcf_dhtml = f'<span id="cx-dcf-d" class="delta {dcf_dcls}">{dcf_dsign}{dcf_delta:.1f}% vs market</span>'
+ else:
+ dcf_dhtml = '<span id="cx-dcf-d" class="delta na">—</span>'
+ cx_dcf = (
+ f'<div class="va-cx-cell dcf">'
+ f'<span class="lbl">DCF · THIS MODEL</span>'
+ f'<span id="cx-dcf-v" class="v num">{iv_str}</span>'
+ f"{dcf_dhtml}"
+ f'<span class="meta">Firm-value DCF · {yrs}-yr explicit · WACC {wacc_pct:.1f}%</span>'
+ f"</div>"
)
def _cx_multiple_cell(label, implied, market_multiple, mult_label):
@@ -878,44 +896,77 @@ def _build_dcf_canvas_html(
<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>
+<style>{_DCF_CANVAS_CSS}
+.va-controls{{background:var(--ink-1);border:1px solid var(--line-1);border-radius:6px;padding:16px 24px}}
+.ctrl-grid{{display:grid;grid-template-columns:repeat(4,1fr);gap:20px}}
+.ctrl-item{{display:flex;flex-direction:column;gap:6px}}
+.ctrl-head{{display:flex;justify-content:space-between;align-items:baseline}}
+.ctrl-lbl{{font-family:var(--font-sans);font-size:11px;text-transform:uppercase;letter-spacing:.12em;color:var(--fg-3)}}
+.ctrl-val{{font-family:var(--font-mono);font-size:14px;color:var(--brass-bright);font-variant-numeric:tabular-nums}}
+.ctrl-warn{{font-family:var(--font-sans);font-size:12px;color:var(--warning);margin-top:8px;padding:6px 10px;background:var(--warning-bg);border-radius:4px}}
+.va-controls input[type=range]{{width:100%;-webkit-appearance:none;appearance:none;background:var(--ink-3);height:4px;border-radius:999px;cursor:pointer;outline:none;margin-top:2px}}
+.va-controls input[type=range]::-webkit-slider-thumb{{-webkit-appearance:none;width:14px;height:14px;border-radius:50%;background:var(--brass);border:2px solid #0B0E13;box-shadow:0 0 0 1px var(--brass-deep);cursor:pointer}}
+.va-controls input[type=range]::-moz-range-thumb{{width:14px;height:14px;border-radius:50%;background:var(--brass);border:2px solid #0B0E13;cursor:pointer;border:none}}
+</style>
</head>
<body>
<div class="va-canvas">
+ <section class="va-controls">
+ <div class="ctrl-grid">
+ <div class="ctrl-item">
+ <div class="ctrl-head"><span class="ctrl-lbl">WACC</span><span class="ctrl-val" id="wacc-disp">{wacc_pct:.2f}%</span></div>
+ <input type="range" id="sl-wacc" min="4" max="15" step="0.25" value="{wacc_pct}">
+ </div>
+ <div class="ctrl-item">
+ <div class="ctrl-head"><span class="ctrl-lbl">Terminal growth</span><span class="ctrl-val" id="tg-disp">{tg_pct:.1f}%</span></div>
+ <input type="range" id="sl-tg" min="0" max="5" step="0.1" value="{tg_pct}">
+ </div>
+ <div class="ctrl-item">
+ <div class="ctrl-head"><span class="ctrl-lbl">Forecast horizon</span><span class="ctrl-val" id="yrs-disp">{yrs} yr</span></div>
+ <input type="range" id="sl-yrs" min="3" max="10" step="1" value="{yrs}">
+ </div>
+ <div class="ctrl-item">
+ <div class="ctrl-head"><span class="ctrl-lbl">FCF growth</span><span class="ctrl-val" id="g-disp">{g_pct:.1f}%</span></div>
+ <input type="range" id="sl-g" min="-15" max="20" step="0.1" value="{g_pct}">
+ </div>
+ </div>
+ <div class="ctrl-warn" id="wacc-tg-warn" style="display:none">WACC must exceed terminal growth — adjust the sliders</div>
+ </section>
+
<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 id="verdict-grad" 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="big num" id="iv-big">{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>
+ <span class="pill {pill_cls}" id="upside-pill">{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>
+ <span>Reading &middot; DCF implies <span class="mono" id="gap-str">{gap_str}</span> <span id="gap-dir">{gap_dir}</span> the current market.</span>
+ <span class="reading" id="reading-str">{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>
+ <span class="units" id="chart-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>
+ <thead><tr id="cf-thead"><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>
+ <tr id="cf-fcf"><td>Forecast FCF</td>{fcf_cells}</tr>
+ <tr id="cf-df"><td>Discount factor</td>{df_cells}</tr>
+ <tr class="total" id="cf-pv"><td>Present value</td>{pv_cells}</tr>
</tbody>
</table>
</section>
@@ -926,13 +977,13 @@ def _build_dcf_canvas_html(
<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="node start"><span class="lbl">Enterprise value</span><span class="v num" id="ev-node-val">{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 class="node result"><span class="lbl">Equity value</span><span class="v num" id="equity-node-val">{equity_b}</span></div>
</div>
<div class="bfoot">
<span>Total debt {total_debt_b}</span>
@@ -946,7 +997,7 @@ def _build_dcf_canvas_html(
<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="v num" id="recon-iv">{iv_str}</span>
<span class="sub">Equity value &divide; shares</span>
</div>
<div class="cell">
@@ -956,8 +1007,8 @@ def _build_dcf_canvas_html(
</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>
+ <span class="v num" id="recon-gap" style="color:{gap_color}">{gap_display}</span>
+ <span class="sub" id="recon-gap-pct">{gap_pct_str}</span>
</div>
<div class="cell">
<span class="lbl">Shares Outstanding</span>
@@ -986,9 +1037,123 @@ def _build_dcf_canvas_html(
</div>
<script>
-var data = {plotly_data};
-var layout = {plotly_layout};
-Plotly.newPlot('dcf-chart', data, layout, {{displayModeBar: false, responsive: true}});
+var D = {data_json};
+var LAYOUT = {plotly_layout_json};
+
+function fB(n) {{ var b=n/1e9; return Math.abs(b)>=1000?'$'+(b/1000).toFixed(2)+'T':'$'+b.toFixed(2)+'B'; }}
+function fS(n) {{ return '$'+n.toLocaleString('en-US',{{minimumFractionDigits:2,maximumFractionDigits:2}}); }}
+
+function runDCF(wacc, tg, yrs, g) {{
+ g = Math.max(-0.5, Math.min(0.5, g));
+ var fcfs=[], dfs=[], pvs=[];
+ for (var i=1; i<=yrs; i++) {{
+ var f = D.baseFcf * Math.pow(1+g, i);
+ var df = 1/Math.pow(1+wacc, i);
+ fcfs.push(f); dfs.push(df); pvs.push(f*df);
+ }}
+ var pvSum = pvs.reduce(function(a,b){{return a+b;}},0);
+ var termFcf = fcfs[yrs-1]*(1+tg);
+ var tvNom = termFcf/(wacc-tg);
+ var tvDf = 1/Math.pow(1+wacc,yrs);
+ var tvPv = tvNom*tvDf;
+ var ev = pvSum+tvPv;
+ var equity = ev-D.netDebt-D.otherClaims;
+ return {{fcfs:fcfs,dfs:dfs,pvs:pvs,termFcf:termFcf,tvDf:tvDf,tvPv:tvPv,ev:ev,equity:equity,iv:equity/D.shares}};
+}}
+
+function setText(id,t){{var e=document.getElementById(id);if(e)e.textContent=t;}}
+function setHtml(id,h){{var e=document.getElementById(id);if(e)e.innerHTML=h;}}
+
+function update() {{
+ var wacc=+document.getElementById('sl-wacc').value/100;
+ var tg=+document.getElementById('sl-tg').value/100;
+ var yrs=+document.getElementById('sl-yrs').value;
+ var g=+document.getElementById('sl-g').value/100;
+
+ setText('wacc-disp',(wacc*100).toFixed(2)+'%');
+ setText('tg-disp',(tg*100).toFixed(1)+'%');
+ setText('yrs-disp',yrs+' yr');
+ setText('g-disp',(g*100).toFixed(1)+'%');
+
+ var warn=document.getElementById('wacc-tg-warn');
+ if (wacc<=tg) {{ warn.style.display='block'; return; }}
+ warn.style.display='none';
+
+ var r=runDCF(wacc,tg,yrs,g);
+ var iv=r.iv, market=D.market, gap=iv-market;
+ var isPos=iv>=market, upside=market>0?(iv-market)/market*100:0;
+
+ setText('iv-big',fS(iv));
+ var pill=document.getElementById('upside-pill');
+ if(pill){{
+ var arr=isPos?'▲':'▼', sign=isPos?'+':'−';
+ pill.textContent=arr+' '+sign+Math.abs(upside).toFixed(1)+'% '+(isPos?'upside':'downside');
+ pill.className='pill '+(isPos?'pos':'neg');
+ }}
+ var grad=document.getElementById('verdict-grad');
+ if(grad) grad.style.background=isPos
+ ?'linear-gradient(110deg,transparent 35%,rgba(79,140,94,.07) 100%)'
+ :'linear-gradient(110deg,transparent 35%,rgba(181,73,75,.07) 100%)';
+ setText('gap-str','$'+Math.abs(gap).toFixed(2));
+ setText('gap-dir',gap>=0?'above':'below');
+ setText('reading-str',isPos?'Constructive':'Cautious');
+ setText('chart-units','USD · billions · discounted at WACC '+(wacc*100).toFixed(1)+'%');
+
+ var thead=document.getElementById('cf-thead');
+ var tfcf=document.getElementById('cf-fcf');
+ var tdf=document.getElementById('cf-df');
+ var tpv=document.getElementById('cf-pv');
+ if(thead){{
+ var hh='<th></th>',fh='<td>Forecast FCF</td>',dh='<td>Discount factor</td>',ph='<td>Present value</td>';
+ for(var i=0;i<yrs;i++){{
+ hh+='<th>Yr '+(i+1)+'</th>';
+ fh+='<td>'+fB(r.fcfs[i])+'</td>';
+ dh+='<td>'+r.dfs[i].toFixed(3)+'</td>';
+ ph+='<td>'+fB(r.pvs[i])+'</td>';
+ }}
+ hh+='<th>Terminal</th>';
+ fh+='<td class="brass">'+fB(r.termFcf)+'</td>';
+ dh+='<td>'+r.tvDf.toFixed(3)+'</td>';
+ ph+='<td class="brass">'+fB(r.tvPv)+'</td>';
+ thead.innerHTML=hh; tfcf.innerHTML=fh; tdf.innerHTML=dh; tpv.innerHTML=ph;
+ }}
+
+ var bx=[],by=[],bc=[],blc=[],bt=[];
+ for(var i=0;i<yrs;i++){{
+ bx.push('Year '+(i+1)); by.push(r.pvs[i]/1e9);
+ bc.push('#243E5A'); blc.push('#1F3B5E'); bt.push(fB(r.pvs[i]));
+ }}
+ bx.push('Terminal'); by.push(r.tvPv/1e9);
+ bc.push('#C2AA7A'); blc.push('#DCC79E'); bt.push(fB(r.tvPv));
+ Plotly.react('dcf-chart',[{{
+ type:'bar',x:bx,y:by,
+ marker:{{color:bc,line:{{color:blc,width:1}}}},
+ text:bt,textposition:'outside',
+ textfont:{{family:'IBM Plex Mono',size:10,color:'#C7C0AE'}},
+ hovertemplate:'%{{x}}: %{{text}}<extra></extra>',
+ cliponaxis:false
+ }}],LAYOUT);
+
+ setText('ev-node-val',fB(r.ev));
+ setText('equity-node-val',fB(r.equity));
+ setText('recon-iv',fS(iv));
+ var gapEl=document.getElementById('recon-gap');
+ if(gapEl){{gapEl.textContent=(gap>=0?'+$':'-$')+Math.abs(gap).toFixed(2);gapEl.style.color=gap>=0?'var(--positive)':'var(--negative)';}}
+ setText('recon-gap-pct',market>0?upside.toFixed(1)+'% vs market':'—');
+ setText('cx-dcf-v',fS(iv));
+ if(market>0){{
+ var dd=upside,dcls=dd>=0?'pos':'neg',dsign=dd>=0?'+':'';
+ setHtml('cx-dcf-d','<span class="delta '+dcls+'">'+dsign+dd.toFixed(1)+'% vs market</span>');
+ }}
+}}
+
+['sl-wacc','sl-tg','sl-yrs','sl-g'].forEach(function(id){{
+ document.getElementById(id).addEventListener('input',update);
+}});
+
+// Initial chart render
+var data = {plotly_data_json};
+Plotly.newPlot('dcf-chart', data, LAYOUT, {{displayModeBar:false,responsive:true}});
</script>
</body>
</html>"""
@@ -1524,27 +1689,6 @@ def _render_dcf_model(ctx: dict):
unsafe_allow_html=True,
)
- wacc_pct = st.slider(
- "WACC (%)",
- min_value=4.0, max_value=15.0, value=10.0, step=0.25,
- key=f"dcf_wacc_{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"]
@@ -1559,27 +1703,16 @@ def _render_dcf_model(ctx: dict):
st.markdown('<hr class="dcf-divider">', 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()
+ 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
+ # Read assumptions from session state (set by in-canvas JS sliders, defaulting on first load)
+ wacc_pct = float(st.session_state.get(f"dcf_wacc_{ctx['ticker']}", 10.0))
+ tg_pct = float(st.session_state.get(f"dcf_tg_{ctx['ticker']}", 2.5))
+ yrs = int(st.session_state.get(f"dcf_yrs_{ctx['ticker']}", 5))
+ g_pct = round(float(st.session_state.get(f"dcf_g_{ctx['ticker']}", round(slider_default, 1))), 1)
result = run_dcf(
fcf_series=ctx["fcf_series"],