feat: unify backtest table controls and add refresh action
Deploy production / deploy (push) Successful in 53s
Deploy production / deploy (push) Successful in 53s
This commit is contained in:
@@ -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 { useSettingsOptions } from "../settings/useSettingsOptions";
|
||||
import { settingsErrors } from "../settings/model";
|
||||
@@ -6,7 +14,7 @@ import {
|
||||
researchSelection,
|
||||
} from "../preparations/ResearchDataInput";
|
||||
import type { InputSnapshot } from "../research/workspaceTypes";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
Banner,
|
||||
Button,
|
||||
@@ -19,7 +27,7 @@ import {
|
||||
Tag,
|
||||
TextArea,
|
||||
} 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 { controlLabels, initialSettings, labels } from "./types";
|
||||
import type {
|
||||
@@ -82,6 +90,30 @@ export function BacktestPage({
|
||||
}) {
|
||||
const [view, setView] = useState("runs");
|
||||
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 [page, setPage] = useState(1);
|
||||
const [runs, setRuns] = useState<Page<Run>>({
|
||||
@@ -139,30 +171,56 @@ export function BacktestPage({
|
||||
const [itemDetail, setItemDetail] = useState<Item | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
const [r, d, c, kinds] = await Promise.all([
|
||||
api<Page<Run>>(
|
||||
`/backtests/runs?offset=${(page - 1) * 25}&source=${encodeURIComponent(sourceFilter)}`,
|
||||
),
|
||||
api<Page<DraftSummary>>(`/backtests/drafts?offset=${(page - 1) * 25}`),
|
||||
api<Scheduler>("/backtests/config"),
|
||||
api<string[]>("/backtests/sources"),
|
||||
]);
|
||||
setRuns(r);
|
||||
setDrafts(d);
|
||||
setConfig(c);
|
||||
setSourceKinds(kinds);
|
||||
}, [page, sourceFilter]);
|
||||
const sequence = ++requestSequence.current;
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = queryString({
|
||||
q,
|
||||
limit: pageSize,
|
||||
offset: (page - 1) * pageSize,
|
||||
sort: sort.value,
|
||||
direction: sort.direction,
|
||||
...(view === "runs" ? { source: sourceFilter } : {}),
|
||||
});
|
||||
const [data, c, kinds] = await Promise.all([
|
||||
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(() => {
|
||||
if (!active) return;
|
||||
let alive = true;
|
||||
const load = () =>
|
||||
refresh().catch((e) => {
|
||||
if (alive) setError(e.message);
|
||||
});
|
||||
let pending = false;
|
||||
const load = () => {
|
||||
// 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();
|
||||
const timer = window.setInterval(load, 3000);
|
||||
return () => {
|
||||
alive = false;
|
||||
requestSequence.current++;
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, [active, refresh, version]);
|
||||
@@ -345,55 +403,183 @@ export function BacktestPage({
|
||||
setRun(r);
|
||||
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;
|
||||
return (
|
||||
<section className="backtest-page">
|
||||
<div className="backtest-toolbar">
|
||||
<Select
|
||||
aria-label="回测视图"
|
||||
value={view}
|
||||
onChange={(v) => {
|
||||
setView(String(v));
|
||||
setPage(1);
|
||||
}}
|
||||
optionList={[
|
||||
{ value: "runs", label: "运行记录" },
|
||||
{ value: "drafts", label: "候选草稿" },
|
||||
]}
|
||||
/>
|
||||
<span className="muted">REGULAR · FASTEXPR</span>
|
||||
{view === "runs" && (
|
||||
<CatalogTableToolbar
|
||||
label="回测研究"
|
||||
active={active && !suspended && !editor && !runId && !settingsDraft}
|
||||
scope={
|
||||
<Select
|
||||
aria-label="回测研究来源筛选"
|
||||
value={sourceFilter || undefined}
|
||||
placeholder="全部研究来源"
|
||||
showClear
|
||||
optionList={sourceKinds.map((kind) => ({
|
||||
value: kind,
|
||||
label: sourceLabel(kind),
|
||||
}))}
|
||||
onChange={(value) => {
|
||||
setSourceFilter(String(value ?? ""));
|
||||
aria-label="回测视图"
|
||||
value={view}
|
||||
onChange={(v) => {
|
||||
setView(String(v));
|
||||
setPage(1);
|
||||
setQ("");
|
||||
setSourceFilter("");
|
||||
setFilterDraft({ q: "", source: "" });
|
||||
}}
|
||||
optionList={[
|
||||
{ value: "runs", label: "运行记录" },
|
||||
{ value: "drafts", label: "候选草稿" },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="backtest-toolbar">
|
||||
<Button
|
||||
type="tertiary"
|
||||
onClick={() => {
|
||||
setSettingsDraft(config);
|
||||
setRunId("");
|
||||
setEditor(false);
|
||||
}}
|
||||
>
|
||||
调度设置
|
||||
</Button>
|
||||
<Button theme="solid" onClick={newDraft}>
|
||||
新建回测
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
columns={view === "runs" ? runColumns : draftColumns}
|
||||
filterCount={
|
||||
Number(Boolean(q)) + Number(view === "runs" && Boolean(sourceFilter))
|
||||
}
|
||||
filters={
|
||||
<div className="catalog-filter-grid">
|
||||
<label>
|
||||
名称
|
||||
<Input
|
||||
aria-label="回测名称筛选"
|
||||
value={filterDraft.q}
|
||||
maxLength={200}
|
||||
onChange={(q) => setFilterDraft({ ...filterDraft, q })}
|
||||
/>
|
||||
</label>
|
||||
{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 && (
|
||||
<Banner type="danger" description={error} />
|
||||
)}
|
||||
@@ -401,7 +587,8 @@ export function BacktestPage({
|
||||
<Banner type="warning" description={config.blocked_reason} />
|
||||
)}
|
||||
{view === "runs" ? (
|
||||
<Table<Run>
|
||||
<WorkspaceTable<Run>
|
||||
fill
|
||||
className="alpha-table"
|
||||
size="small"
|
||||
rowKey="backtest_run_id"
|
||||
@@ -409,52 +596,13 @@ export function BacktestPage({
|
||||
scroll={{ x: 900, y: "100%" }}
|
||||
dataSource={runs.items}
|
||||
empty="尚无回测运行,准备候选并预览后启动。"
|
||||
columns={[
|
||||
{
|
||||
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),
|
||||
},
|
||||
]}
|
||||
columns={runTableColumns.filter((_, i) =>
|
||||
runColumns.visible.includes(runColumns.choices[i].key),
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<Table<DraftSummary>
|
||||
<WorkspaceTable<DraftSummary>
|
||||
fill
|
||||
className="alpha-table"
|
||||
size="small"
|
||||
rowKey="id"
|
||||
@@ -462,39 +610,24 @@ export function BacktestPage({
|
||||
scroll={{ x: 700, y: "100%" }}
|
||||
dataSource={drafts.items}
|
||||
empty="尚无已保存草稿。"
|
||||
columns={[
|
||||
{
|
||||
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),
|
||||
},
|
||||
]}
|
||||
columns={draftTableColumns.filter((_, i) =>
|
||||
draftColumns.visible.includes(draftColumns.choices[i].key),
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<footer className="table-pagination">
|
||||
<footer className="table-pagination workspace-table-footer">
|
||||
<span className="muted">
|
||||
共 {view === "runs" ? runs.total : drafts.total} 条
|
||||
</span>
|
||||
<Pagination
|
||||
currentPage={page}
|
||||
pageSize={25}
|
||||
pageSize={pageSize}
|
||||
showSizeChanger
|
||||
pageSizeOpts={[25, 50, 100]}
|
||||
onPageSizeChange={(size) => {
|
||||
setPageSize(size);
|
||||
setPage(1);
|
||||
}}
|
||||
total={view === "runs" ? runs.total : drafts.total}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
gap: 12px;
|
||||
gap: 0;
|
||||
}
|
||||
.backtest-toolbar {
|
||||
flex-shrink: 0;
|
||||
|
||||
@@ -188,9 +188,110 @@ test("MCP direct batch appears through its run link and source filter", async ({
|
||||
.getByRole("heading")
|
||||
.getByRole("button")
|
||||
.click();
|
||||
await page.getByRole("button", { name: "筛选回测研究", exact: true }).click();
|
||||
await page.getByText("全部研究来源", { exact: true }).click();
|
||||
await page.getByRole("option", { name: /MCP 研究/ }).click();
|
||||
await page.getByRole("button", { name: "查询", exact: true }).click();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "MCP 浏览器留痕", exact: true }),
|
||||
).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`,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user