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)
if st.button("Recompute", key=f"dcf_recompute_{ctx['ticker']}", type="primary"):
get_free_cash_flow_ttm.clear()
get_balance_sheet_bridge_items.clear()
st.rerun()
# 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)
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", use_container_width=True):
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), use_container_width=True, 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), use_container_width=True, 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",
use_container_width=True,
):
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",
use_container_width=True,
):
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 ──────────────────────────────────────────────────────────────
def _render_comps(ticker: str):
info = get_company_info(ticker)
auto_peers = get_peers(ticker)
suggested_peers = _suggest_peer_tickers(ticker, info)
default_peer_string = ", ".join(auto_peers or suggested_peers)
manual_peer_string = st.text_input(
"Peer tickers",
value=default_peer_string,
help="Edit the comparable-company set manually. Comma-separated tickers.",
key=f"peer_input_{ticker.upper()}",
)
if auto_peers:
st.caption("Using FMP-discovered peers.")
elif suggested_peers:
st.caption("Using Prism fallback peers based on sector/industry. Edit them if you want a tighter comp set.")
else:
st.caption("No automatic peer set found. Enter peer tickers manually to build a comps table.")
manual_peers = [p.strip().upper() for p in manual_peer_string.split(",") if p.strip()]
peer_list = []
seen = {ticker.upper()}
for peer in manual_peers:
if peer not in seen:
peer_list.append(peer)
seen.add(peer)
all_tickers = [ticker.upper()] + peer_list[:9]
with st.spinner("Loading comps..."):
ratios_list = get_ratios_for_tickers(all_tickers)
if not ratios_list:
st.info("Could not load ratios for the selected peer companies.")
return
display_cols = {
"symbol": "Ticker",
"peRatioTTM": "P/E",
"priceToSalesRatioTTM": "P/S",
"priceToBookRatioTTM": "P/B",
"enterpriseValueMultipleTTM": "EV/EBITDA",
"evToEBITDATTM": "EV/EBITDA",
"netProfitMarginTTM": "Net Margin",
"returnOnEquityTTM": "ROE",
"debtToEquityRatioTTM": "D/E",
}
df = pd.DataFrame(ratios_list)
if "enterpriseValueMultipleTTM" not in df.columns and "evToEBITDATTM" in df.columns:
df["enterpriseValueMultipleTTM"] = df["evToEBITDATTM"]
if "debtToEquityRatioTTM" not in df.columns and "debtEquityRatioTTM" in df.columns:
df["debtToEquityRatioTTM"] = df["debtEquityRatioTTM"]
available = [c for c in ["symbol", "peRatioTTM", "priceToSalesRatioTTM", "priceToBookRatioTTM", "enterpriseValueMultipleTTM", "netProfitMarginTTM", "returnOnEquityTTM", "debtToEquityRatioTTM"] if c in df.columns]
df = df[available].rename(columns=display_cols)
def _format_comp_value(column: str, value):
if value is None:
return "—"
try:
v = float(value)
except (TypeError, ValueError):
return "—"
if column == "P/E":
return fmt_ratio(v) if v > 0 else "N/M (neg. earnings)"
if column == "P/B":
return fmt_ratio(v) if v > 0 else "N/M (neg. equity)"
if column == "EV/EBITDA":
return fmt_ratio(v) if v > 0 else "N/M (neg. EBITDA)"
if column == "D/E":
return fmt_ratio(v) if v >= 0 else "N/M (neg. equity)"
if column in {"Net Margin", "ROE"}:
return fmt_pct(v)
return fmt_ratio(v) if v > 0 else "—"
for col in df.columns:
if col == "Ticker":
continue
df[col] = df[col].apply(lambda v, c=col: _format_comp_value(c, v))
def highlight_subject(row):
if row["Ticker"] == ticker.upper():
return ["background-color: rgba(79,142,247,0.15)"] * len(row)
return [""] * len(row)
st.dataframe(
df.style.apply(highlight_subject, axis=1),
use_container_width=True,
hide_index=True,
)
# ── Analyst Targets ──────────────────────────────────────────────────────────
def _render_analyst_targets(ticker: str):
targets = get_analyst_price_targets(ticker)
recs = get_recommendations_summary(ticker)
if not targets and (recs is None or recs.empty):
st.info("Analyst data unavailable for this ticker.")
return
if targets:
st.markdown("**Analyst Price Targets**")
current = targets.get("current")
mean_t = targets.get("mean")
t1, t2, t3, t4, t5 = st.columns(5)
t1.metric("Low", fmt_currency(targets.get("low")))
t2.metric("Mean", fmt_currency(mean_t))
t3.metric("Median", fmt_currency(targets.get("median")))
t4.metric("High", fmt_currency(targets.get("high")))
if current and mean_t:
upside = (mean_t - current) / current
t5.metric("Upside to Mean", f"{upside * 100:+.1f}%", delta=f"{upside * 100:+.1f}%")
else:
t5.metric("Current Price", fmt_currency(current))
st.write("")
if recs is not None and not recs.empty:
st.markdown("**Analyst Recommendations (Current Month)**")
current_row = recs[recs["period"] == "0m"] if "period" in recs.columns else pd.DataFrame()
if current_row.empty:
current_row = recs.iloc[[0]]
row = current_row.iloc[0]
counts = {
"Strong Buy": int(row.get("strongBuy", 0)),
"Buy": int(row.get("buy", 0)),
"Hold": int(row.get("hold", 0)),
"Sell": int(row.get("sell", 0)),
"Strong Sell": int(row.get("strongSell", 0)),
}
total = sum(counts.values())
cols = st.columns(5)
for col, (label, count) in zip(cols, counts.items()):
pct = f"{count / total * 100:.0f}%" if total > 0 else "—"
col.metric(label, str(count), delta=pct, delta_color="off")
st.write("")
colors = ["#4F8C5E", "#4F8C5E", "#C49545", "#8F7A50", "#B5494B"]
fig = go.Figure(go.Bar(
x=list(counts.keys()),
y=list(counts.values()),
marker_color=colors,
text=list(counts.values()),
textposition="outside",
))
fig.update_layout(
title="Analyst Recommendation Distribution",
yaxis_title="# Analysts",
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,
)
st.plotly_chart(fig, use_container_width=True)
# ── 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),
use_container_width=True,
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, use_container_width=True)
# ── 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
def _render_historical_ratios(ticker: str):
with st.spinner("Loading historical ratios…"):
ratio_rows = get_historical_ratios(ticker)
metric_rows = get_historical_key_metrics(ticker)
if not ratio_rows and not metric_rows:
st.info("Historical ratio data unavailable.")
return
# Merge both lists by date
combined: dict[str, dict] = {}
for row in ratio_rows + metric_rows:
date = str(row.get("date", ""))[:4]
if date:
combined.setdefault(date, {}).update(row)
merged_rows = [{"date": d, **v} for d, v in sorted(combined.items(), reverse=True)]
selected = st.multiselect(
"Metrics to plot",
options=list(_HIST_RATIO_OPTIONS.keys()),
default=["P/E", "EV/EBITDA", "Net Margin", "ROE"],
)
if not selected:
st.info("Select at least one metric to plot.")
return
fig = go.Figure()
for i, label in enumerate(selected):
primary, alt, fmt = _HIST_RATIO_OPTIONS[label]
series = _extract_hist_series(merged_rows, primary, alt)
if not series:
continue
years = sorted(series.keys())
values = [series[y] * (100 if fmt == "pct" else 1) for y in years]
y_label = f"{label} (%)" if fmt == "pct" else label
fig.add_trace(go.Scatter(
x=years,
y=values,
name=y_label,
mode="lines+markers",
line=dict(color=_CHART_COLORS[i % len(_CHART_COLORS)], width=2),
))
fig.update_layout(
title="Historical Ratios & Metrics",
xaxis_title="Year",
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=380,
legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1),
hovermode="x unified",
)
st.plotly_chart(fig, use_container_width=True)
# Raw data table
with st.expander("Raw data"):
display_cols = {}
for label in selected:
primary, alt, fmt = _HIST_RATIO_OPTIONS[label]
display_cols[label] = (primary, alt, fmt)
def _format_hist_value(label: str, value, fmt: str | None) -> str:
if value is None:
return "—"
try:
v = float(value)
except (TypeError, ValueError):
return "—"
if fmt == "pct":
return f"{v * 100:.2f}%"
if label == "P/E":
return f"{v:.2f}x" if v > 0 else "N/M (neg. earnings)"
if label == "EV/EBITDA":
return f"{v:.2f}x" if v > 0 else "N/M (neg. EBITDA)"
if label == "P/B":
return f"{v:.2f}x" if v > 0 else "N/M (neg. equity)"
if label == "Debt/Equity":
return f"{v:.2f}x" if v >= 0 else "N/M (neg. equity)"
return f"{v:.2f}x" if v > 0 else "—"
table_rows = []
for row in merged_rows:
r: dict = {"Year": str(row.get("date", ""))[:4]}
for label, (primary, alt, fmt) in display_cols.items():
val = row.get(primary) or (row.get(alt) if alt else None)
r[label] = _format_hist_value(label, val, fmt)
table_rows.append(r)
if table_rows:
st.dataframe(pd.DataFrame(table_rows), use_container_width=True, hide_index=True)
# ── Forward Estimates ────────────────────────────────────────────────────────
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
info = get_company_info(ticker)
current_price = get_latest_price(ticker)
tab_ann, tab_qtr = st.tabs(["Annual", "Quarterly"])
def _build_estimates_table(rows: list[dict]) -> pd.DataFrame:
table = []
for row in sorted(rows, key=lambda r: str(r.get("date", ""))):
date = str(row.get("date", ""))[:7]
# FMP stable endpoint uses revenueAvg / epsAvg (no "estimated" prefix)
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")
num_analysts = row.get("numAnalystsRevenue") or row.get("numAnalystsEps") or row.get("numberAnalystEstimatedRevenue") or row.get("numberAnalysts")
table.append({
"Period": date,
"Rev Low": fmt_large(rev_lo) if rev_lo else "—",
"Rev Avg": fmt_large(rev_avg) if rev_avg else "—",
"Rev High": fmt_large(rev_hi) if rev_hi else "—",
"EPS Low": fmt_currency(eps_lo) if eps_lo else "—",
"EPS Avg": fmt_currency(eps_avg) if eps_avg else "—",
"EPS High": fmt_currency(eps_hi) if eps_hi else "—",
"EBITDA Avg": fmt_large(ebitda_avg) if ebitda_avg else "—",
"# Analysts": str(int(num_analysts)) if num_analysts else "—",
})
return pd.DataFrame(table)
def _render_eps_chart(rows: list[dict], title: str):
"""Overlay historical EPS actuals with forward estimates."""
eh = get_earnings_history(ticker)
fwd_dates, fwd_eps = [], []
for row in sorted(rows, key=lambda r: str(r.get("date", ""))):
date = str(row.get("date", ""))[:7]
eps = 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")
if eps is not None:
fwd_dates.append(date)
fwd_eps.append(float(eps))
fig = go.Figure()
if eh is not None and not eh.empty:
hist = eh.sort_index()
fig.add_trace(go.Scatter(
x=hist.index.astype(str),
y=hist["epsActual"],
name="EPS Actual",
mode="lines+markers",
line=dict(color="#C2AA7A", width=2),
))
if fwd_dates:
# Low/high band
fwd_lo = [float(r.get("epsLow") or r.get("estimatedEpsLow")) for r in sorted(rows, key=lambda r: str(r.get("date", "")))
if (r.get("epsLow") or r.get("estimatedEpsLow")) is not None]
fwd_hi = [float(r.get("epsHigh") or r.get("estimatedEpsHigh")) for r in sorted(rows, key=lambda r: str(r.get("date", "")))
if (r.get("epsHigh") or r.get("estimatedEpsHigh")) is not None]
if fwd_lo and fwd_hi and len(fwd_lo) == len(fwd_dates):
fig.add_trace(go.Scatter(
x=fwd_dates + fwd_dates[::-1],
y=fwd_hi + fwd_lo[::-1],
fill="toself",
fillcolor="rgba(247,162,79,0.15)",
line=dict(color="rgba(0,0,0,0)"),
name="Est. Range",
hoverinfo="skip",
))
fig.add_trace(go.Scatter(
x=fwd_dates,
y=fwd_eps,
name="EPS Est. (Avg)",
mode="lines+markers",
line=dict(color="#C49545", width=2, dash="dash"),
))
fig.update_layout(
title=title,
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=320,
hovermode="x unified",
legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1),
)
st.plotly_chart(fig, use_container_width=True)
with tab_ann:
if annual:
df = _build_estimates_table(annual)
st.dataframe(df, use_container_width=True, hide_index=True)
st.write("")
_render_eps_chart(annual, "Annual EPS: Historical Actuals + Forward Estimates")
else:
st.info("No annual estimates available.")
with tab_qtr:
if quarterly:
df = _build_estimates_table(quarterly)
st.dataframe(df, use_container_width=True, hide_index=True)
st.write("")
_render_eps_chart(quarterly, "Quarterly EPS: Historical Actuals + Forward Estimates")
else:
st.info("No quarterly estimates available.")