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 --- services/valuation_service.py | 90 +++++++++++++++++++++++++++++++++++-------- 1 file changed, 73 insertions(+), 17 deletions(-) (limited to 'services/valuation_service.py') 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