Compare commits

...

2 Commits

Author SHA1 Message Date
yuxuanhui ba0ed9d03f feat: Implement Alpha list and metrics enhancements
Deploy production / deploy (push) Successful in 1m12s
- Added new metrics fields: sub_universe_sharpe, robust_universe_sharpe, two_year_sharpe, prod_correlation, pnl, check_type, and neutralization to the Alpha model.
- Updated snapshot_columns function to derive new metrics and check types from platform snapshots.
- Enhanced API to include failed checks and check types in responses.
- Created migration script to backfill existing Alpha records with new metrics and check types.
- Updated frontend components to display new metrics and allow editing of custom tags.
- Improved filtering and sorting capabilities for new metrics in the Alpha list.
- Added tests for new functionality including checks classification and metrics filtering.
2026-09-09 19:02:37 +08:00
yuxuanhui e57b1f7a2e feat: add Pyramid home dashboard 2026-09-09 18:20:08 +08:00
28 changed files with 1628 additions and 56 deletions
@@ -0,0 +1,17 @@
# 补齐 Alpha 列表和标签入口
Type: task
Status: ready-for-agent
Implementation: completed
范围与验收见 ../spec.md。
## Comments
- 已确认失败分档使用 FAIL=1 / FAIL≥2,正在实现。
- 已完成九个新增表头、指标范围筛选/排序/导出、检查结果分档筛选、单条自定义标签编辑与默认标签列。保留已有列偏好。
- 验证:后端全量 269 项通过;Ruff、Prettier、前端构建及 diff 检查通过。
- 浏览器全量首跑 20 项通过、2 项失败:一项 SQLite 锁冲突,单独复跑通过;另一项涉及标签改名和列设置过长,更新断言并修复限高滚动后,workspace 两项通过。
- Playwright CLI 实测 FAIL≥2 包含失败 2/3 项、PPAC 标签保存与组合筛选、刷新保留、390px 无整页溢出、26 项列设置可滚动并关闭。
- SQLite 和独立 PostgreSQL 17 数据库均验证 503 条历史快照回填、迁移回退/重新升级、模型一致及原研究标签/备注/版本保留。已删除本轮临时数据库。
- 已包含 0011 迁移;没有部署、提交代码或操作真实平台。
+12
View File
@@ -0,0 +1,12 @@
# Alpha 列表指标、检查筛选与自定义标签
用户已授权本地实现。失败分档已确认:FAIL=1、FAIL≥2。
- 补充检查结果、失败项、子股票池/稳健股票池/近两年 Sharpe、中性化、平台生产相关性、IS PnL、同步时间。
- 默认显示检查结果、失败项和自定义标签;保留已有列偏好,其余字段可选。
- 检查结果按平台快照计算,与平台状态、研究状态及本地自相关独立。没有完成的检查不得视为通过。
- 指标支持范围筛选、排序及导出;检查筛选在数据库分页前执行。
- 复用本地标签,提供列表单条编辑、批量编辑和筛选,支持 PPAC 等任意标签,平台同步不覆盖。
- 历史快照通过增量迁移回填,不请求平台、不修改研究记录。
验证:API 失败 0/1/2/3 项及未知状态、组合筛选/分页/导出、零值与缺值、标签保存与同步保留;隔离迁移;前端构建及浏览器交互。
@@ -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。
- 真实平台只读核实了两项响应结构与日期参数的接受格式;没有验证日期范围的服务端统计边界。未部署、未提交。
+21
View File
@@ -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 总数。
+1
View File
@@ -37,6 +37,7 @@ class ModelSettingsInput(Contract):
class PageContext(Contract): class PageContext(Contract):
page: Literal[ page: Literal[
"home",
"alphas", "alphas",
"account", "account",
"datasets", "datasets",
+61 -2
View File
@@ -9,6 +9,55 @@ from sqlalchemy import or_, select, update
from .models import Alpha, Research, ResearchTag, SelfCorrelation, now from .models import Alpha, Research, ResearchTag, SelfCorrelation, now
from .research.provenance import source_alpha_ids from .research.provenance import source_alpha_ids
METRIC_FIELDS = (
"sharpe", "fitness", "returns", "turnover", "margin", "drawdown",
"sub_universe_sharpe", "robust_universe_sharpe", "two_year_sharpe", "prod_correlation", "pnl",
)
def failed_checks(checks):
"""Return failed platform check names; local correlation never changes this list."""
return [
check.get("name") if isinstance(check.get("name"), str) else "未命名检查"
for check in checks if isinstance(check, dict) and check.get("result") == "FAIL"
] if isinstance(checks, list) else []
def snapshot_columns(settings, metrics, checks):
"""Derive list fields from a platform snapshot, preserving missing metrics as null.
Only explicit FAIL results count. Empty, malformed and unfinished checks are
pending; all known checks passing without PROD_CORRELATION is only a pre-check.
No submission eligibility or activity eligibility is inferred here.
"""
settings = settings if isinstance(settings, dict) else {}
metrics = metrics if isinstance(metrics, dict) else {}
checks = checks if isinstance(checks, list) else []
valid = [check for check in checks if isinstance(check, dict)]
failures = len(failed_checks(checks))
by_name = {check["name"]: check for check in valid if isinstance(check.get("name"), str)}
if failures:
check_type = "FAIL_1" if failures == 1 else "FAIL_2"
elif not checks or len(valid) != len(checks) or any(check.get("result") != "PASS" for check in valid):
check_type = "PENDING"
else:
check_type = "PASS" if "PROD_CORRELATION" in by_name else "PRE_CHECK"
neutralization = settings.get("neutralization")
return {
"check_type": check_type,
"neutralization": neutralization if isinstance(neutralization, str) else None,
"pnl": number(metrics.get("pnl")),
**{
field: number(by_name.get(name, {}).get("value"))
for field, name in (
("sub_universe_sharpe", "LOW_SUB_UNIVERSE_SHARPE"),
("robust_universe_sharpe", "LOW_ROBUST_UNIVERSE_SHARPE"),
("two_year_sharpe", "LOW_2Y_SHARPE"),
("prod_correlation", "PROD_CORRELATION"),
)
},
}
def submission_condition(submission): def submission_condition(submission):
"""Match the platform list contract; a missing status is never assumed submitted.""" """Match the platform list contract; a missing status is never assumed submitted."""
@@ -106,6 +155,8 @@ async def upsert_alpha(db, raw: dict):
item.settings, item.is_metrics = sanitize(settings), sanitize(metrics) item.settings, item.is_metrics = sanitize(settings), sanitize(metrics)
item.os_metrics = sanitize(raw.get("os")) if isinstance(raw.get("os"), dict) else {} item.os_metrics = sanitize(raw.get("os")) if isinstance(raw.get("os"), dict) else {}
item.checks = sanitize(metrics.get("checks") or raw.get("checks") or []) item.checks = sanitize(metrics.get("checks") or raw.get("checks") or [])
for key, value in snapshot_columns(item.settings, item.is_metrics, item.checks).items():
setattr(item, key, value)
for key in ("sharpe", "fitness", "returns", "turnover", "margin", "drawdown"): for key in ("sharpe", "fitness", "returns", "turnover", "margin", "drawdown"):
setattr(item, key, number(metrics.get(key))) setattr(item, key, number(metrics.get(key)))
item.date_created, item.date_submitted = date(raw.get("dateCreated")), date(raw.get("dateSubmitted")) item.date_created, item.date_submitted = date(raw.get("dateCreated")), date(raw.get("dateSubmitted"))
@@ -134,7 +185,7 @@ def list_statement(filters):
) )
) )
) )
for name in ("region", "universe", "alpha_type", "language", "status", "stage", "hidden"): for name in ("region", "universe", "alpha_type", "language", "status", "stage", "hidden", "check_type", "neutralization"):
value = getattr(filters, name) value = getattr(filters, name)
if value is not None: if value is not None:
query = query.where(getattr(Alpha, name) == value) query = query.where(getattr(Alpha, name) == value)
@@ -148,7 +199,7 @@ def list_statement(filters):
query = query.where(Alpha.date_created >= filters.created_from) query = query.where(Alpha.date_created >= filters.created_from)
if filters.created_to: if filters.created_to:
query = query.where(Alpha.date_created <= filters.created_to) query = query.where(Alpha.date_created <= filters.created_to)
for name in ("sharpe", "fitness", "returns", "turnover", "margin", "drawdown"): for name in METRIC_FIELDS:
for suffix, compare in (("min", "ge"), ("max", "le")): for suffix, compare in (("min", "ge"), ("max", "le")):
value = getattr(filters, f"{name}_{suffix}") value = getattr(filters, f"{name}_{suffix}")
if value is not None: if value is not None:
@@ -183,8 +234,16 @@ def summary(item: Alpha, research: Research):
"date_created", "date_created",
"date_submitted", "date_submitted",
"synced_at", "synced_at",
"check_type",
"neutralization",
"sub_universe_sharpe",
"robust_universe_sharpe",
"two_year_sharpe",
"prod_correlation",
"pnl",
) )
result = {k: getattr(item, k) for k in keys} result = {k: getattr(item, k) for k in keys}
result["failed_checks"] = failed_checks(item.checks)
result["expression_preview"] = (item.expression or item.selection or "")[:240] result["expression_preview"] = (item.expression or item.selection or "")[:240]
result["research"] = { result["research"] = {
k: getattr(research, k) for k in ("note", "tags", "favorite", "state", "updated_at", "version") k: getattr(research, k) for k in ("note", "tags", "favorite", "state", "updated_at", "version")
+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)
+12 -3
View File
@@ -16,12 +16,13 @@ from sqlalchemy import delete, select, text
from .ai.routes import router as ai_router from .ai.routes import router as ai_router
from .ai.runtime import AIRuntime from .ai.runtime import AIRuntime
from .alphas import list_statement, sorted_statement from .alphas import failed_checks, list_statement, sorted_statement
from .backtests.routes import router as backtest_router from .backtests.routes import router as backtest_router
from .business import Business, notify_job from .business import Business, notify_job
from .catalog.research_routes import router as research_catalog_router from .catalog.research_routes import router as research_catalog_router
from .catalog.routes import router as catalog_router from .catalog.routes import router as catalog_router
from .config import Settings from .config import Settings
from .dashboard import router as dashboard_router
from .db import create_database from .db import create_database
from .jobs import AUTH_KINDS, Runner, create_job from .jobs import AUTH_KINDS, Runner, create_job
from .mcp_api.token_routes import router as mcp_token_router from .mcp_api.token_routes import router as mcp_token_router
@@ -374,11 +375,18 @@ def create_app(settings=None, wq_client=None, ai_model_factory=None):
"turnover", "turnover",
"margin", "margin",
"drawdown", "drawdown",
"sub_universe_sharpe",
"robust_universe_sharpe",
"two_year_sharpe",
"prod_correlation",
"pnl",
"neutralization",
"check_type",
"date_created", "date_created",
"date_submitted", "date_submitted",
"synced_at", "synced_at",
] ]
writer.writerow(columns + ["research_state", "favorite", "tags", "note"]) writer.writerow(columns + ["failed_checks", "research_state", "favorite", "tags", "note"])
yield buffer.getvalue() yield buffer.getvalue()
buffer.seek(0) buffer.seek(0)
buffer.truncate(0) buffer.truncate(0)
@@ -387,7 +395,7 @@ def create_app(settings=None, wq_client=None, ai_model_factory=None):
async for a, r in rows: async for a, r in rows:
writer.writerow( writer.writerow(
[csv_cell(getattr(a, key)) for key in columns] [csv_cell(getattr(a, key)) for key in columns]
+ [r.state, r.favorite, csv_cell(";".join(r.tags)), csv_cell(r.note)] + [csv_cell(";".join(failed_checks(a.checks))), r.state, r.favorite, csv_cell(";".join(r.tags)), csv_cell(r.note)]
) )
yield buffer.getvalue() yield buffer.getvalue()
buffer.seek(0) buffer.seek(0)
@@ -471,6 +479,7 @@ def create_app(settings=None, wq_client=None, ai_model_factory=None):
if mcp_runtime: if mcp_runtime:
app.mount("/api/v1/mcp", mcp_runtime.app) app.mount("/api/v1/mcp", mcp_runtime.app)
app.include_router(mcp_token_router) app.include_router(mcp_token_router)
app.include_router(dashboard_router)
app.include_router(backtest_router) app.include_router(backtest_router)
app.include_router(api) app.include_router(api)
app.include_router(catalog_router) app.include_router(catalog_router)
+7
View File
@@ -79,6 +79,13 @@ class Alpha(Base):
turnover: Mapped[float | None] = mapped_column(Float) turnover: Mapped[float | None] = mapped_column(Float)
margin: Mapped[float | None] = mapped_column(Float) margin: Mapped[float | None] = mapped_column(Float)
drawdown: Mapped[float | None] = mapped_column(Float) drawdown: Mapped[float | None] = mapped_column(Float)
sub_universe_sharpe: Mapped[float | None] = mapped_column(Float)
robust_universe_sharpe: Mapped[float | None] = mapped_column(Float)
two_year_sharpe: Mapped[float | None] = mapped_column(Float)
prod_correlation: Mapped[float | None] = mapped_column(Float)
pnl: Mapped[float | None] = mapped_column(Float)
neutralization: Mapped[str | None] = mapped_column(Text)
check_type: Mapped[str] = mapped_column(String(20), default="PENDING", server_default="PENDING", index=True)
date_created: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True) date_created: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
date_submitted: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) date_submitted: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
synced_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now) synced_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
+30 -1
View File
@@ -9,6 +9,7 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_valida
ResearchState = Literal["inbox", "candidate", "optimizing", "archived"] ResearchState = Literal["inbox", "candidate", "optimizing", "archived"]
Submission = Literal["UNSUBMITTED", "SUBMITTED"] Submission = Literal["UNSUBMITTED", "SUBMITTED"]
CheckType = Literal["PENDING", "PRE_CHECK", "PASS", "FAIL_1", "FAIL_2"]
SortField = Literal[ SortField = Literal[
"id", "id",
"name", "name",
@@ -18,6 +19,11 @@ SortField = Literal[
"turnover", "turnover",
"margin", "margin",
"drawdown", "drawdown",
"sub_universe_sharpe",
"robust_universe_sharpe",
"two_year_sharpe",
"prod_correlation",
"pnl",
"date_created", "date_created",
"date_submitted", "date_submitted",
"synced_at", "synced_at",
@@ -76,6 +82,8 @@ class AlphaFilters(Contract):
status: str | None = None status: str | None = None
stage: str | None = None stage: str | None = None
hidden: bool | None = None hidden: bool | None = None
check_type: CheckType | None = None
neutralization: str | None = None
research_state: ResearchState | None = None research_state: ResearchState | None = None
favorite: bool | None = None favorite: bool | None = None
tag: str | None = Field(default=None, max_length=60) tag: str | None = Field(default=None, max_length=60)
@@ -93,6 +101,16 @@ class AlphaFilters(Contract):
margin_max: float | None = None margin_max: float | None = None
drawdown_min: float | None = None drawdown_min: float | None = None
drawdown_max: float | None = None drawdown_max: float | None = None
sub_universe_sharpe_min: float | None = None
sub_universe_sharpe_max: float | None = None
robust_universe_sharpe_min: float | None = None
robust_universe_sharpe_max: float | None = None
two_year_sharpe_min: float | None = None
two_year_sharpe_max: float | None = None
prod_correlation_min: float | None = None
prod_correlation_max: float | None = None
pnl_min: float | None = None
pnl_max: float | None = None
sort: SortField = "date_created" sort: SortField = "date_created"
direction: Literal["asc", "desc"] = "desc" direction: Literal["asc", "desc"] = "desc"
limit: int = Field(default=25, ge=1, le=100) limit: int = Field(default=25, ge=1, le=100)
@@ -105,7 +123,10 @@ class AlphaFilters(Contract):
@model_validator(mode="after") @model_validator(mode="after")
def range_order(self): def range_order(self):
for key in ("sharpe", "fitness", "returns", "turnover", "margin", "drawdown"): for key in (
"sharpe", "fitness", "returns", "turnover", "margin", "drawdown",
"sub_universe_sharpe", "robust_universe_sharpe", "two_year_sharpe", "prod_correlation", "pnl",
):
lo, hi = getattr(self, f"{key}_min"), getattr(self, f"{key}_max") lo, hi = getattr(self, f"{key}_min"), getattr(self, f"{key}_max")
if lo is not None and hi is not None and lo > hi: if lo is not None and hi is not None and lo > hi:
raise ValueError(f"{key} 最小值不能大于最大值") raise ValueError(f"{key} 最小值不能大于最大值")
@@ -222,6 +243,14 @@ class AlphaSummary(BaseModel):
date_created: datetime | None date_created: datetime | None
date_submitted: datetime | None date_submitted: datetime | None
synced_at: datetime synced_at: datetime
check_type: CheckType = "PENDING"
failed_checks: list[str] = Field(default_factory=list)
neutralization: str | None = None
sub_universe_sharpe: float | None = None
robust_universe_sharpe: float | None = None
two_year_sharpe: float | None = None
prod_correlation: float | None = None
pnl: float | None = None
research: ResearchOutput research: ResearchOutput
local_correlation: dict | None = None local_correlation: dict | None = None
source_kinds: list[str] = Field(default_factory=list) source_kinds: list[str] = Field(default_factory=list)
+27
View File
@@ -312,6 +312,33 @@ class WqClient:
async def profile(self): async def profile(self):
return await self.get("/users/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): async def account_usage(self):
"""Read independent account resources; unavailable sections do not hide the profile.""" """Read independent account resources; unavailable sections do not hide the profile."""
from .account_data import usage_snapshot from .account_data import usage_snapshot
@@ -0,0 +1,125 @@
"""Index Alpha checks and metrics; backfill existing snapshots without upstream calls."""
import math
import sqlalchemy as sa
from alembic import op
revision = "0011"
down_revision = "0010"
branch_labels = None
depends_on = None
# Frozen normalization for historical snapshots; do not import mutable app code.
def number(value):
if value is None or isinstance(value, bool):
return None
try:
result = float(value)
return result if math.isfinite(result) else None
except (ValueError, TypeError):
return None
def failed_checks(checks):
"""Return failed platform check names; local correlation never changes this list."""
return (
[
check.get("name") if isinstance(check.get("name"), str) else "未命名检查"
for check in checks
if isinstance(check, dict) and check.get("result") == "FAIL"
]
if isinstance(checks, list)
else []
)
def snapshot_columns(settings, metrics, checks):
"""Derive list fields from a platform snapshot, preserving missing metrics as null.
Only explicit FAIL results count. Empty, malformed and unfinished checks are
pending; all known checks passing without PROD_CORRELATION is only a pre-check.
No submission eligibility or activity eligibility is inferred here.
"""
settings = settings if isinstance(settings, dict) else {}
metrics = metrics if isinstance(metrics, dict) else {}
checks = checks if isinstance(checks, list) else []
valid = [check for check in checks if isinstance(check, dict)]
failures = len(failed_checks(checks))
by_name = {check["name"]: check for check in valid if isinstance(check.get("name"), str)}
if failures:
check_type = "FAIL_1" if failures == 1 else "FAIL_2"
elif not checks or len(valid) != len(checks) or any(check.get("result") != "PASS" for check in valid):
check_type = "PENDING"
else:
check_type = "PASS" if "PROD_CORRELATION" in by_name else "PRE_CHECK"
neutralization = settings.get("neutralization")
return {
"check_type": check_type,
"neutralization": neutralization if isinstance(neutralization, str) else None,
"pnl": number(metrics.get("pnl")),
**{
field: number(by_name.get(name, {}).get("value"))
for field, name in (
("sub_universe_sharpe", "LOW_SUB_UNIVERSE_SHARPE"),
("robust_universe_sharpe", "LOW_ROBUST_UNIVERSE_SHARPE"),
("two_year_sharpe", "LOW_2Y_SHARPE"),
("prod_correlation", "PROD_CORRELATION"),
)
},
}
METRICS = ("sub_universe_sharpe", "robust_universe_sharpe", "two_year_sharpe", "prod_correlation", "pnl")
def upgrade():
columns = [sa.Column(name, sa.Float(), nullable=True) for name in METRICS]
columns += [
sa.Column("neutralization", sa.Text(), nullable=True),
sa.Column("check_type", sa.String(20), nullable=False, server_default="PENDING"),
]
for column in columns:
op.add_column("alphas", column)
op.create_index("ix_alphas_check_type", "alphas", ["check_type"])
table = sa.table(
"alphas",
sa.column("id", sa.String()),
sa.column("settings", sa.JSON()),
sa.column("is_metrics", sa.JSON()),
sa.column("checks", sa.JSON()),
*(sa.column(column.name, column.type) for column in columns),
)
connection = op.get_bind()
last_id = None
while True:
query = (
sa.select(table.c.id, table.c.settings, table.c.is_metrics, table.c.checks)
.order_by(table.c.id)
.limit(500)
)
if last_id is not None:
query = query.where(table.c.id > last_id)
rows = connection.execute(query).mappings().all()
if not rows:
break
connection.execute(
table.update()
.where(table.c.id == sa.bindparam("snapshot_id"))
.values({column.name: sa.bindparam(column.name) for column in columns}),
[
{
"snapshot_id": row["id"],
**snapshot_columns(row["settings"], row["is_metrics"], row["checks"]),
}
for row in rows
],
)
last_id = rows[-1]["id"]
def downgrade():
op.drop_index("ix_alphas_check_type", table_name="alphas")
for name in ("check_type", "neutralization", *reversed(METRICS)):
op.drop_column("alphas", name)
+15
View File
@@ -142,6 +142,21 @@ def create_test_app():
) )
if path == "/users/self/alphas/summary": if path == "/users/self/alphas/summary":
return httpx.Response(200, json={"unsubmitted": 413, "active": 207, "decommissioned": 0}) 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"): if path in ("/users/self/activities/simulations", "/users/self/activities/submissions"):
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
+227
View File
@@ -0,0 +1,227 @@
"""Alpha list filtering, snapshot extraction, local tags and historical migration."""
import csv
import io
from pathlib import Path
import pytest
import sqlalchemy as sa
from alembic import command
from alembic.config import Config
from cryptography.fernet import Fernet
from app.alphas import snapshot_columns, upsert_alpha
from tests.conftest import alpha
PREFIX = "/api/v1/alphas"
def checks(failures):
return [
{"name": name, "result": "FAIL" if index < failures else "PASS", "value": value}
for index, (name, value) in enumerate(
[
("LOW_SUB_UNIVERSE_SHARPE", 0),
("LOW_ROBUST_UNIVERSE_SHARPE", 1.2),
("LOW_2Y_SHARPE", 2.3),
("PROD_CORRELATION", 0),
]
)
]
@pytest.mark.parametrize(
"data, expected",
[
([], "PENDING"),
(None, "PENDING"),
([None], "PENDING"),
([{}], "PENDING"),
([{"name": "LOW_SHARPE", "result": "WARNING"}], "PENDING"),
([{"name": "LOW_SHARPE", "result": "PASS"}], "PRE_CHECK"),
([{"name": "PROD_CORRELATION", "result": "PENDING"}], "PENDING"),
(checks(0), "PASS"),
(checks(1), "FAIL_1"),
(checks(2), "FAIL_2"),
(checks(3), "FAIL_2"),
],
)
def test_check_classification_never_promotes_unknown_results(data, expected):
assert snapshot_columns({}, {}, data)["check_type"] == expected
async def test_checks_filter_before_pagination_and_share_export_scope(app, logged_in):
async with app.state.sessions.begin() as db:
for count in range(4):
await upsert_alpha(db, alpha(f"failed{count}", **{"is": {"pnl": 0, "checks": checks(count)}}))
await upsert_alpha(db, alpha("unknown", **{"is": {}}))
for check_type, expected in [
("FAIL_1", ["failed1"]),
("FAIL_2", ["failed2", "failed3"]),
("PASS", ["failed0"]),
("PENDING", ["unknown"]),
]:
response = await logged_in.get(
PREFIX, params={"check_type": check_type, "sort": "id", "direction": "asc"}
)
assert response.status_code == 200
assert [row["id"] for row in response.json()["items"]] == expected
query = "check_type=FAIL_2&submission=UNSUBMITTED&region=USA&limit=1&offset=1&sort=id&direction=asc"
page = (await logged_in.get(f"{PREFIX}?{query}")).json()
assert page["total"] == 2 and [a["id"] for a in page["items"]] == ["failed3"]
row = page["items"][0]
assert row["failed_checks"] == ["LOW_SUB_UNIVERSE_SHARPE", "LOW_ROBUST_UNIVERSE_SHARPE", "LOW_2Y_SHARPE"]
assert row["prod_correlation"] == row["sub_universe_sharpe"] == row["pnl"] == 0
exported = await logged_in.get(f"{PREFIX}/export?{query}")
rows = list(csv.DictReader(io.StringIO(exported.text.lstrip("\ufeff"))))
assert [r["id"] for r in rows] == ["failed2", "failed3"]
assert rows[0]["check_type"] == "FAIL_2" and rows[0]["pnl"] == "0.0"
assert rows[0]["failed_checks"] == "LOW_SUB_UNIVERSE_SHARPE;LOW_ROBUST_UNIVERSE_SHARPE"
assert (await logged_in.get(f"{PREFIX}?check_type=FAIL_GT_2")).status_code == 422
async def test_extended_metrics_filter_sort_missing_values_and_snapshot_refresh(app, logged_in):
fields = ["sub_universe_sharpe", "robust_universe_sharpe", "two_year_sharpe", "prod_correlation", "pnl"]
async with app.state.sessions.begin() as db:
for name, value in [("zero", 0), ("positive", 2), ("negative", -1)]:
sample_checks = [{**c, "value": value} for c in checks(0)]
await upsert_alpha(
db,
alpha(
name,
settings={"neutralization": "INDUSTRY"},
**{"is": {"pnl": value, "checks": sample_checks}},
),
)
await upsert_alpha(db, alpha("missing", **{"is": {}}))
for field in fields:
result = (await logged_in.get(PREFIX, params={"sort": field, "direction": "asc"})).json()
assert [r["id"] for r in result["items"]] == ["negative", "zero", "positive", "missing"]
result = (
await logged_in.get(
PREFIX, params={f"{field}_min": 0, f"{field}_max": 0, "neutralization": "INDUSTRY"}
)
).json()
assert [r["id"] for r in result["items"]] == ["zero"]
assert (await logged_in.get(PREFIX, params={f"{field}_min": 2, f"{field}_max": 1})).status_code == 422
async with app.state.sessions.begin() as db:
await upsert_alpha(db, alpha("zero", settings={}, **{"is": {}}))
detail = (await logged_in.get(f"{PREFIX}/zero")).json()
assert all(detail[field] is None for field in fields)
assert detail["neutralization"] is None and detail["check_type"] == "PENDING"
malformed = snapshot_columns({}, {"pnl": "nan"}, [{**c, "value": True} for c in checks(0)])
assert all(malformed[field] is None for field in fields)
async def test_ppac_tags_partial_edit_bulk_filter_and_sync_preservation(app, logged_in):
async with app.state.sessions.begin() as db:
for name in ("ppac", "other"):
await upsert_alpha(db, alpha(name, **{"is": {"checks": checks(1)}}))
assert (
await logged_in.patch(
f"{PREFIX}/ppac/research",
json={
"version": 1,
"note": "等活动轮到再提交",
"state": "candidate",
"favorite": True,
},
)
).status_code == 200
assert (
await logged_in.patch(
f"{PREFIX}/ppac/research",
json={
"version": 2,
"tags": [" PPAC ", "PPAC", "待活动提交"],
},
)
).status_code == 200
assert (
await logged_in.patch(f"{PREFIX}/ppac/research", json={"version": 2, "tags": []})
).status_code == 409
async with app.state.sessions.begin() as db:
await upsert_alpha(db, alpha("ppac", **{"is": {"checks": checks(2)}}))
result = (await logged_in.get(f"{PREFIX}?tag=PPAC&check_type=FAIL_2")).json()
assert result["total"] == 1
research = result["items"][0]["research"]
assert (
research["note"] == "等活动轮到再提交" and research["favorite"] and research["state"] == "candidate"
)
assert research["tags"] == ["PPAC", "待活动提交"] and research["version"] == 3
assert "PPAC" in (await logged_in.get(f"{PREFIX}/facets")).json()["tags"]
assert (
await logged_in.patch(
f"{PREFIX}/research/bulk",
json={
"alpha_ids": ["ppac", "other"],
"versions": {"ppac": 3, "other": 1},
"add_tags": ["活动候选"],
"remove_tags": ["待活动提交"],
},
)
).status_code == 200
assert (await logged_in.get(f"{PREFIX}?tag=活动候选")).json()["total"] == 2
assert (await logged_in.get(f"{PREFIX}?tag=待活动提交")).json()["total"] == 0
def test_migration_backfills_multiple_batches_and_preserves_research(tmp_path, monkeypatch):
path = tmp_path / "migration.db"
monkeypatch.setenv("DATABASE_URL", f"sqlite+aiosqlite:///{path}")
monkeypatch.setenv("ADMIN_PASSWORD", "migration-test-only")
monkeypatch.setenv("ENCRYPTION_KEY", Fernet.generate_key().decode())
monkeypatch.setenv("WQ_EMAIL", "")
monkeypatch.setenv("WQ_PASSWORD", "")
root = Path(__file__).resolve().parents[1]
config = Config(str(root / "alembic.ini"))
config.set_main_option("script_location", str(root / "migrations"))
command.upgrade(config, "0010")
engine = sa.create_engine(f"sqlite:///{path}")
metadata = sa.MetaData()
alphas = sa.Table("alphas", metadata, autoload_with=engine)
research = sa.Table("research", metadata, autoload_with=engine)
from app.models import now
with engine.begin() as db:
db.execute(
alphas.insert(),
[
{
"id": f"old{i:04}",
"hidden": False,
"settings": {"neutralization": "INDUSTRY"},
"is_metrics": {"pnl": 0},
"os_metrics": {},
"checks": checks(i % 4),
"synced_at": now(),
"raw": {},
}
for i in range(503)
],
)
db.execute(
research.insert(),
{
"alpha_id": "old0000",
"note": "keep",
"tags": ["PPAC"],
"favorite": True,
"state": "candidate",
"version": 7,
"updated_at": now(),
},
)
for _ in range(2):
command.upgrade(config, "head")
command.check(config)
alphas = sa.Table("alphas", sa.MetaData(), autoload_with=engine)
with engine.connect() as db:
rows = db.execute(sa.select(alphas).order_by(alphas.c.id)).mappings().all()
assert len(rows) == 503
for i, row in enumerate(rows):
expected = snapshot_columns(row["settings"], row["is_metrics"], checks(i % 4))
assert {key: row[key] for key in expected} == expected
record = db.execute(sa.select(research)).mappings().one()
assert record["tags"] == ["PPAC"] and record["note"] == "keep" and record["version"] == 7
command.downgrade(config, "0010")
engine.dispose()
+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
+8
View File
@@ -25,6 +25,7 @@ import { MCPKeysPage } from "./pages/MCPKeysPage";
import { AccountPage } from "./pages/AccountPage"; import { AccountPage } from "./pages/AccountPage";
import { DatasetPage } from "./pages/DatasetPage"; import { DatasetPage } from "./pages/DatasetPage";
import { AlphaPage } from "./pages/AlphaPage"; import { AlphaPage } from "./pages/AlphaPage";
import { HomePage } from "./pages/HomePage";
import { JobPanel } from "./components/JobPanel"; import { JobPanel } from "./components/JobPanel";
import { BacktestPage } from "./backtests/BacktestPage"; import { BacktestPage } from "./backtests/BacktestPage";
import { WorkspacePreferences } from "./components/WorkspacePreferences"; import { WorkspacePreferences } from "./components/WorkspacePreferences";
@@ -319,6 +320,12 @@ export default function App() {
<main <main
className={`page-content ${page !== "account" ? "bounded-page" : "account-page"}`} className={`page-content ${page !== "account" ? "bounded-page" : "account-page"}`}
> >
{page === "home" && (
<HomePage
account={account}
onAccount={() => changePage("account")}
/>
)}
{page === "mcp-keys" && <MCPKeysPage />} {page === "mcp-keys" && <MCPKeysPage />}
{pollError && ( {pollError && (
<Banner <Banner
@@ -479,6 +486,7 @@ export default function App() {
onClose={() => setChatOpen(false)} onClose={() => setChatOpen(false)}
context={ context={
{ {
home: { page: "home" as const },
quantflow: quantflow:
researchContext.page === "quantflow" researchContext.page === "quantflow"
? researchContext ? researchContext
+1
View File
@@ -12,6 +12,7 @@ export type ModelSettings = {
}; };
export type PageContext = { export type PageContext = {
page: page:
| "home"
| "alphas" | "alphas"
| "account" | "account"
| "datasets" | "datasets"
+2 -3
View File
@@ -5,6 +5,7 @@ const contextLabels: Record<
PageContext["page"], PageContext["page"],
(context: PageContext) => string (context: PageContext) => string
> = { > = {
home: () => "上下文:首页看板",
alphas: (context) => alphas: (context) =>
`上下文:${context.alpha_id ? `Alpha ${context.alpha_id}` : "Alpha 列表"}${context.selected_ids?.length ? ` · 已选 ${context.selected_ids.length} 条` : ""}`, `上下文:${context.alpha_id ? `Alpha ${context.alpha_id}` : "Alpha 列表"}${context.selected_ids?.length ? ` · 已选 ${context.selected_ids.length} 条` : ""}`,
operators: () => "上下文:算子库", operators: () => "上下文:算子库",
@@ -30,9 +31,7 @@ export function pageFromHash(hash: string): WorkspacePage {
const value = hash.replace(/^#/, "").split("?")[0]; const value = hash.replace(/^#/, "").split("?")[0];
const page = value.startsWith("quantflow/") ? "quantflow" : value; const page = value.startsWith("quantflow/") ? "quantflow" : value;
if (page === "mcp-keys") return page; if (page === "mcp-keys") return page;
return Object.hasOwn(contextLabels, page) return Object.hasOwn(contextLabels, page) ? (page as WorkspacePage) : "home";
? (page as WorkspacePage)
: "alphas";
} }
type Destination = { type Destination = {
+2 -2
View File
@@ -384,7 +384,7 @@ export function AlphaDetail({
/> />
</label> </label>
<label> <label>
标签 自定义标签
<Input <Input
aria-label="研究标签" aria-label="研究标签"
value={tagText} value={tagText}
@@ -392,7 +392,7 @@ export function AlphaDetail({
dirty.current = true; dirty.current = true;
setTagText(value); setTagText(value);
}} }}
placeholder="以逗号分隔,例如 低换手, 分析师预期" placeholder="以逗号分隔,例如 PPAC, 待活动提交"
/> />
</label> </label>
<label> <label>
+34 -28
View File
@@ -16,6 +16,7 @@ import type { Account } from "../types";
import type { WorkspacePage } from "../ai/workspace"; import type { WorkspacePage } from "../ai/workspace";
const navigation = [ const navigation = [
{ id: "home", label: "首页看板", icon: IconGridView, group: "概览" },
{ id: "datasets", label: "数据目录", icon: IconList, group: "数据与素材" }, { id: "datasets", label: "数据目录", icon: IconList, group: "数据与素材" },
{ id: "operators", label: "算子库", icon: IconList, group: "数据与素材" }, { id: "operators", label: "算子库", icon: IconList, group: "数据与素材" },
{ {
@@ -173,35 +174,40 @@ export function AppSidebar({
</div> </div>
)} )}
<nav className="sidebar-nav" aria-label="主导航"> <nav className="sidebar-nav" aria-label="主导航">
{["数据与素材", "研究实验", "研究编排", "研究成果", "系统管理"].map( {[
(group) => ( "概览",
<div key={group}> "数据与素材",
{!collapsed && ( "研究实验",
<div className="sidebar-group-heading">{group}</div> "研究编排",
)} "研究成果",
{navigation "系统管理",
.filter((item) => item.group === group) ].map((group) => (
.map(({ id, label, icon: Icon }) => ( <div key={group}>
<Tooltip {!collapsed && (
key={id} <div className="sidebar-group-heading">{group}</div>
content={label} )}
position="right" {navigation
visible={collapsed ? undefined : false} .filter((item) => item.group === group)
.map(({ id, label, icon: Icon }) => (
<Tooltip
key={id}
content={label}
position="right"
visible={collapsed ? undefined : false}
>
<button
className={`sidebar-nav-item ${id === page ? "active" : ""}`}
aria-label={label}
aria-current={id === page ? "page" : undefined}
onClick={() => navigate(id)}
> >
<button <Icon style={{ fontSize: 17 }} />
className={`sidebar-nav-item ${id === page ? "active" : ""}`} {!collapsed && <span>{label}</span>}
aria-label={label} </button>
aria-current={id === page ? "page" : undefined} </Tooltip>
onClick={() => navigate(id)} ))}
> </div>
<Icon style={{ fontSize: 17 }} /> ))}
{!collapsed && <span>{label}</span>}
</button>
</Tooltip>
))}
</div>
),
)}
</nav> </nav>
<div className="sidebar-footer"> <div className="sidebar-footer">
{!collapsed && ( {!collapsed && (
+167 -12
View File
@@ -49,12 +49,27 @@ const metricLabels = {
turnover: "Turnover", turnover: "Turnover",
margin: "Margin", margin: "Margin",
drawdown: "Drawdown", drawdown: "Drawdown",
sub_universe_sharpe: "子股票池 Sharpe",
robust_universe_sharpe: "稳健股票池 Sharpe",
two_year_sharpe: "近两年 Sharpe",
prod_correlation: "平台生产相关性",
pnl: "IS PnL",
};
const checkLabels = {
PENDING: "待检查",
PRE_CHECK: "预检通过",
PASS: "检查通过",
FAIL_1: "FAIL=1",
FAIL_2: "FAIL≥2",
}; };
const initialColumns = [ const initialColumns = [
"name", "name",
"expression", "expression",
"region", "region",
"status", "status",
"check_type",
"failed_checks",
"tags",
"sharpe", "sharpe",
"fitness", "fitness",
"turnover", "turnover",
@@ -75,7 +90,16 @@ const columnLabels: Record<string, string> = {
margin: "Margin", margin: "Margin",
drawdown: "Drawdown", drawdown: "Drawdown",
research: "研究状态", research: "研究状态",
tags: "本地标签", tags: "自定义标签",
check_type: "检查结果",
failed_checks: "失败项",
neutralization: "中性化",
synced: "同步时间",
sub_universe_sharpe: metricLabels.sub_universe_sharpe,
robust_universe_sharpe: metricLabels.robust_universe_sharpe,
two_year_sharpe: metricLabels.two_year_sharpe,
prod_correlation: metricLabels.prod_correlation,
pnl: metricLabels.pnl,
language: "语言", language: "语言",
created: "创建时间", created: "创建时间",
submitted: "提交时间", submitted: "提交时间",
@@ -151,6 +175,11 @@ export function AlphaPage({
const [idText, setIdText] = useState(""); const [idText, setIdText] = useState("");
const [busy, setBusy] = useState(""); const [busy, setBusy] = useState("");
const [bulkOpen, setBulkOpen] = useState(false); const [bulkOpen, setBulkOpen] = useState(false);
const [tagEditor, setTagEditor] = useState<{
id: string;
version: number;
text: string;
} | null>(null);
const [bulkVersions, setBulkVersions] = useState<Record<string, number>>({}); const [bulkVersions, setBulkVersions] = useState<Record<string, number>>({});
const actionNonce = useRef(0); const actionNonce = useRef(0);
const [bulk, setBulk] = useState({ const [bulk, setBulk] = useState({
@@ -323,6 +352,26 @@ export function AlphaPage({
setBusy(""); setBusy("");
} }
} }
async function saveTags() {
if (!tagEditor) return;
setBusy("tags");
try {
await patch(`/alphas/${tagEditor.id}/research`, {
version: tagEditor.version,
tags: tagEditor.text
.split(/[,,]/)
.map((tag) => tag.trim())
.filter(Boolean),
});
setTagEditor(null);
refresh();
Toast.success("自定义标签已保存");
} catch (e) {
Toast.error((e as Error).message);
} finally {
setBusy("");
}
}
const columnDefinitions: ColumnProps<Alpha>[] = [ const columnDefinitions: ColumnProps<Alpha>[] = [
{ {
key: "name", key: "name",
@@ -343,6 +392,7 @@ export function AlphaPage({
{row!.research.favorite && <Tag size="small">收藏</Tag>} {row!.research.favorite && <Tag size="small">收藏</Tag>}
{row!.name || row!.id} {row!.name || row!.id}
</span> </span>
{row!.name && <small className="muted">{row!.id}</small>}
</button> </button>
), ),
}, },
@@ -393,7 +443,16 @@ export function AlphaPage({
([key, title]): ColumnProps<Alpha> => ({ ([key, title]): ColumnProps<Alpha> => ({
key, key,
title, title,
width: 112, width:
key in
{
sub_universe_sharpe: 1,
robust_universe_sharpe: 1,
two_year_sharpe: 1,
prod_correlation: 1,
}
? 155
: 112,
align: "right", align: "right",
render: (_, row) => ( render: (_, row) => (
<span className="numeric"> <span className="numeric">
@@ -402,6 +461,40 @@ export function AlphaPage({
), ),
}), }),
), ),
{
key: "check_type",
title: "检查结果",
width: 125,
render: (_, row) => (
<Tag
color={
row!.check_type?.startsWith("FAIL")
? "red"
: row!.check_type === "PASS"
? "green"
: "grey"
}
>
{checkLabels[row!.check_type] || "待检查"}
</Tag>
),
},
{
key: "failed_checks",
title: "失败项",
width: 230,
render: (_, row) => (
<span title={row!.failed_checks?.join("、")}>
{row!.failed_checks?.join("、") || "—"}
</span>
),
},
{
key: "neutralization",
title: "中性化",
width: 155,
render: (_, row) => row!.neutralization || "—",
},
{ {
key: "research", key: "research",
fixed: "right", fixed: "right",
@@ -423,13 +516,27 @@ export function AlphaPage({
}, },
{ {
key: "tags", key: "tags",
title: "本地标签", title: "自定义标签",
width: 180, width: 180,
render: (_, row) => ( render: (_, row) => (
<div className="inline-tags"> <div className="inline-tags">
{row!.research.tags.map((t) => ( {row!.research.tags.map((t) => (
<Tag key={t}>{t}</Tag> <Tag key={t}>{t}</Tag>
))} ))}
<button
className="text-link"
aria-label={`编辑 ${row!.id} 的标签`}
onClick={() => {
setTagEditor({
id: row!.id,
version: row!.research.version,
text: row!.research.tags.join(", "),
});
onOverlay();
}}
>
编辑标签
</button>
</div> </div>
), ),
}, },
@@ -453,6 +560,12 @@ export function AlphaPage({
); );
}, },
}, },
{
key: "synced",
title: "同步时间",
width: 165,
render: (_, row) => formatTime(row!.synced_at, account?.timezone),
},
{ {
key: "submitted", key: "submitted",
title: "提交时间", title: "提交时间",
@@ -553,6 +666,26 @@ export function AlphaPage({
optionList={stateOptions} optionList={stateOptions}
onChange={(v) => updateDraft("research_state", v)} onChange={(v) => updateDraft("research_state", v)}
/> />
<Select
aria-label="检查结果筛选"
placeholder="全部检查结果"
showClear
value={draft.check_type || undefined}
optionList={Object.entries(checkLabels).map(([value, label]) => ({
value,
label,
}))}
onChange={(v) => updateDraft("check_type", v)}
/>
<Select
aria-label="自定义标签筛选"
showClear
filter
value={draft.tag || undefined}
optionList={options("tags")}
placeholder="全部标签"
onChange={(v) => updateDraft("tag", v)}
/>
</div> </div>
<details className="advanced-filters"> <details className="advanced-filters">
<summary> <summary>
@@ -614,14 +747,12 @@ export function AlphaPage({
/> />
</label> </label>
<label> <label>
本地标签 中性化
<Select <Input
showClear aria-label="中性化筛选"
filter value={draft.neutralization || ""}
value={draft.tag || undefined} placeholder="例如 INDUSTRY"
optionList={options("tags")} onChange={(v) => updateDraft("neutralization", v)}
placeholder="全部标签"
onChange={(v) => updateDraft("tag", v)}
/> />
</label> </label>
<label> <label>
@@ -677,6 +808,7 @@ export function AlphaPage({
</div> </div>
<p className="muted"> <p className="muted">
数值筛选使用平台原始值,例如 Turnover 0.15 表示 15%。 数值筛选使用平台原始值,例如 Turnover 0.15 表示 15%。
检查结果仅汇总已同步的平台检查;预检通过不代表可以提交,活动资格可用自定义标签记录。
</p> </p>
</details> </details>
<div className="inline-actions filter-actions"> <div className="inline-actions filter-actions">
@@ -929,6 +1061,29 @@ export function AlphaPage({
/> />
</footer> </footer>
</section> </section>
<Modal
title={`编辑自定义标签 · ${tagEditor?.id || ""}`}
visible={Boolean(tagEditor) && !overlaySuspended}
onCancel={() => setTagEditor(null)}
confirmLoading={busy === "tags"}
okText="保存标签"
onOk={() => void saveTags()}
>
<p>
用逗号分隔多个标签,例如
PPAC、待活动提交。标签保存在本地,平台同步不会覆盖。
</p>
<Input
aria-label="自定义标签"
value={tagEditor?.text || ""}
placeholder="例如 PPAC, 待活动提交"
onChange={(text) =>
setTagEditor((current) =>
current ? { ...current, text } : current,
)
}
/>
</Modal>
<Modal <Modal
title="导入 Alpha ID" title="导入 Alpha ID"
visible={importing && !overlaySuspended} visible={importing && !overlaySuspended}
@@ -967,7 +1122,7 @@ export function AlphaPage({
添加标签 添加标签
<Input <Input
aria-label="批量添加标签" aria-label="批量添加标签"
placeholder="用逗号分隔" placeholder="用逗号分隔,例如 PPAC, 待活动提交"
value={bulk.add_tags} value={bulk.add_tags}
onChange={(add_tags) => setBulk({ ...bulk, add_tags })} onChange={(add_tags) => setBulk({ ...bulk, add_tags })}
/> />
+376
View File
@@ -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<Snapshot | null>(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<Snapshot>(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<typeof useSnapshot>;
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 (
<section className="home-page" aria-label="首页看板">
<header className="home-heading">
<div>
<h1>首页看板</h1>
<p>了解 Pyramid 的平台乘数与 Alpha 分布。</p>
</div>
<Button
disabled={!connected || multipliers.loading || alphas.loading}
onClick={() => setVersion((value) => value + 1)}
>
刷新看板
</Button>
</header>
{!account ? (
<div className="home-empty">
<Spin />
<span>正在读取账户状态…</span>
</div>
) : (
!connected && (
<div className="home-connect">
<div>
<strong>连接 WorldQuant 后查看看板</strong>
<p>请到个人信息页连接账户;如需人工验证,请先完成验证。</p>
</div>
<Button theme="solid" onClick={onAccount}>
前往个人信息
</Button>
</div>
)
)}
<div className="home-metrics">
{metrics.map(([label, value, hint]) => (
<article className="home-metric" key={label} aria-label={label}>
<span>{label}</span>
<strong>
{typeof value === "number"
? value.toLocaleString("zh-CN")
: (value ?? "—")}
</strong>
<small>{hint}</small>
</article>
))}
</div>
<div className="home-panels">
<PyramidPanel
title="Pyramid 乘数"
description="按乘数从高到低排列,日期筛选不影响当前乘数。"
metric="乘数"
state={multipliers}
connected={connected}
timezone={account?.timezone}
/>
<PyramidPanel
title="Alpha 分布"
description="按归属数量从高到低排列;数量为 0 的类别也会保留。"
metric="Alpha 数量"
state={alphas}
connected={connected}
timezone={account?.timezone}
>
<form
className="home-period"
onSubmit={(event) => {
event.preventDefault();
if (invalidDates) return;
if (dirtyDates) setPeriod({ start, end });
else alphas.refresh();
}}
>
<div className="home-date-fields">
<label>
开始日期
<input
aria-label="分布开始日期"
type="date"
value={start}
max={end || "9999-12-31"}
onChange={(event) => setStart(event.target.value)}
/>
</label>
<label>
结束日期
<input
aria-label="分布结束日期"
type="date"
value={end}
min={start || undefined}
max="9999-12-31"
onChange={(event) => setEnd(event.target.value)}
/>
</label>
</div>
<div className="inline-actions">
<Button
htmlType="submit"
disabled={!connected || invalidDates || alphas.loading}
>
应用日期
</Button>
<Button
disabled={!start && !end && !period.start && !period.end}
onClick={() => {
setStart("");
setEnd("");
setPeriod({ start: "", end: "" });
}}
>
恢复默认
</Button>
<small>按平台日期查询;留空使用平台默认范围。</small>
</div>
{invalidDates && <p role="alert">开始日期不能晚于结束日期。</p>}
{dirtyDates && !invalidDates && (
<p className="muted">日期已修改,点击“应用日期”更新分布。</p>
)}
</form>
</PyramidPanel>
</div>
</section>
);
}
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 (
<section
className="home-panel"
aria-label={title}
aria-busy={state.loading}
>
<header>
<h2>{title}</h2>
<p>{description}</p>
</header>
{children}
{state.error ? (
<div className="home-panel-error">
<Banner type="warning" description={state.error} />
<Button onClick={state.refresh}>重试{title}</Button>
</div>
) : state.loading ? (
<div className="home-empty" role="status">
<Spin />
<span>正在加载{title}…</span>
</div>
) : !connected ? (
<div className="home-empty">连接账户后显示{title}。</div>
) : state.data ? (
<>
<div className="home-table-tools">
<Input
aria-label={`搜索${title}`}
value={search}
onChange={setSearch}
placeholder="搜索地区、延迟或类别"
showClear
/>
<span>
{rows.length} / {state.data.items.length} 类
</span>
</div>
<div className="home-table-wrap">
<table>
<thead>
<tr>
<th>地区</th>
<th>延迟</th>
<th>数据类别</th>
<th>{metric}</th>
</tr>
</thead>
<tbody>
{rows.map((row, index) => (
<tr
key={`${row.region}:${row.delay}:${row.category_id}:${index}`}
>
<td>{row.region}</td>
<td>D{row.delay}</td>
<td>
<strong>{row.category_name}</strong>
<small>{row.category_id}</small>
</td>
<td>
<div className="home-value">
{row.value === null
? "—"
: `${row.value.toLocaleString("zh-CN")}${metric === "乘数" ? "×" : ""}`}
</div>
<div className="home-bar" aria-hidden="true">
<i
style={{
width: `${((row.value ?? 0) / maximum) * 100}%`,
}}
/>
</div>
</td>
</tr>
))}
</tbody>
</table>
{!rows.length && (
<div className="home-empty">
{state.data.items.length
? "没有匹配的 Pyramid 类别。"
: "平台暂未返回 Pyramid 数据。"}
</div>
)}
</div>
<footer>
{metric === "Alpha 数量" && (
<span>
查询范围:
{state.data.start_date || state.data.end_date
? `${state.data.start_date ?? "不限开始"} 至 ${state.data.end_date ?? "不限结束"}`
: "平台默认"}
</span>
)}
<span>更新于 {formatTime(state.data.fetched_at, timezone)}</span>
</footer>
</>
) : null}
</section>
);
}
+217
View File
@@ -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;
}
}
+3
View File
@@ -301,6 +301,9 @@ label:not(.semi-checkbox):not(.semi-radio) {
.columns-picker { .columns-picker {
padding: var(--space-3); padding: var(--space-3);
min-width: 192px; min-width: 192px;
max-height: min(420px, 50dvh);
overflow-y: auto;
overscroll-behavior: contain;
} }
.columns-picker > strong { .columns-picker > strong {
display: block; display: block;
+8
View File
@@ -55,6 +55,14 @@ export type Alpha = {
date_created: string | null; date_created: string | null;
date_submitted: string | null; date_submitted: string | null;
synced_at: string; synced_at: string;
check_type: "PENDING" | "PRE_CHECK" | "PASS" | "FAIL_1" | "FAIL_2";
failed_checks: string[];
neutralization: string | null;
sub_universe_sharpe: number | null;
robust_universe_sharpe: number | null;
two_year_sharpe: number | null;
prod_correlation: number | null;
pnl: number | null;
research: Research; research: Research;
local_correlation: CorrelationSummary | null; local_correlation: CorrelationSummary | null;
source_kinds: string[]; source_kinds: string[];
+1 -1
View File
@@ -5,7 +5,7 @@ test("submission tabs, day selection, local correlation and reload", async ({
}) => { }) => {
const errors: string[] = []; const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message)); 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.getByLabel("密码", { exact: true }).fill("browser-test-password");
await page.getByRole("button", { name: "进入工作空间" }).click(); await page.getByRole("button", { name: "进入工作空间" }).click();
await expect( await expect(
+1 -1
View File
@@ -19,7 +19,7 @@ test("sidebar geometry, recent navigation, user menu and compact tools", async (
await page.getByRole("button", { name: "配置", exact: true }).click(); await page.getByRole("button", { name: "配置", exact: true }).click();
await expect(page.locator("#model-settings")).toBeInViewport(); await expect(page.locator("#model-settings")).toBeInViewport();
await expect( await expect(
nav.getByRole("button", { name: "Alpha 管理", exact: true }), nav.getByRole("button", { name: "首页看板", exact: true }),
).toHaveAttribute("aria-current", "page"); ).toHaveAttribute("aria-current", "page");
await page.locator(".settings-drawer .semi-sidesheet-close").click(); await page.locator(".settings-drawer .semi-sidesheet-close").click();
await page.getByRole("button", { name: "用户菜单", exact: true }).click(); await page.getByRole("button", { name: "用户菜单", exact: true }).click();
+10 -3
View File
@@ -175,7 +175,7 @@ test("account → scoped sync → research → resync → PnL → filtered expor
test("batch tags, column visibility, server pagination and saved preferences", async ({ test("batch tags, column visibility, server pagination and saved preferences", async ({
page, page,
}) => { }) => {
await page.goto("/"); await page.goto("/#alphas");
await page.getByLabel("密码", { exact: true }).fill("browser-test-password"); await page.getByLabel("密码", { exact: true }).fill("browser-test-password");
await page.getByRole("button", { name: "进入工作空间" }).click(); await page.getByRole("button", { name: "进入工作空间" }).click();
await expect( await expect(
@@ -245,10 +245,17 @@ test("batch tags, column visibility, server pagination and saved preferences", a
await page.getByRole("button", { name: "显示列设置" }).click(); await page.getByRole("button", { name: "显示列设置" }).click();
await page await page
.locator(".columns-picker") .locator(".columns-picker")
.getByText("本地标签", { exact: true }) .getByText("自定义标签", { exact: true })
.click(); .click();
await expect( await expect(
page.getByRole("columnheader", { name: "本地标签", exact: true }), page.getByRole("columnheader", { name: "自定义标签", exact: true }),
).toHaveCount(0);
await page
.locator(".columns-picker")
.getByText("自定义标签", { exact: true })
.click();
await expect(
page.getByRole("columnheader", { name: "自定义标签", exact: true }),
).toBeVisible(); ).toBeVisible();
await page.getByRole("button", { name: "显示列设置" }).click(); await page.getByRole("button", { name: "显示列设置" }).click();
await page.getByRole("button", { name: "重置", exact: true }).click(); await page.getByRole("button", { name: "重置", exact: true }).click();