diff options
| author | Tyler <tyler@tylerhoang.xyz> | 2026-05-17 02:08:31 -0700 |
|---|---|---|
| committer | Tyler <tyler@tylerhoang.xyz> | 2026-05-17 02:08:31 -0700 |
| commit | 811fb970ac87c6f9e78e24889011623f097041a5 (patch) | |
| tree | 07b6f581b535c1f0f3b2630eaa8e29378478d513 | |
| parent | 4d2e98b3cddba6e89933e7a656feeacef575938f (diff) | |
| parent | 85160a558b2d37965ee2b8799fdd177ec986eadd (diff) | |
Merge branch 'feature/valuation-redesign'
| -rw-r--r-- | components/valuation.py | 2582 |
1 files changed, 1270 insertions, 1312 deletions
diff --git a/components/valuation.py b/components/valuation.py index 9525c69..f0fbdb9 100644 --- a/components/valuation.py +++ b/components/valuation.py @@ -443,6 +443,7 @@ def _render_ratios(ticker: str): price = get_latest_price(ticker) market_cap = get_market_cap_computed(ticker) fcf_ttm = get_free_cash_flow_ttm(ticker) + revenue_ttm = get_revenue_ttm(ticker) hist_rows = get_historical_ratios(ticker, limit=7) # Peer set @@ -461,6 +462,7 @@ def _render_ratios(ticker: str): pe = _r("peRatioTTM") or (info.get("trailingPE") if info else None) pe_fwd = _r("forwardPE") or (info.get("forwardPE") if info else None) + peg = _r("pegRatioTTM") or (info.get("pegRatio") if info else None) ev_ebt = _r("enterpriseValueMultipleTTM") ev_rev = _r("evToSalesTTM") pb = _r("priceToBookRatioTTM") @@ -480,8 +482,20 @@ def _render_ratios(ticker: str): div_y = _r("dividendYieldTTM") payout = _r("dividendPayoutRatioTTM") ebitda = _r("ebitdaTTM") + + # EBITDA margin: ebitda / revenue_ttm + ebitda_margin = None + try: + rev_v = float(revenue_ttm) if revenue_ttm else None + ebt_v = float(ebitda) if ebitda else None + if rev_v and rev_v > 0 and ebt_v is not None: + ebitda_margin = ebt_v / rev_v + except (TypeError, ValueError): + pass + cash_raw = None net_debt_ebt = None + cash_mkt = None try: bridge = get_balance_sheet_bridge_items(ticker) cash_raw = bridge.get("cash_and_equivalents") @@ -490,17 +504,26 @@ def _render_ratios(ticker: str): net_debt_ebt = (total_debt - cash_raw) / ebitda if cash_raw and market_cap and market_cap > 0: cash_mkt = cash_raw / market_cap - else: - cash_mkt = None except Exception: - cash_mkt = None - net_debt_ebt = None + pass + + # Buyback yield + buyback_yield = growth.get("buybackYield") + + # Total shareholder yield + total_yield = None + try: + parts = [x for x in [fcf_yield_v, buyback_yield, div_y] if x is not None] + if parts: + total_yield = sum(parts) + except (TypeError, ValueError): + pass # Price info prev_close = info.get("previousClose") if info else None if price and prev_close and prev_close > 0: chg_pct = (price - prev_close) / prev_close * 100 - chg_str = f"{'▲' if chg_pct >= 0 else '▼'} {'+' if chg_pct >= 0 else ''}{chg_pct:.2f}%" + chg_str = ("▲" if chg_pct >= 0 else "▼") + " " + ("+" if chg_pct >= 0 else "") + f"{chg_pct:.2f}%" chg_cls = "chg-pos" if chg_pct >= 0 else "chg-neg" else: chg_str, chg_cls = "", "chg-pos" @@ -523,28 +546,27 @@ def _render_ratios(ticker: str): try: fv_f, sv_f = float(v), float(sector_v) if kind == "%": - # Show absolute percentage-point difference (design: "+4.1pp") diff_pp = (fv_f - sv_f) * 100 tone = "flat" if abs(diff_pp) < 0.3 else ("neg" if (invert or good_low) == (diff_pp > 0) else "pos") - mini_cls = f'<span class="mini {tone}">{diff_pp:+.1f}pp</span>' + mini_cls = '<span class="mini ' + tone + '">' + f"{diff_pp:+.1f}pp</span>" else: diff = (fv_f - sv_f) / abs(sv_f) * 100 tone = _tone(diff, invert or good_low) - mini_cls = f'<span class="mini {tone}">{diff:+.0f}%</span>' - sector_html = f'<span class="s num">{sv}{mini_cls}</span>' + mini_cls = '<span class="mini ' + tone + '">' + f"{diff:+.0f}%</span>" + sector_html = '<span class="s num r">' + sv + mini_cls + '</span>' except Exception: - sector_html = f'<span class="s num">{sv}</span>' + sector_html = '<span class="s num r">' + sv + '</span>' else: - sector_html = f'<span class="s num">{sv}</span>' + sector_html = '<span class="s num r">' + sv + '</span>' spark_color = "var(--positive)" if not (invert or good_low) else "var(--warning)" spark_svg = _svg_spark(spark_data, 86, 20, spark_color) if spark_data else "" return ( - f'<div class="kr-mini">' - f'<span class="lbl">{lbl}</span>' - f'<span class="v num">{fv}</span>' - f'{sector_html}' - f'<span class="r">{spark_svg}</span>' - f'</div>' + '<div class="kr-mini">' + + '<span class="lbl">' + lbl + '</span>' + + '<span class="v num r">' + fv + '</span>' + + sector_html + + '<span class="r">' + spark_svg + '</span>' + + '</div>' ) # ── Helper: build peer band section ──────────────────────────────────── @@ -560,75 +582,49 @@ def _render_ratios(ticker: str): try: diff = (float(v) - p50) / abs(p50) * 100 tone = _tone(diff, invert) - d_str = f"{'+' if diff >= 0 else ''}{diff:.0f}%" + d_str = ("+" if diff >= 0 else "") + f"{diff:.0f}%" except Exception: tone, d_str = "flat", "—" else: tone, d_str = "flat", "—" if five_avg is not None and v is not None: try: - d_avg = (float(v) - float(five_avg)) / abs(float(five_avg)) * 100 + d_avg = (float(v) - float(five_avg)) / (abs(float(five_avg)) or 1) * 100 avg_tone = _tone(d_avg, invert) avg_html = ( - f'<span class="v dim num r">' - f'{_fmtv(five_avg, kind)}' - f'<span class="mini {avg_tone}">{d_avg:+.0f}%</span>' - f'</span>' + '<span class="v dim num r">' + + _fmtv(five_avg, kind) + + '<span class="mini ' + avg_tone + '">' + f"{d_avg:+.0f}%" + '</span>' + + '</span>' ) except Exception: - avg_html = f'<span class="v dim num r">{_fmtv(five_avg, kind)}</span>' + avg_html = '<span class="v dim num r">' + _fmtv(five_avg, kind) + '</span>' else: - avg_html = f'<span class="v dim num r">{_fmtv(five_avg, kind)}</span>' + avg_html = '<span class="v dim num r">' + _fmtv(five_avg, kind) + '</span>' spark_color = "var(--negative)" if tone == "neg" else ("var(--positive)" if tone == "pos" else "var(--brass-bright)") spark_svg = _svg_spark(spark_data, 108, 24, spark_color) if spark_data else "" peer_bar = _peer_bar_html(v, p25, p50, p75, bmin, bmax) peer_axis = "" if p25 is not None: peer_axis = ( - f'<div class="peer-axis">' - f'<span>{_fmtv(p25, kind)}</span>' - f'<span>{_fmtv(p50, kind)}</span>' - f'<span>{_fmtv(p75, kind)}</span>' - f'</div>' + '<div class="peer-axis">' + + '<span>' + _fmtv(p25, kind) + '</span>' + + '<span>' + _fmtv(p50, kind) + '</span>' + + '<span>' + _fmtv(p75, kind) + '</span>' + + '</div>' ) return ( - f'<div class="kr-rowgrid">' - f'<span class="lbl">{lbl}</span>' - f'<span class="v num r">{fv}</span>' - f'<span class="d {tone} r">{d_str}</span>' - f'<div class="peer-wrap">{peer_bar}{peer_axis}</div>' - f'{avg_html}' - f'{spark_svg}' - f'</div>' + '<div class="kr-rowgrid">' + + '<span class="lbl">' + lbl + '</span>' + + '<span class="v num r">' + fv + '</span>' + + '<span class="d ' + tone + ' r">' + d_str + '</span>' + + '<div class="peer-wrap">' + peer_bar + peer_axis + '</div>' + + avg_html + + spark_svg + + '</div>' ) # ── Snapshot KPIs ─────────────────────────────────────────────────────── - def _kpi(lbl, v, kind, field, invert=False): - fv = _fmtv(v, kind) - band = peer_bands.get(field, {}) - p50 = band.get("p50") - sect_str = _fmtv(p50, kind) if p50 is not None else "—" - if v is not None and p50 is not None: - try: - diff = (float(v) - p50) / abs(p50) * 100 - tone = _tone(diff, invert) - d_str = f"{'+' if diff >= 0 else ''}{diff:.0f}% vs peers" - except Exception: - tone, d_str = "flat", "—" - else: - tone, d_str = "flat", "—" - # Use historical data for sparkline when available - return tone, ( - f'<div class="kr-kpi">' - f'<div class="top"><span class="lbl">{lbl}</span></div>' - f'<span class="v num">{fv}</span>' - f'<div class="bot">' - f'<span class="sector num">peers {sect_str}</span>' - f'<span class="d {tone} num">{d_str}</span>' - f'</div>' - f'</div>' - ) - def _kpi_spark(lbl, v, kind, field, spark_data, invert=False): fv = _fmtv(v, kind) band = peer_bands.get(field, {}) @@ -638,7 +634,7 @@ def _render_ratios(ticker: str): try: diff = (float(v) - p50) / abs(p50) * 100 tone = _tone(diff, invert) - d_str = f"{'+' if diff >= 0 else ''}{diff:.0f}% vs peers" + d_str = ("+" if diff >= 0 else "") + f"{diff:.0f}% vs peers" except Exception: tone, d_str = "flat", "—" else: @@ -646,26 +642,16 @@ def _render_ratios(ticker: str): spark_color = "var(--negative)" if tone == "neg" else ("var(--positive)" if tone == "pos" else "var(--brass-bright)") spark_svg = _svg_spark(spark_data, 68, 22, spark_color) if spark_data else "" return ( - f'<div class="kr-kpi">' - f'<div class="top"><span class="lbl">{lbl}</span>{spark_svg}</div>' - f'<span class="v num">{fv}</span>' - f'<div class="bot">' - f'<span class="sector num">peers {sect_str}</span>' - f'<span class="d {tone} num">{d_str}</span>' - f'</div>' - f'</div>' + '<div class="kr-kpi">' + + '<div class="top"><span class="lbl">' + lbl + '</span>' + spark_svg + '</div>' + + '<span class="v num">' + fv + '</span>' + + '<div class="bot">' + + '<span class="sector num">peers ' + sect_str + '</span>' + + '<span class="d ' + tone + ' num">' + d_str + '</span>' + + '</div>' + + '</div>' ) - # Peer-median for snapshot section headings (approximated from bands) - snap_html = ( - _kpi_spark("P / E", pe, "x", "peRatioTTM", sparks.get("pe"), invert=True) - + _kpi_spark("EV / EBITDA", ev_ebt, "x", "enterpriseValueMultipleTTM", sparks.get("evEbt"), invert=True) - + _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) - ) - # ── Get 5-yr averages from historical rows ────────────────────────────── def _hist_avg(field): vals = [r.get(field) for r in hist_rows if r.get(field) is not None] @@ -685,13 +671,21 @@ def _render_ratios(ticker: str): # Peer medians for detail rows def _pm(field): return peer_bands.get(field, {}).get("p50") - # ── Assemble HTML ─────────────────────────────────────────────────────── - ctx_price = f'<span class="px num">${price:,.2f}</span>' if price else "" - ctx_chg = f'<span class="{chg_cls} num">{chg_str}</span>' if chg_str else "" + # ── Snapshot strip ────────────────────────────────────────────────────── + snap_html = ( + _kpi_spark("P / E", pe, "x", "peRatioTTM", sparks.get("pe"), invert=True) + + _kpi_spark("EV / EBITDA", ev_ebt, "x", "enterpriseValueMultipleTTM", sparks.get("evEbt"), invert=True) + + _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, "%", None, None, invert=False) + ) + # ── Assemble val rows ─────────────────────────────────────────────────── val_rows_html = ( _val_row("P / E · TTM", pe, "x", "peRatioTTM", pe_5avg, sparks.get("pe"), invert=True) + _val_row("P / E · Forward", pe_fwd, "x", "forwardPE", None, None, invert=True) + + _val_row("PEG · 5-yr", peg, "x", "pegRatioTTM", None, None, invert=True) + _val_row("EV / EBITDA", ev_ebt, "x", "enterpriseValueMultipleTTM", evEbt_5avg, sparks.get("evEbt"), invert=True) + _val_row("EV / Revenue", ev_rev, "x", "evToSalesTTM", None, None, invert=True) + _val_row("P / Book", pb, "x", "priceToBookRatioTTM", pb_5avg, sparks.get("pb"), invert=True) @@ -700,108 +694,123 @@ def _render_ratios(ticker: str): ) prof_rows_html = ( - '<div class="kr-mini head"><span>Metric</span><span>Subject</span><span>Peers + Δ</span><span class="r">Trend</span></div>' - + _mini_row("Gross margin", gross_m, "%", _pm("grossProfitMarginTTM"), sparks.get("gross")) - + _mini_row("Operating margin", op_m, "%", _pm("operatingProfitMarginTTM"), sparks.get("op")) - + _mini_row("Net margin", net_m, "%", _pm("netProfitMarginTTM"), sparks.get("net")) - + _mini_row("Return on equity", roe, "%", _pm("returnOnEquityTTM"), sparks.get("roe")) - + _mini_row("Return on assets", roa, "%", _pm("returnOnAssetsTTM"), sparks.get("roa")) - + _mini_row("Return on invested capital", roic, "%", _pm("returnOnInvestedCapitalTTM"), None) + '<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("Gross margin", gross_m, "%", _pm("grossProfitMarginTTM"), sparks.get("gross")) + + _mini_row("Operating margin", op_m, "%", _pm("operatingProfitMarginTTM"), sparks.get("op")) + + _mini_row("EBITDA margin", ebitda_margin,"%", None, None) + + _mini_row("Net margin", net_m, "%", _pm("netProfitMarginTTM"), sparks.get("net")) + + _mini_row("Return on equity", roe, "%", _pm("returnOnEquityTTM"), sparks.get("roe")) + + _mini_row("Return on assets", roa, "%", _pm("returnOnAssetsTTM"), sparks.get("roa")) + + _mini_row("Return on invested capital", roic, "%", _pm("returnOnInvestedCapitalTTM"), None) ) growth_rows_html = ( - '<div class="kr-mini head"><span>Metric</span><span>Subject</span><span>Peers + Δ</span><span class="r">Trend</span></div>' + '<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("Revenue · TTM YoY", growth.get("revYoY"), "%", _pm("revenueGrowthTTM"), None) + _mini_row("Revenue · 3-yr CAGR", growth.get("rev3yrCAGR"), "%", None, None) + _mini_row("EPS · TTM YoY", growth.get("epsYoY"), "%", _pm("earningsGrowthTTM"), None) + _mini_row("FCF · TTM YoY", growth.get("fcfYoY"), "%", None, None) - + _mini_row("Operating income YoY",growth.get("opIncYoY"), "%", None, None) - + _mini_row("Diluted shares YoY", growth.get("sharesYoY"), "%", None, None, invert=True) + + _mini_row("Operating income YoY", growth.get("opIncYoY"), "%", None, None) + + _mini_row("Diluted shares YoY", growth.get("sharesYoY"), "%", None, None, invert=True) ) health_rows_html = ( - '<div class="kr-mini head"><span>Metric</span><span>Subject</span><span>Peers</span><span class="r">Trend</span></div>' - + _mini_row("Net debt / EBITDA", net_debt_ebt, "x", _pm("debtToEquityRatioTTM"), None, good_low=True) - + _mini_row("Total debt / Equity", d_e, "x", _pm("debtToEquityRatioTTM"), sparks.get("de"), good_low=True) - + _mini_row("Interest coverage", coverage, "x", _pm("interestCoverageRatioTTM"), None) - + _mini_row("Current ratio", cur_r, "x", _pm("currentRatioTTM"), None) - + _mini_row("Quick ratio", quick_r, "x", _pm("quickRatioTTM"), None) - + _mini_row("Cash / Market cap", cash_mkt, "%", None, None) + '<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("Net debt / EBITDA", net_debt_ebt, "x", _pm("debtToEquityRatioTTM"), None, good_low=True) + + _mini_row("Total debt / Equity",d_e, "x", _pm("debtToEquityRatioTTM"), sparks.get("de"), good_low=True) + + _mini_row("Interest coverage", coverage, "x", _pm("interestCoverageRatioTTM"), None) + + _mini_row("Current ratio", cur_r, "x", _pm("currentRatioTTM"), None) + + _mini_row("Quick ratio", quick_r, "x", _pm("quickRatioTTM"), None) + + _mini_row("Cash / Market cap", cash_mkt, "%", None, None) ) cash_rows_html = ( - '<div class="kr-mini head"><span>Metric</span><span>Subject</span><span>Peers</span><span class="r">Trend</span></div>' - + _mini_row("FCF yield", fcf_yield_v, "%", _pm("dividendYieldTTM"), 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", growth.get("buybackYield"), "%", None, None) + '<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, "%", 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) ) + if total_yield is not None: + cash_rows_html = cash_rows_html + _mini_row("Total yield", total_yield, "%", None, None) + + # ── Assemble HTML body (string concatenation only — no f-strings) ─────── + ctx_price = ('<span class="px num">$' + f"{price:,.2f}" + '</span>') if price else "" + ctx_chg = ('<span class="' + chg_cls + ' num">' + chg_str + '</span>') if chg_str else "" body = ( - f'<div class="val-ctx">' - f'<span class="sym">{ticker.upper()}</span>' - f'<span class="name">{co_name}</span>' - f'<span class="eyebrow-ctx" style="margin-left:12px">Valuation · Key Ratios</span>' - f'<div class="meta"><span>{exchange}</span>{ctx_price}{ctx_chg}</div>' - f'</div>' - f'<div class="kr-body">' - f'<section class="kr-lede">' - f'<div class="left">' - f'<span class="eyebrow-lbl">Snapshot</span>' - f'<div class="ttl">Where the lens sits — six headline ratios, scored against the peer set</div>' - f'<p class="sub">TTM ratios, peer medians from {n_peers} peers ({sector}). Sparklines show historical drift; the peer band on each row is the 25th–75th percentile of the peer set.</p>' - f'</div>' - f'<div class="right">' - f'<div class="kr-source"><span class="lbl">Peer set</span><span class="v num">{n_peers} names</span><span class="cap">{industry[:28]}</span></div>' - f'<div class="kr-source"><span class="lbl">Basis</span><span class="v num">TTM</span><span class="cap">Trailing twelve months</span></div>' - f'<div class="kr-source"><span class="lbl">As of</span><span class="v num">{today_str}</span><span class="cap">Prices live · yfinance</span></div>' - f'</div>' - f'</section>' - f'<section class="kr-snapshot">{snap_html}</section>' - f'<section class="kr-card">' - f'<div class="kr-card-head"><div class="left-group"><span class="roman">I</span><h3>Valuation multiples</h3></div><span class="hint">Subject · Peer P25 / median / P75 · 5-yr drift</span></div>' - f'<div class="kr-rowgrid head"><span>Ratio</span><span class="r">Subject</span><span class="r">vs peers</span><span>Peer 25 — 75</span><span class="r">5-yr avg</span><span>5-yr trend</span></div>' - f'{val_rows_html}' - f'</section>' - f'<section class="kr-grid-2">' - f'<div class="kr-card"><div class="kr-card-head"><div class="left-group"><span class="roman">II</span><h3>Profitability</h3></div><span class="hint">Wider margins, higher returns on capital</span></div>{prof_rows_html}</div>' - f'<div class="kr-card"><div class="kr-card-head"><div class="left-group"><span class="roman">III</span><h3>Growth · TTM</h3></div><span class="hint">Topline & cash growth vs peers</span></div>{growth_rows_html}</div>' - f'<div class="kr-card"><div class="kr-card-head"><div class="left-group"><span class="roman">IV</span><h3>Balance-sheet health</h3></div><span class="hint">Leverage, liquidity, interest</span></div>{health_rows_html}</div>' - f'<div class="kr-card"><div class="kr-card-head"><div class="left-group"><span class="roman">V</span><h3>Cash returns</h3></div><span class="hint">Cash giveback to holders · yield</span></div>{cash_rows_html}</div>' - f'</section>' - f'<div class="va-foot"><span>Ratios computed from yfinance financial statements, TTM basis. Peer bands from {n_peers} comparable names. Market data live.</span></div>' - f'</div>' + '<div class="kr-val-wrap">' + + '<div class="val-ctx">' + + '<span class="sym">' + _h(ticker.upper()) + '</span>' + + '<span class="name">' + co_name + '</span>' + + '<span class="eyebrow-ctx" style="margin-left:12px">Valuation · Key Ratios</span>' + + '<div class="meta"><span>' + exchange + '</span>' + ctx_price + ctx_chg + '</div>' + + '</div>' + + '<div class="kr-body">' + + '<section class="kr-lede">' + + '<div class="left">' + + '<span class="eyebrow-lbl">Snapshot</span>' + + '<div class="ttl">Where the lens sits — six headline ratios, scored against the peer set</div>' + + '<p class="sub">TTM ratios, peer medians from ' + str(n_peers) + ' peers (' + sector + '). Sparklines show historical drift; the peer band on each row is the 25th–75th percentile of the peer set.</p>' + + '</div>' + + '<div class="right">' + + '<div class="kr-source"><span class="lbl">Peer set</span><span class="v num">' + str(n_peers) + ' names</span><span class="cap">' + industry[:28] + '</span></div>' + + '<div class="kr-source"><span class="lbl">Basis</span><span class="v num">TTM</span><span class="cap">Trailing twelve months</span></div>' + + '<div class="kr-source"><span class="lbl">As of</span><span class="v num">' + today_str + '</span><span class="cap">Prices live · yfinance</span></div>' + + '</div>' + + '</section>' + + '<section class="kr-snapshot">' + snap_html + '</section>' + + '<section class="kr-card">' + + '<div class="kr-card-head"><div class="left-group"><span class="roman">I</span><h3>Valuation multiples</h3></div><span class="hint">Subject · Peer P25 / median / P75 · 5-yr drift</span></div>' + + '<div class="kr-rowgrid head"><span>Ratio</span><span class="r">Subject</span><span class="r">vs peers</span><span>Peer 25 — 75</span><span class="r">5-yr avg</span><span>5-yr trend</span></div>' + + val_rows_html + + '</section>' + + '<section class="kr-grid-2">' + + '<div class="kr-card"><div class="kr-card-head"><div class="left-group"><span class="roman">II</span><h3>Profitability</h3></div><span class="hint">Wider margins, higher returns on capital</span></div>' + prof_rows_html + '</div>' + + '<div class="kr-card"><div class="kr-card-head"><div class="left-group"><span class="roman">III</span><h3>Growth · TTM</h3></div><span class="hint">Topline & cash growth vs peers</span></div>' + growth_rows_html + '</div>' + + '<div class="kr-card"><div class="kr-card-head"><div class="left-group"><span class="roman">IV</span><h3>Balance-sheet health</h3></div><span class="hint">Leverage, liquidity, interest</span></div>' + health_rows_html + '</div>' + + '<div class="kr-card"><div class="kr-card-head"><div class="left-group"><span class="roman">V</span><h3>Cash returns</h3></div><span class="hint">Cash giveback to holders · yield</span></div>' + cash_rows_html + '</div>' + + '</section>' + + '<div class="va-foot"><span>Ratios computed from yfinance financial statements, TTM basis. Peer bands from ' + str(n_peers) + ' comparable names. Market data live.</span></div>' + + '</div>' + + '</div>' ) - doc = f"""<!doctype html><html><head><meta charset="utf-8"> -<link rel="preconnect" href="https://fonts.googleapis.com"> -<link href="https://fonts.googleapis.com/css2?family=EB+Garamond:ital,wght@0,400;0,500;0,600;1,400;1,500;1,600&family=IBM+Plex+Mono:wght@300;400;500;600&family=IBM+Plex+Sans:wght@300;400;500;600;700&display=swap" rel="stylesheet"> -<style> -*,*::before,*::after{{box-sizing:border-box}} -:root{{ - --ink-0:#0B0E13;--ink-1:#11151C;--ink-2:#181D26;--ink-3:#222934;--ink-4:#2C3340; - --line-1:#232934;--line-2:#2E3645;--line-3:#3D4658; - --fg-1:#F2ECDC;--fg-2:#C7C0AE;--fg-3:#8E8676;--fg-4:#5E5849; - --brass:#C2AA7A;--brass-bright:#DCC79E;--brass-deep:#8F7A50;--brass-ink:#17120A; - --oxford:#1F3D5C;--oxford-light:#2E5A87; - --positive:#4F8C5E;--positive-bg:#15241A;--negative:#B5494B;--negative-bg:#2A1517; - --warning:#C49545;--warning-bg:#2A1F0F; - --font-display:'EB Garamond',Georgia,serif; - --font-sans:'IBM Plex Sans','Helvetica Neue',system-ui,sans-serif; - --font-mono:'IBM Plex Mono','SF Mono',Menlo,monospace; - --fs-12:0.75rem;--fs-13:0.8125rem;--fs-14:0.875rem;--fs-16:1rem;--fs-18:1.125rem; - --fs-20:1.25rem;--fs-24:1.5rem;--fs-30:1.875rem;--fs-38:2.375rem; - --tr-wider:0.12em;--tr-wide:0.04em;--tr-snug:-0.01em; - --sp-1:4px;--sp-2:8px;--sp-3:12px;--sp-4:16px;--sp-5:24px;--sp-6:32px;--sp-7:48px; - --r-1:2px;--r-2:4px;--r-3:6px;--r-full:999px; - --shadow-1:0 1px 0 rgba(0,0,0,.4),0 1px 2px rgba(0,0,0,.3); -}} -html,body{{margin:0;padding:0;background:var(--ink-0);color:var(--fg-2);font-family:var(--font-sans);font-size:14px;-webkit-font-smoothing:antialiased}} -</style> -{_KR_CSS} -</head><body>{body}</body></html>""" + # ── Assemble full HTML document (string concat, no f-strings) ────────── + doc = ( + '<!doctype html><html><head><meta charset="utf-8">' + + '<link rel="preconnect" href="https://fonts.googleapis.com">' + + '<link href="https://fonts.googleapis.com/css2?family=EB+Garamond:ital,wght@0,400;0,500;0,600;1,400;1,500;1,600&family=IBM+Plex+Mono:wght@300;400;500;600&family=IBM+Plex+Sans:wght@300;400;500;600;700&display=swap" rel="stylesheet">' + + '<style>' + + '*,*::before,*::after{box-sizing:border-box}' + + ':root{' + + ' --ink-0:#0B0E13;--ink-1:#11151C;--ink-2:#181D26;--ink-3:#222934;--ink-4:#2C3340;' + + ' --line-1:#232934;--line-2:#2E3645;--line-3:#3D4658;' + + ' --fg-1:#F2ECDC;--fg-2:#C7C0AE;--fg-3:#8E8676;--fg-4:#5E5849;' + + ' --brass:#C2AA7A;--brass-bright:#DCC79E;--brass-deep:#8F7A50;--brass-ink:#17120A;' + + ' --oxford:#1F3D5C;--oxford-light:#2E5A87;' + + ' --positive:#4F8C5E;--positive-bg:#15241A;--negative:#B5494B;--negative-bg:#2A1517;' + + ' --warning:#C49545;--warning-bg:#2A1F0F;' + + " --font-display:'EB Garamond',Georgia,serif;" + + " --font-sans:'IBM Plex Sans','Helvetica Neue',system-ui,sans-serif;" + + " --font-mono:'IBM Plex Mono','SF Mono',Menlo,monospace;" + + ' --fs-12:0.75rem;--fs-13:0.8125rem;--fs-14:0.875rem;--fs-16:1rem;--fs-18:1.125rem;' + + ' --fs-20:1.25rem;--fs-24:1.5rem;--fs-30:1.875rem;--fs-38:2.375rem;' + + ' --tr-wider:0.12em;--tr-wide:0.04em;--tr-snug:-0.01em;' + + ' --sp-1:4px;--sp-2:8px;--sp-3:12px;--sp-4:16px;--sp-5:24px;--sp-6:32px;--sp-7:48px;' + + ' --r-1:2px;--r-2:4px;--r-3:6px;--r-full:999px;' + + ' --shadow-1:0 1px 0 rgba(0,0,0,.4),0 1px 2px rgba(0,0,0,.3);' + + '}' + + 'html,body{margin:0;padding:0;background:var(--ink-0);color:var(--fg-2);font-family:var(--font-sans);font-size:14px;-webkit-font-smoothing:antialiased}' + + '</style>' + + _KR_CSS + + '</head><body>' + + body + + '</body></html>' + ) + + components.html(doc, height=2600, scrolling=False) - components.html(doc, height=2400, scrolling=True) # ── Models ─────────────────────────────────────────────────────────────────── @@ -1203,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) @@ -1894,7 +1378,7 @@ def _build_multiples_canvas_html(ctx: dict) -> str: dcf_val_str = "—" dcf_meta_str = "Switch to DCF tab to compute" - ticker = ctx["ticker"] + ticker = _h(ctx["ticker"]) exchange = _h((ctx.get("info") or {}).get("exchange") or "—") data_json = json_for_script({ @@ -1905,338 +1389,649 @@ def _build_multiples_canvas_html(ctx: dict) -> str: "ebSector": eb_sector, "rvSector": rv_sector, "pbSector": pb_sector, }) - html = f"""<!DOCTYPE html> -<html> -<head> -<meta charset="utf-8"> -<style> -{_DCF_CANVAS_CSS} -{_MULT_CANVAS_CSS} -</style> -</head> -<body> -<div class="vm-body"> + html = ("<!DOCTYPE html>" + "<html>" + "<head>" + "<meta charset=\"utf-8\">" + "<style>" + + _DCF_CANVAS_CSS + + _MULT_CANVAS_CSS + + "</style>" + "</head>" + "<body>" + "<div class=\"vm-body\">" + "" + "<section class=\"vm-summary\">" + " <div class=\"vm-summary-head\">" + " <span class=\"eyebrow\">Multiples</span>" + " <h2 class=\"ttl\">Three relative-valuation lenses — implied per-share</h2>" + " <p class=\"lede\">Subject multiple × normalized TTM metric, bridged to equity per share. Compare across columns to see which lens the market is leaning on.</p>" + " </div>" + " <div class=\"vm-summary-strip\">" + " <div class=\"vm-sum-cell\">" + " <span class=\"lbl\">EV / EBITDA</span>" + " <span class=\"v num\" id=\"sum-eb-val\">" + _fs(eb_per0) + "</span>" + " " + _ds_span(eb_per0, 'id="sum-eb-d"') + "" + " </div>" + " <div class=\"vm-sum-cell\">" + " <span class=\"lbl\">EV / Revenue</span>" + " <span class=\"v num\" id=\"sum-rv-val\">" + _fs(rv_per0) + "</span>" + " " + _ds_span(rv_per0, 'id="sum-rv-d"') + "" + " </div>" + " <div class=\"vm-sum-cell\">" + " <span class=\"lbl\">P / Book</span>" + " <span class=\"v num\" id=\"sum-pb-val\">" + _fs(pb_per0) + "</span>" + " " + _ds_span(pb_per0, 'id="sum-pb-d"') + "" + " </div>" + " <div class=\"vm-sum-cell market\">" + " <span class=\"lbl\">Market · last</span>" + " <span class=\"v num\">" + (_fs(market) if has_market else "—") + "</span>" + " <span class=\"d num\" style=\"color:var(--fg-3)\">" + ticker + " · " + exchange + "</span>" + " </div>" + " </div>" + "</section>" + "" + "<section class=\"vm-compare\">" + " <div class=\"vm-compare-head\">" + " <h3>Method comparison</h3>" + " <span class=\"units\">USD · TTM metrics · balance-sheet bridge</span>" + " </div>" + "" + " <div class=\"vm-grid head\">" + " <div class=\"vm-row-lbl\">Method</div>" + " <div class=\"vm-col-head\">" + " <div class=\"vm-col-title\"><span class=\"n\">I</span><h4>EV / EBITDA</h4><span class=\"fit ok\">Strong fit</span></div>" + " <p class=\"lede\">Enterprise value normalized by operating cash profit. Strips depreciation and capital structure so different capex profiles compare cleanly.</p>" + " </div>" + " <div class=\"vm-col-head\">" + " <div class=\"vm-col-title\"><span class=\"n\">II</span><h4>EV / Revenue</h4><span class=\"fit ok\">Strong fit</span></div>" + " <p class=\"lede\">Topline multiple — useful when margins are volatile or the business is reinvesting through profitability. Less sensitive to one-off charges.</p>" + " </div>" + " <div class=\"vm-col-head\">" + " <div class=\"vm-col-title\"><span class=\"n\">III</span><h4>P / Book</h4><span class=\"fit " + pb_fit_cls + "\">" + pb_fit_lbl + "</span></div>" + " <p class=\"lede\">Equity multiple — works for balance-sheet-heavy businesses (banks, insurers, REITs). Limited signal for asset-light software & services.</p>" + " </div>" + " </div>" + "" + " <div class=\"vm-grid\">" + " <div class=\"vm-row-lbl\">Subject multiple<span class=\"sub\">drag to flex the lens</span></div>" + " <div class=\"vm-cell mult\">" + " <div class=\"mult-top\"><span class=\"big num\" id=\"big-eb\">" + _fx(eb_init) + "</span><span class=\"sector num\">sector " + _fx(eb_sector) + "</span></div>" + " <div class=\"mult-slider\">" + " <div class=\"track\">" + " <span class=\"band\" style=\"left:" + f"{eb_bl_pct:.1f}" + "%;right:" + f"{100-eb_bh_pct:.1f}" + "%\"></span>" + " <span class=\"marker\" style=\"left:" + f"{eb_s_pct:.1f}" + "%\"></span>" + " </div>" + " <input type=\"range\" id=\"sl-eb\" min=\"8\" max=\"32\" step=\"0.1\" value=\"" + f"{eb_init:.1f}" + "\"" + (' disabled' if not eb_ok else '') + ">" + " </div>" + " <div class=\"mult-meta\"><span>8×</span><span>typical 14×–26×</span><span>32×</span></div>" + " </div>" + " <div class=\"vm-cell mult\">" + " <div class=\"mult-top\"><span class=\"big num\" id=\"big-rv\">" + _fx(rv_init) + "</span><span class=\"sector num\">sector " + _fx(rv_sector) + "</span></div>" + " <div class=\"mult-slider\">" + " <div class=\"track\">" + " <span class=\"band\" style=\"left:" + f"{rv_bl_pct:.1f}" + "%;right:" + f"{100-rv_bh_pct:.1f}" + "%\"></span>" + " <span class=\"marker\" style=\"left:" + f"{rv_s_pct:.1f}" + "%\"></span>" + " </div>" + " <input type=\"range\" id=\"sl-rv\" min=\"4\" max=\"20\" step=\"0.1\" value=\"" + f"{rv_init:.1f}" + "\"" + (' disabled' if not rv_ok else '') + ">" + " </div>" + " <div class=\"mult-meta\"><span>4×</span><span>typical 6×–13×</span><span>20×</span></div>" + " </div>" + " <div class=\"vm-cell mult\">" + " <div class=\"mult-top\"><span class=\"big num\" id=\"big-pb\">" + _fx(pb_init) + "</span><span class=\"sector num\">sector " + _fx(pb_sector) + "</span></div>" + " <div class=\"mult-slider\">" + " <div class=\"track\">" + " <span class=\"band\" style=\"left:" + f"{pb_bl_pct:.1f}" + "%;right:" + f"{100-pb_bh_pct:.1f}" + "%\"></span>" + " <span class=\"marker\" style=\"left:" + f"{pb_s_pct:.1f}" + "%\"></span>" + " </div>" + " <input type=\"range\" id=\"sl-pb\" min=\"4\" max=\"60\" step=\"0.1\" value=\"" + f"{pb_init:.1f}" + "\"" + (' disabled' if not pb_ok else '') + ">" + " </div>" + " <div class=\"mult-meta\"><span>4×</span><span>typical 8×–14×</span><span>60×</span></div>" + " </div>" + " </div>" + "" + " <div class=\"vm-grid\">" + " <div class=\"vm-row-lbl\">× Normalized metric<span class=\"sub\">from TTM filings</span></div>" + " <div class=\"vm-cell\"><span class=\"v num\">" + (_fb(ebitda) if eb_ok else "—") + "</span><span class=\"cap\">EBITDA · TTM</span></div>" + " <div class=\"vm-cell\"><span class=\"v num\">" + (_fb(revenue) if rv_ok else "—") + "</span><span class=\"cap\">Revenue · TTM</span></div>" + " <div class=\"vm-cell\"><span class=\"v num\">" + (_fs(book_ps) if pb_ok else "—") + "</span><span class=\"cap\">Book value · /share</span></div>" + " </div>" + "" + " <div class=\"vm-grid\">" + " <div class=\"vm-row-lbl\">= Enterprise value</div>" + " <div class=\"vm-cell\"><span class=\"v num\" id=\"eb-ev-val\">" + _fb(eb_ev0) + "</span><span class=\"cap\">multiple × metric</span></div>" + " <div class=\"vm-cell\"><span class=\"v num\" id=\"rv-ev-val\">" + _fb(rv_ev0) + "</span><span class=\"cap\">multiple × metric</span></div>" + " <div class=\"vm-cell faded\"><span class=\"v num dash\">—</span><span class=\"cap\">P/B is an equity multiple — no EV step</span></div>" + " </div>" + "" + " <div class=\"vm-grid\">" + " <div class=\"vm-row-lbl\">− Net debt</div>" + " <div class=\"vm-cell\"><span class=\"v num\">" + _fb(net_debt) + "</span><span class=\"cap\">total " + _fb(total_debt) + " − cash " + _fb(cash) + "</span></div>" + " <div class=\"vm-cell\"><span class=\"v num\">" + _fb(net_debt) + "</span><span class=\"cap\">total " + _fb(total_debt) + " − cash " + _fb(cash) + "</span></div>" + " <div class=\"vm-cell faded\"><span class=\"v num dash\">—</span></div>" + " </div>" + "" + " <div class=\"vm-grid\">" + " <div class=\"vm-row-lbl\">= Equity value</div>" + " <div class=\"vm-cell\"><span class=\"v num\" id=\"eb-eq-val\">" + _fb(eb_eq0) + "</span><span class=\"cap\">EV − net debt</span></div>" + " <div class=\"vm-cell\"><span class=\"v num\" id=\"rv-eq-val\">" + _fb(rv_eq0) + "</span><span class=\"cap\">EV − net debt</span></div>" + " <div class=\"vm-cell faded\"><span class=\"v num dash\">—</span></div>" + " </div>" + "" + " <div class=\"vm-grid\">" + " <div class=\"vm-row-lbl\">÷ Shares outstanding</div>" + " <div class=\"vm-cell\"><span class=\"v num\">" + shares_str + "</span><span class=\"cap\">diluted</span></div>" + " <div class=\"vm-cell\"><span class=\"v num\">" + shares_str + "</span><span class=\"cap\">diluted</span></div>" + " <div class=\"vm-cell faded\"><span class=\"v num dash\">—</span></div>" + " </div>" + "" + " <div class=\"vm-grid result\">" + " <div class=\"vm-row-lbl strong\">= Implied per share</div>" + " <div class=\"vm-cell result\">" + " <span class=\"v num\" id=\"eb-per-val\">" + _fs(eb_per0) + "</span>" + " " + _d_span(eb_per0, 'id="eb-per-d"') + "" + " </div>" + " <div class=\"vm-cell result\">" + " <span class=\"v num\" id=\"rv-per-val\">" + _fs(rv_per0) + "</span>" + " " + _d_span(rv_per0, 'id="rv-per-d"') + "" + " </div>" + " <div class=\"vm-cell result\">" + " <span class=\"v num\" id=\"pb-per-val\">" + _fs(pb_per0) + "</span>" + " " + _d_span(pb_per0, 'id="pb-per-d"') + "" + " </div>" + " </div>" + "</section>" + "" + "<section class=\"vm-sensitivity\">" + " <div class=\"vm-sensitivity-head\">" + " <h3>If the lens shifted to sector</h3>" + " <span class=\"hint\">Same metrics, subject multiple replaced by sector median</span>" + " </div>" + " <div class=\"vm-sens-grid\">" + "" + " <div class=\"vm-sens-cell\">" + " <span class=\"lbl\">EV / EBITDA</span>" + " <div class=\"vm-sens-row\">" + " <div class=\"col\">" + " <span class=\"sub\" id=\"sens-eb-subj-lbl\">At subject " + _fx(eb_init) + "</span>" + " <span class=\"v num\" id=\"sens-eb-subj-v\">" + _fs(eb_per0) + "</span>" + " " + _ds_span(eb_per0, 'id="sens-eb-subj-d"') + "" + " </div>" + " <span class=\"arrow\">→</span>" + " <div class=\"col\">" + " <span class=\"sub\">At sector " + _fx(eb_sector) + "</span>" + " <span class=\"v num brass\">" + _fs(sec_eb) + "</span>" + " " + _ds_span(sec_eb) + "" + " </div>" + " </div>" + " <span class=\"meta\" id=\"sens-eb-meta\">Re-rating Δ " + _rr(eb_per0, sec_eb) + " per share if the subject converged to peers</span>" + " </div>" + "" + " <div class=\"vm-sens-cell\">" + " <span class=\"lbl\">EV / Revenue</span>" + " <div class=\"vm-sens-row\">" + " <div class=\"col\">" + " <span class=\"sub\" id=\"sens-rv-subj-lbl\">At subject " + _fx(rv_init) + "</span>" + " <span class=\"v num\" id=\"sens-rv-subj-v\">" + _fs(rv_per0) + "</span>" + " " + _ds_span(rv_per0, 'id="sens-rv-subj-d"') + "" + " </div>" + " <span class=\"arrow\">→</span>" + " <div class=\"col\">" + " <span class=\"sub\">At sector " + _fx(rv_sector) + "</span>" + " <span class=\"v num brass\">" + _fs(sec_rv) + "</span>" + " " + _ds_span(sec_rv) + "" + " </div>" + " </div>" + " <span class=\"meta\" id=\"sens-rv-meta\">Re-rating Δ " + _rr(rv_per0, sec_rv) + " per share if the subject converged to peers</span>" + " </div>" + "" + " <div class=\"vm-sens-cell\">" + " <span class=\"lbl\">P / Book</span>" + " <div class=\"vm-sens-row\">" + " <div class=\"col\">" + " <span class=\"sub\" id=\"sens-pb-subj-lbl\">At subject " + _fx(pb_init) + "</span>" + " <span class=\"v num\" id=\"sens-pb-subj-v\">" + _fs(pb_per0) + "</span>" + " " + _ds_span(pb_per0, 'id="sens-pb-subj-d"') + "" + " </div>" + " <span class=\"arrow\">→</span>" + " <div class=\"col\">" + " <span class=\"sub\">At sector " + _fx(pb_sector) + "</span>" + " <span class=\"v num brass\">" + _fs(sec_pb) + "</span>" + " " + _ds_span(sec_pb) + "" + " </div>" + " </div>" + " <span class=\"meta\" id=\"sens-pb-meta\">Re-rating Δ " + _rr(pb_per0, sec_pb) + " per share if the subject converged to peers</span>" + " </div>" + "" + " </div>" + "</section>" + "" + "<section class=\"vm-cx\">" + " <div class=\"vm-cx-head\">" + " <h3>Cross-check against DCF</h3>" + " <span class=\"hint\">DCF intrinsic from the firm-value model on the previous tab</span>" + " </div>" + " <div class=\"vm-cx-grid\">" + " <div class=\"vm-cx-cell dcf\">" + " <span class=\"lbl\">DCF · firm value</span>" + " <span class=\"v num\">" + dcf_val_str + "</span>" + " " + dcf_delta_html + "" + " <span class=\"meta\">" + dcf_meta_str + "</span>" + " </div>" + " <div class=\"vm-cx-cell\">" + " <span class=\"lbl\">EV / EBITDA</span>" + " <span class=\"v num\" id=\"cx-eb-val\">" + _fs(eb_per0) + "</span>" + " " + _d_span(eb_per0, 'id="cx-eb-d"') + "" + " <span class=\"meta\" id=\"cx-eb-meta\">Subject " + _fx(eb_init) + " · sector " + _fx(eb_sector) + "</span>" + " </div>" + " <div class=\"vm-cx-cell\">" + " <span class=\"lbl\">EV / Revenue</span>" + " <span class=\"v num\" id=\"cx-rv-val\">" + _fs(rv_per0) + "</span>" + " " + _d_span(rv_per0, 'id="cx-rv-d"') + "" + " <span class=\"meta\" id=\"cx-rv-meta\">Subject " + _fx(rv_init) + " · sector " + _fx(rv_sector) + "</span>" + " </div>" + " <div class=\"vm-cx-cell\">" + " <span class=\"lbl\">P / Book</span>" + " <span class=\"v num\" id=\"cx-pb-val\">" + _fs(pb_per0) + "</span>" + " " + _d_span(pb_per0, 'id="cx-pb-d"') + "" + " <span class=\"meta\" id=\"cx-pb-meta\">Subject " + _fx(pb_init) + " · sector " + _fx(pb_sector) + " · low-signal</span>" + " </div>" + " </div>" + "</section>" + "" + "<div class=\"va-foot\">" + " <span>Multiples · TTM metrics, balance sheet. Net-debt adjustment applies to EV-based methods only; P/B reads directly off book equity per share. Sector medians from peer group analysis.</span>" + " <a href=\"#\">Methodology & sources ↗</a>" + "</div>" + "" + "</div>" + "<script>" + "var D = " + data_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.toFixed(2); }" + "function fX(n) { return n.toFixed(1)+'×'; }" + "function dPct(v) { return D.hasMarket ? (v-D.market)/D.market*100 : 0; }" + "function dStr(d) {" + " var cls=d>=0?'pos':'neg', arr=d>=0?'▲':'▼', sign=d>=0?'+':'';" + " return '<span class=\"d num '+cls+'\">'+arr+' '+sign+d.toFixed(1)+'%</span>';" + "}" + "function dVsStr(d) {" + " var cls=d>=0?'pos':'neg', arr=d>=0?'▲':'▼', sign=d>=0?'+':'';" + " return '<span class=\"delta num '+cls+'\">'+arr+' '+sign+d.toFixed(1)+'% vs '+fS(D.market)+'</span>';" + "}" + "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 ebX=+document.getElementById('sl-eb').value;" + " var rvX=+document.getElementById('sl-rv').value;" + " var pbX=+document.getElementById('sl-pb').value;" + "" + " if (D.ebOk) {" + " var ebEV=ebX*D.ebitda, ebEq=ebEV-D.netDebt, ebPer=ebEq/D.shares, ebD=dPct(ebPer);" + " var secEbPer=(D.ebSector*D.ebitda-D.netDebt)/D.shares;" + " var rrEb=ebPer!==0?(secEbPer-ebPer)/Math.abs(ebPer)*100:0;" + " setText('big-eb', fX(ebX));" + " setText('sum-eb-val', fS(ebPer)); setHtml('sum-eb-d', dStr(ebD));" + " setText('eb-ev-val', fB(ebEV)); setText('eb-eq-val', fB(ebEq));" + " setText('eb-per-val', fS(ebPer)); setHtml('eb-per-d', dVsStr(ebD));" + " setText('sens-eb-subj-lbl', 'At subject '+fX(ebX));" + " setText('sens-eb-subj-v', fS(ebPer)); setHtml('sens-eb-subj-d', dStr(ebD));" + " var rrCls=rrEb>=0?'pos':'neg', rrSign=rrEb>=0?'+':'';" + " setHtml('sens-eb-meta', 'Re-rating Δ <span class=\"num '+rrCls+'\">'+rrSign+rrEb.toFixed(1)+'%</span> per share if the subject converged to peers');" + " setText('cx-eb-val', fS(ebPer)); setHtml('cx-eb-d', dVsStr(ebD));" + " setText('cx-eb-meta', 'Subject '+fX(ebX)+' · sector '+fX(D.ebSector));" + " }" + " if (D.rvOk) {" + " var rvEV=rvX*D.revenue, rvEq=rvEV-D.netDebt, rvPer=rvEq/D.shares, rvD=dPct(rvPer);" + " var secRvPer=(D.rvSector*D.revenue-D.netDebt)/D.shares;" + " var rrRv=rvPer!==0?(secRvPer-rvPer)/Math.abs(rvPer)*100:0;" + " setText('big-rv', fX(rvX));" + " setText('sum-rv-val', fS(rvPer)); setHtml('sum-rv-d', dStr(rvD));" + " setText('rv-ev-val', fB(rvEV)); setText('rv-eq-val', fB(rvEq));" + " setText('rv-per-val', fS(rvPer)); setHtml('rv-per-d', dVsStr(rvD));" + " setText('sens-rv-subj-lbl', 'At subject '+fX(rvX));" + " setText('sens-rv-subj-v', fS(rvPer)); setHtml('sens-rv-subj-d', dStr(rvD));" + " var rrCls=rrRv>=0?'pos':'neg', rrSign=rrRv>=0?'+':'';" + " setHtml('sens-rv-meta', 'Re-rating Δ <span class=\"num '+rrCls+'\">'+rrSign+rrRv.toFixed(1)+'%</span> per share if the subject converged to peers');" + " setText('cx-rv-val', fS(rvPer)); setHtml('cx-rv-d', dVsStr(rvD));" + " setText('cx-rv-meta', 'Subject '+fX(rvX)+' · sector '+fX(D.rvSector));" + " }" + " if (D.pbOk) {" + " var pbPer=pbX*D.bookPs, pbD=dPct(pbPer);" + " var secPbPer=D.pbSector*D.bookPs;" + " var rrPb=pbPer!==0?(secPbPer-pbPer)/Math.abs(pbPer)*100:0;" + " setText('big-pb', fX(pbX));" + " setText('sum-pb-val', fS(pbPer)); setHtml('sum-pb-d', dStr(pbD));" + " setText('pb-per-val', fS(pbPer)); setHtml('pb-per-d', dVsStr(pbD));" + " setText('sens-pb-subj-lbl', 'At subject '+fX(pbX));" + " setText('sens-pb-subj-v', fS(pbPer)); setHtml('sens-pb-subj-d', dStr(pbD));" + " var rrCls=rrPb>=0?'pos':'neg', rrSign=rrPb>=0?'+':'';" + " setHtml('sens-pb-meta', 'Re-rating Δ <span class=\"num '+rrCls+'\">'+rrSign+rrPb.toFixed(1)+'%</span> per share if the subject converged to peers');" + " setText('cx-pb-val', fS(pbPer)); setHtml('cx-pb-d', dVsStr(pbD));" + " setText('cx-pb-meta', 'Subject '+fX(pbX)+' · sector '+fX(D.pbSector)+' · low-signal');" + " }" + "}" + "" + "document.getElementById('sl-eb').addEventListener('input', update);" + "document.getElementById('sl-rv').addEventListener('input', update);" + "document.getElementById('sl-pb').addEventListener('input', update);" + "</script>" + "</body>" + "</html>") + return html + -<section class="vm-summary"> - <div class="vm-summary-head"> - <span class="eyebrow">Multiples</span> - <h2 class="ttl">Three relative-valuation lenses — implied per-share</h2> - <p class="lede">Subject multiple × normalized TTM metric, bridged to equity per share. Compare across columns to see which lens the market is leaning on.</p> - </div> - <div class="vm-summary-strip"> - <div class="vm-sum-cell"> - <span class="lbl">EV / EBITDA</span> - <span class="v num" id="sum-eb-val">{_fs(eb_per0)}</span> - {_ds_span(eb_per0, 'id="sum-eb-d"')} - </div> - <div class="vm-sum-cell"> - <span class="lbl">EV / Revenue</span> - <span class="v num" id="sum-rv-val">{_fs(rv_per0)}</span> - {_ds_span(rv_per0, 'id="sum-rv-d"')} - </div> - <div class="vm-sum-cell"> - <span class="lbl">P / Book</span> - <span class="v num" id="sum-pb-val">{_fs(pb_per0)}</span> - {_ds_span(pb_per0, 'id="sum-pb-d"')} - </div> - <div class="vm-sum-cell market"> - <span class="lbl">Market · last</span> - <span class="v num">{_fs(market) if has_market else "—"}</span> - <span class="d num" style="color:var(--fg-3)">{ticker} · {exchange}</span> - </div> - </div> -</section> +def _build_dcf_canvas_only_html( + ctx: dict, + result: dict, + wacc_pct: float, + tg_pct: float, + yrs: int, + g_pct: float, + ev_ebitda_price, + ev_rev_price, + pb_price, +) -> str: + """Build a standalone HTML document for the DCF canvas (no rail). -<section class="vm-compare"> - <div class="vm-compare-head"> - <h3>Method comparison</h3> - <span class="units">USD · TTM metrics · balance-sheet bridge</span> - </div> + Uses string concatenation throughout — never f-strings — because + _DCF_CANVAS_CSS contains curly braces that would break interpolation. + """ + iv = result["intrinsic_value_per_share"] + market = float(ctx["current_price"] or 0) + has_market = market > 0 - <div class="vm-grid head"> - <div class="vm-row-lbl">Method</div> - <div class="vm-col-head"> - <div class="vm-col-title"><span class="n">I</span><h4>EV / EBITDA</h4><span class="fit ok">Strong fit</span></div> - <p class="lede">Enterprise value normalized by operating cash profit. Strips depreciation and capital structure so different capex profiles compare cleanly.</p> - </div> - <div class="vm-col-head"> - <div class="vm-col-title"><span class="n">II</span><h4>EV / Revenue</h4><span class="fit ok">Strong fit</span></div> - <p class="lede">Topline multiple — useful when margins are volatile or the business is reinvesting through profitability. Less sensitive to one-off charges.</p> - </div> - <div class="vm-col-head"> - <div class="vm-col-title"><span class="n">III</span><h4>P / Book</h4><span class="fit {pb_fit_cls}">{pb_fit_lbl}</span></div> - <p class="lede">Equity multiple — works for balance-sheet-heavy businesses (banks, insurers, REITs). Limited signal for asset-light software & services.</p> - </div> - </div> + upside_pct = (iv - market) / market * 100 if has_market else 0.0 + is_pos = upside_pct >= 0 + gap = iv - market - <div class="vm-grid"> - <div class="vm-row-lbl">Subject multiple<span class="sub">drag to flex the lens</span></div> - <div class="vm-cell mult"> - <div class="mult-top"><span class="big num" id="big-eb">{_fx(eb_init)}</span><span class="sector num">sector {_fx(eb_sector)}</span></div> - <div class="mult-slider"> - <div class="track"> - <span class="band" style="left:{eb_bl_pct:.1f}%;right:{100-eb_bh_pct:.1f}%"></span> - <span class="marker" style="left:{eb_s_pct:.1f}%"></span> - </div> - <input type="range" id="sl-eb" min="8" max="32" step="0.1" value="{eb_init:.1f}"{' disabled' if not eb_ok else ''}> - </div> - <div class="mult-meta"><span>8×</span><span>typical 14×–26×</span><span>32×</span></div> - </div> - <div class="vm-cell mult"> - <div class="mult-top"><span class="big num" id="big-rv">{_fx(rv_init)}</span><span class="sector num">sector {_fx(rv_sector)}</span></div> - <div class="mult-slider"> - <div class="track"> - <span class="band" style="left:{rv_bl_pct:.1f}%;right:{100-rv_bh_pct:.1f}%"></span> - <span class="marker" style="left:{rv_s_pct:.1f}%"></span> - </div> - <input type="range" id="sl-rv" min="4" max="20" step="0.1" value="{rv_init:.1f}"{' disabled' if not rv_ok else ''}> - </div> - <div class="mult-meta"><span>4×</span><span>typical 6×–13×</span><span>20×</span></div> - </div> - <div class="vm-cell mult"> - <div class="mult-top"><span class="big num" id="big-pb">{_fx(pb_init)}</span><span class="sector num">sector {_fx(pb_sector)}</span></div> - <div class="mult-slider"> - <div class="track"> - <span class="band" style="left:{pb_bl_pct:.1f}%;right:{100-pb_bh_pct:.1f}%"></span> - <span class="marker" style="left:{pb_s_pct:.1f}%"></span> - </div> - <input type="range" id="sl-pb" min="4" max="60" step="0.1" value="{pb_init:.1f}"{' disabled' if not pb_ok else ''}> - </div> - <div class="mult-meta"><span>4×</span><span>typical 8×–14×</span><span>60×</span></div> - </div> - </div> + # Bridge values + ev_b = _fmt_b(result["enterprise_value"]) + net_debt_b = _fmt_b(abs(result["net_debt"])) + other_claims_val = ctx["preferred_equity"] + ctx["minority_interest"] + other_claims_b = _fmt_b(other_claims_val) + 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_str = _fmt_b(other_claims_val) + shares_b = ctx["shares"] / 1e9 + source_date = ctx["bridge_items"].get("source_date", "") - <div class="vm-grid"> - <div class="vm-row-lbl">× Normalized metric<span class="sub">from TTM filings</span></div> - <div class="vm-cell"><span class="v num">{_fb(ebitda) if eb_ok else "—"}</span><span class="cap">EBITDA · TTM</span></div> - <div class="vm-cell"><span class="v num">{_fb(revenue) if rv_ok else "—"}</span><span class="cap">Revenue · TTM</span></div> - <div class="vm-cell"><span class="v num">{_fs(book_ps) if pb_ok else "—"}</span><span class="cap">Book value · /share</span></div> - </div> + # Forecast sequences + 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 - <div class="vm-grid"> - <div class="vm-row-lbl">= Enterprise value</div> - <div class="vm-cell"><span class="v num" id="eb-ev-val">{_fb(eb_ev0)}</span><span class="cap">multiple × metric</span></div> - <div class="vm-cell"><span class="v num" id="rv-ev-val">{_fb(rv_ev0)}</span><span class="cap">multiple × metric</span></div> - <div class="vm-cell faded"><span class="v num dash">—</span><span class="cap">P/B is an equity multiple — no EV step</span></div> - </div> + # Verdict strings + pill_cls = "pos" if is_pos else "neg" + pill_arrow = "▲" if is_pos else "▼" + pill_sign = "+" if is_pos else "−" + pill_text = pill_arrow + " " + pill_sign + str(round(abs(upside_pct), 1)) + "% " + ("upside" if is_pos else "downside") + reading = "Constructive" if is_pos else "Cautious" + gap_dir = "above" if gap >= 0 else "below" + iv_str = "$" + "{:,.2f}".format(iv) + market_str = "$" + "{:,.2f}".format(market) if has_market else "—" + gap_str = "$" + "{:,.2f}".format(abs(gap)) + gap_color = "var(--positive)" if gap >= 0 else "var(--negative)" + gap_sign = "+" if gap >= 0 else "" + gap_display = gap_sign + "$" + "{:,.2f}".format(gap) if has_market else "—" + gap_pct_str = "{:.1f}% vs market".format(upside_pct) if has_market else "—" + 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%)" + ) + horizon_sub = "per share · firm value method · " + str(yrs) + "-yr horizon" + wacc_units = "USD · billions · discounted at WACC " + "{:.1f}".format(wacc_pct) + "%" - <div class="vm-grid"> - <div class="vm-row-lbl">− Net debt</div> - <div class="vm-cell"><span class="v num">{_fb(net_debt)}</span><span class="cap">total {_fb(total_debt)} − cash {_fb(cash)}</span></div> - <div class="vm-cell"><span class="v num">{_fb(net_debt)}</span><span class="cap">total {_fb(total_debt)} − cash {_fb(cash)}</span></div> - <div class="vm-cell faded"><span class="v num dash">—</span></div> - </div> + # Cash-flow table cells (string concatenation) + n = len(discounted) + hdr_cells = "" + fcf_cells = "" + df_cells = "" + pv_cells = "" + for i in range(n): + hdr_cells += "<th>Yr " + str(i + 1) + "</th>" + fcf_cells += "<td>" + _fmt_b(projected[i]) + "</td>" + df_cells += "<td>" + "{:.3f}".format(disc_factors[i]) + "</td>" + pv_cells += "<td>" + _fmt_b(discounted[i]) + "</td>" + hdr_cells += "<th>Terminal</th>" + fcf_cells += '<td class="brass">' + _fmt_b(terminal_fcf) + "</td>" + df_cells += "<td>" + "{:.3f}".format(disc_tv_factor) + "</td>" + pv_cells += '<td class="brass">' + _fmt_b(tv_pv) + "</td>" - <div class="vm-grid"> - <div class="vm-row-lbl">= Equity value</div> - <div class="vm-cell"><span class="v num" id="eb-eq-val">{_fb(eb_eq0)}</span><span class="cap">EV − net debt</span></div> - <div class="vm-cell"><span class="v num" id="rv-eq-val">{_fb(rv_eq0)}</span><span class="cap">EV − net debt</span></div> - <div class="vm-cell faded"><span class="v num dash">—</span></div> - </div> + # Plotly data (static — sliders now drive Streamlit reruns) + bar_x = [("Year " + str(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)] - <div class="vm-grid"> - <div class="vm-row-lbl">÷ Shares outstanding</div> - <div class="vm-cell"><span class="v num">{shares_str}</span><span class="cap">diluted</span></div> - <div class="vm-cell"><span class="v num">{shares_str}</span><span class="cap">diluted</span></div> - <div class="vm-cell faded"><span class="v num dash">—</span></div> - </div> + 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}, + }) - <div class="vm-grid result"> - <div class="vm-row-lbl strong">= Implied per share</div> - <div class="vm-cell result"> - <span class="v num" id="eb-per-val">{_fs(eb_per0)}</span> - {_d_span(eb_per0, 'id="eb-per-d"')} - </div> - <div class="vm-cell result"> - <span class="v num" id="rv-per-val">{_fs(rv_per0)}</span> - {_d_span(rv_per0, 'id="rv-per-d"')} - </div> - <div class="vm-cell result"> - <span class="v num" id="pb-per-val">{_fs(pb_per0)}</span> - {_d_span(pb_per0, 'id="pb-per-d"')} - </div> - </div> -</section> + # Cross-check cells (string concatenation) + def _cx_cell_html(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 = '<span class="delta ' + dcls + '">' + dsign + "{:.1f}".format(delta_pct) + "% vs market</span>" + else: + dhtml = '<span class="delta na">—</span>' + return ( + '<div class="' + cls + '">' + + '<span class="lbl">' + lbl + "</span>" + + '<span class="v num">' + val_str + "</span>" + + dhtml + + '<span class="meta">' + meta + "</span>" + + "</div>" + ) -<section class="vm-sensitivity"> - <div class="vm-sensitivity-head"> - <h3>If the lens shifted to sector</h3> - <span class="hint">Same metrics, subject multiple replaced by sector median</span> - </div> - <div class="vm-sens-grid"> + dcf_delta = upside_pct if has_market else None + if dcf_delta is not None: + dcf_dcls = "pos" if dcf_delta >= 0 else "neg" + dcf_dsign = "+" if dcf_delta >= 0 else "" + dcf_dhtml = '<span class="delta ' + dcf_dcls + '">' + dcf_dsign + "{:.1f}".format(dcf_delta) + "% vs market</span>" + else: + dcf_dhtml = '<span class="delta na">—</span>' + cx_dcf = ( + '<div class="va-cx-cell dcf">' + + '<span class="lbl">DCF · THIS MODEL</span>' + + '<span class="v num">' + iv_str + "</span>" + + dcf_dhtml + + '<span class="meta">Firm-value DCF · ' + str(yrs) + '-yr explicit · WACC ' + "{:.1f}".format(wacc_pct) + "%</span>" + + "</div>" + ) - <div class="vm-sens-cell"> - <span class="lbl">EV / EBITDA</span> - <div class="vm-sens-row"> - <div class="col"> - <span class="sub" id="sens-eb-subj-lbl">At subject {_fx(eb_init)}</span> - <span class="v num" id="sens-eb-subj-v">{_fs(eb_per0)}</span> - {_ds_span(eb_per0, 'id="sens-eb-subj-d"')} - </div> - <span class="arrow">→</span> - <div class="col"> - <span class="sub">At sector {_fx(eb_sector)}</span> - <span class="v num brass">{_fs(sec_eb)}</span> - {_ds_span(sec_eb)} - </div> - </div> - <span class="meta" id="sens-eb-meta">Re-rating Δ {_rr(eb_per0, sec_eb)} per share if the subject converged to peers</span> - </div> + def _cx_mult_cell(label, implied, market_multiple, mult_label): + if implied is not None and has_market: + delta = (implied - market) / market * 100 + val = "$" + "{:,.2f}".format(implied) + meta = ("Market multiple " + "{:.1f}".format(market_multiple) + "× · " + mult_label) if market_multiple else mult_label + else: + delta = None + val = "—" + meta = "Unavailable for this company" + return _cx_cell_html("va-cx-cell", label, val, delta, meta) - <div class="vm-sens-cell"> - <span class="lbl">EV / Revenue</span> - <div class="vm-sens-row"> - <div class="col"> - <span class="sub" id="sens-rv-subj-lbl">At subject {_fx(rv_init)}</span> - <span class="v num" id="sens-rv-subj-v">{_fs(rv_per0)}</span> - {_ds_span(rv_per0, 'id="sens-rv-subj-d"')} - </div> - <span class="arrow">→</span> - <div class="col"> - <span class="sub">At sector {_fx(rv_sector)}</span> - <span class="v num brass">{_fs(sec_rv)}</span> - {_ds_span(sec_rv)} - </div> - </div> - <span class="meta" id="sens-rv-meta">Re-rating Δ {_rr(rv_per0, sec_rv)} per share if the subject converged to peers</span> - </div> + cx_ev = _cx_mult_cell( + "EV / EBITDA", ev_ebitda_price, + ctx.get("ev_ebitda_current") or 0, "based on current market multiple", + ) + cx_rev = _cx_mult_cell( + "EV / REVENUE", ev_rev_price, + ctx.get("ev_revenue_current") or 0, "based on current market multiple", + ) + cx_pb = _cx_mult_cell( + "P / BOOK", pb_price, + ctx.get("pb_current") or 0, "based on current market multiple", + ) - <div class="vm-sens-cell"> - <span class="lbl">P / Book</span> - <div class="vm-sens-row"> - <div class="col"> - <span class="sub" id="sens-pb-subj-lbl">At subject {_fx(pb_init)}</span> - <span class="v num" id="sens-pb-subj-v">{_fs(pb_per0)}</span> - {_ds_span(pb_per0, 'id="sens-pb-subj-d"')} - </div> - <span class="arrow">→</span> - <div class="col"> - <span class="sub">At sector {_fx(pb_sector)}</span> - <span class="v num brass">{_fs(sec_pb)}</span> - {_ds_span(sec_pb)} - </div> - </div> - <span class="meta" id="sens-pb-meta">Re-rating Δ {_rr(pb_per0, sec_pb)} per share if the subject converged to peers</span> - </div> + # Bridge source date label + bdate_str = "Balance-sheet bridge" + (" · " + escape_html(source_date) if source_date else "") - </div> -</section> + # Assemble HTML document — string concatenation only + doc = ( + "<!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="vm-cx"> - <div class="vm-cx-head"> - <h3>Cross-check against DCF</h3> - <span class="hint">DCF intrinsic from the firm-value model on the previous tab</span> - </div> - <div class="vm-cx-grid"> - <div class="vm-cx-cell dcf"> - <span class="lbl">DCF · firm value</span> - <span class="v num">{dcf_val_str}</span> - {dcf_delta_html} - <span class="meta">{dcf_meta_str}</span> - </div> - <div class="vm-cx-cell"> - <span class="lbl">EV / EBITDA</span> - <span class="v num" id="cx-eb-val">{_fs(eb_per0)}</span> - {_d_span(eb_per0, 'id="cx-eb-d"')} - <span class="meta" id="cx-eb-meta">Subject {_fx(eb_init)} · sector {_fx(eb_sector)}</span> - </div> - <div class="vm-cx-cell"> - <span class="lbl">EV / Revenue</span> - <span class="v num" id="cx-rv-val">{_fs(rv_per0)}</span> - {_d_span(rv_per0, 'id="cx-rv-d"')} - <span class="meta" id="cx-rv-meta">Subject {_fx(rv_init)} · sector {_fx(rv_sector)}</span> - </div> - <div class="vm-cx-cell"> - <span class="lbl">P / Book</span> - <span class="v num" id="cx-pb-val">{_fs(pb_per0)}</span> - {_d_span(pb_per0, 'id="cx-pb-d"')} - <span class="meta" id="cx-pb-meta">Subject {_fx(pb_init)} · sector {_fx(pb_sector)} · low-signal</span> - </div> - </div> -</section> + # Verdict card + "<section class=\"va-verdict\">" + "<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=\"sub\">" + horizon_sub + "</span>" + "</div>" + "<span class=\"arrow\">vs</span>" + "<div class=\"col\" style=\"align-items:flex-end\">" + "<span class=\"lbl\">Market Price</span>" + "<span class=\"big market num\">" + market_str + "</span>" + "<span class=\"pill " + pill_cls + "\">" + pill_text + "</span>" + "</div>" + "</div>" + "<div class=\"band\">" + "<span>Reading · DCF implies <span class=\"mono\">" + gap_str + "</span> " + gap_dir + " the current market.</span>" + "<span class=\"reading\">" + reading + "</span>" + "</div>" + "</section>" -<div class="va-foot"> - <span>Multiples · TTM metrics, balance sheet. Net-debt adjustment applies to EV-based methods only; P/B reads directly off book equity per share. Sector medians from peer group analysis.</span> - <a href="#">Methodology & sources ↗</a> -</div> + # Projection + "<section class=\"va-projection\">" + "<div class=\"head\">" + "<h3>Enterprise value build — present value of FCFs + terminal</h3>" + "<span class=\"units\">" + wacc_units + "</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>" -</div> -<script> -var D = {data_json}; + # Bridge + "<section class=\"va-bridge\">" + "<div class=\"bhead\">" + "<h3>From enterprise to equity</h3>" + "<span class=\"bdate\">" + bdate_str + "</span>" + "</div>" + "<div class=\"flow\">" + "<div class=\"node start\"><span class=\"lbl\">Enterprise value</span><span class=\"v num\">" + ev_b + "</span></div>" + "<div class=\"op\">−<span class=\"sub\">Net debt</span></div>" + "<div class=\"node\"><span class=\"lbl\">Net debt</span><span class=\"v num\">" + net_debt_b + "</span></div>" + "<div class=\"op\">−<span class=\"sub\">Other claims</span></div>" + "<div class=\"node\"><span class=\"lbl\">Other claims</span><span class=\"v num\">" + other_claims_b + "</span></div>" + "<div class=\"op\">=</div>" + "<div class=\"node result\"><span class=\"lbl\">Equity value</span><span class=\"v num\">" + equity_b + "</span></div>" + "</div>" + "<div class=\"bfoot\">" + "<span>Total debt " + total_debt_b + "</span>" + "<span>·</span>" + "<span>Cash & equiv. " + cash_b + "</span>" + "<span>·</span>" + "<span>Preferred + minority " + other_b_val_str + "</span>" + "</div>" + "</section>" -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.toFixed(2); }} -function fX(n) {{ return n.toFixed(1)+'×'; }} -function dPct(v) {{ return D.hasMarket ? (v-D.market)/D.market*100 : 0; }} -function dStr(d) {{ - var cls=d>=0?'pos':'neg', arr=d>=0?'▲':'▼', sign=d>=0?'+':''; - return '<span class="d num '+cls+'">'+arr+' '+sign+d.toFixed(1)+'%</span>'; -}} -function dVsStr(d) {{ - var cls=d>=0?'pos':'neg', arr=d>=0?'▲':'▼', sign=d>=0?'+':''; - return '<span class="delta num '+cls+'">'+arr+' '+sign+d.toFixed(1)+'% vs '+fS(D.market)+'</span>'; -}} -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; }} + # Per-share recon + "<section class=\"va-recon\">" + "<div class=\"cell intrinsic\">" + "<span class=\"lbl\">Intrinsic · Per Share</span>" + "<span class=\"v num\">" + iv_str + "</span>" + "<span class=\"sub\">Equity value ÷ shares</span>" + "</div>" + "<div class=\"cell\">" + "<span class=\"lbl\">Market · Last</span>" + "<span class=\"v num\">" + market_str + "</span>" + "<span class=\"sub\"> </span>" + "</div>" + "<div class=\"cell\">" + "<span class=\"lbl\">Gap</span>" + "<span class=\"v num\" style=\"color:" + gap_color + "\">" + gap_display + "</span>" + "<span class=\"sub\">" + gap_pct_str + "</span>" + "</div>" + "<div class=\"cell\">" + "<span class=\"lbl\">Shares Outstanding</span>" + "<span class=\"v num\">" + "{:.2f}".format(shares_b) + " B</span>" + "<span class=\"sub\">diluted</span>" + "</div>" + "</section>" -function update() {{ - var ebX=+document.getElementById('sl-eb').value; - var rvX=+document.getElementById('sl-rv').value; - var pbX=+document.getElementById('sl-pb').value; + # Cross-check + "<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>" - if (D.ebOk) {{ - var ebEV=ebX*D.ebitda, ebEq=ebEV-D.netDebt, ebPer=ebEq/D.shares, ebD=dPct(ebPer); - var secEbPer=(D.ebSector*D.ebitda-D.netDebt)/D.shares; - var rrEb=ebPer!==0?(secEbPer-ebPer)/Math.abs(ebPer)*100:0; - setText('big-eb', fX(ebX)); - setText('sum-eb-val', fS(ebPer)); setHtml('sum-eb-d', dStr(ebD)); - setText('eb-ev-val', fB(ebEV)); setText('eb-eq-val', fB(ebEq)); - setText('eb-per-val', fS(ebPer)); setHtml('eb-per-d', dVsStr(ebD)); - setText('sens-eb-subj-lbl', 'At subject '+fX(ebX)); - setText('sens-eb-subj-v', fS(ebPer)); setHtml('sens-eb-subj-d', dStr(ebD)); - var rrCls=rrEb>=0?'pos':'neg', rrSign=rrEb>=0?'+':''; - setHtml('sens-eb-meta', 'Re-rating Δ <span class="num '+rrCls+'">'+rrSign+rrEb.toFixed(1)+'%</span> per share if the subject converged to peers'); - setText('cx-eb-val', fS(ebPer)); setHtml('cx-eb-d', dVsStr(ebD)); - setText('cx-eb-meta', 'Subject '+fX(ebX)+' · sector '+fX(D.ebSector)); - }} - if (D.rvOk) {{ - var rvEV=rvX*D.revenue, rvEq=rvEV-D.netDebt, rvPer=rvEq/D.shares, rvD=dPct(rvPer); - var secRvPer=(D.rvSector*D.revenue-D.netDebt)/D.shares; - var rrRv=rvPer!==0?(secRvPer-rvPer)/Math.abs(rvPer)*100:0; - setText('big-rv', fX(rvX)); - setText('sum-rv-val', fS(rvPer)); setHtml('sum-rv-d', dStr(rvD)); - setText('rv-ev-val', fB(rvEV)); setText('rv-eq-val', fB(rvEq)); - setText('rv-per-val', fS(rvPer)); setHtml('rv-per-d', dVsStr(rvD)); - setText('sens-rv-subj-lbl', 'At subject '+fX(rvX)); - setText('sens-rv-subj-v', fS(rvPer)); setHtml('sens-rv-subj-d', dStr(rvD)); - var rrCls=rrRv>=0?'pos':'neg', rrSign=rrRv>=0?'+':''; - setHtml('sens-rv-meta', 'Re-rating Δ <span class="num '+rrCls+'">'+rrSign+rrRv.toFixed(1)+'%</span> per share if the subject converged to peers'); - setText('cx-rv-val', fS(rvPer)); setHtml('cx-rv-d', dVsStr(rvD)); - setText('cx-rv-meta', 'Subject '+fX(rvX)+' · sector '+fX(D.rvSector)); - }} - if (D.pbOk) {{ - var pbPer=pbX*D.bookPs, pbD=dPct(pbPer); - var secPbPer=D.pbSector*D.bookPs; - var rrPb=pbPer!==0?(secPbPer-pbPer)/Math.abs(pbPer)*100:0; - setText('big-pb', fX(pbX)); - setText('sum-pb-val', fS(pbPer)); setHtml('sum-pb-d', dStr(pbD)); - setText('pb-per-val', fS(pbPer)); setHtml('pb-per-d', dVsStr(pbD)); - setText('sens-pb-subj-lbl', 'At subject '+fX(pbX)); - setText('sens-pb-subj-v', fS(pbPer)); setHtml('sens-pb-subj-d', dStr(pbD)); - var rrCls=rrPb>=0?'pos':'neg', rrSign=rrPb>=0?'+':''; - setHtml('sens-pb-meta', 'Re-rating Δ <span class="num '+rrCls+'">'+rrSign+rrPb.toFixed(1)+'%</span> per share if the subject converged to peers'); - setText('cx-pb-val', fS(pbPer)); setHtml('cx-pb-d', dVsStr(pbD)); - setText('cx-pb-meta', 'Subject '+fX(pbX)+' · sector '+fX(D.pbSector)+' · low-signal'); - }} -}} + # Footer + "<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>" -document.getElementById('sl-eb').addEventListener('input', update); -document.getElementById('sl-rv').addEventListener('input', update); -document.getElementById('sl-pb').addEventListener('input', update); -</script> -</body> -</html>""" - return html + "</div>" # va-canvas + "<script>" + "var data = " + plotly_data_json + ";" + "var layout = " + plotly_layout_json + ";" + "Plotly.newPlot('dcf-chart', data, layout, {displayModeBar:false,responsive:true});" + "</script>" + "</body></html>" + ) + return doc def _render_dcf_model(ctx: dict): @@ -2246,121 +2041,142 @@ def _render_dcf_model(ctx: dict): st.markdown(_DCF_RAIL_CSS, unsafe_allow_html=True) - # 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) + col_rail, col_canvas = st.columns([1, 2.5]) - result = run_dcf( - fcf_series=ctx["fcf_series"], - shares_outstanding=ctx["shares"], - 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"], - minority_interest=ctx["minority_interest"], - base_fcf_override=ctx["base_fcf"], - ) + with col_rail: + 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, + ) + st.markdown('<hr class="dcf-divider">', unsafe_allow_html=True) - if not result: - st.warning("Insufficient data to run DCF model.") - return - if result.get("error"): - st.warning(result["error"]) - return + wacc_pct = st.slider( + "WACC (%)", + min_value=4.0, max_value=15.0, step=0.25, + value=float(st.session_state.get(f"dcf_wacc_{ctx['ticker']}", 10.0)), + key=f"dcf_wacc_{ctx['ticker']}", + help="Weighted Average Cost of Capital — conservative 4%, aggressive 15%", + ) + tg_pct = st.slider( + "Terminal growth (%)", + min_value=0.0, max_value=5.0, step=0.1, + value=float(st.session_state.get(f"dcf_tg_{ctx['ticker']}", 2.5)), + key=f"dcf_tg_{ctx['ticker']}", + help="Long-run growth rate for terminal value — guided by inflation", + ) + yrs = st.slider( + "Forecast horizon (yr)", + min_value=3, max_value=10, step=1, + value=int(st.session_state.get(f"dcf_yrs_{ctx['ticker']}", 5)), + key=f"dcf_yrs_{ctx['ticker']}", + help="Number of explicit projection years before terminal value", + ) + g_pct = round(st.slider( + "FCF growth (%)", + min_value=-15.0, max_value=20.0, step=0.1, + value=round(float(st.session_state.get(f"dcf_g_{ctx['ticker']}", round(slider_default, 1))), 1), + key=f"dcf_g_{ctx['ticker']}", + help="Annual FCF growth rate applied to base FCF — median historical shown as default", + ), 1) - st.session_state[f"dcf_intrinsic_{ctx['ticker']}"] = result["intrinsic_value_per_share"] - st.session_state[f"dcf_params_{ctx['ticker']}"] = {"wacc": wacc_pct, "tg": tg_pct, "yrs": yrs} + st.markdown('<hr class="dcf-divider">', unsafe_allow_html=True) - # 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"]), + # From the filings block (static; populated after DCF run below) + net_debt_raw = ctx["total_debt"] - ctx["cash_and_equivalents"] + base_fcf_raw = ctx.get("base_fcf") + base_fcf_str = _fmt_b(base_fcf_raw) if base_fcf_raw else "—" + hist_growth_str = ("{:+.1f}%".format(hist_growth_raw_pct)) if hist_growth_raw is not None else "—" + net_debt_str = _fmt_b(net_debt_raw) + shares_str = "{:.2f} B".format(ctx["shares"] / 1e9) + source_date = ctx["bridge_items"].get("source_date", "") + nd_label = "Net debt" + (" · " + escape_html(source_date) if source_date else "") + + st.markdown( + '<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 · historical</span><span class="dcf-filing-val">' + hist_growth_str + '</span></div>' + '<div class="dcf-filing-row"><span>' + nd_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>', + unsafe_allow_html=True, ) - ev_ebitda_price = ev_r.get("implied_price_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"]), + if st.button("Recompute", key=f"dcf_recompute_{ctx['ticker']}", type="secondary"): + get_free_cash_flow_ttm.clear() + get_balance_sheet_bridge_items.clear() + st.rerun() + + with col_canvas: + result = run_dcf( + fcf_series=ctx["fcf_series"], + shares_outstanding=ctx["shares"], + wacc=wacc_pct / 100, + terminal_growth=tg_pct / 100, + projection_years=yrs, + growth_rate_override=g_pct / 100, total_debt=ctx["total_debt"], - total_cash=ctx["cash_and_equivalents"], + cash_and_equivalents=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") - - 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"]), + base_fcf_override=ctx["base_fcf"], ) - pb_price = pb_r.get("implied_price_per_share") - - canvas_html = _build_dcf_canvas_html( - ctx, result, wacc_pct, tg_pct, yrs, g_pct, - ev_ebitda_price, ev_rev_price, pb_price, - ) - - components.html(canvas_html, height=1620, scrolling=False) - if st.button("Recompute", key=f"dcf_recompute_{ctx['ticker']}", type="secondary"): - get_free_cash_flow_ttm.clear() - get_balance_sheet_bridge_items.clear() - st.rerun() + if not result: + st.warning("Insufficient data to run DCF model.") + return + if result.get("error"): + st.warning(result["error"]) + return + st.session_state[f"dcf_intrinsic_{ctx['ticker']}"] = result["intrinsic_value_per_share"] + st.session_state[f"dcf_params_{ctx['ticker']}"] = {"wacc": wacc_pct, "tg": tg_pct, "yrs": yrs} -def _render_multiples_model(ctx: dict): - st.markdown(_DCF_RAIL_CSS, unsafe_allow_html=True) - rail_col, canvas_col = st.columns([1, 4], gap="medium") + # Cross-check: implied price from 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") - with rail_col: - st.markdown( - '<span class="dcf-eyebrow">Multiples</span>' - '<div class="dcf-title">Three relative-valuation lenses</div>' - '<div class="dcf-sub">Subject multiple × normalized TTM metric, bridged to equity per share.</div>', - unsafe_allow_html=True, - ) - st.markdown('<hr class="dcf-divider">', unsafe_allow_html=True) + 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") - net_debt_raw = ctx["total_debt"] - ctx["cash_and_equivalents"] - ebitda_str = _fmt_b(ctx["ebitda"]) if ctx.get("ebitda") and ctx["ebitda"] > 0 else "—" - rev_str = _fmt_b(ctx["revenue_ttm"]) if ctx.get("revenue_ttm") and ctx["revenue_ttm"] > 0 else "—" - bps_str = f"${ctx['book_value_per_share']:.2f}" if ctx.get("book_value_per_share") and ctx["book_value_per_share"] > 0 else "—" + 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") - st.markdown( - '<div class="dcf-filings-eyebrow">From the filings</div>' - f'<div class="dcf-filing-row"><span>EBITDA (TTM)</span><span class="dcf-filing-val">{ebitda_str}</span></div>' - f'<div class="dcf-filing-row"><span>Revenue (TTM)</span><span class="dcf-filing-val">{rev_str}</span></div>' - f'<div class="dcf-filing-row"><span>Book value / share</span><span class="dcf-filing-val">{bps_str}</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, + canvas_html = _build_dcf_canvas_only_html( + ctx, result, wacc_pct, tg_pct, yrs, g_pct, + ev_ebitda_price, ev_rev_price, pb_price, ) + components.html(canvas_html, height=1500, scrolling=False) - st.markdown('<hr class="dcf-divider">', unsafe_allow_html=True) - if st.button("Refresh data", key=f"mult_refresh_{ctx['ticker']}", type="primary", width="stretch"): - get_balance_sheet_bridge_items.clear() - st.rerun() - - canvas_html = _build_multiples_canvas_html(ctx) - with canvas_col: - components.html(canvas_html, height=1620, scrolling=False) +def _render_multiples_model(ctx: dict): + doc = _build_multiples_canvas_html(ctx) + components.html(doc, height=1900, scrolling=False) def _render_ev_ebitda_model(ctx: dict): @@ -2853,6 +2669,7 @@ def _render_comps(ticker: str): {"key": "evEbt", "lbl": "EV/EBITDA", "short": "EV/EBITDA", "kind": "x", "invert": True}, {"key": "evSales", "lbl": "EV/Sales", "short": "EV/Sales", "kind": "x", "invert": True}, {"key": "pb", "lbl": "P/Book", "short": "P/B", "kind": "x", "invert": True}, + {"key": "fcfy", "lbl": "FCF yield", "short": "FCF Y", "kind": "%", "invert": False}, {"key": "revG", "lbl": "Rev YoY", "short": "Rev YoY", "kind": "%", "invert": False}, {"key": "opM", "lbl": "Op margin", "short": "Op Mgn", "kind": "%", "invert": False}, ] @@ -2862,6 +2679,7 @@ def _render_comps(ticker: str): "evEbt": ("enterpriseValueMultipleTTM", 1.0), "evSales": ("evToSalesTTM", 1.0), "pb": ("priceToBookRatioTTM", 1.0), + "fcfy": None, # computed below from FCF TTM / market cap "revG": ("revenueGrowthTTM", 100.0), "opM": ("operatingProfitMarginTTM", 100.0), } @@ -2875,14 +2693,27 @@ def _render_comps(ticker: str): mcap_raw = ci.get("marketCap") or 0 mcap_b = round(mcap_raw / 1e9, 2) if mcap_raw else None row = { - "sym": sym_i, - "name": (ci.get("longName") or ci.get("shortName") or sym_i)[:40], + "sym": _h(sym_i), + "name": _h((ci.get("longName") or ci.get("shortName") or sym_i)[:40]), "mcap": mcap_b, "subject": sym_i == ticker.upper(), } + # FCF yield computed from TTM free cash flow / market cap + fcf_ttm_peer = get_free_cash_flow_ttm(sym_i) + if fcf_ttm_peer is not None and mcap_raw and mcap_raw > 0: + fcfy_v = fcf_ttm_peer / mcap_raw * 100.0 + row["fcfy"] = round(fcfy_v, 2) if abs(fcfy_v) <= 100 else None + else: + row["fcfy"] = None + for col in COLS: key = col["key"] - field, scale = FIELD_MAP[key] + if key == "fcfy": + continue # already set above + field_entry = FIELD_MAP[key] + if field_entry is None: + continue + field, scale = field_entry v = r.get(field) if v is not None: try: @@ -2922,7 +2753,7 @@ def _render_comps(ticker: str): "p75": round(_q(vals, 0.75), 2), } - peer_median_row = {"sym": "—", "name": "Peer median", "mcap": None, "subject": False} + peer_median_row = {"sym": _h("—"), "name": _h("Peer median"), "mcap": None, "subject": False} all_mcaps = [p["mcap"] for p in peers if p["mcap"] is not None] peer_median_row["mcap"] = round(_q(all_mcaps, 0.5), 2) if all_mcaps else None for col in COLS: @@ -2930,7 +2761,7 @@ def _render_comps(ticker: str): vals = [p[key] for p in peers if p.get(key) is not None] peer_median_row[key] = round(_q(vals, 0.5), 2) if vals else None - HERO_COLS = ["pe", "evEbt", "revG", "opM"] + HERO_COLS = ["pe", "evEbt", "fcfy", "opM"] subject_row = next((p for p in peers if p["subject"]), None) def _pctof(vals, v): @@ -2982,6 +2813,7 @@ def _render_comps(ticker: str): }) sym = ticker.upper() + sym_h = _h(sym) name = _h((info.get("longName") or info.get("shortName") or sym) if info else sym) price = get_latest_price(ticker) prev_close = (info.get("previousClose") if info else None) @@ -2997,7 +2829,7 @@ def _render_comps(ticker: str): n_peers = len(peers) - 1 data_json = json_for_script({ - "subject": sym, + "subject": sym_h, "peers": peers, "peerMedian": peer_median_row, "cols": COLS, @@ -3006,11 +2838,11 @@ def _render_comps(ticker: str): "nPeers": n_peers, }) - total_height = 920 + n_peers * 54 + total_height = max(1900, 1500 + n_peers * 80) ctx_html = ( '<div class="val-ctx">' - '<span class="sym">' + sym + '</span>' + '<span class="sym">' + sym_h + '</span>' '<span class="name">' + name + '</span>' '<span class="eyebrow-ctx" style="margin-left:12px">Valuation · Comps</span>' '<div class="meta">' @@ -3024,7 +2856,7 @@ def _render_comps(ticker: str): '<section class="cmp-lede">' '<div class="left">' '<span class="eyebrow-lbl">Peer set</span>' - '<h2 class="ttl">' + str(n_peers) + ' names, one table — read across to see where ' + sym + ' sits</h2>' + '<h2 class="ttl">' + str(n_peers) + ' names, one table — read across to see where ' + sym_h + ' sits</h2>' '<p class="sub">Peers sourced from FMP stock-peers or Prism sector fallback. ' 'Subject pinned at top, followed by the peer median; the rest sort by any column. ' 'Every numeric cell shows the value plus a track of where it sits in the column distribution.</p>' @@ -3215,7 +3047,7 @@ def _render_comps(ticker: str): + "<script>" + js + "</script>" + "</body></html>" ) - components.html(doc, height=total_height, scrolling=True) + components.html(doc, height=total_height, scrolling=False) @@ -3972,7 +3804,7 @@ def _render_earnings_history(ticker: str): + "</body></html>" ) - total_height = 1100 + n_total * 48 + total_height = 1500 + n_total * 52 components.html(doc, height=total_height, scrolling=False) @@ -4063,7 +3895,11 @@ _KH_CSS = """<style> def _render_historical_ratios(ticker: str): info = get_company_info(ticker) - hist_rows = get_historical_ratios(ticker, limit=10) + 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 @@ -4071,19 +3907,55 @@ def _render_historical_ratios(ticker: str): periods = [] for r in rows_sorted: y = str(r.get("date", ""))[:4] - periods.append(f"FY{y[2:]}" if len(y) == 4 else y) + periods.append("FY" + y[2:] if len(y) == 4 else y) + + def _peer_median(field_ttm): + vals = [] + for pr in peer_ratios_list: + v = pr.get(field_ttm) + if v is not None: + try: + vals.append(float(v)) + except (TypeError, ValueError): + pass + if not vals: + return None + vals.sort() + m = len(vals) + return vals[m // 2] if m % 2 else (vals[m // 2 - 1] + vals[m // 2]) / 2 + + PEER_FIELD_MAP = { + "pe": ("peRatioTTM", 1.0), + "evebt": ("enterpriseValueMultipleTTM", 1.0), + "pb": ("priceToBookRatioTTM", 1.0), + "ps": ("priceToSalesRatioTTM", 1.0), + "gm": ("grossProfitMarginTTM", 100.0), + "om": ("operatingProfitMarginTTM", 100.0), + "nm": ("netProfitMarginTTM", 100.0), + "roe": ("returnOnEquityTTM", 100.0), + "roa": ("returnOnAssetsTTM", 100.0), + "de": ("debtToEquityRatioTTM", 1.0), + "cr": ("currentRatioTTM", 1.0), + "ic": ("interestCoverageRatioTTM", 1.0), + "divy": ("dividendYieldTTM", 100.0), + } + SERIES_DEFS = [ - ("pe", "Valuation", "P / E", "x", "peRatio"), - ("evebt", "Valuation", "EV / EBITDA", "x", "enterpriseValueMultiple"), - ("pb", "Valuation", "P / Book", "x", "priceToBookRatio"), - ("ps", "Valuation", "P / Sales", "x", "priceToSalesRatio"), - ("gm", "Profitability", "Gross margin", "%", "grossProfitMargin"), - ("om", "Profitability", "Operating margin", "%", "operatingProfitMargin"), - ("nm", "Profitability", "Net margin", "%", "netProfitMargin"), - ("roe", "Profitability", "Return on equity", "%", "returnOnEquity"), - ("roa", "Profitability", "Return on assets", "%", "returnOnAssets"), - ("de", "Health", "Debt / Equity", "x", "debtEquityRatio"), + ("pe", "Valuation", "P / E", "x", "peRatio"), + ("evebt", "Valuation", "EV / EBITDA", "x", "enterpriseValueMultiple"), + ("pb", "Valuation", "P / Book", "x", "priceToBookRatio"), + ("ps", "Valuation", "P / Sales", "x", "priceToSalesRatio"), + ("gm", "Profitability", "Gross margin", "%", "grossProfitMargin"), + ("om", "Profitability", "Operating margin", "%", "operatingProfitMargin"), + ("nm", "Profitability", "Net margin", "%", "netProfitMargin"), + ("roe", "Profitability", "Return on equity", "%", "returnOnEquity"), + ("roa", "Profitability", "Return on assets", "%", "returnOnAssets"), + ("de", "Health", "Debt / Equity", "x", "debtEquityRatio"), + ("cr", "Health", "Current ratio", "x", "currentRatio"), + ("ic", "Health", "Interest coverage", "x", "interestCoverage"), + ("divy", "Cash returns", "Dividend yield", "%", "dividendYield"), ] + series_data = [] for key, group, lbl, kind, field in SERIES_DEFS: vals = [] @@ -4097,16 +3969,35 @@ def _render_historical_ratios(ticker: str): vals.append(None) else: vals.append(None) - if len([v for v in vals if v is not None]) >= 2: - series_data.append({"key": key, "group": group, "lbl": lbl, "kind": kind, "subj": vals}) + if len([v for v in vals if v is not None]) < 2: + continue + sector_ttm = None + if key in PEER_FIELD_MAP: + pf, pm = PEER_FIELD_MAP[key] + pm_val = _peer_median(pf) + if pm_val is not None: + sector_ttm = round(pm_val * pm, 4) + series_data.append({ + "key": key, + "group": group, + "lbl": lbl, + "kind": kind, + "subj": vals, + "sector_ttm": sector_ttm, + }) + if not series_data: st.info("No plottable ratio data available.") return + + # ── Context strip data ──────────────────────────────────────────────────── price = get_latest_price(ticker) prev_close = info.get("previousClose") if info else None if price and prev_close and prev_close > 0: chg_pct = (price - prev_close) / prev_close * 100 - chg_str = f"{'▲' if chg_pct >= 0 else '▼'} {'+' if chg_pct >= 0 else ''}{chg_pct:.2f}%" + arrow = "▲" if chg_pct >= 0 else "▼" + sign = "+" if chg_pct >= 0 else "" + chg_str = arrow + " " + sign + str(round(chg_pct, 2)) + "%" chg_cls = "chg-pos" if chg_pct >= 0 else "chg-neg" else: chg_str, chg_cls = "—", "" @@ -4115,47 +4006,54 @@ def _render_historical_ratios(ticker: str): _XMAP = {"NYQ": "NYSE", "NMS": "NASDAQ", "NGM": "NASDAQ", "NCM": "NASDAQ", "ASE": "AMEX"} raw_x = (info.get("exchange", "") if info else "") or "" exchange = _h(_XMAP.get(raw_x, raw_x) or "—") - price_str = f"${price:.2f}" if price else "—" + price_str = ("$" + str(round(price, 2))) if price else "—" + n_periods = len(periods) n_rows = len(series_data) n_groups = len({s["group"] for s in series_data}) - total_height = 48 + 24 + 180 + 24 + 420 + 24 + (68 + n_groups * 50 + n_rows * 42) + 24 + 60 + 60 + total_height = 48 + 24 + 200 + 24 + 460 + 24 + (68 + n_groups * 50 + n_rows * 42) + 24 + 60 + 80 + data_json = json_for_script({"periods": periods, "series": series_data}) + ctx_html = ( - f'<div class="val-ctx">' - f'<span class="sym">{sym}</span>' - f'<span class="name">{name}</span>' - f'<span class="eyebrow-ctx" style="margin-left:12px">Valuation · Historical Ratios</span>' - f'<div class="meta">' - f'<span>{exchange}</span>' - f'<span class="px num">{price_str}</span>' - f'<span class="{chg_cls} num">{chg_str}</span>' - f'</div></div>' + '<div class="val-ctx">' + + '<span class="sym">' + _h(sym) + '</span>' + + '<span class="name">' + name + '</span>' + + '<span class="eyebrow-ctx" style="margin-left:12px">Valuation · Historical Ratios</span>' + + '<div class="meta">' + + '<span>' + exchange + '</span>' + + '<span class="px num">' + price_str + '</span>' + + '<span class="' + chg_cls + ' num">' + chg_str + '</span>' + + '</div></div>' ) + lede_html = ( - f'<section class="kh-lede">' - f'<div class="left">' - f'<span class="eyebrow-lbl">Drift</span>' - f'<h2 class="ttl">{n_periods} periods of every ratio — pick a line, the heatmap follows</h2>' - f'<p class="sub">Annual ratios from {periods[0]} through {periods[-1]}. ' - f'Click any row in the matrix to plot it in the hero chart above. ' - f'Cell shading shows each ratio's relative position within its own history.</p>' - f'</div>' - f'<div class="right">' - f'<div class="kh-legend">' - f'<span><span class="sw subj"></span>{sym}</span>' - f'</div>' - f'<div class="kh-window">' - f'<span class="lbl">Window</span>' - f'<div class="seg">' - f'<button onclick="setWindow({n_periods},this)" class="active">All</button>' - f'<button onclick="setWindow(5,this)">5 yr</button>' - f'<button onclick="setWindow(3,this)">3 yr</button>' - f'</div>' - f'</div>' - f'</div>' - f'</section>' + '<section class="kh-lede">' + + '<div class="left">' + + '<span class="eyebrow-lbl">Drift</span>' + + '<h2 class="ttl">' + str(n_periods) + ' periods of every ratio — pick a line, the heatmap follows</h2>' + + '<p class="sub">Annual ratios from ' + periods[0] + ' through ' + periods[-1] + '. ' + + 'The subject line plots in champagne; dashed oxford is the sector median TTM. ' + + 'Clicking a row in the matrix brings that series up to the hero chart. ' + + 'Cell shading shows each ratio's relative position within its own history.</p>' + + '</div>' + + '<div class="right">' + + '<div class="kh-legend">' + + '<span><span class="sw subj"></span>' + _h(sym) + '</span>' + + '<span><span class="sw sect"></span>Sector median</span>' + + '</div>' + + '<div class="kh-window">' + + '<span class="lbl">Window</span>' + + '<div class="seg">' + + '<button onclick="setWindow(' + str(n_periods) + ',this)" class="active">All</button>' + + '<button onclick="setWindow(5,this)">5 yr</button>' + + '<button onclick="setWindow(3,this)">3 yr</button>' + + '</div>' + + '</div>' + + '</div>' + + '</section>' ) + hero_html = ( '<section class="kh-hero">' '<div class="kh-hero-head">' @@ -4167,183 +4065,243 @@ def _render_historical_ratios(ticker: str): '<div class="cell"><span class="lbl">Latest</span><span class="v num" id="kh-stat-latest">—</span></div>' '<div class="cell"><span class="lbl" id="kh-stat-n-lbl">Avg</span><span class="v num" id="kh-stat-avg">—</span><span class="d num" id="kh-stat-davg"></span></div>' '<div class="cell"><span class="lbl">Range</span><span class="v num" id="kh-stat-range">—</span></div>' + '<div class="cell"><span class="lbl">vs Sector</span><span class="v num" id="kh-stat-sector">—</span><span class="d num" id="kh-stat-dsector"></span></div>' '</div>' '</div>' '<div class="kh-chart-wrap"><div id="kh-chart"></div></div>' '</section>' ) + matrix_html = ( '<section class="kh-matrix">' '<div class="kh-matrix-head">' - '<h3>Ratio matrix</h3>' - '<span class="hint">Click a row to chart it · shading shows relative position within row history</span>' + '<h3>Ratio matrix · ' + str(n_periods) + ' periods</h3>' + '<span class="hint">Click a row to chart it · cell shading shows relative position within row history</span>' '</div>' '<div class="kh-matrix-grid head" id="kh-matrix-head-row"></div>' '<div id="kh-matrix-body"></div>' '</section>' ) + foot_html = ( '<div class="va-foot">' - '<span>Ratios computed from yfinance annual income statements, balance sheets, and 10-year price history. ' - 'Price-based multiples use average price in a ±45-day window around each fiscal year-end.</span>' + '<span>Annual ratios computed from yfinance financial statements. ' + 'Price-based multiples use average price in a ±45-day window around each fiscal year-end. ' + 'Sector median is the TTM peer-set median across up to 6 comparable companies.</span>' '</div>' ) + body = ctx_html + '<div class="kh-body">' + lede_html + hero_html + matrix_html + foot_html + '</div>' - js = ( - "const DATA=" + data_json + ";\n" - "const PERIODS=DATA.periods;\n" - "const SERIES=DATA.series;\n" - "let selKey=SERIES[0].key;\n" - "let winLen=PERIODS.length;\n" - "function getSlice(){\n" - " const n=Math.min(winLen,PERIODS.length);\n" - " return{periods:PERIODS.slice(-n),series:SERIES.map(s=>({...s,subj:s.subj.slice(-n)}))};\n" - "}\n" - "function fmtV(v,kind){\n" - " if(v===null||v===undefined||isNaN(v))return'—';\n" - " if(kind==='%')return v.toFixed(1)+'%';\n" - " return v.toFixed(1)+'×';\n" - "}\n" - "function heatTone(v,arr){\n" - " const clean=arr.filter(x=>x!==null&&!isNaN(x));\n" - " if(clean.length<2)return'';\n" - " const mn=Math.min(...clean),mx=Math.max(...clean);\n" - " const t=(v-mn)/((mx-mn)||1);\n" - " const a=(0.04+t*0.32).toFixed(3);\n" - " return'rgba(194,170,122,'+a+')';\n" - "}\n" - "function drawChart(){\n" - " const{periods,series}=getSlice();\n" - " const s=series.find(x=>x.key===selKey)||series[0];\n" - " const subj=s.subj;\n" - " const W=1100,H=300,Pl=60,Pr=24,Pt=24,Pb=36;\n" - " const clean=subj.filter(x=>x!==null);\n" - " if(!clean.length)return;\n" - " let yMn=Math.min(...clean),yMx=Math.max(...clean);\n" - " const pad=(yMx-yMn)*0.14||1;\n" - " yMn-=pad;yMx+=pad;\n" - " if(yMn>0&&yMn<pad*2)yMn=0;\n" - " const xAt=i=>Pl+(i/Math.max(periods.length-1,1))*(W-Pl-Pr);\n" - " const yAt=v=>Pt+(1-(v-yMn)/(yMx-yMn))*(H-Pt-Pb);\n" - " const pts=subj.map((v,i)=>({x:xAt(i),y:v!==null?yAt(v):null,v}));\n" - " let segs=[],cur=[];\n" - " pts.forEach(p=>{if(p.y!==null){cur.push(p);}else{if(cur.length){segs.push(cur);cur=[];}}})\n" - " if(cur.length)segs.push(cur);\n" - " const lp=segs.map(seg=>seg.map((p,i)=>(i===0?'M':'L')+p.x.toFixed(1)+' '+p.y.toFixed(1)).join(' ')).join(' ');\n" - " const fp=pts.find(p=>p.y!==null);\n" - " const lsP=[...pts].reverse().find(p=>p.y!==null);\n" - " const ap=fp&&lsP&&lp?lp+' L'+lsP.x.toFixed(1)+' '+(H-Pb)+' L'+fp.x.toFixed(1)+' '+(H-Pb)+' Z':'';\n" - " const ticks=[];\n" - " for(let i=0;i<5;i++)ticks.push(yMn+(yMx-yMn)*(i/4));\n" - " let svg='<defs><linearGradient id=\"kh-grad\" x1=\"0\" x2=\"0\" y1=\"0\" y2=\"1\">';\n" - " svg+='<stop offset=\"0%\" stop-color=\"var(--brass)\" stop-opacity=\"0.18\"/>';\n" - " svg+='<stop offset=\"100%\" stop-color=\"var(--brass)\" stop-opacity=\"0\"/>';\n" - " svg+='</linearGradient></defs>';\n" - " ticks.forEach(t=>{\n" - " const y=yAt(t).toFixed(1);\n" - " svg+='<line x1=\"'+Pl+'\" x2=\"'+(W-Pr)+'\" y1=\"'+y+'\" y2=\"'+y+'\" stroke=\"var(--line-1)\" stroke-width=\"1\"/>';\n" - " svg+='<text x=\"'+(Pl-8)+'\" y=\"'+(parseFloat(y)+3).toFixed(1)+'\" font-family=\"var(--font-mono)\" font-size=\"10\" fill=\"var(--fg-3)\" text-anchor=\"end\">'+fmtV(t,s.kind)+'</text>';\n" - " });\n" - " periods.forEach((p,i)=>{\n" - " svg+='<text x=\"'+xAt(i).toFixed(1)+'\" y=\"'+(H-12)+'\" font-family=\"var(--font-mono)\" font-size=\"11\" fill=\"var(--fg-3)\" text-anchor=\"middle\">'+p+'</text>';\n" - " });\n" - " if(ap)svg+='<path d=\"'+ap+'\" fill=\"url(#kh-grad)\"/>';\n" - " if(lp)svg+='<path d=\"'+lp+'\" stroke=\"var(--brass-bright)\" stroke-width=\"2\" fill=\"none\" stroke-linejoin=\"round\" stroke-linecap=\"round\"/>';\n" - " let lastVI=-1;\n" - " for(let k=pts.length-1;k>=0;k--){if(pts[k].y!==null){lastVI=k;break;}}\n" - " pts.forEach((p,idx)=>{\n" - " if(p.y===null)return;\n" - " svg+='<circle cx=\"'+p.x.toFixed(1)+'\" cy=\"'+p.y.toFixed(1)+'\" r=\"3\" fill=\"var(--brass-bright)\" stroke=\"var(--ink-1)\" stroke-width=\"1.5\"/>';\n" - " if(idx===lastVI)svg+='<text x=\"'+p.x.toFixed(1)+'\" y=\"'+(p.y-10).toFixed(1)+'\" font-family=\"var(--font-mono)\" font-size=\"11\" fill=\"var(--fg-1)\" text-anchor=\"end\" font-weight=\"500\">'+fmtV(p.v,s.kind)+'</text>';\n" - " });\n" - " document.getElementById('kh-chart').innerHTML='<svg viewBox=\"0 0 '+W+' '+H+'\" class=\"kh-chart-svg\" preserveAspectRatio=\"none\">'+svg+'</svg>';\n" - " const nonNull=subj.filter(x=>x!==null);\n" - " const latest=nonNull[nonNull.length-1];\n" - " const avg=nonNull.reduce((a,b)=>a+b,0)/nonNull.length;\n" - " const hi=Math.max(...nonNull),lo=Math.min(...nonNull);\n" - " const dAvg=avg!==0?((latest-avg)/Math.abs(avg))*100:0;\n" - " const n=periods.length;\n" - " document.getElementById('kh-hero-group').textContent=s.group;\n" - " document.getElementById('kh-hero-title').innerHTML=s.lbl+'<span class=\"kind\"> · '+(s.kind==='%'?'percent':'multiple')+'</span>';\n" - " document.getElementById('kh-stat-latest').textContent=fmtV(latest,s.kind);\n" - " document.getElementById('kh-stat-n-lbl').textContent=n+'-yr avg';\n" - " document.getElementById('kh-stat-avg').textContent=fmtV(avg,s.kind);\n" - " const davgEl=document.getElementById('kh-stat-davg');\n" - " davgEl.textContent=(dAvg>=0?'+':'')+dAvg.toFixed(0)+'%';\n" - " davgEl.className='d num '+(dAvg>=0?'pos':'neg');\n" - " document.getElementById('kh-stat-range').textContent=fmtV(lo,s.kind)+' — '+fmtV(hi,s.kind);\n" - "}\n" - "function renderMatrix(){\n" - " const{periods,series}=getSlice();\n" - " const n=periods.length;\n" - " const col='1.6fr '+'1fr '.repeat(n);\n" - " const headRow=document.getElementById('kh-matrix-head-row');\n" - " headRow.style.gridTemplateColumns=col;\n" - " let hh='<span class=\"lbl\" style=\"padding-left:var(--sp-5)\">Ratio</span>';\n" - " periods.forEach(p=>{hh+='<span class=\"r num\" style=\"text-align:right;padding:8px var(--sp-3)\">'+p+'</span>';});\n" - " headRow.innerHTML=hh;\n" - " const groups=[...new Set(series.map(s=>s.group))];\n" - " let html='';\n" - " groups.forEach(group=>{\n" - " html+='<div class=\"kh-matrix-section\">'+group+'</div>';\n" - " series.filter(s=>s.group===group).forEach(s=>{\n" - " const act=s.key===selKey?' active':'';\n" - " html+='<div class=\"kh-matrix-grid'+act+'\" style=\"grid-template-columns:'+col+'\" onclick=\"selectSeries(\\''+s.key+'\\')\">';\n" - " html+='<span class=\"lbl\">'+s.lbl+'</span>';\n" - " s.subj.forEach((v,i)=>{\n" - " const last=i===n-1?' last':'';\n" - " const bg=v!==null?' style=\"background:'+heatTone(v,s.subj)+'\"':'';\n" - " html+='<span class=\"cell num'+last+'\"'+bg+'>'+(v!==null?fmtV(v,s.kind):'—')+'</span>';\n" - " });\n" - " html+='</div>';\n" - " });\n" - " });\n" - " document.getElementById('kh-matrix-body').innerHTML=html;\n" - "}\n" - "function selectSeries(key){\n" - " selKey=key;\n" - " drawChart();\n" - " renderMatrix();\n" - "}\n" - "function setWindow(n,btn){\n" - " winLen=n;\n" - " document.querySelectorAll('.seg button').forEach(b=>b.classList.remove('active'));\n" - " btn.classList.add('active');\n" - " drawChart();\n" - " renderMatrix();\n" - "}\n" - "drawChart();\n" - "renderMatrix();\n" + + _JS_TEMPLATE = ( + 'const DATA=__DATA_JSON__;' + 'const PERIODS=DATA.periods;' + 'const SERIES=DATA.series;' + 'let selKey=SERIES[0].key;' + 'let winLen=PERIODS.length;' + 'function getSlice(){' + ' const n=Math.min(winLen,PERIODS.length);' + ' return{periods:PERIODS.slice(-n),series:SERIES.map(s=>({...s,subj:s.subj.slice(-n)}))};' + '}' + 'function fmtV(v,kind){' + ' if(v===null||v===undefined||isNaN(v))return"—";' + ' if(kind==="%")return v.toFixed(1)+"%";' + ' return v.toFixed(1)+"×";' + '}' + 'function heatTone(v,arr){' + ' const clean=arr.filter(x=>x!==null&&!isNaN(x));' + ' if(clean.length<2)return"";' + ' const mn=Math.min(...clean),mx=Math.max(...clean);' + ' const t=(v-mn)/((mx-mn)||1);' + ' const a=(0.04+t*0.32).toFixed(3);' + ' return"rgba(194,170,122,"+a+")";' + '}' + 'function drawChart(){' + ' const{periods,series}=getSlice();' + ' const s=series.find(x=>x.key===selKey)||series[0];' + ' const subj=s.subj;' + ' const W=1100,H=300,Pl=60,Pr=40,Pt=24,Pb=36;' + ' const clean=subj.filter(x=>x!==null);' + ' if(!clean.length)return;' + ' let yMn=Math.min(...clean),yMx=Math.max(...clean);' + ' if(s.sector_ttm!==null&&s.sector_ttm!==undefined){' + ' yMn=Math.min(yMn,s.sector_ttm);' + ' yMx=Math.max(yMx,s.sector_ttm);' + ' }' + ' const pad=(yMx-yMn)*0.14||1;' + ' yMn-=pad;yMx+=pad;' + ' if(yMn>0&&yMn<pad*2)yMn=0;' + ' const xAt=i=>Pl+(i/Math.max(periods.length-1,1))*(W-Pl-Pr);' + ' const yAt=v=>Pt+(1-(v-yMn)/(yMx-yMn))*(H-Pt-Pb);' + ' const pts=subj.map((v,i)=>({x:xAt(i),y:v!==null?yAt(v):null,v}));' + ' let segs=[],cur=[];' + ' pts.forEach(p=>{if(p.y!==null){cur.push(p);}else{if(cur.length){segs.push(cur);cur=[];}}});' + ' if(cur.length)segs.push(cur);' + ' const lp=segs.map(seg=>seg.map((p,i)=>(i===0?"M":"L")+p.x.toFixed(1)+" "+p.y.toFixed(1)).join(" ")).join(" ");' + ' const fp=pts.find(p=>p.y!==null);' + ' const lsP=[...pts].reverse().find(p=>p.y!==null);' + ' const ap=fp&&lsP&&lp?lp+" L"+lsP.x.toFixed(1)+" "+(H-Pb)+" L"+fp.x.toFixed(1)+" "+(H-Pb)+" Z":"";' + ' const ticks=[];' + ' for(let i=0;i<5;i++)ticks.push(yMn+(yMx-yMn)*(i/4));' + ' let svg=\'<defs><linearGradient id="kh-grad" x1="0" x2="0" y1="0" y2="1">\';' + ' svg+=\'<stop offset="0%" stop-color="var(--brass)" stop-opacity="0.18"/>\';' + ' svg+=\'<stop offset="100%" stop-color="var(--brass)" stop-opacity="0"/>\';' + ' svg+=\'</linearGradient></defs>\';' + ' ticks.forEach(t=>{' + ' const y=yAt(t).toFixed(1);' + ' svg+=\'<line x1="\'+Pl+\'" x2="\'+(W-Pr)+\'" y1="\'+y+\'" y2="\'+y+\'" stroke="var(--line-1)" stroke-width="1"/>\';' + ' svg+=\'<text x="\'+(Pl-8)+\'" y="\'+(parseFloat(y)+3).toFixed(1)+\'" font-family="var(--font-mono)" font-size="10" fill="var(--fg-3)" text-anchor="end">\'+fmtV(t,s.kind)+\'</text>\';' + ' });' + ' periods.forEach((p,i)=>{' + ' svg+=\'<text x="\'+xAt(i).toFixed(1)+\'" y="\'+(H-12)+\'" font-family="var(--font-mono)" font-size="11" fill="var(--fg-3)" text-anchor="middle">\'+p+\'</text>\';' + ' });' + ' if(s.sector_ttm!==null&&s.sector_ttm!==undefined){' + ' const sy=yAt(s.sector_ttm);' + ' const x0=Pl,x1=W-Pr;' + ' svg+=\'<line x1="\'+x0+\'" x2="\'+x1+\'" y1="\'+sy.toFixed(1)+\'" y2="\'+sy.toFixed(1)+\'" stroke="var(--oxford-light)" stroke-width="1.5" stroke-dasharray="4,4"/>\';' + ' svg+=\'<circle cx="\'+x1+\'" cy="\'+sy.toFixed(1)+\'" r="3" fill="var(--oxford-light)"/>\';' + ' svg+=\'<text x="\'+(x1-4)+\'" y="\'+(sy-6).toFixed(1)+\'" font-family="var(--font-mono)" font-size="9" fill="var(--oxford-light)" text-anchor="end">sector</text>\';' + ' }' + ' if(ap)svg+=\'<path d="\'+ap+\'" fill="url(#kh-grad)"/>\';' + ' if(lp)svg+=\'<path d="\'+lp+\'" stroke="var(--brass-bright)" stroke-width="2" fill="none" stroke-linejoin="round" stroke-linecap="round"/>\';' + ' let lastVI=-1;' + ' for(let k=pts.length-1;k>=0;k--){if(pts[k].y!==null){lastVI=k;break;}}' + ' pts.forEach((p,idx)=>{' + ' if(p.y===null)return;' + ' svg+=\'<circle cx="\'+p.x.toFixed(1)+\'" cy="\'+p.y.toFixed(1)+\'" r="3" fill="var(--brass-bright)" stroke="var(--ink-1)" stroke-width="1.5"/>\';' + ' if(idx===lastVI)svg+=\'<text x="\'+p.x.toFixed(1)+\'" y="\'+(p.y-10).toFixed(1)+\'" font-family="var(--font-mono)" font-size="11" fill="var(--fg-1)" text-anchor="end" font-weight="500">\'+fmtV(p.v,s.kind)+\'</text>\';' + ' });' + ' document.getElementById("kh-chart").innerHTML=\'<svg viewBox="0 0 \'+W+\' \'+H+\'" class="kh-chart-svg" preserveAspectRatio="none">\'+svg+\'</svg>\';' + ' const nonNull=subj.filter(x=>x!==null);' + ' const latest=nonNull[nonNull.length-1];' + ' const avg=nonNull.reduce((a,b)=>a+b,0)/nonNull.length;' + ' const hi=Math.max(...nonNull),lo=Math.min(...nonNull);' + ' const dAvg=avg!==0?((latest-avg)/Math.abs(avg))*100:0;' + ' const n=periods.length;' + ' document.getElementById("kh-hero-group").textContent=s.group;' + ' document.getElementById("kh-hero-title").innerHTML=s.lbl+\'<span class="kind"> · \'+(s.kind==="%"?"percent":"multiple")+"</span>";' + ' document.getElementById("kh-stat-latest").textContent=fmtV(latest,s.kind);' + ' document.getElementById("kh-stat-n-lbl").textContent=n+"-yr avg";' + ' document.getElementById("kh-stat-avg").textContent=fmtV(avg,s.kind);' + ' const davgEl=document.getElementById("kh-stat-davg");' + ' davgEl.textContent=(dAvg>=0?"+":"")+dAvg.toFixed(0)+"%";' + ' davgEl.className="d num "+(dAvg>=0?"pos":"neg");' + ' document.getElementById("kh-stat-range").textContent=fmtV(lo,s.kind)+" — "+fmtV(hi,s.kind);' + ' const secEl=document.getElementById("kh-stat-sector");' + ' const dsecEl=document.getElementById("kh-stat-dsector");' + ' if(s.sector_ttm!==null&&s.sector_ttm!==undefined){' + ' secEl.textContent=fmtV(s.sector_ttm,s.kind);' + ' const dSec=s.sector_ttm!==0?((latest-s.sector_ttm)/Math.abs(s.sector_ttm))*100:0;' + ' dsecEl.textContent=(dSec>=0?"+":"")+dSec.toFixed(0)+"%";' + ' dsecEl.className="d num "+(dSec>=0?"pos":"neg");' + ' }else{' + ' secEl.textContent="—";' + ' dsecEl.textContent="";' + ' }' + '}' + 'function renderMatrix(){' + ' const{periods,series}=getSlice();' + ' const n=periods.length;' + ' const col="1.6fr "+"1fr ".repeat(n)+"1fr 0.8fr";' + ' const headRow=document.getElementById("kh-matrix-head-row");' + ' headRow.style.gridTemplateColumns=col;' + ' let hh=\'<span class="lbl" style="padding-left:var(--sp-5)">Ratio</span>\';' + ' periods.forEach(p=>{hh+=\'<span class="r num" style="text-align:right;padding:8px var(--sp-3)">\'+p+\'</span>\';});' + ' hh+=\'<span class="r" style="text-align:right;padding:8px var(--sp-3)">Sector TTM</span>\';' + ' hh+=\'<span class="r" style="text-align:right;padding:8px var(--sp-3)">Δ vs sector</span>\';' + ' headRow.innerHTML=hh;' + ' const groups=[...new Set(series.map(s=>s.group))];' + ' let html="";' + ' groups.forEach(group=>{' + ' html+=\'<div class="kh-matrix-section">\'+group+\'</div>\';' + ' series.filter(s=>s.group===group).forEach(s=>{' + ' const act=s.key===selKey?" active":"";' + ' html+=\'<div class="kh-matrix-grid\'+act+\'" style="grid-template-columns:\'+col+\'" onclick="selectSeries(\\\'"+s.key+"\\\')">\';' + ' html+=\'<span class="lbl">\'+s.lbl+\'</span>\';' + ' s.subj.forEach((v,i)=>{' + ' const last=i===n-1?" last":"";' + ' const bg=v!==null?" style=\\"background:"+heatTone(v,s.subj)+"\\"":\"\";' + ' html+=\'<span class="cell num\'+last+\'"\'+bg+">"+(v!==null?fmtV(v,s.kind):"—")+"</span>";' + ' });' + ' if(s.sector_ttm!==null&&s.sector_ttm!==undefined){' + ' const lastSubj=s.subj.filter(x=>x!==null);' + ' const lv=lastSubj.length?lastSubj[lastSubj.length-1]:null;' + ' html+=\'<span class="cell num sector">\'+fmtV(s.sector_ttm,s.kind)+\'</span>\';' + ' if(lv!==null){' + ' const d=s.sector_ttm!==0?((lv-s.sector_ttm)/Math.abs(s.sector_ttm))*100:0;' + ' html+=\'<span class="cell num d \'+( d>=0?"pos":"neg")+\'">\'+( d>=0?"+":"")+d.toFixed(0)+"%</span>";' + ' }else{html+=\'<span class="cell num">—</span>\';}' + ' }else{' + ' html+=\'<span class="cell num">—</span><span class="cell num">—</span>\';' + ' }' + ' html+="</div>";' + ' });' + ' });' + ' document.getElementById("kh-matrix-body").innerHTML=html;' + '}' + 'function selectSeries(key){' + ' selKey=key;' + ' drawChart();' + ' renderMatrix();' + '}' + 'function setWindow(n,btn){' + ' winLen=n;' + ' document.querySelectorAll(".seg button").forEach(b=>b.classList.remove("active"));' + ' btn.classList.add("active");' + ' drawChart();' + ' renderMatrix();' + '}' + 'drawChart();' + 'renderMatrix();' ) + js = _JS_TEMPLATE.replace('__DATA_JSON__', data_json) + + kh_css_extra = ( + '<style>' + + '.kh-legend .sw.sect{' + + 'background:transparent;' + + 'border-bottom:2px dashed var(--oxford-light);' + + 'height:0px;line-height:0;' + + 'vertical-align:middle;' + + 'display:inline-block;' + + 'width:18px;margin-right:6px;' + + '}' + + '.kh-matrix-grid .cell.sector{color:var(--oxford-light)}' + + '.kh-matrix-grid .cell.d.pos{color:var(--positive)}' + + '.kh-matrix-grid .cell.d.neg{color:var(--negative)}' + + '</style>' + ) + doc = ( - "<!doctype html><html><head><meta charset=\"utf-8\">" - "<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">" - "<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&family=IBM+Plex+Sans:wght@300;400;500;600&display=swap\" rel=\"stylesheet\">" - "<style>*,*::before,*::after{box-sizing:border-box}" - ":root{" - "--ink-0:#0B0E13;--ink-1:#11151C;--ink-2:#181D26;--ink-3:#222934;--ink-4:#2C3340;" - "--line-1:#232934;--line-2:#2E3645;--line-3:#3D4658;" - "--fg-1:#F2ECDC;--fg-2:#C7C0AE;--fg-3:#8E8676;--fg-4:#5E5849;" - "--brass:#C2AA7A;--brass-bright:#DCC79E;--brass-deep:#8F7A50;" - "--oxford:#1F3D5C;--oxford-light:#2E5A87;" - "--positive:#4F8C5E;--negative:#B5494B;" - "--font-display:'EB Garamond',Georgia,serif;" - "--font-sans:'IBM Plex Sans','Helvetica Neue',system-ui,sans-serif;" - "--font-mono:'IBM Plex Mono','SF Mono',Menlo,monospace;" - "--fs-12:0.75rem;--fs-13:0.8125rem;--fs-14:0.875rem;--fs-16:1rem;--fs-18:1.125rem;--fs-20:1.25rem;--fs-24:1.5rem;--fs-30:1.875rem;" - "--tr-wider:0.12em;--tr-wide:0.04em;--tr-snug:-0.01em;" - "--sp-1:4px;--sp-2:8px;--sp-3:12px;--sp-4:16px;--sp-5:24px;--sp-6:32px;--sp-7:48px;" - "--r-1:2px;--r-2:4px;--r-3:6px;--r-full:999px;" - "}" - "html,body{margin:0;padding:0;background:var(--ink-0);color:var(--fg-2);font-family:var(--font-sans);font-size:14px;-webkit-font-smoothing:antialiased}" - "</style>" - + _KR_CSS + _KH_CSS + "<!doctype html><html><head><meta charset='utf-8'>" + + "<link rel='preconnect' href='https://fonts.googleapis.com'>" + + "<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&family=IBM+Plex+Sans:wght@300;400;500;600&display=swap' rel='stylesheet'>" + + "<style>*,*::before,*::after{box-sizing:border-box}" + + ":root{" + + "--ink-0:#0B0E13;--ink-1:#11151C;--ink-2:#181D26;--ink-3:#222934;--ink-4:#2C3340;" + + "--line-1:#232934;--line-2:#2E3645;--line-3:#3D4658;" + + "--fg-1:#F2ECDC;--fg-2:#C7C0AE;--fg-3:#8E8676;--fg-4:#5E5849;" + + "--brass:#C2AA7A;--brass-bright:#DCC79E;--brass-deep:#8F7A50;" + + "--oxford:#1F3D5C;--oxford-light:#2E5A87;" + + "--positive:#4F8C5E;--negative:#B5494B;" + + "--font-display:'EB Garamond',Georgia,serif;" + + "--font-sans:'IBM Plex Sans','Helvetica Neue',system-ui,sans-serif;" + + "--font-mono:'IBM Plex Mono','SF Mono',Menlo,monospace;" + + "--fs-12:0.75rem;--fs-13:0.8125rem;--fs-14:0.875rem;--fs-16:1rem;--fs-18:1.125rem;--fs-20:1.25rem;--fs-24:1.5rem;--fs-30:1.875rem;" + + "--tr-wider:0.12em;--tr-wide:0.04em;--tr-snug:-0.01em;" + + "--sp-1:4px;--sp-2:8px;--sp-3:12px;--sp-4:16px;--sp-5:24px;--sp-6:32px;--sp-7:48px;" + + "--r-1:2px;--r-2:4px;--r-3:6px;--r-full:999px;" + + "}" + + "html,body{margin:0;padding:0;background:var(--ink-0);color:var(--fg-2);font-family:var(--font-sans);font-size:14px;-webkit-font-smoothing:antialiased}" + + "</style>" + + _KR_CSS + _KH_CSS + kh_css_extra + "</head><body>" + body + "<script>" + js + "</script>" + "</body></html>" ) - components.html(doc, height=total_height, scrolling=True) + components.html(doc, height=total_height, scrolling=False) + # ── Forward Estimates ──────────────────────────────────────────────────────── |
