summaryrefslogtreecommitdiff
path: root/backend
diff options
context:
space:
mode:
Diffstat (limited to 'backend')
-rw-r--r--backend/app/services/data_service.py134
-rw-r--r--backend/tests/test_api.py104
2 files changed, 238 insertions, 0 deletions
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 == {}