Simplify template candidate confirmation and direct batch backtesting
Deploy production / deploy (push) Successful in 57s

This commit is contained in:
yuxuanhui
2026-09-20 11:01:07 +08:00
parent 07dd767c52
commit 13a2168ca5
14 changed files with 510 additions and 245 deletions
+93
View File
@@ -0,0 +1,93 @@
import { useState } from "react";
import { Pagination } from "@douyinfe/semi-ui-19";
import { WorkspaceTable } from "../components/WorkspaceTable";
import type { ResearchCandidate } from "./workspaceTypes";
/** Selection spans pages; eligibility is supplied by the producing workflow. */
export function CandidateTable({
candidates,
selected,
onSelectionChange,
disabled = false,
requireValidation = false,
}: {
candidates: ResearchCandidate[];
selected: string[];
onSelectionChange: (ids: string[]) => void;
disabled?: boolean;
requireValidation?: boolean;
}) {
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(25);
return (
<>
<WorkspaceTable<ResearchCandidate>
dataSource={candidates.slice((page - 1) * pageSize, page * pageSize)}
className="research-candidate-table"
rowKey="client_item_id"
scroll={{ x: 1120, y: 400 }}
empty="暂无候选表达式。"
rowSelection={{
width: 48,
disabled,
selectedRowKeys: selected,
onChange: (keys) => onSelectionChange((keys ?? []).map(String)),
getCheckboxProps: (candidate) => ({
disabled:
disabled ||
(requireValidation && candidate.validation?.status !== "valid"),
"aria-label": `选择候选 ${candidate.client_item_id}`,
title: requireValidation
? [
...(candidate.validation?.syntax ?? []),
...(candidate.validation?.types ?? []),
...(candidate.validation?.availability ?? []),
].join(";")
: undefined,
}),
}}
columns={[
{
title: "表达式",
dataIndex: "expression",
width: 420,
render: (value) => <code title={value}>{value}</code>,
},
...(
[
["neutralization", "Neutralization", 170],
["decay", "Decay", 90],
["truncation", "Truncation", 120],
["maxTrade", "Max Trade", 120],
["nanHandling", "NaN Handling", 150],
] as const
).map(([key, title, width]) => ({
key,
title,
width,
render: (_: unknown, candidate: ResearchCandidate) =>
candidate.settings[key],
})),
]}
/>
<div className="table-pagination workspace-table-footer">
<span>
共 {candidates.length} 个候选 · 已选 {selected.length} 个
</span>
<Pagination
currentPage={page}
pageSize={pageSize}
total={candidates.length}
showSizeChanger
pageSizeOpts={[25, 50, 100]}
preventPageChangeOnPageSizeChange
onPageChange={setPage}
onPageSizeChange={(size) => {
setPageSize(size);
setPage(1);
}}
/>
</div>
</>
);
}
+13 -180
View File
@@ -1,19 +1,12 @@
import { useState } from "react";
import {
Banner,
Button,
Checkbox,
Table,
Tag,
Toast,
} from "@douyinfe/semi-ui-19";
import { Banner, Button, Toast } from "@douyinfe/semi-ui-19";
import { post, formatTime } from "../api";
import type { UIAction } from "../ai/types";
import type { Experiment } from "./workspaceTypes";
import { EvaluationPanel } from "./EvaluationPanel";
import { LineagePanel } from "./LineagePanel";
import { ResearchSelect } from "./ResearchSelect";
import { validationLabel } from "./workspaceTypes";
import { CandidateTable } from "./CandidateTable";
import { DeleteResearchButton } from "./DeleteResearchButton";
export function ExperimentView({
@@ -33,7 +26,7 @@ export function ExperimentView({
const [runId, setRunId] = useState(experiment.backtest_run_ids[0]);
const [busy, setBusy] = useState(false);
const valid = experiment.candidates.filter(
(c) => c.validation.status === "valid",
(c) => c.validation?.status === "valid",
);
async function preview() {
setBusy(true);
@@ -72,7 +65,10 @@ export function ExperimentView({
生成回测确认预览{selected.length ? `(${selected.length})` : ""}
</Button>
</div>
<p>{experiment.hypothesis}</p>
<details>
<summary>研究描述</summary>
<p>{experiment.hypothesis}</p>
</details>
{experiment.archived ? (
<Banner
type="info"
@@ -92,175 +88,12 @@ export function ExperimentView({
type="info"
description="候选已保存为不可变研究记录。本地校验不保证平台可执行;在回测预览中确认后才开始模拟。"
/>
<div className="research-table-scroll">
<Table<Experiment["candidates"][number]>
className="research-table"
dataSource={experiment.candidates}
rowKey="client_item_id"
pagination={false}
size="small"
style={{ minWidth: 840 }}
empty="暂无候选表达式。"
columns={[
{
title: "选择",
key: "selection",
width: 64,
render: (_, candidate) => (
<Checkbox
aria-label={`选择候选 ${candidate.client_item_id}`}
disabled={candidate.validation.status !== "valid"}
checked={selected.includes(candidate.client_item_id)}
onChange={(event) =>
setSelected((old) =>
event.target.checked
? [...old, candidate.client_item_id]
: old.filter((id) => id !== candidate.client_item_id),
)
}
/>
),
},
{
title: "候选表达式",
key: "expression",
render: (_, candidate) => (
<>
<code>{candidate.expression}</code>
<details>
<summary>绑定与改动</summary>
<pre>
{JSON.stringify(
{
bindings: candidate.bindings,
changes: candidate.changes,
},
null,
2,
)}
</pre>
</details>
</>
),
},
{
title: "市场与设置",
key: "settings",
width: 240,
render: (_, candidate) => (
<>
{candidate.settings.region} / {candidate.settings.universe} /
D{candidate.settings.delay}
<br />
{candidate.settings.neutralization} · decay{" "}
{candidate.settings.decay}
</>
),
},
{
title: "校验",
key: "validation",
width: 180,
render: (_, candidate) => (
<>
<Tag
color={
candidate.validation.status === "valid"
? "green"
: "orange"
}
>
{validationLabel[candidate.validation.status]}
</Tag>
{(["syntax", "types", "availability"] as const).map((key) =>
candidate.validation[key].map((issue, index) => (
<p key={`${key}-${index}`}>
{
{
syntax: "语法",
types: "类型",
availability: "可用性",
}[key]
}
:{issue}
</p>
)),
)}
</>
),
},
]}
/>
</div>
<div className="research-lineage">
<strong>研究来源</strong>
<span>实验 {experiment.id}</span>
{experiment.evidence.template?.id && (
<Button
theme="borderless"
onClick={() =>
onAction({
type: "open_template",
asset_id: experiment.evidence.template!.id,
version: experiment.evidence.template!.version,
nonce: Date.now(),
})
}
>
模板 v{experiment.evidence.template.version}
</Button>
)}
{experiment.inputs.map((input) => (
<Button
key={input.id}
theme="borderless"
onClick={() =>
onAction({
type: "open_research_input",
input_id: input.id,
nonce: Date.now(),
})
}
>
{input.name} · {input.scope.region}/{input.scope.universe}/D
{input.scope.delay}
</Button>
))}
{experiment.parents.map((parent) => (
<Button
key={`${parent.kind}:${parent.id}`}
theme="borderless"
onClick={() =>
onAction(
parent.kind === "alpha"
? {
type: "open_alpha",
alpha_id: parent.id,
nonce: Date.now(),
}
: {
type: "open_experiment",
experiment_id: parent.id,
nonce: Date.now(),
},
)
}
>
父来源:{parent.id}
</Button>
))}
{experiment.backtest_run_ids.map((id) => (
<Button
key={id}
theme="borderless"
onClick={() =>
onAction({ type: "open_backtest", run_id: id, nonce: Date.now() })
}
>
查看关联回测
</Button>
))}
</div>
<CandidateTable
candidates={experiment.candidates}
selected={selected}
onSelectionChange={setSelected}
requireValidation
/>
<div className="inline-actions">
<Button onClick={() => setAssessment((v) => !v)}>评估研究结果</Button>
<Button onClick={() => setRelations((v) => !v)}>查看变体关系</Button>
+31 -8
View File
@@ -33,6 +33,7 @@ import {
} from "./workspaceTypes";
import { TemplateEditor } from "./TemplateEditor";
import { ExperimentView } from "./ExperimentView";
import { TemplateCandidateSet } from "./TemplateCandidateSet";
import { DeleteResearchButton } from "./DeleteResearchButton";
import { ComparisonPanel } from "./ComparisonPanel";
import "./workspace.css";
@@ -83,9 +84,27 @@ export function ResearchWorkspace({
const [assetTotal, setAssetTotal] = useState(0);
const [historyTotal, setHistoryTotal] = useState(0);
const refreshSequence = useRef(0);
const CandidateView =
experiment?.kind === "template" ? TemplateCandidateSet : ExperimentView;
const selectedInputs = inputs.filter((input) => inputIds.includes(input.id));
const dirty =
!asset || JSON.stringify(template) !== JSON.stringify(asset.content);
const generationKey = JSON.stringify([
asset?.id,
asset?.version,
template,
inputIds,
inputs,
settings,
mode,
limit,
seed,
]);
const currentGeneration = useRef(generationKey);
currentGeneration.current = generationKey;
useEffect(() => {
if (page === "templates" && detailMode === "editor") setExperiment(null);
}, [page, template, inputIds, inputs, settings, mode, limit, seed]);
async function task(label: string, action: () => Promise<void>) {
setBusy(label);
setError("");
@@ -99,13 +118,12 @@ export function ResearchWorkspace({
}
async function refresh() {
const sequence = ++refreshSequence.current;
const [nextAssets, nextInputs, nextHistory] = await Promise.all([
const [nextAssets, nextHistory] = await Promise.all([
api<{ items: Asset[]; total: number }>(
`/research/assets?kind=template&limit=25&offset=${assetPage * 25}&q=${encodeURIComponent(search)}`,
),
Promise.resolve({ items: inputs }),
api<{ items: typeof history; total: number }>(
`/research/experiments?limit=25&offset=${historyPage * 25}`,
`/research/experiments?kind=${page === "templates" ? "template" : "variant"}&limit=25&offset=${historyPage * 25}`,
),
]);
if (sequence !== refreshSequence.current) return;
@@ -118,7 +136,6 @@ export function ResearchWorkspace({
Math.min(current, Math.max(0, Math.ceil(nextHistory.total / 25) - 1)),
);
setAssets(nextAssets.items);
setInputs(nextInputs.items);
setHistory(nextHistory.items);
}
useEffect(() => {
@@ -261,6 +278,9 @@ export function ResearchWorkspace({
seed,
parent_alpha_ids: parent ? parent.split(/[,,\s]+/).filter(Boolean) : [],
});
if (page === "templates" && currentGeneration.current !== generationKey) {
throw new Error("回测准备已改变,请按当前配置重新生成候选集合");
}
setExperiment(next);
setDetailTab("prepare");
await refresh();
@@ -527,7 +547,7 @@ export function ResearchWorkspace({
{error && <Banner type="danger" description={error} />}
{detailMode === "experiment" ? (
experiment && (
<ExperimentView
<CandidateView
key={experiment.id}
experiment={experiment}
onAction={onAction}
@@ -552,7 +572,7 @@ export function ResearchWorkspace({
onChange={setDetailTab}
tabPaneMotion={false}
tabList={[
{ itemKey: "editor", tab: "模板信息编辑" },
{ itemKey: "editor", tab: "模板详情" },
{ itemKey: "prepare", tab: "回测准备" },
]}
/>
@@ -807,7 +827,7 @@ export function ResearchWorkspace({
{dirty && (
<Banner
type="warning"
description="请返回模板信息编辑,保存模板版本后再生成候选。"
description="请返回模板详情,保存模板版本后再生成候选。"
/>
)}
</>
@@ -845,6 +865,9 @@ export function ResearchWorkspace({
</label>
</div>
<SimulationSettingsEditor
validationMode={
page === "templates" ? "combination" : "platform"
}
value={settings}
onChange={setSettings}
{...options}
@@ -911,7 +934,7 @@ export function ResearchWorkspace({
)}
<div hidden={page === "templates" && detailTab !== "prepare"}>
{experiment && (
<ExperimentView
<CandidateView
key={experiment.id}
experiment={experiment}
onAction={onAction}
@@ -0,0 +1,106 @@
import { useRef, useState } from "react";
import { Banner, Button, Toast } from "@douyinfe/semi-ui-19";
import { post } from "../api";
import type { UIAction } from "../ai/types";
import { CandidateTable } from "./CandidateTable";
import { DeleteResearchButton } from "./DeleteResearchButton";
import type { Experiment } from "./workspaceTypes";
/** The generated collection is the confirmation surface for a template backtest. */
export function TemplateCandidateSet({
experiment,
onAction,
onDelete,
deleting,
}: {
experiment: Experiment;
onAction: (action: UIAction) => void;
onDelete?: () => Promise<void>;
deleting?: boolean;
}) {
const [selected, setSelected] = useState(() =>
experiment.candidates.map((c) => c.client_item_id),
);
const [busy, setBusy] = useState(false);
const [runs, setRuns] = useState(experiment.backtest_run_ids);
const attempt = useRef<{ selection: string; key: string } | null>(null);
const pending = useRef(false);
async function backtest() {
if (pending.current || !selected.length) return;
pending.current = true;
setBusy(true);
// Retain the same request key after network failure: retry must not create a second run.
const selection = JSON.stringify([...selected].sort());
if (attempt.current?.selection !== selection) {
attempt.current = { selection, key: crypto.randomUUID() };
}
try {
const result = await post<{ backtest_run_id: string }>(
`/research/experiments/${experiment.id}/backtest`,
{
candidate_ids: selected,
idempotency_key: attempt.current.key,
},
);
setRuns((old) => [...new Set([...old, result.backtest_run_id])]);
onAction({
type: "open_backtest",
run_id: result.backtest_run_id,
nonce: Date.now(),
});
} catch (error) {
Toast.error((error as Error).message);
} finally {
pending.current = false;
setBusy(false);
}
}
return (
<section className="research-card" aria-label="回测候选集合">
<h3>回测候选集合</h3>
<details>
<summary>研究描述</summary>
<p>{experiment.hypothesis}</p>
</details>
{experiment.archived && (
<Banner type="info" description="此候选集合已删除,保留历史关联。" />
)}
<p>确认表达式和参数后,点击“回测”将所选候选交给回测研究批量执行。</p>
<CandidateTable
candidates={experiment.candidates}
selected={selected}
onSelectionChange={setSelected}
disabled={busy || deleting || experiment.archived}
/>
<div className="research-toolbar">
<Button
theme="solid"
disabled={!selected.length || deleting || experiment.archived}
loading={busy}
onClick={() => void backtest()}
>
回测({selected.length})
</Button>
{onDelete && !experiment.archived && (
<DeleteResearchButton
name={experiment.name}
label="候选集合"
disabled={busy || deleting}
onConfirm={onDelete}
/>
)}
{runs.map((id, index) => (
<Button
key={id}
theme="borderless"
onClick={() =>
onAction({ type: "open_backtest", run_id: id, nonce: Date.now() })
}
>
关联回测 {index + 1}
</Button>
))}
</div>
</section>
);
}
+8
View File
@@ -395,3 +395,11 @@
padding-top: 12px;
}
}
.research-card .research-candidate-table .semi-checkbox {
margin: 0;
flex-direction: row;
}
.research-candidate-table .workspace-cell code {
white-space: nowrap;
}
+1 -1
View File
@@ -55,7 +55,7 @@ export type InputSnapshot = {
created_at: string;
};
export type ResearchCandidate = Candidate & {
validation: {
validation?: {
status: string;
syntax: string[];
types: string[];
@@ -28,7 +28,9 @@ export function SimulationSettingsEditor({
fixedScopes,
disabled = false,
onValidityChange,
validationMode = "platform",
}: {
validationMode?: "platform" | "combination";
alphaType?: "REGULAR" | "SUPER";
value: SimulationSettings;
onChange: (value: SimulationSettings) => void;
@@ -43,18 +45,36 @@ export function SimulationSettingsEditor({
}) {
const scope = settingsScope(value);
const row = rows.find((r) => sameScope(r, scope));
const errors = settingsErrors(value, rows, fixedScopes);
const combinationOnly = validationMode === "combination";
const errors = combinationOnly
? !fixedScopes?.length || fixedScopes.some((s) => !sameScope(s, scope))
? ["所选数据准备必须具有相同组合,并与回测组合一致"]
: []
: settingsErrors(value, rows, fixedScopes);
const [scopeReady, setScopeReady] = useState(false);
const valid =
!loading &&
!error &&
!errors.length &&
(fixedScopes !== undefined || scopeReady);
const valid = combinationOnly
? !errors.length
: !loading &&
!error &&
!errors.length &&
(fixedScopes !== undefined || scopeReady);
useEffect(() => {
onValidityChange?.(valid);
}, [valid, onValidityChange]);
function field(key: string) {
const options = fieldOptions(row, key);
const options = combinationOnly
? key === "neutralization"
? {
choices: [
...new Set([
value.neutralization,
...(row?.neutralizations ??
rows.flatMap((r) => r.neutralizations)),
]),
],
}
: fieldOptions(undefined, key)
: fieldOptions(row, key);
const current =
value[key as keyof SimulationSettings] ??
(key === "maxPosition" ? "OFF" : undefined);
@@ -71,7 +91,7 @@ export function SimulationSettingsEditor({
value={typeof current === "boolean" ? String(current) : current}
disabled={
disabled ||
loading ||
(loading && !combinationOnly) ||
!options.choices.length ||
(options.choices.length === 1 && !message)
}
@@ -97,7 +117,7 @@ export function SimulationSettingsEditor({
? current
: undefined
}
disabled={disabled || loading}
disabled={disabled || (loading && !combinationOnly)}
min={options.minimum}
max={options.maximum}
precision={
@@ -129,16 +149,30 @@ export function SimulationSettingsEditor({
选项更新时间:{formatTime(fetchedAt ?? null)}
</small>
{error && (
<Banner type="warning" description={`${error};请重试同步合法设置`} />
<Banner
type="warning"
description={
combinationOnly
? "参数选项暂不可用,可使用当前参数继续生成候选。"
: `${error};请重试同步合法设置`
}
/>
)}
{!loading && !rows.length && !error && (
<Banner type="info" description="尚无参数选项,请先同步合法设置。" />
<Banner
type="info"
description={
combinationOnly
? "可同步参数选项辅助选择,当前参数仍可用于生成候选。"
: "尚无参数选项,请先同步合法设置。"
}
/>
)}
<ScopePicker
value={scope}
options={asScopeOptions(rows)}
readOnly={fixedScopes !== undefined}
disabled={disabled || loading}
disabled={disabled || (loading && !combinationOnly)}
onValidityChange={setScopeReady}
onChange={(next) =>
onChange({