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;
|
||||
|
||||
Reference in New Issue
Block a user