aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--components/valuation.py282
-rw-r--r--services/data_service.py54
-rw-r--r--services/valuation_service.py90
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
@@ -92,6 +92,60 @@ def get_market_indices() -> dict:
@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)."""
t = yf.Ticker(ticker.upper())
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,
+ }