"""Valuation panel — key ratios, models, comparable companies, analyst targets, earnings history."""
import json
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
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 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''
)
def _peer_bar_html(value, p25, p50, p75, min_v, max_v) -> str:
def pct(v):
if min_v is None or max_v is None or max_v <= min_v:
return 50.0
return max(0.0, min(100.0, (v - min_v) / (max_v - min_v) * 100))
vp = pct(value) if value is not None else 50
p25p, p75p, p50p = pct(p25), pct(p75), pct(p50)
return (
f'
'
f''
f''
f''
f''
f'
'
)
def _fmtv(v, kind: str) -> str:
if v is None:
return "—"
try:
fv = float(v)
if fv != fv:
return "—"
except (TypeError, ValueError):
return "—"
if kind == "%":
return f"{fv * 100:.1f}%"
if kind == "x":
return f"{fv:.1f}×"
if kind == "$B":
return f"${fv / 1e9:.1f}B"
if kind == "pp":
return f"{fv * 100:+.1f}pp"
return f"{fv:.2f}"
def _tone(delta_pct: float, invert: bool = False) -> str:
if abs(delta_pct) < 2:
return "flat"
better = delta_pct < 0 if invert else delta_pct > 0
return "pos" if better else "neg"
def _compute_peer_bands(peer_ratio_rows: list[dict]) -> dict:
fields = [
"peRatioTTM", "forwardPE", "enterpriseValueMultipleTTM",
"evToSalesTTM", "priceToBookRatioTTM", "priceToSalesRatioTTM",
"grossProfitMarginTTM", "operatingProfitMarginTTM", "netProfitMarginTTM",
"returnOnEquityTTM", "returnOnAssetsTTM", "returnOnInvestedCapitalTTM",
"currentRatioTTM", "quickRatioTTM", "debtToEquityRatioTTM",
"interestCoverageRatioTTM", "dividendYieldTTM", "dividendPayoutRatioTTM",
"revenueGrowthTTM", "earningsGrowthTTM",
]
result = {}
for field in fields:
vals = []
for row in peer_ratio_rows:
v = row.get(field)
if v is not None:
try:
fv = float(v)
if np.isfinite(fv) and fv > 0:
vals.append(fv)
except (TypeError, ValueError):
pass
if len(vals) >= 2:
arr = np.array(vals)
result[field] = {
"p25": float(np.percentile(arr, 25)),
"p50": float(np.percentile(arr, 50)),
"p75": float(np.percentile(arr, 75)),
"min": float(arr.min()),
"max": float(arr.max()),
"n": len(vals),
}
return result
def _compute_growth_ratios(ticker: str) -> dict:
result: dict = {}
try:
inc = get_income_statement(ticker)
cf = get_cash_flow(ticker)
if inc is not None and not inc.empty and len(inc.columns) >= 2:
def _inc(label):
if label in inc.index:
v = inc.loc[label].dropna()
return v
return None
rev = _inc("Total Revenue")
if rev is not None and len(rev) >= 2:
r0, r1 = float(rev.iloc[0]), float(rev.iloc[1])
if r1 > 0:
result["revYoY"] = (r0 - r1) / r1
if len(rev) >= 4:
r3 = float(rev.iloc[3])
if r3 > 0 and r0 > 0:
result["rev3yrCAGR"] = (r0 / r3) ** (1 / 3) - 1
op_inc = _inc("Operating Income")
if op_inc is not None and len(op_inc) >= 2:
o0, o1 = float(op_inc.iloc[0]), float(op_inc.iloc[1])
if abs(o1) > 0:
result["opIncYoY"] = (o0 - o1) / abs(o1)
for lbl in ("Diluted Average Shares", "Diluted Common Shares Outstanding"):
shares = _inc(lbl)
if shares is not None and len(shares) >= 2:
s0, s1 = float(shares.iloc[0]), float(shares.iloc[1])
if s1 > 0:
result["sharesYoY"] = (s0 - s1) / s1
break
for lbl in ("Diluted EPS", "Basic EPS"):
eps = _inc(lbl)
if eps is not None and len(eps) >= 2:
e0, e1 = float(eps.iloc[0]), float(eps.iloc[1])
if abs(e1) > 0 and e1 > 0:
result["epsYoY"] = (e0 - e1) / e1
break
if cf is not None and not cf.empty:
fcf_s = None
if "Free Cash Flow" in cf.index:
fcf_s = cf.loc["Free Cash Flow"].dropna()
else:
try:
op = cf.loc["Operating Cash Flow"]
capex = cf.loc["Capital Expenditure"]
fcf_s = (op + capex).dropna()
except KeyError:
pass
if fcf_s is not None and len(fcf_s) >= 2:
f0, f1 = float(fcf_s.iloc[0]), float(fcf_s.iloc[1])
if f1 > 0:
result["fcfYoY"] = (f0 - f1) / f1
mkt = get_market_cap_computed(ticker)
for lbl in ("Repurchase Of Capital Stock", "Common Stock Repurchased"):
if lbl in cf.index:
val = cf.loc[lbl].iloc[0]
if val is not None and pd.notna(val):
buybacks = abs(float(val))
if mkt and mkt > 0 and buybacks > 0:
result["buybackYield"] = buybacks / mkt
break
except Exception:
pass
return result
def _build_hist_sparks(hist_rows: list[dict]) -> dict:
rows = list(reversed(hist_rows))
def _ex(field):
return [r[field] for r in rows if field in r and r[field] is not None]
return {
"pe": _ex("peRatio"),
"pb": _ex("priceToBookRatio"),
"ps": _ex("priceToSalesRatio"),
"evEbt": _ex("enterpriseValueMultiple"),
"gross": _ex("grossProfitMargin"),
"op": _ex("operatingProfitMargin"),
"net": _ex("netProfitMargin"),
"roe": _ex("returnOnEquity"),
"roa": _ex("returnOnAssets"),
"de": _ex("debtEquityRatio"),
}
def _render_ratios(ticker: str):
info = get_company_info(ticker)
ratios = get_key_ratios(ticker)
if not ratios and not info:
st.info("Ratio data unavailable.")
return
price = get_latest_price(ticker)
market_cap = get_market_cap_computed(ticker)
fcf_ttm = get_free_cash_flow_ttm(ticker)
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)
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")
cash_raw = None
net_debt_ebt = 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
else:
cash_mkt = None
except Exception:
cash_mkt = None
net_debt_ebt = None
# Price info
prev_close = info.get("previousClose") if info else None
if price and prev_close and prev_close > 0:
chg_pct = (price - prev_close) / prev_close * 100
chg_str = f"{'▲' if chg_pct >= 0 else '▼'} {'+' if chg_pct >= 0 else ''}{chg_pct:.2f}%"
chg_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 = _XMAP.get(raw_x, raw_x) or "—"
co_name = (info.get("longName", ticker) if info else ticker) or ticker
sector = (info.get("sector", "—") if info else "—") or "—"
industry = (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 == "%":
# Show absolute percentage-point difference (design: "+4.1pp")
diff_pp = (fv_f - sv_f) * 100
tone = "flat" if abs(diff_pp) < 0.3 else ("neg" if (invert or good_low) == (diff_pp > 0) else "pos")
mini_cls = f'{diff_pp:+.1f}pp'
else:
diff = (fv_f - sv_f) / abs(sv_f) * 100
tone = _tone(diff, invert or good_low)
mini_cls = f'{diff:+.0f}%'
sector_html = f'{sv}{mini_cls}'
except Exception:
sector_html = f'{sv}'
else:
sector_html = f'{sv}'
spark_color = "var(--positive)" if not (invert or good_low) else "var(--warning)"
spark_svg = _svg_spark(spark_data, 86, 20, spark_color) if spark_data else ""
return (
f'
'
f'{lbl}'
f'{fv}'
f'{sector_html}'
f'{spark_svg}'
f'
'
)
# ── 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 = f"{'+' if diff >= 0 else ''}{diff:.0f}%"
except Exception:
tone, d_str = "flat", "—"
else:
tone, d_str = "flat", "—"
if five_avg is not None and v is not None:
try:
d_avg = (float(v) - float(five_avg)) / abs(float(five_avg)) * 100
avg_tone = _tone(d_avg, invert)
avg_html = (
f''
f'{_fmtv(five_avg, kind)}'
f'{d_avg:+.0f}%'
f''
)
except Exception:
avg_html = f'{_fmtv(five_avg, kind)}'
else:
avg_html = f'{_fmtv(five_avg, kind)}'
spark_color = "var(--negative)" if tone == "neg" else ("var(--positive)" if tone == "pos" else "var(--brass-bright)")
spark_svg = _svg_spark(spark_data, 108, 24, spark_color) if spark_data else ""
peer_bar = _peer_bar_html(v, p25, p50, p75, bmin, bmax)
peer_axis = ""
if p25 is not None:
peer_axis = (
f'
'
f'{_fmtv(p25, kind)}'
f'{_fmtv(p50, kind)}'
f'{_fmtv(p75, kind)}'
f'
'
)
return (
f'
'
f'{lbl}'
f'{fv}'
f'{d_str}'
f'
{peer_bar}{peer_axis}
'
f'{avg_html}'
f'{spark_svg}'
f'
'
)
# ── Snapshot KPIs ───────────────────────────────────────────────────────
def _kpi(lbl, v, kind, field, invert=False):
fv = _fmtv(v, kind)
band = peer_bands.get(field, {})
p50 = band.get("p50")
sect_str = _fmtv(p50, kind) if p50 is not None else "—"
if v is not None and p50 is not None:
try:
diff = (float(v) - p50) / abs(p50) * 100
tone = _tone(diff, invert)
d_str = f"{'+' if diff >= 0 else ''}{diff:.0f}% vs peers"
except Exception:
tone, d_str = "flat", "—"
else:
tone, d_str = "flat", "—"
# Use historical data for sparkline when available
return tone, (
f'
'
f'
{lbl}
'
f'{fv}'
f'
'
f'peers {sect_str}'
f'{d_str}'
f'
'
f'
'
)
def _kpi_spark(lbl, v, kind, field, spark_data, invert=False):
fv = _fmtv(v, kind)
band = peer_bands.get(field, {})
p50 = band.get("p50")
sect_str = _fmtv(p50, kind) if p50 is not None else "—"
if v is not None and p50 is not None:
try:
diff = (float(v) - p50) / abs(p50) * 100
tone = _tone(diff, invert)
d_str = f"{'+' if diff >= 0 else ''}{diff:.0f}% vs peers"
except Exception:
tone, d_str = "flat", "—"
else:
tone, d_str = "flat", "—"
spark_color = "var(--negative)" if tone == "neg" else ("var(--positive)" if tone == "pos" else "var(--brass-bright)")
spark_svg = _svg_spark(spark_data, 68, 22, spark_color) if spark_data else ""
return (
f'
'
f'{ticker.upper()}'
f'{co_name}'
f'Valuation · Key Ratios'
f'
{exchange}{ctx_price}{ctx_chg}
'
f'
'
f'
'
f''
f'
'
f'Snapshot'
f'
Where the lens sits — six headline ratios, scored against the peer set
'
f'
TTM ratios, peer medians from {n_peers} peers ({sector}). Sparklines show historical drift; the peer band on each row is the 25th–75th percentile of the peer set.
Ratios computed from yfinance financial statements, TTM basis. Peer bands from {n_peers} comparable names. Market data live.
'
f'
'
)
doc = f"""
{_KR_CSS}
{body}"""
components.html(doc, height=2400, scrolling=True)
# ── 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'
Reading · DCF implies {gap_str}{gap_dir} the current market.{reading}
Enterprise value build — present value of FCFs + terminal
USD · billions · discounted at WACC {wacc_pct:.1f}%
{hdr_cells}
Forecast FCF
{fcf_cells}
Discount factor
{df_cells}
Present value
{pv_cells}
From enterprise to equity
Balance-sheet bridge{(' · ' + source_date) if source_date else ''}
Enterprise value{ev_b}
−Net debt
Net debt{net_debt_b}
−Other claims
Other claims{other_claims_b}
=
Equity value{equity_b}
Total debt {total_debt_b}·Cash & equiv. {cash_b}·Preferred + minority {_fmt_b(other_b_val)}
Intrinsic · Per Share{iv_str}Equity value ÷ shares
Market · Last{market_str}
Gap{gap_display}{gap_pct_str}
Shares Outstanding{shares_b:.2f} Bdiluted
Cross-check against the multiples
Same business, different lenses · implied per-share
{cx_dcf}
{cx_ev}
{cx_rev}
{cx_pb}
Firm-value DCF · enterprise value bridged to equity using debt & cash from the most recent balance sheet. Negative-FCF years are excluded from the base; terminal value uses Gordon Growth Model.Methodology & sources ↗
"""
return html
def _build_multiples_canvas_html(ctx: dict) -> str:
market = float(ctx["current_price"] or 0)
shares = float(ctx["shares"] or 0)
total_debt = float(ctx["total_debt"] or 0)
cash = float(ctx["cash_and_equivalents"] or 0)
net_debt = total_debt - cash
ebitda = float(ctx["ebitda"]) if ctx.get("ebitda") and ctx["ebitda"] > 0 else 0.0
revenue = float(ctx["revenue_ttm"]) if ctx.get("revenue_ttm") and ctx["revenue_ttm"] > 0 else 0.0
book_ps = float(ctx["book_value_per_share"]) if ctx.get("book_value_per_share") and ctx["book_value_per_share"] > 0 else 0.0
eb_ok = ebitda > 0 and shares > 0
rv_ok = revenue > 0 and shares > 0
pb_ok = book_ps > 0
has_market = market > 0
def _clamp(v, lo, hi):
try:
return max(lo, min(hi, float(v)))
except (TypeError, ValueError):
return lo
eb_init = _clamp(ctx.get("ev_ebitda_current") or 15.0, 8.0, 32.0)
rv_init = _clamp(ctx.get("ev_revenue_current") or 5.0, 4.0, 20.0)
pb_init = _clamp(ctx.get("pb_current") or 5.0, 4.0, 60.0)
# Sector medians — try peers, fall back to defaults
eb_sector, rv_sector, pb_sector = 12.0, 3.0, 4.0
try:
info = ctx.get("info") or {}
peers = get_peers(ctx["ticker"]) or _suggest_peer_tickers(ctx["ticker"], info)
if peers:
pr = get_ratios_for_tickers(peers[:6])
if pr:
import statistics as _stats
eb_vs = [float(r["enterpriseValueMultipleTTM"]) for r in pr.values()
if r and r.get("enterpriseValueMultipleTTM") and 2 < r["enterpriseValueMultipleTTM"] < 100]
rv_vs = [float(r["priceToSalesRatioTTM"]) for r in pr.values()
if r and r.get("priceToSalesRatioTTM") and 0.1 < r["priceToSalesRatioTTM"] < 50]
pb_vs = [float(r["priceToBookRatioTTM"]) for r in pr.values()
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("dcf_intrinsic")
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 = ctx["ticker"]
exchange = (ctx.get("info") or {}).get("exchange") or "—"
data_json = json.dumps({
"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 = f"""
Multiples
Three relative-valuation lenses — implied per-share
Subject multiple × normalized TTM metric, bridged to equity per share. Compare across columns to see which lens the market is leaning on.
EV / EBITDA{_fs(eb_per0)}
{_ds_span(eb_per0, 'id="sum-eb-d"')}
EV / Revenue{_fs(rv_per0)}
{_ds_span(rv_per0, 'id="sum-rv-d"')}
P / Book{_fs(pb_per0)}
{_ds_span(pb_per0, 'id="sum-pb-d"')}
Market · last{_fs(market) if has_market else "—"}{ticker} · {exchange}
Method comparison
USD · TTM metrics · balance-sheet bridge
Method
I
EV / EBITDA
Strong fit
Enterprise value normalized by operating cash profit. Strips depreciation and capital structure so different capex profiles compare cleanly.
II
EV / Revenue
Strong fit
Topline multiple — useful when margins are volatile or the business is reinvesting through profitability. Less sensitive to one-off charges.
III
P / Book
{pb_fit_lbl}
Equity multiple — works for balance-sheet-heavy businesses (banks, insurers, REITs). Limited signal for asset-light software & services.
Subject multipledrag to flex the lens
{_fx(eb_init)}sector {_fx(eb_sector)}
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
Multiples · TTM metrics, balance sheet. Net-debt adjustment applies to EV-based methods only; P/B reads directly off book equity per share. Sector medians from peer group analysis.Methodology & sources ↗
"""
return html
def _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)
# Read assumptions from session state (set by in-canvas JS sliders, defaulting on first load)
wacc_pct = float(st.session_state.get(f"dcf_wacc_{ctx['ticker']}", 10.0))
tg_pct = float(st.session_state.get(f"dcf_tg_{ctx['ticker']}", 2.5))
yrs = int(st.session_state.get(f"dcf_yrs_{ctx['ticker']}", 5))
g_pct = round(float(st.session_state.get(f"dcf_g_{ctx['ticker']}", round(slider_default, 1))), 1)
result = run_dcf(
fcf_series=ctx["fcf_series"],
shares_outstanding=ctx["shares"],
wacc=wacc_pct / 100,
terminal_growth=tg_pct / 100,
projection_years=yrs,
growth_rate_override=g_pct / 100,
total_debt=ctx["total_debt"],
cash_and_equivalents=ctx["cash_and_equivalents"],
preferred_equity=ctx["preferred_equity"],
minority_interest=ctx["minority_interest"],
base_fcf_override=ctx["base_fcf"],
)
if not result:
st.warning("Insufficient data to run DCF model.")
return
if result.get("error"):
st.warning(result["error"])
return
st.session_state["dcf_intrinsic"] = result["intrinsic_value_per_share"]
st.session_state[f"dcf_params_{ctx['ticker']}"] = {"wacc": wacc_pct, "tg": tg_pct, "yrs": yrs}
# Cross-check: run other models at their current market multiples
ev_ebitda_price = None
if ctx["ev_available"] and ctx.get("ev_ebitda_current"):
ev_r = run_ev_ebitda(
ebitda=float(ctx["ebitda"]),
total_debt=ctx["total_debt"],
total_cash=ctx["cash_and_equivalents"],
preferred_equity=ctx["preferred_equity"],
minority_interest=ctx["minority_interest"],
shares_outstanding=float(ctx["shares"]),
target_multiple=float(ctx["ev_ebitda_current"]),
)
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_html(
ctx, result, wacc_pct, tg_pct, yrs, g_pct,
ev_ebitda_price, ev_rev_price, pb_price,
)
components.html(canvas_html, height=1620, scrolling=False)
if st.button("Recompute", key=f"dcf_recompute_{ctx['ticker']}", type="secondary"):
get_free_cash_flow_ttm.clear()
get_balance_sheet_bridge_items.clear()
st.rerun()
def _render_multiples_model(ctx: dict):
st.markdown(_DCF_RAIL_CSS, unsafe_allow_html=True)
rail_col, canvas_col = st.columns([1, 4], gap="medium")
with rail_col:
st.markdown(
'Multiples'
'
Three relative-valuation lenses
'
'
Subject multiple × normalized TTM metric, bridged to equity per share.
',
unsafe_allow_html=True,
)
st.markdown('', unsafe_allow_html=True)
net_debt_raw = ctx["total_debt"] - ctx["cash_and_equivalents"]
ebitda_str = _fmt_b(ctx["ebitda"]) if ctx.get("ebitda") and ctx["ebitda"] > 0 else "—"
rev_str = _fmt_b(ctx["revenue_ttm"]) if ctx.get("revenue_ttm") and ctx["revenue_ttm"] > 0 else "—"
bps_str = f"${ctx['book_value_per_share']:.2f}" if ctx.get("book_value_per_share") and ctx["book_value_per_share"] > 0 else "—"
st.markdown(
'
From the filings
'
f'
EBITDA (TTM){ebitda_str}
'
f'
Revenue (TTM){rev_str}
'
f'
Book value / share{bps_str}
'
f'
Net debt{_fmt_b(net_debt_raw)}
'
f'
Shares outstanding{ctx["shares"] / 1e9:.2f} B
',
unsafe_allow_html=True,
)
st.markdown('', unsafe_allow_html=True)
if st.button("Refresh data", key=f"mult_refresh_{ctx['ticker']}", type="primary", width="stretch"):
get_balance_sheet_bridge_items.clear()
st.rerun()
canvas_html = _build_multiples_canvas_html(ctx)
with canvas_col:
components.html(canvas_html, height=1620, scrolling=False)
def _render_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)
if "models_view" not in st.session_state:
st.session_state["models_view"] = "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["models_view"] == "dcf" else "secondary",
width="stretch",
):
st.session_state["models_view"] = "dcf"
st.rerun()
with _pc2:
if st.button(
"Multiples",
key=f"pick_mult_{ticker}",
type="primary" if st.session_state["models_view"] == "multiples" else "secondary",
width="stretch",
):
st.session_state["models_view"] = "multiples"
st.rerun()
st.markdown("---")
view = st.session_state.get("models_view", "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):
import json as _json
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": "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),
"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": sym_i,
"name": (ci.get("longName") or ci.get("shortName") or sym_i)[:40],
"mcap": mcap_b,
"subject": sym_i == ticker.upper(),
}
for col in COLS:
key = col["key"]
field, scale = FIELD_MAP[key]
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": "—", "name": "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", "revG", "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(0.0, min(100.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()
name = (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 = _XMAP.get(raw_x, raw_x) or "—"
price_str = f"${price:.2f}" if price else "—"
n_peers = len(peers) - 1
data_json = _json.dumps({
"subject": sym,
"peers": peers,
"peerMedian": peer_median_row,
"cols": COLS,
"stats": stats,
"hero": hero,
"nPeers": n_peers,
})
total_height = 920 + n_peers * 54
ctx_html = (
'
' + str(n_peers) + ' names, one table — read across to see where ' + sym + ' sits
'
'
Peers sourced from FMP stock-peers or Prism sector fallback. '
'Subject pinned at top, followed by the peer median; the rest sort by any column. '
'Every numeric cell shows the value plus a track of where it sits in the column distribution.
'
'
'
'
'
'
Peer set'
'' + str(n_peers) + ' names'
'Sector · similar market cap
'
'
Tagging'
'Auto-matched'
'FMP peers · 6h cache
'
'
Period'
'TTM'
'Prices live · ratios T-1
'
'
'
''
)
hero_html = ''
table_html = (
''
'
'
'
Side by side'
'
Peer comparison · TTM ratios
'
'Click a column header to sort · dot in each cell shows column percentile'
'
'
''
''
)
foot_html = (
'
'
'Peer set sourced from FMP stock-peers or Prism sector fallback. '
'Market cap from yfinance. Ratios self-computed from TTM statements. '
'Distribution dot shows position within min↔max of the peer set.'
'
'
stat_row = (
_stat("Low", fmt_currency(low)) +
_stat("Mean", fmt_currency(mean_t), upside_str, upside_cls) +
_stat("Median", fmt_currency(median_t)) +
_stat("High", fmt_currency(high)) +
_stat("Upside to mean", upside_str if upside is not None else "—", delta_cls=upside_cls)
)
stat_html = f'
{stat_row}
'
# ── Build recommendation stacked 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():
if total > 0 and count > 0:
pct = count / total * 100
bar_segs += f''
pct_str = f"({count/total*100:.0f}%)" if total > 0 else "(0%)"
legend_items += (
f'
'
f''
f'{label}'
f'{count}'
f'{pct_str}'
f'
'
)
# ── Build full HTML document ──
doc = f"""
Analyst coverage
Where the street sets its sights — {total} analysts, one consensus
Price targets and recommendation breakdown as of the current reporting period. The range bar shows where current price sits relative to the analyst target spectrum.
Coverage{total} analystscurrent month
Mean target{fmt_currency(mean_t)}vs {fmt_currency(current)} current
Upside / downside{upside_str}to mean target
I
Price target range
Low · Current price · Mean target · High
{svg_html}
{stat_html}
{readout}
II
Recommendation breakdown
{total} analysts · current month
{bar_segs}
{legend_items}
{consensus_readout}
"""
components.html(doc, height=700, scrolling=False)
# ── Earnings History ──────────────────────────────────────────────────────────
def _render_earnings_history(ticker: str):
eh = get_earnings_history(ticker)
next_date = get_next_earnings_date(ticker)
if next_date:
st.info(f"Next earnings date: **{next_date}**")
if eh is None or eh.empty:
st.info("Earnings history unavailable for this ticker.")
return
st.markdown("**Historical EPS: Actual vs. Estimate**")
df = eh.copy().sort_index(ascending=False)
df.index = df.index.astype(str)
df.index.name = "Quarter"
display = pd.DataFrame(index=df.index)
display["EPS Actual"] = df["epsActual"].apply(fmt_currency)
display["EPS Estimate"] = df["epsEstimate"].apply(fmt_currency)
display["Surprise"] = df["epsDifference"].apply(
lambda v: f"{'+' if float(v) >= 0 else ''}{fmt_currency(v)}"
if pd.notna(v) else "—"
)
display["Surprise %"] = df["surprisePercent"].apply(
lambda v: f"{float(v) * 100:+.2f}%" if pd.notna(v) else "—"
)
def highlight_surprise(row):
try:
pct_str = row["Surprise %"].replace("%", "").replace("+", "")
val = float(pct_str)
color = "rgba(46,204,113,0.15)" if val >= 0 else "rgba(231,76,60,0.15)"
return ["", "", f"background-color: {color}", f"background-color: {color}"]
except Exception:
return [""] * len(row)
st.dataframe(
display.style.apply(highlight_surprise, axis=1),
width="stretch",
hide_index=False,
)
st.download_button(
"Download CSV",
display.to_csv().encode(),
file_name=f"{ticker.upper()}_earnings_history.csv",
mime="text/csv",
key=f"dl_earnings_{ticker}",
)
# EPS chart — oldest to newest
df_chart = eh.sort_index()
fig = go.Figure()
fig.add_trace(go.Scatter(
x=df_chart.index.astype(str),
y=df_chart["epsActual"],
name="Actual EPS",
mode="lines+markers",
line=dict(color="#C2AA7A", width=2),
))
fig.add_trace(go.Scatter(
x=df_chart.index.astype(str),
y=df_chart["epsEstimate"],
name="Estimated EPS",
mode="lines+markers",
line=dict(color="#C49545", width=2, dash="dash"),
))
fig.update_layout(
title="EPS: Actual vs. Estimate",
yaxis_title="EPS ($)",
plot_bgcolor="rgba(0,0,0,0)",
paper_bgcolor="rgba(0,0,0,0)",
margin=dict(l=0, r=0, t=40, b=0),
height=280,
legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1),
)
st.plotly_chart(fig, width="stretch")
# ── 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):
import json as _json
info = get_company_info(ticker)
hist_rows = get_historical_ratios(ticker, limit=10)
if not hist_rows:
st.info("Historical ratio data unavailable.")
return
rows_sorted = sorted(hist_rows, key=lambda r: str(r.get("date", "")))
periods = []
for r in rows_sorted:
y = str(r.get("date", ""))[:4]
periods.append(f"FY{y[2:]}" if len(y) == 4 else y)
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"),
]
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:
series_data.append({"key": key, "group": group, "lbl": lbl, "kind": kind, "subj": vals})
if not series_data:
st.info("No plottable ratio data available.")
return
price = get_latest_price(ticker)
prev_close = info.get("previousClose") if info else None
if price and prev_close and prev_close > 0:
chg_pct = (price - prev_close) / prev_close * 100
chg_str = f"{'▲' if chg_pct >= 0 else '▼'} {'+' if chg_pct >= 0 else ''}{chg_pct:.2f}%"
chg_cls = "chg-pos" if chg_pct >= 0 else "chg-neg"
else:
chg_str, chg_cls = "—", ""
sym = ticker.upper()
name = (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 = _XMAP.get(raw_x, raw_x) or "—"
price_str = f"${price:.2f}" if price else "—"
n_periods = len(periods)
n_rows = len(series_data)
n_groups = len({s["group"] for s in series_data})
total_height = 48 + 24 + 180 + 24 + 420 + 24 + (68 + n_groups * 50 + n_rows * 42) + 24 + 60 + 60
data_json = _json.dumps({"periods": periods, "series": series_data})
ctx_html = (
f'
'
f'{sym}'
f'{name}'
f'Valuation · Historical Ratios'
f'
'
f'{exchange}'
f'{price_str}'
f'{chg_str}'
f'
'
)
lede_html = (
f''
f'
'
f'Drift'
f'
{n_periods} periods of every ratio — pick a line, the heatmap follows
'
f'
Annual ratios from {periods[0]} through {periods[-1]}. '
f'Click any row in the matrix to plot it in the hero chart above. '
f'Cell shading shows each ratio's relative position within its own history.
'
f'
'
f'
'
f'
'
f'{sym}'
f'
'
f'
'
f'Window'
f'
'
f''
f''
f''
f'
'
f'
'
f'
'
f''
)
hero_html = (
''
'
'
'
'
''
''
'
'
'
'
'
Latest—
'
'
Avg—
'
'
Range—
'
'
'
'
'
'
'
''
)
matrix_html = (
''
'
'
'
Ratio matrix
'
'Click a row to chart it · shading shows relative position within row history'
'
'
''
''
''
)
foot_html = (
'
'
'Ratios computed from yfinance annual income statements, balance sheets, and 10-year price history. '
'Price-based multiples use average price in a ±45-day window around each fiscal year-end.'
'
'
def _build_est_table_html(rows: list[dict], is_annual: bool = True) -> str:
"""Build HTML table body for estimates."""
if not rows:
return ""
all_rev_lo = [r.get("rev_lo") for r in rows if r.get("rev_lo")]
all_rev_hi = [r.get("rev_hi") for r in rows if r.get("rev_hi")]
all_eps_lo = [r.get("eps_lo") for r in rows if r.get("eps_lo")]
all_eps_hi = [r.get("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(f'
{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 from income statement
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()))
# Compute 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
# Revenue CAGR
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
# Narrative readout
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."
# Build 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_json = json.dumps({
"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 "—"
# Format lede values
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 "—"
qtr_section = f'
What {max_analysts} analysts project for the years ahead
Annual consensus estimates sourced from Financial Modeling Prep. The revenue chart shows historical revenue alongside the analyst range — dashed line is the average estimate, the band spans the bull-to-bear spectrum.