import asyncio from datetime import UTC, date, datetime, timedelta from pathlib import Path from types import SimpleNamespace from unittest.mock import AsyncMock import pytest from alphapulse.calendar.parsers import bea_release_link, parse_fed_calendar, parse_ics, parse_values FIXTURES = Path(__file__).parent / "fixtures" / "calendar" def ics(summary="Consumer Price Index", start="DTSTART;TZID=US-Eastern:20261110T083000", status=""): return f"BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nUID:stable-id\r\nSUMMARY:{summary}\r\n{start}\r\n{status}\r\nEND:VEVENT\r\nEND:VCALENDAR" @pytest.mark.parametrize("date,expected", [("20261014T083000", 12), ("20261110T083000", 13)]) def test_schedule_respects_eastern_dst(date, expected): [event] = parse_ics(ics(start=f"DTSTART;TZID=US-Eastern:{date}"), "bls") assert event.at.hour == expected assert event.external_id == "stable-id" def test_schedule_unfolding_utc_and_cancellation(): [event] = parse_ics(ics(summary="Personal Income and Outlays\\,\r\n September 2026", start="DTSTART:20261029T123000Z", status="STATUS:CANCELLED"), "bea") assert event.family == "pce" and event.status == "withdrawn" assert event.title == "Personal Income and Outlays,September 2026" assert event.at == datetime(2026, 10, 29, 12, 30, tzinfo=UTC) @pytest.mark.parametrize("body,source", [("Access denied", "bea"), (ics(start="DTSTART:20261110T083000"), "bls"), (ics(summary="GDP by County and Personal Income by County, 2025"), "bea")]) def test_invalid_or_irrelevant_schedule_is_not_empty_success(body, source): with pytest.raises(ValueError): parse_ics(body, source) def test_bea_discovery_matches_year_date_and_national_report(): def row(title, link): return f'September 30{title}' body = '' + row("GDP, (Third Estimate)", "/news/2026/gdp-third") + row("GDP by County", "/news/2026/county") + row("Personal Income and Outlays, August", "/news/2026/pce") + row("GDP, (Third Estimate)", "/news/2025/old") + '
' assert bea_release_link(body, date(2026, 9, 30), "gdp") == "https://www.bea.gov/news/2026/gdp-third" assert bea_release_link(body, date(2026, 9, 30), "pce") == "https://www.bea.gov/news/2026/pce" assert bea_release_link(body, date(2026, 10, 29), "pce") is None with pytest.raises(ValueError, match="缺少数据行"): bea_release_link("error", date(2026, 9, 30), "pce") with pytest.raises(ValueError, match="不唯一"): bea_release_link(body + body, date(2026, 9, 30), "pce") def test_fed_calendar_uses_published_time_not_press_conference(): [event] = parse_fed_calendar((FIXTURES / "fed-month.html").read_text()) assert event.at == datetime(2026, 10, 28, 18, tzinfo=UTC) assert event.family == "fomc" @pytest.mark.parametrize("family,key,actual,previous", [ ("cpi", "cpi_mom", "0.6", "0.4"), ("cpi", "core_cpi_yoy", "2.6", None), ("ppi", "ppi_yoy", "6.3", "5.6"), ("jobs", "payrolls", "2.9", "13.3"), ("jobs", "unemployment", "4.2", None), ("pce", "core_pce_mom", "0.2", "0.1"), ("pce", "pce_yoy", "3.4", None), ("pce", "core_pce_yoy", "3.0", None), ("fomc", "fed_rate", "4", None), ]) def test_official_release_values_and_revised_previous(family, key, actual, previous): values = parse_values(family, (FIXTURES / f"{family}.html").read_text()) assert values[key] == {"actual": actual, "previous": previous} @pytest.mark.parametrize("rate,expected", [("4-1/2", "4.5"), ("1/4", "0.25"), ("4.25", "4.25")]) def test_fed_fractional_upper_bound(rate, expected): html = f'
maintain the target range for the federal funds rate at 4 to {rate} percent
' assert parse_values("fomc", html)["fed_rate"]["actual"] == expected def test_employment_decrease_and_negative_gdp(): html = '
Total nonfarm payroll employment declined by 92,000. The unemployment rate rose to 4.4 percent.
' assert parse_values("jobs", html)["payrolls"]["actual"] == "-9.2" html = '
Real gross domestic product (GDP) decreased at an annual rate of 0.6 percent.
' assert parse_values("gdp", html)["gdp"]["actual"] == "-0.6" def test_missing_release_value_is_not_zero(): with pytest.raises((ValueError, KeyError)): parse_values("cpi", '
Release pending
') def test_poll_cadence_is_fast_at_release_and_bounded_after_errors(trading): from alphapulse.calendar.service import next_poll at = datetime(2026, 10, 29, 12, 30, tzinfo=UTC) assert next_poll(at, at - timedelta(seconds=10), False) == at assert next_poll(at, at, False) == at + timedelta(seconds=5) assert next_poll(at, at + timedelta(minutes=5), False) == at + timedelta(minutes=5, seconds=15) assert next_poll(at, at, False, True) == at + timedelta(seconds=60) assert next_poll(at, at, True) == at + timedelta(seconds=60) def test_without_consensus_labels_reference_impact_without_claiming_surprise(trading): from alphapulse.calendar.service import impact direction, reason = impact("cpi_mom", "0.6", "0.4") assert direction == "bearish" and "上升" in reason and "未取得市场一致预期" in reason assert "参考判断" in reason and "不是超预期判断" in reason and "不代表币价必然" in reason assert impact("cpi_mom", None, "0.4") == ("pending", "等待官方公布") @pytest.mark.parametrize("key,actual,previous,forecast,direction", [ ("cpi_mom", "0.2", "0.4", None, "bullish"), ("ppi_mom", "0.2", "0.2", None, "neutral"), ("fed_rate", "4.5", "4.25", None, "bearish"), ("cpi_mom", "0.3", "0.2", "0.4", "bullish"), ("payrolls", "2.9", "13.3", None, "uncertain"), ("core_cpi_yoy", "2.6", None, None, "uncertain"), ]) def test_impact_uses_forecast_first_and_does_not_hide_ambiguous_channels(trading, key, actual, previous, forecast, direction): from alphapulse.calendar.service import impact actual_direction, explanation = impact(key, actual, previous, forecast) assert actual_direction == direction if forecast is not None: assert "市场预期" in explanation and "不是超预期" not in explanation def test_event_has_country_specific_importance_and_comparison_basis(trading): from alphapulse.calendar.service import release_rows now = datetime(2026, 10, 24, tzinfo=UTC) row = SimpleNamespace(id=1, period=None, stage=None, family="ppi", source="bls", title="PPI", scheduled_at=now, schedule_status="scheduled", first_received_at=now, values={"ppi_mom": {"actual": "0.2", "previous": "0.1"}}, last_checked_at=now, error=None, release_url=None, schedule_url="https://www.bls.gov", revision_no=2) [mom, _] = release_rows(row, now) assert mom["country_code"] == "US" and mom["event_name"] == "美国PPI 最终需求月率" assert mom["importance"] == 3 and mom["impact"] == "bearish" and mom["impact_basis"] == "previous" row.family = "cpi" assert release_rows(row, now)[1]["importance"] == 5 def test_result_records_first_arrival_and_revisions_without_overwriting_history(trading): from alphapulse.calendar.service import save_result row = SimpleNamespace(id=1, period=None, stage=None, title="CPI", scheduled_at=datetime(2026, 10, 14, 12, 30, tzinfo=UTC), schedule_status="scheduled", values={}, release_url=None, first_received_at=None, revision_no=1) captured = [] session = SimpleNamespace(add=captured.append) now = row.scheduled_at + timedelta(seconds=7) first = {"cpi_mom": {"actual": "0.6", "previous": "0.4"}} save_result(session, row, first, "https://www.bls.gov/example", 1, now) save_result(session, row, first, "https://www.bls.gov/example", 2, now + timedelta(seconds=5)) save_result(session, row, {"cpi_mom": {"actual": "0.5", "previous": "0.4"}}, "https://www.bls.gov/example", 3, now + timedelta(days=1)) assert row.first_received_at == now and row.revision_no == 3 assert len(captured) == 2 and captured[0].payload["values"] == first def test_result_date_mismatch_waits_without_using_stale_release(trading): import httpx from alphapulse.calendar.worker import CalendarWorker async def check(): worker = CalendarWorker() try: response = httpx.Response(200, text='October 14, 2026', request=httpx.Request("GET", "https://www.bls.gov/example")) worker.fetch = AsyncMock(return_value=(response, 1)) row = SimpleNamespace(source="bls", family="cpi", scheduled_at=datetime(2026, 11, 10, 13, 30, tzinfo=UTC), first_received_at=None) assert await worker.result_page(row) is None row.scheduled_at = datetime(2026, 9, 10, 12, 30, tzinfo=UTC) with pytest.raises(ValueError, match="日期与日历不符"): await worker.result_page(row) finally: await worker.http.aclose() await worker.bls.aclose() asyncio.run(check()) @pytest.mark.parametrize("source", ["bls", "umich"]) def test_rolling_page_rollover_preserves_received_result_without_source_error(trading, monkeypatch, source): import httpx from alphapulse.calendar import worker as module async def check(): first_received = datetime(2026, 9, 24, 14, tzinfo=UTC) values = {"cpi_yoy" if source == "bls" else "umich_sentiment": {"actual": "2.5", "previous": "2.4"}} row = SimpleNamespace(id=1, source=source, family="cpi" if source == "bls" else "umich", scheduled_at=first_received, first_received_at=first_received, period="2026-09", stage="final" if source == "umich" else None, values=values.copy(), revision_no=3, error="旧的采集错误") session = SimpleNamespace(scalar=AsyncMock(return_value=row), commit=AsyncMock()) class Context: async def __aenter__(self): return session async def __aexit__(self, *args): pass monkeypatch.setattr(module, "SessionLocal", Context) html = ('October 22, 2026' if source == "bls" else '

Preliminary Results for October 2026

' '' '
OctSep
20262026
Index of Consumer Sentiment49.048.1
') worker = module.CalendarWorker() worker.fetch = AsyncMock(return_value=(httpx.Response(200, text=html, request=httpx.Request("GET", "https://www.example.com/")), 9)) try: await worker.poll_one(1) assert row.error is None assert row.first_received_at == first_received assert row.values == values and row.revision_no == 3 session.commit.assert_awaited_once() finally: await worker.http.aclose() await worker.bls.aclose() asyncio.run(check()) def test_schedule_reschedule_and_withdrawal_preserve_past_and_revisions(trading): from alphapulse.calendar.parsers import Scheduled from alphapulse.calendar.service import save_schedule now = datetime(2026, 10, 24, tzinfo=UTC) def row(id_, at): return SimpleNamespace(id=id_, period=None, stage=None, external_id=str(id_), scheduled_at=at, title="CPI", schedule_status="scheduled", first_received_at=None, values={}, release_url=None, revision_no=1, schedule_checked_at=now, next_check_at=at) rescheduled = row(1, now + timedelta(days=2)) removed = row(2, now + timedelta(days=3)) past = row(3, now - timedelta(days=3)) recorded = [] session = SimpleNamespace(scalars=AsyncMock(return_value=[rescheduled, removed, past]), add=recorded.append) later = now + timedelta(days=4) async def check(): events = [Scheduled("1", "cpi", "CPI", later)] await save_schedule(session, "bls", "https://www.bls.gov/calendar", events, 9, now) await save_schedule(session, "bls", "https://www.bls.gov/calendar", events, 10, now) asyncio.run(check()) assert rescheduled.scheduled_at == later and rescheduled.next_check_at == later assert removed.schedule_status == "withdrawn" and past.schedule_status == "scheduled" assert len(recorded) == 2 def test_calendar_api_requires_auth_validates_range_and_exposes_unavailable_worker(trading, monkeypatch): import httpx from fastapi import FastAPI from alphapulse.api import calendar from alphapulse.core.auth import require_admin, AuthSession from alphapulse.core.config import get_settings from alphapulse.core.db import get_session monkeypatch.setattr(get_settings(), "jwt_secret", "calendar-review-test-only-32-bytes") session = SimpleNamespace(scalars=AsyncMock(return_value=[]), get=AsyncMock(return_value=None)) app = FastAPI() app.include_router(calendar.router) app.dependency_overrides[get_session] = lambda: session monkeypatch.setattr(calendar.redis, "get", AsyncMock(return_value=None)) async def check(): async with httpx.AsyncClient(transport=httpx.ASGITransport(app), base_url="http://test") as client: assert (await client.get("/api/calendar?start=2026-10-24")).status_code == 401 assert (await client.get("/api/calendar/1/series?indicator=cpi_mom")).status_code == 401 app.dependency_overrides[require_admin] = lambda: AuthSession(get_settings().admin_username, "calendar-test", 9999999999) response = await client.get("/api/calendar?start=2026-10-24") assert response.status_code == 200 assert response.json()["data"]["worker"] is None and response.json()["data"]["forecast_available"] is False assert response.json()["data"]["countries"] == [{"code": "US", "name": "美国"}, {"code": "EA", "name": "欧元区"}, {"code": "GB", "name": "英国"}, {"code": "DE", "name": "德国"}, {"code": "FR", "name": "法国"}] assert (await client.get("/api/calendar?start=2026-10-24&days=32")).status_code == 422 assert (await client.get("/api/calendar/999/revisions")).status_code == 404 assert (await client.get("/api/calendar/999/history?indicator=cpi_mom")).status_code == 404 assert (await client.get("/api/calendar/999/series?indicator=cpi_mom")).status_code == 404 session.get.return_value = SimpleNamespace(family="cpi") assert (await client.get("/api/calendar/1/history?indicator=ppi_mom")).status_code == 400 assert (await client.get("/api/calendar/1/history?indicator=cpi_mom&limit=121")).status_code == 422 assert (await client.get("/api/calendar/1/series?indicator=ppi_mom")).status_code == 400 assert (await client.get("/api/calendar/1/series?indicator=cpi_mom&limit=121")).status_code == 422 session.get.return_value = SimpleNamespace(family="cpi", period=None) assert (await client.get("/api/calendar/1/series?indicator=cpi_mom")).json()["data"] == [] handler = AsyncMock(return_value=[{"actual": "0.3", "first_recorded_actual": "0.4", "revised": True}]) monkeypatch.setattr(calendar, "indicator_history", handler) history = await client.get("/api/calendar/1/history?indicator=cpi_mom") assert history.status_code == 200 and history.json()["data"][0]["revised"] is True assert handler.await_args.args[2:] == ("cpi_mom", 36) asyncio.run(check()) def test_worker_initializes_all_sources_and_drops_expired_month_status(trading): import httpx from alphapulse.calendar.worker import CalendarWorker async def check(): worker = CalendarWorker() worker.sources["https://www.federalreserve.gov/old-month.htm"] = {"error": "old"} async def unavailable(source, url, *, query=None): assert len(worker.sources) == 18 assert all(s["error"] for s in worker.sources.values()) raise httpx.ConnectError("source unavailable") worker.fetch = unavailable try: await worker.schedules() assert all(s["error"] == "source unavailable" for s in worker.sources.values()) assert sum(s["source"] == "fed" for s in worker.sources.values()) == 2 finally: await worker.http.aclose() await worker.bls.aclose() asyncio.run(check()) @pytest.mark.parametrize("value,seconds", [("7200", 7200), ("Sun, 25 Oct 2026 14:00:00 GMT", 7200), ("Sun, 25 Oct 2026 11:00:00 GMT", -3600), (None, None), ("invalid", None), ("-1", None), ("25 Oct 2026 14:00:00", None), ("9" * 30, None)]) def test_retry_after_accepts_seconds_and_http_date_without_guessing_invalid_headers(trading, value, seconds): from alphapulse.calendar.worker import retry_after_at now = datetime(2026, 10, 25, 12, tzinfo=UTC) assert retry_after_at(value, now) == (now + timedelta(seconds=seconds) if seconds is not None else None) @pytest.mark.parametrize("status", [429, 503]) def test_limited_result_preserves_response_and_defers_single_poll(trading, monkeypatch, status): import httpx from alphapulse.calendar import worker as module async def check(): now = datetime.now(UTC) row = SimpleNamespace(id=1, source="eurostat", family="ea_hicp", scheduled_at=now, first_received_at=None, release_url="https://ec.europa.eu/eurostat/product?code=2-16102026-ap") session = SimpleNamespace(scalar=AsyncMock(return_value=row), commit=AsyncMock()) class Context: async def __aenter__(self): return session async def __aexit__(self, *args): pass monkeypatch.setattr(module, "SessionLocal", Context) save_raw = AsyncMock(return_value=1) monkeypatch.setattr(module, "save_raw", save_raw) calls = [] def limited(request): calls.append(request) return httpx.Response(status, headers={"Retry-After": "7200"}, text="Quota exhausted") worker = module.CalendarWorker() await worker.http.aclose() worker.http = httpx.AsyncClient(transport=httpx.MockTransport(limited)) try: await worker.poll_one(1) assert len(calls) == 1 and str(status) in row.error assert row.first_received_at is None assert row.next_check_at >= now + timedelta(seconds=7200) assert save_raw.await_args.args[3] == status assert save_raw.await_args.args[4]["retry-after"] == "7200" finally: await worker.http.aclose() await worker.bls.aclose() asyncio.run(check()) def test_schedule_honors_server_pause_without_blocking_other_sources(trading): import httpx from alphapulse.calendar.worker import CalendarWorker async def check(): worker = CalendarWorker() calls = [] async def unavailable(source, url, *, query=None): calls.append(source) if source == "ecb": httpx.Response(429, headers={"Retry-After": "7200"}, request=httpx.Request("GET", url)).raise_for_status() raise httpx.ConnectError("source unavailable") worker.fetch = unavailable try: await worker.schedules() await worker.schedules() assert calls.count("ecb") == 1 assert calls.count("insee") == 2 ecb = next(s for s in worker.sources.values() if s["source"] == "ecb") assert ecb["error"] and datetime.fromisoformat(ecb["retry_at"]) > datetime.now(UTC) finally: await worker.http.aclose() await worker.bls.aclose() asyncio.run(check())