"""Read-only Pyramid dashboard with an allowlisted, stable browser contract.""" import math from datetime import date, datetime, timezone from typing import Literal from fastapi import APIRouter, Depends, HTTPException, Request from pydantic import BaseModel from .models import Account from .security import require_auth from .worldquant import WqError router = APIRouter(prefix="/api/v1/dashboard", tags=["dashboard"], dependencies=[Depends(require_auth)]) class PyramidRow(BaseModel): region: str delay: int category_id: str category_name: str value: float | int | None class PyramidSnapshot(BaseModel): items: list[PyramidRow] fetched_at: datetime start_date: date | None = None end_date: date | None = None def pyramid_rows(data, metric: Literal["multiplier", "alphaCount"]): """Normalize the observed BRAIN schema; absent metrics remain unknown. Return only category, region, delay and the requested metric. Invalid identities or nonnumeric metrics raise WqError rather than inventing counts or silently dropping rows. Unknown upstream fields never reach the browser. """ if not isinstance(data, dict) or not isinstance(data.get("pyramids"), list): raise WqError("Pyramid 数据格式无法识别", "invalid_response") rows = [] for item in data["pyramids"]: if not isinstance(item, dict): raise WqError("Pyramid 数据格式无法识别", "invalid_response") category = item.get("category") region, delay, value = item.get("region"), item.get("delay"), item.get(metric) if ( not isinstance(category, dict) or not all(isinstance(category.get(key), str) and category[key] for key in ("id", "name")) or not isinstance(region, str) or not region or type(delay) is not int or delay < 0 or (value is not None and ( type(value) not in (int, float) or not math.isfinite(value) or value < 0 or (metric == "alphaCount" and type(value) is not int) )) ): raise WqError("Pyramid 数据格式无法识别", "invalid_response") rows.append(PyramidRow( region=region, delay=delay, category_id=category["id"], category_name=category["name"], value=value, )) return rows async def read_pyramids(request, metric, start_date=None, end_date=None): """Read via the existing session; platform errors must not expire system login.""" runner = request.app.state.runner async with request.app.state.sessions() as db: account = await db.get(Account, 1) if runner.disconnecting or account.connection_status != "connected" or not account.wq_user_id: raise HTTPException(409, "请先在个人信息页连接并确认 WorldQuant 账户") try: if metric == "multiplier": data = await runner.client.get_pyramid_multipliers() else: # BRAIN accepts calendar dates here and rejects ISO datetime values. data = await runner.client.get_pyramid_alphas( start_date.isoformat() if start_date else None, end_date.isoformat() if end_date else None, ) return PyramidSnapshot( items=pyramid_rows(data, metric), fetched_at=datetime.now(timezone.utc), start_date=start_date, end_date=end_date, ) except WqError as exc: raise HTTPException( 409 if exc.code in ("disconnected", "verification_required", "authentication_failed") else 502, str(exc), ) from None @router.get("/pyramid-multipliers", response_model=PyramidSnapshot) async def multipliers(request: Request): return await read_pyramids(request, "multiplier") @router.get("/pyramid-alphas", response_model=PyramidSnapshot) async def alphas(request: Request, start_date: date | None = None, end_date: date | None = None): if start_date and end_date and start_date > end_date: raise HTTPException(422, "开始日期不能晚于结束日期") return await read_pyramids(request, "alphaCount", start_date, end_date)