From 547997cbd069e9b958b12a8da38b3a4a257e29e5 Mon Sep 17 00:00:00 2001 From: Openclaw Date: Sun, 29 Mar 2026 01:12:24 -0700 Subject: Fix valuation methodology and documentation --- README.md | 6 +-- components/news.py | 13 ++--- components/valuation.py | 33 ++++++++++-- readme | 6 +-- services/valuation_service.py | 123 +++++++++++++++++++++++------------------- 5 files changed, 112 insertions(+), 69 deletions(-) diff --git a/README.md b/README.md index c98a363..b82ee48 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ A local financial analysis dashboard. Enter any stock ticker to get a formatted - **Overview** — Price chart (1M / 3M / 6M / 1Y / 5Y), key stats (market cap, P/E, 52W range, beta) - **Financials** — Annual and quarterly Income Statement, Balance Sheet, and Cash Flow Statement with year-over-year % change columns - **Valuation** — Key ratios grid (P/E, EV/EBITDA, margins, ROE, etc.), interactive DCF model with adjustable WACC and growth rate, comparable companies table -- **News** — Recent articles with Bullish / Bearish / Neutral sentiment badges and a 7-day sentiment summary +- **News** — Recent articles with heuristic Bullish / Bearish / Neutral tags and a 7-day sentiment summary --- @@ -96,7 +96,7 @@ prism/ ## DCF Model Notes -The DCF model uses **5 years of historical Free Cash Flow** from yfinance to compute an average growth rate, then projects forward using your chosen assumptions: +The DCF model uses historical **Free Cash Flow** from yfinance, computes a capped median growth rate from valid positive-FCF periods, and then projects forward using your chosen assumptions: | Input | Default | Range | |---|---|---| @@ -104,7 +104,7 @@ The DCF model uses **5 years of historical Free Cash Flow** from yfinance to com | Terminal Growth Rate | 2.5% | 0.5–5% | | Projection Years | 5 | 3–10 | -The model uses the **Gordon Growth Model** for terminal value. Intrinsic value per share is compared against the current market price to show upside/downside. +The model uses the **Gordon Growth Model** for terminal value. It first estimates **enterprise value**, then bridges to **equity value** using debt and cash before calculating value per share. Terminal growth must remain below WACC. --- diff --git a/components/news.py b/components/news.py index cea678e..522826c 100644 --- a/components/news.py +++ b/components/news.py @@ -7,11 +7,11 @@ from services.fmp_service import get_company_news as get_fmp_news def _sentiment_badge(sentiment: str) -> str: badges = { - "bullish": "🟢 Bullish", - "bearish": "🔴 Bearish", - "neutral": "⚪ Neutral", + "bullish": "🟢 Bullish (heuristic)", + "bearish": "🔴 Bearish (heuristic)", + "neutral": "⚪ Neutral (heuristic)", } - return badges.get(sentiment.lower(), "⚪ Neutral") + return badges.get(sentiment.lower(), "⚪ Neutral (heuristic)") def _classify_sentiment(article: dict) -> str: @@ -52,8 +52,9 @@ def render_news(ticker: str): col1.metric("Articles (7d)", buzz.get("articlesInLastWeek", "—")) bull_pct = score.get("bullishPercent") bear_pct = score.get("bearishPercent") - col2.metric("Bullish %", f"{bull_pct * 100:.1f}%" if bull_pct else "—") - col3.metric("Bearish %", f"{bear_pct * 100:.1f}%" if bear_pct else "—") + col2.metric("Bullish %", f"{bull_pct * 100:.1f}%" if bull_pct is not None else "—") + col3.metric("Bearish %", f"{bear_pct * 100:.1f}%" if bear_pct is not None else "—") + st.caption("Sentiment tags below are rule-based headline heuristics, not model-scored article sentiment.") st.divider() # Fetch articles — Finnhub first, FMP as fallback diff --git a/components/valuation.py b/components/valuation.py index 62ee1e3..82e0f0d 100644 --- a/components/valuation.py +++ b/components/valuation.py @@ -61,7 +61,7 @@ def _render_ratios(ticker: str): rows = [ ("Valuation", [ ("P/E (TTM)", r("peRatioTTM", "trailingPE")), - ("Forward P/E", r("priceEarningsRatioTTM", "forwardPE")), + ("Forward P/E", fmt_ratio(info.get("forwardPE")) if info.get("forwardPE") is not None else "—"), ("P/S (TTM)", r("priceToSalesRatioTTM", "priceToSalesTrailing12Months")), ("P/B", r("priceToBookRatioTTM", "priceToBook")), ("EV/EBITDA", r("enterpriseValueMultipleTTM", "enterpriseToEbitda")), @@ -99,6 +99,15 @@ 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") + total_debt = info.get("totalDebt") or 0.0 + cash_and_equivalents = ( + info.get("totalCash") + or info.get("cash") + or info.get("cashAndCashEquivalents") + or 0.0 + ) + preferred_equity = info.get("preferredStock") or 0.0 + minority_interest = info.get("minorityInterest") or 0.0 if not shares: st.info("Shares outstanding not available — DCF cannot be computed.") @@ -143,22 +152,40 @@ def _render_dcf(ticker: str): terminal_growth=terminal_growth, projection_years=projection_years, growth_rate_override=fcf_growth_pct / 100, + total_debt=total_debt, + cash_and_equivalents=cash_and_equivalents, + preferred_equity=preferred_equity, + minority_interest=minority_interest, ) if not result: st.warning("Insufficient data to run DCF model.") return + if result.get("error"): + st.warning(result["error"]) + return iv = result["intrinsic_value_per_share"] m1, m2, m3, m4 = st.columns(4) - m1.metric("Intrinsic Value / Share", fmt_currency(iv)) + m1.metric("Equity 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.caption( + "DCF is modeled on firm-level free cash flow, so enterprise value is bridged to equity value " + "using cash and debt before calculating per-share value." + ) + + bridge1, bridge2, bridge3, bridge4 = st.columns(4) + bridge1.metric("Enterprise Value", fmt_large(result["enterprise_value"])) + bridge2.metric("Net Debt", fmt_large(result["net_debt"])) + bridge3.metric("Equity Value", fmt_large(result["equity_value"])) + bridge4.metric("Terminal Value PV", fmt_large(result["terminal_value_pv"])) + st.write("") years = [f"Year {y}" for y in result["years"]] @@ -173,7 +200,7 @@ def _render_dcf(ticker: str): textposition="outside", )) fig.update_layout( - title="PV of Projected FCFs + Terminal Value (Billions)", + title="Enterprise Value Build: PV of Forecast FCFs + Terminal Value (Billions)", yaxis_title="USD (Billions)", plot_bgcolor="rgba(0,0,0,0)", paper_bgcolor="rgba(0,0,0,0)", diff --git a/readme b/readme index bdc1550..23365f4 100644 --- a/readme +++ b/readme @@ -22,7 +22,7 @@
  • Overview — Price chart (1M / 3M / 6M / 1Y / 5Y), key stats (market cap, P/E, 52W range, beta)
  • Financials — Annual and quarterly Income Statement, Balance Sheet, and Cash Flow Statement with year-over-year % change columns
  • Valuation — Key ratios grid (P/E, EV/EBITDA, margins, ROE, etc.), interactive DCF model with adjustable WACC and growth rate, comparable companies table
  • -
  • News — Recent articles with Bullish / Bearish / Neutral sentiment badges and a 7-day sentiment summary
  • +
  • News — Recent articles with heuristic Bullish / Bearish / Neutral tags and a 7-day sentiment summary

  • Setup

    @@ -80,7 +80,7 @@ streamlit run app.py

    DCF Model Notes

    -

    The DCF model uses 5 years of historical Free Cash Flow from yfinance to compute an average growth rate, then projects forward using your chosen assumptions:

    +

    The DCF model uses historical Free Cash Flow from yfinance, computes a capped median growth rate from valid positive-FCF periods, and then projects forward using your chosen assumptions:

    @@ -107,7 +107,7 @@ streamlit run app.py
    -

    The model uses the Gordon Growth Model for terminal value. Intrinsic value per share is compared against the current market price to show upside/downside.

    +

    The model uses the Gordon Growth Model for terminal value. It first estimates enterprise value, then bridges to equity value using debt and cash before calculating value per share. Terminal growth must remain below WACC.


    API Rate Limits

    diff --git a/services/valuation_service.py b/services/valuation_service.py index c874493..6db4053 100644 --- a/services/valuation_service.py +++ b/services/valuation_service.py @@ -1,25 +1,46 @@ -"""DCF valuation engine — Gordon Growth Model + EV/EBITDA.""" +"""Valuation engines for DCF and EV/EBITDA.""" import numpy as np import pandas as pd +GROWTH_FLOOR = -0.50 +GROWTH_CAP = 0.50 +MIN_BASE_MAGNITUDE = 1e-9 + + +def _cap_growth(value: float) -> float: + return max(GROWTH_FLOOR, min(GROWTH_CAP, float(value))) + + 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. + Return a capped median YoY FCF growth rate from historical data. + + Notes: + - skips periods with near-zero prior FCF + - skips sign-flip periods (negative to positive or vice versa), since the + implied "growth rate" is usually not economically meaningful """ - historical = fcf_series.sort_index().dropna().values + historical = fcf_series.sort_index().dropna().astype(float).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) + previous = float(historical[i - 1]) + current = float(historical[i]) + + if abs(previous) < MIN_BASE_MAGNITUDE: + continue + if previous <= 0 or current <= 0: + continue + + growth_rates.append((current - previous) / previous) + if not growth_rates: return None - raw = float(np.median(growth_rates)) - return max(-0.50, min(0.50, raw)) + + return _cap_growth(float(np.median(growth_rates))) def run_dcf( @@ -29,67 +50,71 @@ def run_dcf( terminal_growth: float = 0.03, projection_years: int = 5, growth_rate_override: float | None = None, + total_debt: float = 0.0, + cash_and_equivalents: float = 0.0, + preferred_equity: float = 0.0, + minority_interest: float = 0.0, ) -> dict: """ - Run a DCF model and return per-year breakdown plus intrinsic value per share. - - Args: - 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: - intrinsic_value_per_share, total_pv, terminal_value_pv, - fcf_pv_sum, years, projected_fcfs, discounted_fcfs, - growth_rate_used + Run a simple FCFF-style DCF and bridge enterprise value to equity value. + + Returns an error payload when inputs are mathematically invalid. """ if fcf_series.empty or shares_outstanding <= 0: return {} - historical = fcf_series.sort_index().dropna().values + historical = fcf_series.sort_index().dropna().astype(float).values if len(historical) < 2: return {} + if wacc <= 0: + return {"error": "WACC must be greater than 0%."} + if terminal_growth >= wacc: + return {"error": "Terminal growth must be lower than WACC."} + if growth_rate_override is not None: - growth_rate = max(-0.50, min(0.50, growth_rate_override)) + growth_rate = _cap_growth(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)) + historical_growth = compute_historical_growth_rate(fcf_series) + growth_rate = historical_growth if historical_growth is not None else 0.05 base_fcf = float(historical[-1]) projected_fcfs = [] for year in range(1, projection_years + 1): - fcf = base_fcf * ((1 + growth_rate) ** year) - projected_fcfs.append(fcf) + projected_fcfs.append(base_fcf * ((1 + growth_rate) ** year)) discounted_fcfs = [] for i, fcf in enumerate(projected_fcfs, start=1): - pv = fcf / ((1 + wacc) ** i) - discounted_fcfs.append(pv) + discounted_fcfs.append(fcf / ((1 + wacc) ** i)) - fcf_pv_sum = sum(discounted_fcfs) + fcf_pv_sum = float(sum(discounted_fcfs)) - terminal_fcf = projected_fcfs[-1] * (1 + terminal_growth) + terminal_fcf = float(projected_fcfs[-1]) * (1 + terminal_growth) terminal_value = terminal_fcf / (wacc - terminal_growth) terminal_value_pv = terminal_value / ((1 + wacc) ** projection_years) - total_pv = fcf_pv_sum + terminal_value_pv - intrinsic_value_per_share = total_pv / shares_outstanding + enterprise_value = fcf_pv_sum + terminal_value_pv + + total_debt = float(total_debt or 0.0) + cash_and_equivalents = float(cash_and_equivalents or 0.0) + preferred_equity = float(preferred_equity or 0.0) + minority_interest = float(minority_interest or 0.0) + + net_debt = total_debt - cash_and_equivalents + equity_value = enterprise_value - net_debt - preferred_equity - minority_interest + intrinsic_value_per_share = equity_value / shares_outstanding return { "intrinsic_value_per_share": intrinsic_value_per_share, - "total_pv": total_pv, + "enterprise_value": enterprise_value, + "equity_value": equity_value, + "net_debt": net_debt, + "cash_and_equivalents": cash_and_equivalents, + "total_debt": total_debt, + "preferred_equity": preferred_equity, + "minority_interest": minority_interest, + "terminal_value": terminal_value, "terminal_value_pv": terminal_value_pv, "fcf_pv_sum": fcf_pv_sum, "years": list(range(1, projection_years + 1)), @@ -107,17 +132,7 @@ def run_ev_ebitda( 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. - """ + """Derive implied equity value per share from an EV/EBITDA multiple.""" if not ebitda or ebitda <= 0: return {} if not shares_outstanding or shares_outstanding <= 0: -- cgit v1.3-2-g0d8e