diff options
Diffstat (limited to 'backend')
| -rw-r--r-- | backend/tests/test_dcf_advanced.py | 148 | ||||
| -rw-r--r-- | backend/tests/test_dcf_edge_cases.py | 155 | ||||
| -rw-r--r-- | backend/tests/test_dcf_math.py | 205 | ||||
| -rw-r--r-- | backend/tests/test_valuation_advanced_endpoint.py | 126 |
4 files changed, 634 insertions, 0 deletions
diff --git a/backend/tests/test_dcf_advanced.py b/backend/tests/test_dcf_advanced.py new file mode 100644 index 0000000..20f6921 --- /dev/null +++ b/backend/tests/test_dcf_advanced.py @@ -0,0 +1,148 @@ +import pytest + +from app.services.data_service import _run_dcf_explicit_build + + +_APPROX = pytest.approx + + +def test_run_dcf_explicit_build_two_year_reference_scenario() -> None: + # Given + base_revenue = 1_000.0 + + # When + result = _run_dcf_explicit_build( + base_revenue=base_revenue, + revenue_growth=[0.10, 0.05], + ebitda_margin=[0.30, 0.32], + dna_pct_revenue=[0.05, 0.05], + capex_pct_revenue=[0.07, 0.07], + nwc_chg_pct_delta_rev=[0.02, 0.02], + tax_rate=[0.25, 0.25], + wacc=0.10, + terminal_growth=0.03, + shares_outstanding=100.0, + total_debt=50.0, + cash=10.0, + preferred_equity=5.0, + minority_interest=3.0, + projection_years=2, + ) + + # Then + assert result["available"] is True + assert result["base_fcf"] == _APPROX(184.25) + assert result["fcf_pv_sum"] == _APPROX(357.42928814344134) + assert result["terminal_value_pv"] == _APPROX(2_674.37743224556) + assert result["enterprise_value"] == _APPROX(3_031.8067203890014) + assert result["net_debt"] == 40.0 + assert result["equity_value"] == _APPROX(2_983.8067203890014) + assert result["intrinsic_value_per_share"] == _APPROX(29.838067203890013) + assert result["wacc"] == 0.10 + assert result["terminal_growth"] == 0.03 + assert result["projection_years"] == 2 + + +def test_run_dcf_explicit_build_terminal_growth_at_wacc_returns_error() -> None: + # Given / When + result = _run_dcf_explicit_build( + base_revenue=1_000.0, + revenue_growth=[0.05], + ebitda_margin=[0.30], + dna_pct_revenue=[0.05], + capex_pct_revenue=[0.07], + nwc_chg_pct_delta_rev=[0.02], + tax_rate=[0.25], + wacc=0.10, + terminal_growth=0.10, + shares_outstanding=100.0, + total_debt=0.0, + cash=0.0, + preferred_equity=0.0, + minority_interest=0.0, + projection_years=1, + ) + + # Then + assert result["available"] is True + assert "Terminal growth" in result["error"] + assert "intrinsic_value_per_share" not in result + + +def test_run_dcf_explicit_build_zero_wacc_returns_error() -> None: + # Given / When + result = _run_dcf_explicit_build( + base_revenue=1_000.0, + revenue_growth=[0.05], + ebitda_margin=[0.30], + dna_pct_revenue=[0.05], + capex_pct_revenue=[0.07], + nwc_chg_pct_delta_rev=[0.02], + tax_rate=[0.25], + wacc=0.0, + terminal_growth=0.03, + shares_outstanding=100.0, + total_debt=0.0, + cash=0.0, + preferred_equity=0.0, + minority_interest=0.0, + projection_years=1, + ) + + # Then + assert result["available"] is True + assert "WACC" in result["error"] + assert "intrinsic_value_per_share" not in result + + +@pytest.mark.parametrize("shares_outstanding", [0.0, -1.0]) +def test_run_dcf_explicit_build_nonpositive_shares_return_error(shares_outstanding: float) -> None: + # Given / When + result = _run_dcf_explicit_build( + base_revenue=1_000.0, + revenue_growth=[0.05], + ebitda_margin=[0.30], + dna_pct_revenue=[0.05], + capex_pct_revenue=[0.07], + nwc_chg_pct_delta_rev=[0.02], + tax_rate=[0.25], + wacc=0.10, + terminal_growth=0.03, + shares_outstanding=shares_outstanding, + total_debt=0.0, + cash=0.0, + preferred_equity=0.0, + minority_interest=0.0, + projection_years=1, + ) + + # Then + assert result["available"] is True + assert "Shares outstanding" in result["error"] + assert "intrinsic_value_per_share" not in result + + +def test_run_dcf_explicit_build_zero_base_revenue_returns_error() -> None: + # Given / When + result = _run_dcf_explicit_build( + base_revenue=0.0, + revenue_growth=[0.05], + ebitda_margin=[0.30], + dna_pct_revenue=[0.05], + capex_pct_revenue=[0.07], + nwc_chg_pct_delta_rev=[0.02], + tax_rate=[0.25], + wacc=0.10, + terminal_growth=0.03, + shares_outstanding=100.0, + total_debt=0.0, + cash=0.0, + preferred_equity=0.0, + minority_interest=0.0, + projection_years=1, + ) + + # Then + assert result["available"] is True + assert "Base revenue" in result["error"] + assert "intrinsic_value_per_share" not in result diff --git a/backend/tests/test_dcf_edge_cases.py b/backend/tests/test_dcf_edge_cases.py new file mode 100644 index 0000000..94a960b --- /dev/null +++ b/backend/tests/test_dcf_edge_cases.py @@ -0,0 +1,155 @@ +"""Boundary and invalid-input tests for the DCF engine.""" + +import pandas as pd +import pytest + +from app.services.data_service import _run_dcf + + +def _fcf_series(base: float, growth: float) -> pd.Series: + """Return a two-point historical FCF series whose median YoY growth equals `growth`.""" + prior = base / (1 + growth) + return pd.Series([prior, base], index=pd.to_datetime(["2023-09-30", "2024-09-30"])) + + +def _expected_mid_year_enterprise_value( + base_fcf: float, + growth: float, + wacc: float, + terminal_growth: float, + projection_years: int, +) -> float: + projected = [base_fcf * ((1 + growth) ** year) for year in range(1, projection_years + 1)] + discounted = [fcf / ((1 + wacc) ** (year - 0.5)) for year, fcf in enumerate(projected, start=1)] + terminal_fcf = projected[-1] * (1 + terminal_growth) + terminal_value = terminal_fcf / (wacc - terminal_growth) + terminal_value_pv = terminal_value / ((1 + wacc) ** (projection_years - 0.5)) + return sum(discounted) + terminal_value_pv + + +def test_run_dcf_two_year_horizon_uses_mid_year_discounting() -> None: + fcf = _fcf_series(100.0, 0.05) + expected_enterprise_value = _expected_mid_year_enterprise_value( + base_fcf=100.0, + growth=0.05, + wacc=0.10, + terminal_growth=0.03, + projection_years=2, + ) + + result = _run_dcf( + fcf_series=fcf, + shares_outstanding=10.0, + wacc=0.10, + terminal_growth=0.03, + projection_years=2, + ) + + assert result["enterprise_value"] == pytest.approx(expected_enterprise_value) + + +def test_run_dcf_terminal_growth_equals_wacc_is_error() -> None: + fcf = _fcf_series(100.0, 0.05) + result = _run_dcf( + fcf_series=fcf, + shares_outstanding=10.0, + wacc=0.10, + terminal_growth=0.10, + projection_years=5, + ) + assert "error" in result + assert "Terminal growth" in result["error"] + assert "intrinsic_value_per_share" not in result + + +def test_run_dcf_terminal_growth_above_wacc_is_error() -> None: + fcf = _fcf_series(100.0, 0.05) + result = _run_dcf( + fcf_series=fcf, + shares_outstanding=10.0, + wacc=0.10, + terminal_growth=0.11, + projection_years=5, + ) + assert "error" in result + assert "Terminal growth" in result["error"] + assert "intrinsic_value_per_share" not in result + + +def test_run_dcf_zero_wacc_is_error() -> None: + fcf = _fcf_series(100.0, 0.05) + result = _run_dcf( + fcf_series=fcf, + shares_outstanding=10.0, + wacc=0.0, + terminal_growth=0.03, + projection_years=5, + ) + assert "error" in result + assert "WACC" in result["error"] + assert "intrinsic_value_per_share" not in result + + +def test_run_dcf_negative_base_fcf_is_error() -> None: + """The most recent FCF is negative, making the DCF not meaningful.""" + fcf = pd.Series([100.0, -50.0], index=pd.to_datetime(["2023-09-30", "2024-09-30"])) + result = _run_dcf( + fcf_series=fcf, + shares_outstanding=10.0, + wacc=0.10, + terminal_growth=0.03, + projection_years=5, + ) + assert "error" in result + assert "negative" in result["error"].lower() or "zero" in result["error"].lower() + assert "intrinsic_value_per_share" not in result + + +def test_run_dcf_zero_base_fcf_is_error() -> None: + fcf = pd.Series([100.0, 0.0], index=pd.to_datetime(["2023-09-30", "2024-09-30"])) + result = _run_dcf( + fcf_series=fcf, + shares_outstanding=10.0, + wacc=0.10, + terminal_growth=0.03, + projection_years=5, + ) + assert "error" in result + assert "zero" in result["error"].lower() + assert "intrinsic_value_per_share" not in result + + +def test_run_dcf_zero_shares_returns_empty() -> None: + fcf = _fcf_series(100.0, 0.05) + result = _run_dcf( + fcf_series=fcf, + shares_outstanding=0.0, + wacc=0.10, + terminal_growth=0.03, + projection_years=5, + ) + assert result == {} + + +def test_run_dcf_negative_shares_returns_empty() -> None: + fcf = _fcf_series(100.0, 0.05) + result = _run_dcf( + fcf_series=fcf, + shares_outstanding=-1.0, + wacc=0.10, + terminal_growth=0.03, + projection_years=5, + ) + assert result == {} + + +def test_run_dcf_insufficient_history_returns_empty() -> None: + fcf = pd.Series([100.0], index=pd.to_datetime(["2024-09-30"])) + result = _run_dcf( + fcf_series=fcf, + shares_outstanding=10.0, + wacc=0.10, + terminal_growth=0.03, + projection_years=5, + ) + assert result == {} diff --git a/backend/tests/test_dcf_math.py b/backend/tests/test_dcf_math.py new file mode 100644 index 0000000..0418a02 --- /dev/null +++ b/backend/tests/test_dcf_math.py @@ -0,0 +1,205 @@ +"""Exact-math verification tests for the DCF engine. + +Each scenario independently recomputes the expected projection, discounting, +terminal value, enterprise value, equity value, and per-share intrinsic value, +then asserts the implementation matches. This makes the test itself auditable. +""" + +import pandas as pd +import pytest + +from app.services.data_service import _run_dcf + + +def _fcf_series(prior: float, base: float) -> pd.Series: + """Return a two-point historical FCF series: prior -> base (most recent).""" + return pd.Series([prior, base], index=pd.to_datetime(["2023-09-30", "2024-09-30"])) + + +def _expected_dcf( + base_fcf: float, + growth_rate: float, + wacc: float, + terminal_growth: float, + projection_years: int, + shares_outstanding: float, + total_debt: float = 0.0, + cash_and_equivalents: float = 0.0, + preferred_equity: float = 0.0, + minority_interest: float = 0.0, +) -> dict: + """Independent reference implementation of the DCF math used by `_run_dcf`.""" + projected = [base_fcf * ((1 + growth_rate) ** yr) for yr in range(1, projection_years + 1)] + discounted = [fcf / ((1 + wacc) ** (year - 0.5)) for year, fcf in enumerate(projected, start=1)] + fcf_pv_sum = sum(discounted) + + terminal_fcf = projected[-1] * (1 + terminal_growth) + terminal_value = terminal_fcf / (wacc - terminal_growth) + terminal_value_pv = terminal_value / ((1 + wacc) ** (projection_years - 0.5)) + + enterprise_value = fcf_pv_sum + terminal_value_pv + 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 { + "fcf_pv_sum": fcf_pv_sum, + "terminal_value_pv": terminal_value_pv, + "enterprise_value": enterprise_value, + "net_debt": net_debt, + "equity_value": equity_value, + "intrinsic_value_per_share": intrinsic_value_per_share, + } + + +_APPROX = pytest.approx + + +def test_run_dcf_one_year_with_equity_bridge() -> None: + """1-year projection with debt, cash, preferred, and minority claims.""" + fcf = _fcf_series(100.0, 105.0) # exact 5% growth + expected = _expected_dcf( + base_fcf=105.0, + growth_rate=0.05, + wacc=0.10, + terminal_growth=0.03, + projection_years=1, + shares_outstanding=10.0, + total_debt=20.0, + cash_and_equivalents=5.0, + preferred_equity=2.0, + minority_interest=3.0, + ) + + result = _run_dcf( + fcf_series=fcf, + shares_outstanding=10.0, + wacc=0.10, + terminal_growth=0.03, + projection_years=1, + total_debt=20.0, + cash_and_equivalents=5.0, + preferred_equity=2.0, + minority_interest=3.0, + ) + + assert result["base_fcf"] == 105.0 + assert result["growth_rate_used"] == _APPROX(0.05) + assert result["fcf_pv_sum"] == _APPROX(expected["fcf_pv_sum"]) + assert result["terminal_value_pv"] == _APPROX(expected["terminal_value_pv"]) + assert result["enterprise_value"] == _APPROX(expected["enterprise_value"]) + assert result["net_debt"] == expected["net_debt"] + assert result["equity_value"] == _APPROX(expected["equity_value"]) + assert result["intrinsic_value_per_share"] == _APPROX(expected["intrinsic_value_per_share"]) + + +def test_run_dcf_three_years_no_claims() -> None: + """3-year projection with a clean capital structure.""" + fcf = _fcf_series(100.0, 105.0) + expected = _expected_dcf( + base_fcf=105.0, + growth_rate=0.05, + wacc=0.10, + terminal_growth=0.03, + projection_years=3, + shares_outstanding=10.0, + ) + + result = _run_dcf( + fcf_series=fcf, + shares_outstanding=10.0, + wacc=0.10, + terminal_growth=0.03, + projection_years=3, + ) + + assert result["base_fcf"] == 105.0 + assert result["fcf_pv_sum"] == _APPROX(expected["fcf_pv_sum"]) + assert result["terminal_value_pv"] == _APPROX(expected["terminal_value_pv"]) + assert result["enterprise_value"] == _APPROX(expected["enterprise_value"]) + assert result["equity_value"] == _APPROX(expected["equity_value"]) + assert result["intrinsic_value_per_share"] == _APPROX(expected["intrinsic_value_per_share"]) + assert result["net_debt"] == 0.0 + + +def test_run_dcf_five_years_default_horizon() -> None: + """5-year projection matching the API default horizon.""" + fcf = _fcf_series(100.0, 105.0) + expected = _expected_dcf( + base_fcf=105.0, + growth_rate=0.05, + wacc=0.10, + terminal_growth=0.03, + projection_years=5, + shares_outstanding=10.0, + ) + + result = _run_dcf( + fcf_series=fcf, + shares_outstanding=10.0, + wacc=0.10, + terminal_growth=0.03, + projection_years=5, + ) + + assert result["base_fcf"] == 105.0 + assert result["fcf_pv_sum"] == _APPROX(expected["fcf_pv_sum"]) + assert result["terminal_value_pv"] == _APPROX(expected["terminal_value_pv"]) + assert result["enterprise_value"] == _APPROX(expected["enterprise_value"]) + assert result["equity_value"] == _APPROX(expected["equity_value"]) + assert result["intrinsic_value_per_share"] == _APPROX(expected["intrinsic_value_per_share"]) + + +def test_run_dcf_zero_growth_flat_fcf() -> None: + """Edge of the model: zero growth projects a flat FCF stream.""" + fcf = _fcf_series(100.0, 100.0) # exact 0% growth + expected = _expected_dcf( + base_fcf=100.0, + growth_rate=0.0, + wacc=0.10, + terminal_growth=0.02, + projection_years=5, + shares_outstanding=10.0, + ) + + result = _run_dcf( + fcf_series=fcf, + shares_outstanding=10.0, + wacc=0.10, + terminal_growth=0.02, + projection_years=5, + ) + + assert result["growth_rate_used"] == _APPROX(0.0) + assert result["fcf_pv_sum"] == _APPROX(expected["fcf_pv_sum"]) + assert result["terminal_value_pv"] == _APPROX(expected["terminal_value_pv"]) + assert result["enterprise_value"] == _APPROX(expected["enterprise_value"]) + assert result["intrinsic_value_per_share"] == _APPROX(expected["intrinsic_value_per_share"]) + + +def test_run_dcf_uses_historical_growth_rate() -> None: + """End-to-end check that the growth-rate estimator feeds the projection. + + A series rising exactly 5% per year should produce the same 5-year result as + the hand-computed 5-year scenario (within float tolerance). + """ + fcf = _fcf_series(100.0, 105.0) + expected = _expected_dcf( + base_fcf=105.0, + growth_rate=0.05, + wacc=0.10, + terminal_growth=0.03, + projection_years=5, + shares_outstanding=10.0, + ) + + result = _run_dcf( + fcf_series=fcf, + shares_outstanding=10.0, + wacc=0.10, + terminal_growth=0.03, + projection_years=5, + ) + + assert result["growth_rate_used"] == _APPROX(0.05) + assert result["intrinsic_value_per_share"] == _APPROX(expected["intrinsic_value_per_share"]) diff --git a/backend/tests/test_valuation_advanced_endpoint.py b/backend/tests/test_valuation_advanced_endpoint.py new file mode 100644 index 0000000..3cdb857 --- /dev/null +++ b/backend/tests/test_valuation_advanced_endpoint.py @@ -0,0 +1,126 @@ +import pandas as pd +import pytest +from fastapi.testclient import TestClient + +from app import main +from app.services import data_service + + +def annual_frame(rows: dict[str, list[float]]) -> pd.DataFrame: + columns = pd.to_datetime(["2024-09-30", "2023-09-30", "2022-09-30", "2021-09-30"]) + return pd.DataFrame(rows, index=columns).T + + +def quarterly_frame(rows: dict[str, list[float]]) -> pd.DataFrame: + columns = pd.to_datetime(["2025-12-31", "2025-09-30", "2025-06-30", "2025-03-31"]) + return pd.DataFrame(rows, index=columns).T + + +def clear_valuation_cache() -> None: + data_service.VALUATION_CACHE.clear() + + +def advanced_payload() -> dict[str, float | int | list[float]]: + return { + "base_revenue": 1_000.0, + "revenue_growth": [0.10, 0.05], + "ebitda_margin": [0.30, 0.32], + "dna_pct_revenue": [0.05, 0.05], + "capex_pct_revenue": [0.07, 0.07], + "nwc_chg_pct_delta_rev": [0.02, 0.02], + "tax_rate": [0.25, 0.25], + "wacc": 0.10, + "terminal_growth": 0.03, + "projection_years": 2, + } + + +def test_simple_valuation_includes_five_by_five_sensitivity_grid(monkeypatch: pytest.MonkeyPatch) -> None: + # Given: historical free cash flow data for the simple DCF endpoint. + clear_valuation_cache() + cash_flow = annual_frame({ + "Operating Cash Flow": [100.0, 90.0, 80.0, 70.0], + "Capital Expenditure": [-10.0, -9.0, -8.0, -7.0], + }) + monkeypatch.setattr(data_service, "get_cash_flow", lambda symbol, quarterly=False: pd.DataFrame() if quarterly else cash_flow) + monkeypatch.setattr(data_service, "get_income_statement", lambda symbol, quarterly=False: pd.DataFrame()) + monkeypatch.setattr(data_service, "get_balance_sheet", lambda symbol, quarterly=False: pd.DataFrame()) + monkeypatch.setattr(data_service, "get_company_info", lambda symbol: {"currentPrice": 150.0}) + monkeypatch.setattr(data_service, "get_shares_outstanding", lambda symbol: 1_000_000_000.0) + + # When: the simple valuation service is called. + result = data_service.get_valuation("ref") + + # Then: the DCF result includes the mandated WACC x terminal-growth matrix. + dcf = result["dcf"] + sensitivity = dcf["sensitivity"] + assert result["currency"] == "USD" + assert result["currency_warning"] is None + assert sensitivity["wacc"] == [0.08, 0.09, 0.10, 0.11, 0.12] + assert sensitivity["terminal_growth"] == [0.015, 0.02, 0.03, 0.04, 0.045] + assert len(sensitivity["implied_prices"]) == 5 + assert all(len(row) == 5 for row in sensitivity["implied_prices"]) + assert sensitivity["implied_prices"][2][2] == pytest.approx(dcf["intrinsic_value_per_share"]) + + +def test_advanced_valuation_endpoint_returns_inputs_and_sensitivity(monkeypatch: pytest.MonkeyPatch) -> None: + # Given: symbol-level balance sheet inputs and a base valuation shell. + clear_valuation_cache() + monkeypatch.setattr( + data_service, + "get_valuation", + lambda symbol: { + "symbol": "REF", + "current_price": 42.0, + "shares_outstanding": 100.0, + "dcf": {"available": False, "wacc": 0.10, "terminal_growth": 0.03, "projection_years": 5}, + "ev_ebitda": {"available": False}, + "ev_revenue": {"available": False}, + "price_to_book": {"available": False}, + }, + ) + monkeypatch.setattr(data_service, "get_shares_outstanding", lambda symbol: 100.0) + monkeypatch.setattr( + data_service, + "get_balance_sheet", + lambda symbol, quarterly=False: quarterly_frame({ + "Total Debt": [50.0, 0.0, 0.0, 0.0], + "Cash And Cash Equivalents": [10.0, 0.0, 0.0, 0.0], + "Preferred Stock": [5.0, 0.0, 0.0, 0.0], + "Minority Interest": [3.0, 0.0, 0.0, 0.0], + }), + ) + client = TestClient(main.app) + + # When: the advanced valuation endpoint receives an explicit build payload. + response = client.post("/api/tickers/REF/valuation/advanced", json=advanced_payload()) + + # Then: it returns a full valuation response with base-case-centered sensitivity. + assert response.status_code == 200 + body = response.json() + dcf = body["dcf"] + sensitivity = dcf["sensitivity"] + assert body["symbol"] == "REF" + assert body["currency"] == "USD" + assert body["currency_warning"] is None + assert dcf["advanced_inputs"] == advanced_payload() + assert dcf["intrinsic_value_per_share"] == pytest.approx(29.838067203890013) + assert sensitivity["wacc"] == [0.08, 0.09, 0.10, 0.11, 0.12] + assert sensitivity["terminal_growth"] == [0.015, 0.02, 0.03, 0.04, 0.045] + assert len(sensitivity["implied_prices"]) == 5 + assert all(len(row) == 5 for row in sensitivity["implied_prices"]) + assert sensitivity["implied_prices"][2][2] == pytest.approx(dcf["intrinsic_value_per_share"]) + + +def test_advanced_valuation_endpoint_rejects_short_projection_arrays() -> None: + # Given: a payload whose per-year arrays do not cover projection_years. + payload = advanced_payload() + payload["revenue_growth"] = [0.10] + client = TestClient(main.app) + + # When: the invalid payload is submitted. + response = client.post("/api/tickers/REF/valuation/advanced", json=payload) + + # Then: FastAPI returns a validation response instead of an unhandled exception. + assert response.status_code == 422 + assert "projection_years" in response.text |
