diff options
| -rw-r--r-- | components/valuation.py | 553 |
1 files changed, 7 insertions, 546 deletions
diff --git a/components/valuation.py b/components/valuation.py index 407538e..f0fbdb9 100644 --- a/components/valuation.py +++ b/components/valuation.py @@ -465,7 +465,6 @@ def _render_ratios(ticker: str): peg = _r("pegRatioTTM") or (info.get("pegRatio") if info else None) ev_ebt = _r("enterpriseValueMultipleTTM") ev_rev = _r("evToSalesTTM") - ev_ebit = _r("evToOperatingCashFlowTTM") # best proxy if direct unavailable pb = _r("priceToBookRatioTTM") ps = _r("priceToSalesRatioTTM") fcf_yield_v = (fcf_ttm / market_cap) if fcf_ttm and market_cap and market_cap > 0 else None @@ -679,7 +678,7 @@ def _render_ratios(ticker: str): + _kpi_spark("EV / Revenue", ev_rev, "x", "evToSalesTTM", None, invert=True) + _kpi_spark("P / Book", pb, "x", "priceToBookRatioTTM", sparks.get("pb"), invert=True) + _kpi_spark("P / FCF", p_fcf, "x", "peRatioTTM", None, invert=True) - + _kpi_spark("FCF Yield", fcf_yield_v, "%", "dividendYieldTTM", None, invert=False) + + _kpi_spark("FCF Yield", fcf_yield_v, "%", None, None, invert=False) ) # ── Assemble val rows ─────────────────────────────────────────────────── @@ -727,7 +726,7 @@ def _render_ratios(ticker: str): cash_rows_html = ( '<div class="kr-mini head"><span>Metric</span><span class="r">Subject</span><span class="r">Peers</span><span class="r">Trend</span></div>' - + _mini_row("FCF yield", fcf_yield_v, "%", _pm("dividendYieldTTM"), None) + + _mini_row("FCF yield", fcf_yield_v, "%", None, None) + _mini_row("Dividend yield", div_y, "%", _pm("dividendYieldTTM"), None) + _mini_row("Payout ratio", payout, "%", _pm("dividendPayoutRatioTTM"), None, good_low=True) + _mini_row("Buyback yield", buyback_yield, "%", None, None) @@ -1213,531 +1212,6 @@ def _fmt_b(v_dollars: float) -> str: 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 = json_for_script([{ - "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 = json_for_script({ - "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}, - }) - - data_json = json_for_script({ - "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%)" - 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 - 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): - 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 "—" - - # Rail filing strings (static, Python-formatted) - net_debt_raw = ctx["total_debt"] - ctx["cash_and_equivalents"] - base_fcf_str = _fmt_b(result["base_fcf"]) - hist_growth_str = f"{result['growth_rate_used']*100:+.1f}%" - net_debt_str = _fmt_b(net_debt_raw) - shares_str = f"{ctx['shares']/1e9:.2f} B" - net_debt_label = f"Net debt{(' · ' + source_date) if source_date 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} -/* 2-col inspector layout */ -.dcf-inspector{{display:grid;grid-template-columns:272px 1fr;min-height:100%;background:var(--ink-0)}} -.dcf-rail{{padding:20px 16px 32px;border-right:1px solid var(--line-1);display:flex;flex-direction:column;gap:0;background:var(--ink-0)}} -.dcf-canvas-inner{{display:flex;flex-direction:column;gap:24px;padding:24px 24px 48px}} -/* Rail type */ -.dcf-eyebrow{{font-family:var(--font-sans);font-size:11px;text-transform:uppercase;letter-spacing:.18em;color:var(--fg-3);font-weight:600;line-height:1}} -.dcf-title{{font-family:'EB Garamond',Georgia,serif;font-size:20px;font-weight:500;letter-spacing:-.01em;color:var(--fg-1);margin:6px 0 0;line-height:1.2}} -.dcf-sub{{font-family:var(--font-sans);font-size:12px;color:var(--fg-3);margin-top:6px;line-height:1.5}} -.dcf-divider{{border:none;border-top:1px solid var(--line-1);margin:14px 0}} -.dcf-filings-eyebrow{{font-family:var(--font-sans);font-size:11px;text-transform:uppercase;letter-spacing:.18em;color:var(--fg-3);font-weight:600;margin-bottom:10px}} -.dcf-filing-row{{display:flex;justify-content:space-between;align-items:baseline;font-family:var(--font-mono);font-size:12px;color:var(--fg-2);margin-bottom:6px}} -.dcf-filing-val{{color:var(--fg-1);font-variant-numeric:tabular-nums}} -/* Rail sliders */ -.rail-sliders{{display:flex;flex-direction:column;gap:14px;margin-top:14px}} -.rail-sl-item{{display:flex;flex-direction:column;gap:5px}} -.rail-sl-head{{display:flex;justify-content:space-between;align-items:baseline}} -.rail-sl-lbl{{font-family:var(--font-sans);font-size:12px;color:var(--fg-2)}} -.rail-sl-val{{font-family:var(--font-mono);font-size:12px;color:var(--brass-bright);font-variant-numeric:tabular-nums}} -.rail-warn{{font-family:var(--font-sans);font-size:11px;color:var(--warning);padding:6px 8px;background:var(--warning-bg);border-radius:4px;margin-top:4px}} -.dcf-rail input[type=range]{{width:100%;-webkit-appearance:none;appearance:none;background:var(--ink-3);height:4px;border-radius:999px;cursor:pointer;outline:none}} -.dcf-rail 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}} -.dcf-rail input[type=range]::-moz-range-thumb{{width:14px;height:14px;border-radius:50%;background:var(--brass);border:2px solid #0B0E13;cursor:pointer;border:none}} -.rail-sl-hint{{display:flex;justify-content:space-between;font-family:var(--font-mono);font-size:10px;color:var(--fg-4);margin-top:3px;letter-spacing:.02em}} -.rail-actions{{display:flex;flex-direction:column;gap:8px;margin-top:16px}} -.rail-btn{{font-family:var(--font-sans);font-size:12px;color:var(--fg-3);background:var(--ink-2);border:1px solid var(--line-2);border-radius:3px;padding:7px 12px;cursor:pointer;text-align:center;transition:color .15s,border-color .15s;width:100%}} -.rail-btn:hover{{color:var(--fg-1);border-color:var(--line-3)}} -.rail-btn[disabled]{{opacity:.4;cursor:not-allowed;pointer-events:none}} -</style> -</head> -<body> -<div class="dcf-inspector"> - - <aside class="dcf-rail"> - <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> - - <div class="rail-sliders"> - <div class="rail-sl-item"> - <div class="rail-sl-head"> - <span class="rail-sl-lbl">WACC (%)</span> - <span class="rail-sl-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 class="rail-sl-hint"><span>4.0 aggressive</span><span>conservative 15.0</span></div> - </div> - <div class="rail-sl-item"> - <div class="rail-sl-head"> - <span class="rail-sl-lbl">Terminal growth (%)</span> - <span class="rail-sl-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 class="rail-sl-hint"><span>0.0 conservative</span><span>aggressive 5.0</span></div> - </div> - <div class="rail-sl-item"> - <div class="rail-sl-head"> - <span class="rail-sl-lbl">Forecast horizon (yr)</span> - <span class="rail-sl-val" id="yrs-disp">{yrs} yr</span> - </div> - <input type="range" id="sl-yrs" min="3" max="10" step="1" value="{yrs}"> - <div class="rail-sl-hint"><span>3 yr short</span><span>extended 10 yr</span></div> - </div> - <div class="rail-sl-item"> - <div class="rail-sl-head"> - <span class="rail-sl-lbl">FCF growth (%)</span> - <span class="rail-sl-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 class="rail-sl-hint"><span>-15 decline</span><span>growth +20</span></div> - </div> - </div> - <div class="rail-warn" id="wacc-tg-warn" style="display:none">WACC must exceed terminal growth</div> - - <hr class="dcf-divider"> - - <div class="dcf-filings-eyebrow">From the filings</div> - <div class="dcf-filing-row"><span>Base FCF (TTM)</span><span class="dcf-filing-val">{base_fcf_str}</span></div> - <div class="dcf-filing-row"><span>FCF · 5-yr median</span><span class="dcf-filing-val">{hist_growth_str}</span></div> - <div class="dcf-filing-row"><span>{net_debt_label}</span><span class="dcf-filing-val">{net_debt_str}</span></div> - <div class="dcf-filing-row"><span>Shares outstanding</span><span class="dcf-filing-val">{shares_str}</span></div> - - <div class="rail-actions"> - <button class="rail-btn" onclick="resetSliders()">Reset to defaults</button> - <button class="rail-btn" disabled>Save scenario · soon</button> - </div> - </aside> - - <div class="dcf-canvas-inner"> - - <section class="va-verdict" style="--verdict-gradient:{verdict_gradient}"> - <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" 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}" id="upside-pill">{pill_text}</span> - </div> - </div> - <div class="band"> - <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" 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 id="cf-thead"><th></th>{hdr_cells}</tr></thead> - <tbody> - <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> - - <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" 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" id="equity-node-val">{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" id="recon-iv">{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" 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> - <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> - - </div> -</div> -<script> -var D = {data_json}; -var LAYOUT = {plotly_layout_json}; -var INIT_WACC = {wacc_pct}; -var INIT_TG = {tg_pct}; -var INIT_YRS = {yrs}; -var INIT_G = {g_pct}; - -function resetSliders() {{ - document.getElementById('sl-wacc').value = INIT_WACC; - document.getElementById('sl-tg').value = INIT_TG; - document.getElementById('sl-yrs').value = INIT_YRS; - document.getElementById('sl-g').value = INIT_G; - update(); -}} - -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>""" - - return html - - def _build_multiples_canvas_html(ctx: dict) -> str: market = float(ctx["current_price"] or 0) shares = float(ctx["shares"] or 0) @@ -2700,22 +2174,11 @@ def _render_dcf_model(ctx: dict): components.html(canvas_html, height=1500, scrolling=False) -def _render_all_multiples(ctx: dict): - """Render all three multiples methods side-by-side in a single HTML canvas. - - Three lenses (EV/EBITDA, EV/Revenue, P/Book) are shown in a math-flow - comparison grid. All computation and slider interactivity happens client-side - in JS. No Streamlit sliders or rail column — one full-width components.html() - call only. - """ +def _render_multiples_model(ctx: dict): doc = _build_multiples_canvas_html(ctx) components.html(doc, height=1900, scrolling=False) -def _render_multiples_model(ctx: dict): - _render_all_multiples(ctx) - - def _render_ev_ebitda_model(ctx: dict): st.markdown("**EV/EBITDA Valuation**") st.caption( @@ -3375,7 +2838,7 @@ def _render_comps(ticker: str): "nPeers": n_peers, }) - total_height = 2600 + max(0, n_peers - 10) * 54 + total_height = max(1900, 1500 + n_peers * 80) ctx_html = ( '<div class="val-ctx">' @@ -4434,6 +3897,9 @@ def _render_historical_ratios(ticker: str): info = get_company_info(ticker) with st.spinner("Loading historical ratios…"): hist_rows = get_historical_ratios(ticker, limit=10) + peers_raw = get_peers(ticker) + peers = [p for p in (peers_raw or []) if p.upper() != ticker.upper()][:6] + peer_ratios_list = get_ratios_for_tickers(peers) if peers else [] if not hist_rows: st.info("Historical ratio data unavailable.") return @@ -4443,11 +3909,6 @@ def _render_historical_ratios(ticker: str): y = str(r.get("date", ""))[:4] periods.append("FY" + y[2:] if len(y) == 4 else y) - # ── Sector median data from TTM peer ratios ─────────────────────────────── - peers_raw = get_peers(ticker) - peers = [p for p in (peers_raw or []) if p.upper() != ticker.upper()][:6] - peer_ratios_list = get_ratios_for_tickers(peers) if peers else [] - def _peer_median(field_ttm): vals = [] for pr in peer_ratios_list: |
