Add authenticated global economic calendar with Jin10 fallback

Collect macroeconomic releases, events and holidays through the official browser SDK with resumable history from 2025. Preserve evidence, revisions and observation times, and merge verified series with official fields taking priority.

Add category filters, field provenance, coverage and connection status. Verify calendar persistence and deployment recovery in isolated acceptance checks.
This commit is contained in:
eezd committed 2026-10-31 12:57:14 +08:00
1 parent 57424fd661
commit e840557d22
35 files changed
+6828 -94

No files matched your search

File diff suppressed because it is too large. Load diff
+3 -3
View File
@@ -260,15 +260,15 @@ def test_calendar_api_requires_auth_validates_range_and_exposes_unavailable_work
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")
session.get.return_value = SimpleNamespace(family="cpi", source="bls")
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)
session.get.return_value = SimpleNamespace(family="cpi", source="bls", 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)
monkeypatch.setattr(calendar, "calendar_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)
+1
View File
@@ -25,6 +25,7 @@ def qr_env(trading, monkeypatch):
app.include_router(jin10.router)
app.dependency_overrides[get_session] = lambda: session
monkeypatch.setattr(jin10, "check_session", AsyncMock())
monkeypatch.setattr(jin10.redis, "get", AsyncMock(return_value=None))
monkeypatch.setattr(jin10_auth, "generate_qr", AsyncMock(return_value="private-qr-token"))
monkeypatch.setattr(jin10_auth, "poll_qr", AsyncMock(return_value=("pending", None)))
def authorize():
+207
View File
@@ -0,0 +1,207 @@
"""Calendar tests use public fixtures and isolate all account, database and upstream I/O."""
import asyncio
from copy import deepcopy
from datetime import UTC, date, datetime, timedelta
import json
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from alphapulse.calendar.jin10 import normalize, period
FIXTURE = json.loads((Path(__file__).parent / "fixtures/calendar/jin10.json").read_text())
def entries(day):
return normalize(deepcopy(FIXTURE[day]), "data", date.fromisoformat(day))
@pytest.mark.parametrize("day", [key for key in FIXTURE if ":" not in key])
def test_real_values_and_timestamp_seconds(day):
result = entries(day)
assert len(result) == len(FIXTURE[day]["list"])
for entry, raw in zip(result, FIXTURE[day]["list"], strict=True):
assert entry.at.timestamp() == raw["pub_time_unix"]
value = entry.values[f"jin10_{raw['indicator_id']}"]
assert value["actual"] == raw["actual"]
assert value["previous"] == raw["previous"]
assert value["previous_revised"] == raw["revised"]
assert "affect" not in value
def test_events_distinguish_midnight_from_undetermined_time():
result = normalize(FIXTURE["event:2025-01-08"], "event", date(2025, 1, 8))
ces = next(r for r in result if "CES" in r.title)
trump = next(r for r in result if "特朗普" in r.title)
assert ces.values["_jin10"]["precision"] == "tentative"
assert trump.values["_jin10"]["precision"] == "minute"
assert ces.at.hour == trump.at.hour == 16
def test_daily_holiday_and_empty_day():
entry, = normalize(FIXTURE["holiday:2026-11-03"], "holiday", date(2026, 11, 3))
assert entry.title == "文化日"
assert entry.values["_jin10"]["exchange"] == "东京证券交易所"
assert entry.values["_jin10"]["precision"] == "day"
assert normalize(FIXTURE["holiday:2026-11-04"], "holiday", date(2026, 11, 4)) == []
@pytest.mark.parametrize("change", ["date", "duplicate", "timestamp", "number", "country"])
def test_invalid_contract_does_not_become_empty_success(change):
body = deepcopy(FIXTURE["2026-11-04"])
if change == "date": body["date"] = "2026-11-05"
if change == "duplicate": body["list"] *= 2
if change == "timestamp": body["list"][0]["pub_time_unix"] += 3600
if change == "number": body["list"][0]["actual"] = 0
if change == "country": body["list"][0]["country"] = 7
with pytest.raises(ValueError):
normalize(body, "data", date(2026, 11, 4))
@pytest.mark.parametrize("payload", [None, [], "not base64", "eJwDAAAAAAE=", "eJyLjo0FAAEVALk=garbage"])
def test_broken_holiday_payload_is_not_accepted(payload):
with pytest.raises(ValueError):
normalize({"date": "2026-11-03", "list": payload}, "holiday", date(2026, 11, 3))
def test_non_numeric_text_and_unknown_units_are_preserved():
body = deepcopy(FIXTURE["2026-11-04"])
body["list"] = body["list"][:1]
body["list"][0].update(actual="4.00-4.25", unit=None, consensus=None, revised=None)
entry, = normalize(body, "data", date(2026, 11, 4))
assert entry.values["jin10_78"]["actual"] == "4.00-4.25"
assert entry.values["_jin10"]["unit"] is None
assert entry.values["jin10_78"]["forecast"] is None
def test_periods_cross_year_without_inventing_unrecognized_periods():
at = datetime(2025, 1, 8, 15, tzinfo=UTC)
assert period("12月", at) == "2024-12"
assert period("至12月30日", at) == "2024-12-30"
assert period("第四季度", at) == "2024-Q4"
assert period("1-12月", at) is None
assert period("至1月9日", at) is None
def stored(entry, id=11, now=None):
return SimpleNamespace(id=id, source="jin10", family="jin10", title=entry.title, scheduled_at=entry.at,
schedule_url="https://rili.jin10.com/day/2026-11-04/", release_url=None,
values=deepcopy(entry.values), first_received_at=now, last_checked_at=now,
schedule_checked_at=now, error=None, revision_no=1)
def test_late_forecast_is_displayed_but_not_a_historical_surprise(trading):
from alphapulse.calendar.jin10_view import jin10_item
row = stored(entries("2026-11-04")[0])
row.values["jin10_78"].update(actual="9", forecast_before_release=False, observed_at={"forecast": "2026-11-05T00:00:00+00:00"})
item = jin10_item(row, datetime(2026, 11, 5, tzinfo=UTC))
assert item["forecast"] == "7.5"
assert not item["forecast_before_release"]
assert item["impact_basis"] == "previous" and item["impact"] == "neutral"
def test_exact_merge_prefers_official_fields_and_preserves_previous_revisions(trading):
from alphapulse.calendar.jin10_view import merge_items
entry = next(e for e in entries("2026-10-29") if "jin10_79" in e.values)
extra = stored(entry)
extra.values["jin10_79"].update(observed_at={"forecast": "2026-10-28T00:00:00+00:00"}, forecast_before_release=True)
official = SimpleNamespace(source="dol", family="initial_claims", stage=None, period="2026-10-24", scheduled_at=entry.at,
first_received_at=entry.at, schedule_checked_at=entry.at, release_url="https://www.dol.gov/claims.pdf",
schedule_url="https://oui.doleta.gov/unemploy/archive.asp")
original = {"release_id": 1, "indicator": "initial_claims", "actual": "19.40", "previous": "19.5", "forecast": None,
"status": "released", "impact": "uncertain", "explanation": "official", "revision_no": 1, "scheduled_at": entry.at}
item, = merge_items([(official, original)], [extra], entry.at)
assert item["release_id"] == 1 and item["actual"] == "19.40"
assert item["forecast"] == "19.9" and item["related_release_id"] == extra.id
assert item["previous_original"] == "19.4" and item["previous_revised"] == "19.5"
assert item["field_sources"]["actual"]["source"] == "dol"
assert item["field_sources"]["forecast"]["source"] == "jin10"
assert original["forecast"] is None
official.period = "2026-10-17"
assert len(merge_items([(official, original)], [extra], entry.at)) == 2
official.period = "2026-10-24"
extra.values["_jin10"]["unit"] = "人"
assert len(merge_items([(official, original)], [extra], entry.at)) == 2
def test_cushing_is_not_total_commercial_crude(trading):
from alphapulse.calendar.jin10_view import definition
crude = next(e for e in entries("2025-01-08") if "jin10_111" in e.values)
cushing = next(e for e in entries("2025-01-08") if "jin10_953" in e.values)
assert definition(crude.values["_jin10"]) == ("eia_stocks", "eia_crude", None)
assert definition(cushing.values["_jin10"]) is None
def test_repeated_storage_preserves_observation_and_first_received_time(trading):
from alphapulse.calendar.jin10_store import save_entry
now = datetime(2026, 11, 3, tzinfo=UTC)
entry = entries("2026-11-04")[0]
row = stored(entry)
row.values["jin10_78"].update(observed_at={"previous": now.isoformat(), "forecast": now.isoformat()}, forecast_before_release=True)
old = deepcopy(row.values)
session = SimpleNamespace(scalar=AsyncMock(return_value=row), add=lambda r: pytest.fail("Unexpected revision"))
asyncio.run(save_entry(session, entry, 1, now + timedelta(hours=1)))
assert row.values == old and row.first_received_at is None
def test_reconnect_rejects_obsolete_inflight_capture(trading, monkeypatch):
from alphapulse.calendar import jin10_worker as worker
now = datetime.now(UTC)
class Session:
async def __aenter__(self): return self
async def __aexit__(self, *args): pass
scalar = AsyncMock(return_value=SimpleNamespace(token_encrypted=b"new", connected_at=now))
commit = AsyncMock()
session = Session()
monkeypatch.setattr(worker, "SessionLocal", lambda: session)
monkeypatch.setattr(worker, "save_raw", AsyncMock())
response = {"data": FIXTURE["2026-11-04"], "event": FIXTURE["event:2026-11-04"], "holiday": FIXTURE["holiday:2026-11-04"]}
with pytest.raises(worker.ConnectionChanged):
asyncio.run(worker.persist_day(date(2026, 11, 4), response, b"old", now, now))
worker.save_raw.assert_not_awaited()
session.commit.assert_not_awaited()
def test_every_verified_mapping_has_a_public_fixture_with_the_exact_definition(trading):
from alphapulse.calendar.jin10_view import MAPPINGS, definition
seen = set()
for day in (key for key in FIXTURE if ":" not in key):
for entry in entries(day):
meta = entry.values["_jin10"]
if definition(meta):
seen.add((meta["country_code"], int(meta["indicator"].removeprefix("jin10_")), meta["name"]))
expected = {(country, id_, name) for country, mapping in MAPPINGS.items() for id_, name in mapping}
assert expected <= seen
def test_holidays_preserve_distinct_exchanges_within_the_same_country():
rows = normalize(FIXTURE["holiday:2025-01-01"], "holiday", date(2025, 1, 1))
assert len(rows) == 17
us = [row for row in rows if row.values["_jin10"]["country_code"] == "US"]
assert len(us) > 1 and len({row.identity for row in us}) == len(us)
def test_connection_status_never_accepts_a_previous_connection_heartbeat(trading):
from alphapulse.api.jin10 import public_status
now = datetime.now(UTC)
row = SimpleNamespace(token_encrypted=b"synthetic", connected_at=now, error=None)
sync = {"at": now.isoformat(), "state": "ready", "connected_at": now.isoformat(), "last_success_at": now.isoformat()}
assert public_status(row, sync)["sync_state"] == "ready"
sync["connected_at"] = (now - timedelta(seconds=1)).isoformat()
assert public_status(row, sync)["sync_state"] == "awaiting_validation"
row.token_encrypted = None
sync["connected_at"] = now.isoformat()
assert public_status(row, sync)["sync_state"] == "disconnected"
def test_account_login_does_not_prove_calendar_access():
from alphapulse.calendar.jin10_browser import validate_access
from alphapulse.calendar.jin10_auth import Jin10Error
response = {"data": FIXTURE["2025-01-08"], "event": FIXTURE["event:2025-01-08"],
"holiday": {"date": "2025-01-08", "list": FIXTURE["holiday:2026-11-04"]["list"]}}
validate_access(response)
response["data"] = {"date": "2025-01-08", "list": []}
with pytest.raises(Jin10Error, match="读取权限"):
validate_access(response)