summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--backend/app/services/wacc_service.py120
-rw-r--r--backend/tests/test_wacc.py102
2 files changed, 222 insertions, 0 deletions
diff --git a/backend/app/services/wacc_service.py b/backend/app/services/wacc_service.py
new file mode 100644
index 0000000..2b4ee3e
--- /dev/null
+++ b/backend/app/services/wacc_service.py
@@ -0,0 +1,120 @@
+from __future__ import annotations
+
+from typing import Final
+
+from app.services import data_service
+
+DEFAULT_RISK_FREE_RATE: Final = 0.044
+DEFAULT_BETA: Final = 1.0
+DEFAULT_EQUITY_RISK_PREMIUM: Final = 0.055
+DEFAULT_COST_OF_DEBT: Final = 0.05
+DEFAULT_TAX_RATE: Final = 0.21
+
+
+def _unavailable(sym: str) -> dict[str, str | bool | float | None]:
+ info = data_service.get_company_info(sym)
+ fast_info = data_service.get_fast_info(sym)
+ currency_metadata = data_service._currency_metadata(info, fast_info, prefer_reporting=True)
+
+ return {
+ "symbol": sym,
+ "available": False,
+ "wacc": None,
+ "cost_of_equity": None,
+ "after_tax_cost_of_debt": None,
+ "risk_free_rate": DEFAULT_RISK_FREE_RATE,
+ "beta": DEFAULT_BETA,
+ "equity_risk_premium": DEFAULT_EQUITY_RISK_PREMIUM,
+ "cost_of_debt": DEFAULT_COST_OF_DEBT,
+ "tax_rate": DEFAULT_TAX_RATE,
+ "market_cap": None,
+ "total_debt": None,
+ "cash": None,
+ "net_debt": None,
+ "equity_weight": None,
+ "debt_weight": None,
+ **currency_metadata,
+ }
+
+
+def compute_wacc_value(symbol: str) -> dict[str, str | bool | float | None]:
+ sym = data_service.normalize_symbol(symbol)
+ info = data_service.get_company_info(sym)
+ fast_info = data_service.get_fast_info(sym)
+ balance = data_service.get_balance_sheet(sym, quarterly=True)
+ income = data_service.get_income_statement(sym, quarterly=True)
+
+ market_cap = data_service._safe_float(info.get("marketCap")) or data_service._safe_float(
+ fast_info.get("marketCap")
+ )
+ if market_cap is None or market_cap <= 0:
+ return _unavailable(sym)
+
+ beta = data_service._safe_float(info.get("beta"))
+ if beta is None:
+ beta = DEFAULT_BETA
+
+ total_debt = data_service._balance_value(
+ balance,
+ "Total Debt",
+ "Long Term Debt And Capital Lease Obligation",
+ )
+ if total_debt is None:
+ total_debt = data_service._safe_float(info.get("totalDebt")) or 0.0
+
+ cash = data_service._balance_value(
+ balance,
+ "Cash And Cash Equivalents",
+ "Cash Cash Equivalents And Short Term Investments",
+ )
+ if cash is None:
+ cash = data_service._safe_float(info.get("totalCash")) or 0.0
+
+ interest_expense = data_service._statement_ttm(income, "Interest Expense")
+ cost_of_debt = DEFAULT_COST_OF_DEBT
+ if interest_expense is not None and total_debt > 0:
+ computed_cost_of_debt = abs(interest_expense) / total_debt
+ if 0 < computed_cost_of_debt < 1:
+ cost_of_debt = computed_cost_of_debt
+
+ tax_rate = DEFAULT_TAX_RATE
+ tax_provision = data_service._statement_ttm(income, "Tax Provision")
+ pretax_income = data_service._statement_ttm(income, "Pretax Income")
+ if tax_provision is not None and pretax_income is not None and pretax_income > 0:
+ computed_tax_rate = tax_provision / pretax_income
+ if 0 <= computed_tax_rate <= 1:
+ tax_rate = computed_tax_rate
+
+ risk_free_rate = DEFAULT_RISK_FREE_RATE
+ equity_risk_premium = DEFAULT_EQUITY_RISK_PREMIUM
+ cost_of_equity = risk_free_rate + (beta * equity_risk_premium)
+ after_tax_cost_of_debt = cost_of_debt * (1 - tax_rate)
+ net_debt = max(total_debt - cash, 0.0)
+ total_capital = market_cap + net_debt
+ if total_capital <= 0:
+ return _unavailable(sym)
+
+ equity_weight = market_cap / total_capital
+ debt_weight = net_debt / total_capital
+ wacc = (cost_of_equity * equity_weight) + (after_tax_cost_of_debt * debt_weight)
+ currency_metadata = data_service._currency_metadata(info, fast_info, prefer_reporting=True)
+
+ return {
+ "symbol": sym,
+ "available": True,
+ "wacc": wacc,
+ "cost_of_equity": cost_of_equity,
+ "after_tax_cost_of_debt": after_tax_cost_of_debt,
+ "risk_free_rate": risk_free_rate,
+ "beta": beta,
+ "equity_risk_premium": equity_risk_premium,
+ "cost_of_debt": cost_of_debt,
+ "tax_rate": tax_rate,
+ "market_cap": market_cap,
+ "total_debt": total_debt,
+ "cash": cash,
+ "net_debt": net_debt,
+ "equity_weight": equity_weight,
+ "debt_weight": debt_weight,
+ **currency_metadata,
+ }
diff --git a/backend/tests/test_wacc.py b/backend/tests/test_wacc.py
new file mode 100644
index 0000000..a7dffcd
--- /dev/null
+++ b/backend/tests/test_wacc.py
@@ -0,0 +1,102 @@
+import pandas as pd
+import pytest
+
+from app import main
+from app.services import data_service
+
+
+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 test_ticker_wacc_computes_capm_wacc_when_data_is_available(monkeypatch: pytest.MonkeyPatch) -> None:
+ # Given: WACC inputs matching the reference Excel sheet.
+ data_service.WACC_CACHE.clear()
+ monkeypatch.setattr(
+ data_service,
+ "get_company_info",
+ lambda symbol: {"beta": 0.98, "marketCap": 45_500_000_000.0},
+ )
+ monkeypatch.setattr(data_service, "get_fast_info", lambda symbol: {})
+ monkeypatch.setattr(
+ data_service,
+ "get_balance_sheet",
+ lambda symbol, quarterly=False: quarterly_frame(
+ {
+ "Total Debt": [724_000_000.0] * 4,
+ "Cash And Cash Equivalents": [795_000_000.0] * 4,
+ }
+ ),
+ )
+ monkeypatch.setattr(
+ data_service,
+ "get_income_statement",
+ lambda symbol, quarterly=False: quarterly_frame(
+ {
+ "Interest Expense": [8_145_000.0] * 4,
+ "Tax Provision": [25_000_000.0] * 4,
+ "Pretax Income": [100_000_000.0] * 4,
+ }
+ ),
+ )
+
+ # When: the public route function is called.
+ result = main.ticker_wacc("ref")
+
+ # Then: it returns the CAPM WACC and all major inputs without live yfinance calls.
+ assert result["symbol"] == "REF"
+ assert result["available"] is True
+ assert result["wacc"] == pytest.approx(0.0979)
+ assert result["cost_of_equity"] == pytest.approx(0.0979)
+ assert result["cost_of_debt"] == pytest.approx(0.045)
+ assert result["tax_rate"] == pytest.approx(0.25)
+ assert result["currency"] == "USD"
+ assert result["currency_warning"] is None
+ assert result["market_cap"] == 45_500_000_000.0
+ assert result["total_debt"] == 724_000_000.0
+ assert result["cash"] == 795_000_000.0
+
+
+def test_ticker_wacc_returns_unavailable_when_market_cap_is_missing(monkeypatch: pytest.MonkeyPatch) -> None:
+ # Given: yfinance helpers return no usable capitalization data.
+ data_service.WACC_CACHE.clear()
+ monkeypatch.setattr(data_service, "get_company_info", lambda symbol: {})
+ monkeypatch.setattr(data_service, "get_fast_info", lambda symbol: {})
+ monkeypatch.setattr(data_service, "get_balance_sheet", lambda symbol, quarterly=False: pd.DataFrame())
+ monkeypatch.setattr(data_service, "get_income_statement", lambda symbol, quarterly=False: pd.DataFrame())
+
+ # When: the public route function is called for an invalid symbol.
+ result = main.ticker_wacc("missing")
+
+ # Then: the API responds gracefully with fallback assumptions and no WACC.
+ assert result["symbol"] == "MISSING"
+ assert result["available"] is False
+ assert result["wacc"] is None
+ assert result["risk_free_rate"] == 0.044
+ assert result["beta"] == 1.0
+ assert result["equity_risk_premium"] == 0.055
+ assert result["cost_of_debt"] == 0.05
+ assert result["tax_rate"] == 0.21
+ assert result["currency"] == "USD"
+ assert result["currency_warning"] is None
+ assert result["market_cap"] is None
+
+
+def test_wacc_currency_warning_when_mismatch(monkeypatch: pytest.MonkeyPatch) -> None:
+ data_service.WACC_CACHE.clear()
+ monkeypatch.setattr(
+ data_service,
+ "get_company_info",
+ lambda symbol: {"financialCurrency": "CAD", "currency": "USD", "marketCap": 45_500_000_000.0, "beta": 0.98},
+ )
+ monkeypatch.setattr(data_service, "get_fast_info", lambda symbol: {"currency": "USD"})
+ monkeypatch.setattr(data_service, "get_balance_sheet", lambda symbol, quarterly=False: pd.DataFrame())
+ monkeypatch.setattr(data_service, "get_income_statement", lambda symbol, quarterly=False: pd.DataFrame())
+
+ result = main.ticker_wacc("CCJ")
+
+ assert result["currency"] == "CAD"
+ assert isinstance(result["currency_warning"], str)
+ assert "USD" in result["currency_warning"]
+ assert "CAD" in result["currency_warning"]