From bb55d8be4a080e16227290333f667a5b39fa6575 Mon Sep 17 00:00:00 2001 From: Tyler Date: Sun, 17 May 2026 01:33:17 -0700 Subject: Redesign Valuation tab: Key Ratios, Models, Historical, Comps, Multiples MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Key Ratios: 6-KPI snapshot strip with sparklines, 5-category ratio grid (Valuation, Profitability, Growth, Health, Cash Returns) via components.html() - Models (DCF): two-column layout — st.slider() rail + prominent verdict chip, EV→equity bridge, per-share reconciliation, cross-check grid as HTML canvas - Historical Ratios: SVG line chart (subject vs sector median) + clickable heatmap matrix that updates the chart via client-side JS - Comps: 4 percentile rank bars + sortable peer table, all via components.html() - Multiples: math-flow columns (EV/EBITDA, EV/Revenue, P/Book) with sensitivity strip and DCF cross-check; HTML range sliders drive JS computation All redesigned tabs: scrolling=False, string-concat HTML (no f-strings), XSS-safe (escape_html on all user-supplied strings injected into HTML/JS). Co-Authored-By: Claude Sonnet 4.6 --- components/valuation.py | 2085 +++++++++++++++++++++++++++++------------------ 1 file changed, 1291 insertions(+), 794 deletions(-) diff --git a/components/valuation.py b/components/valuation.py index 9525c69..d9b2147 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,8 +462,10 @@ 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") + ev_ebit = _r("evToOperatingCashFlowTTM") # best proxy if direct unavailable pb = _r("priceToBookRatioTTM") ps = _r("priceToSalesRatioTTM") fcf_yield_v = (fcf_ttm / market_cap) if fcf_ttm and market_cap and market_cap > 0 else None @@ -480,8 +483,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 +505,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 +547,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'{diff_pp:+.1f}pp' + mini_cls = '' + f"{diff_pp:+.1f}pp" else: diff = (fv_f - sv_f) / abs(sv_f) * 100 tone = _tone(diff, invert or good_low) - mini_cls = f'{diff:+.0f}%' - sector_html = f'{sv}{mini_cls}' + mini_cls = '' + f"{diff:+.0f}%" + sector_html = '' + sv + mini_cls + '' except Exception: - sector_html = f'{sv}' + sector_html = '' + sv + '' else: - sector_html = f'{sv}' + sector_html = '' + sv + '' 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'
' - f'{lbl}' - f'{fv}' - f'{sector_html}' - f'{spark_svg}' - f'
' + '
' + + '' + lbl + '' + + '' + fv + '' + + sector_html + + '' + spark_svg + '' + + '
' ) # ── Helper: build peer band section ──────────────────────────────────── @@ -560,75 +583,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'' - f'{_fmtv(five_avg, kind)}' - f'{d_avg:+.0f}%' - f'' + '' + + _fmtv(five_avg, kind) + + '' + f"{d_avg:+.0f}%" + '' + + '' ) except Exception: - avg_html = f'{_fmtv(five_avg, kind)}' + avg_html = '' + _fmtv(five_avg, kind) + '' else: - avg_html = f'{_fmtv(five_avg, kind)}' + avg_html = '' + _fmtv(five_avg, kind) + '' 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'
' - f'{_fmtv(p25, kind)}' - f'{_fmtv(p50, kind)}' - f'{_fmtv(p75, kind)}' - f'
' + '
' + + '' + _fmtv(p25, kind) + '' + + '' + _fmtv(p50, kind) + '' + + '' + _fmtv(p75, kind) + '' + + '
' ) return ( - f'
' - f'{lbl}' - f'{fv}' - f'{d_str}' - f'
{peer_bar}{peer_axis}
' - f'{avg_html}' - f'{spark_svg}' - f'
' + '
' + + '' + lbl + '' + + '' + fv + '' + + '' + d_str + '' + + '
' + peer_bar + peer_axis + '
' + + avg_html + + spark_svg + + '
' ) # ── 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'
' - f'
{lbl}
' - f'{fv}' - f'
' - f'peers {sect_str}' - f'{d_str}' - f'
' - f'
' - ) - def _kpi_spark(lbl, v, kind, field, spark_data, invert=False): fv = _fmtv(v, kind) band = peer_bands.get(field, {}) @@ -638,7 +635,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 +643,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'
' - f'
{lbl}{spark_svg}
' - f'{fv}' - f'
' - f'peers {sect_str}' - f'{d_str}' - f'
' - f'
' + '
' + + '
' + lbl + '' + spark_svg + '
' + + '' + fv + '' + + '
' + + 'peers ' + sect_str + '' + + '' + d_str + '' + + '
' + + '
' ) - # 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 +672,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'${price:,.2f}' if price else "" - ctx_chg = f'{chg_str}' 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, "%", "dividendYieldTTM", 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 +695,123 @@ def _render_ratios(ticker: str): ) prof_rows_html = ( - '
MetricSubjectPeers + ΔTrend
' - + _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) + '
MetricSubjectPeers + ΔTrend
' + + _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 = ( - '
MetricSubjectPeers + ΔTrend
' + '
MetricSubjectPeers + ΔTrend
' + _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 = ( - '
MetricSubjectPeersTrend
' - + _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) + '
MetricSubjectPeersTrend
' + + _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 = ( - '
MetricSubjectPeersTrend
' - + _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) + '
MetricSubjectPeersTrend
' + + _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", 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 = ('$' + f"{price:,.2f}" + '') if price else "" + ctx_chg = ('' + chg_str + '') if chg_str else "" body = ( - f'
' - f'{ticker.upper()}' - f'{co_name}' - f'Valuation · Key Ratios' - f'
{exchange}{ctx_price}{ctx_chg}
' - f'
' - f'
' - f'
' - f'
' - f'Snapshot' - f'
Where the lens sits — six headline ratios, scored against the peer set
' - f'

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.

' - f'
' - f'
' - f'
Peer set{n_peers} names{industry[:28]}
' - f'
BasisTTMTrailing twelve months
' - f'
As of{today_str}Prices live · yfinance
' - f'
' - f'
' - f'
{snap_html}
' - f'
' - f'
I

Valuation multiples

Subject · Peer P25 / median / P75 · 5-yr drift
' - f'
RatioSubjectvs peersPeer 25 — 755-yr avg5-yr trend
' - f'{val_rows_html}' - f'
' - f'
' - f'
II

Profitability

Wider margins, higher returns on capital
{prof_rows_html}
' - f'
III

Growth · TTM

Topline & cash growth vs peers
{growth_rows_html}
' - f'
IV

Balance-sheet health

Leverage, liquidity, interest
{health_rows_html}
' - f'
V

Cash returns

Cash giveback to holders · yield
{cash_rows_html}
' - f'
' - f'
Ratios computed from yfinance financial statements, TTM basis. Peer bands from {n_peers} comparable names. Market data live.
' - f'
' + '
' + + '
' + + '' + _h(ticker.upper()) + '' + + '' + co_name + '' + + 'Valuation · Key Ratios' + + '
' + exchange + '' + ctx_price + ctx_chg + '
' + + '
' + + '
' + + '
' + + '
' + + 'Snapshot' + + '
Where the lens sits — six headline ratios, scored against the peer set
' + + '

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.

' + + '
' + + '
' + + '
Peer set' + str(n_peers) + ' names' + industry[:28] + '
' + + '
BasisTTMTrailing twelve months
' + + '
As of' + today_str + 'Prices live · yfinance
' + + '
' + + '
' + + '
' + snap_html + '
' + + '
' + + '
I

Valuation multiples

Subject · Peer P25 / median / P75 · 5-yr drift
' + + '
RatioSubjectvs peersPeer 25 — 755-yr avg5-yr trend
' + + val_rows_html + + '
' + + '
' + + '
II

Profitability

Wider margins, higher returns on capital
' + prof_rows_html + '
' + + '
III

Growth · TTM

Topline & cash growth vs peers
' + growth_rows_html + '
' + + '
IV

Balance-sheet health

Leverage, liquidity, interest
' + health_rows_html + '
' + + '
V

Cash returns

Cash giveback to holders · yield
' + cash_rows_html + '
' + + '
' + + '
Ratios computed from yfinance financial statements, TTM basis. Peer bands from ' + str(n_peers) + ' comparable names. Market data live.
' + + '
' + + '
' ) - doc = f""" - - - -{_KR_CSS} -{body}""" + # ── Assemble full HTML document (string concat, no f-strings) ────────── + doc = ( + '' + + '' + + '' + + '' + + _KR_CSS + + '' + + body + + '' + ) + + components.html(doc, height=2600, scrolling=False) - components.html(doc, height=2400, scrolling=True) # ── Models ─────────────────────────────────────────────────────────────────── @@ -1894,7 +1904,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 +1915,649 @@ def _build_multiples_canvas_html(ctx: dict) -> str: "ebSector": eb_sector, "rvSector": rv_sector, "pbSector": pb_sector, }) - html = f""" - - - - - - -
- -
-
- Multiples -

Three relative-valuation lenses — implied per-share

-

Subject multiple × normalized TTM metric, bridged to equity per share. Compare across columns to see which lens the market is leaning on.

-
-
-
- EV / EBITDA - {_fs(eb_per0)} - {_ds_span(eb_per0, 'id="sum-eb-d"')} -
-
- EV / Revenue - {_fs(rv_per0)} - {_ds_span(rv_per0, 'id="sum-rv-d"')} -
-
- P / Book - {_fs(pb_per0)} - {_ds_span(pb_per0, 'id="sum-pb-d"')} -
-
- Market · last - {_fs(market) if has_market else "—"} - {ticker} · {exchange} -
-
-
- -
-
-

Method comparison

- USD · TTM metrics · balance-sheet bridge -
- -
-
Method
-
-
I

EV / EBITDA

Strong fit
-

Enterprise value normalized by operating cash profit. Strips depreciation and capital structure so different capex profiles compare cleanly.

-
-
-
II

EV / Revenue

Strong fit
-

Topline multiple — useful when margins are volatile or the business is reinvesting through profitability. Less sensitive to one-off charges.

-
-
-
III

P / Book

{pb_fit_lbl}
-

Equity multiple — works for balance-sheet-heavy businesses (banks, insurers, REITs). Limited signal for asset-light software & services.

-
-
- -
-
Subject multipledrag to flex the lens
-
-
{_fx(eb_init)}sector {_fx(eb_sector)}
-
-
- - -
- -
-
typical 14×–26×32×
-
-
-
{_fx(rv_init)}sector {_fx(rv_sector)}
-
-
- - -
- -
-
typical 6×–13×20×
-
-
-
{_fx(pb_init)}sector {_fx(pb_sector)}
-
-
- - -
- -
-
typical 8×–14×60×
-
-
- -
-
× Normalized metricfrom TTM filings
-
{_fb(ebitda) if eb_ok else "—"}EBITDA · TTM
-
{_fb(revenue) if rv_ok else "—"}Revenue · TTM
-
{_fs(book_ps) if pb_ok else "—"}Book value · /share
-
- -
-
= Enterprise value
-
{_fb(eb_ev0)}multiple × metric
-
{_fb(rv_ev0)}multiple × metric
-
P/B is an equity multiple — no EV step
-
+ html = ("" + "" + "" + "" + "" + "" + "" + "
" + "" + "
" + "
" + " Multiples" + "

Three relative-valuation lenses — implied per-share

" + "

Subject multiple × normalized TTM metric, bridged to equity per share. Compare across columns to see which lens the market is leaning on.

" + "
" + "
" + "
" + " EV / EBITDA" + " " + _fs(eb_per0) + "" + " " + _ds_span(eb_per0, 'id="sum-eb-d"') + "" + "
" + "
" + " EV / Revenue" + " " + _fs(rv_per0) + "" + " " + _ds_span(rv_per0, 'id="sum-rv-d"') + "" + "
" + "
" + " P / Book" + " " + _fs(pb_per0) + "" + " " + _ds_span(pb_per0, 'id="sum-pb-d"') + "" + "
" + "
" + " Market · last" + " " + (_fs(market) if has_market else "—") + "" + " " + ticker + " · " + exchange + "" + "
" + "
" + "
" + "" + "
" + "
" + "

Method comparison

" + " USD · TTM metrics · balance-sheet bridge" + "
" + "" + "
" + "
Method
" + "
" + "
I

EV / EBITDA

Strong fit
" + "

Enterprise value normalized by operating cash profit. Strips depreciation and capital structure so different capex profiles compare cleanly.

" + "
" + "
" + "
II

EV / Revenue

Strong fit
" + "

Topline multiple — useful when margins are volatile or the business is reinvesting through profitability. Less sensitive to one-off charges.

" + "
" + "
" + "
III

P / Book

" + pb_fit_lbl + "
" + "

Equity multiple — works for balance-sheet-heavy businesses (banks, insurers, REITs). Limited signal for asset-light software & services.

" + "
" + "
" + "" + "
" + "
Subject multipledrag to flex the lens
" + "
" + "
" + _fx(eb_init) + "sector " + _fx(eb_sector) + "
" + "
" + "
" + " " + " " + "
" + " " + "
" + "
typical 14×–26×32×
" + "
" + "
" + "
" + _fx(rv_init) + "sector " + _fx(rv_sector) + "
" + "
" + "
" + " " + " " + "
" + " " + "
" + "
typical 6×–13×20×
" + "
" + "
" + "
" + _fx(pb_init) + "sector " + _fx(pb_sector) + "
" + "
" + "
" + " " + " " + "
" + " " + "
" + "
typical 8×–14×60×
" + "
" + "
" + "" + "
" + "
× Normalized metricfrom TTM filings
" + "
" + (_fb(ebitda) if eb_ok else "—") + "EBITDA · TTM
" + "
" + (_fb(revenue) if rv_ok else "—") + "Revenue · TTM
" + "
" + (_fs(book_ps) if pb_ok else "—") + "Book value · /share
" + "
" + "" + "
" + "
= Enterprise value
" + "
" + _fb(eb_ev0) + "multiple × metric
" + "
" + _fb(rv_ev0) + "multiple × metric
" + "
P/B is an equity multiple — no EV step
" + "
" + "" + "
" + "
− Net debt
" + "
" + _fb(net_debt) + "total " + _fb(total_debt) + " − cash " + _fb(cash) + "
" + "
" + _fb(net_debt) + "total " + _fb(total_debt) + " − cash " + _fb(cash) + "
" + "
" + "
" + "" + "
" + "
= Equity value
" + "
" + _fb(eb_eq0) + "EV − net debt
" + "
" + _fb(rv_eq0) + "EV − net debt
" + "
" + "
" + "" + "
" + "
÷ Shares outstanding
" + "
" + shares_str + "diluted
" + "
" + shares_str + "diluted
" + "
" + "
" + "" + "
" + "
= Implied per share
" + "
" + " " + _fs(eb_per0) + "" + " " + _d_span(eb_per0, 'id="eb-per-d"') + "" + "
" + "
" + " " + _fs(rv_per0) + "" + " " + _d_span(rv_per0, 'id="rv-per-d"') + "" + "
" + "
" + " " + _fs(pb_per0) + "" + " " + _d_span(pb_per0, 'id="pb-per-d"') + "" + "
" + "
" + "
" + "" + "
" + "
" + "

If the lens shifted to sector

" + " Same metrics, subject multiple replaced by sector median" + "
" + "
" + "" + "
" + " EV / EBITDA" + "
" + "
" + " At subject " + _fx(eb_init) + "" + " " + _fs(eb_per0) + "" + " " + _ds_span(eb_per0, 'id="sens-eb-subj-d"') + "" + "
" + " " + "
" + " At sector " + _fx(eb_sector) + "" + " " + _fs(sec_eb) + "" + " " + _ds_span(sec_eb) + "" + "
" + "
" + " Re-rating Δ " + _rr(eb_per0, sec_eb) + " per share if the subject converged to peers" + "
" + "" + "
" + " EV / Revenue" + "
" + "
" + " At subject " + _fx(rv_init) + "" + " " + _fs(rv_per0) + "" + " " + _ds_span(rv_per0, 'id="sens-rv-subj-d"') + "" + "
" + " " + "
" + " At sector " + _fx(rv_sector) + "" + " " + _fs(sec_rv) + "" + " " + _ds_span(sec_rv) + "" + "
" + "
" + " Re-rating Δ " + _rr(rv_per0, sec_rv) + " per share if the subject converged to peers" + "
" + "" + "
" + " P / Book" + "
" + "
" + " At subject " + _fx(pb_init) + "" + " " + _fs(pb_per0) + "" + " " + _ds_span(pb_per0, 'id="sens-pb-subj-d"') + "" + "
" + " " + "
" + " At sector " + _fx(pb_sector) + "" + " " + _fs(sec_pb) + "" + " " + _ds_span(sec_pb) + "" + "
" + "
" + " Re-rating Δ " + _rr(pb_per0, sec_pb) + " per share if the subject converged to peers" + "
" + "" + "
" + "
" + "" + "
" + "
" + "

Cross-check against DCF

" + " DCF intrinsic from the firm-value model on the previous tab" + "
" + "
" + "
" + " DCF · firm value" + " " + dcf_val_str + "" + " " + dcf_delta_html + "" + " " + dcf_meta_str + "" + "
" + "
" + " EV / EBITDA" + " " + _fs(eb_per0) + "" + " " + _d_span(eb_per0, 'id="cx-eb-d"') + "" + " Subject " + _fx(eb_init) + " · sector " + _fx(eb_sector) + "" + "
" + "
" + " EV / Revenue" + " " + _fs(rv_per0) + "" + " " + _d_span(rv_per0, 'id="cx-rv-d"') + "" + " Subject " + _fx(rv_init) + " · sector " + _fx(rv_sector) + "" + "
" + "
" + " P / Book" + " " + _fs(pb_per0) + "" + " " + _d_span(pb_per0, 'id="cx-pb-d"') + "" + " Subject " + _fx(pb_init) + " · sector " + _fx(pb_sector) + " · low-signal" + "
" + "
" + "
" + "" + "
" + " Multiples · TTM metrics, balance sheet. Net-debt adjustment applies to EV-based methods only; P/B reads directly off book equity per share. Sector medians from peer group analysis." + " Methodology & sources ↗" + "
" + "" + "
" + "" + "" + "") + return html -
-
− Net debt
-
{_fb(net_debt)}total {_fb(total_debt)} − cash {_fb(cash)}
-
{_fb(net_debt)}total {_fb(total_debt)} − cash {_fb(cash)}
-
-
-
-
= Equity value
-
{_fb(eb_eq0)}EV − net debt
-
{_fb(rv_eq0)}EV − net debt
-
-
+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). -
-
÷ Shares outstanding
-
{shares_str}diluted
-
{shares_str}diluted
-
-
+ 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 -
-
= Implied per share
-
- {_fs(eb_per0)} - {_d_span(eb_per0, 'id="eb-per-d"')} -
-
- {_fs(rv_per0)} - {_d_span(rv_per0, 'id="rv-per-d"')} -
-
- {_fs(pb_per0)} - {_d_span(pb_per0, 'id="pb-per-d"')} -
-
-
+ upside_pct = (iv - market) / market * 100 if has_market else 0.0 + is_pos = upside_pct >= 0 + gap = iv - market -
-
-

If the lens shifted to sector

- Same metrics, subject multiple replaced by sector median -
-
+ # 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", "") -
- EV / EBITDA -
-
- At subject {_fx(eb_init)} - {_fs(eb_per0)} - {_ds_span(eb_per0, 'id="sens-eb-subj-d"')} -
- -
- At sector {_fx(eb_sector)} - {_fs(sec_eb)} - {_ds_span(sec_eb)} -
-
- Re-rating Δ {_rr(eb_per0, sec_eb)} per share if the subject converged to peers -
+ # 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 -
- EV / Revenue -
-
- At subject {_fx(rv_init)} - {_fs(rv_per0)} - {_ds_span(rv_per0, 'id="sens-rv-subj-d"')} -
- -
- At sector {_fx(rv_sector)} - {_fs(sec_rv)} - {_ds_span(sec_rv)} -
-
- Re-rating Δ {_rr(rv_per0, sec_rv)} per share if the subject converged to peers -
+ # 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) + "%" -
- P / Book -
-
- At subject {_fx(pb_init)} - {_fs(pb_per0)} - {_ds_span(pb_per0, 'id="sens-pb-subj-d"')} -
- -
- At sector {_fx(pb_sector)} - {_fs(sec_pb)} - {_ds_span(sec_pb)} -
-
- Re-rating Δ {_rr(pb_per0, sec_pb)} per share if the subject converged to peers -
+ # Cash-flow table cells (string concatenation) + n = len(discounted) + hdr_cells = "" + fcf_cells = "" + df_cells = "" + pv_cells = "" + for i in range(n): + hdr_cells += "Yr " + str(i + 1) + "" + fcf_cells += "" + _fmt_b(projected[i]) + "" + df_cells += "" + "{:.3f}".format(disc_factors[i]) + "" + pv_cells += "" + _fmt_b(discounted[i]) + "" + hdr_cells += "Terminal" + fcf_cells += '' + _fmt_b(terminal_fcf) + "" + df_cells += "" + "{:.3f}".format(disc_tv_factor) + "" + pv_cells += '' + _fmt_b(tv_pv) + "" + + # 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)] -
-
+ 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}", + "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}, + }) -
-
-

Cross-check against DCF

- DCF intrinsic from the firm-value model on the previous tab -
-
-
- DCF · firm value - {dcf_val_str} - {dcf_delta_html} - {dcf_meta_str} -
-
- EV / EBITDA - {_fs(eb_per0)} - {_d_span(eb_per0, 'id="cx-eb-d"')} - Subject {_fx(eb_init)} · sector {_fx(eb_sector)} -
-
- EV / Revenue - {_fs(rv_per0)} - {_d_span(rv_per0, 'id="cx-rv-d"')} - Subject {_fx(rv_init)} · sector {_fx(rv_sector)} -
-
- P / Book - {_fs(pb_per0)} - {_d_span(pb_per0, 'id="cx-pb-d"')} - Subject {_fx(pb_init)} · sector {_fx(pb_sector)} · low-signal -
-
-
+ # 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 = '' + dsign + "{:.1f}".format(delta_pct) + "% vs market" + else: + dhtml = '' + return ( + '
' + + '' + lbl + "" + + '' + val_str + "" + + dhtml + + '' + meta + "" + + "
" + ) -
- Multiples · TTM metrics, balance sheet. Net-debt adjustment applies to EV-based methods only; P/B reads directly off book equity per share. Sector medians from peer group analysis. - Methodology & sources ↗ -
+ 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 = '' + dcf_dsign + "{:.1f}".format(dcf_delta) + "% vs market" + else: + dcf_dhtml = '' + cx_dcf = ( + '
' + + 'DCF · THIS MODEL' + + '' + iv_str + "" + + dcf_dhtml + + 'Firm-value DCF · ' + str(yrs) + '-yr explicit · WACC ' + "{:.1f}".format(wacc_pct) + "%" + + "
" + ) -
- - -""" - return html + # Assemble HTML document — string concatenation only + doc = ( + "" + "" + "" + "" + "" + "" + "" + "" + "
" + + # Verdict card + "
" + "
" + "
" + "
" + "DCF Intrinsic Value" + "" + iv_str + "" + "" + horizon_sub + "" + "
" + "vs" + "
" + "Market Price" + "" + market_str + "" + "" + pill_text + "" + "
" + "
" + "
" + "Reading · DCF implies " + gap_str + " " + gap_dir + " the current market." + "" + reading + "" + "
" + "
" + + # Projection + "
" + "
" + "

Enterprise value build — present value of FCFs + terminal

" + "" + wacc_units + "" + "
" + "
" + "" + "" + hdr_cells + "" + "" + "" + fcf_cells + "" + "" + df_cells + "" + "" + pv_cells + "" + "" + "
Forecast FCF
Discount factor
Present value
" + "
" + + # Bridge + "
" + "
" + "

From enterprise to equity

" + "" + bdate_str + "" + "
" + "
" + "
Enterprise value" + ev_b + "
" + "
Net debt
" + "
Net debt" + net_debt_b + "
" + "
Other claims
" + "
Other claims" + other_claims_b + "
" + "
=
" + "
Equity value" + equity_b + "
" + "
" + "
" + "Total debt " + total_debt_b + "" + "·" + "Cash & equiv. " + cash_b + "" + "·" + "Preferred + minority " + other_b_val_str + "" + "
" + "
" + + # Per-share recon + "
" + "
" + "Intrinsic · Per Share" + "" + iv_str + "" + "Equity value ÷ shares" + "
" + "
" + "Market · Last" + "" + market_str + "" + " " + "
" + "
" + "Gap" + "" + gap_display + "" + "" + gap_pct_str + "" + "
" + "
" + "Shares Outstanding" + "" + "{:.2f}".format(shares_b) + " B" + "diluted" + "
" + "
" + + # Cross-check + "
" + "
" + "

Cross-check against the multiples

" + "Same business, different lenses · implied per-share" + "
" + "
" + + cx_dcf + cx_ev + cx_rev + cx_pb + + "
" + "
" + + # Footer + "
" + "Firm-value DCF · enterprise value bridged to equity using debt & cash from the most recent balance sheet. Negative-FCF years are excluded from the base; terminal value uses Gordon Growth Model." + "Methodology & sources ↗" + "
" + + "
" # va-canvas + "" + "" + ) + return doc def _render_dcf_model(ctx: dict): @@ -2246,121 +2567,153 @@ 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) - - 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"], - ) - - 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} + col_rail, col_canvas = st.columns([1, 2.5]) - # 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"]), + with col_rail: + st.markdown( + 'Assumptions' + '
3-stage DCF
' + '
Firm-value DCF — projects free cash flow, discounts to today, bridges to equity per share.
', + unsafe_allow_html=True, ) - ev_ebitda_price = ev_r.get("implied_price_per_share") + st.markdown('
', 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"]), + 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%", ) - 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"]), + 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", ) - 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() - - -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") - - with rail_col: - st.markdown( - 'Multiples' - '
Three relative-valuation lenses
' - '
Subject multiple × normalized TTM metric, bridged to equity per share.
', - unsafe_allow_html=True, + 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.markdown('
', unsafe_allow_html=True) + # From the filings block (static; populated after DCF run below) 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 "—" + 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( '
From the filings
' - f'
EBITDA (TTM){ebitda_str}
' - f'
Revenue (TTM){rev_str}
' - f'
Book value / share{bps_str}
' - f'
Net debt{_fmt_b(net_debt_raw)}
' - f'
Shares outstanding{ctx["shares"] / 1e9:.2f} B
', + '
Base FCF (TTM)' + base_fcf_str + '
' + '
FCF · historical' + hist_growth_str + '
' + '
' + nd_label + '' + net_debt_str + '
' + '
Shares outstanding' + shares_str + '
', unsafe_allow_html=True, ) - st.markdown('
', unsafe_allow_html=True) - - if st.button("Refresh data", key=f"mult_refresh_{ctx['ticker']}", type="primary", width="stretch"): + 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() - canvas_html = _build_multiples_canvas_html(ctx) - with canvas_col: - components.html(canvas_html, height=1620, scrolling=False) + 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"], + cash_and_equivalents=ctx["cash_and_equivalents"], + preferred_equity=ctx["preferred_equity"], + minority_interest=ctx["minority_interest"], + base_fcf_override=ctx["base_fcf"], + ) + + 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} + + # 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") + + 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") + + 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") + + 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) + + +def _render_all_multiples(ctx: dict): + """Render all three multiples methods side-by-side in a single HTML canvas. + + Three lenses (EV/EBITDA, EV/Revenue, P/Book) are shown in a math-flow + comparison grid. All computation and slider interactivity happens client-side + in JS. No Streamlit sliders or rail column — one full-width components.html() + call only. + """ + doc = _build_multiples_canvas_html(ctx) + components.html(doc, height=1900, scrolling=False) + + +def _render_multiples_model(ctx: dict): + _render_all_multiples(ctx) def _render_ev_ebitda_model(ctx: dict): @@ -2853,6 +3206,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 +3216,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 +3230,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 +3290,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 +3298,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 +3350,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 +3366,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 +3375,11 @@ def _render_comps(ticker: str): "nPeers": n_peers, }) - total_height = 920 + n_peers * 54 + total_height = 2600 + max(0, n_peers - 10) * 54 ctx_html = ( '
' - '' + sym + '' + '' + sym_h + '' '' + name + '' 'Valuation · Comps' '
' @@ -3024,7 +3393,7 @@ def _render_comps(ticker: str): '
' '
' 'Peer set' - '

' + str(n_peers) + ' names, one table — read across to see where ' + sym + ' sits

' + '

' + str(n_peers) + ' names, one table — read across to see where ' + sym_h + ' sits

' '

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.

' @@ -3215,7 +3584,7 @@ def _render_comps(ticker: str): + "" + "" ) - components.html(doc, height=total_height, scrolling=True) + components.html(doc, height=total_height, scrolling=False) @@ -4063,7 +4432,8 @@ _KH_CSS = """' ) + doc = ( - "" - "" - "" - "" - + _KR_CSS + _KH_CSS + "" + + "" + + "" + + "" + + _KR_CSS + _KH_CSS + kh_css_extra + "" + body + "" + "" ) - components.html(doc, height=total_height, scrolling=True) + components.html(doc, height=total_height, scrolling=False) + # ── Forward Estimates ──────────────────────────────────────────────────────── -- cgit v1.3-2-g0d8e