diff options
| author | Tyler Hoang <tyler@tylerhoang.xyz> | 2026-05-18 01:31:30 -0700 |
|---|---|---|
| committer | Tyler Hoang <tyler@tylerhoang.xyz> | 2026-05-18 01:31:30 -0700 |
| commit | b877047a76cd4b661dccfd1dc8c0e7f2aa6a346c (patch) | |
| tree | fa372d0b7eda271a1a88550a3c372f148309f66e /backend | |
| parent | 0811b116b992ac977630c7deb687f995d89dc9a6 (diff) | |
feat: add get_valuation() service function
Diffstat (limited to 'backend')
| -rw-r--r-- | backend/app/services/data_service.py | 103 | ||||
| -rw-r--r-- | backend/tests/test_api.py | 109 |
2 files changed, 212 insertions, 0 deletions
diff --git a/backend/app/services/data_service.py b/backend/app/services/data_service.py index c7077db..9fe7f67 100644 --- a/backend/app/services/data_service.py +++ b/backend/app/services/data_service.py @@ -456,6 +456,109 @@ def _run_price_to_book(book_value_per_share: float, target_multiple: float) -> d } +@cached(VALUATION_CACHE) +def get_valuation(symbol: str) -> dict: + sym = normalize_symbol(symbol) + + cf_annual = get_cash_flow(sym, quarterly=False) + inc_q = get_income_statement(sym, quarterly=True) + bal_q = get_balance_sheet(sym, quarterly=True) + info = get_company_info(sym) + shares = get_shares_outstanding(sym) + + current_price = _safe_float(info.get("currentPrice")) + + total_debt = _balance_value(bal_q, "Total Debt") or 0.0 + cash = _balance_value( + bal_q, "Cash And Cash Equivalents", + "Cash Cash Equivalents And Short Term Investments" + ) or 0.0 + preferred = _balance_value(bal_q, "Preferred Stock") or 0.0 + minority = _balance_value(bal_q, "Minority Interest") or 0.0 + equity = _balance_value(bal_q, "Stockholders Equity", "Common Stock Equity") + + ebitda_ttm = _statement_ttm(inc_q, "EBITDA", "Normalized EBITDA") + revenue_ttm = _statement_ttm(inc_q, "Total Revenue") + + book_value_per_share: float | None = None + if equity is not None and shares is not None and shares > 0: + book_value_per_share = equity / shares + + ev_ebitda_multiple = _safe_float(info.get("enterpriseToEbitda")) + ev_revenue_multiple = _safe_float(info.get("enterpriseToRevenue")) + pb_multiple = _safe_float(info.get("priceToBook")) + + fcf_series = _build_fcf_series(cf_annual) + dcf_raw: dict = {} + if fcf_series is not None and shares is not None and shares > 0: + dcf_raw = _run_dcf( + fcf_series=fcf_series, + shares_outstanding=shares, + total_debt=total_debt, + cash_and_equivalents=cash, + preferred_equity=preferred, + minority_interest=minority, + ) + + if not dcf_raw: + dcf_out: dict = {"available": False, "wacc": 0.10, "terminal_growth": 0.03} + elif "error" in dcf_raw: + dcf_out = {"available": True, "error": dcf_raw["error"], "wacc": 0.10, "terminal_growth": 0.03} + else: + dcf_out = { + "available": True, + "intrinsic_value_per_share": dcf_raw.get("intrinsic_value_per_share"), + "enterprise_value": dcf_raw.get("enterprise_value"), + "equity_value": dcf_raw.get("equity_value"), + "net_debt": dcf_raw.get("net_debt"), + "cash_and_equivalents": dcf_raw.get("cash_and_equivalents"), + "total_debt": dcf_raw.get("total_debt"), + "terminal_value_pv": dcf_raw.get("terminal_value_pv"), + "fcf_pv_sum": dcf_raw.get("fcf_pv_sum"), + "growth_rate_used": dcf_raw.get("growth_rate_used"), + "base_fcf": dcf_raw.get("base_fcf"), + "wacc": 0.10, + "terminal_growth": 0.03, + } + + common = dict( + total_debt=total_debt, + total_cash=cash, + preferred_equity=preferred, + minority_interest=minority, + shares_outstanding=shares or 0.0, + ) + + ev_ebitda_out = _build_multiple_result( + _run_ev_ebitda(ebitda=ebitda_ttm, target_multiple=ev_ebitda_multiple, **common) + if ebitda_ttm and ev_ebitda_multiple and shares + else {} + ) + ev_revenue_out = _build_multiple_result( + _run_ev_revenue(revenue=revenue_ttm, target_multiple=ev_revenue_multiple, **common) + if revenue_ttm and ev_revenue_multiple and shares + else {} + ) + pb_out = _build_multiple_result( + _run_price_to_book( + book_value_per_share=book_value_per_share, + target_multiple=pb_multiple, + ) + if book_value_per_share and pb_multiple + else {} + ) + + return { + "symbol": sym, + "current_price": current_price, + "shares_outstanding": shares, + "dcf": dcf_out, + "ev_ebitda": ev_ebitda_out, + "ev_revenue": ev_revenue_out, + "price_to_book": pb_out, + } + + @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 8c7edb8..66c021f 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -788,3 +788,112 @@ def test_run_price_to_book_zero_bvps() -> None: book_value_per_share=0.0, target_multiple=3.0 ) assert result == {} + + +def test_get_valuation_happy_path(monkeypatch) -> None: + clear_service_caches() + cf_a = annual_frame({ + "Operating Cash Flow": [100.0, 90.0, 80.0, 70.0], + "Capital Expenditure": [-10.0, -9.0, -8.0, -7.0], + }) + bal_q = quarterly_frame({ + "Total Debt": [50.0, 0.0, 0.0, 0.0], + "Cash And Cash Equivalents": [30.0, 0.0, 0.0, 0.0], + "Stockholders Equity": [400.0, 0.0, 0.0, 0.0], + }) + inc_q = quarterly_frame({ + "EBITDA": [30.0, 28.0, 32.0, 25.0], + "Total Revenue": [100.0, 90.0, 95.0, 85.0], + }) + monkeypatch.setattr(data_service, "get_cash_flow", + lambda sym, quarterly=False: pd.DataFrame() if quarterly else cf_a) + monkeypatch.setattr(data_service, "get_income_statement", + lambda sym, quarterly=False: inc_q) + monkeypatch.setattr(data_service, "get_balance_sheet", + lambda sym, quarterly=False: bal_q) + monkeypatch.setattr(data_service, "get_company_info", + lambda sym: {"currentPrice": 150.0, "enterpriseToEbitda": 15.0, + "enterpriseToRevenue": 5.0, "priceToBook": 3.0}) + monkeypatch.setattr(data_service, "get_shares_outstanding", + lambda sym: 1_000_000_000.0) + + result = data_service.get_valuation("AAPL") + assert result["symbol"] == "AAPL" + assert result["current_price"] == 150.0 + assert result["dcf"]["available"] is True + assert result["dcf"]["intrinsic_value_per_share"] is not None + assert result["dcf"]["wacc"] == 0.10 + assert result["dcf"]["terminal_growth"] == 0.03 + assert result["ev_ebitda"]["available"] is True + assert result["ev_ebitda"]["multiple_used"] == 15.0 + assert result["ev_revenue"]["available"] is True + assert result["price_to_book"]["available"] is True + + +def test_get_valuation_negative_base_fcf(monkeypatch) -> None: + clear_service_caches() + # Most recent year (2024) FCF is negative + cf_a = annual_frame({ + "Operating Cash Flow": [-50.0, 100.0, 90.0, 80.0], + "Capital Expenditure": [-5.0, -5.0, -5.0, -5.0], + }) + monkeypatch.setattr(data_service, "get_cash_flow", + lambda sym, quarterly=False: pd.DataFrame() if quarterly else cf_a) + monkeypatch.setattr(data_service, "get_income_statement", + lambda sym, quarterly=False: pd.DataFrame()) + monkeypatch.setattr(data_service, "get_balance_sheet", + lambda sym, quarterly=False: pd.DataFrame()) + monkeypatch.setattr(data_service, "get_company_info", + lambda sym: {"currentPrice": 100.0}) + monkeypatch.setattr(data_service, "get_shares_outstanding", + lambda sym: 1_000_000_000.0) + + result = data_service.get_valuation("AAPL") + assert result["dcf"]["available"] is True + assert result["dcf"]["error"] is not None + assert "negative" in result["dcf"]["error"].lower() or "zero" in result["dcf"]["error"].lower() + + +def test_get_valuation_no_cf_data(monkeypatch) -> None: + clear_service_caches() + monkeypatch.setattr(data_service, "get_cash_flow", + lambda sym, quarterly=False: pd.DataFrame()) + monkeypatch.setattr(data_service, "get_income_statement", + lambda sym, quarterly=False: pd.DataFrame()) + monkeypatch.setattr(data_service, "get_balance_sheet", + lambda sym, quarterly=False: pd.DataFrame()) + monkeypatch.setattr(data_service, "get_company_info", + lambda sym: {}) + monkeypatch.setattr(data_service, "get_shares_outstanding", + lambda sym: None) + + result = data_service.get_valuation("AAPL") + assert result["dcf"]["available"] is False + assert result["ev_ebitda"]["available"] is False + assert result["ev_revenue"]["available"] is False + assert result["price_to_book"]["available"] is False + + +def test_get_valuation_missing_multiples_data(monkeypatch) -> None: + clear_service_caches() + cf_a = 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 sym, quarterly=False: pd.DataFrame() if quarterly else cf_a) + monkeypatch.setattr(data_service, "get_income_statement", + lambda sym, quarterly=False: pd.DataFrame()) + monkeypatch.setattr(data_service, "get_balance_sheet", + lambda sym, quarterly=False: pd.DataFrame()) + # no enterpriseToEbitda / enterpriseToRevenue / priceToBook in info + monkeypatch.setattr(data_service, "get_company_info", + lambda sym: {"currentPrice": 150.0}) + monkeypatch.setattr(data_service, "get_shares_outstanding", + lambda sym: 1_000_000_000.0) + + result = data_service.get_valuation("AAPL") + assert result["dcf"]["available"] is True + assert result["ev_ebitda"]["available"] is False + assert result["ev_revenue"]["available"] is False + assert result["price_to_book"]["available"] is False |
