From bbf0e37130742a75fefefd7a93b2c9c43eb8c48b Mon Sep 17 00:00:00 2001 From: yuxuanhui Date: Sat, 12 Sep 2026 23:22:59 +0800 Subject: [PATCH] feat: add fixed submission-blocked Alpha tab --- .../submission-blocked/issues/01-fixed-tab.md | 11 +++++ backend/app/alphas.py | 6 +++ backend/app/models.py | 1 + backend/app/schemas.py | 1 + .../versions/0020_submission_blocked.py | 38 +++++++++++++++ backend/tests/test_alpha_list_fields.py | 1 + backend/tests/test_check_stage_migration.py | 10 ++++ backend/tests/test_submission_blocked.py | 45 ++++++++++++++++++ frontend/src/pages/AlphaPage.tsx | 30 ++++++++++-- frontend/src/research/SavedViews.tsx | 18 +++++++- frontend/tests/submission-blocked.spec.ts | 46 +++++++++++++++++++ 11 files changed, 201 insertions(+), 6 deletions(-) create mode 100644 .scratch/submission-blocked/issues/01-fixed-tab.md create mode 100644 backend/migrations/versions/0020_submission_blocked.py create mode 100644 backend/tests/test_submission_blocked.py create mode 100644 frontend/tests/submission-blocked.spec.ts diff --git a/.scratch/submission-blocked/issues/01-fixed-tab.md b/.scratch/submission-blocked/issues/01-fixed-tab.md new file mode 100644 index 0000000..22a4ab3 --- /dev/null +++ b/.scratch/submission-blocked/issues/01-fixed-tab.md @@ -0,0 +1,11 @@ +# Alpha 管理固定提交受阻 Tab +Status: ready-for-agent +Type: task + +筛选未提交且最近缓存 REGULAR_SUBMISSION=FAIL 的 Alpha。条件在后端分页前执行,与其他参数、导出、保存视图一致;同步和平台检查更新缓存时同步刷新索引字段。新增迁移回填历史记录,不改变质量检查分类。 + +## Comments +实现中;待 API、迁移及前端检查。 + +已完成:固定 Tab、组合筛选、分页与导出、保存视图及恢复,迁移 0020 回填历史缓存;同步和 /check 共用 snapshot_columns 更新受阻标记。 +验证:首轮 19 项后端测试通过;最终相关回归 99 项通过(含 503 行迁移回填、降级、再升级及 schema check);浏览器验证切换、叠加搜索、保存恢复和重置通过;ruff、TypeScript、生产构建及 diff 检查通过。构建保留既有 lottie eval 与大包提示。未部署、未提交。 diff --git a/backend/app/alphas.py b/backend/app/alphas.py index 81b50b1..59f6c8c 100644 --- a/backend/app/alphas.py +++ b/backend/app/alphas.py @@ -35,6 +35,7 @@ def snapshot_columns(settings, metrics, checks, *, checked=False): """ settings = settings if isinstance(settings, dict) else {} metrics = metrics if isinstance(metrics, dict) else {} + blocked = submission_limits(checks)["status"] == "blocked" checks, _ = split_checks(checks) valid = [check for check in checks if isinstance(check, dict)] failures = len(failed_checks(checks)) @@ -52,6 +53,7 @@ def snapshot_columns(settings, metrics, checks, *, checked=False): neutralization = settings.get("neutralization") return { "check_type": check_type, + "submission_blocked": blocked, "neutralization": neutralization if isinstance(neutralization, str) else None, "pnl": number(metrics.get("pnl")), "prod_correlation": prod_correlation, @@ -189,6 +191,10 @@ def list_statement(filters): query = select(Alpha, Research).join(Research, Research.alpha_id == Alpha.id) if filters.submission: query = query.where(submission_condition(filters.submission)) + if filters.submission_blocked is not None: + query = query.where(Alpha.submission_blocked == filters.submission_blocked) + if filters.submission_blocked: + query = query.where(submission_condition("UNSUBMITTED")) source_filters = {k: getattr(filters, k) for k in ("source", "source_reference", "research_id", "backtest_run_id")} if any(source_filters.values()): query = query.where(Alpha.id.in_(source_alpha_ids(**source_filters))) diff --git a/backend/app/models.py b/backend/app/models.py index a860ae9..1831ee4 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -87,6 +87,7 @@ class Alpha(Base): 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) + submission_blocked: Mapped[bool] = mapped_column(Boolean, default=False, server_default=false(), 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) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index f8c3a6f..fa74f00 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -69,6 +69,7 @@ class PreferencesInput(Contract): class AlphaFilters(Contract): + submission_blocked: bool | None = None submission: Submission | None = None source: str | None = Field(default=None, max_length=100) source_reference: str | None = Field(default=None, max_length=200) diff --git a/backend/migrations/versions/0020_submission_blocked.py b/backend/migrations/versions/0020_submission_blocked.py new file mode 100644 index 0000000..cbf5164 --- /dev/null +++ b/backend/migrations/versions/0020_submission_blocked.py @@ -0,0 +1,38 @@ +"""Index the cached submission limit independently of Alpha quality checks.""" + +import sqlalchemy as sa +from alembic import op + +revision = "0020" +down_revision = "0019" +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column("alphas", sa.Column("submission_blocked", sa.Boolean(), nullable=False, server_default=sa.false())) + table = sa.table("alphas", sa.column("id", sa.String()), sa.column("checks", sa.JSON()), + sa.column("submission_blocked", sa.Boolean())) + connection = op.get_bind() + last_id = None + while True: + query = sa.select(table.c.id, 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 + # Frozen snapshot interpretation; never depend on future application code. + blocked = [row["id"] for row in rows if isinstance(row["checks"], list) and any( + isinstance(check, dict) and check.get("name") == "REGULAR_SUBMISSION" + and isinstance(check.get("result"), str) and check["result"].upper() == "FAIL" + for check in row["checks"])] + if blocked: + connection.execute(table.update().where(table.c.id.in_(blocked)).values(submission_blocked=True)) + last_id = rows[-1]["id"] + op.create_index("ix_alphas_submission_blocked", "alphas", ["submission_blocked"]) + + +def downgrade(): + op.drop_index("ix_alphas_submission_blocked", table_name="alphas") + op.drop_column("alphas", "submission_blocked") diff --git a/backend/tests/test_alpha_list_fields.py b/backend/tests/test_alpha_list_fields.py index a3c86be..cfc28e5 100644 --- a/backend/tests/test_alpha_list_fields.py +++ b/backend/tests/test_alpha_list_fields.py @@ -219,6 +219,7 @@ def test_migration_backfills_multiple_batches_and_preserves_research(tmp_path, m assert len(rows) == 503 for i, row in enumerate(rows): expected = snapshot_columns(row["settings"], row["is_metrics"], checks(i % 4), checked=True) + expected.pop("submission_blocked") # Added after this historical migration. 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 diff --git a/backend/tests/test_check_stage_migration.py b/backend/tests/test_check_stage_migration.py index 0e62e0b..e9a21b2 100644 --- a/backend/tests/test_check_stage_migration.py +++ b/backend/tests/test_check_stage_migration.py @@ -60,5 +60,15 @@ def test_stage_backfill_preserves_evidence_and_uses_checkpoints(tmp_path, monkey for i, row in enumerate(rows): assert row["check_type"] == patterns[i % 7][expected_index] assert row["checks"] == row["raw"]["is"]["checks"] == row["is_metrics"]["checks"] == patterns[i % 7][0] + command.upgrade(config, "head") + current = sa.Table("alphas", sa.MetaData(), autoload_with=engine) + with engine.connect() as db: + rows = db.execute(sa.select(current).order_by(current.c.id)).mappings().all() + for i, row in enumerate(rows): + assert row["submission_blocked"] is (i % 7 < 4) + assert row["checks"] == patterns[i % 7][0] + command.downgrade(config, "0019") + assert "submission_blocked" not in {c["name"] for c in sa.inspect(engine).get_columns("alphas")} + command.upgrade(config, "head") command.check(config) engine.dispose() diff --git a/backend/tests/test_submission_blocked.py b/backend/tests/test_submission_blocked.py new file mode 100644 index 0000000..de66dcf --- /dev/null +++ b/backend/tests/test_submission_blocked.py @@ -0,0 +1,45 @@ +"""Submission limits filter cached unsubmitted Alpha snapshots before pagination.""" + +import csv +import io + +from app.alphas import snapshot_columns, upsert_alpha +from tests.conftest import alpha + + +async def test_blocked_filter_intersects_filters_and_export_and_refresh(app, logged_in): + async with app.state.sessions.begin() as db: + for name, result, status, region in [ + ("blocked1", "FAIL", "UNSUBMITTED", "USA"), + ("blocked2", "fail", "UNSUBMITTED", "USA"), + ("other_region", "FAIL", "UNSUBMITTED", "CHN"), + ("submitted", "FAIL", "ACTIVE", "USA"), + ("passed", "PASS", "UNSUBMITTED", "USA"), + ("pending", "PENDING", "UNSUBMITTED", "USA"), + ("missing", None, "UNSUBMITTED", "USA"), + ]: + checks = [{"name": "LOW_SHARPE", "result": "PASS"}] + if result: + checks.append({"name": "REGULAR_SUBMISSION", "result": result}) + await upsert_alpha(db, alpha(name, status=status, settings={"region": region}, **{"is": {"checks": checks}})) + query = "submission_blocked=true®ion=USA&check_type=PRE_CHECK&sort=id&direction=asc&limit=1&offset=1" + response = await logged_in.get(f"/api/v1/alphas?{query}") + assert response.status_code == 200 + assert response.json()["total"] == 2 + assert [row["id"] for row in response.json()["items"]] == ["blocked2"] + export = await logged_in.get(f"/api/v1/alphas/export?{query}") + assert [row["id"] for row in csv.DictReader(io.StringIO(export.text.lstrip("\ufeff")))] == ["blocked1", "blocked2"] + assert (await logged_in.get("/api/v1/alphas?submission_blocked=true&submission=SUBMITTED")).json()["total"] == 0 + assert (await logged_in.get("/api/v1/alphas?submission_blocked=true&q=blocked1")).json()["total"] == 1 + async with app.state.sessions.begin() as db: + await upsert_alpha(db, alpha("blocked1", **{"is": {"checks": [{"name": "REGULAR_SUBMISSION", "result": "PASS"}]}})) + assert (await logged_in.get("/api/v1/alphas?submission_blocked=true&q=blocked1")).json()["total"] == 0 + + +def test_explicit_check_refreshes_limit_independently_of_quality(): + for checked in (False, True): + for result, blocked in [("FAIL", True), ("PASS", False), ("PENDING", False)]: + columns = snapshot_columns({}, {}, [{"name": "LOW_SHARPE", "result": "PASS"}, + {"name": "REGULAR_SUBMISSION", "result": result}], checked=checked) + assert columns["submission_blocked"] is blocked + assert columns["check_type"] == ("PASS" if checked else "PRE_CHECK") diff --git a/frontend/src/pages/AlphaPage.tsx b/frontend/src/pages/AlphaPage.tsx index 0833c49..36bd970 100644 --- a/frontend/src/pages/AlphaPage.tsx +++ b/frontend/src/pages/AlphaPage.tsx @@ -184,6 +184,7 @@ export function AlphaPage({ const [filters, setFilters] = useState>({}); const [filterOpen, setFilterOpen] = useState(false); const [selectedViewId, setSelectedViewId] = useState(null); + const [submissionBlocked, setSubmissionBlocked] = useState(false); const [submission, setSubmission] = useState("UNSUBMITTED"); const [syncingScope, setSyncingScope] = useState(null); const [page, setPage] = useState(1); @@ -250,12 +251,13 @@ export function AlphaPage({ () => ({ ...filters, submission, + submission_blocked: submissionBlocked || undefined, sort, direction, limit: pageSize, offset: (page - 1) * pageSize, }), - [filters, submission, sort, direction, pageSize, page], + [filters, submission, submissionBlocked, sort, direction, pageSize, page], ); const query = queryString(params); useEffect(() => { @@ -286,6 +288,7 @@ export function AlphaPage({ limit: _limit, offset: _offset, submission: nextSubmission, + submission_blocked: nextBlocked, ...values } = action.filters; const next = Object.fromEntries( @@ -299,6 +302,7 @@ export function AlphaPage({ setSelected([]); if (nextSubmission === "SUBMITTED" || nextSubmission === "UNSUBMITTED") setSubmission(nextSubmission); + setSubmissionBlocked(nextBlocked === true || nextBlocked === "true"); if (nextSort) setSort(String(nextSort)); if (nextDirection) setDirection(String(nextDirection)); } @@ -352,7 +356,8 @@ export function AlphaPage({ setSelectedViewId(null); setFilterOpen(false); setDirection("desc"); - setSubmission(value as Submission); + setSubmission(value === "SUBMITTED" ? "SUBMITTED" : "UNSUBMITTED"); + setSubmissionBlocked(value === "SUBMISSION_BLOCKED"); setDraft({}); setFilters({}); setPage(1); @@ -925,17 +930,26 @@ export function AlphaPage({
{ setFilterOpen(false); const { submission: savedSubmission, + submission_blocked: savedBlocked, sort: savedSort, direction: savedDirection, limit, @@ -954,6 +968,9 @@ export function AlphaPage({ setSubmission( savedSubmission === "SUBMITTED" ? "SUBMITTED" : "UNSUBMITTED", ); + setSubmissionBlocked( + savedBlocked === true || savedBlocked === "true", + ); setSort(String(savedSort || "date_created")); setDirection(String(savedDirection || "desc")); if (typeof limit === "number") setPageSize(limit); @@ -1228,7 +1245,12 @@ export function AlphaPage({