diff options
Diffstat (limited to 'components/valuation.py')
| -rw-r--r-- | components/valuation.py | 263 |
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 · 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> + <span class="pill {pill_cls}" id="upside-pill">{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> + <span>Reading · 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 — present value of FCFs + terminal</h3> - <span class="units">USD · billions · discounted at WACC {wacc_pct:.1f}%</span> + <span class="units" id="chart-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> + <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{(' · ' + 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">−<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 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 · 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 ÷ 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"], |
