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):
|
class PageContext(Contract):
|
||||||
page: Literal[
|
page: Literal[
|
||||||
|
"home",
|
||||||
"alphas",
|
"alphas",
|
||||||
"account",
|
"account",
|
||||||
"datasets",
|
"datasets",
|
||||||
|
|||||||
+61
-2
@@ -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")
|
||||||
|
|||||||
@@ -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.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)
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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 { 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
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export type ModelSettings = {
|
|||||||
};
|
};
|
||||||
export type PageContext = {
|
export type PageContext = {
|
||||||
page:
|
page:
|
||||||
|
| "home"
|
||||||
| "alphas"
|
| "alphas"
|
||||||
| "account"
|
| "account"
|
||||||
| "datasets"
|
| "datasets"
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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 })}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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 {
|
.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;
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user