Compare commits
2 Commits
be76b89e9d
...
ba0ed9d03f
| Author | SHA1 | Date | |
|---|---|---|---|
| ba0ed9d03f | |||
| e57b1f7a2e |
@@ -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 迁移;没有部署、提交代码或操作真实平台。
|
||||
@@ -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。
|
||||
- 真实平台只读核实了两项响应结构与日期参数的接受格式;没有验证日期范围的服务端统计边界。未部署、未提交。
|
||||
@@ -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 总数。
|
||||
@@ -37,6 +37,7 @@ class ModelSettingsInput(Contract):
|
||||
|
||||
class PageContext(Contract):
|
||||
page: Literal[
|
||||
"home",
|
||||
"alphas",
|
||||
"account",
|
||||
"datasets",
|
||||
|
||||
+61
-2
@@ -9,6 +9,55 @@ from sqlalchemy import or_, select, update
|
||||
from .models import Alpha, Research, ResearchTag, SelfCorrelation, now
|
||||
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):
|
||||
"""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.os_metrics = sanitize(raw.get("os")) if isinstance(raw.get("os"), dict) else {}
|
||||
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"):
|
||||
setattr(item, key, number(metrics.get(key)))
|
||||
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)
|
||||
if value is not None:
|
||||
query = query.where(getattr(Alpha, name) == value)
|
||||
@@ -148,7 +199,7 @@ def list_statement(filters):
|
||||
query = query.where(Alpha.date_created >= filters.created_from)
|
||||
if 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")):
|
||||
value = getattr(filters, f"{name}_{suffix}")
|
||||
if value is not None:
|
||||
@@ -183,8 +234,16 @@ def summary(item: Alpha, research: Research):
|
||||
"date_created",
|
||||
"date_submitted",
|
||||
"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["failed_checks"] = failed_checks(item.checks)
|
||||
result["expression_preview"] = (item.expression or item.selection or "")[:240]
|
||||
result["research"] = {
|
||||
k: getattr(research, k) for k in ("note", "tags", "favorite", "state", "updated_at", "version")
|
||||
|
||||
@@ -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
@@ -16,12 +16,13 @@ from sqlalchemy import delete, select, text
|
||||
|
||||
from .ai.routes import router as ai_router
|
||||
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 .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
|
||||
@@ -374,11 +375,18 @@ def create_app(settings=None, wq_client=None, ai_model_factory=None):
|
||||
"turnover",
|
||||
"margin",
|
||||
"drawdown",
|
||||
"sub_universe_sharpe",
|
||||
"robust_universe_sharpe",
|
||||
"two_year_sharpe",
|
||||
"prod_correlation",
|
||||
"pnl",
|
||||
"neutralization",
|
||||
"check_type",
|
||||
"date_created",
|
||||
"date_submitted",
|
||||
"synced_at",
|
||||
]
|
||||
writer.writerow(columns + ["research_state", "favorite", "tags", "note"])
|
||||
writer.writerow(columns + ["failed_checks", "research_state", "favorite", "tags", "note"])
|
||||
yield buffer.getvalue()
|
||||
buffer.seek(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:
|
||||
writer.writerow(
|
||||
[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()
|
||||
buffer.seek(0)
|
||||
@@ -471,6 +479,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)
|
||||
|
||||
@@ -79,6 +79,13 @@ class Alpha(Base):
|
||||
turnover: Mapped[float | None] = mapped_column(Float)
|
||||
margin: 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_submitted: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
synced_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
|
||||
|
||||
+30
-1
@@ -9,6 +9,7 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_valida
|
||||
|
||||
ResearchState = Literal["inbox", "candidate", "optimizing", "archived"]
|
||||
Submission = Literal["UNSUBMITTED", "SUBMITTED"]
|
||||
CheckType = Literal["PENDING", "PRE_CHECK", "PASS", "FAIL_1", "FAIL_2"]
|
||||
SortField = Literal[
|
||||
"id",
|
||||
"name",
|
||||
@@ -18,6 +19,11 @@ SortField = Literal[
|
||||
"turnover",
|
||||
"margin",
|
||||
"drawdown",
|
||||
"sub_universe_sharpe",
|
||||
"robust_universe_sharpe",
|
||||
"two_year_sharpe",
|
||||
"prod_correlation",
|
||||
"pnl",
|
||||
"date_created",
|
||||
"date_submitted",
|
||||
"synced_at",
|
||||
@@ -76,6 +82,8 @@ class AlphaFilters(Contract):
|
||||
status: str | None = None
|
||||
stage: str | None = None
|
||||
hidden: bool | None = None
|
||||
check_type: CheckType | None = None
|
||||
neutralization: str | None = None
|
||||
research_state: ResearchState | None = None
|
||||
favorite: bool | None = None
|
||||
tag: str | None = Field(default=None, max_length=60)
|
||||
@@ -93,6 +101,16 @@ class AlphaFilters(Contract):
|
||||
margin_max: float | None = None
|
||||
drawdown_min: 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"
|
||||
direction: Literal["asc", "desc"] = "desc"
|
||||
limit: int = Field(default=25, ge=1, le=100)
|
||||
@@ -105,7 +123,10 @@ class AlphaFilters(Contract):
|
||||
|
||||
@model_validator(mode="after")
|
||||
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")
|
||||
if lo is not None and hi is not None and lo > hi:
|
||||
raise ValueError(f"{key} 最小值不能大于最大值")
|
||||
@@ -222,6 +243,14 @@ class AlphaSummary(BaseModel):
|
||||
date_created: datetime | None
|
||||
date_submitted: datetime | None
|
||||
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
|
||||
local_correlation: dict | None = None
|
||||
source_kinds: list[str] = Field(default_factory=list)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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®ion=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()
|
||||
@@ -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
|
||||
@@ -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() {
|
||||
<main
|
||||
className={`page-content ${page !== "account" ? "bounded-page" : "account-page"}`}
|
||||
>
|
||||
{page === "home" && (
|
||||
<HomePage
|
||||
account={account}
|
||||
onAccount={() => changePage("account")}
|
||||
/>
|
||||
)}
|
||||
{page === "mcp-keys" && <MCPKeysPage />}
|
||||
{pollError && (
|
||||
<Banner
|
||||
@@ -479,6 +486,7 @@ export default function App() {
|
||||
onClose={() => setChatOpen(false)}
|
||||
context={
|
||||
{
|
||||
home: { page: "home" as const },
|
||||
quantflow:
|
||||
researchContext.page === "quantflow"
|
||||
? researchContext
|
||||
|
||||
@@ -12,6 +12,7 @@ export type ModelSettings = {
|
||||
};
|
||||
export type PageContext = {
|
||||
page:
|
||||
| "home"
|
||||
| "alphas"
|
||||
| "account"
|
||||
| "datasets"
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -384,7 +384,7 @@ export function AlphaDetail({
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
标签
|
||||
自定义标签
|
||||
<Input
|
||||
aria-label="研究标签"
|
||||
value={tagText}
|
||||
@@ -392,7 +392,7 @@ export function AlphaDetail({
|
||||
dirty.current = true;
|
||||
setTagText(value);
|
||||
}}
|
||||
placeholder="以逗号分隔,例如 低换手, 分析师预期"
|
||||
placeholder="以逗号分隔,例如 PPAC, 待活动提交"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
|
||||
@@ -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({
|
||||
</div>
|
||||
)}
|
||||
<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(({ id, label, icon: Icon }) => (
|
||||
<Tooltip
|
||||
key={id}
|
||||
content={label}
|
||||
position="right"
|
||||
visible={collapsed ? undefined : false}
|
||||
{[
|
||||
"概览",
|
||||
"数据与素材",
|
||||
"研究实验",
|
||||
"研究编排",
|
||||
"研究成果",
|
||||
"系统管理",
|
||||
].map((group) => (
|
||||
<div key={group}>
|
||||
{!collapsed && (
|
||||
<div className="sidebar-group-heading">{group}</div>
|
||||
)}
|
||||
{navigation
|
||||
.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
|
||||
className={`sidebar-nav-item ${id === page ? "active" : ""}`}
|
||||
aria-label={label}
|
||||
aria-current={id === page ? "page" : undefined}
|
||||
onClick={() => navigate(id)}
|
||||
>
|
||||
<Icon style={{ fontSize: 17 }} />
|
||||
{!collapsed && <span>{label}</span>}
|
||||
</button>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
<Icon style={{ fontSize: 17 }} />
|
||||
{!collapsed && <span>{label}</span>}
|
||||
</button>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
<div className="sidebar-footer">
|
||||
{!collapsed && (
|
||||
|
||||
@@ -49,12 +49,27 @@ const metricLabels = {
|
||||
turnover: "Turnover",
|
||||
margin: "Margin",
|
||||
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 = [
|
||||
"name",
|
||||
"expression",
|
||||
"region",
|
||||
"status",
|
||||
"check_type",
|
||||
"failed_checks",
|
||||
"tags",
|
||||
"sharpe",
|
||||
"fitness",
|
||||
"turnover",
|
||||
@@ -75,7 +90,16 @@ const columnLabels: Record<string, string> = {
|
||||
margin: "Margin",
|
||||
drawdown: "Drawdown",
|
||||
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: "语言",
|
||||
created: "创建时间",
|
||||
submitted: "提交时间",
|
||||
@@ -151,6 +175,11 @@ export function AlphaPage({
|
||||
const [idText, setIdText] = useState("");
|
||||
const [busy, setBusy] = useState("");
|
||||
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 actionNonce = useRef(0);
|
||||
const [bulk, setBulk] = useState({
|
||||
@@ -323,6 +352,26 @@ export function AlphaPage({
|
||||
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>[] = [
|
||||
{
|
||||
key: "name",
|
||||
@@ -343,6 +392,7 @@ export function AlphaPage({
|
||||
{row!.research.favorite && <Tag size="small">收藏</Tag>}
|
||||
{row!.name || row!.id}
|
||||
</span>
|
||||
{row!.name && <small className="muted">{row!.id}</small>}
|
||||
</button>
|
||||
),
|
||||
},
|
||||
@@ -393,7 +443,16 @@ export function AlphaPage({
|
||||
([key, title]): ColumnProps<Alpha> => ({
|
||||
key,
|
||||
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",
|
||||
render: (_, row) => (
|
||||
<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",
|
||||
fixed: "right",
|
||||
@@ -423,13 +516,27 @@ export function AlphaPage({
|
||||
},
|
||||
{
|
||||
key: "tags",
|
||||
title: "本地标签",
|
||||
title: "自定义标签",
|
||||
width: 180,
|
||||
render: (_, row) => (
|
||||
<div className="inline-tags">
|
||||
{row!.research.tags.map((t) => (
|
||||
<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>
|
||||
),
|
||||
},
|
||||
@@ -453,6 +560,12 @@ export function AlphaPage({
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "synced",
|
||||
title: "同步时间",
|
||||
width: 165,
|
||||
render: (_, row) => formatTime(row!.synced_at, account?.timezone),
|
||||
},
|
||||
{
|
||||
key: "submitted",
|
||||
title: "提交时间",
|
||||
@@ -553,6 +666,26 @@ export function AlphaPage({
|
||||
optionList={stateOptions}
|
||||
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>
|
||||
<details className="advanced-filters">
|
||||
<summary>
|
||||
@@ -614,14 +747,12 @@ export function AlphaPage({
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
本地标签
|
||||
<Select
|
||||
showClear
|
||||
filter
|
||||
value={draft.tag || undefined}
|
||||
optionList={options("tags")}
|
||||
placeholder="全部标签"
|
||||
onChange={(v) => updateDraft("tag", v)}
|
||||
中性化
|
||||
<Input
|
||||
aria-label="中性化筛选"
|
||||
value={draft.neutralization || ""}
|
||||
placeholder="例如 INDUSTRY"
|
||||
onChange={(v) => updateDraft("neutralization", v)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
@@ -677,6 +808,7 @@ export function AlphaPage({
|
||||
</div>
|
||||
<p className="muted">
|
||||
数值筛选使用平台原始值,例如 Turnover 0.15 表示 15%。
|
||||
检查结果仅汇总已同步的平台检查;预检通过不代表可以提交,活动资格可用自定义标签记录。
|
||||
</p>
|
||||
</details>
|
||||
<div className="inline-actions filter-actions">
|
||||
@@ -929,6 +1061,29 @@ export function AlphaPage({
|
||||
/>
|
||||
</footer>
|
||||
</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
|
||||
title="导入 Alpha ID"
|
||||
visible={importing && !overlaySuspended}
|
||||
@@ -967,7 +1122,7 @@ export function AlphaPage({
|
||||
添加标签
|
||||
<Input
|
||||
aria-label="批量添加标签"
|
||||
placeholder="用逗号分隔"
|
||||
placeholder="用逗号分隔,例如 PPAC, 待活动提交"
|
||||
value={bulk.add_tags}
|
||||
onChange={(add_tags) => setBulk({ ...bulk, add_tags })}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -301,6 +301,9 @@ label:not(.semi-checkbox):not(.semi-radio) {
|
||||
.columns-picker {
|
||||
padding: var(--space-3);
|
||||
min-width: 192px;
|
||||
max-height: min(420px, 50dvh);
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
.columns-picker > strong {
|
||||
display: block;
|
||||
|
||||
@@ -55,6 +55,14 @@ export type Alpha = {
|
||||
date_created: string | null;
|
||||
date_submitted: string | null;
|
||||
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;
|
||||
local_correlation: CorrelationSummary | null;
|
||||
source_kinds: string[];
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(
|
||||
@@ -245,10 +245,17 @@ test("batch tags, column visibility, server pagination and saved preferences", a
|
||||
await page.getByRole("button", { name: "显示列设置" }).click();
|
||||
await page
|
||||
.locator(".columns-picker")
|
||||
.getByText("本地标签", { exact: true })
|
||||
.getByText("自定义标签", { exact: true })
|
||||
.click();
|
||||
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();
|
||||
await page.getByRole("button", { name: "显示列设置" }).click();
|
||||
await page.getByRole("button", { name: "重置", exact: true }).click();
|
||||
|
||||
Reference in New Issue
Block a user