feat: Implement Alpha list and metrics enhancements
Deploy production / deploy (push) Successful in 1m12s

- Added new metrics fields: sub_universe_sharpe, robust_universe_sharpe, two_year_sharpe, prod_correlation, pnl, check_type, and neutralization to the Alpha model.
- Updated snapshot_columns function to derive new metrics and check types from platform snapshots.
- Enhanced API to include failed checks and check types in responses.
- Created migration script to backfill existing Alpha records with new metrics and check types.
- Updated frontend components to display new metrics and allow editing of custom tags.
- Improved filtering and sorting capabilities for new metrics in the Alpha list.
- Added tests for new functionality including checks classification and metrics filtering.
This commit is contained in:
yuxuanhui
2026-09-09 19:02:37 +08:00
parent e57b1f7a2e
commit ba0ed9d03f
13 changed files with 678 additions and 22 deletions
@@ -0,0 +1,17 @@
# 补齐 Alpha 列表和标签入口
Type: task
Status: ready-for-agent
Implementation: completed
范围与验收见 ../spec.md。
## Comments
- 已确认失败分档使用 FAIL=1 / FAIL≥2,正在实现。
- 已完成九个新增表头、指标范围筛选/排序/导出、检查结果分档筛选、单条自定义标签编辑与默认标签列。保留已有列偏好。
- 验证:后端全量 269 项通过;Ruff、Prettier、前端构建及 diff 检查通过。
- 浏览器全量首跑 20 项通过、2 项失败:一项 SQLite 锁冲突,单独复跑通过;另一项涉及标签改名和列设置过长,更新断言并修复限高滚动后,workspace 两项通过。
- Playwright CLI 实测 FAIL≥2 包含失败 2/3 项、PPAC 标签保存与组合筛选、刷新保留、390px 无整页溢出、26 项列设置可滚动并关闭。
- SQLite 和独立 PostgreSQL 17 数据库均验证 503 条历史快照回填、迁移回退/重新升级、模型一致及原研究标签/备注/版本保留。已删除本轮临时数据库。
- 已包含 0011 迁移;没有部署、提交代码或操作真实平台。
+12
View File
@@ -0,0 +1,12 @@
# Alpha 列表指标、检查筛选与自定义标签
用户已授权本地实现。失败分档已确认:FAIL=1、FAIL≥2。
- 补充检查结果、失败项、子股票池/稳健股票池/近两年 Sharpe、中性化、平台生产相关性、IS PnL、同步时间。
- 默认显示检查结果、失败项和自定义标签;保留已有列偏好,其余字段可选。
- 检查结果按平台快照计算,与平台状态、研究状态及本地自相关独立。没有完成的检查不得视为通过。
- 指标支持范围筛选、排序及导出;检查筛选在数据库分页前执行。
- 复用本地标签,提供列表单条编辑、批量编辑和筛选,支持 PPAC 等任意标签,平台同步不覆盖。
- 历史快照通过增量迁移回填,不请求平台、不修改研究记录。
验证:API 失败 0/1/2/3 项及未知状态、组合筛选/分页/导出、零值与缺值、标签保存与同步保留;隔离迁移;前端构建及浏览器交互。
+61 -2
View File
@@ -9,6 +9,55 @@ from sqlalchemy import or_, select, update
from .models import Alpha, Research, ResearchTag, SelfCorrelation, now from .models import Alpha, Research, ResearchTag, SelfCorrelation, now
from .research.provenance import source_alpha_ids from .research.provenance import source_alpha_ids
METRIC_FIELDS = (
"sharpe", "fitness", "returns", "turnover", "margin", "drawdown",
"sub_universe_sharpe", "robust_universe_sharpe", "two_year_sharpe", "prod_correlation", "pnl",
)
def failed_checks(checks):
"""Return failed platform check names; local correlation never changes this list."""
return [
check.get("name") if isinstance(check.get("name"), str) else "未命名检查"
for check in checks if isinstance(check, dict) and check.get("result") == "FAIL"
] if isinstance(checks, list) else []
def snapshot_columns(settings, metrics, checks):
"""Derive list fields from a platform snapshot, preserving missing metrics as null.
Only explicit FAIL results count. Empty, malformed and unfinished checks are
pending; all known checks passing without PROD_CORRELATION is only a pre-check.
No submission eligibility or activity eligibility is inferred here.
"""
settings = settings if isinstance(settings, dict) else {}
metrics = metrics if isinstance(metrics, dict) else {}
checks = checks if isinstance(checks, list) else []
valid = [check for check in checks if isinstance(check, dict)]
failures = len(failed_checks(checks))
by_name = {check["name"]: check for check in valid if isinstance(check.get("name"), str)}
if failures:
check_type = "FAIL_1" if failures == 1 else "FAIL_2"
elif not checks or len(valid) != len(checks) or any(check.get("result") != "PASS" for check in valid):
check_type = "PENDING"
else:
check_type = "PASS" if "PROD_CORRELATION" in by_name else "PRE_CHECK"
neutralization = settings.get("neutralization")
return {
"check_type": check_type,
"neutralization": neutralization if isinstance(neutralization, str) else None,
"pnl": number(metrics.get("pnl")),
**{
field: number(by_name.get(name, {}).get("value"))
for field, name in (
("sub_universe_sharpe", "LOW_SUB_UNIVERSE_SHARPE"),
("robust_universe_sharpe", "LOW_ROBUST_UNIVERSE_SHARPE"),
("two_year_sharpe", "LOW_2Y_SHARPE"),
("prod_correlation", "PROD_CORRELATION"),
)
},
}
def submission_condition(submission): def submission_condition(submission):
"""Match the platform list contract; a missing status is never assumed submitted.""" """Match the platform list contract; a missing status is never assumed submitted."""
@@ -106,6 +155,8 @@ async def upsert_alpha(db, raw: dict):
item.settings, item.is_metrics = sanitize(settings), sanitize(metrics) item.settings, item.is_metrics = sanitize(settings), sanitize(metrics)
item.os_metrics = sanitize(raw.get("os")) if isinstance(raw.get("os"), dict) else {} item.os_metrics = sanitize(raw.get("os")) if isinstance(raw.get("os"), dict) else {}
item.checks = sanitize(metrics.get("checks") or raw.get("checks") or []) item.checks = sanitize(metrics.get("checks") or raw.get("checks") or [])
for key, value in snapshot_columns(item.settings, item.is_metrics, item.checks).items():
setattr(item, key, value)
for key in ("sharpe", "fitness", "returns", "turnover", "margin", "drawdown"): for key in ("sharpe", "fitness", "returns", "turnover", "margin", "drawdown"):
setattr(item, key, number(metrics.get(key))) setattr(item, key, number(metrics.get(key)))
item.date_created, item.date_submitted = date(raw.get("dateCreated")), date(raw.get("dateSubmitted")) item.date_created, item.date_submitted = date(raw.get("dateCreated")), date(raw.get("dateSubmitted"))
@@ -134,7 +185,7 @@ def list_statement(filters):
) )
) )
) )
for name in ("region", "universe", "alpha_type", "language", "status", "stage", "hidden"): for name in ("region", "universe", "alpha_type", "language", "status", "stage", "hidden", "check_type", "neutralization"):
value = getattr(filters, name) value = getattr(filters, name)
if value is not None: if value is not None:
query = query.where(getattr(Alpha, name) == value) query = query.where(getattr(Alpha, name) == value)
@@ -148,7 +199,7 @@ def list_statement(filters):
query = query.where(Alpha.date_created >= filters.created_from) query = query.where(Alpha.date_created >= filters.created_from)
if filters.created_to: if filters.created_to:
query = query.where(Alpha.date_created <= filters.created_to) query = query.where(Alpha.date_created <= filters.created_to)
for name in ("sharpe", "fitness", "returns", "turnover", "margin", "drawdown"): for name in METRIC_FIELDS:
for suffix, compare in (("min", "ge"), ("max", "le")): for suffix, compare in (("min", "ge"), ("max", "le")):
value = getattr(filters, f"{name}_{suffix}") value = getattr(filters, f"{name}_{suffix}")
if value is not None: if value is not None:
@@ -183,8 +234,16 @@ def summary(item: Alpha, research: Research):
"date_created", "date_created",
"date_submitted", "date_submitted",
"synced_at", "synced_at",
"check_type",
"neutralization",
"sub_universe_sharpe",
"robust_universe_sharpe",
"two_year_sharpe",
"prod_correlation",
"pnl",
) )
result = {k: getattr(item, k) for k in keys} result = {k: getattr(item, k) for k in keys}
result["failed_checks"] = failed_checks(item.checks)
result["expression_preview"] = (item.expression or item.selection or "")[:240] result["expression_preview"] = (item.expression or item.selection or "")[:240]
result["research"] = { result["research"] = {
k: getattr(research, k) for k in ("note", "tags", "favorite", "state", "updated_at", "version") k: getattr(research, k) for k in ("note", "tags", "favorite", "state", "updated_at", "version")
+10 -3
View File
@@ -16,7 +16,7 @@ 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
@@ -375,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)
@@ -388,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)
+7
View File
@@ -79,6 +79,13 @@ class Alpha(Base):
turnover: Mapped[float | None] = mapped_column(Float) turnover: Mapped[float | None] = mapped_column(Float)
margin: Mapped[float | None] = mapped_column(Float) margin: Mapped[float | None] = mapped_column(Float)
drawdown: Mapped[float | None] = mapped_column(Float) drawdown: Mapped[float | None] = mapped_column(Float)
sub_universe_sharpe: Mapped[float | None] = mapped_column(Float)
robust_universe_sharpe: Mapped[float | None] = mapped_column(Float)
two_year_sharpe: Mapped[float | None] = mapped_column(Float)
prod_correlation: Mapped[float | None] = mapped_column(Float)
pnl: Mapped[float | None] = mapped_column(Float)
neutralization: Mapped[str | None] = mapped_column(Text)
check_type: Mapped[str] = mapped_column(String(20), default="PENDING", server_default="PENDING", index=True)
date_created: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True) date_created: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
date_submitted: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) date_submitted: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
synced_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now) synced_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
+30 -1
View File
@@ -9,6 +9,7 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_valida
ResearchState = Literal["inbox", "candidate", "optimizing", "archived"] ResearchState = Literal["inbox", "candidate", "optimizing", "archived"]
Submission = Literal["UNSUBMITTED", "SUBMITTED"] Submission = Literal["UNSUBMITTED", "SUBMITTED"]
CheckType = Literal["PENDING", "PRE_CHECK", "PASS", "FAIL_1", "FAIL_2"]
SortField = Literal[ SortField = Literal[
"id", "id",
"name", "name",
@@ -18,6 +19,11 @@ SortField = Literal[
"turnover", "turnover",
"margin", "margin",
"drawdown", "drawdown",
"sub_universe_sharpe",
"robust_universe_sharpe",
"two_year_sharpe",
"prod_correlation",
"pnl",
"date_created", "date_created",
"date_submitted", "date_submitted",
"synced_at", "synced_at",
@@ -76,6 +82,8 @@ class AlphaFilters(Contract):
status: str | None = None status: str | None = None
stage: str | None = None stage: str | None = None
hidden: bool | None = None hidden: bool | None = None
check_type: CheckType | None = None
neutralization: str | None = None
research_state: ResearchState | None = None research_state: ResearchState | None = None
favorite: bool | None = None favorite: bool | None = None
tag: str | None = Field(default=None, max_length=60) tag: str | None = Field(default=None, max_length=60)
@@ -93,6 +101,16 @@ class AlphaFilters(Contract):
margin_max: float | None = None margin_max: float | None = None
drawdown_min: float | None = None drawdown_min: float | None = None
drawdown_max: float | None = None drawdown_max: float | None = None
sub_universe_sharpe_min: float | None = None
sub_universe_sharpe_max: float | None = None
robust_universe_sharpe_min: float | None = None
robust_universe_sharpe_max: float | None = None
two_year_sharpe_min: float | None = None
two_year_sharpe_max: float | None = None
prod_correlation_min: float | None = None
prod_correlation_max: float | None = None
pnl_min: float | None = None
pnl_max: float | None = None
sort: SortField = "date_created" sort: SortField = "date_created"
direction: Literal["asc", "desc"] = "desc" direction: Literal["asc", "desc"] = "desc"
limit: int = Field(default=25, ge=1, le=100) limit: int = Field(default=25, ge=1, le=100)
@@ -105,7 +123,10 @@ class AlphaFilters(Contract):
@model_validator(mode="after") @model_validator(mode="after")
def range_order(self): def range_order(self):
for key in ("sharpe", "fitness", "returns", "turnover", "margin", "drawdown"): for key in (
"sharpe", "fitness", "returns", "turnover", "margin", "drawdown",
"sub_universe_sharpe", "robust_universe_sharpe", "two_year_sharpe", "prod_correlation", "pnl",
):
lo, hi = getattr(self, f"{key}_min"), getattr(self, f"{key}_max") lo, hi = getattr(self, f"{key}_min"), getattr(self, f"{key}_max")
if lo is not None and hi is not None and lo > hi: if lo is not None and hi is not None and lo > hi:
raise ValueError(f"{key} 最小值不能大于最大值") raise ValueError(f"{key} 最小值不能大于最大值")
@@ -222,6 +243,14 @@ class AlphaSummary(BaseModel):
date_created: datetime | None date_created: datetime | None
date_submitted: datetime | None date_submitted: datetime | None
synced_at: datetime synced_at: datetime
check_type: CheckType = "PENDING"
failed_checks: list[str] = Field(default_factory=list)
neutralization: str | None = None
sub_universe_sharpe: float | None = None
robust_universe_sharpe: float | None = None
two_year_sharpe: float | None = None
prod_correlation: float | None = None
pnl: float | None = None
research: ResearchOutput research: ResearchOutput
local_correlation: dict | None = None local_correlation: dict | None = None
source_kinds: list[str] = Field(default_factory=list) source_kinds: list[str] = Field(default_factory=list)
@@ -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)
+227
View File
@@ -0,0 +1,227 @@
"""Alpha list filtering, snapshot extraction, local tags and historical migration."""
import csv
import io
from pathlib import Path
import pytest
import sqlalchemy as sa
from alembic import command
from alembic.config import Config
from cryptography.fernet import Fernet
from app.alphas import snapshot_columns, upsert_alpha
from tests.conftest import alpha
PREFIX = "/api/v1/alphas"
def checks(failures):
return [
{"name": name, "result": "FAIL" if index < failures else "PASS", "value": value}
for index, (name, value) in enumerate(
[
("LOW_SUB_UNIVERSE_SHARPE", 0),
("LOW_ROBUST_UNIVERSE_SHARPE", 1.2),
("LOW_2Y_SHARPE", 2.3),
("PROD_CORRELATION", 0),
]
)
]
@pytest.mark.parametrize(
"data, expected",
[
([], "PENDING"),
(None, "PENDING"),
([None], "PENDING"),
([{}], "PENDING"),
([{"name": "LOW_SHARPE", "result": "WARNING"}], "PENDING"),
([{"name": "LOW_SHARPE", "result": "PASS"}], "PRE_CHECK"),
([{"name": "PROD_CORRELATION", "result": "PENDING"}], "PENDING"),
(checks(0), "PASS"),
(checks(1), "FAIL_1"),
(checks(2), "FAIL_2"),
(checks(3), "FAIL_2"),
],
)
def test_check_classification_never_promotes_unknown_results(data, expected):
assert snapshot_columns({}, {}, data)["check_type"] == expected
async def test_checks_filter_before_pagination_and_share_export_scope(app, logged_in):
async with app.state.sessions.begin() as db:
for count in range(4):
await upsert_alpha(db, alpha(f"failed{count}", **{"is": {"pnl": 0, "checks": checks(count)}}))
await upsert_alpha(db, alpha("unknown", **{"is": {}}))
for check_type, expected in [
("FAIL_1", ["failed1"]),
("FAIL_2", ["failed2", "failed3"]),
("PASS", ["failed0"]),
("PENDING", ["unknown"]),
]:
response = await logged_in.get(
PREFIX, params={"check_type": check_type, "sort": "id", "direction": "asc"}
)
assert response.status_code == 200
assert [row["id"] for row in response.json()["items"]] == expected
query = "check_type=FAIL_2&submission=UNSUBMITTED&region=USA&limit=1&offset=1&sort=id&direction=asc"
page = (await logged_in.get(f"{PREFIX}?{query}")).json()
assert page["total"] == 2 and [a["id"] for a in page["items"]] == ["failed3"]
row = page["items"][0]
assert row["failed_checks"] == ["LOW_SUB_UNIVERSE_SHARPE", "LOW_ROBUST_UNIVERSE_SHARPE", "LOW_2Y_SHARPE"]
assert row["prod_correlation"] == row["sub_universe_sharpe"] == row["pnl"] == 0
exported = await logged_in.get(f"{PREFIX}/export?{query}")
rows = list(csv.DictReader(io.StringIO(exported.text.lstrip("\ufeff"))))
assert [r["id"] for r in rows] == ["failed2", "failed3"]
assert rows[0]["check_type"] == "FAIL_2" and rows[0]["pnl"] == "0.0"
assert rows[0]["failed_checks"] == "LOW_SUB_UNIVERSE_SHARPE;LOW_ROBUST_UNIVERSE_SHARPE"
assert (await logged_in.get(f"{PREFIX}?check_type=FAIL_GT_2")).status_code == 422
async def test_extended_metrics_filter_sort_missing_values_and_snapshot_refresh(app, logged_in):
fields = ["sub_universe_sharpe", "robust_universe_sharpe", "two_year_sharpe", "prod_correlation", "pnl"]
async with app.state.sessions.begin() as db:
for name, value in [("zero", 0), ("positive", 2), ("negative", -1)]:
sample_checks = [{**c, "value": value} for c in checks(0)]
await upsert_alpha(
db,
alpha(
name,
settings={"neutralization": "INDUSTRY"},
**{"is": {"pnl": value, "checks": sample_checks}},
),
)
await upsert_alpha(db, alpha("missing", **{"is": {}}))
for field in fields:
result = (await logged_in.get(PREFIX, params={"sort": field, "direction": "asc"})).json()
assert [r["id"] for r in result["items"]] == ["negative", "zero", "positive", "missing"]
result = (
await logged_in.get(
PREFIX, params={f"{field}_min": 0, f"{field}_max": 0, "neutralization": "INDUSTRY"}
)
).json()
assert [r["id"] for r in result["items"]] == ["zero"]
assert (await logged_in.get(PREFIX, params={f"{field}_min": 2, f"{field}_max": 1})).status_code == 422
async with app.state.sessions.begin() as db:
await upsert_alpha(db, alpha("zero", settings={}, **{"is": {}}))
detail = (await logged_in.get(f"{PREFIX}/zero")).json()
assert all(detail[field] is None for field in fields)
assert detail["neutralization"] is None and detail["check_type"] == "PENDING"
malformed = snapshot_columns({}, {"pnl": "nan"}, [{**c, "value": True} for c in checks(0)])
assert all(malformed[field] is None for field in fields)
async def test_ppac_tags_partial_edit_bulk_filter_and_sync_preservation(app, logged_in):
async with app.state.sessions.begin() as db:
for name in ("ppac", "other"):
await upsert_alpha(db, alpha(name, **{"is": {"checks": checks(1)}}))
assert (
await logged_in.patch(
f"{PREFIX}/ppac/research",
json={
"version": 1,
"note": "等活动轮到再提交",
"state": "candidate",
"favorite": True,
},
)
).status_code == 200
assert (
await logged_in.patch(
f"{PREFIX}/ppac/research",
json={
"version": 2,
"tags": [" PPAC ", "PPAC", "待活动提交"],
},
)
).status_code == 200
assert (
await logged_in.patch(f"{PREFIX}/ppac/research", json={"version": 2, "tags": []})
).status_code == 409
async with app.state.sessions.begin() as db:
await upsert_alpha(db, alpha("ppac", **{"is": {"checks": checks(2)}}))
result = (await logged_in.get(f"{PREFIX}?tag=PPAC&check_type=FAIL_2")).json()
assert result["total"] == 1
research = result["items"][0]["research"]
assert (
research["note"] == "等活动轮到再提交" and research["favorite"] and research["state"] == "candidate"
)
assert research["tags"] == ["PPAC", "待活动提交"] and research["version"] == 3
assert "PPAC" in (await logged_in.get(f"{PREFIX}/facets")).json()["tags"]
assert (
await logged_in.patch(
f"{PREFIX}/research/bulk",
json={
"alpha_ids": ["ppac", "other"],
"versions": {"ppac": 3, "other": 1},
"add_tags": ["活动候选"],
"remove_tags": ["待活动提交"],
},
)
).status_code == 200
assert (await logged_in.get(f"{PREFIX}?tag=活动候选")).json()["total"] == 2
assert (await logged_in.get(f"{PREFIX}?tag=待活动提交")).json()["total"] == 0
def test_migration_backfills_multiple_batches_and_preserves_research(tmp_path, monkeypatch):
path = tmp_path / "migration.db"
monkeypatch.setenv("DATABASE_URL", f"sqlite+aiosqlite:///{path}")
monkeypatch.setenv("ADMIN_PASSWORD", "migration-test-only")
monkeypatch.setenv("ENCRYPTION_KEY", Fernet.generate_key().decode())
monkeypatch.setenv("WQ_EMAIL", "")
monkeypatch.setenv("WQ_PASSWORD", "")
root = Path(__file__).resolve().parents[1]
config = Config(str(root / "alembic.ini"))
config.set_main_option("script_location", str(root / "migrations"))
command.upgrade(config, "0010")
engine = sa.create_engine(f"sqlite:///{path}")
metadata = sa.MetaData()
alphas = sa.Table("alphas", metadata, autoload_with=engine)
research = sa.Table("research", metadata, autoload_with=engine)
from app.models import now
with engine.begin() as db:
db.execute(
alphas.insert(),
[
{
"id": f"old{i:04}",
"hidden": False,
"settings": {"neutralization": "INDUSTRY"},
"is_metrics": {"pnl": 0},
"os_metrics": {},
"checks": checks(i % 4),
"synced_at": now(),
"raw": {},
}
for i in range(503)
],
)
db.execute(
research.insert(),
{
"alpha_id": "old0000",
"note": "keep",
"tags": ["PPAC"],
"favorite": True,
"state": "candidate",
"version": 7,
"updated_at": now(),
},
)
for _ in range(2):
command.upgrade(config, "head")
command.check(config)
alphas = sa.Table("alphas", sa.MetaData(), autoload_with=engine)
with engine.connect() as db:
rows = db.execute(sa.select(alphas).order_by(alphas.c.id)).mappings().all()
assert len(rows) == 503
for i, row in enumerate(rows):
expected = snapshot_columns(row["settings"], row["is_metrics"], checks(i % 4))
assert {key: row[key] for key in expected} == expected
record = db.execute(sa.select(research)).mappings().one()
assert record["tags"] == ["PPAC"] and record["note"] == "keep" and record["version"] == 7
command.downgrade(config, "0010")
engine.dispose()
+2 -2
View File
@@ -384,7 +384,7 @@ export function AlphaDetail({
/> />
</label> </label>
<label> <label>
标签 自定义标签
<Input <Input
aria-label="研究标签" aria-label="研究标签"
value={tagText} value={tagText}
@@ -392,7 +392,7 @@ export function AlphaDetail({
dirty.current = true; dirty.current = true;
setTagText(value); setTagText(value);
}} }}
placeholder="以逗号分隔,例如 低换手, 分析师预期" placeholder="以逗号分隔,例如 PPAC, 待活动提交"
/> />
</label> </label>
<label> <label>
+167 -12
View File
@@ -49,12 +49,27 @@ const metricLabels = {
turnover: "Turnover", turnover: "Turnover",
margin: "Margin", margin: "Margin",
drawdown: "Drawdown", drawdown: "Drawdown",
sub_universe_sharpe: "子股票池 Sharpe",
robust_universe_sharpe: "稳健股票池 Sharpe",
two_year_sharpe: "近两年 Sharpe",
prod_correlation: "平台生产相关性",
pnl: "IS PnL",
};
const checkLabels = {
PENDING: "待检查",
PRE_CHECK: "预检通过",
PASS: "检查通过",
FAIL_1: "FAIL=1",
FAIL_2: "FAIL≥2",
}; };
const initialColumns = [ const initialColumns = [
"name", "name",
"expression", "expression",
"region", "region",
"status", "status",
"check_type",
"failed_checks",
"tags",
"sharpe", "sharpe",
"fitness", "fitness",
"turnover", "turnover",
@@ -75,7 +90,16 @@ const columnLabels: Record<string, string> = {
margin: "Margin", margin: "Margin",
drawdown: "Drawdown", drawdown: "Drawdown",
research: "研究状态", research: "研究状态",
tags: "本地标签", tags: "自定义标签",
check_type: "检查结果",
failed_checks: "失败项",
neutralization: "中性化",
synced: "同步时间",
sub_universe_sharpe: metricLabels.sub_universe_sharpe,
robust_universe_sharpe: metricLabels.robust_universe_sharpe,
two_year_sharpe: metricLabels.two_year_sharpe,
prod_correlation: metricLabels.prod_correlation,
pnl: metricLabels.pnl,
language: "语言", language: "语言",
created: "创建时间", created: "创建时间",
submitted: "提交时间", submitted: "提交时间",
@@ -151,6 +175,11 @@ export function AlphaPage({
const [idText, setIdText] = useState(""); const [idText, setIdText] = useState("");
const [busy, setBusy] = useState(""); const [busy, setBusy] = useState("");
const [bulkOpen, setBulkOpen] = useState(false); const [bulkOpen, setBulkOpen] = useState(false);
const [tagEditor, setTagEditor] = useState<{
id: string;
version: number;
text: string;
} | null>(null);
const [bulkVersions, setBulkVersions] = useState<Record<string, number>>({}); const [bulkVersions, setBulkVersions] = useState<Record<string, number>>({});
const actionNonce = useRef(0); const actionNonce = useRef(0);
const [bulk, setBulk] = useState({ const [bulk, setBulk] = useState({
@@ -323,6 +352,26 @@ export function AlphaPage({
setBusy(""); setBusy("");
} }
} }
async function saveTags() {
if (!tagEditor) return;
setBusy("tags");
try {
await patch(`/alphas/${tagEditor.id}/research`, {
version: tagEditor.version,
tags: tagEditor.text
.split(/[,,]/)
.map((tag) => tag.trim())
.filter(Boolean),
});
setTagEditor(null);
refresh();
Toast.success("自定义标签已保存");
} catch (e) {
Toast.error((e as Error).message);
} finally {
setBusy("");
}
}
const columnDefinitions: ColumnProps<Alpha>[] = [ const columnDefinitions: ColumnProps<Alpha>[] = [
{ {
key: "name", key: "name",
@@ -343,6 +392,7 @@ export function AlphaPage({
{row!.research.favorite && <Tag size="small">收藏</Tag>} {row!.research.favorite && <Tag size="small">收藏</Tag>}
{row!.name || row!.id} {row!.name || row!.id}
</span> </span>
{row!.name && <small className="muted">{row!.id}</small>}
</button> </button>
), ),
}, },
@@ -393,7 +443,16 @@ export function AlphaPage({
([key, title]): ColumnProps<Alpha> => ({ ([key, title]): ColumnProps<Alpha> => ({
key, key,
title, title,
width: 112, width:
key in
{
sub_universe_sharpe: 1,
robust_universe_sharpe: 1,
two_year_sharpe: 1,
prod_correlation: 1,
}
? 155
: 112,
align: "right", align: "right",
render: (_, row) => ( render: (_, row) => (
<span className="numeric"> <span className="numeric">
@@ -402,6 +461,40 @@ export function AlphaPage({
), ),
}), }),
), ),
{
key: "check_type",
title: "检查结果",
width: 125,
render: (_, row) => (
<Tag
color={
row!.check_type?.startsWith("FAIL")
? "red"
: row!.check_type === "PASS"
? "green"
: "grey"
}
>
{checkLabels[row!.check_type] || "待检查"}
</Tag>
),
},
{
key: "failed_checks",
title: "失败项",
width: 230,
render: (_, row) => (
<span title={row!.failed_checks?.join("、")}>
{row!.failed_checks?.join("、") || "—"}
</span>
),
},
{
key: "neutralization",
title: "中性化",
width: 155,
render: (_, row) => row!.neutralization || "—",
},
{ {
key: "research", key: "research",
fixed: "right", fixed: "right",
@@ -423,13 +516,27 @@ export function AlphaPage({
}, },
{ {
key: "tags", key: "tags",
title: "本地标签", title: "自定义标签",
width: 180, width: 180,
render: (_, row) => ( render: (_, row) => (
<div className="inline-tags"> <div className="inline-tags">
{row!.research.tags.map((t) => ( {row!.research.tags.map((t) => (
<Tag key={t}>{t}</Tag> <Tag key={t}>{t}</Tag>
))} ))}
<button
className="text-link"
aria-label={`编辑 ${row!.id} 的标签`}
onClick={() => {
setTagEditor({
id: row!.id,
version: row!.research.version,
text: row!.research.tags.join(", "),
});
onOverlay();
}}
>
编辑标签
</button>
</div> </div>
), ),
}, },
@@ -453,6 +560,12 @@ export function AlphaPage({
); );
}, },
}, },
{
key: "synced",
title: "同步时间",
width: 165,
render: (_, row) => formatTime(row!.synced_at, account?.timezone),
},
{ {
key: "submitted", key: "submitted",
title: "提交时间", title: "提交时间",
@@ -553,6 +666,26 @@ export function AlphaPage({
optionList={stateOptions} optionList={stateOptions}
onChange={(v) => updateDraft("research_state", v)} onChange={(v) => updateDraft("research_state", v)}
/> />
<Select
aria-label="检查结果筛选"
placeholder="全部检查结果"
showClear
value={draft.check_type || undefined}
optionList={Object.entries(checkLabels).map(([value, label]) => ({
value,
label,
}))}
onChange={(v) => updateDraft("check_type", v)}
/>
<Select
aria-label="自定义标签筛选"
showClear
filter
value={draft.tag || undefined}
optionList={options("tags")}
placeholder="全部标签"
onChange={(v) => updateDraft("tag", v)}
/>
</div> </div>
<details className="advanced-filters"> <details className="advanced-filters">
<summary> <summary>
@@ -614,14 +747,12 @@ export function AlphaPage({
/> />
</label> </label>
<label> <label>
本地标签 中性化
<Select <Input
showClear aria-label="中性化筛选"
filter value={draft.neutralization || ""}
value={draft.tag || undefined} placeholder="例如 INDUSTRY"
optionList={options("tags")} onChange={(v) => updateDraft("neutralization", v)}
placeholder="全部标签"
onChange={(v) => updateDraft("tag", v)}
/> />
</label> </label>
<label> <label>
@@ -677,6 +808,7 @@ export function AlphaPage({
</div> </div>
<p className="muted"> <p className="muted">
数值筛选使用平台原始值,例如 Turnover 0.15 表示 15%。 数值筛选使用平台原始值,例如 Turnover 0.15 表示 15%。
检查结果仅汇总已同步的平台检查;预检通过不代表可以提交,活动资格可用自定义标签记录。
</p> </p>
</details> </details>
<div className="inline-actions filter-actions"> <div className="inline-actions filter-actions">
@@ -929,6 +1061,29 @@ export function AlphaPage({
/> />
</footer> </footer>
</section> </section>
<Modal
title={`编辑自定义标签 · ${tagEditor?.id || ""}`}
visible={Boolean(tagEditor) && !overlaySuspended}
onCancel={() => setTagEditor(null)}
confirmLoading={busy === "tags"}
okText="保存标签"
onOk={() => void saveTags()}
>
<p>
用逗号分隔多个标签,例如
PPAC、待活动提交。标签保存在本地,平台同步不会覆盖。
</p>
<Input
aria-label="自定义标签"
value={tagEditor?.text || ""}
placeholder="例如 PPAC, 待活动提交"
onChange={(text) =>
setTagEditor((current) =>
current ? { ...current, text } : current,
)
}
/>
</Modal>
<Modal <Modal
title="导入 Alpha ID" title="导入 Alpha ID"
visible={importing && !overlaySuspended} visible={importing && !overlaySuspended}
@@ -967,7 +1122,7 @@ export function AlphaPage({
添加标签 添加标签
<Input <Input
aria-label="批量添加标签" aria-label="批量添加标签"
placeholder="用逗号分隔" placeholder="用逗号分隔,例如 PPAC, 待活动提交"
value={bulk.add_tags} value={bulk.add_tags}
onChange={(add_tags) => setBulk({ ...bulk, add_tags })} onChange={(add_tags) => setBulk({ ...bulk, add_tags })}
/> />
+3
View File
@@ -301,6 +301,9 @@ label:not(.semi-checkbox):not(.semi-radio) {
.columns-picker { .columns-picker {
padding: var(--space-3); padding: var(--space-3);
min-width: 192px; min-width: 192px;
max-height: min(420px, 50dvh);
overflow-y: auto;
overscroll-behavior: contain;
} }
.columns-picker > strong { .columns-picker > strong {
display: block; display: block;
+8
View File
@@ -55,6 +55,14 @@ export type Alpha = {
date_created: string | null; date_created: string | null;
date_submitted: string | null; date_submitted: string | null;
synced_at: string; synced_at: string;
check_type: "PENDING" | "PRE_CHECK" | "PASS" | "FAIL_1" | "FAIL_2";
failed_checks: string[];
neutralization: string | null;
sub_universe_sharpe: number | null;
robust_universe_sharpe: number | null;
two_year_sharpe: number | null;
prod_correlation: number | null;
pnl: number | null;
research: Research; research: Research;
local_correlation: CorrelationSummary | null; local_correlation: CorrelationSummary | null;
source_kinds: string[]; source_kinds: string[];
+9 -2
View File
@@ -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();