diff options
| author | Openclaw <openclaw@mail.tylerhoang.xyz> | 2026-03-29 01:12:24 -0700 |
|---|---|---|
| committer | Openclaw <openclaw@mail.tylerhoang.xyz> | 2026-03-29 01:12:24 -0700 |
| commit | 547997cbd069e9b958b12a8da38b3a4a257e29e5 (patch) | |
| tree | dbae519a7c6c8f2d803e58e9a77f9a9db73da969 | |
| parent | ad6b0b59c2a4f557d6d9d7fe9810c2ba7627580d (diff) | |
Fix valuation methodology and documentation
| -rw-r--r-- | README.md | 6 | ||||
| -rw-r--r-- | components/news.py | 13 | ||||
| -rw-r--r-- | components/valuation.py | 33 | ||||
| -rw-r--r-- | readme | 6 | ||||
| -rw-r--r-- | services/valuation_service.py | 121 |
5 files changed, 111 insertions, 68 deletions
@@ -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)", @@ -22,7 +22,7 @@ <li><strong>Overview</strong> — Price chart (1M / 3M / 6M / 1Y / 5Y), key stats (market cap, P/E, 52W range, beta)</li> <li><strong>Financials</strong> — Annual and quarterly Income Statement, Balance Sheet, and Cash Flow Statement with year-over-year % change columns</li> <li><strong>Valuation</strong> — Key ratios grid (P/E, EV/EBITDA, margins, ROE, etc.), interactive DCF model with adjustable WACC and growth rate, comparable companies table</li> -<li><strong>News</strong> — Recent articles with Bullish / Bearish / Neutral sentiment badges and a 7-day sentiment summary</li> +<li><strong>News</strong> — Recent articles with heuristic Bullish / Bearish / Neutral tags and a 7-day sentiment summary</li> </ul> <hr /> <h2>Setup</h2> @@ -80,7 +80,7 @@ streamlit run app.py </code></pre> <hr /> <h2>DCF Model Notes</h2> -<p>The DCF model uses <strong>5 years of historical Free Cash Flow</strong> from yfinance to compute an average growth rate, then projects forward using your chosen assumptions:</p> +<p>The DCF model uses historical <strong>Free Cash Flow</strong> from yfinance, computes a capped median growth rate from valid positive-FCF periods, and then projects forward using your chosen assumptions:</p> <table> <thead> <tr> @@ -107,7 +107,7 @@ streamlit run app.py </tr> </tbody> </table> -<p>The model uses the <strong>Gordon Growth Model</strong> for terminal value. Intrinsic value per share is compared against the current market price to show upside/downside.</p> +<p>The model uses the <strong>Gordon Growth Model</strong> for terminal value. It first estimates <strong>enterprise value</strong>, then bridges to <strong>equity value</strong> using debt and cash before calculating value per share. Terminal growth must remain below WACC.</p> <hr /> <h2>API Rate Limits</h2> <table> 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. + Run a simple FCFF-style DCF and bridge enterprise value to equity value. - 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 + 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: |
