"""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, 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, 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, tab_analyst, tab_earnings = st.tabs([ "Key Ratios", "DCF Model", "Comps", "Analyst Targets", "Earnings History" ]) with tab_ratios: _render_ratios(ticker) with tab_dcf: _render_dcf(ticker) 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 ─────────────────────────────────────────────────────────────── 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. Check your FMP API key.") return 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: val = info.get(yf_key) return fmt(val) if val is not None else "—" rows = [ ("Valuation", [ ("P/E (TTM)", r("peRatioTTM", "trailingPE")), ("Forward P/E", r("priceEarningsRatioTTM", "forwardPE")), ("P/S (TTM)", r("priceToSalesRatioTTM", "priceToSalesTrailing12Months")), ("P/B", r("priceToBookRatioTTM", "priceToBook")), ("EV/EBITDA", r("enterpriseValueMultipleTTM", "enterpriseToEbitda")), ("EV/Revenue", r("evToSalesTTM", "enterpriseToRevenue")), ]), ("Profitability", [ ("Gross Margin", r("grossProfitMarginTTM", "grossMargins", fmt_pct)), ("Operating Margin", r("operatingProfitMarginTTM", "operatingMargins", fmt_pct)), ("Net Margin", r("netProfitMarginTTM", "profitMargins", fmt_pct)), ("ROE", r("returnOnEquityTTM", "returnOnEquity", fmt_pct)), ("ROA", r("returnOnAssetsTTM", "returnOnAssets", fmt_pct)), ("ROIC", r("returnOnInvestedCapitalTTM", fmt=fmt_pct)), ]), ("Leverage & Liquidity", [ ("Debt/Equity", r("debtEquityRatioTTM", "debtToEquity")), ("Current Ratio", r("currentRatioTTM", "currentRatio")), ("Quick Ratio", r("quickRatioTTM", "quickRatio")), ("Interest Coverage", r("interestCoverageTTM")), ("Dividend Yield", r("dividendYieldTTM", "dividendYield", fmt_pct)), ("Payout Ratio", r("payoutRatioTTM", "payoutRatio", 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("") # ── DCF Model ──────────────────────────────────────────────────────────────── def _render_dcf(ticker: str): info = get_company_info(ticker) shares = info.get("sharesOutstanding") or info.get("floatShares") current_price = info.get("currentPrice") or info.get("regularMarketPrice") if not shares: st.info("Shares outstanding not available — DCF cannot be computed.") return fcf_series = get_free_cash_flow_series(ticker) if fcf_series.empty: 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, 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 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, shares_outstanding=shares, wacc=wacc, terminal_growth=terminal_growth, projection_years=projection_years, growth_rate_override=fcf_growth_pct / 100, ) if not result: st.warning("Insufficient data to run DCF model.") return iv = result["intrinsic_value_per_share"] m1, m2, m3, m4 = st.columns(4) m1.metric("Intrinsic 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}%") 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="PV of Projected 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) # ── 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 ────────────────────────────────────────────────────────────── def _render_comps(ticker: str): peers = get_peers(ticker) if not peers: st.info("No comparable companies found. Check your FMP API key.") return all_tickers = [ticker.upper()] + [p for p in peers[:9] if p != ticker.upper()] with st.spinner("Loading comps..."): ratios_list = get_ratios_for_tickers(all_tickers) if not ratios_list: st.info("Could not load ratios for peer companies.") return display_cols = { "symbol": "Ticker", "peRatioTTM": "P/E", "priceToSalesRatioTTM": "P/S", "priceToBookRatioTTM": "P/B", "enterpriseValueMultipleTTM": "EV/EBITDA", "netProfitMarginTTM": "Net Margin", "returnOnEquityTTM": "ROE", "debtEquityRatioTTM": "D/E", } df = pd.DataFrame(ratios_list) available = [c for c in display_cols if c in df.columns] df = df[available].rename(columns=display_cols) pct_cols = {"Net Margin", "ROE"} for col in df.columns: if col == "Ticker": continue if col in pct_cols: df[col] = df[col].apply(lambda v: fmt_pct(v) if v is not None else "—") else: df[col] = df[col].apply(lambda v: fmt_ratio(v) if v is not None else "—") 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, ) # 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)