From e57b1f7a2ea8a1bbd009af58e8d5f77dfd8e9e0e Mon Sep 17 00:00:00 2001 From: yuxuanhui Date: Wed, 9 Sep 2026 18:20:08 +0800 Subject: [PATCH] feat: add Pyramid home dashboard --- .../home-dashboard/issues/01-dashboard.md | 16 + .scratch/home-dashboard/spec.md | 21 + backend/app/ai/contracts.py | 1 + backend/app/dashboard.py | 101 +++++ backend/app/main.py | 2 + backend/app/worldquant.py | 27 ++ backend/tests/browser_server.py | 15 + backend/tests/test_dashboard.py | 126 ++++++ frontend/src/App.tsx | 8 + frontend/src/ai/types.ts | 1 + frontend/src/ai/workspace.ts | 5 +- frontend/src/components/AppSidebar.tsx | 62 +-- frontend/src/pages/HomePage.tsx | 376 ++++++++++++++++++ frontend/src/pages/home.css | 217 ++++++++++ frontend/tests/alpha-management.spec.ts | 2 +- frontend/tests/sidebar.spec.ts | 2 +- frontend/tests/workspace.spec.ts | 2 +- 17 files changed, 950 insertions(+), 34 deletions(-) create mode 100644 .scratch/home-dashboard/issues/01-dashboard.md create mode 100644 .scratch/home-dashboard/spec.md create mode 100644 backend/app/dashboard.py create mode 100644 backend/tests/test_dashboard.py create mode 100644 frontend/src/pages/HomePage.tsx create mode 100644 frontend/src/pages/home.css diff --git a/.scratch/home-dashboard/issues/01-dashboard.md b/.scratch/home-dashboard/issues/01-dashboard.md new file mode 100644 index 0000000..e1f3473 --- /dev/null +++ b/.scratch/home-dashboard/issues/01-dashboard.md @@ -0,0 +1,16 @@ +# 首页 Pyramid 看板 + +Type: task +Status: ready-for-agent + +按 ../spec.md 接入两项只读资源并实现独立首页,完成本地自动化与页面验证。 + +## Comments + +- 用户已授权新增首页模块,首期范围为两个 Pyramid 接口。 +- 实现完成:默认首页、导航/助手页面上下文、两项独立只读资源、摘要卡片、搜索、日期筛选、刷新/重试及连接状态。 +- 验证完成:后端全量 254 项通过;日期格式修正后相关 14 项再次通过;ruff 全量通过。 +- 前端构建通过,现有浏览器回归 22 项全部通过。构建仍有依赖 lottie-web 的 eval 提示。 +- Playwright CLI 在独立临时数据库和模拟平台中检查新首页:未连接、正确摘要、搜索无匹配、日期应用/无效范围/恢复默认、单项失败不影响另一项、重试、空数据、缺失值、迟到响应不覆盖新结果、断开后清空数据均通过。 +- 已检查 1440px 浅色/深色与 390px 窄屏截图,未发生横向页面溢出。截图位于忽略目录 output/playwright/home-dashboard-*.png。 +- 真实平台只读核实了两项响应结构与日期参数的接受格式;没有验证日期范围的服务端统计边界。未部署、未提交。 diff --git a/.scratch/home-dashboard/spec.md b/.scratch/home-dashboard/spec.md new file mode 100644 index 0000000..3eb1a34 --- /dev/null +++ b/.scratch/home-dashboard/spec.md @@ -0,0 +1,21 @@ +# 首页 Pyramid 看板 + +新增首页导航并作为无 hash 时的默认页面;保留已有页面深链接。 +首期仅展示 cnhk 已封装的 Pyramid multipliers 与 Pyramid alphas 数据。 +复用后端 WorldQuant 会话及认证重试,浏览器仅访问受系统登录保护的本地接口。 +两个资源独立加载、独立报错;分布支持可选起止日期,乘数不受日期筛选影响。 +不以缺失值推断零,不自行推断点亮规则;响应结构必须以参考代码或样例为依据。 +提供加载、无数据、未连接、失败、刷新状态,支持现有浅色/深色与窄屏布局。 +不新增数据库存储、模型工具、外部写入或部署。 + +验证:上游路径与日期转发、仅 404 回退、错误隔离、登录保护、数据解析; +前端构建及浏览器导航/筛选/刷新/异常与响应式检查。真实接口可用性单独说明。 + +## 接口核实(2026-09-09) + +两个真实接口均只读请求成功。响应的 `pyramids` 列表包含 `category.id/name`、 +`region`、`delay`,两类指标分别为 `multiplier` 和 `alphaCount`。 +分布接口实测接受 `startDate/endDate=YYYY-MM-DD`,包含时间的 ISO datetime 返回 400。 +页面按平台日期原样查询,不擅自转换为浏览器时区或推断统计边界。 +浏览器合同仅保留上述字段并统一指标为 `value`,缺失指标保留 null,错误不暴露上游正文。 +统计归属次数跨类别累计,不作为去重后的 Alpha 总数。 diff --git a/backend/app/ai/contracts.py b/backend/app/ai/contracts.py index 5955456..5db55ee 100644 --- a/backend/app/ai/contracts.py +++ b/backend/app/ai/contracts.py @@ -37,6 +37,7 @@ class ModelSettingsInput(Contract): class PageContext(Contract): page: Literal[ + "home", "alphas", "account", "datasets", diff --git a/backend/app/dashboard.py b/backend/app/dashboard.py new file mode 100644 index 0000000..f3fa83e --- /dev/null +++ b/backend/app/dashboard.py @@ -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) diff --git a/backend/app/main.py b/backend/app/main.py index 70226a5..e93ab8f 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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) diff --git a/backend/app/worldquant.py b/backend/app/worldquant.py index 0aa1bc5..9a997bc 100644 --- a/backend/app/worldquant.py +++ b/backend/app/worldquant.py @@ -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 diff --git a/backend/tests/browser_server.py b/backend/tests/browser_server.py index adca331..d9dd50e 100644 --- a/backend/tests/browser_server.py +++ b/backend/tests/browser_server.py @@ -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 diff --git a/backend/tests/test_dashboard.py b/backend/tests/test_dashboard.py new file mode 100644 index 0000000..b3efd06 --- /dev/null +++ b/backend/tests/test_dashboard.py @@ -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 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1653a39..7bdf912 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -25,6 +25,7 @@ import { MCPKeysPage } from "./pages/MCPKeysPage"; import { AccountPage } from "./pages/AccountPage"; import { DatasetPage } from "./pages/DatasetPage"; import { AlphaPage } from "./pages/AlphaPage"; +import { HomePage } from "./pages/HomePage"; import { JobPanel } from "./components/JobPanel"; import { BacktestPage } from "./backtests/BacktestPage"; import { WorkspacePreferences } from "./components/WorkspacePreferences"; @@ -319,6 +320,12 @@ export default function App() {
+ {page === "home" && ( + changePage("account")} + /> + )} {page === "mcp-keys" && } {pollError && ( setChatOpen(false)} context={ { + home: { page: "home" as const }, quantflow: researchContext.page === "quantflow" ? researchContext diff --git a/frontend/src/ai/types.ts b/frontend/src/ai/types.ts index 95bc7a3..213bf65 100644 --- a/frontend/src/ai/types.ts +++ b/frontend/src/ai/types.ts @@ -12,6 +12,7 @@ export type ModelSettings = { }; export type PageContext = { page: + | "home" | "alphas" | "account" | "datasets" diff --git a/frontend/src/ai/workspace.ts b/frontend/src/ai/workspace.ts index 4b902f0..d28a2fc 100644 --- a/frontend/src/ai/workspace.ts +++ b/frontend/src/ai/workspace.ts @@ -5,6 +5,7 @@ const contextLabels: Record< PageContext["page"], (context: PageContext) => string > = { + home: () => "上下文:首页看板", alphas: (context) => `上下文:${context.alpha_id ? `Alpha ${context.alpha_id}` : "Alpha 列表"}${context.selected_ids?.length ? ` · 已选 ${context.selected_ids.length} 条` : ""}`, operators: () => "上下文:算子库", @@ -30,9 +31,7 @@ export function pageFromHash(hash: string): WorkspacePage { const value = hash.replace(/^#/, "").split("?")[0]; const page = value.startsWith("quantflow/") ? "quantflow" : value; if (page === "mcp-keys") return page; - return Object.hasOwn(contextLabels, page) - ? (page as WorkspacePage) - : "alphas"; + return Object.hasOwn(contextLabels, page) ? (page as WorkspacePage) : "home"; } type Destination = { diff --git a/frontend/src/components/AppSidebar.tsx b/frontend/src/components/AppSidebar.tsx index 831c85c..478c89f 100644 --- a/frontend/src/components/AppSidebar.tsx +++ b/frontend/src/components/AppSidebar.tsx @@ -16,6 +16,7 @@ import type { Account } from "../types"; import type { WorkspacePage } from "../ai/workspace"; const navigation = [ + { id: "home", label: "首页看板", icon: IconGridView, group: "概览" }, { id: "datasets", label: "数据目录", icon: IconList, group: "数据与素材" }, { id: "operators", label: "算子库", icon: IconList, group: "数据与素材" }, { @@ -173,35 +174,40 @@ export function AppSidebar({ )}
{!collapsed && ( diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx new file mode 100644 index 0000000..3430e25 --- /dev/null +++ b/frontend/src/pages/HomePage.tsx @@ -0,0 +1,376 @@ +import { useEffect, useState } from "react"; +import { Banner, Button, Input, Spin } from "@douyinfe/semi-ui-19"; +import { api, formatTime } from "../api"; +import type { Account } from "../types"; +import "./home.css"; + +type PyramidRow = { + region: string; + delay: number; + category_id: string; + category_name: string; + value: number | null; +}; +type Snapshot = { + items: PyramidRow[]; + fetched_at: string; + start_date: string | null; + end_date: string | null; +}; + +function useSnapshot( + path: string, + enabled: boolean, + version: number, + identity: string, +) { + const [data, setData] = useState(null); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + const [retry, setRetry] = useState(0); + useEffect(() => { + let ignore = false; + const controller = new AbortController(); + setData(null); + setError(""); + setLoading(enabled); + if (enabled) { + api(path, { signal: controller.signal }) + .then((next) => { + if (!ignore) setData(next); + }) + .catch((e: Error) => { + if (!ignore) setError(e.message); + }) + .finally(() => { + if (!ignore) setLoading(false); + }); + } + return () => { + ignore = true; + controller.abort(); + }; + }, [path, enabled, version, identity, retry]); + return { + data: enabled ? data : null, + error: enabled ? error : "", + loading: enabled && loading, + refresh: () => setRetry((value) => value + 1), + }; +} +type ResourceState = ReturnType; + +export function HomePage({ + account, + onAccount, +}: { + account: Account | null; + onAccount: () => void; +}) { + const [version, setVersion] = useState(0); + const [start, setStart] = useState(""); + const [end, setEnd] = useState(""); + const [period, setPeriod] = useState({ start: "", end: "" }); + const connected = + account?.connection_status === "connected" && !!account?.wq_user_id; + const identity = account?.wq_user_id ?? ""; + const params = new URLSearchParams(); + if (period.start) params.set("start_date", period.start); + if (period.end) params.set("end_date", period.end); + const multipliers = useSnapshot( + "/dashboard/pyramid-multipliers", + connected, + version, + identity, + ); + const alphas = useSnapshot( + `/dashboard/pyramid-alphas?${params}`, + connected, + version, + identity, + ); + const multiplierValues = + multipliers.data?.items.flatMap((item) => + item.value === null ? [] : [item.value], + ) ?? []; + const counts = alphas.data?.items; + const completeCounts = counts && counts.every((item) => item.value !== null); + const coverage = completeCounts + ? counts.filter((item) => item.value! > 0).length + : null; + const total = completeCounts + ? counts.reduce((sum, item) => sum + item.value!, 0) + : null; + const invalidDates = !!(start && end && start > end); + const dirtyDates = start !== period.start || end !== period.end; + const metrics = [ + ["Pyramid 类别", multipliers.data?.items.length, "当前乘数列表中的类别"], + [ + "最高乘数", + multiplierValues.length && + multiplierValues.length === multipliers.data?.items.length + ? `${Math.max(...multiplierValues).toLocaleString("zh-CN")}×` + : null, + "平台当前鼓励程度", + ], + ["已覆盖类别", coverage, "所选范围内 Alpha 数量大于 0"], + ["Alpha 归属次数", total, "跨类别累计,同一 Alpha 可能重复计入"], + ] as const; + + return ( +
+
+
+

首页看板

+

了解 Pyramid 的平台乘数与 Alpha 分布。

+
+ +
+ {!account ? ( +
+ + 正在读取账户状态… +
+ ) : ( + !connected && ( +
+
+ 连接 WorldQuant 后查看看板 +

请到个人信息页连接账户;如需人工验证,请先完成验证。

+
+ +
+ ) + )} +
+ {metrics.map(([label, value, hint]) => ( +
+ {label} + + {typeof value === "number" + ? value.toLocaleString("zh-CN") + : (value ?? "—")} + + {hint} +
+ ))} +
+
+ + +
{ + event.preventDefault(); + if (invalidDates) return; + if (dirtyDates) setPeriod({ start, end }); + else alphas.refresh(); + }} + > +
+ + +
+
+ + + 按平台日期查询;留空使用平台默认范围。 +
+ {invalidDates &&

开始日期不能晚于结束日期。

} + {dirtyDates && !invalidDates && ( +

日期已修改,点击“应用日期”更新分布。

+ )} +
+
+
+
+ ); +} + +function PyramidPanel({ + title, + description, + metric, + state, + connected, + timezone, + children, +}: { + title: string; + description: string; + metric: "乘数" | "Alpha 数量"; + state: ResourceState; + connected: boolean; + timezone?: string; + children?: React.ReactNode; +}) { + const [search, setSearch] = useState(""); + const query = search.trim().toLowerCase(); + const rows = (state.data?.items ?? []) + .filter((item) => + `${item.region} D${item.delay} ${item.category_id} ${item.category_name}` + .toLowerCase() + .includes(query), + ) + .sort( + (a, b) => + (b.value ?? -1) - (a.value ?? -1) || + a.region.localeCompare(b.region) || + a.delay - b.delay || + a.category_id.localeCompare(b.category_id), + ); + const maximum = Math.max(1, ...rows.map((row) => row.value ?? 0)); + return ( +
+
+

{title}

+

{description}

+
+ {children} + {state.error ? ( +
+ + +
+ ) : state.loading ? ( +
+ + 正在加载{title}… +
+ ) : !connected ? ( +
连接账户后显示{title}。
+ ) : state.data ? ( + <> +
+ + + {rows.length} / {state.data.items.length} 类 + +
+
+ + + + + + + + + + + {rows.map((row, index) => ( + + + + + + + ))} + +
地区延迟数据类别{metric}
{row.region}D{row.delay} + {row.category_name} + {row.category_id} + +
+ {row.value === null + ? "—" + : `${row.value.toLocaleString("zh-CN")}${metric === "乘数" ? "×" : ""}`} +
+ +
+ {!rows.length && ( +
+ {state.data.items.length + ? "没有匹配的 Pyramid 类别。" + : "平台暂未返回 Pyramid 数据。"} +
+ )} +
+ + + ) : null} +
+ ); +} diff --git a/frontend/src/pages/home.css b/frontend/src/pages/home.css new file mode 100644 index 0000000..40bd0f2 --- /dev/null +++ b/frontend/src/pages/home.css @@ -0,0 +1,217 @@ +.home-page { + flex: 1; + min-height: 0; + overflow: auto; + padding: 8px 4px 24px; +} +.home-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 24px; +} +.home-heading p, +.home-panel header p, +.home-connect p { + color: var(--muted); + margin-top: 4px; +} +.home-metrics { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 16px; + margin-bottom: 24px; +} +.home-metric, +.home-panel { + border: 1px solid var(--line); + border-radius: 8px; + background: var(--surface); + min-width: 0; +} +.home-metric { + display: flex; + flex-direction: column; + gap: 8px; + padding: 20px; +} +.home-metric > span { + color: var(--muted); +} +.home-metric > strong { + font-size: 30px; + font-weight: 600; + font-variant-numeric: tabular-nums; + line-height: 1.3; +} +.home-metric small, +.home-period small, +.home-panel footer { + color: var(--muted); + font-size: 12px; + line-height: 1.6; +} +.home-panels { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 440px), 1fr)); + gap: 20px; + align-items: start; +} +.home-panel { + overflow: hidden; +} +.home-panel header { + padding: 20px; + border-bottom: 1px solid var(--line); +} +.home-panel header p { + font-size: 12px; +} +.home-connect { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 16px; + background: var(--semi-color-primary-light-default); + border-radius: 8px; + padding: 20px; + margin-bottom: 20px; +} +.home-empty { + display: flex; + justify-content: center; + align-items: center; + gap: 12px; + padding: 48px 20px; + color: var(--muted); + text-align: center; +} +.home-period { + padding: 16px 20px; + background: var(--canvas); + border-bottom: 1px solid var(--line); +} +.home-date-fields { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} +.home-date-fields input { + width: 100%; + min-width: 0; + height: 32px; + padding: 4px 8px; + color: var(--ink); + background: var(--surface); + border: 1px solid var(--line); + border-radius: 4px; +} +body[theme-mode="dark"] .home-date-fields input { + color-scheme: dark; +} +.home-table-tools { + display: flex; + align-items: center; + gap: 12px; + padding: 16px 20px; +} +.home-table-tools > span { + flex-shrink: 0; + color: var(--muted); + font-size: 12px; +} +.home-table-wrap { + max-height: 420px; + overflow: auto; +} +.home-panel table { + width: 100%; + border-collapse: separate; + border-spacing: 0; + text-align: left; + font-size: 13px; +} +.home-panel th { + position: sticky; + top: 0; + z-index: 1; + background: var(--canvas); + font-weight: 500; + color: var(--muted); + white-space: nowrap; +} +.home-panel td, +.home-panel th { + padding: 12px 16px; + border-bottom: 1px solid var(--line); +} +.home-panel td:first-child, +.home-panel td:nth-child(2) { + white-space: nowrap; +} +.home-panel td strong { + font-weight: 400; + overflow-wrap: anywhere; +} +.home-panel td small { + display: block; + margin-top: 3px; + color: var(--muted); + overflow-wrap: anywhere; +} +.home-panel td:last-child { + min-width: 96px; + text-align: right; +} +.home-value { + font-variant-numeric: tabular-nums; + font-weight: 500; +} +.home-bar { + height: 3px; + background: var(--canvas); + border-radius: 2px; + margin-top: 6px; +} +.home-bar i { + display: block; + height: 100%; + border-radius: inherit; + background: var(--accent); + opacity: 0.7; +} +.home-panel footer { + display: flex; + justify-content: space-between; + flex-wrap: wrap; + gap: 8px; + padding: 12px 20px; + border-top: 1px solid var(--line); +} +.home-panel-error { + display: grid; + justify-items: start; + gap: 16px; + padding: 20px; +} +@media (max-width: 600px) { + .home-metrics { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + } + .home-metric { + padding: 14px; + } + .home-metric > strong { + font-size: 26px; + } + .home-heading { + align-items: flex-start; + } + .home-panel td, + .home-panel th { + padding: 10px; + } +} diff --git a/frontend/tests/alpha-management.spec.ts b/frontend/tests/alpha-management.spec.ts index 0e299c6..ac85b48 100644 --- a/frontend/tests/alpha-management.spec.ts +++ b/frontend/tests/alpha-management.spec.ts @@ -5,7 +5,7 @@ test("submission tabs, day selection, local correlation and reload", async ({ }) => { const errors: string[] = []; page.on("pageerror", (error) => errors.push(error.message)); - await page.goto("/"); + await page.goto("/#alphas"); await page.getByLabel("密码", { exact: true }).fill("browser-test-password"); await page.getByRole("button", { name: "进入工作空间" }).click(); await expect( diff --git a/frontend/tests/sidebar.spec.ts b/frontend/tests/sidebar.spec.ts index b90f0ad..7b553de 100644 --- a/frontend/tests/sidebar.spec.ts +++ b/frontend/tests/sidebar.spec.ts @@ -19,7 +19,7 @@ test("sidebar geometry, recent navigation, user menu and compact tools", async ( await page.getByRole("button", { name: "配置", exact: true }).click(); await expect(page.locator("#model-settings")).toBeInViewport(); await expect( - nav.getByRole("button", { name: "Alpha 管理", exact: true }), + nav.getByRole("button", { name: "首页看板", exact: true }), ).toHaveAttribute("aria-current", "page"); await page.locator(".settings-drawer .semi-sidesheet-close").click(); await page.getByRole("button", { name: "用户菜单", exact: true }).click(); diff --git a/frontend/tests/workspace.spec.ts b/frontend/tests/workspace.spec.ts index 920793d..668cf35 100644 --- a/frontend/tests/workspace.spec.ts +++ b/frontend/tests/workspace.spec.ts @@ -175,7 +175,7 @@ test("account → scoped sync → research → resync → PnL → filtered expor test("batch tags, column visibility, server pagination and saved preferences", async ({ page, }) => { - await page.goto("/"); + await page.goto("/#alphas"); await page.getByLabel("密码", { exact: true }).fill("browser-test-password"); await page.getByRole("button", { name: "进入工作空间" }).click(); await expect(