TerraFin / tests /interface /test_dcf_api.py
sk851's picture
refactor: Phase 6 data layer + portfolio + UI fixes + breadth label
085d910
from fastapi.testclient import TestClient
import TerraFin.interface.market_insights.data_routes as market_insights_routes
import TerraFin.interface.stock.data_routes as stock_routes
from TerraFin.analytics.analysis.risk.models import BetaEstimate
from TerraFin.data.cache.registry import reset_cache_manager
from TerraFin.interface.server import create_app
from TerraFin.interface.watchlist_service import reset_watchlist_service
def _client() -> TestClient:
reset_watchlist_service()
reset_cache_manager()
return TestClient(create_app())
def _ready_payload(symbol: str, entity_type: str) -> dict:
return {
"status": "ready",
"entityType": entity_type,
"symbol": symbol,
"asOf": "2026-04-05",
"currentPrice": 100.0,
"currentIntrinsicValue": 112.0,
"upsidePct": 12.0,
"scenarios": {
"base": {
"key": "base",
"label": "Base",
"status": "ready",
"growthShiftPct": 0.0,
"discountRateShiftBps": 0,
"terminalGrowthShiftBps": 0,
"intrinsicValue": 112.0,
"upsidePct": 12.0,
"terminalValue": 80.0,
"terminalGrowthPct": 3.0,
"terminalDiscountRatePct": 9.0,
"projectedCashFlows": [],
}
},
"assumptions": {"baseGrowthPct": 6.0},
"sensitivity": {
"discountRateShiftBps": [-100, 0, 100],
"terminalGrowthShiftBps": [-100, 0, 100],
"cells": [],
},
"rateCurve": {
"source": "test",
"asOf": "2026-04-05",
"fitRmse": 0.01,
"fallbackUsed": False,
"points": [],
"fittedPoints": [],
},
"dataQuality": {"mode": "live", "sources": ["test"]},
"warnings": [],
"methods": None,
}
def test_market_insights_sp500_dcf_endpoint_contract(monkeypatch) -> None:
monkeypatch.setattr(
market_insights_routes,
"build_sp500_dcf_payload",
lambda *args, **kwargs: {
**_ready_payload("S&P 500", "index"),
"methods": [
{
"key": "shareholder_yield",
"label": "Shareholder Yield",
"description": "Case A",
"weight": 0.5,
"currentIntrinsicValue": 105.0,
"upsidePct": 5.0,
},
{
"key": "earnings_power",
"label": "Earnings Power",
"description": "Case B",
"weight": 0.5,
"currentIntrinsicValue": 119.0,
"upsidePct": 19.0,
},
],
},
)
client = _client()
response = client.get("/market-insights/api/dcf/sp500")
assert response.status_code == 200
body = response.json()
assert body["entityType"] == "index"
assert body["symbol"] == "S&P 500"
assert body["currentIntrinsicValue"] == 112.0
assert len(body["methods"]) == 2
def test_market_insights_sp500_dcf_post_accepts_overrides(monkeypatch) -> None:
captured = {}
def _build(*args, **kwargs):
captured["overrides"] = kwargs.get("overrides")
return _ready_payload("S&P 500", "index")
monkeypatch.setattr(market_insights_routes, "build_sp500_dcf_payload", _build)
client = _client()
response = client.post(
"/market-insights/api/dcf/sp500",
json={
"baseYearEps": 240.0,
"terminalGrowthPct": 3.8,
"terminalEquityRiskPremiumPct": 4.4,
"terminalRoePct": 19.5,
"yearlyAssumptions": [
{
"yearOffset": 1,
"growthPct": 12.0,
"payoutRatioPct": 30.0,
"buybackRatioPct": 45.0,
"equityRiskPremiumPct": 5.5,
}
],
},
)
assert response.status_code == 200
assert captured["overrides"].base_year_eps == 240.0
assert captured["overrides"].terminal_growth_pct == 3.8
assert len(captured["overrides"].yearly_assumptions) == 1
def test_stock_dcf_endpoint_contract(monkeypatch) -> None:
monkeypatch.setattr(
stock_routes,
"build_stock_dcf_payload",
lambda ticker, *args, **kwargs: _ready_payload(ticker.upper(), "stock"),
)
client = _client()
response = client.get("/stock/api/dcf?ticker=aapl")
assert response.status_code == 200
body = response.json()
assert body["entityType"] == "stock"
assert body["symbol"] == "AAPL"
assert "base" in body["scenarios"]
def test_stock_dcf_endpoint_allows_insufficient_data(monkeypatch) -> None:
monkeypatch.setattr(
stock_routes,
"build_stock_dcf_payload",
lambda ticker, *args, **kwargs: {
**_ready_payload(ticker.upper(), "stock"),
"status": "insufficient_data",
"currentIntrinsicValue": None,
"upsidePct": None,
"scenarios": {},
"warnings": ["Free cash flow per share is not positive."],
},
)
client = _client()
response = client.get("/stock/api/dcf?ticker=loss")
assert response.status_code == 200
body = response.json()
assert body["status"] == "insufficient_data"
assert body["warnings"] == ["Free cash flow per share is not positive."]
def test_stock_beta_estimate_endpoint_contract(monkeypatch) -> None:
monkeypatch.setattr(
stock_routes,
"estimate_beta_5y_monthly",
lambda ticker: BetaEstimate(
symbol=ticker.upper(),
benchmark_symbol="^SPX",
benchmark_label="S&P 500",
method_id="beta_5y_monthly",
lookback_years=5,
frequency="monthly",
beta=1.11,
observations=60,
r_squared=0.42,
status="ready",
warnings=[],
),
)
monkeypatch.setattr(
stock_routes,
"estimate_beta_5y_monthly_adjusted",
lambda ticker: BetaEstimate(
symbol=ticker.upper(),
benchmark_symbol="^SPX",
benchmark_label="S&P 500",
method_id="beta_5y_monthly_adjusted",
lookback_years=5,
frequency="monthly",
beta=1.07,
observations=60,
r_squared=0.42,
status="ready",
warnings=[],
),
)
client = _client()
response = client.get("/stock/api/beta-estimate?ticker=googl")
assert response.status_code == 200
body = response.json()
assert body["symbol"] == "GOOGL"
assert body["benchmarkSymbol"] == "^SPX"
assert body["methodId"] == "beta_5y_monthly"
assert body["adjustedMethodId"] == "beta_5y_monthly_adjusted"
assert body["beta"] == 1.11
assert body["adjustedBeta"] == 1.07
assert body["observations"] == 60
assert body["rSquared"] == 0.42
def test_stock_dcf_post_accepts_overrides(monkeypatch) -> None:
captured = {}
def _build(ticker, *args, **kwargs):
captured["ticker"] = ticker
captured["overrides"] = kwargs.get("overrides")
return _ready_payload(ticker.upper(), "stock")
monkeypatch.setattr(stock_routes, "build_stock_dcf_payload", _build)
client = _client()
response = client.post(
"/stock/api/dcf?ticker=nvda",
json={
"baseCashFlowPerShare": 4.2,
"baseGrowthPct": 18.0,
"terminalGrowthPct": 3.4,
"beta": 1.3,
"equityRiskPremiumPct": 4.9,
"fcfBaseSource": "ttm",
},
)
assert response.status_code == 200
assert captured["ticker"] == "nvda"
assert captured["overrides"].base_cash_flow_per_share == 4.2
assert captured["overrides"].beta == 1.3
assert captured["overrides"].fcf_base_source == "ttm"
def test_fcf_history_endpoint_contract(monkeypatch) -> None:
monkeypatch.setattr(
stock_routes,
"build_fcf_history_payload",
lambda ticker, **kwargs: {
"ticker": ticker.upper(),
"sharesOutstanding": 100.0,
"ttmFcfPerShare": 0.85,
"ttmSource": "quarterly_ttm",
"candidates": {
"threeYearAvg": 0.43,
"latestAnnual": -0.50,
"ttm": 0.85,
},
"autoSelectedSource": "3yr_avg",
"sharesNote": "Per-year FCF/share is computed using current sharesOutstanding.",
"history": [
{"year": "2022", "fcf": 80.0, "fcfPerShare": 0.80},
{"year": "2023", "fcf": 100.0, "fcfPerShare": 1.00},
{"year": "2024", "fcf": -50.0, "fcfPerShare": -0.50},
],
},
)
client = _client()
response = client.get("/stock/api/fcf-history?ticker=moh&years=5")
assert response.status_code == 200
body = response.json()
assert body["ticker"] == "MOH"
assert body["ttmFcfPerShare"] == 0.85
assert body["candidates"]["threeYearAvg"] == 0.43
assert body["candidates"]["latestAnnual"] == -0.50
assert body["candidates"]["ttm"] == 0.85
assert body["autoSelectedSource"] == "3yr_avg"
assert len(body["history"]) == 3
assert body["history"][2]["year"] == "2024"
assert body["history"][2]["fcfPerShare"] == -0.50
def test_stock_dcf_post_accepts_turnaround_and_horizon(monkeypatch) -> None:
captured = {}
def _build(ticker, *args, **kwargs):
captured["ticker"] = ticker
captured["overrides"] = kwargs.get("overrides")
captured["projection_years"] = kwargs.get("projection_years")
return _ready_payload(ticker.upper(), "stock")
monkeypatch.setattr(stock_routes, "build_stock_dcf_payload", _build)
client = _client()
response = client.post(
"/stock/api/dcf?ticker=moh",
json={
"projectionYears": 10,
"breakevenYear": 3,
"breakevenCashFlowPerShare": 2.5,
"postBreakevenGrowthPct": 12.0,
},
)
assert response.status_code == 200
assert captured["ticker"] == "moh"
assert captured["projection_years"] == 10
assert captured["overrides"].breakeven_year == 3
assert captured["overrides"].breakeven_cash_flow_per_share == 2.5
assert captured["overrides"].post_breakeven_growth_pct == 12.0