871 lines
31 KiB
TypeScript
871 lines
31 KiB
TypeScript
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,
|
||
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 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 [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)}`,
|
||
),
|
||
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);
|
||
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]);
|
||
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,
|
||
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>
|
||
固定研究输入
|
||
<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>
|
||
{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>
|
||
<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 ||
|
||
!hypothesis ||
|
||
!!busy ||
|
||
!settingsValid
|
||
}
|
||
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,
|
||
...researchSelection(inputIds, inputs),
|
||
...(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>
|
||
);
|
||
}
|