diff options
Diffstat (limited to 'services')
| -rw-r--r-- | services/data_service.py | 54 | ||||
| -rw-r--r-- | services/valuation_service.py | 90 |
2 files changed, 127 insertions, 17 deletions
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, + } |
