amplegest / tests /test_analyst.py
Viney's picture
feat: multi-provider LLM support, prominent chat, design pass, and new analytics
7880373
Raw
History Blame Contribute Delete
10.5 kB
"""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