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([]); const [page, setPage] = useState(1); const [runs, setRuns] = useState>({ items: [], total: 0, limit: 25, offset: 0, }); const [drafts, setDrafts] = useState>({ items: [], total: 0, limit: 25, offset: 0, }); const [config, setConfig] = useState(null); const [settingsDraft, setSettingsDraft] = useState(null); const [editor, setEditor] = useState(false); const [draft, setDraft] = useState(null); const [name, setName] = useState(""); const [source, setSource] = useState({ kind: "manual" }); const [text, setText] = useState(""); const [mode, setMode] = useState("lines"); const [settings, setSettings] = useState(initialSettings); const [advanced, setAdvanced] = useState(""); const [preview, setPreview] = useState(null); const [excluded, setExcluded] = useState([]); const [referenceUrls, setReferenceUrls] = useState>( {}, ); useEffect(() => setExcluded([]), [preview?.preview_id]); const [runId, setRunId] = useState(""); const [run, setRun] = useState(null); const [results, setResults] = useState>({ items: [], total: 0, limit: 25, offset: 0, }); const [resultPage, setResultPage] = useState(1); const [selected, setSelected] = useState([]); const [attempts, setAttempts] = useState([]); 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(null); const refresh = useCallback(async () => { const [r, d, c, kinds] = await Promise.all([ api>( `/backtests/runs?offset=${(page - 1) * 25}&source=${encodeURIComponent(sourceFilter)}`, ), api>(`/backtests/drafts?offset=${(page - 1) * 25}`), api("/backtests/config"), api("/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(`/backtests/runs/${runId}`), api>( `/backtests/runs/${runId}/results?offset=${(resultPage - 1) * 25}`, ), api(`/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(`/backtests/previews/${action.preview_id}`) .then((p) => { setPreview(p); setEditor(true); }) .catch((e) => setError(e.message)); } }, [action]); async function perform(work: () => Promise) { 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(`/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(`/backtests/drafts/${draft.id}`, { method: "PUT", body: JSON.stringify({ ...input(), version: draft.version }), }) : await post("/backtests/drafts", input()); setDraft(d); setNotice("草稿已保存"); await refresh(); } async function prepare() { setPreview(await post("/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( `/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("/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 (
({ value: kind, label: sourceLabel(kind), }))} onChange={(value) => { setSourceFilter(String(value ?? "")); setPage(1); }} /> )}
{error && !editor && !runId && !settingsDraft && ( )} {config?.blocked_reason && ( )} {view === "runs" ? ( 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) => ( ), }, { 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), }, ]} /> ) : ( 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) => ( ), }, { title: "候选数", dataIndex: "total", width: 100 }, { title: "版本", dataIndex: "version", width: 80 }, { title: "更新时间", width: 180, render: (_, d) => formatTime(d!.updated_at, timezone), }, ]} /> )}
共 {view === "runs" ? runs.total : drafts.total} 条
setEditor(false)} >
{error && } {notice && } {preview ? ( <>

{preview.name} · {preview.total} 条候选 · {preview.batch_count}{" "} 个平台批次 · 来源 {sourceLabel(preview.source.kind)}

{preview.duplicate_count > 0 && ( )} {excluded.length > 0 && (
已排除 {excluded.length} 条
)} 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) => (
{c!.settings.region} / {c!.settings.universe} / Delay{" "} {c!.settings.delay}
{JSON.stringify(c!.settings, null, 2)}
), }, ]} /> void perform(async () => setPreview( await api( `/backtests/previews/${preview.preview_id}?offset=${(p - 1) * 25}`, ), ), ) } />

本次只执行以上固定集合。暂停或停止只影响后续提交,已提交结果继续收集。

) : ( <>
其他参数(JSON 覆盖,预览展示最终值)