From ad6b0b59c2a4f557d6d9d7fe9810c2ba7627580d Mon Sep 17 00:00:00 2001 From: Tyler Date: Sun, 29 Mar 2026 00:52:25 -0700 Subject: Add EV/EBITDA valuation, analyst targets, earnings history, and FCF growth override - DCF model: user-adjustable FCF growth rate slider (defaults to historical median) - EV/EBITDA valuation section with target multiple slider and implied price - Analyst Targets tab: price target summary + recommendation breakdown chart - Earnings History tab: EPS actual vs estimate table and line chart with next earnings date Co-Authored-By: Claude Sonnet 4.6 --- components/valuation.py | 282 ++++++++++++++++++++++++++++++++++++++---- services/data_service.py | 54 ++++++++ services/valuation_service.py | 90 +++++++++++--- 3 files changed, 384 insertions(+), 42 deletions(-) diff --git a/components/valuation.py b/components/valuation.py index 6549d07..62ee1e3 100644 --- a/components/valuation.py +++ b/components/valuation.py @@ -1,15 +1,24 @@ -"""Valuation panel — key ratios, DCF model, comparable companies.""" +"""Valuation panel — key ratios, DCF model, 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_free_cash_flow_series +from services.data_service import ( + get_company_info, + get_free_cash_flow_series, + 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 -from services.valuation_service import run_dcf +from services.valuation_service import run_dcf, run_ev_ebitda, compute_historical_growth_rate from utils.formatters import fmt_ratio, fmt_pct, fmt_large, fmt_currency def render_valuation(ticker: str): - tab_ratios, tab_dcf, tab_comps = st.tabs(["Key Ratios", "DCF Model", "Comps"]) + tab_ratios, tab_dcf, tab_comps, tab_analyst, tab_earnings = st.tabs([ + "Key Ratios", "DCF Model", "Comps", "Analyst Targets", "Earnings History" + ]) with tab_ratios: _render_ratios(ticker) @@ -20,6 +29,18 @@ def render_valuation(ticker: str): with tab_comps: _render_comps(ticker) + 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 ─────────────────────────────────────────────────────────────── @@ -31,7 +52,6 @@ def _render_ratios(ticker: str): st.info("Ratio data unavailable. Check your FMP API key.") return - # Prefer FMP ratios, fall back to yfinance info def r(fmp_key, yf_key=None, fmt=fmt_ratio): val = ratios.get(fmp_key) if ratios else None if val is None and yf_key and info: @@ -89,14 +109,32 @@ def _render_dcf(ticker: str): st.info("Free cash flow data unavailable.") return + # Compute historical growth rate for slider default + caption reference + hist_growth = compute_historical_growth_rate(fcf_series) + 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**") - col1, col2, col3 = st.columns(3) + 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 with col2: - terminal_growth = st.slider("Terminal Growth (%)", min_value=0.5, max_value=5.0, value=2.5, step=0.5) / 100 + terminal_growth = st.slider( + "Terminal Growth (%)", min_value=0.5, max_value=5.0, value=2.5, step=0.5 + ) / 100 with col3: projection_years = st.slider("Projection Years", min_value=3, max_value=10, value=5, step=1) + 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.", + ) + + st.caption(f"Historical FCF growth (median): **{hist_growth_pct:.1f}%**") result = run_dcf( fcf_series=fcf_series, @@ -104,6 +142,7 @@ def _render_dcf(ticker: str): wacc=wacc, terminal_growth=terminal_growth, projection_years=projection_years, + growth_rate_override=fcf_growth_pct / 100, ) if not result: @@ -112,7 +151,6 @@ def _render_dcf(ticker: str): iv = result["intrinsic_value_per_share"] - # ── Summary metrics ────────────────────────────────────────────────────── m1, m2, m3, m4 = st.columns(4) m1.metric("Intrinsic Value / Share", fmt_currency(iv)) if current_price: @@ -123,24 +161,17 @@ def _render_dcf(ticker: str): st.write("") - # ── Waterfall chart ─────────────────────────────────────────────────────── years = [f"Year {y}" for y in result["years"]] discounted = result["discounted_fcfs"] terminal_pv = result["terminal_value_pv"] - bar_labels = years + ["Terminal Value"] - bar_values = discounted + [terminal_pv] - bar_colors = ["#4F8EF7"] * len(years) + ["#F7A24F"] - - fig = go.Figure( - go.Bar( - x=bar_labels, - y=[v / 1e9 for v in bar_values], - marker_color=bar_colors, - text=[f"${v / 1e9:.2f}B" for v in bar_values], - textposition="outside", - ) - ) + 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="PV of Projected FCFs + Terminal Value (Billions)", yaxis_title="USD (Billions)", @@ -151,6 +182,65 @@ def _render_dcf(ticker: str): ) st.plotly_chart(fig, use_container_width=True) + # ── EV/EBITDA Valuation ─────────────────────────────────────────────────── + st.divider() + st.markdown("**EV/EBITDA Valuation**") + + ebitda = info.get("ebitda") + total_debt = info.get("totalDebt") or 0.0 + total_cash = info.get("totalCash") or 0.0 + ev_ebitda_current = info.get("enterpriseToEbitda") + + if not ebitda or ebitda <= 0: + st.info("EBITDA not available or negative — EV/EBITDA valuation cannot be computed.") + 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))) + + 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, + ) + + 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, + ) + + 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: {fmt_large(ev_result['net_debt'])} · " + f"Equity Value: {fmt_large(ev_result['equity_value'])}" + ) + else: + st.warning("Could not compute EV/EBITDA valuation.") + # ── Comps Table ────────────────────────────────────────────────────────────── @@ -160,7 +250,6 @@ def _render_comps(ticker: str): st.info("No comparable companies found. Check your FMP API key.") return - # Include the subject ticker all_tickers = [ticker.upper()] + [p for p in peers[:9] if p != ticker.upper()] with st.spinner("Loading comps..."): @@ -185,7 +274,6 @@ def _render_comps(ticker: str): available = [c for c in display_cols if c in df.columns] df = df[available].rename(columns=display_cols) - # Format numeric columns pct_cols = {"Net Margin", "ROE"} for col in df.columns: if col == "Ticker": @@ -195,7 +283,6 @@ def _render_comps(ticker: str): else: df[col] = df[col].apply(lambda v: fmt_ratio(v) if v is not None else "—") - # Highlight subject ticker row def highlight_subject(row): if row["Ticker"] == ticker.upper(): return ["background-color: rgba(79,142,247,0.15)"] * len(row) @@ -206,3 +293,148 @@ def _render_comps(ticker: str): 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, + ) + + # 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) diff --git a/services/data_service.py b/services/data_service.py index fa9b026..0399c58 100644 --- a/services/data_service.py +++ b/services/data_service.py @@ -91,6 +91,60 @@ def get_market_indices() -> dict: return result +@st.cache_data(ttl=3600) +def get_analyst_price_targets(ticker: str) -> dict: + """Return analyst price target summary (keys: current, high, low, mean, median).""" + try: + t = yf.Ticker(ticker.upper()) + data = t.analyst_price_targets + return data if isinstance(data, dict) and data else {} + except Exception: + return {} + + +@st.cache_data(ttl=3600) +def get_recommendations_summary(ticker: str) -> pd.DataFrame: + """Return analyst recommendation counts by period. + Columns: period, strongBuy, buy, hold, sell, strongSell. + Row with period='0m' is the current month. + """ + try: + t = yf.Ticker(ticker.upper()) + df = t.recommendations_summary + return df if df is not None and not df.empty else pd.DataFrame() + except Exception: + return pd.DataFrame() + + +@st.cache_data(ttl=3600) +def get_earnings_history(ticker: str) -> pd.DataFrame: + """Return historical EPS actual vs estimate. + Columns: epsActual, epsEstimate, epsDifference, surprisePercent. + """ + try: + t = yf.Ticker(ticker.upper()) + df = t.earnings_history + return df if df is not None and not df.empty else pd.DataFrame() + except Exception: + return pd.DataFrame() + + +@st.cache_data(ttl=3600) +def get_next_earnings_date(ticker: str) -> str | None: + """Return the next expected earnings date as a string, or None. + Uses t.calendar (no lxml dependency). + """ + try: + t = yf.Ticker(ticker.upper()) + cal = t.calendar + dates = cal.get("Earnings Date", []) + if dates: + return str(dates[0]) + return None + except Exception: + return None + + @st.cache_data(ttl=3600) def get_free_cash_flow_series(ticker: str) -> pd.Series: """Return annual Free Cash Flow series (most recent first).""" diff --git a/services/valuation_service.py b/services/valuation_service.py index f876f78..c874493 100644 --- a/services/valuation_service.py +++ b/services/valuation_service.py @@ -1,24 +1,46 @@ -"""DCF valuation engine — Gordon Growth Model.""" +"""DCF valuation engine — Gordon Growth Model + EV/EBITDA.""" import numpy as np import pandas as pd +def compute_historical_growth_rate(fcf_series: pd.Series) -> float | None: + """ + Return the median YoY FCF growth rate from historical data, capped at [-0.5, 0.5]. + Returns None if there is insufficient data. + """ + historical = fcf_series.sort_index().dropna().values + if len(historical) < 2: + return None + growth_rates = [] + for i in range(1, len(historical)): + if historical[i - 1] != 0: + g = (historical[i] - historical[i - 1]) / abs(historical[i - 1]) + growth_rates.append(g) + if not growth_rates: + return None + raw = float(np.median(growth_rates)) + return max(-0.50, min(0.50, raw)) + + def run_dcf( fcf_series: pd.Series, shares_outstanding: float, wacc: float = 0.10, terminal_growth: float = 0.03, projection_years: int = 5, + growth_rate_override: float | None = None, ) -> dict: """ Run a DCF model and return per-year breakdown plus intrinsic value per share. Args: - fcf_series: Annual FCF values, most recent first (yfinance order). + fcf_series: Annual FCF values (yfinance order — most recent first). shares_outstanding: Diluted shares outstanding. wacc: Weighted average cost of capital (decimal, e.g. 0.10). terminal_growth: Perpetuity growth rate (decimal, e.g. 0.03). projection_years: Number of years to project FCFs. + growth_rate_override: If provided, use this growth rate instead of + computing from historical FCF data (decimal, e.g. 0.08). Returns: dict with keys: @@ -29,31 +51,28 @@ def run_dcf( if fcf_series.empty or shares_outstanding <= 0: return {} - # Use last N years of FCF (sorted oldest → newest) historical = fcf_series.sort_index().dropna().values if len(historical) < 2: return {} - # Compute average YoY growth rate from historical FCF - growth_rates = [] - for i in range(1, len(historical)): - if historical[i - 1] != 0: - g = (historical[i] - historical[i - 1]) / abs(historical[i - 1]) - growth_rates.append(g) - - # Cap growth rate to reasonable bounds [-0.5, 0.5] - raw_growth = float(np.median(growth_rates)) if growth_rates else 0.05 - growth_rate = max(-0.50, min(0.50, raw_growth)) + if growth_rate_override is not None: + growth_rate = max(-0.50, min(0.50, growth_rate_override)) + else: + growth_rates = [] + for i in range(1, len(historical)): + if historical[i - 1] != 0: + g = (historical[i] - historical[i - 1]) / abs(historical[i - 1]) + growth_rates.append(g) + raw_growth = float(np.median(growth_rates)) if growth_rates else 0.05 + growth_rate = max(-0.50, min(0.50, raw_growth)) - base_fcf = float(historical[-1]) # most recent FCF + base_fcf = float(historical[-1]) - # Project FCFs projected_fcfs = [] for year in range(1, projection_years + 1): fcf = base_fcf * ((1 + growth_rate) ** year) projected_fcfs.append(fcf) - # Discount projected FCFs discounted_fcfs = [] for i, fcf in enumerate(projected_fcfs, start=1): pv = fcf / ((1 + wacc) ** i) @@ -61,7 +80,6 @@ def run_dcf( fcf_pv_sum = sum(discounted_fcfs) - # Terminal value (Gordon Growth Model) terminal_fcf = projected_fcfs[-1] * (1 + terminal_growth) terminal_value = terminal_fcf / (wacc - terminal_growth) terminal_value_pv = terminal_value / ((1 + wacc) ** projection_years) @@ -80,3 +98,41 @@ def run_dcf( "growth_rate_used": growth_rate, "base_fcf": base_fcf, } + + +def run_ev_ebitda( + ebitda: float, + total_debt: float, + total_cash: float, + shares_outstanding: float, + target_multiple: float, +) -> dict: + """ + Derive implied equity value per share from an EV/EBITDA multiple. + + Steps: + implied_ev = ebitda * target_multiple + net_debt = total_debt - total_cash + equity_value = implied_ev - net_debt + price_per_share = equity_value / shares_outstanding + + Returns {} if EBITDA <= 0 or any required input is missing/invalid. + """ + if not ebitda or ebitda <= 0: + return {} + if not shares_outstanding or shares_outstanding <= 0: + return {} + if not target_multiple or target_multiple <= 0: + return {} + + implied_ev = ebitda * 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, + } -- cgit v1.3-2-g0d8e