diff options
Diffstat (limited to 'backend/tests/test_api.py')
| -rw-r--r-- | backend/tests/test_api.py | 205 |
1 files changed, 205 insertions, 0 deletions
diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 70b67a7..1c99cf9 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -6,6 +6,21 @@ from app import main from app.services import data_service +def clear_service_caches() -> None: + data_service.INFO_CACHE.clear() + data_service.FAST_INFO_CACHE.clear() + data_service.PRICE_CACHE.clear() + data_service.HISTORY_CACHE.clear() + data_service.STATEMENT_CACHE.clear() + data_service.SHARES_CACHE.clear() + data_service.RATIO_CACHE.clear() + + +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_health() -> None: assert main.health() == {"status": "ok"} @@ -31,6 +46,22 @@ def test_mocked_ticker_overview(monkeypatch) -> None: "quote": {"price": 100.0, "prev_close": 98.0, "change": 2.0, "change_pct": 0.0204}, "signals": [], "stats": {"market_cap": None, "trailing_pe": None, "trailing_eps": None, "volume": None, "average_volume": None, "beta": None}, + "ratios": { + "price_to_book": None, + "price_to_sales": None, + "ev_to_sales": None, + "ev_to_ebitda": None, + "gross_margin_ttm": None, + "operating_margin_ttm": None, + "net_margin_ttm": None, + "roe_ttm": None, + "roa_ttm": None, + "roic_ttm": None, + "debt_to_equity": None, + "current_ratio": None, + "dividend_yield_ttm": None, + "dividend_payout_ratio_ttm": None, + }, "range_52w": {"low": None, "high": None, "price": 100.0}, "short_interest": {"short_percent_of_float": None, "short_ratio": None, "shares_short": None, "shares_short_prior_month": None, "shares_short_delta_pct": None}, "meta": {"status": "partial", "is_partial": True, "field_availability": {}, "sources": {}}, @@ -40,6 +71,7 @@ def test_mocked_ticker_overview(monkeypatch) -> None: def test_service_overview_prefers_info_fields(monkeypatch) -> None: + clear_service_caches() monkeypatch.setattr( data_service, "get_company_info", @@ -74,6 +106,7 @@ def test_service_overview_prefers_info_fields(monkeypatch) -> None: def test_service_overview_falls_back_to_fast_info(monkeypatch) -> None: + clear_service_caches() monkeypatch.setattr(data_service, "get_company_info", lambda symbol: {}) monkeypatch.setattr( data_service, @@ -103,6 +136,7 @@ def test_service_overview_falls_back_to_fast_info(monkeypatch) -> None: def test_service_overview_falls_back_to_search_and_history(monkeypatch) -> None: + clear_service_caches() month_history = [ {"date": "2026-01-01", "close": 98.0, "volume": 1000.0}, {"date": "2026-01-02", "close": 100.0, "volume": 1200.0}, @@ -124,6 +158,7 @@ def test_service_overview_falls_back_to_search_and_history(monkeypatch) -> None: def test_service_overview_invalid_symbol(monkeypatch) -> None: + clear_service_caches() monkeypatch.setattr(data_service, "get_company_info", lambda symbol: {}) monkeypatch.setattr(data_service, "get_fast_info", lambda symbol: {}) monkeypatch.setattr(data_service, "_pick_search_match", lambda symbol: {}) @@ -149,6 +184,22 @@ def test_ticker_overview_partial_response(monkeypatch) -> None: "quote": {"price": 100.0, "prev_close": 98.0, "change": 2.0, "change_pct": 0.0204}, "signals": [], "stats": {"market_cap": None, "trailing_pe": None, "trailing_eps": None, "volume": 1000.0, "average_volume": None, "beta": None}, + "ratios": { + "price_to_book": None, + "price_to_sales": None, + "ev_to_sales": None, + "ev_to_ebitda": None, + "gross_margin_ttm": None, + "operating_margin_ttm": None, + "net_margin_ttm": None, + "roe_ttm": None, + "roa_ttm": None, + "roic_ttm": None, + "debt_to_equity": None, + "current_ratio": None, + "dividend_yield_ttm": None, + "dividend_payout_ratio_ttm": None, + }, "range_52w": {"low": None, "high": None, "price": 100.0}, "short_interest": {"short_percent_of_float": None, "short_ratio": None, "shares_short": None, "shares_short_prior_month": None, "shares_short_delta_pct": None}, "meta": {"status": "partial", "is_partial": True, "field_availability": {"stats.market_cap": False}, "sources": {"profile.name": "search"}}, @@ -179,3 +230,157 @@ def test_ticker_history_period_mapping(monkeypatch) -> None: assert len(data_service.get_price_history("AAPL", period="3m")) == 1 assert len(data_service.get_price_history("AAPL", period="6m")) == 1 assert captured == ["1mo", "3mo", "6mo"] + + +def test_compute_ttm_ratios_populates_overlapping_stats(monkeypatch) -> None: + clear_service_caches() + monkeypatch.setattr(data_service, "get_company_info", lambda symbol: {}) + monkeypatch.setattr(data_service, "get_fast_info", lambda symbol: {}) + monkeypatch.setattr(data_service, "get_latest_price", lambda symbol: 50.0) + monkeypatch.setattr(data_service, "get_shares_outstanding", lambda symbol: 100.0) + monkeypatch.setattr( + data_service, + "get_income_statement", + lambda symbol, quarterly=False: quarterly_frame( + { + "Total Revenue": [1_000.0, 1_000.0, 1_000.0, 1_000.0], + "Gross Profit": [500.0, 500.0, 500.0, 500.0], + "Operating Income": [250.0, 250.0, 250.0, 250.0], + "Net Income": [100.0, 100.0, 100.0, 100.0], + "EBIT": [150.0, 150.0, 150.0, 150.0], + "EBITDA": [200.0, 200.0, 200.0, 200.0], + "Tax Provision": [20.0, 20.0, 20.0, 20.0], + "Pretax Income": [120.0, 120.0, 120.0, 120.0], + } + ), + ) + monkeypatch.setattr( + data_service, + "get_balance_sheet", + lambda symbol, quarterly=False: quarterly_frame( + { + "Stockholders Equity": [500.0, 0.0, 0.0, 0.0], + "Total Assets": [1_200.0, 0.0, 0.0, 0.0], + "Total Debt": [150.0, 0.0, 0.0, 0.0], + "Current Assets": [300.0, 0.0, 0.0, 0.0], + "Current Liabilities": [100.0, 0.0, 0.0, 0.0], + "Cash And Cash Equivalents": [50.0, 0.0, 0.0, 0.0], + } + ), + ) + monkeypatch.setattr( + data_service, + "get_cash_flow", + lambda symbol, quarterly=False: quarterly_frame({"Cash Dividends Paid": [-10.0, -10.0, -10.0, -10.0]}), + ) + + ratios = data_service.compute_ttm_ratios("AAPL") + assert ratios["market_cap"] == 5_000.0 + assert ratios["trailing_eps"] == 4.0 + assert ratios["trailing_pe"] == 12.5 + assert ratios["price_to_book"] == 10.0 + assert ratios["price_to_sales"] == 1.25 + assert ratios["ev_to_sales"] == 1.275 + assert ratios["gross_margin_ttm"] == 0.5 + assert ratios["operating_margin_ttm"] == 0.25 + assert ratios["net_margin_ttm"] == 0.1 + assert ratios["roe_ttm"] == 0.8 + assert round(ratios["roic_ttm"], 6) == round((600.0 * (1 - (80.0 / 480.0))) / 600.0, 6) + assert ratios["debt_to_equity"] == 0.3 + assert ratios["current_ratio"] == 3.0 + assert ratios["dividend_yield_ttm"] == 0.008 + assert ratios["dividend_payout_ratio_ttm"] == 0.1 + + +def test_compute_ttm_ratios_guardrails_suppress_outliers(monkeypatch) -> None: + clear_service_caches() + monkeypatch.setattr(data_service, "get_company_info", lambda symbol: {}) + monkeypatch.setattr(data_service, "get_fast_info", lambda symbol: {}) + monkeypatch.setattr(data_service, "get_latest_price", lambda symbol: 1_000.0) + monkeypatch.setattr(data_service, "get_shares_outstanding", lambda symbol: 100.0) + monkeypatch.setattr( + data_service, + "get_income_statement", + lambda symbol, quarterly=False: quarterly_frame( + { + "Total Revenue": [1.0, 1.0, 1.0, 1.0], + "Net Income": [1.0, 1.0, 1.0, 1.0], + "EBIT": [1.0, 1.0, 1.0, 1.0], + "EBITDA": [100_000.0, 100_000.0, 100_000.0, 100_000.0], + "Tax Provision": [0.0, 0.0, 0.0, 0.0], + "Pretax Income": [1.0, 1.0, 1.0, 1.0], + } + ), + ) + monkeypatch.setattr( + data_service, + "get_balance_sheet", + lambda symbol, quarterly=False: quarterly_frame( + { + "Stockholders Equity": [1.0, 0.0, 0.0, 0.0], + "Total Assets": [10.0, 0.0, 0.0, 0.0], + "Total Debt": [1_000.0, 0.0, 0.0, 0.0], + "Cash And Cash Equivalents": [0.0, 0.0, 0.0, 0.0], + } + ), + ) + monkeypatch.setattr(data_service, "get_cash_flow", lambda symbol, quarterly=False: pd.DataFrame()) + + ratios = data_service.compute_ttm_ratios("AAPL") + assert ratios["trailing_pe"] == 25_000.0 + assert "price_to_book" not in ratios + assert "price_to_sales" not in ratios + assert "ev_to_sales" not in ratios + assert "ev_to_ebitda" not in ratios + + +def test_overview_uses_computed_sources_and_ratios(monkeypatch) -> None: + clear_service_caches() + monkeypatch.setattr( + data_service, + "get_company_info", + lambda symbol: { + "longName": "Apple Inc.", + "exchange": "NMS", + "currentPrice": 120.0, + "previousClose": 118.0, + }, + ) + monkeypatch.setattr(data_service, "get_fast_info", lambda symbol: {}) + monkeypatch.setattr(data_service, "get_price_history", lambda symbol, period="1m": []) + monkeypatch.setattr(data_service, "_pick_search_match", lambda symbol: {"symbol": "AAPL", "name": "Apple Inc.", "exchange": "NASDAQ"}) + monkeypatch.setattr(data_service, "get_profile_enrichment", lambda symbol: {}) + monkeypatch.setattr( + data_service, + "compute_ttm_ratios", + lambda symbol: { + "market_cap": 1_500_000_000.0, + "trailing_pe": 24.5, + "trailing_eps": 4.9, + "price_to_book": 8.0, + "price_to_sales": 6.2, + "ev_to_sales": 6.8, + "ev_to_ebitda": 22.1, + "gross_margin_ttm": 0.44, + "operating_margin_ttm": 0.27, + "net_margin_ttm": 0.18, + "roe_ttm": 0.31, + "roa_ttm": 0.12, + "roic_ttm": 0.19, + "debt_to_equity": 0.42, + "current_ratio": 1.8, + "dividend_yield_ttm": 0.005, + "dividend_payout_ratio_ttm": 0.12, + }, + ) + + overview = data_service.get_ticker_overview("AAPL") + assert overview is not None + assert overview["stats"]["trailing_pe"] == 24.5 + assert overview["stats"]["market_cap"] == 1_500_000_000.0 + assert overview["ratios"]["price_to_book"] == 8.0 + assert overview["meta"]["sources"]["stats.trailing_pe"] == "computed" + assert overview["meta"]["sources"]["stats.market_cap"] == "computed" + assert overview["meta"]["sources"]["ratios.price_to_book"] == "computed" + assert overview["meta"]["field_availability"]["ratios.ev_to_ebitda"] is True + assert any(signal["key"] == "Valuation" and "24.5x" in signal["value"] for signal in overview["signals"]) |
