feat: run fixed research pipelines within durable budgets

This commit is contained in:
yuxuanhui
2026-09-08 22:16:05 +08:00
parent eb4850a003
commit 7860434b04
25 changed files with 2357 additions and 37 deletions
+306
View File
@@ -0,0 +1,306 @@
import { useEffect, useState } from "react";
import {
Banner,
Button,
Input,
InputNumber,
Modal,
TextArea,
Toast,
} from "@douyinfe/semi-ui-19";
import { api, post } from "../api";
import type { InputSnapshot, Asset } from "./workspaceTypes";
import type { FlowLaunch, FlowRun, Budget } from "./flowTypes";
import type { SimulationSettings } from "../backtests/types";
import { ResearchSelect } from "./ResearchSelect";
export function FlowLaunchForm({
onStarted,
workflow,
}: {
onStarted: (r: FlowRun) => void;
workflow?: { id: string; version: number; name: string };
}) {
const [inputs, setInputs] = useState<InputSnapshot[]>([]);
const [templates, setTemplates] = useState<Asset[]>([]);
const [ids, setIds] = useState<string[]>([]);
const [template, setTemplate] = useState<string>();
const [name, setName] = useState("固定研究流水线");
const [hypothesis, setHypothesis] = useState("");
const [parent, setParent] = useState("");
const [budget, setBudget] = useState<Budget>({
max_rounds: 3,
max_simulations: 24,
max_model_calls: 5,
});
const [batch, setBatch] = useState(8);
const [seed, setSeed] = useState(0);
const [confirmation, setConfirmation] = useState<FlowLaunch | null>(null);
const [busy, setBusy] = useState(false);
const [settings, setSettings] = useState<SimulationSettings>({
instrumentType: "EQUITY",
region: "USA",
universe: "TOP3000",
delay: 1,
decay: 0,
neutralization: "INDUSTRY",
truncation: 0.08,
pasteurization: "ON",
unitHandling: "VERIFY",
nanHandling: "OFF",
language: "FASTEXPR",
visualization: false,
maxTrade: "OFF",
});
useEffect(() => {
const c = new AbortController();
Promise.all([
api<{ items: InputSnapshot[] }>("/research/inputs", { signal: c.signal }),
api<{ items: Asset[] }>("/research/assets?kind=template&limit=100", {
signal: c.signal,
}),
])
.then(([i, t]) => {
setInputs(i.items);
setTemplates(t.items);
})
.catch((e) => {
if (!c.signal.aborted) Toast.error(e.message);
});
return () => c.abort();
}, []);
function preview() {
const fixed = templates.find((t) => t.id === template);
setConfirmation({
request_id: crypto.randomUUID(),
name,
input_ids: ids,
hypothesis,
settings,
budget,
batch_candidates: batch,
seed,
parent_alpha_ids: parent.split(/[,,\s]+/).filter(Boolean),
...(fixed
? { template_id: fixed.id, template_version: fixed.version }
: {}),
...(workflow
? { workflow_id: workflow.id, workflow_version: workflow.version }
: {}),
});
}
async function start() {
if (!confirmation) return;
setBusy(true);
try {
const run = await post<FlowRun>("/research/flows/runs", confirmation);
setConfirmation(null);
onStarted(run);
} catch (e) {
Toast.error((e as Error).message);
} finally {
setBusy(false);
}
}
return (
<section className="research-card">
<h3>
{workflow
? `启动 ${workflow.name} v${workflow.version}`
: "新建固定研究"}
</h3>
<div className="research-form-grid">
<label>
研究名称
<Input aria-label="自动研究名称" value={name} onChange={setName} />
</label>
<label>
种子 Alpha(可选)
<Input
aria-label="自动研究种子"
value={parent}
onChange={setParent}
placeholder="多个 ID 用逗号分隔"
/>
</label>
</div>
<label>
研究假设
<TextArea
aria-label="自动研究假设"
value={hypothesis}
onChange={setHypothesis}
/>
</label>
<label>
固定数据范围
<ResearchSelect
label="自动研究固定输入"
multiple
filter
value={ids}
optionList={inputs.map((i) => ({
value: i.id,
label: `${i.dataset_id} · ${i.scope.region}/${i.scope.universe}/D${i.scope.delay} · ${i.id.slice(0, 8)}`,
}))}
onChange={(v) => {
const next = v as string[];
setIds(next);
const first = inputs.find((i) => i.id === next[0]);
if (first)
setSettings((s) => ({
...s,
region: first.scope.region,
universe: first.scope.universe,
delay: first.scope.delay,
}));
}}
/>
</label>
<label>
初始模板版本(可选)
<ResearchSelect
label="自动研究初始模板"
filter
showClear
value={template}
optionList={templates.map((t) => ({
value: t.id,
label: `${t.name} · v${t.version}`,
}))}
onChange={(v) => setTemplate(v ? String(v) : undefined)}
/>
</label>
<p>
{settings.region} / {settings.universe} / D{settings.delay} · REGULAR /
FASTEXPR / EQUITY
</p>
<div className="research-form-grid">
<label>
中性化
<Input
aria-label="自动研究中性化"
value={settings.neutralization}
onChange={(neutralization) =>
setSettings({ ...settings, neutralization })
}
/>
</label>
<label>
Decay
<InputNumber
aria-label="自动研究 Decay"
min={0}
value={settings.decay}
onChange={(v) => {
if (typeof v === "number") setSettings({ ...settings, decay: v });
}}
/>
</label>
<label>
Truncation
<InputNumber
aria-label="自动研究 Truncation"
min={0}
max={1}
step={0.01}
value={settings.truncation}
onChange={(v) => {
if (typeof v === "number")
setSettings({ ...settings, truncation: v });
}}
/>
</label>
</div>
<h4>本次授权预算</h4>
<div className="research-form-grid">
{(
[
["max_rounds", "最大轮数"],
["max_simulations", "最大模拟条目数"],
["max_model_calls", "最大模型调用数"],
] as const
).map(([key, label]) => (
<label key={key}>
{label}
<InputNumber
aria-label={label}
min={1}
precision={0}
value={budget[key]}
onChange={(v) => {
if (typeof v === "number") setBudget({ ...budget, [key]: v });
}}
/>
</label>
))}
<label>
每轮候选上限
<InputNumber
aria-label="每轮候选上限"
min={1}
max={100}
precision={0}
value={batch}
onChange={(v) => {
if (typeof v === "number") setBatch(v);
}}
/>
</label>
<label>
采样种子
<InputNumber
aria-label="自动研究采样种子"
precision={0}
value={seed}
onChange={(v) => {
if (typeof v === "number") setSeed(v);
}}
/>
</label>
</div>
<Banner
type="info"
description="启动后,在确认的有限预算内自动生成、校验、回测、评估和增强。超出预算即停止推进;扩大范围或预算需要重新确认。"
/>
<Button
theme="solid"
disabled={!ids.length || !hypothesis.trim() || !name.trim()}
onClick={preview}
>
核对并启动研究
</Button>
<Modal
title="确认自动研究授权"
visible={Boolean(confirmation)}
onCancel={() => {
if (!busy) setConfirmation(null);
}}
onOk={() => void start()}
confirmLoading={busy}
okText="确认并开始自动研究"
>
{confirmation && (
<>
<p>
{confirmation.name}:{confirmation.hypothesis}
</p>
<p>
{confirmation.input_ids.length} 个固定输入 · {settings.region}/
{settings.universe}/D{settings.delay}
</p>
<p>
最多 {confirmation.budget.max_rounds} 轮、
{confirmation.budget.max_simulations} 条模拟、
{confirmation.budget.max_model_calls} 次模型调用;每轮最多{" "}
{confirmation.batch_candidates} 个候选。
</p>
<p>
评估规则
research-v1。模型输出不能扩展固定范围;每轮候选预览会先保存,再由服务端核验授权后启动回测。
</p>
</>
)}
</Modal>
</section>
);
}
+195
View File
@@ -0,0 +1,195 @@
import { Banner, Button, Tag, Toast } from "@douyinfe/semi-ui-19";
import { useState } from "react";
import { post } from "../api";
import type { UIAction } from "../ai/types";
import type { FlowRun } from "./flowTypes";
import { flowStatus } from "./flowTypes";
export function FlowRunView({
run,
onRefresh,
onAction,
}: {
run: FlowRun;
onRefresh: () => void;
onAction: (a: UIAction) => void;
}) {
const [busy, setBusy] = useState(false);
async function control(action: string) {
setBusy(true);
try {
await post(`/research/flows/runs/${run.id}/control`, {
action,
version: run.version,
});
} catch (e) {
Toast.error((e as Error).message);
} finally {
setBusy(false);
onRefresh();
}
}
const budget = run.authorization.budget;
return (
<section className="research-card" aria-label="研究运行详情">
<div className="section-toolbar">
<h3>{run.name}</h3>
<Tag>{flowStatus[run.status] || run.status}</Tag>
</div>
<p>{run.authorization.hypothesis}</p>
<div className="research-form-grid">
<div>
研究轮数{" "}
<strong>
{run.round} / {budget.max_rounds}
</strong>
</div>
<div>
模拟条目{" "}
<strong>
{run.simulations_used} / {budget.max_simulations}
</strong>
</div>
<div>
模型调用{" "}
<strong>
{run.model_calls_used} / {budget.max_model_calls}
</strong>
</div>
</div>
{run.error && <Banner type="warning" description={run.error} />}
<div className="inline-actions">
<Button
disabled={busy || !["queued", "running"].includes(run.status)}
onClick={() => void control("pause")}
>
暂停研究
</Button>
<Button
disabled={
busy ||
!["paused", "interrupted", "needs_review"].includes(run.status) ||
run.steps.some((step) => step.status === "blocked")
}
onClick={() => void control("resume")}
>
恢复研究
</Button>
<Button
type="danger"
disabled={busy || ["completed", "stopped"].includes(run.status)}
onClick={() => void control("stop")}
>
停止研究
</Button>
</div>
<p className="research-hint">
暂停和停止阻止后续步骤,已发出的模拟继续收集。预算不足或范围、模型配置变化时,需要重新确认新研究;未知提交请在关联回测中核实。
</p>
<details>
<summary>本次固定授权</summary>
<p>
{run.authorization.settings.region} /{" "}
{run.authorization.settings.universe} / D
{run.authorization.settings.delay} ·{" "}
{run.authorization.methods.join(" → ")}
</p>
<pre className="code-block">
{JSON.stringify(run.authorization, null, 2)}
</pre>
</details>
<div className="research-table-scroll">
<table className="research-table">
<thead>
<tr>
<th>轮次 / 阶段</th>
<th>状态</th>
<th>产物与候选池</th>
</tr>
</thead>
<tbody>
{run.steps.map((step) => (
<tr key={step.id}>
<td>
{step.round} ·{" "}
{run.definition.nodes.find((n) => n.id === step.node_id)
?.label || step.node_id}
</td>
<td>
{flowStatus[step.status] || step.status}
{step.error && <p>{step.error}</p>}
</td>
<td>
<div className="inline-actions">
{step.output.template && (
<Button
size="small"
onClick={() =>
onAction({
type: "open_template",
asset_id: step.output.template!.id,
version: step.output.template!.version,
nonce: Date.now(),
})
}
>
模板 v{step.output.template.version}
</Button>
)}
{step.output.experiment_id && (
<Button
size="small"
onClick={() =>
onAction({
type: "open_experiment",
experiment_id: step.output.experiment_id!,
nonce: Date.now(),
})
}
>
候选实验 {step.output.candidate_ids?.length ?? ""}
</Button>
)}
{step.output.preview_id && (
<Button
size="small"
onClick={() =>
onAction({
type: "open_backtest_preview",
preview_id: step.output.preview_id!,
nonce: Date.now(),
})
}
>
固定预览
</Button>
)}
{step.backtest_run_id && (
<Button
size="small"
onClick={() =>
onAction({
type: "open_backtest",
run_id: step.backtest_run_id!,
nonce: Date.now(),
})
}
>
关联回测
</Button>
)}
</div>
<details>
<summary>步骤产物</summary>
<pre className="code-block">
{JSON.stringify(step.output, null, 2)}
</pre>
</details>
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
);
}
+125
View File
@@ -0,0 +1,125 @@
import { useEffect, useState } from "react";
import { Banner, Button, Pagination } from "@douyinfe/semi-ui-19";
import { api, formatTime } from "../api";
import type { PageContext, UIAction } from "../ai/types";
import { FlowLaunchForm } from "./FlowLaunchForm";
import { FlowRunView } from "./FlowRunView";
import type { FlowRun } from "./flowTypes";
import { flowStatus } from "./flowTypes";
import "./workspace.css";
export function PipelinePage({
active,
onAction,
onContext,
}: {
active: boolean;
onAction: (a: UIAction) => void;
onContext: (c: PageContext) => void;
}) {
const [items, setItems] = useState<FlowRun[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [selected, setSelected] = useState<string | null>(() =>
localStorage.getItem("research-selected-flow"),
);
const [run, setRun] = useState<FlowRun | null>(null);
const [creating, setCreating] = useState(!selected);
const [revision, setRevision] = useState(0);
const [error, setError] = useState("");
useEffect(() => {
if (active) onContext({ page: "pipeline", research_run_id: selected });
}, [active, selected, onContext]);
useEffect(() => {
if (!active) return;
const c = new AbortController();
let timer: ReturnType<typeof setTimeout>;
async function refresh() {
try {
const list = await api<{ items: FlowRun[]; total: number }>(
`/research/flows/runs?offset=${(page - 1) * 25}`,
{ signal: c.signal },
);
setItems(list.items);
setTotal(list.total);
if (selected)
setRun(
await api(`/research/flows/runs/${selected}`, { signal: c.signal }),
);
setError("");
} catch (e) {
if (!c.signal.aborted) setError((e as Error).message);
} finally {
if (!c.signal.aborted) timer = setTimeout(() => void refresh(), 1500);
}
}
void refresh();
return () => {
c.abort();
clearTimeout(timer);
};
}, [active, page, selected, revision]);
function select(id: string) {
setSelected(id);
localStorage.setItem("research-selected-flow", id);
setCreating(false);
}
return (
<section className="research-workspace">
<div className="section-toolbar">
<div>
<h2>研究流水线</h2>
<p className="muted">
生成 → 校验与设参 → 回测 → 评估决策 → 增强 → 重新展开
</p>
</div>
<Button onClick={() => setCreating(true)}>新建自动研究</Button>
</div>
{error && <Banner type="danger" description={error} />}
<div className="research-layout">
<aside className="research-library">
<h3>研究运行</h3>
{items.map((item) => (
<button
className={`research-library-item ${selected === item.id ? "selected" : ""}`}
key={item.id}
onClick={() => select(item.id)}
>
<strong>{item.name}</strong>
<span>
{flowStatus[item.status]} · 第 {item.round} 轮
</span>
<span>{formatTime(item.created_at)}</span>
</button>
))}
<Pagination
size="small"
total={total}
currentPage={page}
pageSize={25}
onPageChange={setPage}
/>
</aside>
<div className="research-main">
{creating ? (
<FlowLaunchForm
key="new-flow"
onStarted={(r) => {
select(r.id);
setRun(r);
setRevision((v) => v + 1);
}}
/>
) : (
run && (
<FlowRunView
run={run}
onRefresh={() => setRevision((v) => v + 1)}
onAction={onAction}
/>
)
)}
</div>
</div>
</section>
);
}
+27 -25
View File
@@ -8,31 +8,33 @@ export function ResearchToolCard({ call, onAction }: ToolCardProps) {
{typeof result.name === "string" ? result.name : "研究资料"}
{typeof result.version === "number" ? ` · v${result.version}` : ""}
</p>
{typeof result.id === "string" && !result.report && (
<Button
onClick={() =>
onAction(
result.content
? {
type:
result.kind === "feature"
? "open_feature"
: "open_template",
asset_id: result.id as string,
version: result.version as number,
nonce: Date.now(),
}
: {
type: "open_experiment",
experiment_id: result.id as string,
nonce: Date.now(),
},
)
}
>
打开研究记录
</Button>
)}
{typeof result.id === "string" &&
!result.report &&
!result.authorization && (
<Button
onClick={() =>
onAction(
result.content
? {
type:
result.kind === "feature"
? "open_feature"
: "open_template",
asset_id: result.id as string,
version: result.version as number,
nonce: Date.now(),
}
: {
type: "open_experiment",
experiment_id: result.id as string,
nonce: Date.now(),
},
)
}
>
打开研究记录
</Button>
)}
<details>
<summary>查看资料与校验依据</summary>
<pre>{JSON.stringify(result, null, 2)}</pre>
+78
View File
@@ -0,0 +1,78 @@
import type { SimulationSettings } from "../backtests/types";
export type Budget = {
max_rounds: number;
max_simulations: number;
max_model_calls: number;
};
export type FlowNode = {
id: string;
type: string;
label: string;
x: number;
y: number;
config: Record<string, unknown>;
};
export type Workflow = {
name: string;
nodes: FlowNode[];
edges: { source: string; target: string; branch?: string | null }[];
};
export type FlowLaunch = {
request_id: string;
name: string;
input_ids: string[];
hypothesis: string;
settings: SimulationSettings;
budget: Budget;
batch_candidates: number;
seed: number;
parent_alpha_ids: string[];
template_id?: string;
template_version?: number;
workflow_id?: string;
workflow_version?: number;
};
export type FlowRun = {
id: string;
name: string;
status: string;
version: number;
round: number;
simulations_used: number;
model_calls_used: number;
error: string | null;
created_at: string;
definition: Workflow;
authorization: FlowLaunch & { kind: string; methods: string[] };
steps: {
id: string;
node_id: string;
round: number;
status: string;
output: {
type?: string;
template?: { id: string; version: number };
experiment_id?: string;
evaluation_id?: string;
preview_id?: string;
candidate_ids?: string[];
[key: string]: unknown;
};
backtest_run_id: string | null;
error: string | null;
}[];
};
export const flowStatus: Record<string, string> = {
queued: "等待推进",
running: "执行中",
completed: "已完成",
skipped: "已跳过",
blocked: "候选校验未通过",
waiting: "收集回测",
previewed: "候选预览已固定",
paused: "已暂停",
stopped: "已停止",
interrupted: "步骤中断",
needs_review: "需要处理",
budget_exhausted: "预算不足",
};