"""Read-only platform dashboard with an allowlisted, stable browser contract.""" import math from datetime import date, datetime, timedelta, timezone from typing import Literal from zoneinfo import ZoneInfo 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)]) EASTERN = ZoneInfo("America/New_York") class ActivityDay(BaseModel): date: date count: int | None class ActivitySnapshot(BaseModel): items: list[ActivityDay] today: date timezone: str = "America/New_York" fetched_at: datetime def activity_snapshot(data, days: int, at: datetime) -> ActivitySnapshot: """Return platform calendar counts, keeping absent dates unknown. BRAIN activity dates already denote Eastern calendar days. Never interpret them as UTC instants or replace missing records with zero. Malformed or duplicate rows raise WqError rather than displaying misleading totals. """ today = at.astimezone(EASTERN).date() start = today - timedelta(days=days - 1) recordset = data.get("records") if isinstance(data, dict) else None schema = recordset.get("schema") if isinstance(recordset, dict) else None properties = schema.get("properties") if isinstance(schema, dict) else None records = recordset.get("records") if isinstance(recordset, dict) else None if not isinstance(properties, list) or not isinstance(records, list): raise WqError("平台活动数据格式无法识别", "invalid_response") names = [prop.get("name") if isinstance(prop, dict) else prop for prop in properties] if names.count("date") != 1 or names.count("value") != 1: raise WqError("平台活动数据格式无法识别", "invalid_response") date_index, value_index = names.index("date"), names.index("value") counts = {} for row in records: if not isinstance(row, list) or len(row) <= max(date_index, value_index): raise WqError("平台活动数据格式无法识别", "invalid_response") raw_date, value = row[date_index], row[value_index] try: day = date.fromisoformat(raw_date) if day.isoformat() != raw_date: raise ValueError except (TypeError, ValueError): raise WqError("平台活动日期格式无法识别", "invalid_response") from None if day in counts or (value is not None and (type(value) is not int or value < 0)): raise WqError("平台活动次数格式无法识别", "invalid_response") counts[day] = value return ActivitySnapshot( items=[ActivityDay(date=day, count=counts.get(day)) for offset in range(days) for day in [start + timedelta(days=offset)]], today=today, fetched_at=at, ) @router.get("/activity/{metric}", response_model=ActivitySnapshot) async def activity(request: Request, metric: Literal["submissions", "simulations"]): """Read each activity independently using the bound platform session.""" 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: data = await runner.client.get(f"/users/self/activities/{metric}") return activity_snapshot(data, 182 if metric == "submissions" else 14, datetime.now(timezone.utc)) except WqError as exc: raise HTTPException( 409 if exc.code in ("disconnected", "verification_required", "authentication_failed") else 502, str(exc), ) from None 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)