diff options
Diffstat (limited to 'components')
| -rw-r--r-- | components/valuation.py | 282 |
1 files changed, 257 insertions, 25 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) |
