"""tests/test_analyst.py — unit tests for ingestion/analyst.py. Mocks yfinance so tests are hermetic and fast. Verifies parsing of earnings_estimate / eps_trend and the 30-day revision computation. """ from __future__ import annotations from datetime import datetime, timedelta from unittest.mock import MagicMock, patch import pandas as pd import pytest from ingestion import analyst # Disable cache for these tests (we don't want stale DB state interfering) @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_analyst_estimates ─────────────────────────────────────────────────── def _build_estimate_df(avg_eps=2.35): return pd.DataFrame( {"avg": [avg_eps, 2.50], "low": [2.20, 2.40], "high": [2.50, 2.60]}, index=["0q", "+1q"], ) def _build_revenue_df(avg_rev=91_400_000_000): return pd.DataFrame( {"avg": [avg_rev, 95_000_000_000]}, index=["0q", "+1q"], ) def _build_trend_df(current=2.35, ago30=2.31): return pd.DataFrame( {"current": [current, 2.50], "30daysAgo": [ago30, 2.45]}, index=["0q", "+1q"], ) def test_fetch_analyst_estimates_success(): fake = MagicMock() fake.earnings_estimate = _build_estimate_df() fake.revenue_estimate = _build_revenue_df() fake.eps_trend = _build_trend_df(current=2.40, ago30=2.30) with patch("yfinance.Ticker", return_value=fake): data, err = analyst.fetch_analyst_estimates("AAPL") assert err is None assert data["consensus_eps_est"] == 2.35 assert data["consensus_rev_est"] == 91_400_000_000 # (2.40 - 2.30) / 2.30 * 100 ≈ 4.35 assert abs(data["estimate_revision_30d_pct"] - 4.35) < 0.01 def test_fetch_analyst_estimates_handles_empty_dataframes(): fake = MagicMock() fake.earnings_estimate = pd.DataFrame() fake.revenue_estimate = pd.DataFrame() fake.eps_trend = pd.DataFrame() with patch("yfinance.Ticker", return_value=fake): data, err = analyst.fetch_analyst_estimates("XYZ") assert err is None assert data["consensus_eps_est"] is None assert data["consensus_rev_est"] is None assert data["estimate_revision_30d_pct"] is None assert data["period_aligned"] is False assert data["comparison_allowed"] is False def test_fetch_analyst_estimates_handles_zero_prior_estimate(): """Avoid division by zero when 30daysAgo == 0.""" fake = MagicMock() fake.earnings_estimate = _build_estimate_df() fake.revenue_estimate = _build_revenue_df() fake.eps_trend = _build_trend_df(current=2.30, ago30=0.0) with patch("yfinance.Ticker", return_value=fake): data, err = analyst.fetch_analyst_estimates("AAPL") assert err is None assert data["estimate_revision_30d_pct"] is None def test_fetch_analyst_estimates_propagates_exception(): with patch("yfinance.Ticker", side_effect=RuntimeError("rate limit")): data, err = analyst.fetch_analyst_estimates("AAPL") assert data is None assert "rate limit" in err # ── fetch_price_reaction ────────────────────────────────────────────────────── def _make_close_df(start_date: str, prices: list[float]): """Return a price-history DataFrame indexed by trading days starting at start_date.""" idx = pd.bdate_range(start=start_date, periods=len(prices)) return pd.DataFrame({"Close": prices, "Open": prices, "High": prices, "Low": prices}, index=idx) def _event_provenance(timing: str, event_date: str = "2026-04-01"): published_at = { "before_open": f"{event_date}T12:00:00+00:00", # 08:00 New York (EDT) "after_close": f"{event_date}T20:05:00+00:00", # 16:05 New York (EDT) }[timing] return analyst.make_earnings_event_provenance( ticker="AAPL", published_at=published_at, source_type="company_ir", source_url="https://investor.example.com/earnings-release", source_excerpt="Apple announced its quarterly financial results after market close.", ) def test_fetch_price_reaction_fails_closed_without_verified_provenance(): hist = _make_close_df("2026-04-01", [ 100.0, # 2026-04-01 Wed 101.0, # 2026-04-02 Thu 102.0, # 2026-04-03 Fri 103.0, # 2026-04-06 Mon 104.0, # 2026-04-07 Tue 105.0, # 2026-04-08 Wed 106.0, # 2026-04-09 Thu 107.0, # 2026-04-10 Fri ]) with patch("yfinance.download", return_value=hist) as mock_download: data, err = analyst.fetch_price_reaction("AAPL", "2026-04-01") assert err is None assert data["d1_pct"] is None assert data["d5_pct"] is None assert data["since_release_pct"] is None assert data["event_aligned"] is False assert data["comparison_allowed"] is False assert data["alignment_status"] == "UNVERIFIED_EVENT_PROVENANCE" mock_download.assert_not_called() def test_caller_strings_alone_never_verify_an_earnings_event(): hist = _make_close_df("2026-04-01", [100.0 + i for i in range(8)]) with patch("yfinance.download", return_value=hist) as mock_download: data, err = analyst.fetch_price_reaction( "AAPL", "2026-04-01", event_kind="earnings_release", event_timing="after_close", ) assert err is None assert data["event_aligned"] is False assert data["comparison_allowed"] is False assert data["alignment_reason"] == "missing_event_provenance" mock_download.assert_not_called() def test_verified_after_close_uses_event_close_then_next_and_fifth_sessions(): hist = _make_close_df("2026-04-01", [100.0 + i for i in range(8)]) provenance = _event_provenance("after_close") with patch("yfinance.download", return_value=hist): data, err = analyst.fetch_price_reaction( "AAPL", "2026-04-01", event_kind="earnings_release", event_timing="after_close", event_provenance=provenance, ) assert err is None assert data["event_aligned"] is True assert data["comparison_allowed"] is True assert data["alignment_status"] == "VERIFIED_EARNINGS_EVENT" assert data["baseline_trading_date"] == "2026-04-01" assert data["d1_trading_date"] == "2026-04-02" assert data["d5_trading_date"] == "2026-04-08" assert data["d1_pct"] == pytest.approx(1.0) assert data["d5_pct"] == pytest.approx(5.0) def test_verified_before_open_uses_prior_close_and_event_session_as_d1(): hist = _make_close_df("2026-03-31", [100.0 + i for i in range(9)]) provenance = _event_provenance("before_open") with patch("yfinance.download", return_value=hist): data, err = analyst.fetch_price_reaction( "AAPL", "2026-04-01", event_kind="earnings_release", event_timing="before_open", event_provenance=provenance, ) assert err is None assert data["event_aligned"] is True assert data["baseline_trading_date"] == "2026-03-31" assert data["d1_trading_date"] == "2026-04-01" assert data["d5_trading_date"] == "2026-04-07" assert data["d1_pct"] == pytest.approx(1.0) assert data["d5_pct"] == pytest.approx(5.0) def test_tampered_event_provenance_fails_closed_without_price_fetch(): provenance = _event_provenance("after_close") provenance["content_hash"] = "0" * 64 with patch("yfinance.download") as mock_download: data, err = analyst.fetch_price_reaction( "AAPL", "2026-04-01", event_kind="earnings_release", event_timing="after_close", event_provenance=provenance, ) assert err is None assert data["event_aligned"] is False assert data["alignment_reason"] == "event_content_hash_mismatch" mock_download.assert_not_called() def test_caller_timing_must_match_timestamp_derived_provenance(): provenance = _event_provenance("after_close") with patch("yfinance.download") as mock_download: data, err = analyst.fetch_price_reaction( "AAPL", "2026-04-01", event_kind="earnings_release", event_timing="before_open", event_provenance=provenance, ) assert err is None assert data["event_aligned"] is False assert data["alignment_reason"] == "event_timing_mismatch" mock_download.assert_not_called() def test_fetch_price_reaction_handles_invalid_date(): data, err = analyst.fetch_price_reaction("AAPL", "not-a-date") assert data is None assert "invalid filing_date" in err def test_fetch_price_reaction_handles_empty_history(): provenance = _event_provenance("after_close") with patch("yfinance.download", return_value=pd.DataFrame()): data, err = analyst.fetch_price_reaction( "AAPL", "2026-04-01", event_kind="earnings_release", event_timing="after_close", event_provenance=provenance, ) assert data is None assert "no price history" in err # ── post_earnings_returns_batch ─────────────────────────────────────────────── def test_post_earnings_returns_batch_fails_closed_without_provenance(): hist = _make_close_df("2026-04-01", [100.0 + i for i in range(20)]) with patch("yfinance.download", return_value=hist) as mock_download: out = analyst.post_earnings_returns_batch("AAPL", ["2026-04-01", "2026-04-08"]) assert out == {} mock_download.assert_not_called() def test_post_earnings_returns_batch_uses_verified_event_windows(): hist = _make_close_df("2026-04-01", [100.0 + i for i in range(20)]) provenance = { "2026-04-01": _event_provenance("after_close", "2026-04-01"), "2026-04-08": _event_provenance("after_close", "2026-04-08"), } with patch("yfinance.download", return_value=hist): out = analyst.post_earnings_returns_batch( "AAPL", ["2026-04-01", "2026-04-08"], event_provenance_by_date=provenance, ) assert "2026-04-01" in out assert "2026-04-08" in out # Returns are decimals, not percents assert 0 < out["2026-04-01"]["d1"] < 1 assert out["2026-04-01"]["event_aligned"] is True