From 0811b116b992ac977630c7deb687f995d89dc9a6 Mon Sep 17 00:00:00 2001 From: Tyler Hoang Date: Mon, 18 May 2026 01:27:30 -0700 Subject: feat: add _run_dcf, _run_ev_ebitda, _run_ev_revenue, _run_price_to_book --- backend/app/services/data_service.py | 134 +++++++++++++++++++++++++++++++++++ backend/tests/test_api.py | 104 +++++++++++++++++++++++++++ 2 files changed, 238 insertions(+) (limited to 'backend') diff --git a/backend/app/services/data_service.py b/backend/app/services/data_service.py index 9a88005..c7077db 100644 --- a/backend/app/services/data_service.py +++ b/backend/app/services/data_service.py @@ -322,6 +322,140 @@ def _build_multiple_result(raw: dict) -> dict: } +def _run_dcf( + fcf_series: "pd.Series", + shares_outstanding: float, + wacc: float = 0.10, + terminal_growth: float = 0.03, + projection_years: int = 5, + total_debt: float = 0.0, + cash_and_equivalents: float = 0.0, + preferred_equity: float = 0.0, + minority_interest: float = 0.0, +) -> dict: + if fcf_series.empty or shares_outstanding <= 0: + return {} + 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."} + + growth_rate = _dcf_capped_growth_rate(fcf_series) + if growth_rate is None: + growth_rate = 0.05 + + base_fcf = float(historical[-1]) + if base_fcf <= 0: + return { + "error": ( + "DCF is not meaningful with zero or negative base free cash flow. " + "Use comps, EV/EBITDA, or adjust the model after underwriting a credible FCF turnaround." + ) + } + + projected = [base_fcf * ((1 + growth_rate) ** yr) for yr in range(1, projection_years + 1)] + discounted = [fcf / ((1 + wacc) ** i) for i, fcf in enumerate(projected, start=1)] + fcf_pv_sum = float(sum(discounted)) + + terminal_fcf = float(projected[-1]) * (1 + terminal_growth) + terminal_value = terminal_fcf / (wacc - terminal_growth) + terminal_value_pv = terminal_value / ((1 + wacc) ** projection_years) + + 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, + "enterprise_value": enterprise_value, + "equity_value": equity_value, + "net_debt": net_debt, + "cash_and_equivalents": cash_and_equivalents, + "total_debt": total_debt, + "terminal_value_pv": terminal_value_pv, + "fcf_pv_sum": fcf_pv_sum, + "growth_rate_used": growth_rate, + "base_fcf": base_fcf, + } + + +def _run_ev_ebitda( + ebitda: float, + total_debt: float, + total_cash: float, + preferred_equity: float, + minority_interest: float, + shares_outstanding: float, + target_multiple: float, +) -> dict: + if not ebitda or ebitda <= 0: + return {} + if not shares_outstanding or shares_outstanding <= 0: + return {} + if not target_multiple or target_multiple <= 0: + return {} + implied_ev = ebitda * target_multiple + net_debt = (total_debt or 0.0) - (total_cash or 0.0) + other_claims = (preferred_equity or 0.0) + (minority_interest or 0.0) + equity_value = implied_ev - net_debt - other_claims + return { + "implied_ev": implied_ev, + "net_debt": net_debt, + "equity_value": equity_value, + "implied_price_per_share": equity_value / shares_outstanding, + "target_multiple_used": target_multiple, + } + + +def _run_ev_revenue( + revenue: float, + total_debt: float, + total_cash: float, + preferred_equity: float, + minority_interest: float, + shares_outstanding: float, + target_multiple: float, +) -> dict: + if not revenue or revenue <= 0: + return {} + if not shares_outstanding or shares_outstanding <= 0: + return {} + if not target_multiple or target_multiple <= 0: + return {} + implied_ev = revenue * target_multiple + net_debt = (total_debt or 0.0) - (total_cash or 0.0) + other_claims = (preferred_equity or 0.0) + (minority_interest or 0.0) + equity_value = implied_ev - net_debt - other_claims + return { + "implied_ev": implied_ev, + "net_debt": net_debt, + "equity_value": equity_value, + "implied_price_per_share": equity_value / shares_outstanding, + "target_multiple_used": target_multiple, + } + + +def _run_price_to_book(book_value_per_share: float, target_multiple: float) -> dict: + if not book_value_per_share or book_value_per_share <= 0: + return {} + if not target_multiple or target_multiple <= 0: + return {} + return { + "implied_price_per_share": float(book_value_per_share) * float(target_multiple), + "target_multiple_used": float(target_multiple), + "book_value_per_share": float(book_value_per_share), + } + + @cached(FINANCIALS_CACHE) def get_financials(symbol: str, period: str = "annual") -> dict: sym = normalize_symbol(symbol) diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index a5c4eb5..8c7edb8 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -684,3 +684,107 @@ def test_dcf_capped_growth_rate_skips_sign_flip() -> None: series = pd.Series([-10.0, 20.0], index=pd.to_datetime(["2022", "2023"])) result = data_service._dcf_capped_growth_rate(series) assert result is None + + +def test_run_dcf_happy_path() -> None: + import pandas as pd + fcf = pd.Series( + [70.0, 80.0, 90.0, 100.0], + index=pd.to_datetime(["2021", "2022", "2023", "2024"]), + ) + result = data_service._run_dcf(fcf, shares_outstanding=1_000_000_000.0) + assert "intrinsic_value_per_share" in result + assert result["intrinsic_value_per_share"] > 0 + assert "growth_rate_used" in result + assert "enterprise_value" in result + assert "net_debt" in result + + +def test_run_dcf_negative_base_fcf() -> None: + import pandas as pd + # last (most recent) FCF is negative + fcf = pd.Series( + [100.0, 90.0, 80.0, -50.0], + index=pd.to_datetime(["2021", "2022", "2023", "2024"]), + ) + result = data_service._run_dcf(fcf, shares_outstanding=1_000_000_000.0) + assert "error" in result + assert result["error"] + + +def test_run_dcf_insufficient_history() -> None: + import pandas as pd + fcf = pd.Series([100.0], index=pd.to_datetime(["2024"])) + result = data_service._run_dcf(fcf, shares_outstanding=1_000_000_000.0) + assert result == {} + + +def test_run_dcf_zero_shares() -> None: + import pandas as pd + fcf = pd.Series([100.0, 110.0], index=pd.to_datetime(["2023", "2024"])) + result = data_service._run_dcf(fcf, shares_outstanding=0.0) + assert result == {} + + +def test_run_ev_ebitda_happy_path() -> None: + result = data_service._run_ev_ebitda( + ebitda=100.0, + total_debt=50.0, + total_cash=20.0, + preferred_equity=0.0, + minority_interest=0.0, + shares_outstanding=10.0, + target_multiple=15.0, + ) + # implied_ev = 100 * 15 = 1500; net_debt = 50-20 = 30; equity = 1470; per_share = 147 + assert result["implied_price_per_share"] == 147.0 + assert result["implied_ev"] == 1500.0 + assert result["net_debt"] == 30.0 + + +def test_run_ev_ebitda_zero_ebitda() -> None: + result = data_service._run_ev_ebitda( + ebitda=0.0, total_debt=0.0, total_cash=0.0, + preferred_equity=0.0, minority_interest=0.0, + shares_outstanding=10.0, target_multiple=15.0, + ) + assert result == {} + + +def test_run_ev_revenue_happy_path() -> None: + result = data_service._run_ev_revenue( + revenue=500.0, + total_debt=50.0, + total_cash=20.0, + preferred_equity=0.0, + minority_interest=0.0, + shares_outstanding=10.0, + target_multiple=5.0, + ) + # implied_ev = 500*5 = 2500; net_debt = 30; equity = 2470; per_share = 247 + assert result["implied_price_per_share"] == 247.0 + + +def test_run_ev_revenue_zero_revenue() -> None: + result = data_service._run_ev_revenue( + revenue=0.0, total_debt=0.0, total_cash=0.0, + preferred_equity=0.0, minority_interest=0.0, + shares_outstanding=10.0, target_multiple=5.0, + ) + assert result == {} + + +def test_run_price_to_book_happy_path() -> None: + result = data_service._run_price_to_book( + book_value_per_share=20.0, target_multiple=3.0 + ) + assert result["implied_price_per_share"] == 60.0 + assert result["target_multiple_used"] == 3.0 + assert result["book_value_per_share"] == 20.0 + + +def test_run_price_to_book_zero_bvps() -> None: + result = data_service._run_price_to_book( + book_value_per_share=0.0, target_multiple=3.0 + ) + assert result == {} -- cgit v1.3-2-g0d8e