Files

174 lines
7.5 KiB
Python

"""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)