"""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 from services.data_service import ( get_company_info, get_latest_price, 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, get_earnings_history, get_next_earnings_date, ) from services.fmp_service import ( get_key_ratios, get_peers, get_ratios_for_tickers, get_historical_ratios, get_historical_key_metrics, get_analyst_estimates, ) from services.valuation_service import ( run_dcf, run_ev_ebitda, run_ev_revenue, run_price_to_book, compute_historical_growth_rate, ) from utils.formatters import fmt_ratio, fmt_pct, fmt_large, fmt_currency FINANCIAL_SECTORS = {"Financial Services"} FINANCIAL_INDUSTRY_KEYWORDS = ( "bank", "insurance", "asset management", "capital markets", "financial data", "credit services", "mortgage", "reit", ) INDUSTRY_PEER_MAP = { "consumer electronics": ["AAPL", "SONY", "DELL", "HPQ", "LOGI"], "software - infrastructure": ["MSFT", "ORCL", "CRM", "NOW", "SNOW"], "semiconductors": ["NVDA", "AMD", "AVGO", "QCOM", "INTC"], "internet content & information": ["GOOGL", "META", "PINS", "SNAP", "RDDT"], "banks - diversified": ["JPM", "BAC", "WFC", "C", "GS"], "credit services": ["V", "MA", "AXP", "DFS", "COF"], "insurance - diversified": ["BRK-B", "AIG", "ALL", "TRV", "CB"], "reit - industrial": ["PLD", "PSA", "EXR", "COLD", "REXR"], } SECTOR_PEER_MAP = { "Technology": ["AAPL", "MSFT", "NVDA", "ORCL", "ADBE"], "Communication Services": ["GOOGL", "META", "NFLX", "TMUS", "DIS"], "Consumer Cyclical": ["AMZN", "TSLA", "HD", "MCD", "NKE"], "Consumer Defensive": ["WMT", "COST", "PG", "KO", "PEP"], "Financial Services": ["JPM", "BAC", "WFC", "GS", "MS"], "Healthcare": ["LLY", "UNH", "JNJ", "MRK", "PFE"], "Industrials": ["GE", "CAT", "RTX", "UPS", "UNP"], "Energy": ["XOM", "CVX", "COP", "SLB", "EOG"], "Utilities": ["NEE", "DUK", "SO", "AEP", "XEL"], "Real Estate": ["PLD", "AMT", "EQIX", "O", "SPG"], } def _is_financial_company(info: dict) -> bool: sector = str(info.get("sector") or "").strip() industry = str(info.get("industry") or "").strip().lower() if sector in FINANCIAL_SECTORS: return True return any(keyword in industry for keyword in FINANCIAL_INDUSTRY_KEYWORDS) def _suggest_peer_tickers(ticker: str, info: dict) -> list[str]: industry = str(info.get("industry") or "").strip().lower() sector = str(info.get("sector") or "").strip() candidates = [] if industry in INDUSTRY_PEER_MAP: candidates.extend(INDUSTRY_PEER_MAP[industry]) if not candidates and sector in SECTOR_PEER_MAP: candidates.extend(SECTOR_PEER_MAP[sector]) candidates = [c.upper() for c in candidates if c.upper() != ticker.upper()] seen = set() deduped = [] for c in candidates: if c not in seen: deduped.append(c) seen.add(c) return deduped[:8] def _coerce_float(value) -> float | None: try: out = float(value) except (TypeError, ValueError): return None return None if pd.isna(out) else out def _escape_markdown_currency(value: str) -> str: return value.replace("$", r"\$") def render_valuation(ticker: str): tabs = st.tabs([ "Key Ratios", "Historical Ratios", "Models", "Comps", "Forward Estimates", "Analyst Targets", "Earnings History", ]) tab_ratios, tab_hist, tab_models, tab_comps, tab_fwd, tab_analyst, tab_earnings = tabs with tab_ratios: _render_ratios(ticker) with tab_hist: try: _render_historical_ratios(ticker) except Exception as e: st.error(f"Historical ratios unavailable: {e}") with tab_models: _render_models(ticker) with tab_comps: _render_comps(ticker) with tab_fwd: try: _render_forward_estimates(ticker) except Exception as e: st.error(f"Forward estimates unavailable: {e}") with tab_analyst: try: _render_analyst_targets(ticker) except Exception as e: st.error(f"Analyst targets unavailable: {e}") with tab_earnings: try: _render_earnings_history(ticker) except Exception as e: st.error(f"Earnings history unavailable: {e}") # ── Key Ratios ─────────────────────────────────────────────────────────────── def _render_ratios(ticker: str): ratios = get_key_ratios(ticker) info = get_company_info(ticker) if not ratios and not info: st.info("Ratio data unavailable.") return def _normalized_label(label: str) -> str: return " ".join(str(label).replace("/", " ").replace("-", " ").split()).strip().lower() def _display_value(key: str, fmt=fmt_ratio): val = ratios.get(key) if ratios else None return fmt(val) if val is not None else "—" def _company_context() -> dict: return info or {} def _display_reasoned_metric(key: str, fmt=fmt_ratio) -> str: val = ratios.get(key) if ratios else None if val is not None: return fmt(val) ctx = _company_context() if key == "peRatioTTM": trailing_pe = ctx.get("trailingPE") if trailing_pe is not None: return fmt_ratio(trailing_pe) if ratios and ratios.get("netProfitMarginTTM") is not None and ratios.get("netProfitMarginTTM") < 0: return "N/M (neg. TTM earnings)" trailing_eps = ctx.get("trailingEps") if trailing_eps is not None: try: if float(trailing_eps) <= 0: return "N/M (neg. TTM earnings)" except (TypeError, ValueError): pass return "—" if key == "priceToBookRatioTTM": book_value = ctx.get("bookValue") if book_value is not None: try: if float(book_value) <= 0: return "N/M (neg. equity)" except (TypeError, ValueError): pass return "—" if key == "enterpriseValueMultipleTTM": ebitda = ratios.get("ebitdaTTM") if ratios else None if ebitda is not None: try: if float(ebitda) <= 0: return "N/M (neg. EBITDA)" except (TypeError, ValueError): pass return "—" if key == "dividendPayoutRatioTTM": payout_ratio = ctx.get("payoutRatio") if payout_ratio is not None: try: if float(payout_ratio) <= 0: return "—" except (TypeError, ValueError): pass if ratios and ratios.get("netProfitMarginTTM") is not None and ratios.get("netProfitMarginTTM") < 0: return "N/M (neg. earnings)" return "—" if key == "returnOnEquityTTM": book_value = ctx.get("bookValue") if book_value is not None: try: if float(book_value) <= 0: return "N/M (neg. equity)" except (TypeError, ValueError): pass return "—" if key == "debtToEquityRatioTTM": book_value = ctx.get("bookValue") if book_value is not None: try: if float(book_value) <= 0: return "N/M (neg. equity)" except (TypeError, ValueError): pass return "—" if key == "interestCoverageRatioTTM": operating_margins = ctx.get("operatingMargins") if operating_margins is not None: try: if float(operating_margins) <= 0: return "N/M (neg. EBIT)" except (TypeError, ValueError): pass return "—" return "—" def _dedupe_metrics(metrics: list[tuple[str, str]]) -> list[tuple[str, str]]: deduped: list[tuple[str, str]] = [] seen_labels: set[str] = set() for label, val in metrics: norm = _normalized_label(label) if norm in seen_labels: continue seen_labels.add(norm) deduped.append((label, val)) return deduped rows = [ ("Valuation", _dedupe_metrics([ ("P/E (TTM)", _display_reasoned_metric("peRatioTTM")), ("Forward P/E", _display_value("forwardPE")), ("P/S (TTM)", _display_value("priceToSalesRatioTTM")), ("P/B", _display_reasoned_metric("priceToBookRatioTTM")), ("EV/EBITDA", _display_reasoned_metric("enterpriseValueMultipleTTM")), ("EV/Revenue", _display_value("evToSalesTTM")), ])), ("Profitability", _dedupe_metrics([ ("Gross Margin", _display_value("grossProfitMarginTTM", fmt=fmt_pct)), ("Operating Margin", _display_value("operatingProfitMarginTTM", fmt=fmt_pct)), ("Net Margin", _display_value("netProfitMarginTTM", fmt=fmt_pct)), ("ROE", _display_reasoned_metric("returnOnEquityTTM", fmt=fmt_pct)), ("ROA", _display_value("returnOnAssetsTTM", fmt=fmt_pct)), ("ROIC", _display_value("returnOnInvestedCapitalTTM", fmt=fmt_pct)), ])), ("Leverage & Liquidity", _dedupe_metrics([ ("Debt/Equity", _display_reasoned_metric("debtToEquityRatioTTM")), ("Current Ratio", _display_value("currentRatioTTM")), ("Quick Ratio", _display_value("quickRatioTTM")), ("Interest Coverage", _display_reasoned_metric("interestCoverageRatioTTM")), ("Dividend Yield", _display_value("dividendYieldTTM", fmt=fmt_pct)), ("Payout Ratio", _display_reasoned_metric("dividendPayoutRatioTTM", fmt=fmt_pct)), ])), ] for section_name, metrics in rows: st.markdown(f"**{section_name}**") cols = st.columns(6) for col, (label, val) in zip(cols, metrics): col.metric(label, val) st.write("") # ── Models ─────────────────────────────────────────────────────────────────── def _net_debt_label(value: float) -> str: return "Net Cash" if value < 0 else "Net Debt" def _build_model_context(ticker: str) -> dict: info = get_company_info(ticker) ratios_data = get_key_ratios(ticker) shares = get_shares_outstanding(ticker) current_price = get_latest_price(ticker) market_cap = get_market_cap_computed(ticker) bridge_items = get_balance_sheet_bridge_items(ticker) total_debt = float(bridge_items["total_debt"]) cash_and_equivalents = float(bridge_items["cash_and_equivalents"]) preferred_equity = float(bridge_items["preferred_equity"]) minority_interest = float(bridge_items["minority_interest"]) fcf_series_raw = get_free_cash_flow_series(ticker) if fcf_series_raw is None or fcf_series_raw.empty: fcf_series = pd.Series(dtype=float) else: try: fcf_series = fcf_series_raw.sort_index().dropna().astype(float) except Exception: fcf_series = pd.Series(dtype=float) base_fcf = 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 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.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, 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, 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, key=f"dcf_years_{ctx['ticker']}", ) with col4: fcf_growth_pct = st.slider( "FCF Growth (%)", min_value=-20.0, max_value=30.0, 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=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=ctx["total_debt"], cash_and_equivalents=ctx["cash_and_equivalents"], preferred_equity=ctx["preferred_equity"], minority_interest=ctx["minority_interest"], ) if not result: st.warning("Insufficient data to run DCF model.") return if result.get("error"): st.warning(result["error"]) return iv = result["intrinsic_value_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 projects free cash flow, discounts those cash flows back to today, " "adds a terminal value, and then bridges from enterprise value to equity value per share." ) m1, m2, m3, m4 = st.columns(4) m1.metric("Equity Value / Share", fmt_currency(iv)) if current_price: upside = (iv - current_price) / current_price m2.metric("Current Price", fmt_currency(current_price)) 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}%") if current_price and current_price > 0: valuation_gap = iv - 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(iv)) gap_value = _escape_markdown_currency(fmt_currency(abs(valuation_gap))) current_value = _escape_markdown_currency(fmt_currency(current_price)) st.markdown( f"The DCF implies **{implied_value} per share**, which is **{gap_value} " f"{market_message}** the current market price of **{current_value}**." ) calc_a, calc_b, calc_c, calc_d = st.columns(4) calc_a.metric("Base FCF", fmt_large(result["base_fcf"])) calc_b.metric("Forecast FCF PV", fmt_large(result["fcf_pv_sum"])) calc_c.metric("Terminal Value PV", fmt_large(result["terminal_value_pv"])) calc_d.metric("Implied Enterprise Value", fmt_large(result["enterprise_value"])) 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." ) if source_date: st.caption(f"Balance-sheet bridge source date: **{source_date}**") with st.expander("Methodology & sources", expanded=False): st.markdown( "- **TTM ratios:** computed from raw quarterly financial statements where possible.\n" "- **Enterprise Value:** computed as market cap + total debt - cash & equivalents.\n" "- **Market cap:** computed as latest price × shares outstanding when available.\n" "- **Shares outstanding:** pulled from yfinance shares fields.\n" "- **DCF bridge:** uses the most recent quarterly balance sheet for debt, cash, preferred equity, and minority interest.\n" "- **Historical ratios:** computed from annual statements plus price history, with guards against nonsensical EV/EBITDA values.\n" "- **Forward metrics:** analyst-driven items such as Forward P/E and estimates still depend on vendor data." ) st.markdown("**Enterprise Value To Equity Value Bridge**") bridge_a, bridge_b, bridge_c, bridge_d = st.columns(4) 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"])) bridge2.metric(_net_debt_label(result["net_debt"]), fmt_large(abs(result["net_debt"]))) bridge3.metric("Equity Value", fmt_large(result["equity_value"])) bridge4.metric("Terminal Value PV", fmt_large(result["terminal_value_pv"])) bridge_labels = ["Enterprise Value"] bridge_measures = ["absolute"] bridge_values = [float(result["enterprise_value"])] bridge_text = [fmt_large(result["enterprise_value"])] if ctx["total_debt"]: bridge_labels.append("Add Debt") bridge_measures.append("relative") bridge_values.append(float(ctx["total_debt"])) bridge_text.append(fmt_large(ctx["total_debt"])) if ctx["cash_and_equivalents"]: bridge_labels.append("Less Cash") bridge_measures.append("relative") bridge_values.append(-float(ctx["cash_and_equivalents"])) bridge_text.append(fmt_large(-float(ctx["cash_and_equivalents"]))) if ctx["preferred_equity"]: bridge_labels.append("Less Preferred") bridge_measures.append("relative") bridge_values.append(-float(ctx["preferred_equity"])) bridge_text.append(fmt_large(-float(ctx["preferred_equity"]))) if ctx["minority_interest"]: bridge_labels.append("Less Minority") bridge_measures.append("relative") bridge_values.append(-float(ctx["minority_interest"])) bridge_text.append(fmt_large(-float(ctx["minority_interest"]))) bridge_labels.append("Equity Value") bridge_measures.append("total") bridge_values.append(float(result["equity_value"])) bridge_text.append(fmt_large(result["equity_value"])) bridge_fig = go.Figure( go.Waterfall( x=bridge_labels, measure=bridge_measures, y=bridge_values, text=bridge_text, textposition="outside", connector={"line": {"color": "#6f7785"}}, increasing={"marker": {"color": "#4F8EF7"}}, decreasing={"marker": {"color": "#F76E6E"}}, totals={"marker": {"color": "#2ecc71"}}, ) ) bridge_fig.update_layout( title="Enterprise Value Bridge To Equity Value", yaxis_title="USD", plot_bgcolor="rgba(0,0,0,0)", paper_bgcolor="rgba(0,0,0,0)", margin=dict(l=0, r=0, t=44, b=0), height=360, ) st.plotly_chart(bridge_fig, use_container_width=True) if market_cap and market_cap > 0: compare_a, compare_b, compare_c, compare_d = st.columns(4) compare_a.metric("Market Cap", fmt_large(market_cap)) if market_enterprise_value and market_enterprise_value > 0: ev_delta = (result["enterprise_value"] - market_enterprise_value) / market_enterprise_value compare_b.metric( "Market Enterprise Value", fmt_large(market_enterprise_value), delta=f"{ev_delta * 100:+.1f}%", ) equity_delta = (result["equity_value"] - market_cap) / market_cap compare_c.metric("DCF Equity Value", fmt_large(result["equity_value"]), delta=f"{equity_delta * 100:+.1f}%") compare_d.metric("Shares Outstanding", f"{ctx['shares'] / 1e6:,.1f}M") summary_rows = [ { "Step": "1. Start with base free cash flow", "Value": fmt_large(result["base_fcf"]), "What it means": "Most recent annual free cash flow used as the starting point.", }, { "Step": "2. Project and discount forecast cash flows", "Value": fmt_large(result["fcf_pv_sum"]), "What it means": f"Present value of {projection_years} years of projected FCF.", }, { "Step": "3. Add discounted terminal value", "Value": fmt_large(result["terminal_value_pv"]), "What it means": "Present value of cash flows beyond the explicit forecast period.", }, { "Step": "4. Arrive at enterprise value", "Value": fmt_large(result["enterprise_value"]), "What it means": "Value of the operations before debt, cash, and other claims.", }, { "Step": "5. Bridge to equity value", "Value": fmt_large(result["equity_value"]), "What it means": "Enterprise value less net debt, preferred equity, and minority interest.", }, { "Step": "6. Convert to value per share", "Value": fmt_currency(iv), "What it means": "Equity value divided by shares outstanding.", }, ] st.dataframe(pd.DataFrame(summary_rows), use_container_width=True, hide_index=True) st.write("") years = [f"Year {y}" for y in result["years"]] discounted = result["discounted_fcfs"] terminal_pv = result["terminal_value_pv"] fig = go.Figure(go.Bar( x=years + ["Terminal Value"], y=[(v / 1e9) for v in discounted] + [terminal_pv / 1e9], marker_color=["#4F8EF7"] * len(years) + ["#F7A24F"], text=[f"${v / 1e9:.2f}B" for v in discounted] + [f"${terminal_pv / 1e9:.2f}B"], textposition="outside", )) fig.update_layout( title="Enterprise Value Build: PV of Forecast FCFs + Terminal Value (Billions)", yaxis_title="USD (Billions)", 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=360, ) st.plotly_chart(fig, use_container_width=True) 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"], 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"]) ) st.caption( "This model applies a target EV/EBITDA multiple to current EBITDA, then bridges from enterprise value to equity value per share." ) 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}**." ) 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"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}**") bridge_labels = ["Implied EV"] bridge_measures = ["absolute"] bridge_values = [float(ev_result["implied_ev"])] bridge_text = [fmt_large(ev_result["implied_ev"])] if ctx["total_debt"]: bridge_labels.append("Add Debt") bridge_measures.append("relative") bridge_values.append(float(ctx["total_debt"])) bridge_text.append(fmt_large(ctx["total_debt"])) if ctx["cash_and_equivalents"]: bridge_labels.append("Less Cash") bridge_measures.append("relative") bridge_values.append(-float(ctx["cash_and_equivalents"])) bridge_text.append(fmt_large(-float(ctx["cash_and_equivalents"]))) bridge_labels.append("Implied Equity") bridge_measures.append("total") bridge_values.append(float(ev_result["equity_value"])) bridge_text.append(fmt_large(ev_result["equity_value"])) bridge_fig = go.Figure( go.Waterfall( x=bridge_labels, measure=bridge_measures, y=bridge_values, text=bridge_text, textposition="outside", connector={"line": {"color": "#6f7785"}}, increasing={"marker": {"color": "#4F8EF7"}}, decreasing={"marker": {"color": "#F76E6E"}}, totals={"marker": {"color": "#2ecc71"}}, ) ) bridge_fig.update_layout( title="EV/EBITDA Bridge To Equity Value", yaxis_title="USD", plot_bgcolor="rgba(0,0,0,0)", paper_bgcolor="rgba(0,0,0,0)", margin=dict(l=0, r=0, t=44, b=0), height=320, ) st.plotly_chart(bridge_fig, use_container_width=True) if market_cap and market_cap > 0: compare_a, compare_b, compare_c, compare_d = st.columns(4) compare_a.metric("Market Cap", fmt_large(market_cap)) if market_enterprise_value and market_enterprise_value > 0: ev_delta = (ev_result["implied_ev"] - market_enterprise_value) / market_enterprise_value compare_b.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_c.metric("Model Equity Value", fmt_large(ev_result["equity_value"]), delta=f"{equity_delta * 100:+.1f}%") compare_d.metric("Shares Outstanding", f"{ctx['shares'] / 1e6:,.1f}M") 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.", }, { "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) 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"] 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"]) ) st.caption( "This model applies a target EV/Revenue multiple to TTM revenue, then bridges from enterprise value to equity value per share." ) 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}**." ) 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"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}**") bridge_labels = ["Implied EV"] bridge_measures = ["absolute"] bridge_values = [float(ev_revenue_result["implied_ev"])] bridge_text = [fmt_large(ev_revenue_result["implied_ev"])] if ctx["total_debt"]: bridge_labels.append("Add Debt") bridge_measures.append("relative") bridge_values.append(float(ctx["total_debt"])) bridge_text.append(fmt_large(ctx["total_debt"])) if ctx["cash_and_equivalents"]: bridge_labels.append("Less Cash") bridge_measures.append("relative") bridge_values.append(-float(ctx["cash_and_equivalents"])) bridge_text.append(fmt_large(-float(ctx["cash_and_equivalents"]))) bridge_labels.append("Implied Equity") bridge_measures.append("total") bridge_values.append(float(ev_revenue_result["equity_value"])) bridge_text.append(fmt_large(ev_revenue_result["equity_value"])) bridge_fig = go.Figure( go.Waterfall( x=bridge_labels, measure=bridge_measures, y=bridge_values, text=bridge_text, textposition="outside", connector={"line": {"color": "#6f7785"}}, increasing={"marker": {"color": "#4F8EF7"}}, decreasing={"marker": {"color": "#F76E6E"}}, totals={"marker": {"color": "#2ecc71"}}, ) ) bridge_fig.update_layout( title="EV/Revenue Bridge To Equity Value", yaxis_title="USD", plot_bgcolor="rgba(0,0,0,0)", paper_bgcolor="rgba(0,0,0,0)", margin=dict(l=0, r=0, t=44, b=0), height=320, ) st.plotly_chart(bridge_fig, use_container_width=True) if market_cap and market_cap > 0: compare_a, compare_b, compare_c, compare_d = st.columns(4) compare_a.metric("Market Cap", fmt_large(market_cap)) if market_enterprise_value and market_enterprise_value > 0: ev_delta = (ev_revenue_result["implied_ev"] - market_enterprise_value) / market_enterprise_value compare_b.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_c.metric("Model Equity Value", fmt_large(ev_revenue_result["equity_value"]), delta=f"{equity_delta * 100:+.1f}%") compare_d.metric("Shares Outstanding", f"{ctx['shares'] / 1e6:,.1f}M") 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.", }, { "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) 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) 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 ────────────────────────────────────────────────────────────── 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 = ["#2ecc71", "#82e0aa", "#f0b27a", "#e59866", "#e74c3c"] 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="#4F8EF7", 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="#F7A24F", 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 = [ "#4F8EF7", "#F7A24F", "#2ecc71", "#e74c3c", "#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="#4F8EF7", 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="#F7A24F", 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.")