diff options
| author | Tyler <tyler@tylerhoang.xyz> | 2026-04-02 00:10:06 -0700 |
|---|---|---|
| committer | Tyler <tyler@tylerhoang.xyz> | 2026-04-02 00:10:06 -0700 |
| commit | 7a267bc3c28bc7a77e84eaa400667a7b4c0d5adf (patch) | |
| tree | 51b65d0ad1f1eaa1f276372a48cb319529284bb9 /components/valuation.py | |
| parent | 3806bd3b4d69917f3f5312acfa57bc4ee2886a49 (diff) | |
Refactor valuation models tab
Diffstat (limited to 'components/valuation.py')
| -rw-r--r-- | components/valuation.py | 528 |
1 files changed, 420 insertions, 108 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 = [] |
