aboutsummaryrefslogtreecommitdiff
path: root/components/valuation.py
diff options
context:
space:
mode:
Diffstat (limited to 'components/valuation.py')
-rw-r--r--components/valuation.py282
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)