"""Valuation panel — key ratios, models, comparable companies, analyst targets, earnings history.""" import numpy as np import pandas as pd import plotly.graph_objects as go import streamlit as st import streamlit.components.v1 as components from services.data_service import ( get_company_info, get_latest_price, get_shares_outstanding, get_market_cap_computed, get_free_cash_flow_series, get_free_cash_flow_ttm, get_revenue_ttm, get_balance_sheet_bridge_items, get_analyst_price_targets, get_recommendations_summary, get_earnings_history, get_next_earnings_date, get_income_statement, get_cash_flow, ) from services.fmp_service import ( get_key_ratios, get_peers, get_ratios_for_tickers, get_historical_ratios, get_historical_key_metrics, get_analyst_estimates, ) from services.valuation_service import ( run_dcf, run_ev_ebitda, run_ev_revenue, run_price_to_book, compute_historical_growth_rate, compute_raw_historical_growth_rate, ) from utils.formatters import fmt_ratio, fmt_pct, fmt_large, fmt_currency from utils.security import escape_html, json_for_script FINANCIAL_SECTORS = {"Financial Services"} FINANCIAL_INDUSTRY_KEYWORDS = ( "bank", "insurance", "asset management", "capital markets", "financial data", "credit services", "mortgage", "reit", ) INDUSTRY_PEER_MAP = { "consumer electronics": ["AAPL", "SONY", "DELL", "HPQ", "LOGI"], "software - infrastructure": ["MSFT", "ORCL", "CRM", "NOW", "SNOW"], "semiconductors": ["NVDA", "AMD", "AVGO", "QCOM", "INTC"], "internet content & information": ["GOOGL", "META", "PINS", "SNAP", "RDDT"], "banks - diversified": ["JPM", "BAC", "WFC", "C", "GS"], "credit services": ["V", "MA", "AXP", "DFS", "COF"], "insurance - diversified": ["BRK-B", "AIG", "ALL", "TRV", "CB"], "reit - industrial": ["PLD", "PSA", "EXR", "COLD", "REXR"], } SECTOR_PEER_MAP = { "Technology": ["AAPL", "MSFT", "NVDA", "ORCL", "ADBE"], "Communication Services": ["GOOGL", "META", "NFLX", "TMUS", "DIS"], "Consumer Cyclical": ["AMZN", "TSLA", "HD", "MCD", "NKE"], "Consumer Defensive": ["WMT", "COST", "PG", "KO", "PEP"], "Financial Services": ["JPM", "BAC", "WFC", "GS", "MS"], "Healthcare": ["LLY", "UNH", "JNJ", "MRK", "PFE"], "Industrials": ["GE", "CAT", "RTX", "UPS", "UNP"], "Energy": ["XOM", "CVX", "COP", "SLB", "EOG"], "Utilities": ["NEE", "DUK", "SO", "AEP", "XEL"], "Real Estate": ["PLD", "AMT", "EQIX", "O", "SPG"], } def _is_financial_company(info: dict) -> bool: sector = str(info.get("sector") or "").strip() industry = str(info.get("industry") or "").strip().lower() if sector in FINANCIAL_SECTORS: return True return any(keyword in industry for keyword in FINANCIAL_INDUSTRY_KEYWORDS) def _suggest_peer_tickers(ticker: str, info: dict) -> list[str]: industry = str(info.get("industry") or "").strip().lower() sector = str(info.get("sector") or "").strip() candidates = [] if industry in INDUSTRY_PEER_MAP: candidates.extend(INDUSTRY_PEER_MAP[industry]) if not candidates and sector in SECTOR_PEER_MAP: candidates.extend(SECTOR_PEER_MAP[sector]) candidates = [c.upper() for c in candidates if c.upper() != ticker.upper()] seen = set() deduped = [] for c in candidates: if c not in seen: deduped.append(c) seen.add(c) return deduped[:8] def _coerce_float(value) -> float | None: try: out = float(value) except (TypeError, ValueError): return None return None if pd.isna(out) else out def _escape_markdown_currency(value: str) -> str: return value.replace("$", r"\$") def _h(value) -> str: return escape_html(value) def render_valuation(ticker: str): tabs = st.tabs([ "Key Ratios", "Historical Ratios", "Models", "Comps", "Forward Estimates", "Analyst Targets", "Earnings History", ]) tab_ratios, tab_hist, tab_models, tab_comps, tab_fwd, tab_analyst, tab_earnings = tabs with tab_ratios: _render_ratios(ticker) with tab_hist: try: _render_historical_ratios(ticker) except Exception as e: st.error(f"Historical ratios unavailable: {e}") with tab_models: _render_models(ticker) with tab_comps: _render_comps(ticker) with tab_fwd: try: _render_forward_estimates(ticker) except Exception as e: st.error(f"Forward estimates unavailable: {e}") with tab_analyst: try: _render_analyst_targets(ticker) except Exception as e: st.error(f"Analyst targets unavailable: {e}") with tab_earnings: try: _render_earnings_history(ticker) except Exception as e: st.error(f"Earnings history unavailable: {e}") # ── Key Ratios ─────────────────────────────────────────────────────────────── # CSS injected once per render for the Key Ratios design. _KR_CSS = """""" def _svg_spark(data: list, w: int = 96, h: int = 26, color: str = "var(--brass-bright)") -> str: clean = [float(x) for x in data if x is not None and x == x] if len(clean) < 2: return "" min_v, max_v = min(clean), max(clean) span = (max_v - min_v) or 1 dx = w / (len(clean) - 1) pts = [(i * dx, h - ((v - min_v) / span) * (h - 4) - 2) for i, v in enumerate(clean)] d = " ".join(f"{'M' if i == 0 else 'L'}{x:.2f} {y:.2f}" for i, (x, y) in enumerate(pts)) lx, ly = pts[-1] return ( f'' f'' f'' f'' ) def _peer_bar_html(value, p25, p50, p75, min_v, max_v) -> str: def pct(v): if min_v is None or max_v is None or max_v <= min_v: return 50.0 return max(0.0, min(100.0, (v - min_v) / (max_v - min_v) * 100)) vp = pct(value) if value is not None else 50 p25p, p75p, p50p = pct(p25), pct(p75), pct(p50) return ( f'
' f'
' f'
' f'
' f'
' f'
' ) def _fmtv(v, kind: str) -> str: if v is None: return "—" try: fv = float(v) if fv != fv: return "—" except (TypeError, ValueError): return "—" if kind == "%": return f"{fv * 100:.1f}%" if kind == "x": return f"{fv:.1f}×" if kind == "$B": return f"${fv / 1e9:.1f}B" if kind == "pp": return f"{fv * 100:+.1f}pp" return f"{fv:.2f}" def _tone(delta_pct: float, invert: bool = False) -> str: if abs(delta_pct) < 2: return "flat" better = delta_pct < 0 if invert else delta_pct > 0 return "pos" if better else "neg" def _compute_peer_bands(peer_ratio_rows: list[dict]) -> dict: fields = [ "peRatioTTM", "forwardPE", "enterpriseValueMultipleTTM", "evToSalesTTM", "priceToBookRatioTTM", "priceToSalesRatioTTM", "grossProfitMarginTTM", "operatingProfitMarginTTM", "netProfitMarginTTM", "returnOnEquityTTM", "returnOnAssetsTTM", "returnOnInvestedCapitalTTM", "currentRatioTTM", "quickRatioTTM", "debtToEquityRatioTTM", "interestCoverageRatioTTM", "dividendYieldTTM", "dividendPayoutRatioTTM", "revenueGrowthTTM", "earningsGrowthTTM", ] result = {} for field in fields: vals = [] for row in peer_ratio_rows: v = row.get(field) if v is not None: try: fv = float(v) if np.isfinite(fv) and fv > 0: vals.append(fv) except (TypeError, ValueError): pass if len(vals) >= 2: arr = np.array(vals) result[field] = { "p25": float(np.percentile(arr, 25)), "p50": float(np.percentile(arr, 50)), "p75": float(np.percentile(arr, 75)), "min": float(arr.min()), "max": float(arr.max()), "n": len(vals), } return result def _compute_growth_ratios(ticker: str) -> dict: result: dict = {} try: inc = get_income_statement(ticker) cf = get_cash_flow(ticker) if inc is not None and not inc.empty and len(inc.columns) >= 2: def _inc(label): if label in inc.index: v = inc.loc[label].dropna() return v return None rev = _inc("Total Revenue") if rev is not None and len(rev) >= 2: r0, r1 = float(rev.iloc[0]), float(rev.iloc[1]) if r1 > 0: result["revYoY"] = (r0 - r1) / r1 if len(rev) >= 4: r3 = float(rev.iloc[3]) if r3 > 0 and r0 > 0: result["rev3yrCAGR"] = (r0 / r3) ** (1 / 3) - 1 op_inc = _inc("Operating Income") if op_inc is not None and len(op_inc) >= 2: o0, o1 = float(op_inc.iloc[0]), float(op_inc.iloc[1]) if abs(o1) > 0: result["opIncYoY"] = (o0 - o1) / abs(o1) for lbl in ("Diluted Average Shares", "Diluted Common Shares Outstanding"): shares = _inc(lbl) if shares is not None and len(shares) >= 2: s0, s1 = float(shares.iloc[0]), float(shares.iloc[1]) if s1 > 0: result["sharesYoY"] = (s0 - s1) / s1 break for lbl in ("Diluted EPS", "Basic EPS"): eps = _inc(lbl) if eps is not None and len(eps) >= 2: e0, e1 = float(eps.iloc[0]), float(eps.iloc[1]) if abs(e1) > 0 and e1 > 0: result["epsYoY"] = (e0 - e1) / e1 break if cf is not None and not cf.empty: fcf_s = None if "Free Cash Flow" in cf.index: fcf_s = cf.loc["Free Cash Flow"].dropna() else: try: op = cf.loc["Operating Cash Flow"] capex = cf.loc["Capital Expenditure"] fcf_s = (op + capex).dropna() except KeyError: pass if fcf_s is not None and len(fcf_s) >= 2: f0, f1 = float(fcf_s.iloc[0]), float(fcf_s.iloc[1]) if f1 > 0: result["fcfYoY"] = (f0 - f1) / f1 mkt = get_market_cap_computed(ticker) for lbl in ("Repurchase Of Capital Stock", "Common Stock Repurchased"): if lbl in cf.index: val = cf.loc[lbl].iloc[0] if val is not None and pd.notna(val): buybacks = abs(float(val)) if mkt and mkt > 0 and buybacks > 0: result["buybackYield"] = buybacks / mkt break except Exception: pass return result def _build_hist_sparks(hist_rows: list[dict]) -> dict: rows = list(reversed(hist_rows)) def _ex(field): return [r[field] for r in rows if field in r and r[field] is not None] return { "pe": _ex("peRatio"), "pb": _ex("priceToBookRatio"), "ps": _ex("priceToSalesRatio"), "evEbt": _ex("enterpriseValueMultiple"), "gross": _ex("grossProfitMargin"), "op": _ex("operatingProfitMargin"), "net": _ex("netProfitMargin"), "roe": _ex("returnOnEquity"), "roa": _ex("returnOnAssets"), "de": _ex("debtEquityRatio"), } def _render_ratios(ticker: str): info = get_company_info(ticker) ratios = get_key_ratios(ticker) if not ratios and not info: st.info("Ratio data unavailable.") return 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 peers_raw = get_peers(ticker) if not peers_raw: peers_raw = _suggest_peer_tickers(ticker, info or {}) peers = [p for p in peers_raw[:8] if p.upper() != ticker.upper()] peer_ratio_list = get_ratios_for_tickers(peers) if peers else [] peer_bands = _compute_peer_bands(peer_ratio_list) growth = _compute_growth_ratios(ticker) sparks = _build_hist_sparks(hist_rows) # Computed values def _r(key): return ratios.get(key) if ratios else None 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 p_fcf = (market_cap / fcf_ttm) if fcf_ttm and fcf_ttm > 0 and market_cap else None gross_m = _r("grossProfitMarginTTM") op_m = _r("operatingProfitMarginTTM") net_m = _r("netProfitMarginTTM") roe = _r("returnOnEquityTTM") roa = _r("returnOnAssetsTTM") roic = _r("returnOnInvestedCapitalTTM") cur_r = _r("currentRatioTTM") quick_r = _r("quickRatioTTM") d_e = _r("debtToEquityRatioTTM") coverage = _r("interestCoverageRatioTTM") 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") total_debt = bridge.get("total_debt") or 0 if ebitda and ebitda > 0 and cash_raw is not None and total_debt is not None: net_debt_ebt = (total_debt - cash_raw) / ebitda if cash_raw and market_cap and market_cap > 0: cash_mkt = cash_raw / market_cap except Exception: 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 = ("▲" 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" _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 "—") co_name = _h((info.get("longName", ticker) if info else ticker) or ticker) sector = _h((info.get("sector", "—") if info else "—") or "—") industry = _h((info.get("industry", "—") if info else "—") or "—") n_peers = len(peers) from datetime import date as _date today_str = _date.today().strftime("%b %d, %Y") # ── Helper: render a row in the mini detail cards ────────────────────── def _mini_row(lbl, v, kind, sector_v, spark_data, invert=False, good_low=False): fv = _fmtv(v, kind) sv = _fmtv(sector_v, kind) if v is not None and sector_v is not None: try: fv_f, sv_f = float(v), float(sector_v) if kind == "%": 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" else: diff = (fv_f - sv_f) / abs(sv_f) * 100 tone = _tone(diff, invert or good_low) mini_cls = '' + f"{diff:+.0f}%" sector_html = '' + sv + mini_cls + '' except Exception: sector_html = '' + sv + '' else: 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 ( '
' + '' + lbl + '' + '' + fv + '' + sector_html + '' + spark_svg + '' + '
' ) # ── Helper: build peer band section ──────────────────────────────────── def _val_row(lbl, v, kind, field, five_avg, spark_data, invert=True): fv = _fmtv(v, kind) band = peer_bands.get(field, {}) p25 = band.get("p25") p50 = band.get("p50") p75 = band.get("p75") bmin = band.get("min") bmax = band.get("max") if v is not None and p50 is not None: try: diff = (float(v) - p50) / abs(p50) * 100 tone = _tone(diff, invert) 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)) or 1) * 100 avg_tone = _tone(d_avg, invert) avg_html = ( '' + _fmtv(five_avg, kind) + '' + f"{d_avg:+.0f}%" + '' + '' ) except Exception: avg_html = '' + _fmtv(five_avg, kind) + '' else: 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 = ( '
' + '' + _fmtv(p25, kind) + '' + '' + _fmtv(p50, kind) + '' + '' + _fmtv(p75, kind) + '' + '
' ) return ( '
' + '' + lbl + '' + '' + fv + '' + '' + d_str + '' + '
' + peer_bar + peer_axis + '
' + avg_html + spark_svg + '
' ) # ── Snapshot KPIs ─────────────────────────────────────────────────────── def _kpi_spark(lbl, v, kind, field, spark_data, 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 = ("+" if diff >= 0 else "") + f"{diff:.0f}% vs peers" except Exception: tone, d_str = "flat", "—" else: tone, d_str = "flat", "—" 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 ( '
' + '
' + lbl + '' + spark_svg + '
' + '' + fv + '' + '
' + 'peers ' + sect_str + '' + '' + d_str + '' + '
' + '
' ) # ── 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] return float(np.mean(vals)) if vals else None pe_5avg = _hist_avg("peRatio") pb_5avg = _hist_avg("priceToBookRatio") ps_5avg = _hist_avg("priceToSalesRatio") evEbt_5avg = _hist_avg("enterpriseValueMultiple") gross_5avg = _hist_avg("grossProfitMargin") op_5avg = _hist_avg("operatingProfitMargin") net_5avg = _hist_avg("netProfitMargin") roe_5avg = _hist_avg("returnOnEquity") roa_5avg = _hist_avg("returnOnAssets") de_5avg = _hist_avg("debtEquityRatio") # Peer medians for detail rows def _pm(field): return peer_bands.get(field, {}).get("p50") # ── 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) + _val_row("P / Sales", ps, "x", "priceToSalesRatioTTM", ps_5avg, sparks.get("ps"), invert=True) + _val_row("P / FCF", p_fcf, "x", "peRatioTTM", None, None, invert=True) ) 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("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
' + _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) ) 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) ) 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", 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 = ( '
' + '
' + '' + _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.
' + '
' + '
' ) # ── Assemble full HTML document (string concat, no f-strings) ────────── doc = ( '' + '' + '' + '' + _KR_CSS + '' + body + '' ) components.html(doc, height=2600, scrolling=False) # ── Models ─────────────────────────────────────────────────────────────────── def _net_debt_label(value: float) -> str: return "Net Cash" if value < 0 else "Net Debt" def _build_model_context(ticker: str) -> dict: info = get_company_info(ticker) ratios_data = get_key_ratios(ticker) shares = get_shares_outstanding(ticker) current_price = get_latest_price(ticker) market_cap = get_market_cap_computed(ticker) bridge_items = get_balance_sheet_bridge_items(ticker) total_debt = float(bridge_items["total_debt"]) cash_and_equivalents = float(bridge_items["cash_and_equivalents"]) preferred_equity = float(bridge_items["preferred_equity"]) minority_interest = float(bridge_items["minority_interest"]) fcf_series_raw = get_free_cash_flow_series(ticker) if fcf_series_raw is None or fcf_series_raw.empty: fcf_series = pd.Series(dtype=float) else: try: fcf_series = fcf_series_raw.sort_index().dropna().astype(float) except Exception: fcf_series = pd.Series(dtype=float) base_fcf = _coerce_float(get_free_cash_flow_ttm(ticker)) hist_growth = compute_historical_growth_rate(fcf_series) if len(fcf_series) >= 2 else None hist_growth_raw = compute_raw_historical_growth_rate(fcf_series) if len(fcf_series) >= 2 else None ebitda = _coerce_float(ratios_data.get("ebitdaTTM")) revenue_ttm = _coerce_float(get_revenue_ttm(ticker)) if revenue_ttm is None or revenue_ttm <= 0: revenue_ttm = _coerce_float(info.get("totalRevenue")) if revenue_ttm is None or revenue_ttm <= 0: ps_ratio = _coerce_float(ratios_data.get("priceToSalesRatioTTM")) if market_cap and market_cap > 0 and ps_ratio and ps_ratio > 0: revenue_ttm = float(market_cap) / float(ps_ratio) book_value_per_share = _coerce_float(info.get("bookValue")) is_financial = _is_financial_company(info) dcf_reason = None if is_financial: dcf_reason = "Not suitable for financial companies." elif not shares or shares <= 0: dcf_reason = "Shares outstanding unavailable." elif fcf_series.empty: dcf_reason = "Free cash flow history unavailable." elif len(fcf_series) < 2: dcf_reason = "Need at least two FCF periods." elif base_fcf is None or base_fcf <= 0: dcf_reason = "Base free cash flow is zero or negative." ev_reason = None if not shares or shares <= 0: ev_reason = "Shares outstanding unavailable." elif ebitda is None: ev_reason = "EBITDA unavailable." elif ebitda <= 0: ev_reason = "EBITDA is zero or negative." ev_revenue_reason = None if is_financial: ev_revenue_reason = "Not preferred for financial companies." elif not shares or shares <= 0: ev_revenue_reason = "Shares outstanding unavailable." elif revenue_ttm is None: ev_revenue_reason = "Revenue unavailable." elif revenue_ttm <= 0: ev_revenue_reason = "Revenue is zero or negative." pb_reason = None if book_value_per_share is None: pb_reason = "Book value per share unavailable." elif book_value_per_share <= 0: pb_reason = "Book value per share is zero or negative." dcf_available = dcf_reason is None ev_available = ev_reason is None ev_revenue_available = ev_revenue_reason is None pb_available = pb_reason is None ev_value = None ev_ebitda_current = None ev_revenue_current = None other_claims = preferred_equity + minority_interest if market_cap and market_cap > 0 and ebitda and ebitda > 0: ev_value = float(market_cap) + total_debt - cash_and_equivalents + other_claims if ev_value > 0: ev_ebitda_current = ev_value / ebitda elif market_cap and market_cap > 0: ev_value = float(market_cap) + total_debt - cash_and_equivalents + other_claims if ev_value and ev_value > 0 and revenue_ttm and revenue_ttm > 0: ev_revenue_current = ev_value / revenue_ttm pb_current = None if current_price and current_price > 0 and book_value_per_share and book_value_per_share > 0: pb_current = current_price / book_value_per_share if is_financial and pb_available: summary = "P/B is the primary method here because this looks like a financial company." elif dcf_available: summary = "DCF is the primary method because the business has usable free cash flow history and positive base FCF." elif ev_available: summary = "EV/EBITDA is the best fit because EBITDA is positive while DCF is not suitable." elif ev_revenue_available: summary = "EV/Revenue is the best fit because the company has revenue but cash-flow-based models are not suitable." elif pb_available: summary = "P/B is the fallback because book value is positive while cash-flow-based models are not suitable." else: summary = "No valuation model is currently robust enough to show. Use ratios, comps, earnings history, and analyst targets instead." return { "ticker": ticker.upper(), "info": info, "ratios_data": ratios_data, "shares": shares, "current_price": current_price, "market_cap": market_cap, "bridge_items": bridge_items, "total_debt": total_debt, "cash_and_equivalents": cash_and_equivalents, "preferred_equity": preferred_equity, "minority_interest": minority_interest, "fcf_series": fcf_series, "base_fcf": base_fcf, "hist_growth": hist_growth, "hist_growth_raw": hist_growth_raw, "ebitda": ebitda, "revenue_ttm": revenue_ttm, "book_value_per_share": book_value_per_share, "is_financial": is_financial, "dcf_available": dcf_available, "dcf_reason": dcf_reason or "Usable free cash flow history and positive base FCF.", "ev_available": ev_available, "ev_reason": ev_reason or "Positive EBITDA and shares outstanding are available.", "ev_revenue_available": ev_revenue_available, "ev_revenue_reason": ev_revenue_reason or "Positive revenue and shares outstanding are available.", "pb_available": pb_available, "pb_reason": pb_reason or "Positive book value per share is available.", "ev_ebitda_current": ev_ebitda_current, "ev_revenue_current": ev_revenue_current, "pb_current": pb_current, "summary": summary, } def _render_model_availability(ctx: dict): dcf_ok = ctx["dcf_available"] ev_ok = ctx["ev_available"] rev_ok = ctx["ev_revenue_available"] pb_ok = ctx["pb_available"] pb_limited = pb_ok and not ctx["is_financial"] pb_color = "#C49545" if pb_limited else ("#4F8C5E" if pb_ok else "#5E5849") pb_glyph = "◐" if pb_limited else "●" dcf_c = "#4F8C5E" if dcf_ok else "#5E5849" ev_c = "#4F8C5E" if ev_ok else "#5E5849" rev_c = "#4F8C5E" if rev_ok else "#5E5849" st.markdown( f'
' f'Applicable' f' DCF' f' EV/EBITDA' f' EV/Revenue' f'{pb_glyph} P/Book' f'
', unsafe_allow_html=True, ) _DCF_CANVAS_CSS = """ *,*::before,*::after{box-sizing:border-box} :root{ --ink-0:#0B0E13;--ink-1:#11151C;--ink-2:#181D26;--ink-3:#222934; --line-1:#232934;--line-2:#2E3645; --fg-1:#F2ECDC;--fg-2:#C7C0AE;--fg-3:#8E8676;--fg-4:#5E5849; --brass:#C2AA7A;--brass-bright:#DCC79E;--brass-deep:#8F7A50; --oxford:#1F3B5E;--oxford-light:#243E5A; --positive:#4F8C5E;--positive-bg:#15241A; --negative:#B5494B;--negative-bg:#2A1517; --warning:#C49545;--warning-bg:#2A1F0A; --font-display:'EB Garamond',Georgia,serif; --font-sans:'IBM Plex Sans',system-ui,sans-serif; --font-mono:'IBM Plex Mono',monospace; } body{margin:0;padding:0;background:transparent;font-family:var(--font-sans);color:var(--fg-2);-webkit-font-smoothing:antialiased} .num{font-family:var(--font-mono);font-variant-numeric:tabular-nums} .va-canvas{display:flex;flex-direction:column;gap:24px;padding-bottom:32px} /* Verdict */ .va-verdict{background:var(--ink-1);border:1px solid var(--line-1);border-radius:6px;position:relative;overflow:hidden;box-shadow:0 8px 24px -8px rgba(0,0,0,.5)} .va-verdict .top{display:grid;grid-template-columns:1fr auto 1fr;gap:48px;align-items:center;padding:32px 48px;position:relative;z-index:1} .va-verdict .col{display:flex;flex-direction:column;gap:6px} .va-verdict .lbl{font-family:var(--font-sans);font-size:12px;text-transform:uppercase;letter-spacing:.18em;color:var(--fg-3)} .va-verdict .big{font-family:var(--font-mono);font-variant-numeric:tabular-nums;font-size:56px;font-weight:500;color:var(--fg-1);line-height:.95;letter-spacing:-.02em} .va-verdict .big.market{color:var(--fg-2)} .va-verdict .sub{font-family:var(--font-sans);font-size:13px;color:var(--fg-3)} .va-verdict .arrow{font-family:var(--font-display);font-size:32px;color:var(--fg-4);font-style:italic;font-weight:400;text-align:center} .va-verdict .pill{display:inline-flex;align-items:center;gap:6px;font-family:var(--font-mono);font-size:13px;padding:4px 10px;border-radius:2px;align-self:flex-start;margin-top:4px} .va-verdict .pill.neg{color:var(--negative);background:var(--negative-bg);border:1px solid rgba(181,73,75,.35)} .va-verdict .pill.pos{color:var(--positive);background:var(--positive-bg);border:1px solid rgba(79,140,94,.35)} .va-verdict .band{display:flex;align-items:baseline;justify-content:space-between;border-top:1px solid var(--line-1);padding:12px 48px;font-family:var(--font-sans);font-size:13px;color:var(--fg-2);position:relative;z-index:1;background:var(--ink-1)} .va-verdict .band .reading{font-family:var(--font-display);font-style:italic;font-size:20px;color:var(--fg-1)} .va-verdict .band .mono{font-family:var(--font-mono);font-variant-numeric:tabular-nums;color:var(--fg-1)} /* Projection */ .va-projection{background:var(--ink-1);border:1px solid var(--line-1);border-radius:6px;overflow:hidden} .va-projection .head{padding:16px 24px;border-bottom:1px solid var(--line-1);display:flex;justify-content:space-between;align-items:baseline} .va-projection .head h3{font-family:var(--font-display);font-size:20px;font-weight:500;color:var(--fg-1);margin:0} .va-projection .head .units{font-family:var(--font-mono);font-size:12px;color:var(--fg-3)} .va-cf-table{width:100%;border-collapse:collapse;border-top:1px solid var(--line-1)} .va-cf-table th,.va-cf-table td{padding:8px 14px;text-align:right;font-family:var(--font-mono);font-variant-numeric:tabular-nums;font-size:12px;border-bottom:1px solid var(--line-1)} .va-cf-table th{font-family:var(--font-sans);text-transform:uppercase;font-size:11px;letter-spacing:.08em;color:var(--fg-3);font-weight:600;background:var(--ink-2)} .va-cf-table th:first-child,.va-cf-table td:first-child{text-align:left;color:var(--fg-2);font-size:12px} .va-cf-table td.brass{color:var(--brass-bright)} .va-cf-table tr:last-child td{border-bottom:none} .va-cf-table tr.total td{border-top:1px solid var(--line-2);font-weight:600;color:var(--fg-1);background:var(--ink-2)} /* Bridge */ .va-bridge{background:var(--ink-1);border:1px solid var(--line-1);border-radius:6px;padding:24px;display:flex;flex-direction:column;gap:16px} .va-bridge .bhead{display:flex;justify-content:space-between;align-items:baseline} .va-bridge .bhead h3{font-family:var(--font-display);font-size:20px;font-weight:500;color:var(--fg-1);margin:0} .va-bridge .bhead .bdate{font-family:var(--font-mono);font-size:12px;color:var(--fg-3)} .va-bridge .flow{display:grid;grid-template-columns:1fr auto 1fr auto 1fr auto 1fr;align-items:stretch;gap:12px} .va-bridge .node{display:flex;flex-direction:column;gap:4px;padding:12px 16px;background:var(--ink-2);border:1px solid var(--line-2);border-radius:4px;min-height:80px;justify-content:center} .va-bridge .node.start{border-color:var(--oxford);background:rgba(74,120,181,.06)} .va-bridge .node.result{border-color:rgba(194,170,122,.4);background:rgba(194,170,122,.06)} .va-bridge .node .lbl{font-family:var(--font-sans);font-size:11px;text-transform:uppercase;letter-spacing:.18em;color:var(--fg-3)} .va-bridge .node .v{font-family:var(--font-mono);font-variant-numeric:tabular-nums;font-size:20px;color:var(--fg-1)} .va-bridge .node.result .v{color:var(--brass-bright)} .va-bridge .op{display:flex;flex-direction:column;align-items:center;justify-content:center;font-family:var(--font-mono);font-size:16px;color:var(--fg-3);min-width:20px} .va-bridge .op .sub{font-family:var(--font-sans);font-size:10px;color:var(--fg-4);text-transform:uppercase;letter-spacing:.18em;margin-top:6px} .va-bridge .bfoot{font-family:var(--font-sans);font-size:12px;color:var(--fg-3);display:flex;gap:12px;flex-wrap:wrap} /* Recon */ .va-recon{display:grid;grid-template-columns:1.4fr 1fr 1fr 1fr;background:var(--ink-1);border:1px solid var(--line-1);border-radius:6px;overflow:hidden} .va-recon .cell{padding:16px 24px;border-right:1px solid var(--line-1);display:flex;flex-direction:column;gap:4px} .va-recon .cell:last-child{border-right:none} .va-recon .cell .lbl{font-family:var(--font-sans);font-size:11px;text-transform:uppercase;letter-spacing:.18em;color:var(--fg-3);font-weight:600} .va-recon .cell .v{font-family:var(--font-mono);font-variant-numeric:tabular-nums;font-size:28px;color:var(--fg-1);font-weight:500;line-height:1} .va-recon .cell .sub{font-family:var(--font-mono);font-size:11px;color:var(--fg-3)} .va-recon .cell.intrinsic .v{color:var(--brass-bright)} /* Cross-check */ .va-cx{background:var(--ink-1);border:1px solid var(--line-1);border-radius:6px;overflow:hidden} .va-cx-head{padding:16px 24px;border-bottom:1px solid var(--line-1);display:flex;justify-content:space-between;align-items:baseline} .va-cx-head h3{font-family:var(--font-display);font-size:20px;font-weight:500;color:var(--fg-1);margin:0} .va-cx-head .hint{font-family:var(--font-mono);font-size:12px;color:var(--fg-3)} .va-cx-grid{display:grid;grid-template-columns:1.2fr 1fr 1fr 1fr} .va-cx-cell{padding:16px 24px;border-right:1px solid var(--line-1);display:flex;flex-direction:column;gap:6px} .va-cx-cell:last-child{border-right:none} .va-cx-cell .lbl{font-family:var(--font-sans);font-size:11px;text-transform:uppercase;letter-spacing:.18em;color:var(--fg-3);font-weight:600} .va-cx-cell .v{font-family:var(--font-mono);font-variant-numeric:tabular-nums;font-size:26px;color:var(--fg-1);font-weight:500;line-height:1} .va-cx-cell.dcf{background:rgba(194,170,122,.05)} .va-cx-cell.dcf .v{color:var(--brass-bright)} .va-cx-cell.dcf .lbl{color:var(--brass)} .va-cx-cell .delta{font-family:var(--font-mono);font-variant-numeric:tabular-nums;font-size:12px} .va-cx-cell .delta.neg{color:var(--negative)} .va-cx-cell .delta.pos{color:var(--positive)} .va-cx-cell .delta.na{color:var(--fg-4)} .va-cx-cell .meta{font-family:var(--font-sans);font-size:11px;color:var(--fg-3);border-top:1px solid var(--line-1);padding-top:6px;margin-top:auto;line-height:1.4} /* Footer */ .va-foot{font-family:var(--font-sans);font-size:12px;color:var(--fg-3);line-height:1.6;padding:12px 20px;border:1px solid var(--line-1);border-radius:4px;background:var(--ink-1);display:flex;justify-content:space-between;align-items:center;gap:24px} .va-foot a{color:var(--brass-bright);text-decoration:none;white-space:nowrap;flex-shrink:0} .va-foot a:hover{color:var(--brass)} """ _DCF_RAIL_CSS = """""" _MULT_CANVAS_CSS = """ .vm-body{display:flex;flex-direction:column;gap:24px;padding:24px 32px 48px} /* Summary band */ .vm-summary{background:var(--ink-1);border:1px solid var(--line-1);border-radius:6px;overflow:hidden;display:grid;grid-template-columns:1.4fr 2fr} .vm-summary-head{padding:24px;display:flex;flex-direction:column;gap:8px;border-right:1px solid var(--line-1)} .vm-summary-head .eyebrow{font-family:var(--font-sans);font-size:12px;text-transform:uppercase;letter-spacing:.18em;color:var(--fg-3);font-weight:600} .vm-summary-head .ttl{font-family:var(--font-display);font-size:22px;font-weight:500;color:var(--fg-1);margin:0;line-height:1.2} .vm-summary-head .lede{font-family:var(--font-sans);font-size:13px;color:var(--fg-2);line-height:1.5;margin:0} .vm-summary-strip{background:var(--ink-2);display:grid;grid-template-columns:repeat(4,1fr)} .vm-sum-cell{padding:16px;display:flex;flex-direction:column;gap:4px;border-right:1px solid var(--line-1)} .vm-sum-cell:last-child{border-right:none} .vm-sum-cell.market{background:rgba(74,120,181,.05);border-left:1px solid var(--line-2)} .vm-sum-cell .lbl{font-family:var(--font-sans);font-size:11px;text-transform:uppercase;letter-spacing:.12em;color:var(--fg-3)} .vm-sum-cell .v{font-family:var(--font-mono);font-size:20px;color:var(--fg-1);font-variant-numeric:tabular-nums} .vm-sum-cell.market .v{color:var(--fg-2)} .vm-sum-cell .d{font-family:var(--font-mono);font-size:11px} .d.pos{color:var(--positive)}.d.neg{color:var(--negative)}.d.na{color:var(--fg-4)} /* Comparison card */ .vm-compare{background:var(--ink-1);border:1px solid var(--line-1);border-radius:6px;overflow:hidden} .vm-compare-head{padding:16px 24px;border-bottom:1px solid var(--line-1);display:flex;align-items:baseline;gap:12px} .vm-compare-head h3{font-family:var(--font-display);font-size:20px;font-weight:500;color:var(--fg-1);margin:0} .vm-compare-head .units{font-family:var(--font-sans);font-size:11px;color:var(--fg-3)} .vm-grid{display:grid;grid-template-columns:220px 1fr 1fr 1fr;border-bottom:1px solid var(--line-1)} .vm-grid:last-child{border-bottom:none} .vm-row-lbl{padding:12px 16px;background:var(--ink-2);border-right:1px solid var(--line-1);font-family:var(--font-sans);font-size:11px;text-transform:uppercase;letter-spacing:.12em;color:var(--fg-3);display:flex;flex-direction:column;gap:4px;align-items:flex-start;justify-content:center} .vm-row-lbl .sub{font-family:var(--font-sans);font-size:10px;color:var(--fg-4);text-transform:none;letter-spacing:0} .vm-row-lbl.strong{color:var(--brass);background:rgba(194,170,122,.06)} .vm-cell{padding:12px 16px;display:flex;flex-direction:column;gap:4px;justify-content:center} .vm-cell .v{font-family:var(--font-mono);font-size:20px;color:var(--fg-1);font-variant-numeric:tabular-nums} .vm-cell .v.dash{color:var(--fg-4)} .vm-cell .cap{font-family:var(--font-sans);font-size:11px;color:var(--fg-3)} .vm-cell.faded{background:rgba(255,255,255,.005)} .vm-cell.faded .v{color:var(--fg-4)} .vm-col-head{padding:16px} .vm-col-title{display:flex;align-items:center;gap:8px;margin-bottom:8px} .vm-col-title .n{font-family:var(--font-display);font-style:italic;font-size:16px;color:var(--brass)} .vm-col-title h4{font-family:var(--font-sans);font-size:14px;font-weight:600;color:var(--fg-1);margin:0} .vm-col-title .fit{font-family:var(--font-sans);font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.1em;padding:2px 6px;border-radius:2px} .vm-col-title .fit.ok{color:var(--positive);background:var(--positive-bg)} .vm-col-title .fit.warn{color:var(--warning);background:var(--warning-bg)} .vm-col-head .lede{font-family:var(--font-sans);font-size:12px;color:var(--fg-3);line-height:1.5;margin:0} .vm-grid.result .vm-row-lbl{color:var(--brass);background:rgba(194,170,122,.06)} .vm-grid.result .vm-cell{background:rgba(194,170,122,.04)} .vm-grid.result .vm-cell .v{font-size:28px;color:var(--brass-bright)} .vm-grid.result .vm-cell .delta{font-family:var(--font-mono);font-size:12px} .delta.pos{color:var(--positive)}.delta.neg{color:var(--negative)}.delta.na{color:var(--fg-4)} /* Subject multiple slider */ .vm-cell.mult{gap:6px} .mult-top{display:flex;align-items:baseline;gap:8px} .mult-top .big{font-family:var(--font-mono);font-size:24px;color:var(--brass-bright);font-variant-numeric:tabular-nums} .mult-top .sector{font-family:var(--font-mono);font-size:11px;color:var(--fg-3)} .mult-slider{position:relative;height:18px;margin:2px 0} .mult-slider .track{position:absolute;inset:7px 0;background:var(--ink-3);border-radius:999px;pointer-events:none} .mult-slider .track .band{position:absolute;inset:0;background:rgba(74,120,181,.18)} .mult-slider .track .marker{position:absolute;top:-3px;bottom:-3px;width:2px;background:var(--oxford-light);border-radius:1px} .mult-slider input[type=range]{position:absolute;inset:0;width:100%;height:18px;background:transparent;-webkit-appearance:none;appearance:none;cursor:pointer;outline:none} .mult-slider 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} .mult-slider input[type=range]::-moz-range-thumb{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;border:none} .mult-slider input[type=range]::-webkit-slider-runnable-track{background:transparent} .mult-slider input[type=range]::-moz-range-track{background:transparent;height:4px} .mult-meta{display:flex;justify-content:space-between;font-family:var(--font-mono);font-size:10px;color:var(--fg-4)} /* Sensitivity strip */ .vm-sensitivity{background:var(--ink-1);border:1px solid var(--line-1);border-radius:6px;overflow:hidden} .vm-sensitivity-head{padding:16px 24px;border-bottom:1px solid var(--line-1);display:flex;align-items:baseline;gap:12px} .vm-sensitivity-head h3{font-family:var(--font-display);font-size:20px;font-weight:500;color:var(--fg-1);margin:0} .vm-sensitivity-head .hint{font-family:var(--font-sans);font-size:11px;color:var(--fg-3)} .vm-sens-grid{display:grid;grid-template-columns:repeat(3,1fr)} .vm-sens-cell{padding:16px;border-right:1px solid var(--line-1)} .vm-sens-cell:last-child{border-right:none} .vm-sens-cell>.lbl{font-family:var(--font-sans);font-size:11px;text-transform:uppercase;letter-spacing:.12em;color:var(--fg-3);display:block;margin-bottom:10px} .vm-sens-row{display:grid;grid-template-columns:1fr auto 1fr;gap:8px;align-items:center;margin-bottom:10px;padding-bottom:10px;border-bottom:1px solid var(--line-1)} .vm-sens-row .col{display:flex;flex-direction:column;gap:2px} .vm-sens-row .col .sub{font-family:var(--font-mono);font-size:10px;color:var(--fg-3)} .vm-sens-row .col .v{font-family:var(--font-mono);font-size:18px;color:var(--fg-1);font-variant-numeric:tabular-nums} .vm-sens-row .col .v.brass{color:var(--brass-bright)} .vm-sens-row .col .d{font-family:var(--font-mono);font-size:11px} .vm-sens-row .arrow{font-family:var(--font-display);font-style:italic;font-size:20px;color:var(--fg-4);text-align:center} .vm-sens-cell>.meta{font-family:var(--font-sans);font-size:11px;color:var(--fg-3)} .vm-sens-cell>.meta .num{font-family:var(--font-mono);color:var(--fg-2)} /* Cross-check vs DCF */ .vm-cx{background:var(--ink-1);border:1px solid var(--line-1);border-radius:6px;overflow:hidden} .vm-cx-head{padding:16px 24px;border-bottom:1px solid var(--line-1);display:flex;align-items:baseline;gap:12px} .vm-cx-head h3{font-family:var(--font-display);font-size:20px;font-weight:500;color:var(--fg-1);margin:0} .vm-cx-head .hint{font-family:var(--font-sans);font-size:11px;color:var(--fg-3)} .vm-cx-grid{display:grid;grid-template-columns:1.2fr 1fr 1fr 1fr} .vm-cx-cell{padding:16px 24px;border-right:1px solid var(--line-1);display:flex;flex-direction:column;gap:4px} .vm-cx-cell:last-child{border-right:none} .vm-cx-cell .lbl{font-family:var(--font-sans);font-size:11px;text-transform:uppercase;letter-spacing:.12em;color:var(--fg-3)} .vm-cx-cell .v{font-family:var(--font-mono);font-size:24px;color:var(--fg-1);font-variant-numeric:tabular-nums} .vm-cx-cell .delta{font-family:var(--font-mono);font-size:12px} .vm-cx-cell .meta{font-family:var(--font-sans);font-size:11px;color:var(--fg-4);margin-top:4px} .vm-cx-cell.dcf{background:rgba(194,170,122,.05)} .vm-cx-cell.dcf .lbl{color:var(--brass-deep)} .vm-cx-cell.dcf .v{color:var(--brass-bright)} """ def _fmt_b(v_dollars: float) -> str: b = v_dollars / 1e9 if abs(b) >= 1000: return f"${b / 1000:.2f}T" 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}", "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"Yr {i + 1}" for i in range(n)) + "Terminal" fcf_cells = "".join(f"{_fmt_b(v)}" for v in projected) fcf_cells += f'{_fmt_b(terminal_fcf)}' df_cells = "".join(f"{disc_factors[i]:.3f}" for i in range(n)) df_cells += f"{disc_tv_factor:.3f}" pv_cells = "".join(f"{_fmt_b(v)}" for v in discounted) pv_cells += f'{_fmt_b(tv_pv)}' # 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'{dsign}{delta_pct:.1f}% vs market' else: dhtml = '' return ( f'
' f'{lbl}' f'{val_str}' f"{dhtml}" f'{meta}' f"
" ) 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'{dcf_dsign}{dcf_delta:.1f}% vs market' else: dcf_dhtml = '' cx_dcf = ( f'
' f'DCF · THIS MODEL' f'{iv_str}' f"{dcf_dhtml}" f'Firm-value DCF · {yrs}-yr explicit · WACC {wacc_pct:.1f}%' f"
" ) 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"""
DCF Intrinsic Value {iv_str} per share · firm value method · {yrs}-yr horizon
vs
Market Price {market_str} {pill_text}
Reading · DCF implies {gap_str} {gap_dir} the current market. {reading}

Enterprise value build — present value of FCFs + terminal

USD · billions · discounted at WACC {wacc_pct:.1f}%
{hdr_cells}{fcf_cells}{df_cells}{pv_cells}
Forecast FCF
Discount factor
Present value

From enterprise to equity

Balance-sheet bridge{(' · ' + source_date) if source_date else ''}
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 {_fmt_b(other_b_val)}
Intrinsic · Per Share {iv_str} Equity value ÷ shares
Market · Last {market_str}  
Gap {gap_display} {gap_pct_str}
Shares Outstanding {shares_b:.2f} B diluted

Cross-check against the multiples

Same business, different lenses · implied per-share
{cx_dcf} {cx_ev} {cx_rev} {cx_pb}
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 ↗
""" return html def _build_multiples_canvas_html(ctx: dict) -> str: market = float(ctx["current_price"] or 0) shares = float(ctx["shares"] or 0) total_debt = float(ctx["total_debt"] or 0) cash = float(ctx["cash_and_equivalents"] or 0) net_debt = total_debt - cash ebitda = float(ctx["ebitda"]) if ctx.get("ebitda") and ctx["ebitda"] > 0 else 0.0 revenue = float(ctx["revenue_ttm"]) if ctx.get("revenue_ttm") and ctx["revenue_ttm"] > 0 else 0.0 book_ps = float(ctx["book_value_per_share"]) if ctx.get("book_value_per_share") and ctx["book_value_per_share"] > 0 else 0.0 eb_ok = ebitda > 0 and shares > 0 rv_ok = revenue > 0 and shares > 0 pb_ok = book_ps > 0 has_market = market > 0 def _clamp(v, lo, hi): try: return max(lo, min(hi, float(v))) except (TypeError, ValueError): return lo eb_init = _clamp(ctx.get("ev_ebitda_current") or 15.0, 8.0, 32.0) rv_init = _clamp(ctx.get("ev_revenue_current") or 5.0, 4.0, 20.0) pb_init = _clamp(ctx.get("pb_current") or 5.0, 4.0, 60.0) # Sector medians — try peers, fall back to defaults eb_sector, rv_sector, pb_sector = 12.0, 3.0, 4.0 try: info = ctx.get("info") or {} peers = get_peers(ctx["ticker"]) or _suggest_peer_tickers(ctx["ticker"], info) if peers: pr = get_ratios_for_tickers(peers[:6]) if pr: import statistics as _stats eb_vs = [float(r["enterpriseValueMultipleTTM"]) for r in pr if r and r.get("enterpriseValueMultipleTTM") and 2 < r["enterpriseValueMultipleTTM"] < 100] rv_vs = [float(r["evToSalesTTM"]) for r in pr if r and r.get("evToSalesTTM") and 0.1 < r["evToSalesTTM"] < 50] pb_vs = [float(r["priceToBookRatioTTM"]) for r in pr if r and r.get("priceToBookRatioTTM") and 0.5 < r["priceToBookRatioTTM"] < 200] if eb_vs: eb_sector = _stats.median(eb_vs) if rv_vs: rv_sector = _stats.median(rv_vs) if pb_vs: pb_sector = _stats.median(pb_vs) except Exception: pass eb_sector = _clamp(eb_sector, 8.0, 32.0) rv_sector = _clamp(rv_sector, 4.0, 20.0) pb_sector = _clamp(pb_sector, 4.0, 60.0) dcf_iv = st.session_state.get(f"dcf_intrinsic_{ctx['ticker']}") dcf_wacc = st.session_state.get(f"dcf_wacc_{ctx['ticker']}", 10.0) dcf_tg = st.session_state.get(f"dcf_tg_{ctx['ticker']}", 2.5) dcf_yrs = st.session_state.get(f"dcf_yrs_{ctx['ticker']}", 5) def _fb(v): if v is None or not (isinstance(v, (int, float)) and v == v): return "—" b = v / 1e9 if abs(b) >= 1000: return f"${b / 1000:.2f}T" return f"${b:.2f}B" def _fs(v): if v is None or not (isinstance(v, (int, float)) and v == v): return "—" return f"${v:.2f}" def _fx(v): return f"{v:.1f}×" def _dpct(v): if not has_market or v is None: return None return (v - market) / market * 100 def _d_span(val, id_attr=""): d = _dpct(val) if d is None: return f'' cls = "pos" if d >= 0 else "neg" arr = "▲" if d >= 0 else "▼" sign = "+" if d >= 0 else "" market_str = _fs(market) return f'{arr} {sign}{d:.1f}% vs {market_str}' def _ds_span(val, id_attr=""): d = _dpct(val) if d is None: return f'' cls = "pos" if d >= 0 else "neg" arr = "▲" if d >= 0 else "▼" sign = "+" if d >= 0 else "" return f'{arr} {sign}{d:.1f}%' # Initial computed values if eb_ok: eb_ev0 = eb_init * ebitda eb_eq0 = eb_ev0 - net_debt eb_per0 = eb_eq0 / shares else: eb_ev0 = eb_eq0 = eb_per0 = None if rv_ok: rv_ev0 = rv_init * revenue rv_eq0 = rv_ev0 - net_debt rv_per0 = rv_eq0 / shares else: rv_ev0 = rv_eq0 = rv_per0 = None pb_per0 = pb_init * book_ps if pb_ok else None # Sector reference values (static) sec_eb = (eb_sector * ebitda - net_debt) / shares if eb_ok else None sec_rv = (rv_sector * revenue - net_debt) / shares if rv_ok else None sec_pb = pb_sector * book_ps if pb_ok else None # Slider CSS % positions def _pct(v, lo, hi): return (v - lo) / (hi - lo) * 100 eb_s_pct = _pct(eb_sector, 8, 32) eb_bl_pct = _pct(14, 8, 32) eb_bh_pct = _pct(26, 8, 32) rv_s_pct = _pct(rv_sector, 4, 20) rv_bl_pct = _pct(6, 4, 20) rv_bh_pct = _pct(13, 4, 20) pb_s_pct = _pct(pb_sector, 4, 60) pb_bl_pct = _pct(8, 4, 60) pb_bh_pct = _pct(14, 4, 60) shares_str = f"{shares / 1e9:.2f} B" if shares > 0 else "—" # P/Book fit badge depends on whether company is financial pb_fit_cls = "ok" if ctx.get("is_financial") else "warn" pb_fit_lbl = "Strong fit" if ctx.get("is_financial") else "Limited fit" # Sensitivity re-rating strings (static sector side) def _rr(subj_per, sect_per): if subj_per is None or sect_per is None or subj_per == 0: return "—" rr = (sect_per - subj_per) / abs(subj_per) * 100 sign = "+" if rr >= 0 else "" cls = "pos" if rr >= 0 else "neg" return f'{sign}{rr:.1f}%' # DCF cross-check cell if dcf_iv is not None: dcf_d = _dpct(float(dcf_iv)) if dcf_d is not None: dcf_cls = "pos" if dcf_d >= 0 else "neg" dcf_arr = "▲" if dcf_d >= 0 else "▼" dcf_sign = "+" if dcf_d >= 0 else "" dcf_delta_html = f'{dcf_arr} {dcf_sign}{dcf_d:.1f}% vs market' else: dcf_delta_html = '' dcf_val_str = _fs(float(dcf_iv)) dcf_meta_str = f"WACC {dcf_wacc:.1f}% · TG {dcf_tg:.1f}% · {dcf_yrs}-yr explicit" else: dcf_delta_html = 'Run DCF tab first' dcf_val_str = "—" dcf_meta_str = "Switch to DCF tab to compute" ticker = _h(ctx["ticker"]) exchange = _h((ctx.get("info") or {}).get("exchange") or "—") data_json = json_for_script({ "market": market, "shares": shares, "netDebt": net_debt, "totalDebt": total_debt, "cash": cash, "ebitda": ebitda, "revenue": revenue, "bookPs": book_ps, "ebOk": eb_ok, "rvOk": rv_ok, "pbOk": pb_ok, "hasMarket": has_market, "ebSector": eb_sector, "rvSector": rv_sector, "pbSector": pb_sector, }) 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 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). 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 upside_pct = (iv - market) / market * 100 if has_market else 0.0 is_pos = upside_pct >= 0 gap = iv - market # 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", "") # 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 # 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) + "%" # 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 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 + "" + "
" ) 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) + "%" + "
" ) 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) 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", ) # Bridge source date label bdate_str = "Balance-sheet bridge" + (" · " + escape_html(source_date) if source_date else "") # 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): hist_growth_raw = ctx["hist_growth_raw"] hist_growth_raw_pct = hist_growth_raw * 100 if hist_growth_raw is not None else -5.0 slider_default = float(max(-15.0, min(20.0, hist_growth_raw_pct))) st.markdown(_DCF_RAIL_CSS, unsafe_allow_html=True) col_rail, col_canvas = st.columns([1, 2.5]) 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, ) st.markdown('
', unsafe_allow_html=True) 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.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"] 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
' '
Base FCF (TTM)' + base_fcf_str + '
' '
FCF · historical' + hist_growth_str + '
' '
' + nd_label + '' + net_debt_str + '
' '
Shares outstanding' + shares_str + '
', unsafe_allow_html=True, ) 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"], 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): st.markdown("**EV/EBITDA Valuation**") st.caption( "This is the better fallback when EBITDA is positive but free cash flow is weak, volatile, or currently negative." ) default_multiple = float(ctx["ev_ebitda_current"]) if ctx["ev_ebitda_current"] else 15.0 default_multiple = max(1.0, min(50.0, round(default_multiple, 1))) help_text = ( f"Current market multiple: {ctx['ev_ebitda_current']:.1f}x" if ctx["ev_ebitda_current"] else "Current multiple unavailable" ) target_multiple = st.slider( "Target EV/EBITDA", min_value=1.0, max_value=50.0, value=default_multiple, step=0.5, help=help_text, key=f"ev_ebitda_multiple_{ctx['ticker']}", ) ev_result = 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=target_multiple, ) if not ev_result: st.warning("Could not compute EV/EBITDA valuation.") return imp_price = ev_result["implied_price_per_share"] current_price = ctx["current_price"] market_cap = ctx["market_cap"] market_enterprise_value = None if market_cap and market_cap > 0: market_enterprise_value = ( float(market_cap) + float(ctx["total_debt"]) - float(ctx["cash_and_equivalents"]) + float(ctx["preferred_equity"]) + float(ctx["minority_interest"]) ) st.caption( "This model applies a target EV/EBITDA multiple to current EBITDA, then bridges from enterprise value to equity value per share." ) calc_a, calc_b, calc_c, calc_d = st.columns(4) calc_a.metric("EBITDA Used", fmt_large(ctx["ebitda"])) calc_b.metric("Target Multiple", f"{target_multiple:.1f}x") calc_c.metric("Implied Enterprise Value", fmt_large(ev_result["implied_ev"])) calc_d.metric("Implied Equity Value", fmt_large(ev_result["equity_value"])) st.caption( f"EBITDA: {fmt_large(ctx['ebitda'])} · " f"{_net_debt_label(ev_result['net_debt'])}: {fmt_large(abs(ev_result['net_debt']))} · " f"Other claims: {fmt_large(ev_result['other_claims'])} · " f"Equity Value: {fmt_large(ev_result['equity_value'])}" ) source_date = ctx["bridge_items"].get("source_date") if source_date: st.caption(f"EV/EBITDA bridge source date: **{source_date}**") if market_cap and market_cap > 0: st.markdown("**Market Comparison**") compare_a, compare_b = st.columns(2) if market_enterprise_value and market_enterprise_value > 0: ev_delta = (ev_result["implied_ev"] - market_enterprise_value) / market_enterprise_value compare_a.metric( "Market Enterprise Value", fmt_large(market_enterprise_value), delta=f"{ev_delta * 100:+.1f}%", ) equity_delta = (ev_result["equity_value"] - market_cap) / market_cap compare_b.metric("Market Cap", fmt_large(market_cap), delta=f"{equity_delta * 100:+.1f}%") summary_rows = [ { "Step": "1. Start with EBITDA", "Value": fmt_large(ctx["ebitda"]), "What it means": "Current EBITDA used as the operating earnings base.", }, { "Step": "2. Apply target multiple", "Value": f"{target_multiple:.1f}x", "What it means": "Chosen EV/EBITDA multiple applied to EBITDA.", }, { "Step": "3. Arrive at enterprise value", "Value": fmt_large(ev_result["implied_ev"]), "What it means": "Implied value of the operating business before capital structure.", }, { "Step": "4. Bridge to equity value", "Value": fmt_large(ev_result["equity_value"]), "What it means": "Enterprise value less net debt and other claims.", }, { "Step": "5. Convert to value per share", "Value": fmt_currency(imp_price), "What it means": "Equity value divided by shares outstanding.", }, ] st.dataframe(pd.DataFrame(summary_rows), width="stretch", hide_index=True) st.markdown("**EV/EBITDA Conclusion**") ev_m1, ev_m2, ev_m3, ev_m4 = st.columns(4) ev_m1.metric("Implied Price / Share", fmt_currency(imp_price)) if current_price: ev_upside = (imp_price - current_price) / current_price ev_m2.metric("Current Price", fmt_currency(current_price)) ev_m3.metric( "Upside / Downside", f"{ev_upside * 100:+.1f}%", delta=f"{ev_upside * 100:+.1f}%", ) ev_m4.metric("Implied EV", fmt_large(ev_result["implied_ev"])) if current_price and current_price > 0: valuation_gap = imp_price - current_price market_message = "above" if valuation_gap > 0 else "below" if abs(valuation_gap) < 0.005: market_message = "roughly in line with" implied_value = _escape_markdown_currency(fmt_currency(imp_price)) gap_value = _escape_markdown_currency(fmt_currency(abs(valuation_gap))) current_value = _escape_markdown_currency(fmt_currency(current_price)) st.markdown( f"At **{target_multiple:.1f}x EBITDA**, the model implies **{implied_value} per share**, " f"which is **{gap_value} {market_message}** the current market price of " f"**{current_value}**." ) def _render_ev_revenue_model(ctx: dict): st.markdown("**EV/Revenue Valuation**") st.caption( "This is the better fallback for scaled companies that have revenue but little or no EBITDA or free cash flow." ) default_multiple = float(ctx["ev_revenue_current"]) if ctx["ev_revenue_current"] else 4.0 default_multiple = max(0.5, min(30.0, round(default_multiple, 1))) help_text = ( f"Current market multiple: {ctx['ev_revenue_current']:.2f}x" if ctx["ev_revenue_current"] else "Current multiple unavailable" ) target_multiple = st.slider( "Target EV/Revenue", min_value=0.5, max_value=30.0, value=default_multiple, step=0.1, help=help_text, key=f"ev_revenue_multiple_{ctx['ticker']}", ) ev_revenue_result = 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=target_multiple, ) if not ev_revenue_result: st.warning("Could not compute EV/Revenue valuation.") return implied_price = ev_revenue_result["implied_price_per_share"] current_price = ctx["current_price"] market_cap = ctx["market_cap"] market_enterprise_value = None if market_cap and market_cap > 0: market_enterprise_value = ( float(market_cap) + float(ctx["total_debt"]) - float(ctx["cash_and_equivalents"]) + float(ctx["preferred_equity"]) + float(ctx["minority_interest"]) ) st.caption( "This model applies a target EV/Revenue multiple to TTM revenue, then bridges from enterprise value to equity value per share." ) calc_a, calc_b, calc_c, calc_d = st.columns(4) calc_a.metric("Revenue Used", fmt_large(ctx["revenue_ttm"])) calc_b.metric("Target Multiple", f"{target_multiple:.1f}x") calc_c.metric("Implied Enterprise Value", fmt_large(ev_revenue_result["implied_ev"])) calc_d.metric("Implied Equity Value", fmt_large(ev_revenue_result["equity_value"])) st.caption( f"Revenue: {fmt_large(ctx['revenue_ttm'])} · " f"{_net_debt_label(ev_revenue_result['net_debt'])}: {fmt_large(abs(ev_revenue_result['net_debt']))} · " f"Other claims: {fmt_large(ev_revenue_result['other_claims'])} · " f"Equity Value: {fmt_large(ev_revenue_result['equity_value'])}" ) source_date = ctx["bridge_items"].get("source_date") if source_date: st.caption(f"EV/Revenue bridge source date: **{source_date}**") if market_cap and market_cap > 0: st.markdown("**Market Comparison**") compare_a, compare_b = st.columns(2) if market_enterprise_value and market_enterprise_value > 0: ev_delta = (ev_revenue_result["implied_ev"] - market_enterprise_value) / market_enterprise_value compare_a.metric( "Market Enterprise Value", fmt_large(market_enterprise_value), delta=f"{ev_delta * 100:+.1f}%", ) equity_delta = (ev_revenue_result["equity_value"] - market_cap) / market_cap compare_b.metric("Market Cap", fmt_large(market_cap), delta=f"{equity_delta * 100:+.1f}%") summary_rows = [ { "Step": "1. Start with TTM revenue", "Value": fmt_large(ctx["revenue_ttm"]), "What it means": "Trailing twelve-month revenue used as the operating base.", }, { "Step": "2. Apply target multiple", "Value": f"{target_multiple:.1f}x", "What it means": "Chosen EV/Revenue multiple applied to TTM revenue.", }, { "Step": "3. Arrive at enterprise value", "Value": fmt_large(ev_revenue_result["implied_ev"]), "What it means": "Implied value of the operating business before capital structure.", }, { "Step": "4. Bridge to equity value", "Value": fmt_large(ev_revenue_result["equity_value"]), "What it means": "Enterprise value less net debt and other claims.", }, { "Step": "5. Convert to value per share", "Value": fmt_currency(implied_price), "What it means": "Equity value divided by shares outstanding.", }, ] st.dataframe(pd.DataFrame(summary_rows), width="stretch", hide_index=True) st.markdown("**EV/Revenue Conclusion**") evr_m1, evr_m2, evr_m3, evr_m4 = st.columns(4) evr_m1.metric("Implied Price / Share", fmt_currency(implied_price)) if current_price: evr_upside = (implied_price - current_price) / current_price evr_m2.metric("Current Price", fmt_currency(current_price)) evr_m3.metric( "Upside / Downside", f"{evr_upside * 100:+.1f}%", delta=f"{evr_upside * 100:+.1f}%", ) evr_m4.metric("Implied EV", fmt_large(ev_revenue_result["implied_ev"])) if current_price and current_price > 0: valuation_gap = implied_price - current_price market_message = "above" if valuation_gap > 0 else "below" if abs(valuation_gap) < 0.005: market_message = "roughly in line with" implied_value = _escape_markdown_currency(fmt_currency(implied_price)) gap_value = _escape_markdown_currency(fmt_currency(abs(valuation_gap))) current_value = _escape_markdown_currency(fmt_currency(current_price)) st.markdown( f"At **{target_multiple:.1f}x revenue**, the model implies **{implied_value} per share**, " f"which is **{gap_value} {market_message}** the current market price of " f"**{current_value}**." ) def _render_price_to_book_model(ctx: dict): st.markdown("**Price / Book Valuation**") if ctx["is_financial"]: st.caption( "P/B is often a better anchor for financial companies than cash-flow models because book value is closer to the operating asset base." ) else: st.caption( "P/B is a useful fallback when book value is meaningful and cash-flow-based models are not reliable." ) default_multiple = float(ctx["pb_current"]) if ctx["pb_current"] else (1.2 if ctx["is_financial"] else 2.0) default_multiple = max(0.2, min(10.0, round(default_multiple, 1))) help_text = ( f"Current market multiple: {ctx['pb_current']:.2f}x" if ctx["pb_current"] else "Current multiple unavailable" ) target_multiple = st.slider( "Target P/B", min_value=0.2, max_value=10.0, value=default_multiple, step=0.1, help=help_text, key=f"pb_multiple_{ctx['ticker']}", ) pb_result = run_price_to_book( book_value_per_share=float(ctx["book_value_per_share"]), target_multiple=target_multiple, ) if not pb_result: st.warning("Could not compute P/B valuation.") return implied_price = pb_result["implied_price_per_share"] current_price = ctx["current_price"] pb_m1, pb_m2, pb_m3, pb_m4 = st.columns(4) pb_m1.metric("Implied Price / Share", fmt_currency(implied_price)) pb_m2.metric("Book Value / Share", fmt_currency(ctx["book_value_per_share"])) if current_price: pb_upside = (implied_price - current_price) / current_price pb_m3.metric("Current Price", fmt_currency(current_price)) pb_m4.metric( "Upside / Downside", f"{pb_upside * 100:+.1f}%", delta=f"{pb_upside * 100:+.1f}%", ) else: pb_m3.metric("Target P/B", fmt_ratio(target_multiple)) pb_m4.metric("Current P/B", fmt_ratio(ctx["pb_current"]) if ctx["pb_current"] else "—") st.caption( f"Book value/share: {fmt_currency(ctx['book_value_per_share'])} · " f"Target P/B: {fmt_ratio(target_multiple)}" ) if current_price and ctx["pb_current"]: st.caption(f"Current market P/B: **{ctx['pb_current']:.2f}x**") def _render_models(ticker: str): ctx = _build_model_context(ticker) st.caption(ctx["summary"]) _render_model_availability(ctx) view_key = f"models_view_{ticker}" if view_key not in st.session_state: st.session_state[view_key] = "dcf" st.markdown(_DCF_RAIL_CSS, unsafe_allow_html=True) _pc1, _pc2 = st.columns(2) with _pc1: if st.button( "Discounted Cash Flow", key=f"pick_dcf_{ticker}", type="primary" if st.session_state[view_key] == "dcf" else "secondary", width="stretch", ): st.session_state[view_key] = "dcf" st.rerun() with _pc2: if st.button( "Multiples", key=f"pick_mult_{ticker}", type="primary" if st.session_state[view_key] == "multiples" else "secondary", width="stretch", ): st.session_state[view_key] = "multiples" st.rerun() st.markdown("---") view = st.session_state.get(view_key, "dcf") if view == "dcf": if ctx["dcf_available"]: _render_dcf_model(ctx) else: st.warning(f"DCF model not available: {ctx['dcf_reason']}") if st.expander("Show available alternatives", expanded=True): _render_multiples_model(ctx) else: _render_multiples_model(ctx) unavailable = [] if not ctx["dcf_available"]: unavailable.append(f"- **DCF:** {ctx['dcf_reason']}") if not ctx["ev_available"]: unavailable.append(f"- **EV/EBITDA:** {ctx['ev_reason']}") if not ctx["ev_revenue_available"]: unavailable.append(f"- **EV/Revenue:** {ctx['ev_revenue_reason']}") if not ctx["pb_available"]: unavailable.append(f"- **P/B:** {ctx['pb_reason']}") if unavailable: with st.expander("Why some models are unavailable", expanded=False): st.markdown("\n".join(unavailable)) # ── Comps Table ────────────────────────────────────────────────────────────── _CC_CSS = """""" def _render_comps(ticker: str): info = get_company_info(ticker) auto_peers = get_peers(ticker) if not auto_peers: auto_peers = _suggest_peer_tickers(ticker, info or {}) peer_syms = [p.upper() for p in auto_peers[:10]] all_syms = [ticker.upper()] + peer_syms with st.spinner("Loading comps…"): ratios_list = get_ratios_for_tickers(all_syms) if not ratios_list: st.info("No ratio data available for the peer set.") return ratios_map = {r["symbol"].upper(): r for r in ratios_list} COLS = [ {"key": "pe", "lbl": "P/E · TTM", "short": "P/E", "kind": "x", "invert": True}, {"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}, ] FIELD_MAP = { "pe": ("peRatioTTM", 1.0), "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), } _XMAP = {"NYQ": "NYSE", "NMS": "NASDAQ", "NGM": "NASDAQ", "NCM": "NASDAQ", "ASE": "AMEX"} peers = [] for sym_i in all_syms: r = ratios_map.get(sym_i, {}) ci = get_company_info(sym_i) or {} mcap_raw = ci.get("marketCap") or 0 mcap_b = round(mcap_raw / 1e9, 2) if mcap_raw else None row = { "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"] 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: fv = float(v) * scale if key in ("pe", "evEbt", "evSales", "pb") and (fv <= 0 or fv > 500): row[key] = None elif key in ("revG", "opM") and abs(fv) > 500: row[key] = None else: row[key] = round(fv, 2) except (TypeError, ValueError): row[key] = None else: row[key] = None peers.append(row) def _q(arr, q): if not arr: return None s = sorted(arr) pos = (len(s) - 1) * q lo, hi = int(pos), min(int(pos) + 1, len(s) - 1) return s[lo] if lo == hi else s[lo] + (s[hi] - s[lo]) * (pos - lo) stats = {} for col in COLS: key = col["key"] vals = [p[key] for p in peers if p.get(key) is not None] if not vals: stats[key] = {"min": None, "max": None, "p25": None, "p50": None, "p75": None} else: stats[key] = { "min": round(min(vals), 2), "max": round(max(vals), 2), "p25": round(_q(vals, 0.25), 2), "p50": round(_q(vals, 0.50), 2), "p75": round(_q(vals, 0.75), 2), } 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: key = col["key"] 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", "fcfy", "opM"] subject_row = next((p for p in peers if p["subject"]), None) def _pctof(vals, v): if not vals: return 50 return round(sum(1 for x in vals if x <= v) / len(vals) * 100) hero = [] for col_key in HERO_COLS: col = next(c for c in COLS if c["key"] == col_key) st_data = stats[col_key] if st_data["min"] is None or subject_row is None: continue subj_v = subject_row.get(col_key) if subj_v is None: continue all_vals = [p[col_key] for p in peers if p.get(col_key) is not None] if not all_vals: continue pct = _pctof(all_vals, subj_v) median_v = st_data["p50"] invert = col["invert"] good = (pct < 50) if invert else (pct >= 50) if invert: readout = "Richer than peers" if pct >= 70 else ("In line with peers" if pct >= 30 else "Cheaper than peers") else: readout = "Outperforms peers" if pct >= 70 else ("In line with peers" if pct >= 30 else "Trails peers") span = (st_data["max"] - st_data["min"]) or 1 def _pos(v_in, mn=st_data["min"], sp=span): return round(max(4.0, min(96.0, (v_in - mn) / sp * 100)), 1) hero.append({ "key": col_key, "lbl": col["lbl"], "kind": col["kind"], "value": subj_v, "median": median_v, "pct": pct, "good": good, "readout": readout, "subjPos": _pos(subj_v), "peerPositions": [_pos(p[col_key]) for p in peers if not p["subject"] and p.get(col_key) is not None], "p25Pos": _pos(st_data["p25"]), "p75Pos": _pos(st_data["p75"]), "medPos": _pos(st_data["p50"]), "minV": st_data["min"], "maxV": st_data["max"], }) 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) if price and prev_close and prev_close > 0: chg_pct = (price - prev_close) / prev_close * 100 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 = "—", "" 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 "—" n_peers = len(peers) - 1 data_json = json_for_script({ "subject": sym_h, "peers": peers, "peerMedian": peer_median_row, "cols": COLS, "stats": stats, "hero": hero, "nPeers": n_peers, }) total_height = 2600 + max(0, n_peers - 10) * 54 ctx_html = ( '
' '' + sym_h + '' '' + name + '' 'Valuation · Comps' '
' '' + exchange + '' '' + price_str + '' '' + chg_str + '' '
' ) lede_html = ( '
' '
' 'Peer set' '

' + 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.

' '
' '
' '
Peer set' '' + str(n_peers) + ' names' 'Sector · similar market cap
' '
Tagging' 'Auto-matched' 'FMP peers · 6h cache
' '
Period' 'TTM' 'Prices live · ratios T-1
' '
' '
' ) hero_html = '
' table_html = ( '
' '
' '
Side by side' '

Peer comparison · TTM ratios

' 'Click a column header to sort · dot in each cell shows column percentile' '
' '
' '
' ) foot_html = ( '
' 'Peer set sourced from FMP stock-peers or Prism sector fallback. ' 'Market cap from yfinance. Ratios self-computed from TTM statements. ' 'Distribution dot shows position within min↔max of the peer set.' '
' ) body = ctx_html + '
' + lede_html + hero_html + table_html + foot_html + '
' js = ( "const DATA=" + data_json + ";\n" "var sortKey='mcap',sortDir='desc';\n" "function fmtV(v,kind){\n" " if(v===null||v===undefined)return'—';\n" " if(kind==='x')return v.toFixed(1)+'×';\n" " if(kind==='%')return v.toFixed(1)+'%';\n" " return v.toFixed(2);\n" "}\n" "function fmtMcap(v){\n" " if(v===null||v===undefined)return'—';\n" " if(v>=1000)return'$'+(v/1000).toFixed(2)+'T';\n" " return'$'+v.toFixed(1)+'B';\n" "}\n" "function renderHero(){\n" " var h=DATA.hero,html='';\n" " for(var i=0;i';\n" " }\n" " html+='
';\n" " html+='
'+c.lbl+'';\n" " html+='P'+c.pct+'
';\n" " html+='
';\n" " html+='
'+DATA.subject+'';\n" " html+=''+fmtV(c.value,c.kind)+'
';\n" " html+='
Peer median';\n" " html+=''+fmtV(c.median,c.kind)+'
';\n" " html+='
';\n" " html+='
';\n" " html+='
';\n" " html+='
';\n" " html+=dots;\n" " html+='
';\n" " html+='
'+fmtV(c.minV,c.kind)+'';\n" " html+=''+fmtV(c.maxV,c.kind)+'
';\n" " html+=''+c.readout+'
';\n" " }\n" " document.getElementById('cmp-hero').innerHTML=html;\n" "}\n" "function distCell(v,colKey,hl){\n" " var st=DATA.stats[colKey],col=null;\n" " for(var i=0;i';\n" " }\n" " var span=(st.max-st.min)||1;\n" " var pct=Math.max(4,Math.min(96,((v-st.min)/span)*100));\n" " var tone=col.invert?(v>st.p50?'neg':'pos'):(v>st.p50?'pos':'neg');\n" " var dotCls=hl?'subject':tone;\n" " return'
'"\ "+''+fmtV(v,col.kind)+''"\ "+'
'"\ "+'
'"\ "+'
';\n" "}\n" "function renderTable(){\n" " var peers=DATA.peers,pm=DATA.peerMedian,cols=DATA.cols;\n" " var subject=null,others=[];\n" " for(var i=0;i';\n" " hdr+='Ticker';\n" " hdr+='Company';\n" " hdr+='Mkt cap'+(sortKey==='mcap'?''+arr+'':'')+'';\n" " for(var i=0;i';\n" " hdr+=c.short+(sortKey===c.key?''+arr+'':'')+'';\n" " }\n" " hdr+='';\n" " function buildRow(p,cls){\n" " var r='
';\n" " if(p.subject)r+=''+p.sym+' subject';\n" " else r+=''+p.sym+'';\n" " r+=''+p.name;\n" " if(cls===' median')r+=' '+DATA.nPeers+' names';\n" " r+='';\n" " var mcCls=(cls===' median')?' dim':'';\n" " r+=''+fmtMcap(p.mcap)+'';\n" " if(cls===' median'){\n" " for(var i=0;i
';\n" " }\n" " } else {\n" " var hl=!!p.subject;\n" " for(var i=0;i" "" "" "" + _KR_CSS + _CC_CSS + "" + body + "" + "" ) components.html(doc, height=total_height, scrolling=False) # ── Analyst Targets CSS ────────────────────────────────────────────────────── _AT_CSS = """""" # ── Analyst Targets ────────────────────────────────────────────────────────── def _render_analyst_targets(ticker: str): targets = get_analyst_price_targets(ticker) recs = get_recommendations_summary(ticker) info = get_company_info(ticker) if not targets and (recs is None or recs.empty): st.info("Analyst data unavailable.") return # Extract targets current = float(targets.get("current") or 0) low = float(targets.get("low") or 0) mean_t = float(targets.get("mean") or 0) median_t = float(targets.get("median") or 0) high = float(targets.get("high") or 0) upside = (mean_t - current) / current if current > 0 and mean_t else None upside_str = f"{upside * 100:+.1f}%" if upside is not None else "—" upside_cls = "pos" if (upside or 0) > 0 else "neg" # Extract recommendations counts = {"Strong Buy": 0, "Buy": 0, "Hold": 0, "Sell": 0, "Strong Sell": 0} if recs is not None and not recs.empty: if "period" in recs.columns: row_r = recs[recs["period"] == "0m"] row_r = row_r.iloc[0] if not row_r.empty else recs.iloc[0] else: row_r = recs.iloc[0] counts["Strong Buy"] = int(row_r.get("strongBuy", 0)) counts["Buy"] = int(row_r.get("buy", 0)) counts["Hold"] = int(row_r.get("hold", 0)) counts["Sell"] = int(row_r.get("sell", 0)) counts["Strong Sell"] = int(row_r.get("strongSell", 0)) total = sum(counts.values()) # Narrative readouts if upside and upside > 0.20: readout = f"Consensus sees significant upside — analysts expect {upside * 100:.0f}% appreciation from current levels." elif upside and upside > 0.05: readout = f"Moderate upside in view — the mean target implies {upside * 100:.0f}% from current price." elif upside and upside > 0: readout = f"Limited upside priced in — analysts see {upside * 100:.0f}% appreciation from here." elif upside and upside < 0: readout = f"Targets trail price — mean consensus implies {abs(upside) * 100:.0f}% downside from current." else: readout = "Analyst consensus on price targets." strong_bullish = counts["Strong Buy"] + counts["Buy"] bearish = counts["Sell"] + counts["Strong Sell"] if total > 0: bull_pct = strong_bullish / total if bull_pct >= 0.70: consensus_readout = f"Strong bullish consensus — {strong_bullish} of {total} analysts rate this a Buy or better." elif bull_pct >= 0.40: consensus_readout = f"Mixed but leaning bullish — {strong_bullish} analysts bullish against {total - strong_bullish} neutral or bearish." elif bearish / total >= 0.30: consensus_readout = f"Elevated skepticism — {bearish} of {total} analysts carry a sell rating." else: consensus_readout = "Cautious stance — analysts predominantly hold with limited conviction on direction." else: consensus_readout = "Insufficient coverage to assess consensus." # SVG track (800px internal coordinate) _span = high - low if high > low else 1 def _pct_pos(v): return max(0.0, min(1.0, (v - low) / _span)) if _span > 0 else 0.5 px_low_x = 20 px_high_x = 780 px_w = px_high_x - px_low_x px_current = max(28, min(772, px_low_x + _pct_pos(current) * px_w)) px_mean = max(28, min(772, px_low_x + _pct_pos(mean_t) * px_w)) if mean_t > current and current > 0: fill_x = min(px_current, px_mean) fill_w = abs(px_mean - px_current) svg_fill = f'' elif mean_t < current and current > 0: fill_x = min(px_current, px_mean) fill_w = abs(px_mean - px_current) svg_fill = f'' else: svg_fill = "" svg_html = ( '' '' '' + svg_fill + '' '' + f'{fmt_currency(low)}' + f'{fmt_currency(high)}' + f'' + f'Current {fmt_currency(current)}' + f'' + f'' + f'Mean {fmt_currency(mean_t)}' + '' ) # Stat cards def _sc(lbl, val_str, val_cls=""): cls_str = (' ' + val_cls) if val_cls else '' return ( '
' '' + lbl + '' '' + val_str + '' '
' ) stat_html = ( '
' + _sc("Low", fmt_currency(low), "dim") + _sc("Mean", fmt_currency(mean_t)) + _sc("Median", fmt_currency(median_t), "dim") + _sc("High", fmt_currency(high), "dim") + _sc("Upside to mean", upside_str, upside_cls) + '
' ) # Recommendation bar + legend rec_colors = { "Strong Buy": "#2E5A35", "Buy": "#4F8C5E", "Hold": "#8F7A50", "Sell": "#8B3A3F", "Strong Sell": "#6E2A2E", } bar_segs = "" legend_items = "" for label, count in counts.items(): color = rec_colors[label] if total > 0 and count > 0: pct_w = count / total * 100 bar_segs += f'
' pct_str = f"({count / total * 100:.0f}%)" if total > 0 else "(0%)" legend_items += ( '
' '
' '' + label + '' '' + str(count) + '' '' + pct_str + '' '
' ) # Context strip sym = ticker.upper() 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) if price and prev_close and prev_close > 0: chg_pct = (price - prev_close) / prev_close * 100 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 = "—", "" _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 "—" ctx_html = ( '
' '' + sym + '' '' + name + '' 'Valuation · Analyst Targets' '
' '' + exchange + '' '' + price_str + '' '' + chg_str + '' '
' ) lede_html = ( '
' '
' 'Analyst coverage' '

Where the street sets its sights — ' + str(total) + ' analysts, one consensus

' '

Price targets and recommendation breakdown as of the current reporting period. ' 'The range bar shows where the current price sits relative to the analyst target spectrum.

' '
' '
' '
Coverage' '' + str(total) + ' analysts' 'current month
' '
Mean target' '' + fmt_currency(mean_t) + '' 'vs ' + fmt_currency(current) + ' current
' '
Upside / downside' '' + upside_str + '' 'to mean target
' '
' '
' ) card1_html = ( '
' '
' '
I

Price target range

' 'Low · Current price · Mean target · High' '
' '
' + svg_html + '
' + stat_html + '
' + readout + '
' '
' ) card2_html = ( '
' '
' '
II

Recommendation breakdown

' '' + str(total) + ' analysts · current month' '
' '
' '
' + bar_segs + '
' '
' + legend_items + '
' '
' '
' + consensus_readout + '
' '
' ) foot_html = ( '
' 'Price targets and recommendations sourced from yfinance. ' 'Coverage counts as of the most recent reporting month.' '
' ) body = ( ctx_html + '
' + lede_html + card1_html + card2_html + foot_html + '
' ) _ROOT = ( "" ) doc = ( "" "" "" + _ROOT + _KR_CSS + _AT_CSS + "" + body + "" ) components.html(doc, height=1200, scrolling=False) # ── Earnings History ────────────────────────────────────────────────────────── _EH_CSS = """""" def _render_earnings_history(ticker: str): eh = get_earnings_history(ticker) next_date = get_next_earnings_date(ticker) info = get_company_info(ticker) if eh is None or eh.empty: st.info("Earnings history unavailable for this ticker.") return # Build normalized row list, oldest first (for chart) df = eh.copy().sort_index() rows = [] for idx in df.index: def _safe_float(col): try: v = df.loc[idx, col] if col in df.columns else None return float(v) if v is not None and pd.notna(v) else None except (TypeError, ValueError): return None actual_f = _safe_float("epsActual") est_f = _safe_float("epsEstimate") diff_f = _safe_float("epsDifference") surprise_f = _safe_float("surprisePercent") beat = (actual_f >= est_f) if (actual_f is not None and est_f is not None) else None rows.append({ "quarter": str(idx)[:10], "epsActual": actual_f, "epsEstimate": est_f, "diff": diff_f, "surprisePct": surprise_f, "beat": beat, }) n_total = len(rows) # Compute stats beats = [r for r in rows if r["beat"] is True] beat_rate = len(beats) / n_total * 100 if n_total > 0 else 0 surprise_vals = [r["surprisePct"] for r in rows if r["surprisePct"] is not None] avg_surprise = sum(surprise_vals) / len(surprise_vals) if surprise_vals else None med_surprise = sorted(surprise_vals)[len(surprise_vals) // 2] if surprise_vals else None # Current streak (from most recent) streak_count = 0 streak_type = None for r in reversed(rows): if r["beat"] is None: break if streak_type is None: streak_type = r["beat"] if r["beat"] == streak_type: streak_count += 1 else: break if streak_count > 0 and streak_type is not None: streak_str = f"{streak_count} {'beats' if streak_type else 'misses'}" streak_cls = "pos" if streak_type else "neg" else: streak_str = "—" streak_cls = "" # Build SVG chart (oldest to newest on x-axis) n = len(rows) SVG_W, SVG_H = 800, 260 PAD_L, PAD_R, PAD_T, PAD_B = 64, 24, 20, 56 all_eps = [] for r in rows: if r["epsActual"] is not None: all_eps.append(r["epsActual"]) if r["epsEstimate"] is not None: all_eps.append(r["epsEstimate"]) if all_eps: y_min_raw = min(all_eps) y_max_raw = max(all_eps) y_pad = (y_max_raw - y_min_raw) * 0.18 or 0.1 y_min = y_min_raw - y_pad y_max = y_max_raw + y_pad else: y_min, y_max = -1.0, 1.0 y_span = (y_max - y_min) or 1.0 ch_h = SVG_H - PAD_T - PAD_B ch_w = SVG_W - PAD_L - PAD_R def _cx(i): return PAD_L + (i / max(n - 1, 1)) * ch_w if n > 1 else PAD_L + ch_w / 2 def _cy(v): return PAD_T + (1.0 - (v - y_min) / y_span) * ch_h svg_parts = [ f'' ] # Horizontal grid lines for frac in [0.0, 0.25, 0.5, 0.75, 1.0]: gy = PAD_T + frac * ch_h gv = y_max - frac * y_span svg_parts.append( f'' f'{gv:.2f}' ) # Zero line if y_min < 0 < y_max: zy = _cy(0) svg_parts.append( f'' ) # Estimate line (dashed oxford-light) est_pts = [(i, rows[i]["epsEstimate"]) for i in range(n) if rows[i]["epsEstimate"] is not None] if len(est_pts) >= 2: est_d = " ".join( f"{'M' if j == 0 else 'L'}{_cx(i):.1f} {_cy(v):.1f}" for j, (i, v) in enumerate(est_pts) ) svg_parts.append( f'' ) # Actual line (solid brass) act_pts = [(i, rows[i]["epsActual"]) for i in range(n) if rows[i]["epsActual"] is not None] if len(act_pts) >= 2: act_d = " ".join( f"{'M' if j == 0 else 'L'}{_cx(i):.1f} {_cy(v):.1f}" for j, (i, v) in enumerate(act_pts) ) svg_parts.append( f'' ) # Dots and x-axis labels for i, r in enumerate(rows): xi = _cx(i) if r["epsEstimate"] is not None: yi = _cy(r["epsEstimate"]) svg_parts.append( f'' ) if r["epsActual"] is not None: ya = _cy(r["epsActual"]) dot_color = "#4F8C5E" if r["beat"] is True else ("#B5494B" if r["beat"] is False else "#C2AA7A") svg_parts.append( f'' ) label = r["quarter"][:7] ly = SVG_H - PAD_B + 14 svg_parts.append( f'{label}' ) svg_parts.append('') svg_html = "".join(svg_parts) # EPS table (most recent first) def _pill(beat): if beat is True: return ( 'Beat' ) if beat is False: return ( 'Miss' ) return '' table_rows_html = "" for r in reversed(rows): beat = r["beat"] row_bg = ( "rgba(79,140,94,0.05)" if beat is True else ("rgba(181,73,75,0.05)" if beat is False else "transparent") ) eps_actual_str = fmt_currency(r["epsActual"]) if r["epsActual"] is not None else "—" eps_est_str = fmt_currency(r["epsEstimate"]) if r["epsEstimate"] is not None else "—" diff_str = (("+" if (r["diff"] or 0) >= 0 else "") + fmt_currency(abs(r["diff"])) if r["diff"] is not None else "—") if r["diff"] is not None: diff_str = ("+" if r["diff"] >= 0 else "") + fmt_currency(r["diff"]) diff_cls = "pos" if (r["diff"] or 0) >= 0 else "neg" if r["surprisePct"] is not None: surp_str = f"{r['surprisePct'] * 100:+.2f}%" else: surp_str = "—" surp_cls = "pos" if (r["surprisePct"] or 0) >= 0 else "neg" pill = _pill(beat) table_rows_html += ( f'' f'{r["quarter"]}' f'{eps_est_str}' f'{eps_actual_str}' f'{diff_str}' f'{surp_str}' f'{pill}' '' ) # Stat strip beat_rate_str = f"{beat_rate:.0f}%" avg_surp_str = (f"{avg_surprise * 100:+.1f}%" if avg_surprise is not None else "—") avg_surp_cls = "pos" if (avg_surprise or 0) >= 0 else "neg" stat_strip_html = ( '
' '
Beat rate' '' + beat_rate_str + '
' '
Avg surprise' '' + avg_surp_str + '
' '
Current streak' '' + streak_str + '
' '
' ) # Context strip sym = ticker.upper() 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) if price and prev_close and prev_close > 0: chg_pct = (price - prev_close) / prev_close * 100 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 = "—", "" _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 "—" ctx_html = ( '
' '' + sym + '' '' + name + '' 'Valuation · Earnings History' '
' '' + exchange + '' '' + price_str + '' '' + chg_str + '' '
' ) next_date_str = _h(next_date if next_date else "Not scheduled") med_surp_str = (f"{med_surprise * 100:+.1f}%" if med_surprise is not None else "—") lede_html = ( '
' '
' 'Earnings track record' '

' + str(n_total) + ' quarters — beat rate ' + f"{beat_rate:.0f}%" + ', streak ' + streak_str + '

' '

Quarterly EPS actuals versus analyst consensus estimates. ' 'Green dots indicate beats, red misses. The strip below tracks beat rate, average surprise, and current streak.

' '
' '
' '
Next earnings' '' + next_date_str + '' 'estimated date
' '
Median surprise' '' + med_surp_str + '' 'vs consensus
' '
Current streak' '' + streak_str + '' 'consecutive
' '
' '
' ) chart_legend = ( '
' '' '' '' 'Actual EPS' '' '' '' 'Est. EPS' '' '' '' 'Beat' '' '' '' 'Miss' '
' ) chart_card_html = ( '
' '
' '
I

EPS: actual vs. estimate

' + chart_legend + '
' '
' + svg_html + '
' '
' ) table_card_html = ( '
' '
' '
II

Quarterly detail

' 'Most recent first · ' + str(n_total) + ' quarters' '
' + stat_strip_html + '' '' '' '' '' '' '' '' '' '' + table_rows_html + '' '
QuarterEPS EstEPS ActualSurprise $Surprise %Result
' '
' ) foot_html = ( '
' 'Earnings history from yfinance. Surprise % relative to analyst consensus at report time.' + ('Next: ' + next_date + '' if next_date else '') + '
' ) body = ( ctx_html + '
' + lede_html + chart_card_html + table_card_html + foot_html + '
' ) _ROOT = ( "" ) doc = ( "" "" "" + _ROOT + _KR_CSS + _EH_CSS + "" + body + "" ) total_height = 1500 + n_total * 52 components.html(doc, height=total_height, scrolling=False) # ── Historical Ratios ──────────────────────────────────────────────────────── _HIST_RATIO_OPTIONS = { "P/E": ("peRatio", "priceToEarningsRatio", None), "P/B": ("priceToBookRatio", None, None), "P/S": ("priceToSalesRatio", None, None), "EV/EBITDA": ("enterpriseValueMultiple", "evToEBITDA", None), "Net Margin": ("netProfitMargin", None, "pct"), "Operating Margin": ("operatingProfitMargin", None, "pct"), "Gross Margin": ("grossProfitMargin", None, "pct"), "ROE": ("returnOnEquity", None, "pct"), "ROA": ("returnOnAssets", None, "pct"), "Debt/Equity": ("debtEquityRatio", None, None), } _CHART_COLORS = [ "#C2AA7A", "#C49545", "#4F8C5E", "#B5494B", "#9b59b6", "#1abc9c", "#f39c12", "#e67e22", ] def _extract_hist_series(rows: list[dict], primary: str, alt: str | None) -> dict[str, float]: """Extract {year: value} from FMP historical rows.""" out = {} for row in rows: date = str(row.get("date", ""))[:4] val = row.get(primary) if val is None and alt: val = row.get(alt) if val is not None: try: out[date] = float(val) except (TypeError, ValueError): pass return out _KH_CSS = """""" def _render_historical_ratios(ticker: str): info = get_company_info(ticker) with st.spinner("Loading historical ratios…"): hist_rows = get_historical_ratios(ticker, limit=10) if not hist_rows: st.info("Historical ratio data unavailable.") return rows_sorted = sorted(hist_rows, key=lambda r: str(r.get("date", ""))) periods = [] for r in rows_sorted: y = str(r.get("date", ""))[:4] periods.append("FY" + y[2:] if len(y) == 4 else y) # ── Sector median data from TTM peer ratios ─────────────────────────────── peers_raw = get_peers(ticker) peers = [p for p in (peers_raw or []) if p.upper() != ticker.upper()][:6] peer_ratios_list = get_ratios_for_tickers(peers) if peers else [] def _peer_median(field_ttm): vals = [] for pr in peer_ratios_list: 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"), ("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 = [] for r in rows_sorted: v = r.get(field) if v is not None: try: fv = float(v) vals.append(round(fv * 100, 4) if kind == "%" else round(fv, 4)) except (TypeError, ValueError): vals.append(None) else: vals.append(None) 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 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 = "—", "" sym = ticker.upper() name = _h((info.get("longName") or info.get("shortName") or sym) if info else sym) _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 = ("$" + 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 + 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 = ( '
' + '' + _h(sym) + '' + '' + name + '' + 'Valuation · Historical Ratios' + '
' + '' + exchange + '' + '' + price_str + '' + '' + chg_str + '' + '
' ) lede_html = ( '
' + '
' + 'Drift' + '

' + str(n_periods) + ' periods of every ratio — pick a line, the heatmap follows

' + '

Annual ratios from ' + periods[0] + ' through ' + periods[-1] + '. ' + 'The subject line plots in champagne; dashed oxford is the sector median TTM. ' + 'Clicking a row in the matrix brings that series up to the hero chart. ' + 'Cell shading shows each ratio's relative position within its own history.

' + '
' + '
' + '
' + '' + _h(sym) + '' + 'Sector median' + '
' + '
' + 'Window' + '
' + '' + '' + '' + '
' + '
' + '
' + '
' ) hero_html = ( '
' '
' '
' '' '

' '
' '
' '
Latest
' '
Avg
' '
Range
' '
vs Sector
' '
' '
' '
' '
' ) matrix_html = ( '
' '
' '

Ratio matrix · ' + str(n_periods) + ' periods

' 'Click a row to chart it · cell shading shows relative position within row history' '
' '
' '
' '
' ) foot_html = ( '
' '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.' '
' ) body = ctx_html + '
' + lede_html + hero_html + matrix_html + foot_html + '
' _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&&yMnPl+(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=\'\';' ' svg+=\'\';' ' svg+=\'\';' ' svg+=\'\';' ' ticks.forEach(t=>{' ' const y=yAt(t).toFixed(1);' ' svg+=\'\';' ' svg+=\'\'+fmtV(t,s.kind)+\'\';' ' });' ' periods.forEach((p,i)=>{' ' svg+=\'\'+p+\'\';' ' });' ' if(s.sector_ttm!==null&&s.sector_ttm!==undefined){' ' const sy=yAt(s.sector_ttm);' ' const x0=Pl,x1=W-Pr;' ' svg+=\'\';' ' svg+=\'\';' ' svg+=\'sector\';' ' }' ' if(ap)svg+=\'\';' ' if(lp)svg+=\'\';' ' 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+=\'\';' ' if(idx===lastVI)svg+=\'\'+fmtV(p.v,s.kind)+\'\';' ' });' ' document.getElementById("kh-chart").innerHTML=\'\'+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+\' · \'+(s.kind==="%"?"percent":"multiple")+"";' ' 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=\'Ratio\';' ' periods.forEach(p=>{hh+=\'\'+p+\'\';});' ' hh+=\'Sector TTM\';' ' hh+=\'Δ vs sector\';' ' headRow.innerHTML=hh;' ' const groups=[...new Set(series.map(s=>s.group))];' ' let html="";' ' groups.forEach(group=>{' ' html+=\'
\'+group+\'
\';' ' series.filter(s=>s.group===group).forEach(s=>{' ' const act=s.key===selKey?" active":"";' ' html+=\'
\';' ' html+=\'\'+s.lbl+\'\';' ' s.subj.forEach((v,i)=>{' ' const last=i===n-1?" last":"";' ' const bg=v!==null?" style=\\"background:"+heatTone(v,s.subj)+"\\"":\"\";' ' html+=\'"+(v!==null?fmtV(v,s.kind):"—")+"";' ' });' ' 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+=\'\'+fmtV(s.sector_ttm,s.kind)+\'\';' ' if(lv!==null){' ' const d=s.sector_ttm!==0?((lv-s.sector_ttm)/Math.abs(s.sector_ttm))*100:0;' ' html+=\'\'+( d>=0?"+":"")+d.toFixed(0)+"%";' ' }else{html+=\'\';}' ' }else{' ' html+=\'\';' ' }' ' html+="
";' ' });' ' });' ' 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 = ( '' ) doc = ( "" + "" + "" + "" + _KR_CSS + _KH_CSS + kh_css_extra + "" + body + "" + "" ) components.html(doc, height=total_height, scrolling=False) # ── Forward Estimates ──────────────────────────────────────────────────────── _FE_CSS = """""" def _render_forward_estimates(ticker: str): with st.spinner("Loading forward estimates…"): estimates = get_analyst_estimates(ticker) annual = estimates.get("annual", []) quarterly = estimates.get("quarterly", []) if not annual and not quarterly: st.info("Forward estimates unavailable. Requires FMP API key.") return def _parse_est_rows(rows): parsed = [] for row in sorted(rows, key=lambda r: str(r.get("date", ""))): rev_avg = row.get("revenueAvg") or row.get("estimatedRevenueAvg") rev_lo = row.get("revenueLow") or row.get("estimatedRevenueLow") rev_hi = row.get("revenueHigh") or row.get("estimatedRevenueHigh") eps_avg = row.get("epsAvg") or row.get("estimatedEpsAvg") eps_lo = row.get("epsLow") or row.get("estimatedEpsLow") eps_hi = row.get("epsHigh") or row.get("estimatedEpsHigh") ebitda_avg = row.get("ebitdaAvg") or row.get("estimatedEbitdaAvg") n_analysts = ( row.get("numAnalystsRevenue") or row.get("numAnalystsEps") or row.get("numberAnalystEstimatedRevenue") or row.get("numberAnalysts") ) parsed.append({ "date": str(row.get("date", "")), "rev_avg": rev_avg, "rev_lo": rev_lo, "rev_hi": rev_hi, "eps_avg": eps_avg, "eps_lo": eps_lo, "eps_hi": eps_hi, "ebitda_avg": ebitda_avg, "n_analysts": int(n_analysts) if n_analysts else 0, }) return parsed def _range_bar(lo, avg, hi, lo_min, hi_max): if not lo or not hi or not avg: return '' lo_f, avg_f, hi_f = float(lo), float(avg), float(hi) lo_min_f, hi_max_f = float(lo_min), float(hi_max) rng = hi_max_f - lo_min_f if rng <= 0: return '' lo_pct = (lo_f - lo_min_f) / rng * 100 hi_pct = (hi_f - lo_min_f) / rng * 100 avg_pct = (avg_f - lo_min_f) / rng * 100 return ( '
' '
' f'
' f'
' '
' ) def _build_est_table_html(rows, is_annual=True): if not rows: return "" all_rev_lo = [r["rev_lo"] for r in rows if r.get("rev_lo")] all_rev_hi = [r["rev_hi"] for r in rows if r.get("rev_hi")] all_eps_lo = [r["eps_lo"] for r in rows if r.get("eps_lo")] all_eps_hi = [r["eps_hi"] for r in rows if r.get("eps_hi")] rev_lo_min = min(all_rev_lo) if all_rev_lo else None rev_hi_max = max(all_rev_hi) if all_rev_hi else None eps_lo_min = min(all_eps_lo) if all_eps_lo else None eps_hi_max = max(all_eps_hi) if all_eps_hi else None tbody = [] for row in rows: period = row["date"][:4] if is_annual else row["date"][:7] rev_range = _range_bar(row.get("rev_lo"), row.get("rev_avg"), row.get("rev_hi"), rev_lo_min, rev_hi_max) rev_avg_str = fmt_large(row["rev_avg"]) if row.get("rev_avg") else "—" eps_range = _range_bar(row.get("eps_lo"), row.get("eps_avg"), row.get("eps_hi"), eps_lo_min, eps_hi_max) eps_avg_str = fmt_currency(row["eps_avg"]) if row.get("eps_avg") else "—" ebitda_str = fmt_large(row["ebitda_avg"]) if row.get("ebitda_avg") else "—" analysts_str = str(row["n_analysts"]) if row.get("n_analysts") else "—" tbody.append( '' '' + period + '' '' + rev_range + '' '' + rev_avg_str + '' '' + eps_range + '' '' + eps_avg_str + '' '' + ebitda_str + '' '' + analysts_str + '' '' ) return "\n".join(tbody) annual_rows = _parse_est_rows(annual) quarterly_rows = _parse_est_rows(quarterly) # Historical revenue inc = get_income_statement(ticker) hist_rev = {} if inc is not None and not inc.empty and "Total Revenue" in inc.index: rev_series = inc.loc["Total Revenue"].dropna() for col in rev_series.index: yr = str(col)[:4] v = rev_series[col] if v and pd.notna(v): hist_rev[yr] = float(v) / 1e9 hist_rev = dict(sorted(hist_rev.items())) # Lede stats next_year_rev = annual_rows[0].get("rev_avg") if annual_rows else None next_year_eps = annual_rows[0].get("eps_avg") if annual_rows else None next_year_period = annual_rows[0]["date"][:4] if annual_rows else "—" max_analysts = max((r.get("n_analysts") or 0) for r in annual_rows) if annual_rows else 0 cagr = None if len(annual_rows) >= 2 and annual_rows[0].get("rev_avg") and annual_rows[-1].get("rev_avg"): n_years = len(annual_rows) cagr = (float(annual_rows[-1]["rev_avg"]) / float(annual_rows[0]["rev_avg"])) ** (1 / max(n_years - 1, 1)) - 1 if cagr is not None: if cagr > 0.12: fwd_readout = f"Analysts project accelerating growth — revenue expected to compound at {cagr * 100:.0f}% annually over the forecast horizon." elif cagr > 0.05: fwd_readout = f"Steady expansion in view — consensus projects {cagr * 100:.0f}% annual revenue growth through {annual_rows[-1]['date'][:4] if annual_rows else 'end of period'}." elif cagr > 0: fwd_readout = f"Modest growth expected — analysts see {cagr * 100:.0f}% annual expansion with limited upside surprise potential." else: fwd_readout = "Analysts project revenue contraction or flat growth over the forecast period." else: fwd_readout = "Analyst estimates show the expected trajectory for revenue and earnings per share." # Chart data hist_years = list(hist_rev.keys())[-5:] hist_vals = [hist_rev[y] for y in hist_years] bridge_yr = hist_years[-1] if hist_years else None bridge_val = hist_vals[-1] if hist_vals else None fwd_years = [r["date"][:4] for r in annual_rows] fwd_avg = [float(r["rev_avg"]) / 1e9 if r["rev_avg"] else None for r in annual_rows] fwd_lo = [float(r["rev_lo"]) / 1e9 if r.get("rev_lo") else None for r in annual_rows] fwd_hi = [float(r["rev_hi"]) / 1e9 if r.get("rev_hi") else None for r in annual_rows] if bridge_yr and bridge_val: fwd_years = [bridge_yr] + fwd_years fwd_avg = [bridge_val] + fwd_avg fwd_lo = [bridge_val] + fwd_lo fwd_hi = [bridge_val] + fwd_hi chart_data = { "hist_years": hist_years, "hist_vals": hist_vals, "fwd_years": fwd_years, "fwd_avg": fwd_avg, "fwd_lo": fwd_lo, "fwd_hi": fwd_hi, } annual_tbody = _build_est_table_html(annual_rows, is_annual=True) if annual_rows else "" qtr_tbody = _build_est_table_html(quarterly_rows, is_annual=False) if quarterly_rows else "" last_period = annual_rows[-1]["date"][:4] if annual_rows else "—" rev_str = fmt_large(next_year_rev) if next_year_rev else "—" eps_str = fmt_currency(next_year_eps) if next_year_eps else "—" cagr_str = f"{cagr * 100:.1f}%" if cagr is not None else "—" # Context strip info = get_company_info(ticker) sym = ticker.upper() 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) if price and prev_close and prev_close > 0: chg_pct = (price - prev_close) / prev_close * 100 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 = "—", "" _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 "—" ctx_html = ( '
' '' + sym + '' '' + name + '' 'Valuation · Forward Estimates' '
' '' + exchange + '' '' + price_str + '' '' + chg_str + '' '
' ) lede_html = ( '
' '
' 'Wall Street outlook' '

What ' + str(max_analysts) + ' analysts project for the years ahead

' '

Annual consensus estimates sourced from Financial Modeling Prep. ' 'The revenue chart bridges historical actuals to the analyst range — dashed line is the consensus average, ' 'the band spans the bull-to-bear spectrum.

' '
' '
' '
' + next_year_period + ' Revenue' '' + rev_str + '' '' + str(max_analysts) + ' analysts · consensus
' '
' + next_year_period + ' EPS' '' + eps_str + '' 'consensus estimate
' '
Rev. CAGR' '' + cagr_str + '' 'est. through ' + last_period + '
' '
' '
' ) tab_row_html = ( '
' '' '' '
' ) annual_table_empty = ( '' 'No annual estimates available.' ) annual_content_html = ( '
' '
' '
' '
I

Revenue trajectory

' 'Historical + analyst consensus range' '
' '
' '
' '
Historical
' '
Est. avg
' '
Est. range (low–high)
' '
' '
' + fwd_readout + '
' '
' '
' '
' '
' '
II

Annual estimates

' 'Revenue · EPS · EBITDA · Coverage' '
' '
' '' '' '' '' '' '' + (annual_tbody if annual_tbody else annual_table_empty) + '' '
PeriodRevenue rangeRev avgEPS rangeEPS avgEBITDAAnalysts
' '
' '
' ) if qtr_tbody: qtr_content_html = ( '' ) else: qtr_content_html = ( '' ) foot_html = ( '
' 'Forward estimates from Financial Modeling Prep. Historical revenue from yfinance. ' 'CAGR computed over the full estimate horizon.' '
' ) body = ( ctx_html + '
' + lede_html + tab_row_html + annual_content_html + qtr_content_html + foot_html + '
' ) js = ( "const D=" + json_for_script(chart_data) + ";\n" "function showTab(tab,el){" "document.querySelectorAll('.tab-pill').forEach(function(b){" "b.className='tab-pill '+(b===el?'active':'inactive');" "});" "document.getElementById('annual-content').style.display=tab==='annual'?'block':'none';" "document.getElementById('qtr-content').style.display=tab==='quarterly'?'block':'none';" "}\n" "var traces=[" "{x:D.hist_years,y:D.hist_vals,fill:'tozeroy',fillcolor:'rgba(194,170,122,0.06)'," "line:{color:'transparent'},showlegend:false,hoverinfo:'skip',type:'scatter'}," "{x:D.hist_years,y:D.hist_vals,name:'Historical',mode:'lines+markers',type:'scatter'," "line:{color:'#C2AA7A',width:2},marker:{size:6,color:'#C2AA7A'},showlegend:false}," "{x:D.fwd_years,y:D.fwd_lo,fill:'none',line:{color:'transparent'}," "showlegend:false,hoverinfo:'skip',type:'scatter'}," "{x:D.fwd_years,y:D.fwd_hi,fill:'tonexty',fillcolor:'rgba(31,61,92,0.22)'," "line:{color:'transparent'},showlegend:false,hoverinfo:'skip',type:'scatter'}," "{x:D.fwd_years,y:D.fwd_avg,name:'Est. avg',mode:'lines+markers',type:'scatter'," "line:{color:'#C2AA7A',width:1.5,dash:'dash'},marker:{size:5,color:'#C2AA7A'},showlegend:false}" "];\n" "var layout={" "paper_bgcolor:'#0B0E13',plot_bgcolor:'#0B0E13'," "margin:{l:56,r:16,t:8,b:40},showlegend:false," "xaxis:{gridcolor:'#232934',tickfont:{family:'IBM Plex Mono,monospace',color:'#5E5849',size:10}," "linecolor:'#232934'}," "yaxis:{gridcolor:'#232934',tickfont:{family:'IBM Plex Mono,monospace',color:'#5E5849',size:10}," "linecolor:'#232934',title:{text:'Revenue ($B)',font:{color:'#8E8676',size:11}}}," "hovermode:'x unified'," "hoverlabel:{bgcolor:'#181D26',bordercolor:'#2E3645'," "font:{family:'IBM Plex Mono,monospace',color:'#F2ECDC',size:11}}," "font:{family:'IBM Plex Mono,monospace',color:'#C7C0AE',size:11}" "};\n" "Plotly.newPlot('rev-chart',traces,layout,{responsive:true,displayModeBar:false});\n" ) _ROOT = ( "" ) doc = ( "" "" "" "" + _ROOT + _KR_CSS + _FE_CSS + "" + body + "" + "" ) height = 1320 + len(annual_rows) * 50 components.html(doc, height=height, scrolling=False)