aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--components/valuation.py2582
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 &amp; 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 &amp; 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 &middot; 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 &middot; firm value method &middot; {yrs}-yr horizon</span>
- </div>
- <span class="arrow">vs</span>
- <div class="col" style="align-items:flex-end">
- <span class="lbl">Market Price</span>
- <span class="big market num">{market_str}</span>
- <span class="pill {pill_cls}" id="upside-pill">{pill_text}</span>
- </div>
- </div>
- <div class="band">
- <span>Reading &middot; DCF implies <span class="mono" id="gap-str">{gap_str}</span> <span id="gap-dir">{gap_dir}</span> the current market.</span>
- <span class="reading" id="reading-str">{reading}</span>
- </div>
- </section>
-
- <section class="va-projection">
- <div class="head">
- <h3>Enterprise value build &mdash; present value of FCFs + terminal</h3>
- <span class="units" id="chart-units">USD &middot; billions &middot; discounted at WACC {wacc_pct:.1f}%</span>
- </div>
- <div id="dcf-chart" style="width:100%;height:260px"></div>
- <table class="va-cf-table">
- <thead><tr 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{(' &middot; ' + source_date) if source_date else ''}</span>
- </div>
- <div class="flow">
- <div class="node start"><span class="lbl">Enterprise value</span><span class="v num" id="ev-node-val">{ev_b}</span></div>
- <div class="op">&minus;<span class="sub">Net debt</span></div>
- <div class="node"><span class="lbl">Net debt</span><span class="v num">{net_debt_b}</span></div>
- <div class="op">&minus;<span class="sub">Other claims</span></div>
- <div class="node"><span class="lbl">Other claims</span><span class="v num">{other_claims_b}</span></div>
- <div class="op">=</div>
- <div class="node result"><span class="lbl">Equity value</span><span class="v num" id="equity-node-val">{equity_b}</span></div>
- </div>
- <div class="bfoot">
- <span>Total debt {total_debt_b}</span>
- <span>&middot;</span>
- <span>Cash &amp; equiv. {cash_b}</span>
- <span>&middot;</span>
- <span>Preferred + minority {_fmt_b(other_b_val)}</span>
- </div>
- </section>
-
- <section class="va-recon">
- <div class="cell intrinsic">
- <span class="lbl">Intrinsic &middot; Per Share</span>
- <span class="v num" id="recon-iv">{iv_str}</span>
- <span class="sub">Equity value &divide; shares</span>
- </div>
- <div class="cell">
- <span class="lbl">Market &middot; Last</span>
- <span class="v num">{market_str}</span>
- <span class="sub">&nbsp;</span>
- </div>
- <div class="cell">
- <span class="lbl">Gap</span>
- <span class="v num" 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 &middot; implied per-share</span>
- </div>
- <div class="va-cx-grid">
- {cx_dcf}
- {cx_ev}
- {cx_rev}
- {cx_pb}
- </div>
- </section>
-
- <div class="va-foot">
- <span>Firm-value DCF &middot; enterprise value bridged to equity using debt &amp; cash from the most recent balance sheet. Negative-FCF years are excluded from the base; terminal value uses Gordon Growth Model.</span>
- <a href="#">Methodology &amp; sources &nearr;</a>
- </div>
-
- </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 &mdash; implied per-share</h2>"
+ " <p class=\"lede\">Subject multiple &times; 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 &middot; last</span>"
+ " <span class=\"v num\">" + (_fs(market) if has_market else "—") + "</span>"
+ " <span class=\"d num\" style=\"color:var(--fg-3)\">" + ticker + " &middot; " + exchange + "</span>"
+ " </div>"
+ " </div>"
+ "</section>"
+ ""
+ "<section class=\"vm-compare\">"
+ " <div class=\"vm-compare-head\">"
+ " <h3>Method comparison</h3>"
+ " <span class=\"units\">USD &middot; TTM metrics &middot; 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 &mdash; 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 &mdash; works for balance-sheet-heavy businesses (banks, insurers, REITs). Limited signal for asset-light software &amp; 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&times;</span><span>typical 14&times;&ndash;26&times;</span><span>32&times;</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&times;</span><span>typical 6&times;&ndash;13&times;</span><span>20&times;</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&times;</span><span>typical 8&times;&ndash;14&times;</span><span>60&times;</span></div>"
+ " </div>"
+ " </div>"
+ ""
+ " <div class=\"vm-grid\">"
+ " <div class=\"vm-row-lbl\">&times; 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 &middot; TTM</span></div>"
+ " <div class=\"vm-cell\"><span class=\"v num\">" + (_fb(revenue) if rv_ok else "—") + "</span><span class=\"cap\">Revenue &middot; TTM</span></div>"
+ " <div class=\"vm-cell\"><span class=\"v num\">" + (_fs(book_ps) if pb_ok else "—") + "</span><span class=\"cap\">Book value &middot; /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 &times; metric</span></div>"
+ " <div class=\"vm-cell\"><span class=\"v num\" id=\"rv-ev-val\">" + _fb(rv_ev0) + "</span><span class=\"cap\">multiple &times; metric</span></div>"
+ " <div class=\"vm-cell faded\"><span class=\"v num dash\">—</span><span class=\"cap\">P/B is an equity multiple &mdash; no EV step</span></div>"
+ " </div>"
+ ""
+ " <div class=\"vm-grid\">"
+ " <div class=\"vm-row-lbl\">&minus; Net debt</div>"
+ " <div class=\"vm-cell\"><span class=\"v num\">" + _fb(net_debt) + "</span><span class=\"cap\">total " + _fb(total_debt) + " &minus; cash " + _fb(cash) + "</span></div>"
+ " <div class=\"vm-cell\"><span class=\"v num\">" + _fb(net_debt) + "</span><span class=\"cap\">total " + _fb(total_debt) + " &minus; 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 &minus; net debt</span></div>"
+ " <div class=\"vm-cell\"><span class=\"v num\" id=\"rv-eq-val\">" + _fb(rv_eq0) + "</span><span class=\"cap\">EV &minus; 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\">&divide; 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\">&rarr;</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 &Delta; " + _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\">&rarr;</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 &Delta; " + _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\">&rarr;</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 &Delta; " + _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 &middot; 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) + " &middot; 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) + " &middot; 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) + " &middot; sector " + _fx(pb_sector) + " &middot; low-signal</span>"
+ " </div>"
+ " </div>"
+ "</section>"
+ ""
+ "<div class=\"va-foot\">"
+ " <span>Multiples &middot; 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 &amp; sources &nearr;</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 &mdash; implied per-share</h2>
- <p class="lede">Subject multiple &times; 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 &middot; last</span>
- <span class="v num">{_fs(market) if has_market else "—"}</span>
- <span class="d num" style="color:var(--fg-3)">{ticker} &middot; {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 &middot; TTM metrics &middot; 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 &mdash; 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 &mdash; works for balance-sheet-heavy businesses (banks, insurers, REITs). Limited signal for asset-light software &amp; 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&times;</span><span>typical 14&times;&ndash;26&times;</span><span>32&times;</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&times;</span><span>typical 6&times;&ndash;13&times;</span><span>20&times;</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&times;</span><span>typical 8&times;&ndash;14&times;</span><span>60&times;</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">&times; 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 &middot; TTM</span></div>
- <div class="vm-cell"><span class="v num">{_fb(revenue) if rv_ok else "—"}</span><span class="cap">Revenue &middot; TTM</span></div>
- <div class="vm-cell"><span class="v num">{_fs(book_ps) if pb_ok else "—"}</span><span class="cap">Book value &middot; /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 &times; metric</span></div>
- <div class="vm-cell"><span class="v num" id="rv-ev-val">{_fb(rv_ev0)}</span><span class="cap">multiple &times; metric</span></div>
- <div class="vm-cell faded"><span class="v num dash">—</span><span class="cap">P/B is an equity multiple &mdash; 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">&minus; Net debt</div>
- <div class="vm-cell"><span class="v num">{_fb(net_debt)}</span><span class="cap">total {_fb(total_debt)} &minus; cash {_fb(cash)}</span></div>
- <div class="vm-cell"><span class="v num">{_fb(net_debt)}</span><span class="cap">total {_fb(total_debt)} &minus; 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 &minus; net debt</span></div>
- <div class="vm-cell"><span class="v num" id="rv-eq-val">{_fb(rv_eq0)}</span><span class="cap">EV &minus; 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">&divide; 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">&rarr;</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 &Delta; {_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">&rarr;</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 &Delta; {_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">&rarr;</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 &Delta; {_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 &middot; 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)} &middot; 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)} &middot; 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)} &middot; sector {_fx(pb_sector)} &middot; 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 &middot; 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 &amp; sources &nearr;</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 &amp; 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\">&nbsp;</span>"
+ "</div>"
+ "<div class=\"cell\">"
+ "<span class=\"lbl\">Gap</span>"
+ "<span class=\"v num\" style=\"color:" + gap_color + "\">" + gap_display + "</span>"
+ "<span class=\"sub\">" + gap_pct_str + "</span>"
+ "</div>"
+ "<div class=\"cell\">"
+ "<span class=\"lbl\">Shares Outstanding</span>"
+ "<span class=\"v num\">" + "{:.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 &amp; cash from the most recent balance sheet. Negative-FCF years are excluded from the base; terminal value uses Gordon Growth Model.</span>"
+ "<a href=\"#\">Methodology &amp; sources ↗</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 &times; 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&#39;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&#39;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 ────────────────────────────────────────────────────────