Compare commits
2 Commits
8981c24fda
...
8206b36cd2
| Author | SHA1 | Date | |
|---|---|---|---|
| 8206b36cd2 | |||
| ed349693db |
@@ -0,0 +1,12 @@
|
|||||||
|
# 实现与验证
|
||||||
|
Type: task
|
||||||
|
Status: ready-for-agent
|
||||||
|
|
||||||
|
- [x] 共用表格、工具栏、图标刷新
|
||||||
|
- [x] 服务端名称筛选与排序
|
||||||
|
- [x] 后端与浏览器回归、构建
|
||||||
|
|
||||||
|
## Comments
|
||||||
|
用户授权本地实现,未要求本次提交或推送。
|
||||||
|
|
||||||
|
本地实现完成。后端 20 项测试及 ruff 检查通过,前端构建通过;4 项浏览器用例通过,轮询慢请求保护修改后工具栏用例再次通过。验证了名称/来源筛选、排序回到第一页、手动刷新、列显隐、40px 行高、桌面与窄屏固定分页,以及原有草稿/预览/运行和 MCP 来源流程。使用模拟平台,无真实回测请求。构建保留已有依赖 eval 与包体积警告。
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# 回测研究表格交互
|
||||||
|
运行记录与候选草稿复用目录工具栏和 WorkspaceTable:筛选浮层、列显隐持久化、服务端名称/时间排序、图标刷新与调度设置、固定分页及 25/50/100 条容量。保留自动刷新和现有候选/运行详情操作;手动刷新当前视图,避免旧请求覆盖新筛选。
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
"""Authenticated adapters; every mutation is committed before the execution lane wakes."""
|
"""Authenticated adapters; every mutation is committed before the execution lane wakes."""
|
||||||
|
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query, Request
|
from fastapi import APIRouter, Depends, Query, Request
|
||||||
|
|
||||||
from ..business import Business
|
from ..business import Business
|
||||||
@@ -48,9 +50,16 @@ async def configure(body: SchedulerInput, request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/drafts")
|
@router.get("/drafts")
|
||||||
async def drafts(request: Request, limit: int = Query(25, ge=1, le=100), offset: int = Query(0, ge=0)):
|
async def drafts(
|
||||||
|
request: Request,
|
||||||
|
limit: int = Query(25, ge=1, le=100),
|
||||||
|
offset: int = Query(0, ge=0),
|
||||||
|
q: str = Query("", max_length=200),
|
||||||
|
sort: Literal["name", "updated_at"] = "updated_at",
|
||||||
|
direction: Literal["asc", "desc"] = "desc",
|
||||||
|
):
|
||||||
async with request.app.state.sessions() as db:
|
async with request.app.state.sessions() as db:
|
||||||
return await Business(db).backtests.drafts(limit, offset)
|
return await Business(db).backtests.drafts(limit, offset, q, sort, direction)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/drafts", status_code=201)
|
@router.post("/drafts", status_code=201)
|
||||||
@@ -108,9 +117,14 @@ async def runs(
|
|||||||
source: str | None = Query(None, max_length=100),
|
source: str | None = Query(None, max_length=100),
|
||||||
reference: str | None = Query(None, max_length=200),
|
reference: str | None = Query(None, max_length=200),
|
||||||
research_id: str | None = Query(None, max_length=200),
|
research_id: str | None = Query(None, max_length=200),
|
||||||
|
q: str = Query("", max_length=200),
|
||||||
|
sort: Literal["name", "created_at"] = "created_at",
|
||||||
|
direction: Literal["asc", "desc"] = "desc",
|
||||||
):
|
):
|
||||||
async with request.app.state.sessions() as db:
|
async with request.app.state.sessions() as db:
|
||||||
return await Business(db).backtests.runs(limit, offset, source, reference, research_id)
|
return await Business(db).backtests.runs(
|
||||||
|
limit, offset, source, reference, research_id, q, sort, direction
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/sources", response_model=list[str])
|
@router.get("/sources", response_model=list[str])
|
||||||
|
|||||||
@@ -151,11 +151,16 @@ class Backtests:
|
|||||||
await self.db.flush()
|
await self.db.flush()
|
||||||
return await self.draft(draft_id)
|
return await self.draft(draft_id)
|
||||||
|
|
||||||
async def drafts(self, limit=25, offset=0):
|
async def drafts(self, limit=25, offset=0, q="", sort="updated_at", direction="desc"):
|
||||||
|
query = select(BacktestDraft)
|
||||||
|
if q:
|
||||||
|
query = query.where(BacktestDraft.name.contains(q, autoescape=True))
|
||||||
|
column = {"name": BacktestDraft.name, "updated_at": BacktestDraft.updated_at}[sort]
|
||||||
|
order = column.asc() if direction == "asc" else column.desc()
|
||||||
rows = (
|
rows = (
|
||||||
await self.db.scalars(
|
await self.db.scalars(
|
||||||
select(BacktestDraft)
|
query
|
||||||
.order_by(BacktestDraft.updated_at.desc(), BacktestDraft.id)
|
.order_by(order, BacktestDraft.id)
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
.offset(offset)
|
.offset(offset)
|
||||||
)
|
)
|
||||||
@@ -173,7 +178,7 @@ class Backtests:
|
|||||||
)
|
)
|
||||||
for r in rows
|
for r in rows
|
||||||
],
|
],
|
||||||
"total": await self.db.scalar(select(func.count()).select_from(BacktestDraft)),
|
"total": await self.db.scalar(select(func.count()).select_from(query.subquery())),
|
||||||
"limit": limit,
|
"limit": limit,
|
||||||
"offset": offset,
|
"offset": offset,
|
||||||
}
|
}
|
||||||
@@ -355,15 +360,19 @@ class Backtests:
|
|||||||
await self.db.flush()
|
await self.db.flush()
|
||||||
return await self.run(run.id)
|
return await self.run(run.id)
|
||||||
|
|
||||||
async def runs(self, limit=25, offset=0, source=None, reference=None, research_id=None):
|
async def runs(self, limit=25, offset=0, source=None, reference=None, research_id=None, q="", sort="created_at", direction="desc"):
|
||||||
query = select(BacktestRun)
|
query = select(BacktestRun)
|
||||||
|
if q:
|
||||||
|
query = query.where(BacktestRun.name.contains(q, autoescape=True))
|
||||||
|
column = {"name": BacktestRun.name, "created_at": BacktestRun.created_at}[sort]
|
||||||
|
order = column.asc() if direction == "asc" else column.desc()
|
||||||
for key, value in (("kind", source), ("reference", reference), ("research_id", research_id)):
|
for key, value in (("kind", source), ("reference", reference), ("research_id", research_id)):
|
||||||
if value:
|
if value:
|
||||||
query = query.where(BacktestRun.source[key].as_string() == value)
|
query = query.where(BacktestRun.source[key].as_string() == value)
|
||||||
total = await self.db.scalar(select(func.count()).select_from(query.subquery()))
|
total = await self.db.scalar(select(func.count()).select_from(query.subquery()))
|
||||||
rows = (
|
rows = (
|
||||||
await self.db.scalars(
|
await self.db.scalars(
|
||||||
query.order_by(BacktestRun.created_at.desc(), BacktestRun.id).limit(limit).offset(offset)
|
query.order_by(order, BacktestRun.id).limit(limit).offset(offset)
|
||||||
)
|
)
|
||||||
).all()
|
).all()
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""List filtering and ordering happen before pagination, without platform execution."""
|
||||||
|
|
||||||
|
from tests.test_backtests import PREFIX, candidate, setup, start
|
||||||
|
|
||||||
|
|
||||||
|
async def test_list_sorting(app, logged_in):
|
||||||
|
await setup(app)
|
||||||
|
for name in ["sort-C", "sort-A", "sort-B", "unrelated"]:
|
||||||
|
body = {"name": name, "source": {"kind": "test"}, "candidates": [candidate()]}
|
||||||
|
assert (await logged_in.post(f"{PREFIX}/drafts", json=body)).status_code == 201
|
||||||
|
preview = await logged_in.post(f"{PREFIX}/previews", json={"inline": body})
|
||||||
|
assert preview.status_code == 201
|
||||||
|
await start(logged_in, preview.json(), key=name)
|
||||||
|
for path in ["runs", "drafts"]:
|
||||||
|
for direction, expected in [
|
||||||
|
("asc", ["sort-A", "sort-B", "sort-C"]),
|
||||||
|
("desc", ["sort-C", "sort-B", "sort-A"]),
|
||||||
|
]:
|
||||||
|
names = []
|
||||||
|
for offset in range(3):
|
||||||
|
r = await logged_in.get(
|
||||||
|
f"{PREFIX}/{path}",
|
||||||
|
params={
|
||||||
|
"q": "sort-",
|
||||||
|
"sort": "name",
|
||||||
|
"direction": direction,
|
||||||
|
"limit": 1,
|
||||||
|
"offset": offset,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["total"] == 3
|
||||||
|
names.append(r.json()["items"][0]["name"])
|
||||||
|
assert names == expected
|
||||||
|
for params in [{"sort": "unknown"}, {"direction": "unknown"}]:
|
||||||
|
assert (await logged_in.get(f"{PREFIX}/{path}", params=params)).status_code == 422
|
||||||
|
assert (await logged_in.get(f"{PREFIX}/{path}", params={"q": "%"})).json()["total"] == 0
|
||||||
|
assert (await logged_in.get(f"{PREFIX}/runs", params={"q": "sort-", "source": "missing"})).json()[
|
||||||
|
"total"
|
||||||
|
] == 0
|
||||||
@@ -1,3 +1,11 @@
|
|||||||
|
import {
|
||||||
|
CatalogTableToolbar,
|
||||||
|
CatalogIconAction,
|
||||||
|
useCatalogColumns,
|
||||||
|
} from "../components/CatalogTableToolbar";
|
||||||
|
import { WorkspaceTable } from "../components/WorkspaceTable";
|
||||||
|
import { IconRefresh, IconSetting, IconPlus } from "@douyinfe/semi-icons";
|
||||||
|
import type { ColumnProps } from "@douyinfe/semi-ui-19/lib/es/table/interface";
|
||||||
import { SimulationSettingsEditor } from "../settings/SimulationSettingsEditor";
|
import { SimulationSettingsEditor } from "../settings/SimulationSettingsEditor";
|
||||||
import { useSettingsOptions } from "../settings/useSettingsOptions";
|
import { useSettingsOptions } from "../settings/useSettingsOptions";
|
||||||
import { settingsErrors } from "../settings/model";
|
import { settingsErrors } from "../settings/model";
|
||||||
@@ -6,7 +14,7 @@ import {
|
|||||||
researchSelection,
|
researchSelection,
|
||||||
} from "../preparations/ResearchDataInput";
|
} from "../preparations/ResearchDataInput";
|
||||||
import type { InputSnapshot } from "../research/workspaceTypes";
|
import type { InputSnapshot } from "../research/workspaceTypes";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Banner,
|
Banner,
|
||||||
Button,
|
Button,
|
||||||
@@ -19,7 +27,7 @@ import {
|
|||||||
Tag,
|
Tag,
|
||||||
TextArea,
|
TextArea,
|
||||||
} from "@douyinfe/semi-ui-19";
|
} from "@douyinfe/semi-ui-19";
|
||||||
import { api, formatNumber, formatTime, post } from "../api";
|
import { api, formatNumber, formatTime, post, queryString } from "../api";
|
||||||
import type { PageContext, UIAction } from "../ai/types";
|
import type { PageContext, UIAction } from "../ai/types";
|
||||||
import { controlLabels, initialSettings, labels } from "./types";
|
import { controlLabels, initialSettings, labels } from "./types";
|
||||||
import type {
|
import type {
|
||||||
@@ -82,6 +90,30 @@ export function BacktestPage({
|
|||||||
}) {
|
}) {
|
||||||
const [view, setView] = useState("runs");
|
const [view, setView] = useState("runs");
|
||||||
const [sourceFilter, setSourceFilter] = useState("");
|
const [sourceFilter, setSourceFilter] = useState("");
|
||||||
|
const [q, setQ] = useState("");
|
||||||
|
const [filterDraft, setFilterDraft] = useState({ q: "", source: "" });
|
||||||
|
const [pageSize, setPageSize] = useState(25);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const requestSequence = useRef(0);
|
||||||
|
const [sorting, setSorting] = useState({
|
||||||
|
runs: { value: "created_at", direction: "desc" },
|
||||||
|
drafts: { value: "updated_at", direction: "desc" },
|
||||||
|
});
|
||||||
|
const sort = sorting[view === "runs" ? "runs" : "drafts"];
|
||||||
|
const runColumns = useCatalogColumns("backtest-run-columns", [
|
||||||
|
{ key: "name", label: "运行", required: true },
|
||||||
|
{ key: "source", label: "来源" },
|
||||||
|
{ key: "status", label: "状态" },
|
||||||
|
{ key: "saved", label: "已保存 / 总数" },
|
||||||
|
{ key: "failed", label: "失败" },
|
||||||
|
{ key: "created_at", label: "创建时间" },
|
||||||
|
]);
|
||||||
|
const draftColumns = useCatalogColumns("backtest-draft-columns", [
|
||||||
|
{ key: "name", label: "候选草稿", required: true },
|
||||||
|
{ key: "total", label: "候选数" },
|
||||||
|
{ key: "version", label: "版本" },
|
||||||
|
{ key: "updated_at", label: "更新时间" },
|
||||||
|
]);
|
||||||
const [sourceKinds, setSourceKinds] = useState<string[]>([]);
|
const [sourceKinds, setSourceKinds] = useState<string[]>([]);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [runs, setRuns] = useState<Page<Run>>({
|
const [runs, setRuns] = useState<Page<Run>>({
|
||||||
@@ -139,30 +171,56 @@ export function BacktestPage({
|
|||||||
const [itemDetail, setItemDetail] = useState<Item | null>(null);
|
const [itemDetail, setItemDetail] = useState<Item | null>(null);
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
const [r, d, c, kinds] = await Promise.all([
|
const sequence = ++requestSequence.current;
|
||||||
api<Page<Run>>(
|
setLoading(true);
|
||||||
`/backtests/runs?offset=${(page - 1) * 25}&source=${encodeURIComponent(sourceFilter)}`,
|
try {
|
||||||
),
|
const params = queryString({
|
||||||
api<Page<DraftSummary>>(`/backtests/drafts?offset=${(page - 1) * 25}`),
|
q,
|
||||||
api<Scheduler>("/backtests/config"),
|
limit: pageSize,
|
||||||
api<string[]>("/backtests/sources"),
|
offset: (page - 1) * pageSize,
|
||||||
]);
|
sort: sort.value,
|
||||||
setRuns(r);
|
direction: sort.direction,
|
||||||
setDrafts(d);
|
...(view === "runs" ? { source: sourceFilter } : {}),
|
||||||
setConfig(c);
|
});
|
||||||
setSourceKinds(kinds);
|
const [data, c, kinds] = await Promise.all([
|
||||||
}, [page, sourceFilter]);
|
api<Page<Run> | Page<DraftSummary>>(`/backtests/${view}?${params}`),
|
||||||
|
api<Scheduler>("/backtests/config"),
|
||||||
|
api<string[]>("/backtests/sources"),
|
||||||
|
]);
|
||||||
|
if (sequence !== requestSequence.current) return;
|
||||||
|
if (view === "runs") setRuns(data as Page<Run>);
|
||||||
|
else setDrafts(data as Page<DraftSummary>);
|
||||||
|
setPage((p) =>
|
||||||
|
Math.min(p, Math.max(1, Math.ceil(data.total / pageSize))),
|
||||||
|
);
|
||||||
|
setConfig(c);
|
||||||
|
setSourceKinds(kinds);
|
||||||
|
setError("");
|
||||||
|
} finally {
|
||||||
|
if (sequence === requestSequence.current) setLoading(false);
|
||||||
|
}
|
||||||
|
}, [page, pageSize, sourceFilter, q, view, sort.value, sort.direction]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
let alive = true;
|
let alive = true;
|
||||||
const load = () =>
|
let pending = false;
|
||||||
refresh().catch((e) => {
|
const load = () => {
|
||||||
if (alive) setError(e.message);
|
// Slow responses must finish before the next polling request supersedes them.
|
||||||
});
|
if (pending) return;
|
||||||
|
pending = true;
|
||||||
|
void refresh()
|
||||||
|
.catch((e) => {
|
||||||
|
if (alive) setError(e.message);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
pending = false;
|
||||||
|
});
|
||||||
|
};
|
||||||
void load();
|
void load();
|
||||||
const timer = window.setInterval(load, 3000);
|
const timer = window.setInterval(load, 3000);
|
||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
|
requestSequence.current++;
|
||||||
clearInterval(timer);
|
clearInterval(timer);
|
||||||
};
|
};
|
||||||
}, [active, refresh, version]);
|
}, [active, refresh, version]);
|
||||||
@@ -345,55 +403,183 @@ export function BacktestPage({
|
|||||||
setRun(r);
|
setRun(r);
|
||||||
await refresh();
|
await refresh();
|
||||||
}
|
}
|
||||||
|
const runTableColumns: ColumnProps<Run>[] = [
|
||||||
|
{
|
||||||
|
title: "运行",
|
||||||
|
dataIndex: "name",
|
||||||
|
width: 240,
|
||||||
|
render: (value, r) => (
|
||||||
|
<Button
|
||||||
|
theme="borderless"
|
||||||
|
className="backtest-name-button"
|
||||||
|
title={String(value)}
|
||||||
|
onClick={() => openRun(r!.backtest_run_id)}
|
||||||
|
>
|
||||||
|
{String(value)}
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "来源",
|
||||||
|
width: 120,
|
||||||
|
render: (_, r) => sourceLabel(r!.source.kind),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "状态",
|
||||||
|
width: 160,
|
||||||
|
render: (_, r) => <BacktestStatusTag status={r!.status} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "已保存 / 总数",
|
||||||
|
width: 120,
|
||||||
|
render: (_, r) => `${r!.counts.persistence.saved || 0} / ${r!.total}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "失败",
|
||||||
|
width: 80,
|
||||||
|
render: (_, r) => r!.counts.platform.failed || 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "创建时间",
|
||||||
|
width: 180,
|
||||||
|
render: (_, r) => formatTime(r!.created_at, timezone),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const draftTableColumns: ColumnProps<DraftSummary>[] = [
|
||||||
|
{
|
||||||
|
title: "候选草稿",
|
||||||
|
dataIndex: "name",
|
||||||
|
width: 260,
|
||||||
|
render: (v, d) => (
|
||||||
|
<Button
|
||||||
|
theme="borderless"
|
||||||
|
className="backtest-name-button"
|
||||||
|
title={String(v)}
|
||||||
|
onClick={() => void perform(() => loadDraft(d!.id))}
|
||||||
|
>
|
||||||
|
{String(v)}
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ title: "候选数", dataIndex: "total", width: 100 },
|
||||||
|
{ title: "版本", dataIndex: "version", width: 80 },
|
||||||
|
{
|
||||||
|
title: "更新时间",
|
||||||
|
width: 180,
|
||||||
|
render: (_, d) => formatTime(d!.updated_at, timezone),
|
||||||
|
},
|
||||||
|
];
|
||||||
const drawerVisible = active && !suspended;
|
const drawerVisible = active && !suspended;
|
||||||
return (
|
return (
|
||||||
<section className="backtest-page">
|
<section className="backtest-page">
|
||||||
<div className="backtest-toolbar">
|
<CatalogTableToolbar
|
||||||
<Select
|
label="回测研究"
|
||||||
aria-label="回测视图"
|
active={active && !suspended && !editor && !runId && !settingsDraft}
|
||||||
value={view}
|
scope={
|
||||||
onChange={(v) => {
|
|
||||||
setView(String(v));
|
|
||||||
setPage(1);
|
|
||||||
}}
|
|
||||||
optionList={[
|
|
||||||
{ value: "runs", label: "运行记录" },
|
|
||||||
{ value: "drafts", label: "候选草稿" },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<span className="muted">REGULAR · FASTEXPR</span>
|
|
||||||
{view === "runs" && (
|
|
||||||
<Select
|
<Select
|
||||||
aria-label="回测研究来源筛选"
|
aria-label="回测视图"
|
||||||
value={sourceFilter || undefined}
|
value={view}
|
||||||
placeholder="全部研究来源"
|
onChange={(v) => {
|
||||||
showClear
|
setView(String(v));
|
||||||
optionList={sourceKinds.map((kind) => ({
|
|
||||||
value: kind,
|
|
||||||
label: sourceLabel(kind),
|
|
||||||
}))}
|
|
||||||
onChange={(value) => {
|
|
||||||
setSourceFilter(String(value ?? ""));
|
|
||||||
setPage(1);
|
setPage(1);
|
||||||
|
setQ("");
|
||||||
|
setSourceFilter("");
|
||||||
|
setFilterDraft({ q: "", source: "" });
|
||||||
}}
|
}}
|
||||||
|
optionList={[
|
||||||
|
{ value: "runs", label: "运行记录" },
|
||||||
|
{ value: "drafts", label: "候选草稿" },
|
||||||
|
]}
|
||||||
/>
|
/>
|
||||||
)}
|
}
|
||||||
</div>
|
columns={view === "runs" ? runColumns : draftColumns}
|
||||||
<div className="backtest-toolbar">
|
filterCount={
|
||||||
<Button
|
Number(Boolean(q)) + Number(view === "runs" && Boolean(sourceFilter))
|
||||||
type="tertiary"
|
}
|
||||||
onClick={() => {
|
filters={
|
||||||
setSettingsDraft(config);
|
<div className="catalog-filter-grid">
|
||||||
setRunId("");
|
<label>
|
||||||
setEditor(false);
|
名称
|
||||||
}}
|
<Input
|
||||||
>
|
aria-label="回测名称筛选"
|
||||||
调度设置
|
value={filterDraft.q}
|
||||||
</Button>
|
maxLength={200}
|
||||||
<Button theme="solid" onClick={newDraft}>
|
onChange={(q) => setFilterDraft({ ...filterDraft, q })}
|
||||||
新建回测
|
/>
|
||||||
</Button>
|
</label>
|
||||||
</div>
|
{view === "runs" && (
|
||||||
|
<label>
|
||||||
|
研究来源
|
||||||
|
<Select
|
||||||
|
aria-label="回测研究来源筛选"
|
||||||
|
value={filterDraft.source || undefined}
|
||||||
|
placeholder="全部研究来源"
|
||||||
|
showClear
|
||||||
|
optionList={sourceKinds.map((kind) => ({
|
||||||
|
value: kind,
|
||||||
|
label: sourceLabel(kind),
|
||||||
|
}))}
|
||||||
|
onChange={(v) =>
|
||||||
|
setFilterDraft({ ...filterDraft, source: String(v ?? "") })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
onApply={() => {
|
||||||
|
setQ(filterDraft.q);
|
||||||
|
setSourceFilter(filterDraft.source);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
onReset={() => {
|
||||||
|
setQ("");
|
||||||
|
setSourceFilter("");
|
||||||
|
setFilterDraft({ q: "", source: "" });
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
sort={{
|
||||||
|
...sort,
|
||||||
|
options: [
|
||||||
|
{ value: "name", label: "名称" },
|
||||||
|
{
|
||||||
|
value: view === "runs" ? "created_at" : "updated_at",
|
||||||
|
label: view === "runs" ? "创建时间" : "更新时间",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
onChange: (value, direction) => {
|
||||||
|
setSorting((old) => ({ ...old, [view]: { value, direction } }));
|
||||||
|
setPage(1);
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
<CatalogIconAction
|
||||||
|
label="刷新回测"
|
||||||
|
icon={<IconRefresh aria-hidden="true" />}
|
||||||
|
loading={loading}
|
||||||
|
onClick={() => void perform(refresh)}
|
||||||
|
/>
|
||||||
|
<CatalogIconAction
|
||||||
|
label="调度设置"
|
||||||
|
icon={<IconSetting aria-hidden="true" />}
|
||||||
|
disabled={!config}
|
||||||
|
onClick={() => {
|
||||||
|
setSettingsDraft(config);
|
||||||
|
setRunId("");
|
||||||
|
setEditor(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
theme="solid"
|
||||||
|
icon={<IconPlus aria-hidden="true" />}
|
||||||
|
onClick={newDraft}
|
||||||
|
>
|
||||||
|
新建回测
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
{error && !editor && !runId && !settingsDraft && (
|
{error && !editor && !runId && !settingsDraft && (
|
||||||
<Banner type="danger" description={error} />
|
<Banner type="danger" description={error} />
|
||||||
)}
|
)}
|
||||||
@@ -401,7 +587,8 @@ export function BacktestPage({
|
|||||||
<Banner type="warning" description={config.blocked_reason} />
|
<Banner type="warning" description={config.blocked_reason} />
|
||||||
)}
|
)}
|
||||||
{view === "runs" ? (
|
{view === "runs" ? (
|
||||||
<Table<Run>
|
<WorkspaceTable<Run>
|
||||||
|
fill
|
||||||
className="alpha-table"
|
className="alpha-table"
|
||||||
size="small"
|
size="small"
|
||||||
rowKey="backtest_run_id"
|
rowKey="backtest_run_id"
|
||||||
@@ -409,52 +596,13 @@ export function BacktestPage({
|
|||||||
scroll={{ x: 900, y: "100%" }}
|
scroll={{ x: 900, y: "100%" }}
|
||||||
dataSource={runs.items}
|
dataSource={runs.items}
|
||||||
empty="尚无回测运行,准备候选并预览后启动。"
|
empty="尚无回测运行,准备候选并预览后启动。"
|
||||||
columns={[
|
columns={runTableColumns.filter((_, i) =>
|
||||||
{
|
runColumns.visible.includes(runColumns.choices[i].key),
|
||||||
title: "运行",
|
)}
|
||||||
dataIndex: "name",
|
|
||||||
width: 240,
|
|
||||||
render: (value, r) => (
|
|
||||||
<Button
|
|
||||||
theme="borderless"
|
|
||||||
className="backtest-name-button"
|
|
||||||
title={String(value)}
|
|
||||||
onClick={() => openRun(r!.backtest_run_id)}
|
|
||||||
>
|
|
||||||
{String(value)}
|
|
||||||
</Button>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "来源",
|
|
||||||
width: 120,
|
|
||||||
render: (_, r) => sourceLabel(r!.source.kind),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "状态",
|
|
||||||
width: 160,
|
|
||||||
render: (_, r) => <BacktestStatusTag status={r!.status} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "已保存 / 总数",
|
|
||||||
width: 120,
|
|
||||||
render: (_, r) =>
|
|
||||||
`${r!.counts.persistence.saved || 0} / ${r!.total}`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "失败",
|
|
||||||
width: 80,
|
|
||||||
render: (_, r) => r!.counts.platform.failed || 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "创建时间",
|
|
||||||
width: 180,
|
|
||||||
render: (_, r) => formatTime(r!.created_at, timezone),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Table<DraftSummary>
|
<WorkspaceTable<DraftSummary>
|
||||||
|
fill
|
||||||
className="alpha-table"
|
className="alpha-table"
|
||||||
size="small"
|
size="small"
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
@@ -462,39 +610,24 @@ export function BacktestPage({
|
|||||||
scroll={{ x: 700, y: "100%" }}
|
scroll={{ x: 700, y: "100%" }}
|
||||||
dataSource={drafts.items}
|
dataSource={drafts.items}
|
||||||
empty="尚无已保存草稿。"
|
empty="尚无已保存草稿。"
|
||||||
columns={[
|
columns={draftTableColumns.filter((_, i) =>
|
||||||
{
|
draftColumns.visible.includes(draftColumns.choices[i].key),
|
||||||
title: "候选草稿",
|
)}
|
||||||
dataIndex: "name",
|
|
||||||
width: 260,
|
|
||||||
render: (v, d) => (
|
|
||||||
<Button
|
|
||||||
theme="borderless"
|
|
||||||
className="backtest-name-button"
|
|
||||||
title={String(v)}
|
|
||||||
onClick={() => void perform(() => loadDraft(d!.id))}
|
|
||||||
>
|
|
||||||
{String(v)}
|
|
||||||
</Button>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{ title: "候选数", dataIndex: "total", width: 100 },
|
|
||||||
{ title: "版本", dataIndex: "version", width: 80 },
|
|
||||||
{
|
|
||||||
title: "更新时间",
|
|
||||||
width: 180,
|
|
||||||
render: (_, d) => formatTime(d!.updated_at, timezone),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<footer className="table-pagination">
|
<footer className="table-pagination workspace-table-footer">
|
||||||
<span className="muted">
|
<span className="muted">
|
||||||
共 {view === "runs" ? runs.total : drafts.total} 条
|
共 {view === "runs" ? runs.total : drafts.total} 条
|
||||||
</span>
|
</span>
|
||||||
<Pagination
|
<Pagination
|
||||||
currentPage={page}
|
currentPage={page}
|
||||||
pageSize={25}
|
pageSize={pageSize}
|
||||||
|
showSizeChanger
|
||||||
|
pageSizeOpts={[25, 50, 100]}
|
||||||
|
onPageSizeChange={(size) => {
|
||||||
|
setPageSize(size);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
total={view === "runs" ? runs.total : drafts.total}
|
total={view === "runs" ? runs.total : drafts.total}
|
||||||
onPageChange={setPage}
|
onPageChange={setPage}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
gap: 12px;
|
gap: 0;
|
||||||
}
|
}
|
||||||
.backtest-toolbar {
|
.backtest-toolbar {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ export function CatalogTableToolbar({
|
|||||||
label,
|
label,
|
||||||
active = true,
|
active = true,
|
||||||
filterCount = 0,
|
filterCount = 0,
|
||||||
|
scope,
|
||||||
filters,
|
filters,
|
||||||
onApply,
|
onApply,
|
||||||
onReset,
|
onReset,
|
||||||
@@ -82,6 +83,7 @@ export function CatalogTableToolbar({
|
|||||||
label: string;
|
label: string;
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
filterCount?: number;
|
filterCount?: number;
|
||||||
|
scope?: ReactNode;
|
||||||
filters: ReactNode;
|
filters: ReactNode;
|
||||||
onApply: () => void;
|
onApply: () => void;
|
||||||
onReset: () => void;
|
onReset: () => void;
|
||||||
@@ -149,6 +151,7 @@ export function CatalogTableToolbar({
|
|||||||
role="toolbar"
|
role="toolbar"
|
||||||
aria-label={`${label}表格工具栏`}
|
aria-label={`${label}表格工具栏`}
|
||||||
>
|
>
|
||||||
|
{scope}
|
||||||
<div className="catalog-toolbar-tools">
|
<div className="catalog-toolbar-tools">
|
||||||
{popup(
|
{popup(
|
||||||
"filter",
|
"filter",
|
||||||
|
|||||||
@@ -310,12 +310,6 @@ export function DatasetPage({
|
|||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<section className="preparation-page">
|
<section className="preparation-page">
|
||||||
<ScopeControls
|
|
||||||
value={scope}
|
|
||||||
onChange={setScope}
|
|
||||||
active={active}
|
|
||||||
revision={version}
|
|
||||||
/>
|
|
||||||
{error && <Banner type="danger" description={error} />}
|
{error && <Banner type="danger" description={error} />}
|
||||||
{copied && (
|
{copied && (
|
||||||
<Banner
|
<Banner
|
||||||
@@ -329,6 +323,15 @@ export function DatasetPage({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<CatalogTableToolbar
|
<CatalogTableToolbar
|
||||||
|
scope={
|
||||||
|
<ScopeControls
|
||||||
|
inline
|
||||||
|
value={scope}
|
||||||
|
onChange={setScope}
|
||||||
|
active={active}
|
||||||
|
revision={version}
|
||||||
|
/>
|
||||||
|
}
|
||||||
label="数据目录"
|
label="数据目录"
|
||||||
active={active && !suspended && !detail}
|
active={active && !suspended && !detail}
|
||||||
filterCount={[q, category, subcategory].filter(Boolean).length}
|
filterCount={[q, category, subcategory].filter(Boolean).length}
|
||||||
|
|||||||
@@ -209,16 +209,19 @@ export function FieldBrowser({
|
|||||||
setDraft((old) => ({ ...old, [k]: v }));
|
setDraft((old) => ({ ...old, [k]: v }));
|
||||||
return (
|
return (
|
||||||
<div className="preparation-fields">
|
<div className="preparation-fields">
|
||||||
{!fixedScope && (
|
|
||||||
<ScopeControls
|
|
||||||
value={scope}
|
|
||||||
onChange={setScope}
|
|
||||||
local={source === "local"}
|
|
||||||
active={active}
|
|
||||||
revision={revision}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<CatalogTableToolbar
|
<CatalogTableToolbar
|
||||||
|
scope={
|
||||||
|
!fixedScope ? (
|
||||||
|
<ScopeControls
|
||||||
|
inline
|
||||||
|
value={scope}
|
||||||
|
onChange={setScope}
|
||||||
|
local={source === "local"}
|
||||||
|
active={active}
|
||||||
|
revision={revision}
|
||||||
|
/>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
label="字段目录"
|
label="字段目录"
|
||||||
active={active && !adding}
|
active={active && !adding}
|
||||||
columns={columnSettings}
|
columns={columnSettings}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export function ScopePicker({
|
|||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
local = false,
|
local = false,
|
||||||
|
inline = false,
|
||||||
active = true,
|
active = true,
|
||||||
revision = "",
|
revision = "",
|
||||||
options,
|
options,
|
||||||
@@ -28,6 +29,7 @@ export function ScopePicker({
|
|||||||
value: Scope;
|
value: Scope;
|
||||||
onChange: (scope: Scope) => void;
|
onChange: (scope: Scope) => void;
|
||||||
local?: boolean;
|
local?: boolean;
|
||||||
|
inline?: boolean;
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
revision?: string;
|
revision?: string;
|
||||||
options?: ScopeOption[];
|
options?: ScopeOption[];
|
||||||
@@ -86,7 +88,10 @@ export function ScopePicker({
|
|||||||
value.universe &&
|
value.universe &&
|
||||||
Number.isFinite(value.delay);
|
Number.isFinite(value.delay);
|
||||||
return (
|
return (
|
||||||
<section className="scope-picker" aria-label="组合选择">
|
<section
|
||||||
|
className={`scope-picker${inline ? " scope-picker-inline" : ""}`}
|
||||||
|
aria-label="组合选择"
|
||||||
|
>
|
||||||
<div className="scope-picker-summary">
|
<div className="scope-picker-summary">
|
||||||
<div>
|
<div>
|
||||||
<span className="settings-label">组合</span>
|
<span className="settings-label">组合</span>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
.scope-picker-summary {
|
.scope-picker-summary {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: flex-start;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
border: 1px solid var(--semi-color-border);
|
border: 1px solid var(--semi-color-border);
|
||||||
@@ -83,3 +83,30 @@
|
|||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The toolbar variant keeps the summary compact; editing does not move the table. */
|
||||||
|
.scope-picker-inline {
|
||||||
|
position: relative;
|
||||||
|
margin: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
.scope-picker-inline .scope-picker-summary {
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.scope-picker-inline .scope-picker-panel {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 8px);
|
||||||
|
left: 0;
|
||||||
|
z-index: 10;
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: min(560px, calc(100vw - 48px));
|
||||||
|
max-height: min(600px, 65dvh);
|
||||||
|
overflow-y: auto;
|
||||||
|
background: var(--semi-color-bg-2);
|
||||||
|
border: 1px solid var(--semi-color-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: var(--semi-shadow-elevated);
|
||||||
|
}
|
||||||
|
|||||||
@@ -188,9 +188,110 @@ test("MCP direct batch appears through its run link and source filter", async ({
|
|||||||
.getByRole("heading")
|
.getByRole("heading")
|
||||||
.getByRole("button")
|
.getByRole("button")
|
||||||
.click();
|
.click();
|
||||||
|
await page.getByRole("button", { name: "筛选回测研究", exact: true }).click();
|
||||||
await page.getByText("全部研究来源", { exact: true }).click();
|
await page.getByText("全部研究来源", { exact: true }).click();
|
||||||
await page.getByRole("option", { name: /MCP 研究/ }).click();
|
await page.getByRole("option", { name: /MCP 研究/ }).click();
|
||||||
|
await page.getByRole("button", { name: "查询", exact: true }).click();
|
||||||
await expect(
|
await expect(
|
||||||
page.getByRole("button", { name: "MCP 浏览器留痕", exact: true }),
|
page.getByRole("button", { name: "MCP 浏览器留痕", exact: true }),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("backtest toolbar filters, sorts, refreshes and preserves compact layout", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await login(page);
|
||||||
|
let revision = 1;
|
||||||
|
const requests: URL[] = [];
|
||||||
|
await page.route("**/api/v1/backtests/runs?*", async (route) => {
|
||||||
|
const url = new URL(route.request().url());
|
||||||
|
requests.push(url);
|
||||||
|
const offset = Number(url.searchParams.get("offset") || 0);
|
||||||
|
const limit = Number(url.searchParams.get("limit") || 25);
|
||||||
|
await route.fulfill({
|
||||||
|
json: {
|
||||||
|
total: 60,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
items: Array.from({ length: Math.min(limit, 60 - offset) }, (_, i) => ({
|
||||||
|
backtest_run_id: `run-${offset + i}`,
|
||||||
|
name: `工具栏验收-${revision}-${offset + i} ` + "长名称".repeat(20),
|
||||||
|
source: { kind: "manual" },
|
||||||
|
status: "running",
|
||||||
|
total: 8,
|
||||||
|
counts: { persistence: { saved: 2 }, platform: { failed: 0 } },
|
||||||
|
created_at: "2026-09-12T10:00:00Z",
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.reload();
|
||||||
|
const table = page.locator(".backtest-page .workspace-table");
|
||||||
|
await expect(
|
||||||
|
table.getByRole("row").filter({ hasText: "工具栏验收-1-0" }),
|
||||||
|
).toBeVisible();
|
||||||
|
revision = 2;
|
||||||
|
await page.getByRole("button", { name: "刷新回测", exact: true }).click();
|
||||||
|
await expect(
|
||||||
|
table.getByRole("row").filter({ hasText: "工具栏验收-2-0" }),
|
||||||
|
).toBeVisible();
|
||||||
|
await page.getByRole("button", { name: "筛选回测研究", exact: true }).click();
|
||||||
|
await page.getByLabel("回测名称筛选").fill("test-filter");
|
||||||
|
expect(
|
||||||
|
requests.some((url) => url.searchParams.get("q") === "test-filter"),
|
||||||
|
).toBe(false);
|
||||||
|
await page.getByRole("button", { name: "查询", exact: true }).click();
|
||||||
|
await expect
|
||||||
|
.poll(() =>
|
||||||
|
requests.some((url) => url.searchParams.get("q") === "test-filter"),
|
||||||
|
)
|
||||||
|
.toBe(true);
|
||||||
|
await page.getByRole("button", { name: "显示列设置", exact: true }).click();
|
||||||
|
await page
|
||||||
|
.locator(".catalog-column-panel .semi-checkbox")
|
||||||
|
.filter({ hasText: "失败" })
|
||||||
|
.click();
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
await expect(
|
||||||
|
table.getByRole("columnheader", { name: "失败", exact: true }),
|
||||||
|
).toHaveCount(0);
|
||||||
|
await page
|
||||||
|
.locator(".backtest-page .semi-page-item")
|
||||||
|
.filter({ hasText: /^2$/ })
|
||||||
|
.click();
|
||||||
|
await expect
|
||||||
|
.poll(() => requests.some((url) => url.searchParams.get("offset") === "25"))
|
||||||
|
.toBe(true);
|
||||||
|
await page.getByRole("button", { name: "排序设置", exact: true }).click();
|
||||||
|
await page.getByRole("combobox", { name: "排序字段", exact: true }).click();
|
||||||
|
await page
|
||||||
|
.getByRole("option")
|
||||||
|
.filter({ hasText: /^名称$/ })
|
||||||
|
.click();
|
||||||
|
await expect
|
||||||
|
.poll(() =>
|
||||||
|
requests.some(
|
||||||
|
(url) =>
|
||||||
|
url.searchParams.get("sort") === "name" &&
|
||||||
|
url.searchParams.get("offset") === "0",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toBe(true);
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
for (const width of [1440, 850]) {
|
||||||
|
await page.setViewportSize({ width, height: 900 });
|
||||||
|
const row = table.locator("tbody tr.semi-table-row").first();
|
||||||
|
await expect(row).toBeVisible();
|
||||||
|
expect(await row.evaluate((el) => el.getBoundingClientRect().height)).toBe(
|
||||||
|
40,
|
||||||
|
);
|
||||||
|
const footer = await page
|
||||||
|
.locator(".backtest-page .workspace-table-footer")
|
||||||
|
.boundingBox();
|
||||||
|
expect(footer!.y + footer!.height).toBeLessThanOrEqual(900);
|
||||||
|
expect(footer!.y + footer!.height).toBeGreaterThan(860);
|
||||||
|
await page.screenshot({
|
||||||
|
path: `../output/playwright/backtest-toolbar-${width}.png`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -219,6 +219,26 @@ test("目录表格保持紧凑行高、截断和固定分页", async ({ page })
|
|||||||
await expect(
|
await expect(
|
||||||
table.locator(".semi-tag").filter({ hasText: "已同步字段" }),
|
table.locator(".semi-tag").filter({ hasText: "已同步字段" }),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
|
const summary = page
|
||||||
|
.getByRole("toolbar", { name: "数据目录表格工具栏" })
|
||||||
|
.locator(".scope-picker-summary");
|
||||||
|
const summaryBox = await summary.boundingBox();
|
||||||
|
const filterBox = await page
|
||||||
|
.getByRole("button", { name: "筛选数据目录", exact: true })
|
||||||
|
.boundingBox();
|
||||||
|
expect(
|
||||||
|
Math.abs(
|
||||||
|
summaryBox!.y +
|
||||||
|
summaryBox!.height / 2 -
|
||||||
|
filterBox!.y -
|
||||||
|
filterBox!.height / 2,
|
||||||
|
),
|
||||||
|
).toBeLessThan(3);
|
||||||
|
const textBox = await summary.locator(":scope > div").boundingBox();
|
||||||
|
const changeBox = await summary
|
||||||
|
.getByRole("button", { name: "更改组合" })
|
||||||
|
.boundingBox();
|
||||||
|
expect(changeBox!.x - textBox!.x - textBox!.width).toBeLessThanOrEqual(16);
|
||||||
await page.screenshot({ path: "test-results/catalog-table-layout.png" });
|
await page.screenshot({ path: "test-results/catalog-table-layout.png" });
|
||||||
await page
|
await page
|
||||||
.getByRole("navigation", { name: "主导航" })
|
.getByRole("navigation", { name: "主导航" })
|
||||||
|
|||||||
Reference in New Issue
Block a user