Files
worldquant-alpha-system/frontend/src/research/ResearchWorkspace.tsx
T

951 lines
34 KiB
TypeScript
Raw Normal View History

import { ResearchList, ResearchDetail } from "./ResearchList";
import { ResearchSelect } from "./ResearchSelect";
import { useEffect, useRef, useState } from "react";
import {
Banner,
Button,
Input,
InputNumber,
Table,
TextArea,
Toast,
} from "@douyinfe/semi-ui-19";
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";
type ImportResult = {
templates: Template[];
digest: string;
conflicts: unknown[];
errors: unknown[];
differences?: unknown[];
};
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 [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 [neutralizations, setNeutralizations] = useState<string[]>([]);
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 [importText, setImportText] = useState("");
const [importResult, setImportResult] = useState<ImportResult | null>(null);
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)}`,
),
api<{ items: InputSnapshot[] }>("/research/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,
template_input_id: inputIds.length === 1 ? 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);
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}` : ""}`,
);
setAsset(next);
setTemplate(next.content);
const feature = next.provenance?.feature;
if (feature) {
setInputIds(feature.content.input_ids);
setHypothesis(feature.content.hypothesis);
const fixed = feature.provenance?.inputs || [];
setInputs((old) => [
...old,
...fixed.filter((i) => !old.some((o) => o.id === i.id)),
]);
const first = fixed[0];
if (first)
setSettings((old) => ({
...old,
region: first.scope.region,
universe: first.scope.universe,
delay: first.scope.delay,
}));
}
});
if (action.type === "open_variant") {
setParent(action.alpha_id);
setMethod("structure");
}
}, [active, action]);
function selectInputs(ids: string[]) {
setInputIds(ids);
const first = inputs.find((item) => item.id === ids[0]);
if (first)
setSettings((old) => ({
...old,
region: first.scope.region,
universe: first.scope.universe,
delay: first.scope.delay,
}));
}
async function refreshSettings() {
const data = await post<{
content: {
items: {
region: string;
universe: string;
delay: number;
neutralizations: string[];
}[];
};
}>("/catalog/setting-options/refresh");
const options = data.content.items
.filter(
(row) =>
row.region === settings.region &&
row.universe === settings.universe &&
row.delay === settings.delay,
)
.flatMap((row) => row.neutralizations);
setNeutralizations([...new Set(options)]);
Toast.success("合法设置已同步");
}
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,
input_ids: inputIds,
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,
input_ids: inputIds,
hypothesis,
settings,
mode,
limit,
seed,
parent_alpha_ids: parent ? parent.split(/[,,\s]+/).filter(Boolean) : [],
});
setExperiment(next);
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);
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" ? (
<>
<Table<Asset>
className="research-table"
dataSource={assets}
rowKey="id"
pagination={false}
size="small"
sticky
style={{ minWidth: 720 }}
empty="还没有模板。新建或导入一个模板开始研究。"
columns={[
{
title: "模板名称",
key: "name",
render: (_, item) => (
<Button
theme="borderless"
onClick={() =>
void task("读取模板", async () => {
const next = await api<Asset>(
`/research/assets/${item.id}`,
);
setAsset(next);
setTemplate(next.content);
setExperiment(null);
setDetailMode("editor");
setDetailOpen(true);
})
}
>
{item.name}
</Button>
),
},
{
title: "版本",
key: "version",
width: 80,
render: (_, item) => <>v{item.version}</>,
},
{
title: "表达式",
key: "expression",
width: 260,
render: (_, item) => <code>{item.content.expression}</code>,
},
{
title: "分类",
key: "category",
width: 90,
render: (_, item) =>
item.content.category === "fragment" ? "片段" : "模板",
},
{
title: "操作",
key: "actions",
width: 80,
render: (_, item) => (
<DeleteResearchButton
name={item.name}
label="模板"
disabled={!!busy}
onConfirm={() =>
task("删除模板", () => removeAsset(item))
}
/>
),
},
]}
/>
</>
) : (
<>
<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="此模板已删除,当前显示保留的历史版本;可另存新模板继续编辑。"
/>
)}
<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>
固定研究输入
<ResearchSelect
multiple
filter
label="固定研究输入"
value={inputIds}
optionList={inputs.map((input) => ({
value: input.id,
label: `${input.dataset_id} · ${input.scope.region}/${input.scope.universe}/D${input.scope.delay} · ${input.field_ids.length} 字段 · ${input.id.slice(0, 8)}`,
}))}
onChange={(value) => selectInputs(value as string[])}
/>
</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.dataset_id} · ${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>
{page !== "variants" || method === "structure" ? (
<section className="research-card">
<div className="research-section-heading">
<h3>
编辑与展开{asset && ` · v${asset.version}`}
{dirty && " · 未保存"}
</h3>
</div>
<TemplateEditor
value={template}
onChange={setTemplate}
inputs={selectedInputs}
/>
{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>
<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>
<div className="research-section-heading">
<h4>模拟设置</h4>
<Button
disabled={!inputIds.length || !!busy}
onClick={() => void task("同步合法设置", refreshSettings)}
>
同步合法设置
</Button>
</div>
<p>
{settings.region || "未选择市场"} /{" "}
{settings.universe || "未选择股票池"} / D{settings.delay}
(来自固定输入)
</p>
<div className="research-form-grid">
<label>
中性化
<Input
list={`${page}-neutralizations`}
aria-label="中性化"
value={settings.neutralization}
onChange={(neutralization) =>
setSettings({ ...settings, neutralization })
}
/>
<datalist id={`${page}-neutralizations`}>
{neutralizations.map((value) => (
<option key={value} value={value} />
))}
</datalist>
</label>
<label>
Decay
<InputNumber
aria-label="Decay"
min={0}
value={settings.decay}
onChange={(decay) =>
setSettings({ ...settings, decay: Number(decay) })
}
/>
</label>
<label>
Truncation
<InputNumber
aria-label="Truncation"
min={0}
max={1}
step={0.01}
value={settings.truncation}
onChange={(truncation) =>
setSettings({
...settings,
truncation: Number(truncation),
})
}
/>
</label>
</div>
<div className="research-form-grid">
{(["pasteurization", "nanHandling", "maxTrade"] as const).map(
(key) => (
<label key={key}>
{key}
<ResearchSelect
label={key}
value={settings[key]}
optionList={["ON", "OFF"].map((value) => ({
value,
label: value,
}))}
onChange={(value) =>
setSettings({ ...settings, [key]: value })
}
/>
</label>
),
)}
</div>
<Button
theme="solid"
disabled={dirty || !inputIds.length || !hypothesis || !!busy}
onClick={() => void task("生成候选", expand)}
>
保存候选研究记录
</Button>
</section>
) : (
<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,
input_ids: inputIds,
...(hypothesis ? { hypothesis } : {}),
}),
);
await refresh();
})
}
>
生成设置变体
</Button>
</section>
)}
{experiment && (
<ExperimentView
key={experiment.id}
experiment={experiment}
onAction={onAction}
deleting={!!busy}
onDelete={() =>
task("删除生成记录", () => removeExperiment(experiment))
}
/>
)}
{page === "templates" && (
<details className="research-card">
<summary>导入旧模板</summary>
<p>
粘贴 cnhk 模板 JSON
或模板数组。先预览转换结果,同名模板不会自动覆盖。
</p>
<TextArea
aria-label="导入模板 JSON"
value={importText}
onChange={(text) => {
setImportText(text);
setImportResult(null);
}}
autosize={{ minRows: 4, maxRows: 12 }}
/>
<Button
disabled={!!busy || !importText}
onClick={() =>
void task("预览导入", async () => {
const data = JSON.parse(importText);
setImportResult(
await post("/research/templates/import-preview", {
templates: Array.isArray(data) ? data : [data],
}),
);
})
}
>
预览导入差异
</Button>
{importResult && (
<>
<pre>{JSON.stringify(importResult, null, 2)}</pre>
<Button
disabled={
!!busy ||
!!importResult.errors?.length ||
!!importResult.conflicts?.length
}
onClick={() =>
void task("导入模板", async () => {
await post("/research/templates/import", {
templates: importResult.templates,
digest: importResult.digest,
});
setImportResult(null);
setImportText("");
await refresh();
Toast.success("导入完成");
})
}
>
确认导入
</Button>
</>
)}
</details>
)}
{page === "variants" && (
<ComparisonPanel key={parent} baseline={parent} />
)}
</div>
)}
</ResearchDetail>
</div>
);
}