aboutsummaryrefslogtreecommitdiff
path: root/services
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 /services
parentad6b0b59c2a4f557d6d9d7fe9810c2ba7627580d (diff)
Fix valuation methodology and documentation
Diffstat (limited to 'services')
-rw-r--r--services/valuation_service.py121
1 files changed, 68 insertions, 53 deletions
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: