diff --git a/.scratch/research-deletion/issues/01-delete-research.md b/.scratch/research-deletion/issues/01-delete-research.md new file mode 100644 index 0000000..7e0ef01 --- /dev/null +++ b/.scratch/research-deletion/issues/01-delete-research.md @@ -0,0 +1,14 @@ +# 研究素材和 Alpha 变体删除 +Type: task +Status: resolved + +为模板工坊、特征工程、Alpha 变体的列表和详情补齐删除操作。沿用素材软删除,生成记录新增软删除标记;移出列表时保留不可变候选、历史版本、回测和来源引用。删除需要页面确认,素材删除使用当前版本防止覆盖并发修改;删除后刷新列表并修正空页。 + +验证:删除/取消/冲突、历史引用保留、列表总数与分页、后端测试及静态检查、前端构建和浏览器交互。只使用隔离测试数据,不删除实际研究数据、不部署。 + +## Answer +已实现素材与生成记录的列表、详情删除和确认,删除后清理当前选择并刷新/修正分页。素材删除与保存均拒绝已删除状态及过期版本;生成记录不可变,重复删除幂等。删除仅移出日常列表,明确引用的历史版本仍可供来源追溯、复用和已授权运行使用,不取消已有回测。 + +验证:`uv run ruff check app tests` 通过;研究删除、工作空间、结果、流水线、QuantFlow 相关后端测试 82 passed;前端 `pnpm build` 通过(已有 lottie-web eval 和大包提示)。现有 research-outcomes / research-flows / quantflow 浏览器回归 3 passed。另用 Playwright CLI 和隔离合成库实测三处列表/详情删除、取消、26→25→24 总数、最后一页自动回退、模板并发删除 409 与未保存草稿保留;已查看 `output/playwright/research-delete-confirm.png`。 + +新增迁移 `0014`:隔离 SQLite 上升级、降级、再升级及旧记录保留已验证。Docker 服务未运行,未做 PostgreSQL 实例迁移实测。未部署、未迁移正式数据库、未删除真实数据。 diff --git a/.scratch/semi-tables/issues/01-migrate.md b/.scratch/semi-tables/issues/01-migrate.md new file mode 100644 index 0000000..b9b19c9 --- /dev/null +++ b/.scratch/semi-tables/issues/01-migrate.md @@ -0,0 +1,23 @@ +# 统一业务表格为 Semi Table + +Type: task +Status: ready-for-agent +Progress: completed + +## Scope + +将 11 个文件内的 13 处原生业务表格替换为项目已安装的 Semi 2.103.0 Table。保留筛选、分页、操作、候选选择和详情联动;删除按钮继续仅显示“删除”。AI Markdown 内容的渲染器保持原样。 + +## Validation + +运行前端构建与现有浏览器回归,并补充关键表格、空状态和滚动的浏览器核验。 + +## Result + +13 处业务表格全部改用 Semi Table,移除原生表格的样式覆盖和外置空提示。保留外部分页、稳定行标识和单元格操作;研究列表及首页使用原生 sticky 表头。 + +验证:`pnpm build` 通过,`pnpm test` 28 项全部通过,`git diff --check` 通过。静态检索 `frontend/src` 未发现手写 table/thead/tbody/tr/td/th。 + +隔离浏览器补充核验通过:模板表头滚动保持位置;26 条跨页显示,取消删除保留数据,删除第 2 页唯一条目后回到第 1 页显示 25 条;筛选空状态无重复;390px 横向滚动和页面宽度正常。首页 6 条 Pyramid 数据及筛选、算子详情联动、AI Alpha 结果 5 个 Semi Table 和窄屏宽度均通过。 + +构建保留第三方 lottie-web eval 与大 bundle 提示,未影响构建成功。未部署或提交。截图位于 `output/playwright/semi-*.png`。 diff --git a/backend/app/models.py b/backend/app/models.py index af4adfe..9fed18b 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -13,6 +13,7 @@ from sqlalchemy import ( String, Text, UniqueConstraint, + false, ) from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column @@ -443,6 +444,7 @@ class ResearchExperiment(Base): id: Mapped[str] = mapped_column(String(36), primary_key=True) name: Mapped[str] = mapped_column(String(200)) kind: Mapped[str] = mapped_column(String(30), index=True) + archived: Mapped[bool] = mapped_column(Boolean, default=False, server_default=false()) hypothesis: Mapped[str] = mapped_column(Text) inputs: Mapped[list] = mapped_column(JSON) parents: Mapped[list] = mapped_column(JSON) diff --git a/backend/app/research/assets.py b/backend/app/research/assets.py index 6fa4b24..66b0bfe 100644 --- a/backend/app/research/assets.py +++ b/backend/app/research/assets.py @@ -76,11 +76,12 @@ class Assets: ResearchAsset.id == asset_id, ResearchAsset.version == body.version, ResearchAsset.kind == body.kind, + ResearchAsset.archived.is_(False), ) .values(version=body.version + 1, name=content["name"], updated_at=now()) ) if changed.rowcount != 1: - raise HTTPException(409, "素材已变化,保留草稿并读取最新版本") + raise HTTPException(409, "素材已变化或已删除,保留草稿并读取最新版本") version = body.version + 1 else: asset_id, version = uid(), 1 @@ -100,11 +101,15 @@ class Assets: async def archive(self, asset_id, version): result = await self.db.execute( update(ResearchAsset) - .where(ResearchAsset.id == asset_id, ResearchAsset.version == version) + .where( + ResearchAsset.id == asset_id, + ResearchAsset.version == version, + ResearchAsset.archived.is_(False), + ) .values(archived=True, version=ResearchAsset.version + 1, updated_at=now()) ) if result.rowcount != 1: - raise HTTPException(409, "素材已变化或不存在") + raise HTTPException(409, "素材已变化、已删除或不存在,请刷新后重试") # Archiving is itself a revision; old references remain resolvable. previous = await self.db.get(ResearchRevision, (asset_id, version)) self.db.add( diff --git a/backend/app/research/experiments.py b/backend/app/research/experiments.py index 526802c..7431570 100644 --- a/backend/app/research/experiments.py +++ b/backend/app/research/experiments.py @@ -5,7 +5,7 @@ import json from collections import defaultdict from fastapi import HTTPException -from sqlalchemy import func, select +from sqlalchemy import func, select, update from ..backtests.contracts import Candidate, DraftInput, PreviewInput, SimulationSettings, Source from ..backtests.service import Backtests, uid @@ -243,6 +243,7 @@ class Experiments: "id", "name", "kind", + "archived", "hypothesis", "inputs", "parents", @@ -256,7 +257,7 @@ class Experiments: ) async def list(self, kind=None, limit=25, offset=0): - query = select(ResearchExperiment) + query = select(ResearchExperiment).where(ResearchExperiment.archived.is_(False)) if kind: query = query.where(ResearchExperiment.kind == kind) total = await self.db.scalar(select(func.count()).select_from(query.subquery())) @@ -281,6 +282,19 @@ class Experiments: } ) + async def archive(self, experiment_id): + """Hide an immutable experiment; backtests and lineage must still resolve it. + + Returns an acknowledgement, or raises HTTP 404 for an unknown ID. Repeated + deletion is idempotent because candidate contents cannot change. + """ + result = await self.db.execute( + update(ResearchExperiment).where(ResearchExperiment.id == experiment_id).values(archived=True) + ) + if result.rowcount != 1: + raise HTTPException(404, "研究实验不存在") + return {"ok": True} + async def preview(self, experiment_id, candidate_ids=None, source_kind=None, reference=None): experiment = await self.get(experiment_id) candidates = experiment["candidates"] diff --git a/backend/app/research/routes.py b/backend/app/research/routes.py index 3ac0ba5..983b5b2 100644 --- a/backend/app/research/routes.py +++ b/backend/app/research/routes.py @@ -143,6 +143,12 @@ async def experiment(experiment_id: str, request: Request): return await Experiments(db).get(experiment_id) +@router.delete("/experiments/{experiment_id}") +async def archive_experiment(experiment_id: str, request: Request): + async with request.app.state.sessions.begin() as db: + return await Experiments(db).archive(experiment_id) + + @router.post("/experiments/{experiment_id}/preview", status_code=201) async def preview(experiment_id: str, body: ExperimentPreview, request: Request): async with request.app.state.sessions.begin() as db: diff --git a/backend/migrations/versions/0014_research_experiment_archive.py b/backend/migrations/versions/0014_research_experiment_archive.py new file mode 100644 index 0000000..b52dc76 --- /dev/null +++ b/backend/migrations/versions/0014_research_experiment_archive.py @@ -0,0 +1,20 @@ +"""Hide deleted research experiments while preserving candidate and source history.""" + +import sqlalchemy as sa +from alembic import op + +revision = "0014" +down_revision = "0013" +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column( + "research_experiments", + sa.Column("archived", sa.Boolean(), nullable=False, server_default=sa.false()), + ) + + +def downgrade(): + op.drop_column("research_experiments", "archived") diff --git a/backend/tests/test_research_deletion.py b/backend/tests/test_research_deletion.py new file mode 100644 index 0000000..3dba6c7 --- /dev/null +++ b/backend/tests/test_research_deletion.py @@ -0,0 +1,121 @@ +"""Deleting workspace items hides them without severing research provenance.""" + +import importlib.util +from pathlib import Path + +import pytest +import sqlalchemy as sa +from alembic.migration import MigrationContext +from alembic.operations import Operations + +from tests.test_backtests import execute, setup, start +from tests.test_catalog import catalog as catalog_fixture +from tests.test_research_workspace import expansion, template +from tests.test_research_workspace import research_input as research_input_fixture + +catalog = catalog_fixture +research_input = research_input_fixture + + +@pytest.mark.parametrize("kind", ["template", "feature"]) +async def test_asset_deletion_checks_version_and_preserves_revisions(logged_in, research_input, kind): + content = template() if kind == "template" else { + "name": "待删除特征", + "hypothesis": "排名比较", + "input_ids": [research_input["id"]], + "template": template(), + } + response = await logged_in.post("/api/v1/research/assets", json={"kind": kind, "content": content}) + assert response.status_code == 201, response.text + original = response.json() + url = f"/api/v1/research/assets/{original['id']}" + updated = await logged_in.put(url, json={"kind": kind, "version": 1, "content": {**content, "name": "新版"}}) + assert updated.status_code == 200 + assert (await logged_in.delete(f"{url}?version=1")).status_code == 409 + assert not (await logged_in.get(url)).json()["archived"] + assert (await logged_in.delete(f"{url}?version=2")).status_code == 200 + listing = (await logged_in.get(f"/api/v1/research/assets?kind={kind}")).json() + assert listing["total"] == 0 and listing["items"] == [] + historical = (await logged_in.get(f"{url}?version=1")).json() + assert historical["archived"] is True + assert historical["content"] == original["content"] + assert historical["provenance"] == original["provenance"] + latest = (await logged_in.get(url)).json() + assert latest["version"] == 3 + assert (await logged_in.delete(f"{url}?version=3")).status_code == 409 + assert (await logged_in.put(url, json={"kind": kind, "version": 3, "content": content})).status_code == 409 + assert (await logged_in.get(url)).json()["version"] == 3 + + +async def test_experiment_deletion_preserves_backtest_and_child_sources(app, logged_in, research_input): + created = await logged_in.post("/api/v1/research/experiments", json=expansion(research_input["id"])) + assert created.status_code == 201, created.text + parent = created.json() + generated = await logged_in.post( + "/api/v1/research/experiments", + json=expansion(research_input["id"], parent_experiment_ids=[parent["id"]]), + ) + assert generated.status_code == 201, generated.text + variant = generated.json() + assert variant["kind"] == "variant" + child = await logged_in.post( + "/api/v1/research/experiments", + json=expansion(research_input["id"], parent_experiment_ids=[variant["id"]]), + ) + assert child.status_code == 201 + _, lane = await setup(app) + url = f"/api/v1/research/experiments/{variant['id']}" + preview = await logged_in.post(f"{url}/preview", json={}) + assert preview.status_code == 201, preview.text + run = await start(logged_in, preview.json(), "delete-variant-provenance") + await execute(app, lane, run["backtest_run_id"]) + before = (await logged_in.get(url)).json() + assert before["backtest_run_ids"] == [run["backtest_run_id"]] + + assert (await logged_in.delete(url)).status_code == 200 + assert (await logged_in.delete(url)).status_code == 200 + all_items = (await logged_in.get("/api/v1/research/experiments")).json() + assert all_items["total"] == 2 + assert variant["id"] not in {item["id"] for item in all_items["items"]} + variants = (await logged_in.get("/api/v1/research/experiments?kind=variant&limit=1&offset=1")).json() + assert variants["total"] == 1 and variants["items"] == [] + after = (await logged_in.get(url)).json() + assert after == {**before, "archived": True} + saved_child = (await logged_in.get(f"/api/v1/research/experiments/{child.json()['id']}")).json() + assert saved_child["parents"] == child.json()["parents"] + results = (await logged_in.get(f"/api/v1/backtests/runs/{run['backtest_run_id']}/results")).json() + alpha_id = results["items"][0]["alpha_id"] + sources = (await logged_in.get(f"/api/v1/alphas/{alpha_id}/sources")).json() + assert sources["items"][0]["source"]["research_id"] == variant["id"] + assert (await logged_in.delete(f"/api/v1/research/experiments/{parent['id']}")).status_code == 200 + assert (await logged_in.get("/api/v1/research/experiments")).json()["total"] == 1 + + +async def test_delete_research_requires_auth_and_known_id(client): + for url in ("assets/missing?version=1", "experiments/missing"): + assert (await client.delete(f"/api/v1/research/{url}")).status_code == 401 + + +async def test_delete_unknown_experiment(logged_in): + assert (await logged_in.delete("/api/v1/research/experiments/missing")).status_code == 404 + + +def test_archive_migration_preserves_existing_rows(tmp_path): + path = Path(__file__).parents[1] / "migrations/versions/0014_research_experiment_archive.py" + spec = importlib.util.spec_from_file_location("research_archive_migration", path) + migration = importlib.util.module_from_spec(spec) + spec.loader.exec_module(migration) + engine = sa.create_engine(f"sqlite:///{tmp_path}/migration.db") + with engine.begin() as connection: + connection.execute(sa.text("CREATE TABLE research_experiments (id VARCHAR(36) PRIMARY KEY, evidence JSON NOT NULL)")) + connection.execute(sa.text("INSERT INTO research_experiments VALUES ('existing', :evidence)"), {"evidence": '{"source":"old"}'}) + with Operations.context(MigrationContext.configure(connection)): + migration.upgrade() + assert connection.execute(sa.text("SELECT archived FROM research_experiments")).scalar_one() == 0 + connection.execute(sa.text("UPDATE research_experiments SET archived = 1")) + migration.downgrade() + migration.upgrade() + assert connection.execute(sa.text("SELECT evidence FROM research_experiments")).scalar_one() == '{"source":"old"}' + column = next(c for c in sa.inspect(connection).get_columns("research_experiments") if c["name"] == "archived") + assert column["nullable"] is False + engine.dispose() diff --git a/frontend/src/ai/AlphaToolCard.tsx b/frontend/src/ai/AlphaToolCard.tsx index c136a37..a32e928 100644 --- a/frontend/src/ai/AlphaToolCard.tsx +++ b/frontend/src/ai/AlphaToolCard.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from "react"; -import { Toast } from "@douyinfe/semi-ui-19"; +import { Table, Toast } from "@douyinfe/semi-ui-19"; import { api, formatNumber, formatTime, stateLabels } from "../api"; import { PnlChart } from "../components/PnlChart"; import type { Alpha, Pnl, Research } from "../types"; @@ -62,26 +62,33 @@ export function AlphaToolCard({ call, onAction, timezone }: ToolCardProps) { {alpha.id} · {alpha.region ?? "地区未提供"} - - - - - - - - - - - - - - - -
SharpeFitnessTurnover
{formatNumber(alpha.sharpe)}{formatNumber(alpha.fitness)} - {alpha.turnover == null + + dataSource={[alpha]} + rowKey="id" + pagination={false} + size="small" + tableLayout="fixed" + columns={[ + { + title: "Sharpe", + key: "sharpe", + render: (_, alpha) => formatNumber(alpha.sharpe), + }, + { + title: "Fitness", + key: "fitness", + render: (_, alpha) => formatNumber(alpha.fitness), + }, + { + title: "Turnover", + key: "turnover", + render: (_, alpha) => + alpha.turnover == null ? "未提供" - : `${formatNumber(alpha.turnover * 100)}%`} -
+ : `${formatNumber(alpha.turnover * 100)}%`, + }, + ]} + /> 本地快照 · {formatTime(alpha.synced_at, timezone)} ))} diff --git a/frontend/src/ai/style.css b/frontend/src/ai/style.css index 41525dc..1e04be0 100644 --- a/frontend/src/ai/style.css +++ b/frontend/src/ai/style.css @@ -369,26 +369,10 @@ text-align: left; overflow-wrap: anywhere; } -.ai-alpha-results table { - width: 100%; - text-align: left; +.ai-alpha-results .semi-table { margin-top: var(--space-2); - border-collapse: collapse; - table-layout: fixed; font-variant-numeric: tabular-nums; } -.ai-alpha-results th, -.ai-alpha-results td { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - font-weight: 400; - padding: var(--space-1) 0; -} -.ai-alpha-results th { - color: var(--muted); - font-size: 12px; -} .ai-resize { position: absolute; left: -4px; diff --git a/frontend/src/pages/AccountPage.tsx b/frontend/src/pages/AccountPage.tsx index 171ac2f..c487d4d 100644 --- a/frontend/src/pages/AccountPage.tsx +++ b/frontend/src/pages/AccountPage.tsx @@ -5,6 +5,7 @@ import { Button, Input, Spin, + Table, Tag, Toast, } from "@douyinfe/semi-ui-19"; @@ -374,46 +375,56 @@ export function AccountPage({ ))}
- - - - - - - - - - - - {( - [ - ["submissions", "Alpha 提交"], - ["simulations", "回测"], - ] as const - ).map(([key, label]) => ( - - - - - - - - ))} - -
用量类型今日昨日每日上限剩余额度
{label} - {usage?.[key]?.today == null - ? "尚无当日记录" - : count(usage[key]?.today)} - + + className="usage-table" + dataSource={[ + { key: "submissions", label: "Alpha 提交" }, + { key: "simulations", label: "回测" }, + ]} + rowKey="key" + pagination={false} + size="small" + style={{ minWidth: 640 }} + columns={[ + { title: "用量类型", dataIndex: "label", width: 120 }, + { + title: "今日", + key: "today", + render: (_, { key }) => + usage?.[key]?.today == null + ? "尚无当日记录" + : count(usage[key]?.today), + }, + { + title: "昨日", + key: "yesterday", + render: (_, { key }) => ( + {count(usage?.[key]?.yesterday)} - + + ), + }, + { + title: "每日上限", + key: "limit", + render: (_, { key }) => ( + <> {usage?.[key]?.limit == null ? "平台未返回" : count(usage[key]?.limit)} {key === "simulations" && usage?.simulations?.limit != null && "(本地设定)"} - {count(usage?.[key]?.remaining)}
+ + ), + }, + { + title: "剩余额度", + key: "remaining", + render: (_, { key }) => count(usage?.[key]?.remaining), + }, + ]} + />

回测按本地每日额度计算剩余次数;活动统计可能延迟,当日次数未知时不估算余额。 diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx index 3430e25..eae76c2 100644 --- a/frontend/src/pages/HomePage.tsx +++ b/frontend/src/pages/HomePage.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from "react"; -import { Banner, Button, Input, Spin } from "@douyinfe/semi-ui-19"; +import { Banner, Button, Input, Spin, Table } from "@douyinfe/semi-ui-19"; import { api, formatTime } from "../api"; import type { Account } from "../types"; import "./home.css"; @@ -312,27 +312,50 @@ function PyramidPanel({

- - - - - - - - - - - {rows.map((row, index) => ( - - - - - - - ))} - -
地区延迟数据类别{metric}
{row.region}D{row.delay} + + dataSource={rows} + rowKey={(row) => + row ? `${row.region}:${row.delay}:${row.category_id}` : "" + } + pagination={false} + size="small" + sticky + style={{ minWidth: 440 }} + empty={ + state.data.items.length + ? "没有匹配的 Pyramid 类别。" + : "平台暂未返回 Pyramid 数据。" + } + columns={[ + { + title: "地区", + key: "region", + width: 64, + render: (_, row) => row.region, + }, + { + title: "延迟", + key: "delay", + width: 64, + render: (_, row) => <>D{row.delay}, + }, + { + title: "数据类别", + key: "category", + render: (_, row) => ( + <> {row.category_name} {row.category_id} - + + ), + }, + { + title: metric, + key: "value", + width: 110, + align: "right", + render: (_, row) => ( + <>
{row.value === null ? "—" @@ -345,18 +368,11 @@ function PyramidPanel({ }} />
-
- {!rows.length && ( -
- {state.data.items.length - ? "没有匹配的 Pyramid 类别。" - : "平台暂未返回 Pyramid 数据。"} -
- )} + + ), + }, + ]} + />