Files
worldquant-alpha-system/frontend/src/research/ResearchWorkspace.tsx
T
yuxuanhui 34f1a4fa77
Deploy production / deploy (push) Successful in 1m35s
feat(research): streamline template details and enable bot versioning
2026-09-20 09:57:37 +08:00

934 lines
35 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { SimulationSettingsEditor } from "../settings/SimulationSettingsEditor";
import { useSettingsOptions } from "../settings/useSettingsOptions";
import {
ResearchDataInput,
researchSelection,
} from "../preparations/ResearchDataInput";
import { ResearchList, ResearchDetail } from "./ResearchList";
import { ResearchSelect } from "./ResearchSelect";
import { useEffect, useRef, useState } from "react";
import {
Banner,
Button,
Input,
InputNumber,
Table,
Tabs,
TextArea,
Toast,
Typography,
} from "@douyinfe/semi-ui-19";
import { WorkspaceTable } from "../components/WorkspaceTable";
import { ResearchInput } from "../ai/ResearchInput";
import { IconAIEditLevel3 } from "@douyinfe/semi-icons";
import { api, post, formatTime } from "../api";
import type { PageContext, UIAction } from "../ai/types";
import { initialSettings, type SimulationSettings } from "../backtests/types";
import {
blankTemplate,
type Asset,
type Experiment,
type InputSnapshot,
type Template,
} from "./workspaceTypes";
import { TemplateEditor } from "./TemplateEditor";
import { ExperimentView } from "./ExperimentView";
import { DeleteResearchButton } from "./DeleteResearchButton";
import { ComparisonPanel } from "./ComparisonPanel";
import "./workspace.css";
export function ResearchWorkspace({
page,
active,
action,
onAction,
onContext,
}: {
page: "templates" | "variants";
active: boolean;
action: UIAction | null;
onAction: (action: UIAction) => void;
onContext: (context: PageContext) => void;
}) {
const [detailTab, setDetailTab] = useState("editor");
const [detailOpen, setDetailOpen] = useState(false);
const [detailMode, setDetailMode] = useState<"editor" | "experiment">(
"editor",
);
const [listMode, setListMode] = useState("assets");
const processedAction = useRef<number | null>(null);
const [assets, setAssets] = useState<Asset[]>([]);
const [inputs, setInputs] = useState<InputSnapshot[]>([]);
const [inputIds, setInputIds] = useState<string[]>([]);
const [template, setTemplate] = useState<Template>(blankTemplate);
const [asset, setAsset] = useState<Asset | null>(null);
const [hypothesis, setHypothesis] = useState("");
const [parent, setParent] = useState("");
const [method, setMethod] = useState("structure");
const [settings, setSettings] = useState<SimulationSettings>(initialSettings);
const options = useSettingsOptions(active && detailOpen);
const [settingsValid, setSettingsValid] = useState(false);
const [mode, setMode] = useState("all");
const [limit, setLimit] = useState(100);
const [seed, setSeed] = useState(0);
const [experiment, setExperiment] = useState<Experiment | null>(null);
const [history, setHistory] = useState<
{ id: string; name: string; created_at: string; total: number }[]
>([]);
const [busy, setBusy] = useState("");
const [error, setError] = useState("");
const [search, setSearch] = useState("");
const [assetPage, setAssetPage] = useState(0);
const [historyPage, setHistoryPage] = useState(0);
const [assetTotal, setAssetTotal] = useState(0);
const [historyTotal, setHistoryTotal] = useState(0);
const refreshSequence = useRef(0);
const selectedInputs = inputs.filter((input) => inputIds.includes(input.id));
const dirty =
!asset || JSON.stringify(template) !== JSON.stringify(asset.content);
async function task(label: string, action: () => Promise<void>) {
setBusy(label);
setError("");
try {
await action();
} catch (error) {
setError((error as Error).message);
} finally {
setBusy("");
}
}
async function refresh() {
const sequence = ++refreshSequence.current;
const [nextAssets, nextInputs, 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}`,
),
]);
if (sequence !== refreshSequence.current) return;
setAssetTotal(nextAssets.total);
setHistoryTotal(nextHistory.total);
setAssetPage((current) =>
Math.min(current, Math.max(0, Math.ceil(nextAssets.total / 25) - 1)),
);
setHistoryPage((current) =>
Math.min(current, Math.max(0, Math.ceil(nextHistory.total / 25) - 1)),
);
setAssets(nextAssets.items);
setInputs(nextInputs.items);
setHistory(nextHistory.items);
}
useEffect(() => {
if (active) void task("读取研究素材", refresh);
}, [active, search, assetPage, historyPage]);
useEffect(() => {
if (active)
onContext({
page,
research_asset_id: asset?.id,
research_experiment_id: experiment?.id,
alpha_id: parent.split(/[,,\s]+/)[0] || undefined,
input_snapshot_id:
inputIds.length === 1 &&
!inputs.find((i) => i.id === inputIds[0])?.preparation_ref
? inputIds[0]
: undefined,
});
}, [active, page, asset?.id, experiment?.id, parent, inputIds, onContext]);
useEffect(() => {
if (!active || !action || processedAction.current === action.nonce) return;
processedAction.current = action.nonce;
if (
["open_experiment", "open_template", "open_variant"].includes(action.type)
) {
setDetailOpen(true);
setDetailTab("editor");
setDetailMode(
action.type === "open_experiment" ? "experiment" : "editor",
);
}
if (action.type === "open_experiment")
void task("读取实验", async () =>
setExperiment(
await api(`/research/experiments/${action.experiment_id}`),
),
);
if (action.type === "open_template")
void task("读取模板", async () => {
const next = await api<Asset>(
`/research/assets/${action.asset_id}${action.version ? `?version=${action.version}` : ""}`,
);
openTemplate(next);
});
if (action.type === "open_variant") {
setParent(action.alpha_id);
setMethod("structure");
}
}, [active, action]);
function openTemplate(next: Asset) {
setAsset(next);
setTemplate(next.content);
setExperiment(null);
const feature = next.provenance?.feature;
const fixed = feature?.provenance?.inputs || [];
setInputIds(feature?.content.input_ids || []);
setInputs(fixed);
setHypothesis(feature?.content.hypothesis || "");
const first = fixed[0];
setSettings({
...initialSettings,
...(first
? {
region: first.scope.region,
universe: first.scope.universe,
delay: first.scope.delay,
}
: {}),
});
}
async function save() {
const next = asset
? await api<Asset>(`/research/assets/${asset.id}`, {
method: "PUT",
body: JSON.stringify({
kind: "template",
version: asset.version,
content: template,
}),
})
: await post<Asset>("/research/assets", {
kind: "template",
content: template,
});
setAsset(next);
setTemplate(next.content);
await refresh();
Toast.success(`已保存模板 v${next.version}`);
}
async function generate() {
const next = await post<Asset>("/research/generate", {
name: template.name,
hypothesis,
...researchSelection(inputIds, inputs),
parent_alpha_ids: parent ? parent.split(/[,,\s]+/).filter(Boolean) : [],
method: page === "variants" ? "structure" : "template",
});
setAsset(next);
setTemplate(next.content);
await refresh();
}
async function removeAsset(item: Asset) {
await api(`/research/assets/${item.id}?version=${item.version}`, {
method: "DELETE",
});
setAssets((old) => old.filter((entry) => entry.id !== item.id));
setAssetTotal((old) => Math.max(0, old - 1));
if (asset?.id === item.id) {
if (detailMode === "editor") setDetailOpen(false);
setAsset(null);
setTemplate(blankTemplate());
}
Toast.success("模板已删除");
await refresh();
}
async function removeExperiment(item: { id: string }) {
await api(`/research/experiments/${item.id}`, { method: "DELETE" });
setHistory((old) => old.filter((entry) => entry.id !== item.id));
setHistoryTotal((old) => Math.max(0, old - 1));
if (experiment?.id === item.id) {
if (detailMode === "experiment") setDetailOpen(false);
setExperiment(null);
}
Toast.success("生成记录已删除");
await refresh();
}
async function expand() {
if (dirty) throw new Error("请先保存模板版本,再生成可追溯候选");
const next = await post<Experiment>("/research/experiments", {
asset_id: asset!.id,
version: asset!.version,
...researchSelection(inputIds, inputs),
hypothesis:
page === "templates"
? template.description.trim() || `使用模板:${template.name}`
: hypothesis,
settings,
mode,
limit,
seed,
parent_alpha_ids: parent ? parent.split(/[,,\s]+/).filter(Boolean) : [],
});
setExperiment(next);
setDetailTab("prepare");
await refresh();
}
const combination = Object.values(template.variables)
.reduce(
(size, variable) =>
size * BigInt(new Set(variable.values.map(String)).size),
1n,
)
.toString();
return (
<div className="research-workspace research-list-page">
{error && !detailOpen && <Banner type="danger" description={error} />}
<ResearchList
resetKey={JSON.stringify([listMode, search, assetPage, historyPage])}
label={page === "templates" ? "模板工坊" : "Alpha 变体"}
filters={
<>
<ResearchSelect
label="研究列表"
value={listMode}
optionList={[
{ value: "assets", label: "模板库" },
{ value: "history", label: "生成记录" },
]}
onChange={(value) => setListMode(String(value))}
/>
{listMode === "assets" && (
<Input
aria-label="搜索模板"
placeholder="搜索名称"
value={search}
onChange={(value) => {
setSearch(value);
setAssetPage(0);
}}
/>
)}
</>
}
actions={
<>
<Button
theme="solid"
onClick={() => {
setAsset(null);
setTemplate(blankTemplate());
setExperiment(null);
setInputIds([]);
setInputs([]);
setHypothesis("");
setSettings(initialSettings);
setDetailTab("editor");
setDetailMode("editor");
setDetailOpen(true);
}}
>
新建
</Button>
<Button loading={!!busy} onClick={() => void task("刷新", refresh)}>
刷新素材
</Button>
</>
}
pagination={
<>
<span>
共 {listMode === "assets" ? assetTotal : historyTotal} 条
</span>
<div className="inline-actions">
<Button
disabled={!(listMode === "assets" ? assetPage : historyPage)}
onClick={() =>
listMode === "assets"
? setAssetPage(assetPage - 1)
: setHistoryPage(historyPage - 1)
}
>
上一页
</Button>
<Button
disabled={
listMode === "assets"
? (assetPage + 1) * 25 >= assetTotal
: (historyPage + 1) * 25 >= historyTotal
}
onClick={() =>
listMode === "assets"
? setAssetPage(assetPage + 1)
: setHistoryPage(historyPage + 1)
}
>
下一页
</Button>
</div>
</>
}
>
{listMode === "assets" ? (
<div className="research-template-table-container">
<WorkspaceTable<Asset>
key={JSON.stringify([search, assetPage])}
fill
dataSource={assets}
rowKey="id"
scroll={{ x: 800, y: "100%" }}
empty="还没有模板。新建一个模板开始研究。"
columns={[
{
title: "模板名称",
key: "name",
width: 280,
ellipsis: { showTitle: false },
render: (_, item) => (
<Button
theme="borderless"
onClick={() =>
void task("读取模板", async () => {
const next = await api<Asset>(
`/research/assets/${item.id}`,
);
openTemplate(next);
setDetailTab("editor");
setDetailMode("editor");
setDetailOpen(true);
})
}
>
<Typography.Text
size="inherit"
style={{ width: "100%", color: "inherit" }}
ellipsis={{ showTooltip: true }}
>
{item.name}
</Typography.Text>
</Button>
),
},
{
title: "版本",
key: "version",
width: 80,
render: (_, item) => <>v{item.version}</>,
},
{
title: "表达式",
key: "expression",
width: undefined,
ellipsis: { showTitle: false },
render: (_, item) => (
<Typography.Text
className="research-template-expression"
style={{ width: "100%" }}
ellipsis={{ showTooltip: true }}
>
{item.content.expression}
</Typography.Text>
),
},
{
title: "分类",
key: "category",
width: 90,
render: (_, item) =>
item.content.category === "fragment" ? "片段" : "模板",
},
{
title: "操作",
key: "actions",
width: 80,
render: (_, item) => (
<div className="workspace-cell-actions">
<DeleteResearchButton
name={item.name}
label="模板"
disabled={!!busy}
onConfirm={() =>
task("删除模板", () => removeAsset(item))
}
/>
</div>
),
},
]}
/>
</div>
) : (
<>
<Table<(typeof history)[number]>
className="research-table"
dataSource={history}
rowKey="id"
pagination={false}
size="small"
sticky
style={{ minWidth: 640 }}
empty="暂无生成记录。"
columns={[
{
title: "研究名称",
key: "name",
render: (_, item) => (
<Button
theme="borderless"
onClick={() =>
void task("读取实验", async () => {
setExperiment(
await api(`/research/experiments/${item.id}`),
);
setDetailMode("experiment");
setDetailOpen(true);
})
}
>
{item.name}
</Button>
),
},
{
title: "候选数量",
key: "total",
width: 110,
render: (_, item) => item.total,
},
{
title: "创建时间",
key: "created_at",
width: 190,
render: (_, item) => formatTime(item.created_at),
},
{
title: "操作",
key: "actions",
width: 80,
render: (_, item) => (
<DeleteResearchButton
name={item.name}
label="生成记录"
disabled={!!busy}
onConfirm={() =>
task("删除生成记录", () => removeExperiment(item))
}
/>
),
},
]}
/>
</>
)}
</ResearchList>
<ResearchDetail
width="min(100vw, max(640px, 60vw))"
title={
detailMode === "experiment"
? experiment?.name || "生成记录"
: page === "variants"
? "Alpha 变体详情"
: asset?.name || "新建模板"
}
visible={active && detailOpen}
onClose={() => setDetailOpen(false)}
>
{error && <Banner type="danger" description={error} />}
{detailMode === "experiment" ? (
experiment && (
<ExperimentView
key={experiment.id}
experiment={experiment}
onAction={onAction}
deleting={!!busy}
onDelete={() =>
task("删除生成记录", () => removeExperiment(experiment))
}
/>
)
) : (
<div className="research-main">
{asset?.archived && (
<Banner
type="info"
description="此模板已删除,当前仅显示保留的历史版本。"
/>
)}
{page === "templates" && (
<Tabs
type="card"
activeKey={detailTab}
onChange={setDetailTab}
tabPaneMotion={false}
tabList={[
{ itemKey: "editor", tab: "模板信息编辑" },
{ itemKey: "prepare", tab: "回测准备" },
]}
/>
)}
{page === "variants" && (
<div>
<section className="research-card">
<h3>研究输入与假设</h3>
{page === "variants" && (
<>
<div className="research-methods">
<Button
theme={method === "structure" ? "solid" : "light"}
onClick={() => setMethod("structure")}
>
结构与字段变体
</Button>
<Button
theme={method === "settings" ? "solid" : "light"}
onClick={() => setMethod("settings")}
>
市场与设置变体
</Button>
</div>
<p>
{method === "structure"
? "神笔马良:保留种子与改动依据,研究新的表达式。"
: "缘分一道桥:保持表达式,为各目标市场重新固定输入后检查可用范围。"}
</p>
<label>
种子 Alpha
<Input
aria-label="种子 Alpha"
value={parent}
onChange={setParent}
placeholder="已同步的 Alpha ID"
/>
</label>
</>
)}
<label>
固定研究输入
<ResearchDataInput
label="选择数据准备"
ids={inputIds}
inputs={inputs}
onChange={(ids, rows) => {
setInputs(rows);
setInputIds(ids);
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,
}));
}}
/>
</label>
<p className="research-hint">
从数据准备选择集合;每个集合可包含同范围下多个数据集的字段。
</p>
{page === "variants" && method === "settings" ? (
<label>
本次研究假设
<TextArea
aria-label="本次研究假设"
value={hypothesis}
onChange={setHypothesis}
autosize={{ minRows: 2, maxRows: 5 }}
/>
</label>
) : (
<div className="research-ai-section">
<h4>
<IconAIEditLevel3 aria-hidden="true" />
本次研究假设
</h4>
<ResearchInput
maxLength={10000}
label="本次研究假设"
value={hypothesis}
onChange={setHypothesis}
placeholder={
page === "variants"
? "描述希望保留的信号与要探索的变体方向…"
: "描述经济假设、信号方向与希望验证的关系…"
}
references={[
...selectedInputs.map((input) => ({
id: input.id,
name: `${input.name} · ${input.scope.region}/${input.scope.universe}/D${input.scope.delay} · ${input.field_ids.length} 字段 · ${input.id}`,
})),
...(page === "variants" && parent
? [
{
id: "parent-alpha",
name: `种子 Alpha:${parent}`,
},
]
: []),
]}
disabled={!!busy}
canSubmit={!!inputIds.length}
busy={busy === "AI 生成"}
submitLabel="AI 生成模板"
sendHotKey="shift+enter"
onSubmit={() => void task("AI 生成", generate)}
prompts={
page === "variants"
? ["保留种子的经济含义,探索不同时间窗口和字段组合"]
: ["研究短期价格反转与成交量之间的关系"]
}
hint={
inputIds.length
? "基于已关联的固定输入生成,保留假设供下一轮调整。"
: "请先关联固定研究输入。"
}
/>
</div>
)}
</section>
</div>
)}
{page !== "variants" || method === "structure" ? (
<>
<div hidden={page === "templates" && detailTab !== "editor"}>
<section className="research-card">
<div className="research-section-heading">
<h3>
模板信息{asset && ` · v${asset.version}`}
{dirty && " · 未保存"}
</h3>
</div>
<TemplateEditor
value={template}
onChange={setTemplate}
inputs={selectedInputs}
definitionsOnly={page === "templates"}
/>
{page === "templates" ? (
<div className="research-toolbar">
<Button
theme="solid"
disabled={!!busy || asset?.archived || !dirty}
onClick={() => void task("保存模板", save)}
>
保存
</Button>
<Button
disabled={!!busy || !asset || asset.archived}
onClick={() => void task("新增版本", save)}
>
新增版本
</Button>
</div>
) : (
<>
{asset && (
<div className="research-toolbar">
<span>版本记录</span>
<ResearchSelect
label="模板历史版本"
value={asset.version}
optionList={Array.from(
{
length:
assets.find((item) => item.id === asset.id)
?.version || asset.version,
},
(_, i) => ({
value: i + 1,
label: `v${i + 1}`,
}),
)}
onChange={(version) =>
void task("读取历史版本", async () => {
const next = await api<Asset>(
`/research/assets/${asset.id}?version=${version}`,
);
setAsset(next);
setTemplate(next.content);
})
}
/>
<Button
onClick={() => {
setAsset(null);
setTemplate({
...template,
name: `${template.name}(副本)`,
});
}}
>
另存新模板
</Button>
</div>
)}
<div className="research-toolbar">
<Button
disabled={!!busy || asset?.archived}
onClick={() => void task("保存模板", save)}
>
保存模板版本
</Button>
{asset && !asset.archived && (
<DeleteResearchButton
name={asset.name}
label="模板"
disabled={!!busy}
onConfirm={() =>
task("删除模板", () => removeAsset(asset))
}
/>
)}
<span>组合规模:{combination}</span>
</div>
</>
)}
</section>
</div>
<div hidden={page === "templates" && detailTab !== "prepare"}>
<section className="research-card">
<h3>回测准备</h3>
<div aria-label="回测表达式">
<h4>表达式</h4>
<pre>{template.expression}</pre>
</div>
{page === "templates" && (
<>
<label>
数据准备
<ResearchDataInput
label="选择回测数据准备"
ids={inputIds}
inputs={inputs}
onChange={(ids, rows) => {
setInputs(rows);
setInputIds(ids);
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,
}));
}}
/>
</label>
{dirty && (
<Banner
type="warning"
description="请返回模板信息编辑,保存模板版本后再生成候选。"
/>
)}
</>
)}
<div className="research-form-grid">
<label>
展开方式
<ResearchSelect
label="展开方式"
value={mode}
optionList={[
{ value: "all", label: "全组合" },
{ value: "random", label: "可复现随机采样" },
]}
onChange={(value) => setMode(value as string)}
/>
</label>
<label>
候选数量上限
<InputNumber
aria-label="候选数量上限"
value={limit}
min={1}
max={10000}
onChange={(value) => setLimit(Number(value))}
/>
</label>
<label>
随机种子
<InputNumber
aria-label="随机种子"
value={seed}
onChange={(value) => setSeed(Number(value))}
/>
</label>
</div>
<SimulationSettingsEditor
value={settings}
onChange={setSettings}
{...options}
onRefresh={() => void options.refresh()}
fixedScopes={selectedInputs.map((r) => r.scope)}
disabled={!!busy}
onValidityChange={setSettingsValid}
/>
<Button
theme="solid"
disabled={
dirty ||
!inputIds.length ||
(page === "variants" && !hypothesis) ||
!!busy ||
!settingsValid
}
onClick={() => void task("生成候选", expand)}
>
生成候选集合
</Button>
{(!inputIds.length ||
(page === "variants" && !hypothesis) ||
!settingsValid) && (
<p className="research-hint">
待完成:
{[
!inputIds.length && "选择固定研究输入",
page === "variants" && !hypothesis && "填写研究假设",
!settingsValid && "检查模拟设置",
]
.filter(Boolean)
.join("、")}
</p>
)}
</section>
</div>
</>
) : (
<section className="research-card">
<h3>目标范围检查</h3>
<p>
保留种子表达式和模拟参数,按各目标输入的市场、股票池与延迟生成设置变体。局部变量不会当作字段;无法核实的字段不会被跳过。
</p>
<Button
theme="solid"
disabled={!parent || !inputIds.length || !!busy}
onClick={() =>
void task("检查设置变体", async () => {
setExperiment(
await post("/research/variants/settings", {
alpha_id: parent,
...researchSelection(inputIds, inputs),
...(hypothesis ? { hypothesis } : {}),
}),
);
await refresh();
})
}
>
生成设置变体
</Button>
</section>
)}
<div hidden={page === "templates" && detailTab !== "prepare"}>
{experiment && (
<ExperimentView
key={experiment.id}
experiment={experiment}
onAction={onAction}
deleting={!!busy}
onDelete={() =>
task("删除生成记录", () => removeExperiment(experiment))
}
/>
)}
</div>
{page === "variants" && (
<ComparisonPanel key={parent} baseline={parent} />
)}
</div>
)}
</ResearchDetail>
</div>
);
}