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

1050 lines
34 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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({
active,
suspended,
chatOffset,
timezone,
action,
onAction,
onContext,
}: {
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]);
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]);
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]);
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 className="backtest-spacer" />
<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={Math.min(880, window.innerWidth - chatOffset)}
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={Math.min(1120, window.innerWidth - chatOffset)}
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>
);
}