diff options
| author | Tyler Hoang <tyler@tylerhoang.xyz> | 2026-05-18 22:45:59 -0700 |
|---|---|---|
| committer | Tyler Hoang <tyler@tylerhoang.xyz> | 2026-05-18 22:45:59 -0700 |
| commit | 66cfb26ebd8fa44b24e37b4ffc796ab29dcbd704 (patch) | |
| tree | 4d98b268502c6aa7c8988957d6e41dffd319534d /backend/tests/test_api.py | |
| parent | 7fc2f0177518d70114aa75b7874a0ef59bdaec61 (diff) | |
| parent | 52635efd7d435b091b4f13897511ca8e2c48f0b9 (diff) | |
Merge branch 'feat/key-ratios-tab'
Diffstat (limited to 'backend/tests/test_api.py')
| -rw-r--r-- | backend/tests/test_api.py | 340 |
1 files changed, 340 insertions, 0 deletions
diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index af43975..345c0a3 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -19,6 +19,9 @@ def clear_service_caches() -> None: data_service.RATIO_CACHE.clear() data_service.FINANCIALS_CACHE.clear() data_service.VALUATION_CACHE.clear() + data_service.HIST_RATIOS_CACHE.clear() + data_service.RATIOS_ENDPOINT_CACHE.clear() + data_service.SECTOR_BENCHMARK_CACHE.clear() def quarterly_frame(rows: dict[str, list[float]]) -> pd.DataFrame: @@ -940,3 +943,340 @@ def test_valuation_route_returns_structure(monkeypatch) -> None: assert result["dcf"]["intrinsic_value_per_share"] == 182.0 assert result["ev_ebitda"]["multiple_used"] == 20.0 assert result["ev_revenue"]["available"] is False + + +def test_compute_historical_ratios_margins(monkeypatch) -> None: + clear_service_caches() + monkeypatch.setattr( + data_service, + "get_income_statement", + lambda symbol, quarterly=False: annual_frame( + { + "Total Revenue": [100.0, 90.0, 80.0, 70.0], + "Gross Profit": [50.0, 45.0, 40.0, 35.0], + "Operating Income": [20.0, 18.0, 16.0, 14.0], + "Net Income": [10.0, 9.0, 8.0, 7.0], + "EBITDA": [25.0, 22.5, 20.0, 17.5], + } + ), + ) + monkeypatch.setattr( + data_service, + "get_balance_sheet", + lambda symbol, quarterly=False: annual_frame( + { + "Stockholders Equity": [50.0, 45.0, 40.0, 35.0], + "Total Assets": [100.0, 90.0, 80.0, 70.0], + "Total Debt": [20.0, 18.0, 16.0, 14.0], + "Current Assets": [30.0, 27.0, 24.0, 21.0], + "Current Liabilities": [15.0, 13.5, 12.0, 10.5], + "Cash And Cash Equivalents": [5.0, 4.5, 4.0, 3.5], + } + ), + ) + monkeypatch.setattr(data_service, "get_cash_flow", lambda symbol, quarterly=False: annual_frame({"Operating Cash Flow": [1.0, 1.0, 1.0, 1.0]})) + monkeypatch.setattr(data_service, "get_shares_outstanding", lambda symbol: 1_000.0) + monkeypatch.setattr(data_service, "get_price_history", lambda symbol, period="5y": []) + + result = data_service.compute_historical_ratios("AAPL") + + assert result["gross_margin"] == [0.5, 0.5, 0.5, 0.5] + assert result["operating_margin"] == [0.2, 0.2, 0.2, 0.2] + assert result["net_margin"] == [0.1, 0.1, 0.1, 0.1] + assert result["ebitda_margin"] == [0.25, 0.25, 0.25, 0.25] + assert result["roe"] == [0.2, 0.2, 0.2, 0.2] + assert result["roa"] == [0.1, 0.1, 0.1, 0.1] + assert result["debt_to_equity"] == [0.4, 0.4, 0.4, 0.4] + assert result["current_ratio"] == [2.0, 2.0, 2.0, 2.0] + assert result["trailing_pe"] == [None, None, None, None] + assert result["ev_to_ebitda"] == [None, None, None, None] + assert result["price_to_book"] == [None, None, None, None] + assert result["price_to_sales"] == [None, None, None, None] + + +def test_compute_historical_ratios_uses_period_share_counts_oldest_first(monkeypatch) -> None: + clear_service_caches() + monkeypatch.setattr( + data_service, + "get_income_statement", + lambda symbol, quarterly=False: annual_frame( + { + "Total Revenue": [40.0, 40.0, 40.0, 40.0], + "Gross Profit": [20.0, 20.0, 20.0, 20.0], + "Operating Income": [15.0, 15.0, 15.0, 15.0], + "Net Income": [10.0, 10.0, 10.0, 10.0], + "EBITDA": [2_000_000.0, 2_000_000.0, 2_000_000.0, 2_000_000.0], + } + ), + ) + monkeypatch.setattr( + data_service, + "get_balance_sheet", + lambda symbol, quarterly=False: annual_frame( + { + "Stockholders Equity": [20.0, 20.0, 20.0, 20.0], + "Total Assets": [50.0, 50.0, 50.0, 50.0], + "Total Debt": [100_000.0, 100_000.0, 100_000.0, 100_000.0], + "Current Assets": [10.0, 10.0, 10.0, 10.0], + "Current Liabilities": [5.0, 5.0, 5.0, 5.0], + "Cash And Cash Equivalents": [0.0, 0.0, 0.0, 0.0], + "Ordinary Shares Number": [10.0, 20.0, 40.0, 80.0], + } + ), + ) + monkeypatch.setattr(data_service, "get_cash_flow", lambda symbol, quarterly=False: pd.DataFrame()) + monkeypatch.setattr(data_service, "get_shares_outstanding", lambda symbol: 999.0) + monkeypatch.setattr( + data_service, + "get_price_history", + lambda symbol, period="5y": [ + {"date": "2024-09-30", "close": 10.0}, + {"date": "2023-09-30", "close": 10.0}, + {"date": "2022-09-30", "close": 10.0}, + {"date": "2021-09-30", "close": 10.0}, + ], + ) + + result = data_service.compute_historical_ratios("AAPL") + + assert result["trailing_pe"] == [80.0, 40.0, 20.0, 10.0] + assert result["price_to_book"] == [40.0, 20.0, 10.0, 5.0] + assert result["price_to_sales"] == [20.0, 10.0, 5.0, 2.5] + + +def test_compute_historical_ratios_uses_long_term_debt_fallback(monkeypatch) -> None: + clear_service_caches() + monkeypatch.setattr( + data_service, + "get_income_statement", + lambda symbol, quarterly=False: annual_frame( + { + "Total Revenue": [5_000_000.0, 5_000_000.0, 5_000_000.0, 5_000_000.0], + "Gross Profit": [2_500_000.0, 2_500_000.0, 2_500_000.0, 2_500_000.0], + "Operating Income": [2_100_000.0, 2_100_000.0, 2_100_000.0, 2_100_000.0], + "Net Income": [1_000_000.0, 1_000_000.0, 1_000_000.0, 1_000_000.0], + "EBITDA": [2_000_000.0, 2_000_000.0, 2_000_000.0, 2_000_000.0], + } + ), + ) + monkeypatch.setattr( + data_service, + "get_balance_sheet", + lambda symbol, quarterly=False: annual_frame( + { + "Stockholders Equity": [4_000_000.0, 4_000_000.0, 4_000_000.0, 4_000_000.0], + "Total Assets": [8_000_000.0, 8_000_000.0, 8_000_000.0, 8_000_000.0], + "Long Term Debt And Capital Lease Obligation": [2_000_000.0, 2_000_000.0, 2_000_000.0, 2_000_000.0], + "Cash And Cash Equivalents": [1_000_000.0, 1_000_000.0, 1_000_000.0, 1_000_000.0], + "Ordinary Shares Number": [1_000_000.0, 1_000_000.0, 1_000_000.0, 1_000_000.0], + } + ), + ) + monkeypatch.setattr(data_service, "get_cash_flow", lambda symbol, quarterly=False: pd.DataFrame()) + monkeypatch.setattr(data_service, "get_shares_outstanding", lambda symbol: None) + monkeypatch.setattr( + data_service, + "get_price_history", + lambda symbol, period="5y": [ + {"date": "2024-09-30", "close": 10.0}, + {"date": "2023-09-30", "close": 10.0}, + {"date": "2022-09-30", "close": 10.0}, + {"date": "2021-09-30", "close": 10.0}, + ], + ) + + result = data_service.compute_historical_ratios("AAPL") + + assert result["ev_to_ebitda"] == [5.5, 5.5, 5.5, 5.5] + + +def test_compute_historical_ratios_empty_income(monkeypatch) -> None: + clear_service_caches() + monkeypatch.setattr(data_service, "get_income_statement", lambda symbol, quarterly=False: pd.DataFrame()) + monkeypatch.setattr(data_service, "get_balance_sheet", lambda symbol, quarterly=False: pd.DataFrame()) + monkeypatch.setattr(data_service, "get_cash_flow", lambda symbol, quarterly=False: pd.DataFrame()) + monkeypatch.setattr(data_service, "get_shares_outstanding", lambda symbol: None) + monkeypatch.setattr(data_service, "get_price_history", lambda symbol, period="5y": []) + + result = data_service.compute_historical_ratios("AAPL") + + assert result == {} + + +def test_get_ratios_quick_ratio(monkeypatch) -> None: + clear_service_caches() + monkeypatch.setattr(data_service, "compute_ttm_ratios", lambda symbol: {"market_cap": 1_000.0, "current_ratio": 2.0}) + monkeypatch.setattr( + data_service, + "compute_historical_ratios", + lambda symbol: { + "current_ratio": [1.4, 1.5, 1.6, 1.8], + }, + ) + monkeypatch.setattr(data_service, "get_company_info", lambda symbol: {}) + monkeypatch.setattr( + data_service, + "get_income_statement", + lambda symbol, quarterly=True: quarterly_frame( + { + "Total Revenue": [100.0, 100.0, 100.0, 100.0], + } + ), + ) + monkeypatch.setattr( + data_service, + "get_balance_sheet", + lambda symbol, quarterly=True: quarterly_frame( + { + "Current Assets": [200.0, 0.0, 0.0, 0.0], + "Current Liabilities": [100.0, 0.0, 0.0, 0.0], + "Inventory": [30.0, 0.0, 0.0, 0.0], + } + ), + ) + monkeypatch.setattr(data_service, "get_cash_flow", lambda symbol, quarterly=True: quarterly_frame({"Operating Cash Flow": [1.0, 1.0, 1.0, 1.0]})) + monkeypatch.setattr(data_service, "get_price_history", lambda symbol, period="5y": []) + + result = data_service.get_ratios("AAPL") + + assert result["quick_ratio"]["value"] == 1.7 + assert result["quick_ratio"]["spark"] == [] + assert result["current_ratio"]["value"] == 2.0 + assert result["current_ratio"]["spark"] == [1.4, 1.5, 1.6, 1.8] + + +def test_get_ratios_interest_coverage(monkeypatch) -> None: + clear_service_caches() + monkeypatch.setattr(data_service, "compute_ttm_ratios", lambda symbol: {"market_cap": 1_000.0}) + monkeypatch.setattr(data_service, "compute_historical_ratios", lambda symbol: {}) + monkeypatch.setattr(data_service, "get_company_info", lambda symbol: {}) + monkeypatch.setattr( + data_service, + "get_income_statement", + lambda symbol, quarterly=True: quarterly_frame( + { + "Total Revenue": [100.0, 100.0, 100.0, 100.0], + "EBIT": [42.5, 42.5, 42.5, 42.5], + "Interest Expense": [5.0, 5.0, 5.0, 5.0], + } + ), + ) + monkeypatch.setattr(data_service, "get_balance_sheet", lambda symbol, quarterly=True: quarterly_frame({})) + monkeypatch.setattr(data_service, "get_cash_flow", lambda symbol, quarterly=True: quarterly_frame({"Operating Cash Flow": [1.0, 1.0, 1.0, 1.0]})) + monkeypatch.setattr(data_service, "get_price_history", lambda symbol, period="5y": []) + + result = data_service.get_ratios("AAPL") + + assert result["interest_coverage"]["value"] == 8.5 + assert result["interest_coverage"]["spark"] == [] + + +def test_get_ratios_sector_benchmark_fields(monkeypatch) -> None: + clear_service_caches() + monkeypatch.setattr( + data_service, + "compute_ttm_ratios", + lambda symbol: { + "market_cap": 1000.0, + "trailing_pe": 20.0, + "price_to_book": 5.0, + "current_ratio": 2.0, + "dividend_yield_ttm": 0.01, + }, + ) + monkeypatch.setattr(data_service, "compute_historical_ratios", lambda symbol: {}) + monkeypatch.setattr( + data_service, + "compute_sector_ratio_benchmarks", + lambda symbol: { + "trailing_pe": 18.0, + "price_to_book": 4.0, + "current_ratio": 1.7, + "dividend_yield_ttm": 0.012, + }, + ) + monkeypatch.setattr(data_service, "get_company_info", lambda symbol: {}) + monkeypatch.setattr( + data_service, + "get_income_statement", + lambda symbol, quarterly=True: quarterly_frame({"Total Revenue": [100.0, 100.0, 100.0, 100.0]}), + ) + monkeypatch.setattr(data_service, "get_balance_sheet", lambda symbol, quarterly=True: quarterly_frame({})) + monkeypatch.setattr(data_service, "get_cash_flow", lambda symbol, quarterly=True: quarterly_frame({})) + + result = data_service.get_ratios("AAPL") + + assert result["pe_ttm"]["vs_sector"] == 18.0 + assert result["price_to_book"]["vs_sector"] == 4.0 + assert result["current_ratio"]["vs_sector"] == 1.7 + assert result["dividend_yield"]["vs_sector"] == pytest.approx(0.012) + + +def test_compute_sector_ratio_benchmarks_without_fmp_key(monkeypatch) -> None: + clear_service_caches() + monkeypatch.delenv("FMP_API_KEY", raising=False) + monkeypatch.setattr( + data_service, + "get_company_info", + lambda symbol: ( + {"sector": "Technology"} + if symbol == "AAPL" + else {"sector": "Technology"} + if symbol == "MSFT" + else {"sector": "Technology"} + if symbol == "NVDA" + else {"sector": "Healthcare"} + ), + ) + monkeypatch.setattr( + data_service, + "search_tickers", + lambda query: [ + {"symbol": "MSFT", "name": "Microsoft", "exchange": "NASDAQ"}, + {"symbol": "NVDA", "name": "NVIDIA", "exchange": "NASDAQ"}, + {"symbol": "UNH", "name": "UnitedHealth", "exchange": "NYSE"}, + ], + ) + monkeypatch.setattr( + data_service, + "compute_ttm_ratios", + lambda symbol: ( + {"trailing_pe": 30.0, "current_ratio": 2.0} + if symbol == "MSFT" + else {"trailing_pe": 20.0, "current_ratio": 1.5} + if symbol == "NVDA" + else {"trailing_pe": 10.0, "current_ratio": 1.0} + ), + ) + + result = data_service.compute_sector_ratio_benchmarks("AAPL") + + assert result["trailing_pe"] == pytest.approx(25.0) + assert result["current_ratio"] == pytest.approx(1.75) + + +def test_ticker_ratios_route(monkeypatch) -> None: + """GET /api/tickers/{symbol}/ratios returns a valid RatiosResponse shape.""" + clear_service_caches() + + def _fake_ratios(sym: str) -> dict: + def _pt(v=None): + return {"value": v, "spark": [], "vs_sector": None} + + return { + "pe_ttm": _pt(24.3), "ev_ebitda": _pt(16.1), + "gross_margin": _pt(0.46), "net_margin": _pt(0.14), + "price_to_book": _pt(5.8), "price_to_sales": _pt(6.2), + "ev_to_sales": _pt(6.5), "p_fcf": _pt(28.4), "forward_pe": _pt(22.0), + "operating_margin": _pt(0.19), "ebitda_margin": _pt(0.22), "fcf_margin": _pt(0.18), + "roe": _pt(0.38), "roa": _pt(0.12), "roic": _pt(0.22), + "debt_to_equity": _pt(1.4), "current_ratio": _pt(1.9), + "quick_ratio": _pt(1.5), "interest_coverage": _pt(8.5), + "dividend_yield": _pt(None), "dividend_payout": _pt(None), + } + + monkeypatch.setattr(main.data_service, "get_ratios", _fake_ratios) + result = main.ticker_ratios("AAPL") + assert result["pe_ttm"]["value"] == pytest.approx(24.3) + assert result["gross_margin"]["spark"] == [] + assert result["dividend_yield"]["vs_sector"] is None |
