aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorOpenclaw <openclaw@mail.tylerhoang.xyz>2026-03-29 01:12:24 -0700
committerOpenclaw <openclaw@mail.tylerhoang.xyz>2026-03-29 01:12:24 -0700
commit547997cbd069e9b958b12a8da38b3a4a257e29e5 (patch)
treedbae519a7c6c8f2d803e58e9a77f9a9db73da969
parentad6b0b59c2a4f557d6d9d7fe9810c2ba7627580d (diff)
Fix valuation methodology and documentation
-rw-r--r--README.md6
-rw-r--r--components/news.py13
-rw-r--r--components/valuation.py33
-rw-r--r--readme6
-rw-r--r--services/valuation_service.py121
5 files changed, 111 insertions, 68 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 @@
<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: