2026-09-12 01:24:02 +08:00
|
|
|
|
import {
|
|
|
|
|
|
ResearchDataInput,
|
|
|
|
|
|
researchSelection,
|
|
|
|
|
|
} from "../preparations/ResearchDataInput";
|
|
|
|
|
|
import type { InputSnapshot } from "../research/workspaceTypes";
|
2026-09-08 10:06:00 +08:00
|
|
|
|
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";
|
2026-09-08 12:43:00 +08:00
|
|
|
|
import { SourceDetails, sourceLabel } from "../research/SourceDetails";
|
2026-09-08 10:06:00 +08:00
|
|
|
|
|
|
|
|
|
|
export function BacktestPage({
|
2026-09-08 19:28:44 +08:00
|
|
|
|
version = 0,
|
2026-09-08 10:06:00 +08:00
|
|
|
|
active,
|
|
|
|
|
|
suspended,
|
|
|
|
|
|
chatOffset,
|
|
|
|
|
|
timezone,
|
|
|
|
|
|
action,
|
|
|
|
|
|
onAction,
|
|
|
|
|
|
onContext,
|
|
|
|
|
|
}: {
|
2026-09-08 19:28:44 +08:00
|
|
|
|
version?: number;
|
2026-09-08 10:06:00 +08:00
|
|
|
|
active: boolean;
|
|
|
|
|
|
suspended: boolean;
|
|
|
|
|
|
chatOffset: number;
|
|
|
|
|
|
timezone?: string;
|
|
|
|
|
|
action: UIAction | null;
|
|
|
|
|
|
onAction: (action: UIAction) => void;
|
|
|
|
|
|
onContext: (context: PageContext) => void;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
const [view, setView] = useState("runs");
|
2026-09-08 12:43:00 +08:00
|
|
|
|
const [sourceFilter, setSourceFilter] = useState("");
|
|
|
|
|
|
const [sourceKinds, setSourceKinds] = useState<string[]>([]);
|
2026-09-08 10:06:00 +08:00
|
|
|
|
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("");
|
2026-09-12 01:24:02 +08:00
|
|
|
|
const [inputIds, setInputIds] = useState<string[]>([]);
|
|
|
|
|
|
const [inputs, setInputs] = useState<InputSnapshot[]>([]);
|
2026-09-08 10:06:00 +08:00
|
|
|
|
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 () => {
|
2026-09-08 12:43:00 +08:00
|
|
|
|
const [r, d, c, kinds] = await Promise.all([
|
|
|
|
|
|
api<Page<Run>>(
|
|
|
|
|
|
`/backtests/runs?offset=${(page - 1) * 25}&source=${encodeURIComponent(sourceFilter)}`,
|
|
|
|
|
|
),
|
2026-09-08 10:06:00 +08:00
|
|
|
|
api<Page<DraftSummary>>(`/backtests/drafts?offset=${(page - 1) * 25}`),
|
|
|
|
|
|
api<Scheduler>("/backtests/config"),
|
2026-09-08 12:43:00 +08:00
|
|
|
|
api<string[]>("/backtests/sources"),
|
2026-09-08 10:06:00 +08:00
|
|
|
|
]);
|
|
|
|
|
|
setRuns(r);
|
|
|
|
|
|
setDrafts(d);
|
|
|
|
|
|
setConfig(c);
|
2026-09-08 12:43:00 +08:00
|
|
|
|
setSourceKinds(kinds);
|
|
|
|
|
|
}, [page, sourceFilter]);
|
2026-09-08 10:06:00 +08:00
|
|
|
|
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);
|
|
|
|
|
|
};
|
2026-09-08 19:28:44 +08:00
|
|
|
|
}, [active, refresh, version]);
|
2026-09-08 10:06:00 +08:00
|
|
|
|
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);
|
|
|
|
|
|
};
|
2026-09-08 19:28:44 +08:00
|
|
|
|
}, [runId, resultPage, version]);
|
2026-09-08 10:06:00 +08:00
|
|
|
|
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]);
|
|
|
|
|
|
|
2026-09-09 16:18:27 +08:00
|
|
|
|
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);
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
2026-09-08 10:06:00 +08:00
|
|
|
|
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" });
|
2026-09-12 01:24:02 +08:00
|
|
|
|
setInputIds([]);
|
|
|
|
|
|
setInputs([]);
|
2026-09-08 10:06:00 +08:00
|
|
|
|
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("请提供非空候选集合");
|
2026-09-12 01:24:02 +08:00
|
|
|
|
return { name, source, candidates, ...researchSelection(inputIds, inputs) };
|
2026-09-08 10:06:00 +08:00
|
|
|
|
}
|
|
|
|
|
|
async function loadDraft(id: string) {
|
|
|
|
|
|
setSettingsDraft(null);
|
|
|
|
|
|
const d = await api<Draft>(`/backtests/drafts/${id}`);
|
|
|
|
|
|
setDraft(d);
|
|
|
|
|
|
setName(d.name);
|
|
|
|
|
|
setSource(d.source);
|
2026-09-12 01:24:02 +08:00
|
|
|
|
setInputIds(d.source.input_snapshot_ids ?? []);
|
|
|
|
|
|
setInputs([]);
|
2026-09-08 10:06:00 +08:00
|
|
|
|
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>
|
2026-09-08 12:43:00 +08:00
|
|
|
|
{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);
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
2026-09-09 09:29:30 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<div className="backtest-toolbar">
|
2026-09-08 10:06:00 +08:00
|
|
|
|
<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"
|
2026-09-11 13:32:31 +08:00
|
|
|
|
className="backtest-name-button"
|
|
|
|
|
|
title={String(value)}
|
2026-09-08 10:06:00 +08:00
|
|
|
|
onClick={() => openRun(r!.backtest_run_id)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{String(value)}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
),
|
|
|
|
|
|
},
|
2026-09-08 12:43:00 +08:00
|
|
|
|
{
|
|
|
|
|
|
title: "来源",
|
|
|
|
|
|
width: 120,
|
|
|
|
|
|
render: (_, r) => sourceLabel(r!.source.kind),
|
|
|
|
|
|
},
|
2026-09-08 10:06:00 +08:00
|
|
|
|
{
|
|
|
|
|
|
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"
|
2026-09-11 13:32:31 +08:00
|
|
|
|
className="backtest-name-button"
|
|
|
|
|
|
title={String(v)}
|
2026-09-08 10:06:00 +08:00
|
|
|
|
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}
|
2026-09-09 09:29:30 +08:00
|
|
|
|
width={`min(60vw, calc(100vw - ${chatOffset}px))`}
|
2026-09-08 10:06:00 +08:00
|
|
|
|
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}{" "}
|
2026-09-08 12:43:00 +08:00
|
|
|
|
个平台批次 · 来源 {sourceLabel(preview.source.kind)}
|
2026-09-08 10:06:00 +08:00
|
|
|
|
</p>
|
2026-09-08 12:43:00 +08:00
|
|
|
|
<SourceDetails source={preview.source} onAction={onAction} />
|
2026-09-08 10:06:00 +08:00
|
|
|
|
{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>
|
2026-09-12 01:24:02 +08:00
|
|
|
|
<ResearchDataInput
|
|
|
|
|
|
label="选择回测数据准备(可选)"
|
|
|
|
|
|
ids={inputIds}
|
|
|
|
|
|
inputs={inputs}
|
|
|
|
|
|
onChange={(ids, rows) => {
|
|
|
|
|
|
setInputIds(ids);
|
|
|
|
|
|
setInputs(rows);
|
|
|
|
|
|
const first = rows.find((r) => r.id === ids[0]);
|
|
|
|
|
|
if (first)
|
|
|
|
|
|
setSettings((old) => ({
|
|
|
|
|
|
...old,
|
|
|
|
|
|
region: first.scope.region,
|
|
|
|
|
|
universe: first.scope.universe,
|
|
|
|
|
|
delay: first.scope.delay,
|
|
|
|
|
|
}));
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
2026-09-08 10:06:00 +08:00
|
|
|
|
<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}
|
2026-09-09 09:29:30 +08:00
|
|
|
|
width={`min(60vw, calc(100vw - ${chatOffset}px))`}
|
2026-09-08 10:06:00 +08:00
|
|
|
|
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">
|
2026-09-08 12:43:00 +08:00
|
|
|
|
来源 {sourceLabel(run.source.kind)}{" "}
|
2026-09-08 10:06:00 +08:00
|
|
|
|
{run.source.batch_id ? `· 批次 ${run.source.batch_id}` : ""} ·{" "}
|
|
|
|
|
|
{formatTime(run.created_at, timezone)}
|
|
|
|
|
|
</p>
|
2026-09-08 12:43:00 +08:00
|
|
|
|
<SourceDetails source={run.source} onAction={onAction} />
|
2026-09-08 10:06:00 +08:00
|
|
|
|
{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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|