diff --git a/src/agents/valuation.py b/src/agents/valuation.py index b64c13eec..76e17ee87 100644 --- a/src/agents/valuation.py +++ b/src/agents/valuation.py @@ -398,54 +398,83 @@ def calculate_enhanced_dcf_value( market_cap: float, revenue_growth: float | None = None ) -> float: - """Enhanced DCF with multi-stage growth.""" - + """Enhanced DCF with multi-stage growth. + + Includes safeguards against unrealistic valuations by: + - Ensuring minimum spread between WACC and terminal growth + - Capping terminal value relative to market cap + - Applying sanity checks on final valuation + """ + if not fcf_history or fcf_history[0] <= 0: return 0 - + # Analyze FCF trend and quality fcf_current = fcf_history[0] fcf_avg_3yr = sum(fcf_history[:3]) / min(3, len(fcf_history)) fcf_volatility = calculate_fcf_volatility(fcf_history) - + # Stage 1: High Growth (Years 1-3) # Use revenue growth but cap based on business maturity high_growth = min(revenue_growth or 0.05, 0.25) if revenue_growth else 0.05 if market_cap > 50_000_000_000: # Large cap high_growth = min(high_growth, 0.10) - + # Stage 2: Transition (Years 4-7) transition_growth = (high_growth + 0.03) / 2 - + # Stage 3: Terminal (steady state) - terminal_growth = min(0.03, high_growth * 0.6) - + # Cap terminal growth at 2.5% to be more conservative + terminal_growth = min(0.025, high_growth * 0.5) + # Project FCF with stages pv = 0 base_fcf = max(fcf_current, fcf_avg_3yr * 0.85) # Conservative base - + # High growth stage for year in range(1, 4): fcf_projected = base_fcf * (1 + high_growth) ** year pv += fcf_projected / (1 + wacc) ** year - - # Transition stage + + # Transition stage - use cumulative growth calculation + fcf_end_high_growth = base_fcf * (1 + high_growth) ** 3 + cumulative_fcf = fcf_end_high_growth for year in range(4, 8): - transition_rate = transition_growth * (8 - year) / 4 # Declining - fcf_projected = base_fcf * (1 + high_growth) ** 3 * (1 + transition_rate) ** (year - 3) - pv += fcf_projected / (1 + wacc) ** year - - # Terminal value - final_fcf = base_fcf * (1 + high_growth) ** 3 * (1 + transition_growth) ** 4 - if wacc <= terminal_growth: - terminal_growth = wacc * 0.8 # Adjust if invalid + # Declining growth rate during transition + transition_rate = transition_growth * (8 - year) / 4 + cumulative_fcf = cumulative_fcf * (1 + transition_rate) + pv += cumulative_fcf / (1 + wacc) ** year + + # Terminal value with safeguards + final_fcf = cumulative_fcf + + # Ensure minimum spread between WACC and terminal growth (at least 4%) + min_spread = 0.04 + if wacc - terminal_growth < min_spread: + terminal_growth = wacc - min_spread + if terminal_growth < 0: + terminal_growth = 0.01 + wacc = max(wacc, min_spread + terminal_growth) + terminal_value = (final_fcf * (1 + terminal_growth)) / (wacc - terminal_growth) + + # Cap terminal value at 50x current market cap to prevent unrealistic valuations + max_terminal_value = market_cap * 50 if market_cap > 0 else terminal_value + terminal_value = min(terminal_value, max_terminal_value) + pv_terminal = terminal_value / (1 + wacc) ** 7 - + # Quality adjustment based on FCF volatility quality_factor = max(0.7, 1 - (fcf_volatility * 0.5)) - - return (pv + pv_terminal) * quality_factor + + intrinsic_value = (pv + pv_terminal) * quality_factor + + # Final sanity check: cap at 100x market cap for extreme growth scenarios + if market_cap > 0: + max_valuation = market_cap * 100 + intrinsic_value = min(intrinsic_value, max_valuation) + + return intrinsic_value def calculate_dcf_scenarios( @@ -455,21 +484,28 @@ def calculate_dcf_scenarios( market_cap: float, revenue_growth: float | None = None ) -> dict: - """Calculate DCF under multiple scenarios.""" - + """Calculate DCF under multiple scenarios. + + Applies scenario adjustments with safeguards: + - Minimum WACC floor of 7% to prevent unrealistic valuations + - Growth rate caps to maintain reasonable projections + """ + scenarios = { 'bear': {'growth_adj': 0.5, 'wacc_adj': 1.2, 'terminal_adj': 0.8}, 'base': {'growth_adj': 1.0, 'wacc_adj': 1.0, 'terminal_adj': 1.0}, 'bull': {'growth_adj': 1.5, 'wacc_adj': 0.9, 'terminal_adj': 1.2} } - + results = {} - base_revenue_growth = revenue_growth or 0.05 - + # Cap base revenue growth at 30% to prevent extreme projections + base_revenue_growth = min(revenue_growth or 0.05, 0.30) + for scenario, adjustments in scenarios.items(): adjusted_revenue_growth = base_revenue_growth * adjustments['growth_adj'] - adjusted_wacc = wacc * adjustments['wacc_adj'] - + # Apply WACC adjustment with minimum floor of 7% + adjusted_wacc = max(wacc * adjustments['wacc_adj'], 0.07) + results[scenario] = calculate_enhanced_dcf_value( fcf_history=fcf_history, growth_metrics=growth_metrics, @@ -477,14 +513,14 @@ def calculate_dcf_scenarios( market_cap=market_cap, revenue_growth=adjusted_revenue_growth ) - + # Probability-weighted average expected_value = ( - results['bear'] * 0.2 + - results['base'] * 0.6 + + results['bear'] * 0.2 + + results['base'] * 0.6 + results['bull'] * 0.2 ) - + return { 'scenarios': results, 'expected_value': expected_value, diff --git a/tests/test_dcf_valuation.py b/tests/test_dcf_valuation.py new file mode 100644 index 000000000..930aa83d5 --- /dev/null +++ b/tests/test_dcf_valuation.py @@ -0,0 +1,248 @@ +"""Tests for DCF valuation functions to prevent unrealistic valuations. + +These tests verify the fixes for issue #431 where DCF analysis was producing +unrealistic valuations (e.g., $16T for OKTA). +""" + +import pytest + +from src.agents.valuation import ( + calculate_enhanced_dcf_value, + calculate_dcf_scenarios, + calculate_wacc, + calculate_fcf_volatility, +) + + +class TestCalculateEnhancedDCFValue: + """Tests for calculate_enhanced_dcf_value function.""" + + def test_returns_zero_for_empty_fcf_history(self): + """Should return 0 when FCF history is empty.""" + result = calculate_enhanced_dcf_value( + fcf_history=[], + growth_metrics={}, + wacc=0.10, + market_cap=10_000_000_000, + ) + assert result == 0 + + def test_returns_zero_for_negative_fcf(self): + """Should return 0 when current FCF is negative.""" + result = calculate_enhanced_dcf_value( + fcf_history=[-100_000_000], + growth_metrics={}, + wacc=0.10, + market_cap=10_000_000_000, + ) + assert result == 0 + + def test_valuation_capped_at_100x_market_cap(self): + """Valuation should never exceed 100x market cap.""" + market_cap = 10_000_000_000 # $10B + result = calculate_enhanced_dcf_value( + fcf_history=[5_000_000_000] * 5, # Large FCF + growth_metrics={}, + wacc=0.06, # Low WACC + market_cap=market_cap, + revenue_growth=0.50, # High growth + ) + assert result <= market_cap * 100 + + def test_reasonable_valuation_for_typical_company(self): + """Should produce reasonable valuation for typical inputs.""" + market_cap = 10_000_000_000 # $10B + result = calculate_enhanced_dcf_value( + fcf_history=[500_000_000, 450_000_000, 400_000_000], # ~$500M FCF + growth_metrics={}, + wacc=0.10, + market_cap=market_cap, + revenue_growth=0.15, + ) + # Valuation should be positive and within reasonable bounds + assert result > 0 + assert result < market_cap * 50 # Less than 50x market cap + + def test_large_cap_growth_rate_limited(self): + """Large cap companies should have growth rate limited to 10%.""" + large_market_cap = 100_000_000_000 # $100B (large cap) + small_market_cap = 5_000_000_000 # $5B (small cap) + + fcf_history = [1_000_000_000] * 5 + + large_cap_result = calculate_enhanced_dcf_value( + fcf_history=fcf_history, + growth_metrics={}, + wacc=0.10, + market_cap=large_market_cap, + revenue_growth=0.25, # High growth requested + ) + + small_cap_result = calculate_enhanced_dcf_value( + fcf_history=fcf_history, + growth_metrics={}, + wacc=0.10, + market_cap=small_market_cap, + revenue_growth=0.25, # High growth requested + ) + + # Large cap should have lower valuation due to growth cap + # Note: Results are also capped by market_cap, so we compare relative to market cap + large_cap_multiple = large_cap_result / large_market_cap + small_cap_multiple = small_cap_result / small_market_cap + # Small cap can have higher multiple due to higher allowed growth + assert small_cap_multiple >= large_cap_multiple * 0.5 + + def test_wacc_terminal_growth_spread_enforced(self): + """Should maintain minimum spread between WACC and terminal growth.""" + result = calculate_enhanced_dcf_value( + fcf_history=[500_000_000] * 5, + growth_metrics={}, + wacc=0.05, # Very low WACC + market_cap=10_000_000_000, + revenue_growth=0.10, + ) + # Should still produce reasonable result due to safeguards + assert result > 0 + assert result < 10_000_000_000 * 100 + + +class TestCalculateDCFScenarios: + """Tests for calculate_dcf_scenarios function.""" + + def test_returns_all_scenario_keys(self): + """Should return all expected scenario keys.""" + result = calculate_dcf_scenarios( + fcf_history=[500_000_000] * 3, + growth_metrics={}, + wacc=0.10, + market_cap=10_000_000_000, + ) + assert 'scenarios' in result + assert 'expected_value' in result + assert 'range' in result + assert 'upside' in result + assert 'downside' in result + assert 'bear' in result['scenarios'] + assert 'base' in result['scenarios'] + assert 'bull' in result['scenarios'] + + def test_bear_less_than_base_less_than_bull(self): + """Bear case should be <= base <= bull.""" + result = calculate_dcf_scenarios( + fcf_history=[500_000_000] * 3, + growth_metrics={}, + wacc=0.10, + market_cap=10_000_000_000, + revenue_growth=0.15, + ) + assert result['scenarios']['bear'] <= result['scenarios']['base'] + assert result['scenarios']['base'] <= result['scenarios']['bull'] + + def test_expected_value_is_weighted_average(self): + """Expected value should be probability-weighted average.""" + result = calculate_dcf_scenarios( + fcf_history=[500_000_000] * 3, + growth_metrics={}, + wacc=0.10, + market_cap=10_000_000_000, + ) + expected = ( + result['scenarios']['bear'] * 0.2 + + result['scenarios']['base'] * 0.6 + + result['scenarios']['bull'] * 0.2 + ) + assert abs(result['expected_value'] - expected) < 1 # Allow small float error + + def test_wacc_floor_enforced_in_bull_scenario(self): + """WACC should not go below 7% even in bull scenario.""" + # With low WACC, bull scenario adjustment (0.9x) could go very low + result = calculate_dcf_scenarios( + fcf_history=[500_000_000] * 3, + growth_metrics={}, + wacc=0.06, # Low WACC, 0.9x would be 5.4% + market_cap=10_000_000_000, + revenue_growth=0.20, + ) + # Bull should still be reasonable due to WACC floor + assert result['scenarios']['bull'] < 10_000_000_000 * 100 + + def test_revenue_growth_capped_at_30_percent(self): + """Base revenue growth should be capped at 30%.""" + result = calculate_dcf_scenarios( + fcf_history=[500_000_000] * 3, + growth_metrics={}, + wacc=0.10, + market_cap=10_000_000_000, + revenue_growth=0.80, # Extremely high growth + ) + # Should produce reasonable results due to growth cap + assert result['expected_value'] < 10_000_000_000 * 100 + + def test_okta_like_scenario_no_trillion_valuation(self): + """Simulate OKTA-like inputs - should not produce $16T valuation. + + This is the specific test case from issue #431. + """ + # OKTA approximate values (as of issue date) + market_cap = 15_000_000_000 # ~$15B market cap + fcf_history = [300_000_000, 250_000_000, 200_000_000, 150_000_000] # Positive FCF + + result = calculate_dcf_scenarios( + fcf_history=fcf_history, + growth_metrics={}, + wacc=0.08, + market_cap=market_cap, + revenue_growth=0.25, # High growth tech company + ) + + # Valuation should be reasonable, definitely not $16T + assert result['expected_value'] < 1_000_000_000_000 # Less than $1T + assert result['expected_value'] < market_cap * 100 # Less than 100x market cap + # Bull case should also be reasonable + assert result['upside'] < 2_000_000_000_000 # Less than $2T + + +class TestCalculateWACC: + """Tests for calculate_wacc function.""" + + def test_wacc_floor_at_6_percent(self): + """WACC should have a floor of 6%.""" + result = calculate_wacc( + market_cap=10_000_000_000, + total_debt=0, + cash=5_000_000_000, # Lots of cash + interest_coverage=100, # Very high coverage + debt_to_equity=0, + ) + assert result >= 0.06 + + def test_wacc_cap_at_20_percent(self): + """WACC should be capped at 20%.""" + result = calculate_wacc( + market_cap=1_000_000, # Small market cap + total_debt=10_000_000, # High debt + cash=0, + interest_coverage=0.5, # Very low coverage + debt_to_equity=10, # Very high leverage + ) + assert result <= 0.20 + + +class TestCalculateFCFVolatility: + """Tests for calculate_fcf_volatility function.""" + + def test_default_volatility_for_short_history(self): + """Should return default 0.5 for history < 3 periods.""" + result = calculate_fcf_volatility([100, 200]) + assert result == 0.5 + + def test_high_volatility_for_mostly_negative_fcf(self): + """Should return high volatility (0.8) for mostly negative FCF.""" + result = calculate_fcf_volatility([-100, -200, 50, -150, -100]) + assert result == 0.8 + + def test_volatility_capped_at_1(self): + """Volatility should be capped at 1.0.""" + result = calculate_fcf_volatility([100, 1000, 50, 2000, 25]) + assert result <= 1.0