aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorTyler <tyler@tylerhoang.xyz>2026-04-02 00:10:06 -0700
committerTyler <tyler@tylerhoang.xyz>2026-04-02 00:10:06 -0700
commit7a267bc3c28bc7a77e84eaa400667a7b4c0d5adf (patch)
tree51b65d0ad1f1eaa1f276372a48cb319529284bb9
parent3806bd3b4d69917f3f5312acfa57bc4ee2886a49 (diff)
Refactor valuation models tab
-rw-r--r--components/valuation.py528
-rw-r--r--services/data_service.py16
-rw-r--r--services/fmp_service.py13
-rw-r--r--services/valuation_service.py49
4 files changed, 491 insertions, 115 deletions
diff --git a/components/valuation.py b/components/valuation.py
index 72c8001..37a964d 100644
--- a/components/valuation.py
+++ b/components/valuation.py
@@ -1,4 +1,4 @@
-"""Valuation panel — key ratios, DCF model, comparable companies, analyst targets, earnings history."""
+"""Valuation panel — key ratios, models, comparable companies, analyst targets, earnings history."""
import pandas as pd
import plotly.graph_objects as go
import streamlit as st
@@ -8,6 +8,7 @@ from services.data_service import (
get_shares_outstanding,
get_market_cap_computed,
get_free_cash_flow_series,
+ get_revenue_ttm,
get_balance_sheet_bridge_items,
get_analyst_price_targets,
get_recommendations_summary,
@@ -22,7 +23,13 @@ from services.fmp_service import (
get_historical_key_metrics,
get_analyst_estimates,
)
-from services.valuation_service import run_dcf, run_ev_ebitda, compute_historical_growth_rate
+from services.valuation_service import (
+ run_dcf,
+ run_ev_ebitda,
+ run_ev_revenue,
+ run_price_to_book,
+ compute_historical_growth_rate,
+)
from utils.formatters import fmt_ratio, fmt_pct, fmt_large, fmt_currency
@@ -90,17 +97,25 @@ def _suggest_peer_tickers(ticker: str, info: dict) -> list[str]:
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 render_valuation(ticker: str):
tabs = st.tabs([
"Key Ratios",
"Historical Ratios",
- "DCF Model",
+ "Models",
"Comps",
"Forward Estimates",
"Analyst Targets",
"Earnings History",
])
- tab_ratios, tab_hist, tab_dcf, tab_comps, tab_fwd, tab_analyst, tab_earnings = tabs
+ tab_ratios, tab_hist, tab_models, tab_comps, tab_fwd, tab_analyst, tab_earnings = tabs
with tab_ratios:
_render_ratios(ticker)
@@ -111,8 +126,8 @@ def render_valuation(ticker: str):
except Exception as e:
st.error(f"Historical ratios unavailable: {e}")
- with tab_dcf:
- _render_dcf(ticker)
+ with tab_models:
+ _render_models(ticker)
with tab_comps:
_render_comps(ticker)
@@ -288,58 +303,205 @@ def _render_ratios(ticker: str):
st.write("")
-# ── DCF Model ────────────────────────────────────────────────────────────────
+# ── Models ───────────────────────────────────────────────────────────────────
def _net_debt_label(value: float) -> str:
return "Net Cash" if value < 0 else "Net Debt"
-def _render_dcf(ticker: str):
+def _build_model_context(ticker: str) -> dict:
info = get_company_info(ticker)
-
- if _is_financial_company(info):
- st.warning(
- "DCF is disabled for financial companies in Prism. Free-cash-flow and capital-structure "
- "assumptions are not directly comparable for banks, insurers, and similar businesses."
- )
- st.caption(
- "Use ratios, comps, earnings history, and analyst targets instead. A bank-specific valuation "
- "framework can be added later."
- )
- return
-
+ 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 = bridge_items["total_debt"]
- cash_and_equivalents = bridge_items["cash_and_equivalents"]
- preferred_equity = bridge_items["preferred_equity"]
- minority_interest = bridge_items["minority_interest"]
+ 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 not shares:
- st.info("Shares outstanding not available — DCF cannot be computed.")
- return
+ 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)
- fcf_series = get_free_cash_flow_series(ticker)
- if fcf_series.empty:
- st.info("Free cash flow data unavailable.")
- return
+ base_fcf = float(fcf_series.iloc[-1]) if not fcf_series.empty else None
+ hist_growth = compute_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
+ if market_cap and market_cap > 0 and ebitda and ebitda > 0:
+ ev_value = float(market_cap) + total_debt - cash_and_equivalents
+ 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
+
+ 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
- # Compute historical growth rate for slider default + caption reference
- hist_growth = compute_historical_growth_rate(fcf_series)
+ 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,
+ "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):
+ st.markdown("**Applicable models**")
+ cols = st.columns(4)
+ cards = [
+ ("DCF", ctx["dcf_available"], ctx["dcf_reason"]),
+ ("EV/EBITDA", ctx["ev_available"], ctx["ev_reason"]),
+ ("EV/Revenue", ctx["ev_revenue_available"], ctx["ev_revenue_reason"]),
+ ("P/B", ctx["pb_available"], ctx["pb_reason"]),
+ ]
+ for col, (label, available, reason) in zip(cols, cards):
+ col.markdown(f"**{label}**")
+ col.caption("Available" if available else "Not suitable")
+ col.write(reason)
+
+
+def _render_dcf_model(ctx: dict):
+ st.markdown("**Discounted Cash Flow (DCF)**")
+
+ hist_growth = ctx["hist_growth"]
hist_growth_pct = hist_growth * 100 if hist_growth is not None else 5.0
slider_default = float(max(-20.0, min(30.0, hist_growth_pct)))
- st.markdown("**Assumptions**")
+ st.caption(
+ "Firm-value DCF works best for operating companies with positive, reasonably stable free cash flow."
+ )
+
col1, col2, col3, col4 = st.columns(4)
with col1:
- wacc = st.slider("WACC (%)", min_value=5.0, max_value=20.0, value=10.0, step=0.5) / 100
+ wacc = st.slider(
+ "WACC (%)",
+ min_value=5.0,
+ max_value=20.0,
+ value=10.0,
+ step=0.5,
+ key=f"dcf_wacc_{ctx['ticker']}",
+ ) / 100
with col2:
terminal_growth = st.slider(
- "Terminal Growth (%)", min_value=0.5, max_value=5.0, value=2.5, step=0.5
+ "Terminal Growth (%)",
+ min_value=0.5,
+ max_value=5.0,
+ value=2.5,
+ step=0.5,
+ key=f"dcf_terminal_{ctx['ticker']}",
) / 100
with col3:
- projection_years = st.slider("Projection Years", min_value=3, max_value=10, value=5, step=1)
+ projection_years = st.slider(
+ "Projection Years",
+ min_value=3,
+ max_value=10,
+ value=5,
+ step=1,
+ key=f"dcf_years_{ctx['ticker']}",
+ )
with col4:
fcf_growth_pct = st.slider(
"FCF Growth (%)",
@@ -348,21 +510,22 @@ def _render_dcf(ticker: str):
value=round(slider_default, 1),
step=0.5,
help=f"Historical median: {hist_growth_pct:.1f}%. Drag to override.",
+ key=f"dcf_growth_{ctx['ticker']}",
)
st.caption(f"Historical FCF growth (median): **{hist_growth_pct:.1f}%**")
result = run_dcf(
- fcf_series=fcf_series,
- shares_outstanding=shares,
+ fcf_series=ctx["fcf_series"],
+ shares_outstanding=ctx["shares"],
wacc=wacc,
terminal_growth=terminal_growth,
projection_years=projection_years,
growth_rate_override=fcf_growth_pct / 100,
- total_debt=total_debt,
- cash_and_equivalents=cash_and_equivalents,
- preferred_equity=preferred_equity,
- minority_interest=minority_interest,
+ total_debt=ctx["total_debt"],
+ cash_and_equivalents=ctx["cash_and_equivalents"],
+ preferred_equity=ctx["preferred_equity"],
+ minority_interest=ctx["minority_interest"],
)
if not result:
@@ -373,6 +536,7 @@ def _render_dcf(ticker: str):
return
iv = result["intrinsic_value_per_share"]
+ current_price = ctx["current_price"]
m1, m2, m3, m4 = st.columns(4)
m1.metric("Equity Value / Share", fmt_currency(iv))
@@ -382,7 +546,7 @@ def _render_dcf(ticker: str):
m3.metric("Upside / Downside", f"{upside * 100:+.1f}%", delta=f"{upside * 100:+.1f}%")
m4.metric("FCF Growth Used", f"{result['growth_rate_used'] * 100:.1f}%")
- source_date = bridge_items.get("source_date")
+ source_date = ctx["bridge_items"].get("source_date")
st.caption(
"DCF is modeled on firm-level free cash flow, so enterprise value is bridged to equity value "
"using debt and cash from the most recent balance sheet before calculating per-share value."
@@ -402,10 +566,10 @@ def _render_dcf(ticker: str):
)
bridge_a, bridge_b, bridge_c, bridge_d = st.columns(4)
- bridge_a.metric("Total Debt", fmt_large(total_debt))
- bridge_b.metric("Cash & Equivalents", fmt_large(cash_and_equivalents))
- bridge_c.metric("Preferred Equity", fmt_large(preferred_equity))
- bridge_d.metric("Minority Interest", fmt_large(minority_interest))
+ bridge_a.metric("Total Debt", fmt_large(ctx["total_debt"]))
+ bridge_b.metric("Cash & Equivalents", fmt_large(ctx["cash_and_equivalents"]))
+ bridge_c.metric("Preferred Equity", fmt_large(ctx["preferred_equity"]))
+ bridge_d.metric("Minority Interest", fmt_large(ctx["minority_interest"]))
bridge1, bridge2, bridge3, bridge4 = st.columns(4)
bridge1.metric("Enterprise Value", fmt_large(result["enterprise_value"]))
@@ -436,74 +600,220 @@ def _render_dcf(ticker: str):
)
st.plotly_chart(fig, use_container_width=True)
- # ── EV/EBITDA Valuation ───────────────────────────────────────────────────
- st.divider()
+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."
+ )
- # Use TTM EBITDA from compute_ttm_ratios — same source as Key Ratios tab
- ratios_data = get_key_ratios(ticker)
- ebitda = ratios_data.get("ebitdaTTM")
- ev_bridge_items = get_balance_sheet_bridge_items(ticker)
- total_debt = ev_bridge_items["total_debt"]
- total_cash = ev_bridge_items["cash_and_equivalents"]
+ 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)))
- market_cap = get_market_cap_computed(ticker)
- ev_val = None
- if market_cap and ebitda and ebitda > 0:
- ev_val = float(market_cap) + float(total_debt or 0.0) - float(total_cash or 0.0)
- ev_ebitda_current = (ev_val / ebitda) if (ev_val and ebitda and ebitda > 0) else None
+ 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"],
+ shares_outstanding=float(ctx["shares"]),
+ target_multiple=target_multiple,
+ )
- if not ebitda or ebitda <= 0:
- st.info("EBITDA not available or negative — EV/EBITDA valuation cannot be computed.")
+ 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"]
+ 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"]))
+ st.caption(
+ f"EBITDA: {fmt_large(ctx['ebitda'])} · "
+ f"{_net_debt_label(ev_result['net_debt'])}: {fmt_large(abs(ev_result['net_debt']))} · "
+ 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}**")
+
+
+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"],
+ 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"]
+ 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"]))
+ 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"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}**")
+
+
+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:
- default_multiple = float(ev_ebitda_current) if ev_ebitda_current else 15.0
- default_multiple = max(1.0, min(50.0, round(default_multiple, 1)))
+ 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']}",
+ )
- ev_col1, ev_col2 = st.columns([1, 3])
- with ev_col1:
- help_text = (
- f"Current market multiple: {ev_ebitda_current:.1f}x"
- if 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,
- )
+ 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
- ev_result = run_ev_ebitda(
- ebitda=float(ebitda),
- total_debt=float(total_debt),
- total_cash=float(total_cash),
- shares_outstanding=float(shares),
- target_multiple=target_multiple,
+ 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 "—")
- if ev_result:
- imp_price = ev_result["implied_price_per_share"]
- ev_m1, ev_m2, ev_m3, ev_m4 = st.columns(4)
- ev_m1.metric("Implied Price (EV/EBITDA)", 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"]))
- st.caption(
- f"EBITDA: {fmt_large(ebitda)} · "
- f"{_net_debt_label(ev_result['net_debt'])}: {fmt_large(abs(ev_result['net_debt']))} · "
- f"Equity Value: {fmt_large(ev_result['equity_value'])}"
- )
- if ev_bridge_items.get("source_date"):
- st.caption(f"EV/EBITDA bridge source date: **{ev_bridge_items['source_date']}**")
- else:
- st.warning("Could not compute EV/EBITDA valuation.")
+ 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)
+
+ sections = []
+ if ctx["is_financial"] and ctx["pb_available"]:
+ sections.append(_render_price_to_book_model)
+ if ctx["dcf_available"]:
+ sections.append(_render_dcf_model)
+ if ctx["ev_available"]:
+ sections.append(_render_ev_ebitda_model)
+ if ctx["ev_revenue_available"] and not ctx["is_financial"]:
+ sections.append(_render_ev_revenue_model)
+ if ctx["pb_available"] and _render_price_to_book_model not in sections and not ctx["dcf_available"]:
+ sections.append(_render_price_to_book_model)
+
+ if not sections:
+ st.info("No valuation model is currently applicable for this company.")
+ st.caption("Use comps, ratios, earnings history, and analyst targets instead.")
+ else:
+ for i, render_section in enumerate(sections):
+ if i > 0:
+ st.divider()
+ render_section(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 hidden", expanded=False):
+ st.markdown("\n".join(unavailable))
# ── Comps Table ──────────────────────────────────────────────────────────────
@@ -870,8 +1180,10 @@ def _render_historical_ratios(ticker: str):
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 in {"P/B", "Debt/Equity"}:
+ 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 = []
diff --git a/services/data_service.py b/services/data_service.py
index c278a2f..e3f46cc 100644
--- a/services/data_service.py
+++ b/services/data_service.py
@@ -213,6 +213,22 @@ def get_insider_transactions(ticker: str) -> pd.DataFrame:
@st.cache_data(ttl=3600)
+def get_revenue_ttm(ticker: str) -> float | None:
+ """Return trailing-twelve-month revenue from the last 4 reported quarters."""
+ try:
+ t = yf.Ticker(ticker.upper())
+ inc_q = t.quarterly_income_stmt
+ if inc_q is None or inc_q.empty or "Total Revenue" not in inc_q.index:
+ return None
+ vals = inc_q.loc["Total Revenue"].iloc[:4].dropna()
+ if len(vals) != 4:
+ return None
+ return float(vals.sum())
+ except Exception:
+ return None
+
+
+@st.cache_data(ttl=3600)
def compute_ttm_ratios(ticker: str) -> dict:
"""Compute all key financial ratios from raw yfinance quarterly statements.
diff --git a/services/fmp_service.py b/services/fmp_service.py
index 82a9c4c..914c14d 100644
--- a/services/fmp_service.py
+++ b/services/fmp_service.py
@@ -80,12 +80,13 @@ def get_key_ratios(ticker: str) -> dict:
if merged.get("dividendYieldTTM") is None and info.get("dividendYield") is not None:
merged["dividendYieldTTM"] = info["dividendYield"]
payout_ratio_info = info.get("payoutRatio")
- if (
- merged.get("dividendPayoutRatioTTM") is None
- and payout_ratio_info is not None
- and float(payout_ratio_info) > 0
- ):
- merged["dividendPayoutRatioTTM"] = payout_ratio_info
+ if merged.get("dividendPayoutRatioTTM") is None and payout_ratio_info is not None:
+ try:
+ payout_ratio_value = float(payout_ratio_info)
+ except (TypeError, ValueError):
+ payout_ratio_value = None
+ if payout_ratio_value is not None and payout_ratio_value > 0:
+ merged["dividendPayoutRatioTTM"] = payout_ratio_value
return merged if len(merged) > 1 else {}
diff --git a/services/valuation_service.py b/services/valuation_service.py
index 8559842..357c679 100644
--- a/services/valuation_service.py
+++ b/services/valuation_service.py
@@ -1,4 +1,4 @@
-"""Valuation engines for DCF and EV/EBITDA."""
+"""Valuation engines for DCF, EV/EBITDA, EV/Revenue, and simple multiple-based models."""
import numpy as np
import pandas as pd
@@ -158,3 +158,50 @@ def run_ev_ebitda(
"implied_price_per_share": equity_value / shares_outstanding,
"target_multiple_used": target_multiple,
}
+
+
+def run_ev_revenue(
+ revenue: float,
+ total_debt: float,
+ total_cash: float,
+ shares_outstanding: float,
+ target_multiple: float,
+) -> dict:
+ """Derive implied equity value per share from an EV/Revenue multiple."""
+ if not revenue or revenue <= 0:
+ return {}
+ if not shares_outstanding or shares_outstanding <= 0:
+ return {}
+ if not target_multiple or target_multiple <= 0:
+ return {}
+
+ implied_ev = revenue * target_multiple
+ net_debt = (total_debt or 0.0) - (total_cash or 0.0)
+ equity_value = implied_ev - net_debt
+
+ return {
+ "implied_ev": implied_ev,
+ "net_debt": net_debt,
+ "equity_value": equity_value,
+ "implied_price_per_share": equity_value / shares_outstanding,
+ "target_multiple_used": target_multiple,
+ "revenue_used": revenue,
+ }
+
+
+def run_price_to_book(
+ book_value_per_share: float,
+ target_multiple: float,
+) -> dict:
+ """Derive implied equity value per share from a P/B multiple."""
+ if not book_value_per_share or book_value_per_share <= 0:
+ return {}
+ if not target_multiple or target_multiple <= 0:
+ return {}
+
+ implied_price = float(book_value_per_share) * float(target_multiple)
+ return {
+ "implied_price_per_share": implied_price,
+ "target_multiple_used": float(target_multiple),
+ "book_value_per_share": float(book_value_per_share),
+ }