"""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'
'
)
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")
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, "%", None, None, invert=False)
)
# ── Assemble val rows ───────────────────────────────────────────────────
val_rows_html = (
_val_row("P / E · TTM", pe, "x", "peRatioTTM", pe_5avg, sparks.get("pe"), invert=True)
+ _val_row("P / E · Forward", pe_fwd, "x", "forwardPE", None, None, invert=True)
+ _val_row("PEG · 5-yr", peg, "x", "pegRatioTTM", None, None, invert=True)
+ _val_row("EV / EBITDA", ev_ebt, "x", "enterpriseValueMultipleTTM", evEbt_5avg, sparks.get("evEbt"), invert=True)
+ _val_row("EV / Revenue", ev_rev, "x", "evToSalesTTM", None, None, invert=True)
+ _val_row("P / Book", pb, "x", "priceToBookRatioTTM", pb_5avg, sparks.get("pb"), invert=True)
+ _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 = (
'Metric Subject Peers + Δ 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 = (
'Metric Subject Peers + Δ 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 = (
'Metric Subject Peers Trend
'
+ _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 = (
'Metric Subject Peers Trend
'
+ _mini_row("FCF yield", fcf_yield_v, "%", None, None)
+ _mini_row("Dividend yield", div_y, "%", _pm("dividendYieldTTM"), None)
+ _mini_row("Payout ratio", payout, "%", _pm("dividendPayoutRatioTTM"), None, good_low=True)
+ _mini_row("Buyback yield", buyback_yield, "%", None, None)
)
if total_yield is not None:
cash_rows_html = cash_rows_html + _mini_row("Total yield", total_yield, "%", None, None)
# ── Assemble HTML body (string concatenation only — no f-strings) ───────
ctx_price = ('$' + 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] + '
'
+ '
Basis TTM Trailing twelve months
'
+ '
As of ' + today_str + ' Prices live · yfinance
'
+ '
'
+ ' '
+ '
'
+ '
'
+ 'I
Valuation multiples Subject · Peer P25 / median / P75 · 5-yr drift '
+ 'Ratio Subject vs peers Peer 25 — 75 5-yr avg 5-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 + '
'
+ ' '
+ ''
+ '
'
+ '
'
)
# ── 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_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) + "
"
"
"
"
8× typical 14×–26× 32×
"
"
"
"
"
"
" + _fx(rv_init) + " sector " + _fx(rv_sector) + "
"
"
"
"
4× typical 6×–13× 20×
"
"
"
"
"
"
" + _fx(pb_init) + " sector " + _fx(pb_sector) + "
"
"
"
"
4× 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 "
"
"
"
"
" "
""
""
""
"
"
""
""
"")
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 + " "
""
"Forecast FCF " + fcf_cells + " "
"Discount factor " + df_cells + " "
"Present value " + pv_cells + " "
" "
"
"
" "
# 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 + "
"
"
"
""
" "
# 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
""
"
" # 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_multiples_model(ctx: dict):
doc = _build_multiples_canvas_html(ctx)
components.html(doc, height=1900, scrolling=False)
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 = max(1900, 1500 + n_peers * 80)
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 = (
''
)
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"
" r+=(val!==null&&val!==undefined?fmtV(val,c.kind):'—')+'
';\n"
" }\n"
" } else {\n"
" var hl=!!p.subject;\n"
" for(var i=0;i';return r;\n"
" }\n"
" var tbl=hdr;\n"
" if(subject)tbl+=buildRow(subject,' subject');\n"
" tbl+=buildRow(pm,' median');\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 = (
''
)
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
+ ''
''
'Quarter '
'EPS Est '
'EPS Actual '
'Surprise $ '
'Surprise % '
'Result '
' '
'' + table_rows_html + ' '
'
'
' '
)
foot_html = (
''
)
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)
peers_raw = get_peers(ticker)
peers = [p for p in (peers_raw or []) if p.upper() != ticker.upper()][:6]
peer_ratios_list = get_ratios_for_tickers(peers) if peers else []
if not hist_rows:
st.info("Historical ratio data unavailable.")
return
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)
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 '
+ '
'
+ 'All '
+ '5 yr '
+ '3 yr '
+ '
'
+ '
'
+ '
'
+ ' '
)
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 = (
''
)
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 (
''
)
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 '
'Quarterly '
'
'
)
annual_table_empty = (
''
'No annual estimates available. '
)
annual_content_html = (
''
'
'
''
'
I
Revenue trajectory '
'
Historical + analyst consensus range '
'
'
''
''
'' + fwd_readout + '
'
' '
'
'
''
'
'
'
II
Annual estimates '
'
Revenue · EPS · EBITDA · Coverage '
'
'
'
'
''
''
'Period Revenue range Rev avg '
'EPS range EPS avg EBITDA Analysts '
' '
'' + (annual_tbody if annual_tbody else annual_table_empty) + ' '
'
'
' '
'
'
)
if qtr_tbody:
qtr_content_html = (
''
'
'
''
'
'
'
II
Quarterly detail '
'
Quarterly estimates '
'
'
'
'
''
''
'Period Revenue range Rev avg '
'EPS range EPS avg EBITDA Analysts '
' '
'' + qtr_tbody + ' '
'
'
' '
'
'
)
else:
qtr_content_html = (
''
'
Quarterly estimates require FMP premium subscription.
'
'
'
)
foot_html = (
''
)
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)