"""tests/test_valuation.py — unit tests for analytics/valuation.py. Mocks yfinance so tests are hermetic and fast, following the same pattern as tests/test_analyst.py. """ from __future__ import annotations from unittest.mock import MagicMock, patch import pytest from analytics import valuation @pytest.fixture(autouse=True) def _no_cache(monkeypatch): monkeypatch.setattr("storage.earnings_cache.get", lambda *a, **k: None) monkeypatch.setattr("storage.earnings_cache.set", lambda *a, **k: None) # ── fetch_multiples ──────────────────────────────────────────────────────────── def test_fetch_multiples_success(): fake = MagicMock() fake.info = { "trailingPE": 45.2, "forwardPE": 32.1, "enterpriseToRevenue": 18.5, "enterpriseToEbitda": 25.0, "marketCap": 3_000_000_000_000, } with patch("yfinance.Ticker", return_value=fake): data, err = valuation.fetch_multiples("NVDA") assert err is None assert data["trailing_pe"] == 45.2 assert data["forward_pe"] == 32.1 assert data["ev_to_sales"] == 18.5 assert data["ev_to_ebitda"] == 25.0 def test_fetch_multiples_fail_closed_when_nothing_available(): fake = MagicMock() fake.info = {} with patch("yfinance.Ticker", return_value=fake): data, err = valuation.fetch_multiples("XYZ") assert data is None assert err is not None def test_fetch_multiples_never_raises_on_yfinance_error(): with patch("yfinance.Ticker", side_effect=RuntimeError("rate limit")): data, err = valuation.fetch_multiples("NVDA") assert data is None assert err is not None def test_fetch_multiples_tolerates_nan_values(): fake = MagicMock() fake.info = {"trailingPE": float("nan"), "forwardPE": 20.0} with patch("yfinance.Ticker", return_value=fake): data, err = valuation.fetch_multiples("NVDA") assert err is None assert data["trailing_pe"] is None assert data["forward_pe"] == 20.0 # ── fetch_peer_multiples ─────────────────────────────────────────────────────── def test_fetch_peer_multiples_includes_primary_and_peers(): fake = MagicMock() fake.info = {"trailingPE": 40.0} with patch("yfinance.Ticker", return_value=fake): rows = valuation.fetch_peer_multiples("NVDA") tickers = [r["ticker"] for r in rows] assert "NVDA" in tickers assert "AMD" in tickers # from the curated PEERS map assert "AVGO" in tickers def test_fetch_peer_multiples_unknown_ticker_has_no_peers(): fake = MagicMock() fake.info = {"trailingPE": 40.0} with patch("yfinance.Ticker", return_value=fake): rows = valuation.fetch_peer_multiples("SOMEOBSCURETICKER") assert len(rows) == 1 assert rows[0]["ticker"] == "SOMEOBSCURETICKER" def test_fetch_peer_multiples_omits_peer_whose_fetch_fails(): def _side_effect(ticker): if ticker == "AMD": raise RuntimeError("no data") fake = MagicMock() fake.info = {"trailingPE": 40.0} return fake with patch("yfinance.Ticker", side_effect=_side_effect): rows = valuation.fetch_peer_multiples("NVDA") tickers = [r["ticker"] for r in rows] assert "AMD" not in tickers assert "NVDA" in tickers # ── next_catalysts ───────────────────────────────────────────────────────────── def test_next_catalysts_parses_dict_calendar(): fake = MagicMock() fake.calendar = {"Earnings Date": ["2026-08-20"], "Ex-Dividend Date": "2026-07-01"} with patch("yfinance.Ticker", return_value=fake): result = valuation.next_catalysts("NVDA") assert result["next_earnings_date"] == "2026-08-20" assert result["next_ex_dividend_date"] == "2026-07-01" def test_next_catalysts_never_raises_when_calendar_missing(): fake = MagicMock() fake.calendar = None with patch("yfinance.Ticker", return_value=fake): result = valuation.next_catalysts("NVDA") assert result == {"next_earnings_date": None, "next_ex_dividend_date": None} def test_next_catalysts_never_raises_on_yfinance_error(): with patch("yfinance.Ticker", side_effect=RuntimeError("boom")): result = valuation.next_catalysts("NVDA") assert result == {"next_earnings_date": None, "next_ex_dividend_date": None}