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:
1 parent
57424fd661
commit
e840557d22
35 files changed
+6828
-94
No files matched your search
@@ -9,11 +9,14 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from alphapulse.api.response import ApiResponse, success
|
||||
from alphapulse.calendar.catalog import COUNTRIES, FAMILIES
|
||||
from alphapulse.calendar.service import indicator_history, release_rows
|
||||
from alphapulse.calendar.service import release_rows
|
||||
from alphapulse.calendar.jin10_history import calendar_history
|
||||
from alphapulse.calendar.jin10_view import merge_items
|
||||
from alphapulse.core.auth import require_admin
|
||||
from alphapulse.core.db import get_session
|
||||
from alphapulse.core.redis import KEY_PREFIX, redis
|
||||
from alphapulse.models import EconomicRelease, EconomicRevision, EconomicSeriesValue, RawResponse
|
||||
from alphapulse.models.jin10 import Jin10Connection, Jin10SyncDay
|
||||
|
||||
router = APIRouter(prefix="/api/calendar", tags=["calendar"], dependencies=[Depends(require_admin)])
|
||||
LOCAL_TZ = ZoneInfo("Asia/Shanghai")
|
||||
@@ -25,12 +28,32 @@ async def calendar_data(session: AsyncSession, start: datetime, end: datetime) -
|
||||
).order_by(EconomicRelease.scheduled_at, EconomicRelease.id)))
|
||||
raw = await redis.get(f"{KEY_PREFIX}calendar:status")
|
||||
status = json.loads(raw) if raw else None
|
||||
raw_jin10 = await redis.get(f"{KEY_PREFIX}calendar:jin10")
|
||||
jin10_status = json.loads(raw_jin10) if raw_jin10 else None
|
||||
if jin10_status:
|
||||
connection = await session.get(Jin10Connection, 1)
|
||||
if not connection or not connection.token_encrypted or jin10_status.get("connected_at") != connection.connected_at.isoformat():
|
||||
jin10_status = {**jin10_status, "state": "disconnected", "error": None}
|
||||
coverage = list(await session.scalars(select(Jin10SyncDay).where(
|
||||
Jin10SyncDay.day >= start.astimezone(LOCAL_TZ).date(), Jin10SyncDay.day < end.astimezone(LOCAL_TZ).date(),
|
||||
).order_by(Jin10SyncDay.day)))
|
||||
now = datetime.now(UTC)
|
||||
return {"items": [item for row in rows for item in release_rows(row, now)], "worker": status,
|
||||
items = merge_items([(row, item) for row in rows if row.source != "jin10" for item in release_rows(row, now)],
|
||||
[row for row in rows if row.source == "jin10"], now)
|
||||
countries = {**COUNTRIES, **{item["country_code"]: item["country_name"] for item in items}}
|
||||
return {"items": items, "worker": status,
|
||||
"jin10": jin10_status,
|
||||
"coverage": [{"date": day.day, "captured_at": day.captured_at, "counts": day.counts} for day in coverage],
|
||||
"server_time": now, "timezone": "Asia/Shanghai",
|
||||
"countries": [{"code": code, "name": name} for code, name in COUNTRIES.items()],
|
||||
"forecast_available": any(row.values.get(key, {}).get("forecast") is not None
|
||||
for row in rows for key in FAMILIES[row.family].indicators)}
|
||||
"countries": [{"code": code, "name": name} for code, name in countries.items()],
|
||||
"forecast_available": any(item["forecast"] is not None for item in items)}
|
||||
|
||||
|
||||
def indicators(row: EconomicRelease) -> tuple[str, ...]:
|
||||
if row.source == "jin10":
|
||||
meta = row.values["_jin10"]
|
||||
return (meta["indicator"],) if meta["kind"] == "data" else ()
|
||||
return FAMILIES[row.family].indicators
|
||||
|
||||
|
||||
@router.get("")
|
||||
@@ -42,11 +65,12 @@ async def list_calendar(session: Annotated[AsyncSession, Depends(get_session)],
|
||||
|
||||
@router.get("/{release_id}/revisions")
|
||||
async def revisions(release_id: int, session: Annotated[AsyncSession, Depends(get_session)]) -> ApiResponse[list[dict]]:
|
||||
if await session.get(EconomicRelease, release_id) is None:
|
||||
row = await session.get(EconomicRelease, release_id)
|
||||
if row is None:
|
||||
raise HTTPException(404, "日历事件不存在")
|
||||
rows = await session.scalars(select(EconomicRevision).where(EconomicRevision.release_id == release_id)
|
||||
.order_by(EconomicRevision.revision_no.desc()))
|
||||
return success([{"revision_no": r.revision_no, "captured_at": r.captured_at,
|
||||
return success([{"source": row.source, "revision_no": r.revision_no, "captured_at": r.captured_at,
|
||||
"payload": r.payload, "raw_response_id": r.raw_response_id} for r in rows])
|
||||
|
||||
|
||||
@@ -56,9 +80,9 @@ async def history(release_id: int, indicator: str, session: Annotated[AsyncSessi
|
||||
row = await session.get(EconomicRelease, release_id)
|
||||
if row is None:
|
||||
raise HTTPException(404, "日历事件不存在")
|
||||
if indicator not in FAMILIES[row.family].indicators:
|
||||
if indicator not in indicators(row):
|
||||
raise HTTPException(400, "指标不属于该事件")
|
||||
return success(await indicator_history(session, row, indicator, limit))
|
||||
return success(await calendar_history(session, row, indicator, limit))
|
||||
|
||||
|
||||
@router.get("/{release_id}/series")
|
||||
@@ -67,9 +91,9 @@ async def series(release_id: int, indicator: str, session: Annotated[AsyncSessio
|
||||
row = await session.get(EconomicRelease, release_id)
|
||||
if row is None:
|
||||
raise HTTPException(404, "日历事件不存在")
|
||||
if indicator not in FAMILIES[row.family].indicators:
|
||||
if indicator not in indicators(row):
|
||||
raise HTTPException(400, "指标不属于该事件")
|
||||
if row.period is None:
|
||||
if row.period is None or row.source == "jin10":
|
||||
return success([])
|
||||
# 此接口是当前官方序列,采集时间可以晚于所选事件;不得用作历史时点回测输入。
|
||||
rows = (await session.execute(select(EconomicSeriesValue, RawResponse.url).join(
|
||||
|
||||
@@ -119,7 +119,6 @@ async def calendar_sources(session: AsyncSession) -> list[CalendarSourceOut]:
|
||||
now = datetime.now(UTC)
|
||||
raw = await redis.get(f"{KEY_PREFIX}calendar:status")
|
||||
heartbeat = json.loads(raw) if raw else None
|
||||
alive = bool(heartbeat and now - datetime.fromisoformat(heartbeat["at"]) < timedelta(seconds=30))
|
||||
pending = EconomicRelease.first_received_at.is_(None) & (EconomicRelease.schedule_status == "scheduled")
|
||||
published = EconomicRelease.first_received_at.is_not(None)
|
||||
rows = (await session.execute(select(
|
||||
@@ -138,18 +137,26 @@ async def calendar_sources(session: AsyncSession) -> list[CalendarSourceOut]:
|
||||
EconomicRelease.scheduled_at >= now - timedelta(days=45),
|
||||
).order_by(EconomicRelease.last_checked_at.desc())):
|
||||
result_errors.setdefault(source, error)
|
||||
codes = {family.source for family in FAMILIES.values()}
|
||||
codes = {family.source for family in FAMILIES.values()} | {"jin10"}
|
||||
raw_jin10 = await redis.get(f"{KEY_PREFIX}calendar:jin10")
|
||||
jin10_heartbeat = json.loads(raw_jin10) if raw_jin10 else None
|
||||
sources = list(await session.scalars(select(Source).where(Source.code.in_(codes)).order_by(Source.code)))
|
||||
output = []
|
||||
for source in sources:
|
||||
checks = [s for s in heartbeat["sources"].values() if s["source"] == source.code] if heartbeat else []
|
||||
current_heartbeat = jin10_heartbeat if source.code == "jin10" else heartbeat
|
||||
source_alive = bool(current_heartbeat and now - datetime.fromisoformat(current_heartbeat["at"]) < timedelta(seconds=30))
|
||||
if source.code == "jin10":
|
||||
checks = [{"source": "jin10", "checked_at": jin10_heartbeat["at"],
|
||||
"error": jin10_heartbeat.get("error") or (None if jin10_heartbeat.get("state") in ("ready", "syncing") else "金十同步尚未就绪")}] if jin10_heartbeat else []
|
||||
else:
|
||||
checks = [s for s in heartbeat["sources"].values() if s["source"] == source.code] if heartbeat else []
|
||||
errors = [s["error"] for s in checks if s["error"]]
|
||||
if source.code in result_errors:
|
||||
errors.append(result_errors[source.code])
|
||||
data = stats.get(source.code, {})
|
||||
output.append(CalendarSourceOut(
|
||||
code=source.code, name=source.name, region_code=REGIONS[source.code], worker_alive=alive,
|
||||
heartbeat_at=heartbeat["at"] if heartbeat else None,
|
||||
code=source.code, name=source.name, region_code=REGIONS[source.code], worker_alive=source_alive,
|
||||
heartbeat_at=current_heartbeat["at"] if current_heartbeat else None,
|
||||
schedule_checked_at=max((s["checked_at"] for s in checks), default=None),
|
||||
**{key: data.get(key) for key in ("schedule_success_at", "latest_result_at", "next_event_at", "history_start", "history_end")},
|
||||
overdue_count=data.get("overdue_count", 0), errors=list(dict.fromkeys(errors)),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Administrator-only Jin10 connection; credentials never leave the backend."""
|
||||
from datetime import UTC, datetime, timedelta
|
||||
import json
|
||||
import secrets
|
||||
from typing import Annotated
|
||||
|
||||
@@ -12,6 +13,7 @@ from alphapulse.calendar import jin10_auth
|
||||
from alphapulse.core.auth import AuthSession, check_session, require_admin
|
||||
from alphapulse.core.crypto import decrypt, encrypt
|
||||
from alphapulse.core.db import get_session
|
||||
from alphapulse.core.redis import KEY_PREFIX, redis
|
||||
from alphapulse.models.jin10 import Jin10Connection
|
||||
|
||||
router = APIRouter(prefix="/api/settings/jin10", tags=["settings"], dependencies=[Depends(require_admin)])
|
||||
@@ -33,14 +35,21 @@ def clear_task(row: Jin10Connection) -> None:
|
||||
row.task_id = row.owner = row.qr_encrypted = row.expires_at = row.next_poll_at = None
|
||||
|
||||
|
||||
def public_status(row: Jin10Connection) -> dict:
|
||||
def public_status(row: Jin10Connection, sync: dict | None = None) -> dict:
|
||||
current = bool(row.token_encrypted and row.connected_at and sync and sync.get("connected_at") == row.connected_at.isoformat()
|
||||
and datetime.now(UTC) - datetime.fromisoformat(sync["at"]) < timedelta(seconds=30))
|
||||
return {"connected": row.token_encrypted is not None, "connected_at": row.connected_at,
|
||||
"error": row.error, "sync_state": "awaiting_validation" if row.token_encrypted else "disconnected"}
|
||||
"error": row.error or (sync.get("error") if current else None),
|
||||
"sync_state": sync["state"] if current else "awaiting_validation" if row.token_encrypted else "disconnected",
|
||||
"last_success_at": sync.get("last_success_at") if current else None,
|
||||
"history_next": sync.get("history_next") if current else None}
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def status(session: Session) -> ApiResponse[dict]:
|
||||
return success(public_status(await connection(session)))
|
||||
row = await connection(session)
|
||||
raw = await redis.get(f"{KEY_PREFIX}calendar:jin10")
|
||||
return success(public_status(row, json.loads(raw) if raw else None))
|
||||
|
||||
|
||||
@router.post("/qr", status_code=201)
|
||||
|
||||
Reference in new issue
Block a user