| """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 |
|
|
|
|
| |
| @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) |
|
|
|
|
| |
|
|
| 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 |
| |
| 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 |
|
|
|
|
| |
|
|
| 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", |
| "after_close": f"{event_date}T20:05:00+00:00", |
| }[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, |
| 101.0, |
| 102.0, |
| 103.0, |
| 104.0, |
| 105.0, |
| 106.0, |
| 107.0, |
| ]) |
| 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 |
|
|
|
|
| |
|
|
| 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 |
| |
| assert 0 < out["2026-04-01"]["d1"] < 1 |
| assert out["2026-04-01"]["event_aligned"] is True |
|
|