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
+15
View File
@@ -142,6 +142,21 @@ def create_test_app():
)
if path == "/users/self/alphas/summary":
return httpx.Response(200, json={"unsubmitted": 413, "active": 207, "decommissioned": 0})
if path in ("/users/self/activities/pyramid-multipliers", "/users/self/activities/pyramid-alphas"):
metric = "multiplier" if path.endswith("multipliers") else "alphaCount"
categories = [
("analyst", "Analyst", "USA", 1, 1.8, 12),
("fundamental", "Fundamental", "EUR", 1, 1.6, 7),
("model", "Model", "GLB", 1, 1.5, 5),
("sentiment", "Sentiment", "CHN", 0, 1.4, 3),
("news", "News", "ASI", 1, 1.2, 0),
("pv", "Price Volume", "USA", 0, 1.0, 0),
]
return httpx.Response(200, json={"pyramids": [
{"category": {"id": cid, "name": name}, "region": region, "delay": delay,
metric: multiplier if metric == "multiplier" else min(count, 2) if request.url.query else count}
for cid, name, region, delay, multiplier, count in categories
]})
if path in ("/users/self/activities/simulations", "/users/self/activities/submissions"):
from zoneinfo import ZoneInfo
+126
View File
@@ -0,0 +1,126 @@
from datetime import datetime
import httpx
import pytest
from app.dashboard import pyramid_rows
from app.models import Account
from app.worldquant import WqClient, WqError
def pyramid(**extra):
return {"category": {"id": "analyst", "name": "Analyst"}, "region": "USA", "delay": 1, **extra}
async def test_pyramid_read_paths_and_404_fallback(settings):
calls = []
def upstream(request):
calls.append(request)
assert request.method == "GET"
if request.url.path.endswith("pyramid-multipliers"):
return httpx.Response(200, json={"pyramids": [pyramid(multiplier=1.3)]})
if request.url.path != "/activities/pyramid-alphas":
return httpx.Response(404)
return httpx.Response(200, json={"pyramids": [pyramid(alphaCount=0)]})
client = WqClient(settings, transport=httpx.MockTransport(upstream))
client.credentials, client.authenticated = ("test@example.com", "test-only"), True
try:
assert (await client.get_pyramid_multipliers())["pyramids"][0]["multiplier"] == 1.3
data = await client.get_pyramid_alphas("2026-07-01", "2026-07-31")
assert data["pyramids"][0]["alphaCount"] == 0
assert [r.url.path for r in calls] == [
"/users/self/activities/pyramid-multipliers", "/users/self/activities/pyramid-alphas",
"/users/self/pyramid/alphas", "/activities/pyramid-alphas",
]
for request in calls[1:]:
assert dict(request.url.params) == {
"startDate": "2026-07-01", "endDate": "2026-07-31",
}
finally:
await client.close()
@pytest.mark.parametrize("status,code,expected_calls", [(403, "access_denied", 1), (404, "not_found", 3)])
async def test_pyramid_errors_preserve_access_boundary(settings, status, code, expected_calls):
calls = []
def upstream(request):
calls.append(request)
return httpx.Response(status, json={"detail": "private-upstream-body"})
client = WqClient(settings, transport=httpx.MockTransport(upstream))
client.credentials, client.authenticated = ("test@example.com", "test-only"), True
try:
with pytest.raises(WqError) as error:
await client.get_pyramid_alphas()
assert error.value.code == code
assert "private-upstream-body" not in str(error.value)
assert len(calls) == expected_calls
assert all(not request.url.query for request in calls)
finally:
await client.close()
def test_pyramid_normalization_preserves_zero_unknown_and_allowlist():
rows = pyramid_rows({"pyramids": [pyramid(alphaCount=0, token="private"), pyramid()]}, "alphaCount")
assert rows[0].value == 0 and rows[1].value is None
assert "private" not in str(rows)
assert pyramid_rows({"pyramids": []}, "multiplier") == []
@pytest.mark.parametrize("data", [
{}, {"pyramids": {}}, {"pyramids": [None]},
{"pyramids": [pyramid(alphaCount="3")]},
{"pyramids": [pyramid(alphaCount=True)]},
{"pyramids": [pyramid(alphaCount=-1)]},
{"pyramids": [pyramid(alphaCount=2.5)]},
{"pyramids": [pyramid(alphaCount=float("nan"))]},
{"pyramids": [pyramid(category=None)]},
])
def test_pyramid_unknown_shapes_are_not_empty_success(data):
with pytest.raises(WqError, match="格式无法识别"):
pyramid_rows(data, "alphaCount")
async def test_dashboard_auth_connection_dates_and_independent_errors(app, client, logged_in, monkeypatch):
calls = []
async def read_alphas(start_date=None, end_date=None):
calls.append((start_date, end_date))
return {"pyramids": [pyramid(alphaCount=7)], "token": "private"}
async def read_multipliers():
raise WqError("无权访问该平台资源", "access_denied")
monkeypatch.setattr(app.state.runner.client, "get_pyramid_alphas", read_alphas)
monkeypatch.setattr(app.state.runner.client, "get_pyramid_multipliers", read_multipliers)
prefix = "/api/v1/dashboard"
assert (await logged_in.get(f"{prefix}/pyramid-alphas")).status_code == 409
assert calls == []
async with app.state.sessions.begin() as db:
account = await db.get(Account, 1)
account.connection_status, account.wq_user_id = "connected", "TEST_USER"
for query in ("start_date=bad", "start_date=2026-08-02&end_date=2026-08-01"):
assert (await logged_in.get(f"{prefix}/pyramid-alphas?{query}")).status_code == 422
assert calls == []
response = await logged_in.get(f"{prefix}/pyramid-alphas?start_date=2026-07-01&end_date=2026-07-31")
assert response.status_code == 200
assert calls == [("2026-07-01", "2026-07-31")]
result = response.json()
assert result["items"][0] == {
"region": "USA", "delay": 1, "category_id": "analyst", "category_name": "Analyst", "value": 7,
}
assert result["start_date"] == "2026-07-01" and result["end_date"] == "2026-07-31"
assert datetime.fromisoformat(result["fetched_at"]).tzinfo is not None
assert "private" not in response.text
assert (await logged_in.get(f"{prefix}/pyramid-multipliers")).status_code == 502
assert (await logged_in.get("/api/v1/auth/me")).status_code == 200
await logged_in.get(f"{prefix}/pyramid-alphas")
assert calls[-1] == (None, None)
await logged_in.get(f"{prefix}/pyramid-alphas?end_date=2026-07-31")
assert calls[-1] == (None, "2026-07-31")
client.cookies.clear()
for endpoint in ("pyramid-alphas", "pyramid-multipliers"):
assert (await client.get(f"{prefix}/{endpoint}")).status_code == 401