Files
worldquant-alpha-system/frontend/src/backtests/BacktestPage.tsx
T

1064 lines
35 KiB
TypeScript
Raw Normal View History

import { useCallback, useEffect, useState } from "react";
import {
Banner,
Button,
Input,
InputNumber,
Pagination,
Select,
SideSheet,
Table,
Tag,
TextArea,
} from "@douyinfe/semi-ui-19";
import { api, formatNumber, formatTime, post } from "../api";
import type { PageContext, UIAction } from "../ai/types";
import { controlLabels, initialSettings, labels } from "./types";
import type {
Attempt,
Candidate,
Draft,
DraftSummary,
Item,
Page,
Preview,
Run,
Scheduler,
SimulationSettings,
Source,
} from "./types";
import "./style.css";
import { SourceDetails, sourceLabel } from "../research/SourceDetails";
export function BacktestPage({
version = 0,
active,
suspended,
chatOffset,
timezone,
action,
onAction,
onContext,
}: {
version?: number;
active: boolean;
suspended: boolean;
chatOffset: number;
timezone?: string;
action: UIAction | null;
onAction: (action: UIAction) => void;
onContext: (context: PageContext) => void;
}) {
const [view, setView] = useState("runs");
const [sourceFilter, setSourceFilter] = useState("");
const [sourceKinds, setSourceKinds] = useState<string[]>([]);
const [page, setPage] = useState(1);
const [runs, setRuns] = useState<Page<Run>>({
items: [],
total: 0,
limit: 25,
offset: 0,
});
const [drafts, setDrafts] = useState<Page<DraftSummary>>({
items: [],
total: 0,
limit: 25,
offset: 0,
});
const [config, setConfig] = useState<Scheduler | null>(null);
const [settingsDraft, setSettingsDraft] = useState<Scheduler | null>(null);
const [editor, setEditor] = useState(false);
const [draft, setDraft] = useState<Draft | null>(null);
const [name, setName] = useState("");
const [source, setSource] = useState<Source>({ kind: "manual" });
const [text, setText] = useState("");
const [mode, setMode] = useState("lines");
const [settings, setSettings] = useState<SimulationSettings>(initialSettings);
const [advanced, setAdvanced] = useState("");
const [preview, setPreview] = useState<Preview | null>(null);
const [excluded, setExcluded] = useState<string[]>([]);
const [referenceUrls, setReferenceUrls] = useState<Record<string, string>>(
{},
);
useEffect(() => setExcluded([]), [preview?.preview_id]);
const [runId, setRunId] = useState("");
const [run, setRun] = useState<Run | null>(null);
const [results, setResults] = useState<Page<Item>>({
items: [],
total: 0,
limit: 25,
offset: 0,
});
const [resultPage, setResultPage] = useState(1);
const [selected, setSelected] = useState<string[]>([]);
const [attempts, setAttempts] = useState<Attempt[]>([]);
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const [notice, setNotice] = useState("");
const [control, setControl] = useState<{
action: string;
version: number;
} | null>(null);
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]);
useEffect(() => {
if (!active) return;
let alive = true;
const load = () =>
refresh().catch((e) => {
if (alive) setError(e.message);
});
void load();
const timer = window.setInterval(load, 3000);
return () => {
alive = false;
clearInterval(timer);
};
}, [active, refresh, version]);
useEffect(() => {
if (!runId) return;
let alive = true;
const load = async () => {
try {
const [r, items, a] = await Promise.all([
api<Run>(`/backtests/runs/${runId}`),
api<Page<Item>>(
`/backtests/runs/${runId}/results?offset=${(resultPage - 1) * 25}`,
),
api<Attempt[]>(`/backtests/runs/${runId}/attempts`),
]);
if (alive) {
setRun(r);
setResults(items);
setAttempts(a);
}
} catch (e) {
if (alive) setError((e as Error).message);
}
};
void load();
const timer = window.setInterval(load, 2000);
return () => {
alive = false;
clearInterval(timer);
};
}, [runId, resultPage, version]);
useEffect(() => {
onContext({
page: "backtests",
backtest_run_id: runId || undefined,
backtest_preview_id: preview?.preview_id,
backtest_draft_id: draft?.id,
});
}, [runId, preview?.preview_id, draft?.id, onContext]);
useEffect(() => {
if (action?.type === "open_backtest") openRun(action.run_id);
if (action?.type === "open_backtest_preview") {
void api<Preview>(`/backtests/previews/${action.preview_id}`)
.then((p) => {
setPreview(p);
setEditor(true);
})
.catch((e) => setError(e.message));
}
}, [action]);
useEffect(() => {
const locate = () => {
if (!location.hash.startsWith("#backtests?")) return;
const id = new URLSearchParams(location.hash.split("?")[1]).get("run_id");
if (id && /^[a-f0-9-]{36}$/i.test(id)) openRun(id);
};
locate();
window.addEventListener("hashchange", locate);
return () => window.removeEventListener("hashchange", locate);
}, []);
async function perform(work: () => Promise<void>) {
setBusy(true);
setError("");
setNotice("");
try {
await work();
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
}
function openRun(id: string) {
setSettingsDraft(null);
setRunId(id);
setRun(null);
setResultPage(1);
setSelected([]);
setControl(null);
setItemDetail(null);
setEditor(false);
}
function newDraft() {
setSettingsDraft(null);
setDraft(null);
setName("");
setSource({ kind: "manual" });
setText("");
setSettings(initialSettings);
setMode("lines");
setAdvanced("");
setPreview(null);
setEditor(true);
setRunId("");
}
function input() {
const candidates: Candidate[] =
mode === "json"
? JSON.parse(text)
: text
.split("\n")
.map((s) => s.trim())
.filter(Boolean)
.map((expression, i) => ({
client_item_id: `item-${i + 1}`,
expression,
settings: {
...settings,
...(advanced.trim() ? JSON.parse(advanced) : {}),
},
}));
if (!Array.isArray(candidates) || !candidates.length)
throw new Error("请提供非空候选集合");
return { name, source, candidates };
}
async function loadDraft(id: string) {
setSettingsDraft(null);
const d = await api<Draft>(`/backtests/drafts/${id}`);
setDraft(d);
setName(d.name);
setSource(d.source);
setMode("json");
setText(JSON.stringify(d.candidates, null, 2));
setPreview(null);
setEditor(true);
setRunId("");
}
async function save() {
const d = draft
? await api<Draft>(`/backtests/drafts/${draft.id}`, {
method: "PUT",
body: JSON.stringify({ ...input(), version: draft.version }),
})
: await post<Draft>("/backtests/drafts", input());
setDraft(d);
setNotice("草稿已保存");
await refresh();
}
async function prepare() {
setPreview(await post<Preview>("/backtests/previews", { inline: input() }));
}
async function editPreview() {
if (!preview) return;
const items: Candidate[] = [];
for (let offset = 0; offset < preview.total; offset += 100) {
const next = await api<Preview>(
`/backtests/previews/${preview.preview_id}?offset=${offset}&limit=100`,
);
items.push(...next.items);
}
setName(preview.name);
setSource(preview.source);
setMode("json");
setText(JSON.stringify(items, null, 2));
setDraft(null);
setPreview(null);
}
async function start() {
if (!preview) return;
const r = await post<Run>("/backtests/runs", {
preview_id: preview.preview_id,
version: preview.version,
idempotency_key: `preview-${preview.preview_id}`,
});
openRun(r.backtest_run_id);
setRun(r);
await refresh();
}
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" && (
<Select
aria-label="回测研究来源筛选"
value={sourceFilter || undefined}
placeholder="全部研究来源"
showClear
optionList={sourceKinds.map((kind) => ({
value: kind,
label: sourceLabel(kind),
}))}
onChange={(value) => {
setSourceFilter(String(value ?? ""));
setPage(1);
}}
/>
)}
</div>
<div className="backtest-toolbar">
<Button
type="tertiary"
onClick={() => {
setSettingsDraft(config);
setRunId("");
setEditor(false);
}}
>
调度设置
</Button>
<Button theme="solid" onClick={newDraft}>
新建回测
</Button>
</div>
{error && !editor && !runId && !settingsDraft && (
<Banner type="danger" description={error} />
)}
{config?.blocked_reason && (
<Banner type="warning" description={config.blocked_reason} />
)}
{view === "runs" ? (
<Table<Run>
className="alpha-table"
size="small"
rowKey="backtest_run_id"
pagination={false}
scroll={{ x: 900, y: "100%" }}
dataSource={runs.items}
empty="尚无回测运行,准备候选并预览后启动。"
columns={[
{
title: "运行",
dataIndex: "name",
width: 240,
render: (value, r) => (
<Button
theme="borderless"
onClick={() => openRun(r!.backtest_run_id)}
>
{String(value)}
</Button>
),
},
{
title: "来源",
width: 120,
render: (_, r) => sourceLabel(r!.source.kind),
},
{
title: "状态",
width: 160,
render: (_, r) => labels[r!.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>
className="alpha-table"
size="small"
rowKey="id"
pagination={false}
scroll={{ x: 700, y: "100%" }}
dataSource={drafts.items}
empty="尚无已保存草稿。"
columns={[
{
title: "候选草稿",
dataIndex: "name",
width: 260,
render: (v, d) => (
<Button
theme="borderless"
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">
<span className="muted">
共 {view === "runs" ? runs.total : drafts.total} 条
</span>
<Pagination
currentPage={page}
pageSize={25}
total={view === "runs" ? runs.total : drafts.total}
onPageChange={setPage}
/>
</footer>
<SideSheet
title={preview ? "确认回测输入" : "准备候选"}
visible={editor && drawerVisible}
width={`min(60vw, calc(100vw - ${chatOffset}px))`}
style={{ right: chatOffset }}
maskStyle={{ right: chatOffset }}
onCancel={() => setEditor(false)}
>
<div className="backtest-sheet">
{error && <Banner type="danger" description={error} />}
{notice && <Banner type="success" description={notice} />}
{preview ? (
<>
<div className="backtest-toolbar">
<Button
type="tertiary"
onClick={() => void perform(editPreview)}
>
返回编辑
</Button>
<div className="backtest-spacer" />
<Button
theme="solid"
loading={busy}
disabled={excluded.length > 0}
onClick={() => void perform(start)}
>
确认启动回测
</Button>
</div>
<p>
{preview.name} · {preview.total} 条候选 · {preview.batch_count}{" "}
个平台批次 · 来源 {sourceLabel(preview.source.kind)}
</p>
<SourceDetails source={preview.source} onAction={onAction} />
{preview.duplicate_count > 0 && (
<Banner
type="warning"
description={`${preview.duplicate_count} 条候选与历史或本次输入重复。确认后仍会独立执行,不复用历史结果。`}
/>
)}
{excluded.length > 0 && (
<div className="backtest-toolbar">
<span>已排除 {excluded.length} 条</span>
<Button
disabled={busy || excluded.length === preview.total}
onClick={() =>
void perform(async () =>
setPreview(
await post<Preview>(
`/backtests/previews/${preview.preview_id}/subset`,
{ exclude_ids: excluded },
),
),
)
}
>
以选中候选重新预览
</Button>
<Button onClick={() => setExcluded([])}>恢复全选</Button>
</div>
)}
<Table<Candidate>
size="small"
rowKey="client_item_id"
rowSelection={{
selectedRowKeys: preview.items
.filter((i) => !excluded.includes(i.client_item_id))
.map((i) => i.client_item_id),
onChange: (keys) => {
const selected = new Set((keys || []).map(String));
const pageIds = new Set(
preview.items.map((i) => i.client_item_id),
);
setExcluded((old) => [
...old.filter((id) => !pageIds.has(id)),
...preview.items
.filter((i) => !selected.has(i.client_item_id))
.map((i) => i.client_item_id),
]);
},
}}
pagination={false}
scroll={{ x: 600 }}
dataSource={preview.items}
columns={[
{ title: "候选", dataIndex: "client_item_id", width: 120 },
{
title: "表达式",
dataIndex: "expression",
width: 260,
ellipsis: true,
},
{
title: "最终参数",
width: 220,
render: (_, c) => (
<details>
<summary>
{c!.settings.region} / {c!.settings.universe} / Delay{" "}
{c!.settings.delay}
</summary>
<pre>{JSON.stringify(c!.settings, null, 2)}</pre>
</details>
),
},
]}
/>
<Pagination
currentPage={Math.floor(preview.offset / 25) + 1}
pageSize={25}
total={preview.total}
onPageChange={(p) =>
void perform(async () =>
setPreview(
await api<Preview>(
`/backtests/previews/${preview.preview_id}?offset=${(p - 1) * 25}`,
),
),
)
}
/>
<p className="muted">
本次只执行以上固定集合。暂停或停止只影响后续提交,已提交结果继续收集。
</p>
</>
) : (
<>
<div className="backtest-toolbar">
<Button disabled={busy} onClick={() => void perform(save)}>
保存草稿
</Button>
<Button
theme="solid"
loading={busy}
onClick={() => void perform(prepare)}
>
预览回测
</Button>
</div>
<label>
运行名称
<Input
aria-label="运行名称"
value={name}
onChange={setName}
maxLength={200}
/>
</label>
<div className="backtest-form-grid">
<label>
来源
<Input
aria-label="研究来源"
value={source.kind}
onChange={(kind) => setSource({ ...source, kind })}
/>
</label>
<label>
业务批次
<Input
aria-label="业务批次"
value={source.batch_id || ""}
onChange={(batch_id) =>
setSource({ ...source, batch_id: batch_id || null })
}
/>
</label>
</div>
<label>
输入方式
<Select
aria-label="候选输入方式"
value={mode}
onChange={(v) => setMode(String(v))}
optionList={[
{ value: "lines", label: "每行一条表达式,共用参数" },
{ value: "json", label: "结构化候选 JSON,逐项完整参数" },
]}
/>
</label>
{mode === "lines" && (
<>
<div className="backtest-form-grid">
<label>
Region
<Input
aria-label="Region"
placeholder="例如 USA"
value={settings.region}
onChange={(region) =>
setSettings({ ...settings, region })
}
/>
</label>
<label>
Universe
<Input
aria-label="Universe"
placeholder="例如 TOP3000"
value={settings.universe}
onChange={(universe) =>
setSettings({ ...settings, universe })
}
/>
</label>
<label>
Delay
<Select
aria-label="Delay"
value={settings.delay}
onChange={(v) =>
setSettings({
...settings,
delay: Number(v) as 0 | 1,
})
}
optionList={[
{ value: 0, label: "0" },
{ value: 1, label: "1" },
]}
/>
</label>
<label>
Decay
<InputNumber
aria-label="Decay"
value={settings.decay}
min={0}
onChange={(v) =>
setSettings({ ...settings, decay: Number(v) })
}
/>
</label>
<label>
Neutralization
<Input
aria-label="Neutralization"
value={settings.neutralization}
onChange={(neutralization) =>
setSettings({ ...settings, neutralization })
}
/>
</label>
<label>
Truncation
<InputNumber
aria-label="Truncation"
value={settings.truncation}
min={0}
max={1}
step={0.01}
onChange={(v) =>
setSettings({ ...settings, truncation: Number(v) })
}
/>
</label>
</div>
<details>
<summary>其他参数(JSON 覆盖,预览展示最终值)</summary>
<TextArea
aria-label="其他参数"
value={advanced}
onChange={setAdvanced}
placeholder={'{"nanHandling":"ON"}'}
rows={3}
/>
</details>
</>
)}
<label>
{mode === "lines" ? "表达式" : "候选 JSON"}
<TextArea
aria-label="回测候选"
value={text}
onChange={setText}
rows={12}
placeholder={
mode === "lines"
? "rank(close)\n-rank(ts_delta(close, 5))"
: '[{"client_item_id":"1","expression":"rank(close)","settings":{"region":"USA","universe":"TOP3000","delay":1}}]'
}
/>
</label>
<p className="muted">
草稿保存和预览不发起平台回测。完整参数在启动前固定。
</p>
</>
)}
</div>
</SideSheet>
<SideSheet
title={run?.name || "回测详情"}
visible={!!runId && drawerVisible}
width={`min(60vw, calc(100vw - ${chatOffset}px))`}
style={{ right: chatOffset }}
maskStyle={{ right: chatOffset }}
onCancel={() => {
setRunId("");
setControl(null);
}}
>
{run && (
<div className="backtest-sheet">
{error && <Banner type="danger" description={error} />}
<div className="backtest-toolbar">
<Tag>{labels[run.status]}</Tag>
<span>
{run.counts.persistence.saved || 0} / {run.total} 已保存
</span>
<div className="backtest-spacer" />
{Object.entries(controlLabels).map(([a, label]) => (
<Button
key={a}
disabled={
busy ||
(run.control === "stopped" &&
["pause", "resume"].includes(a))
}
onClick={() =>
setControl({ action: a, version: run.version })
}
>
{label}
</Button>
))}
</div>
{control && (
<Banner
type="warning"
description={
<div>
{controlLabels[control.action]} ·
仅改变本地调度,已提交结果继续收集。
<Button
theme="solid"
disabled={busy}
onClick={() =>
void perform(async () => {
setRun(
await post<Run>(
`/backtests/runs/${runId}/control`,
control,
),
);
setControl(null);
})
}
>
确认操作
</Button>
<Button onClick={() => setControl(null)}>返回</Button>
</div>
}
/>
)}
<p className="muted">
来源 {sourceLabel(run.source.kind)}{" "}
{run.source.batch_id ? `· 批次 ${run.source.batch_id}` : ""} ·{" "}
{formatTime(run.created_at, timezone)}
</p>
<SourceDetails source={run.source} onAction={onAction} />
{run.scheduler.blocked_reason && (
<Banner
type="warning"
description={run.scheduler.blocked_reason}
/>
)}
<div>
<Button
disabled={!selected.length || busy}
onClick={() =>
void perform(async () => {
setPreview(
await post<Preview>(
`/backtests/runs/${runId}/rerun-preview`,
{ item_ids: selected },
),
);
setRunId("");
setEditor(true);
})
}
>
为选中项准备重跑预览({selected.length})
</Button>
</div>
<Table<Item>
size="small"
rowKey="id"
pagination={false}
scroll={{ x: 1000 }}
dataSource={results.items}
rowSelection={{
selectedRowKeys: selected,
onChange: (keys) => setSelected((keys || []).map(String)),
}}
columns={[
{
title: "表达式",
dataIndex: "expression",
width: 260,
ellipsis: true,
render: (v, i) => (
<Button
theme="borderless"
onClick={() => setItemDetail(i!)}
>
{String(v)}
</Button>
),
},
{
title: "平台",
width: 120,
render: (_, i) => labels[i!.platform_status],
},
{
title: "收集",
width: 100,
render: (_, i) => labels[i!.collection_status],
},
{
title: "保存",
width: 100,
render: (_, i) => labels[i!.persistence_status],
},
{
title: "Sharpe",
width: 90,
render: (_, i) =>
formatNumber(i!.result?.snapshot.is?.sharpe),
},
{
title: "Fitness",
width: 90,
render: (_, i) =>
formatNumber(i!.result?.snapshot.is?.fitness),
},
{
title: "Alpha",
width: 140,
render: (_, i) =>
i!.persistence_status === "saved" && i!.alpha_id ? (
<Button
theme="borderless"
onClick={() =>
onAction({
type: "open_alpha",
alpha_id: i!.alpha_id!,
nonce: Date.now(),
})
}
>
{i!.alpha_id}
</Button>
) : (
"—"
),
},
]}
/>
<Pagination
currentPage={resultPage}
pageSize={25}
total={results.total}
onPageChange={setResultPage}
/>
{itemDetail && (
<section className="backtest-item-detail">
<div className="backtest-toolbar">
<h3>{itemDetail.client_item_id}</h3>
<Button onClick={() => setItemDetail(null)}>
收起候选详情
</Button>
</div>
<p>{itemDetail.expression}</p>
{itemDetail.error && (
<Banner type="warning" description={itemDetail.error} />
)}
<pre>
{JSON.stringify(
{
settings: itemDetail.settings,
result: itemDetail.result,
},
null,
2,
)}
</pre>
</section>
)}
<details>
<summary>执行尝试与恢复信息</summary>
{attempts.map((a) => (
<div className="backtest-attempt" key={a.id}>
<p>
{labels[a.state]} · 提交 {a.submit_count} 次 · 查询{" "}
{a.poll_count} 次
</p>
<code>{a.id}</code>
{a.error && <p className="error-text">{a.error}</p>}
{a.state === "needs_review" && !a.progress_url && (
<div className="backtest-sheet">
<label>
原平台 progress URL
<Input
aria-label={`原模拟引用 ${a.id}`}
value={referenceUrls[a.id] || ""}
onChange={(value) =>
setReferenceUrls((old) => ({
...old,
[a.id]: value,
}))
}
/>
</label>
<Button
disabled={busy || !referenceUrls[a.id]}
onClick={() =>
void perform(async () => {
setRun(
await post<Run>(
`/backtests/attempts/${a.id}/reference`,
{
progress_url: referenceUrls[a.id],
version: run.version,
},
),
);
})
}
>
确认关联原模拟并找回
</Button>
</div>
)}
{a.progress_url && (
<a href={a.progress_url} target="_blank" rel="noreferrer">
查看平台模拟
</a>
)}
</div>
))}
</details>
</div>
)}
</SideSheet>
<SideSheet
title="回测调度设置"
visible={!!settingsDraft && drawerVisible}
width={Math.min(480, window.innerWidth - chatOffset)}
style={{ right: chatOffset }}
maskStyle={{ right: chatOffset }}
onCancel={() => setSettingsDraft(null)}
>
{settingsDraft && (
<div className="backtest-sheet">
{error && <Banner type="danger" description={error} />}
<label>
并发平台任务
<InputNumber
aria-label="并发平台任务"
value={settingsDraft.concurrency}
min={1}
max={8}
onChange={(v) =>
setSettingsDraft({ ...settingsDraft, concurrency: Number(v) })
}
/>
</label>
<label>
每批最大候选数
<InputNumber
aria-label="每批最大候选数"
value={settingsDraft.batch_size}
min={1}
max={10}
onChange={(v) =>
setSettingsDraft({ ...settingsDraft, batch_size: Number(v) })
}
/>
</label>
<p className="muted">
本地调度配置,不代表平台剩余额度。并发调整用于后续补位,批大小只用于新预览。
</p>
<Button
theme="solid"
loading={busy}
onClick={() =>
void perform(async () => {
await api("/backtests/config", {
method: "PUT",
body: JSON.stringify({
concurrency: settingsDraft.concurrency,
batch_size: settingsDraft.batch_size,
version: settingsDraft.version,
}),
});
setSettingsDraft(null);
await refresh();
})
}
>
保存调度设置
</Button>
</div>
)}
</SideSheet>
</section>
);
}