summaryrefslogtreecommitdiff
path: root/backend
diff options
context:
space:
mode:
authorTyler Hoang <tyler@tylerhoang.xyz>2026-05-18 01:31:30 -0700
committerTyler Hoang <tyler@tylerhoang.xyz>2026-05-18 01:31:30 -0700
commitb877047a76cd4b661dccfd1dc8c0e7f2aa6a346c (patch)
treefa372d0b7eda271a1a88550a3c372f148309f66e /backend
parent0811b116b992ac977630c7deb687f995d89dc9a6 (diff)
feat: add get_valuation() service function
Diffstat (limited to 'backend')
-rw-r--r--backend/app/services/data_service.py103
-rw-r--r--backend/tests/test_api.py109
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