feat: add Pyramid home dashboard

This commit is contained in:
yuxuanhui
2026-09-09 18:20:08 +08:00
parent be76b89e9d
commit e57b1f7a2e
17 changed files with 950 additions and 34 deletions
+1
View File
@@ -37,6 +37,7 @@ class ModelSettingsInput(Contract):
class PageContext(Contract):
page: Literal[
"home",
"alphas",
"account",
"datasets",
+101
View File
@@ -0,0 +1,101 @@
"""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)
+2
View File
@@ -22,6 +22,7 @@ from .business import Business, notify_job
from .catalog.research_routes import router as research_catalog_router
from .catalog.routes import router as catalog_router
from .config import Settings
from .dashboard import router as dashboard_router
from .db import create_database
from .jobs import AUTH_KINDS, Runner, create_job
from .mcp_api.token_routes import router as mcp_token_router
@@ -471,6 +472,7 @@ def create_app(settings=None, wq_client=None, ai_model_factory=None):
if mcp_runtime:
app.mount("/api/v1/mcp", mcp_runtime.app)
app.include_router(mcp_token_router)
app.include_router(dashboard_router)
app.include_router(backtest_router)
app.include_router(api)
app.include_router(catalog_router)
+27
View File
@@ -312,6 +312,33 @@ class WqClient:
async def profile(self):
return await self.get("/users/self")
async def get_pyramid_multipliers(self):
"""Return current Pyramid multipliers using the shared authenticated session."""
return await self.get("/users/self/activities/pyramid-multipliers")
async def get_pyramid_alphas(self, start_date=None, end_date=None):
"""Return distribution for optional ISO dates; only missing routes trigger fallback.
Paths mirror the cnhk adapter. Access denial, authentication and network
failures must retain their meaning instead of trying unrelated routes.
"""
params = {}
if start_date is not None:
params["startDate"] = start_date
if end_date is not None:
params["endDate"] = end_date
for path in (
"/users/self/activities/pyramid-alphas",
"/users/self/pyramid/alphas",
"/activities/pyramid-alphas",
):
try:
return await self.get(path, params=params)
except WqError as exc:
if exc.code != "not_found":
raise
raise WqError("当前账户的 Pyramid 分布接口不可用", "not_found")
async def account_usage(self):
"""Read independent account resources; unavailable sections do not hide the profile."""
from .account_data import usage_snapshot