feat: run fixed research pipelines within durable budgets
This commit is contained in:
+19
-1
@@ -12,6 +12,7 @@ import {
|
||||
} from "@douyinfe/semi-ui-19";
|
||||
import zhCN from "@douyinfe/semi-ui-19/lib/es/locale/source/zh_CN";
|
||||
import { IconSetting, IconComment, IconHistory } from "@douyinfe/semi-icons";
|
||||
import { PipelinePage } from "./research/PipelinePage";
|
||||
import { FeaturesPage } from "./research/FeaturesPage";
|
||||
import { ResearchWorkspace } from "./research/ResearchWorkspace";
|
||||
import { OperatorsPage } from "./research/OperatorsPage";
|
||||
@@ -50,7 +51,11 @@ export default function App() {
|
||||
page: "templates",
|
||||
});
|
||||
useEffect(() => {
|
||||
if (["operators", "templates", "variants", "features"].includes(page))
|
||||
if (
|
||||
["operators", "templates", "variants", "features", "pipeline"].includes(
|
||||
page,
|
||||
)
|
||||
)
|
||||
setVisitedResearch((old) => (old.includes(page) ? old : [...old, page]));
|
||||
}, [page]);
|
||||
const [catalogModal, setCatalogModal] = useState(false);
|
||||
@@ -371,6 +376,15 @@ export default function App() {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{visitedResearch.includes("pipeline") && (
|
||||
<div className="alpha-page-view" hidden={page !== "pipeline"}>
|
||||
<PipelinePage
|
||||
active={page === "pipeline"}
|
||||
onAction={handleAction}
|
||||
onContext={setResearchContext}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{visitedResearch.includes("features") && (
|
||||
<div className="alpha-page-view" hidden={page !== "features"}>
|
||||
<FeaturesPage
|
||||
@@ -448,6 +462,10 @@ export default function App() {
|
||||
onClose={() => setChatOpen(false)}
|
||||
context={
|
||||
{
|
||||
pipeline:
|
||||
researchContext.page === "pipeline"
|
||||
? researchContext
|
||||
: { page: "pipeline" as const },
|
||||
features:
|
||||
researchContext.page === "features"
|
||||
? researchContext
|
||||
|
||||
@@ -19,7 +19,9 @@ export type PageContext = {
|
||||
| "operators"
|
||||
| "templates"
|
||||
| "variants"
|
||||
| "features";
|
||||
| "features"
|
||||
| "pipeline";
|
||||
research_run_id?: string | null;
|
||||
research_asset_id?: string;
|
||||
research_experiment_id?: string;
|
||||
catalog_scope?: {
|
||||
|
||||
@@ -7,6 +7,8 @@ const contextLabels: Record<WorkspacePage, (context: PageContext) => string> = {
|
||||
operators: () => "上下文:算子库",
|
||||
templates: (context) =>
|
||||
`上下文:模板工坊${context.research_asset_id ? ` · ${context.research_asset_id}` : ""}${context.research_experiment_id ? ` · 实验 ${context.research_experiment_id}` : ""}`,
|
||||
pipeline: (context) =>
|
||||
`上下文:研究流水线${context.research_run_id ? ` · ${context.research_run_id}` : ""}`,
|
||||
features: () => "上下文:特征工程",
|
||||
variants: () => "上下文:Alpha 变体",
|
||||
account: () => "上下文:个人信息页",
|
||||
|
||||
@@ -27,6 +27,7 @@ const navigation = [
|
||||
{ id: "features", label: "特征工程", icon: IconBeaker, group: "研究实验" },
|
||||
{ id: "variants", label: "Alpha 变体", icon: IconBeaker, group: "研究实验" },
|
||||
{ id: "backtests", label: "回测研究", icon: IconBeaker, group: "研究实验" },
|
||||
{ id: "pipeline", label: "研究流水线", icon: IconBeaker, group: "研究编排" },
|
||||
{ id: "alphas", label: "Alpha 管理", icon: IconGridView, group: "研究成果" },
|
||||
{ id: "account", label: "个人信息", icon: IconUser, group: "" },
|
||||
] as const;
|
||||
@@ -170,7 +171,7 @@ export function AppSidebar({
|
||||
</div>
|
||||
)}
|
||||
<nav className="sidebar-nav" aria-label="主导航">
|
||||
{["数据与素材", "研究实验", "研究成果"].map((group) => (
|
||||
{["数据与素材", "研究实验", "研究编排", "研究成果"].map((group) => (
|
||||
<div key={group}>
|
||||
{!collapsed && (
|
||||
<div className="sidebar-group-heading">{group}</div>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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: "预算不足",
|
||||
};
|
||||
@@ -0,0 +1,157 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
const headers = { "X-WQ-Request": "1" };
|
||||
const scope = {
|
||||
instrument_type: "EQUITY",
|
||||
region: "USA",
|
||||
universe: "TOP3000",
|
||||
delay: 1,
|
||||
};
|
||||
test("finite pipeline confirms scope and budget, executes native steps and restores run", async ({
|
||||
page,
|
||||
}) => {
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", (e) => errors.push(e.message));
|
||||
await page.goto("/");
|
||||
await page.getByLabel("密码", { exact: true }).fill("browser-test-password");
|
||||
await page.getByRole("button", { name: "进入工作空间" }).click();
|
||||
await page
|
||||
.getByRole("navigation", { name: "主导航" })
|
||||
.getByRole("button", { name: "研究流水线", exact: true })
|
||||
.click();
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "研究流水线", exact: true }),
|
||||
).toBeVisible();
|
||||
await page.request.put("/api/v1/account/credentials", {
|
||||
headers,
|
||||
data: { email: "test@example.com", password: "synthetic-password" },
|
||||
});
|
||||
await page.request.post("/api/v1/account/connect", { headers });
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(await (await page.request.get("/api/v1/account")).json())
|
||||
.connection_status,
|
||||
)
|
||||
.toBe("connected");
|
||||
const config = { base_url: "https://model.test/v1", model: "test-model" };
|
||||
await page.request.put("/api/v1/ai/settings", {
|
||||
headers,
|
||||
data: { ...config, api_key: "synthetic-key" },
|
||||
});
|
||||
expect(
|
||||
(
|
||||
await (
|
||||
await page.request.post("/api/v1/ai/settings/test", { headers })
|
||||
).json()
|
||||
).ready,
|
||||
).toBe(true);
|
||||
await page.request.put("/api/v1/ai/settings", {
|
||||
headers,
|
||||
data: { ...config, enabled: true },
|
||||
});
|
||||
for (const dataset_id of [null, "TEST_FIN"]) {
|
||||
const job = await (
|
||||
await page.request.post("/api/v1/catalog/sync-jobs", {
|
||||
headers,
|
||||
data: { scope, dataset_id },
|
||||
})
|
||||
).json();
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(await (await page.request.get(`/api/v1/sync-jobs/${job.id}`)).json())
|
||||
.status,
|
||||
)
|
||||
.toBe("completed");
|
||||
}
|
||||
const params = new URLSearchParams(
|
||||
Object.entries(scope).map(([k, v]) => [k, String(v)]),
|
||||
);
|
||||
const fields = await (
|
||||
await page.request.get(`/api/v1/catalog/datasets/TEST_FIN/fields?${params}`)
|
||||
).json();
|
||||
const fixed = await (
|
||||
await page.request.post("/api/v1/catalog/inputs", {
|
||||
headers,
|
||||
data: {
|
||||
scope,
|
||||
dataset_id: "TEST_FIN",
|
||||
collection_version: fields.collection_version,
|
||||
selection: "all",
|
||||
},
|
||||
})
|
||||
).json();
|
||||
expect(
|
||||
(
|
||||
await page.request.post("/api/v1/catalog/operators/refresh", { headers })
|
||||
).ok(),
|
||||
).toBe(true);
|
||||
expect(
|
||||
(
|
||||
await page.request.post("/api/v1/catalog/setting-options/refresh", {
|
||||
headers,
|
||||
})
|
||||
).ok(),
|
||||
).toBe(true);
|
||||
await page.reload();
|
||||
await page.getByLabel("自动研究名称").fill("浏览器有限研究");
|
||||
await page.getByLabel("自动研究假设").fill("研究排名稳定性");
|
||||
await page.getByRole("combobox", { name: "自动研究固定输入" }).click();
|
||||
await page
|
||||
.getByRole("option")
|
||||
.filter({ hasText: fixed.id.slice(0, 8) })
|
||||
.click();
|
||||
await page.getByRole("heading", { name: "研究流水线", exact: true }).click();
|
||||
for (const [label, value] of [
|
||||
["最大轮数", "1"],
|
||||
["最大模拟条目数", "2"],
|
||||
["最大模型调用数", "2"],
|
||||
["每轮候选上限", "2"],
|
||||
])
|
||||
await page.getByLabel(label, { exact: true }).fill(value);
|
||||
await page.getByRole("button", { name: "核对并启动研究" }).click();
|
||||
await expect(page.getByRole("dialog", {name: "确认自动研究授权"})).toContainText(
|
||||
"最多 1 轮、2 条模拟、2 次模型调用",
|
||||
);
|
||||
const started = page.waitForResponse(
|
||||
(r) =>
|
||||
r.url().endsWith("/research/flows/runs") &&
|
||||
r.request().method() === "POST",
|
||||
);
|
||||
await page
|
||||
.getByRole("dialog", {name: "确认自动研究授权"})
|
||||
.getByRole("button", { name: "confirm", exact: true })
|
||||
.click();
|
||||
const response = await started;
|
||||
expect(response.status()).toBe(201);
|
||||
const run = await response.json();
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(
|
||||
await (
|
||||
await page.request.get(`/api/v1/research/flows/runs/${run.id}`)
|
||||
).json()
|
||||
).status,
|
||||
{ timeout: 40000 },
|
||||
)
|
||||
.toBe("completed");
|
||||
const completed = await (
|
||||
await page.request.get(`/api/v1/research/flows/runs/${run.id}`)
|
||||
).json();
|
||||
expect(completed.simulations_used).toBe(2);
|
||||
expect(completed.model_calls_used).toBe(2);
|
||||
expect(completed.steps).toHaveLength(8);
|
||||
await page.reload();
|
||||
await expect(
|
||||
page.getByRole("region", { name: "研究运行详情" }),
|
||||
).toContainText("浏览器有限研究");
|
||||
await expect(
|
||||
page.getByRole("button", { name: "关联回测", exact: true }),
|
||||
).toBeVisible();
|
||||
await page.screenshot({
|
||||
path: "/tmp/wq-stage-three-run.png",
|
||||
fullPage: true,
|
||||
});
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
Reference in New Issue
Block a user