feat: Implement Alpha list and metrics enhancements
Deploy production / deploy (push) Successful in 1m12s
Deploy production / deploy (push) Successful in 1m12s
- Added new metrics fields: sub_universe_sharpe, robust_universe_sharpe, two_year_sharpe, prod_correlation, pnl, check_type, and neutralization to the Alpha model. - Updated snapshot_columns function to derive new metrics and check types from platform snapshots. - Enhanced API to include failed checks and check types in responses. - Created migration script to backfill existing Alpha records with new metrics and check types. - Updated frontend components to display new metrics and allow editing of custom tags. - Improved filtering and sorting capabilities for new metrics in the Alpha list. - Added tests for new functionality including checks classification and metrics filtering.
This commit is contained in:
@@ -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 项及未知状态、组合筛选/分页/导出、零值与缺值、标签保存与同步保留;隔离迁移;前端构建及浏览器交互。
|
||||
+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")
|
||||
|
||||
+10
-3
@@ -16,7 +16,7 @@ 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
|
||||
@@ -375,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)
|
||||
@@ -388,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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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>
|
||||
|
||||
@@ -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 })}
|
||||
/>
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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