feat(research): streamline template details and enable bot versioning
Deploy production / deploy (push) Successful in 1m35s
Deploy production / deploy (push) Successful in 1m35s
This commit is contained in:
@@ -37,13 +37,6 @@ 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,
|
||||
@@ -90,8 +83,6 @@ export function ResearchWorkspace({
|
||||
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);
|
||||
@@ -170,32 +161,34 @@ export function ResearchWorkspace({
|
||||
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,
|
||||
}));
|
||||
}
|
||||
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}`, {
|
||||
@@ -258,7 +251,10 @@ export function ResearchWorkspace({
|
||||
asset_id: asset!.id,
|
||||
version: asset!.version,
|
||||
...researchSelection(inputIds, inputs),
|
||||
hypothesis,
|
||||
hypothesis:
|
||||
page === "templates"
|
||||
? template.description.trim() || `使用模板:${template.name}`
|
||||
: hypothesis,
|
||||
settings,
|
||||
mode,
|
||||
limit,
|
||||
@@ -314,6 +310,10 @@ export function ResearchWorkspace({
|
||||
setAsset(null);
|
||||
setTemplate(blankTemplate());
|
||||
setExperiment(null);
|
||||
setInputIds([]);
|
||||
setInputs([]);
|
||||
setHypothesis("");
|
||||
setSettings(initialSettings);
|
||||
setDetailTab("editor");
|
||||
setDetailMode("editor");
|
||||
setDetailOpen(true);
|
||||
@@ -368,7 +368,7 @@ export function ResearchWorkspace({
|
||||
dataSource={assets}
|
||||
rowKey="id"
|
||||
scroll={{ x: 800, y: "100%" }}
|
||||
empty="还没有模板。新建或导入一个模板开始研究。"
|
||||
empty="还没有模板。新建一个模板开始研究。"
|
||||
columns={[
|
||||
{
|
||||
title: "模板名称",
|
||||
@@ -383,9 +383,7 @@ export function ResearchWorkspace({
|
||||
const next = await api<Asset>(
|
||||
`/research/assets/${item.id}`,
|
||||
);
|
||||
setAsset(next);
|
||||
setTemplate(next.content);
|
||||
setExperiment(null);
|
||||
openTemplate(next);
|
||||
setDetailTab("editor");
|
||||
setDetailMode("editor");
|
||||
setDetailOpen(true);
|
||||
@@ -544,7 +542,7 @@ export function ResearchWorkspace({
|
||||
{asset?.archived && (
|
||||
<Banner
|
||||
type="info"
|
||||
description="此模板已删除,当前显示保留的历史版本;可另存新模板继续编辑。"
|
||||
description="此模板已删除,当前仅显示保留的历史版本。"
|
||||
/>
|
||||
)}
|
||||
{page === "templates" && (
|
||||
@@ -559,125 +557,127 @@ export function ResearchWorkspace({
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
<div hidden={page === "templates" && detailTab !== "editor"}>
|
||||
<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" ? (
|
||||
{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>
|
||||
本次研究假设
|
||||
<TextArea
|
||||
aria-label="本次研究假设"
|
||||
value={hypothesis}
|
||||
onChange={setHypothesis}
|
||||
autosize={{ minRows: 2, maxRows: 5 }}
|
||||
固定研究输入
|
||||
<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>
|
||||
) : (
|
||||
<div className="research-ai-section">
|
||||
<h4>
|
||||
<IconAIEditLevel3 aria-hidden="true" />
|
||||
<p className="research-hint">
|
||||
从数据准备选择集合;每个集合可包含同范围下多个数据集的字段。
|
||||
</p>
|
||||
{page === "variants" && method === "settings" ? (
|
||||
<label>
|
||||
本次研究假设
|
||||
</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>
|
||||
<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"}>
|
||||
@@ -692,85 +692,100 @@ export function ResearchWorkspace({
|
||||
value={template}
|
||||
onChange={setTemplate}
|
||||
inputs={selectedInputs}
|
||||
definitionsOnly={page === "templates"}
|
||||
/>
|
||||
{asset && (
|
||||
{page === "templates" ? (
|
||||
<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}(副本)`,
|
||||
});
|
||||
}}
|
||||
theme="solid"
|
||||
disabled={!!busy || asset?.archived || !dirty}
|
||||
onClick={() => void task("保存模板", save)}
|
||||
>
|
||||
另存新模板
|
||||
保存
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!!busy || !asset || asset.archived}
|
||||
onClick={() => void task("新增版本", save)}
|
||||
>
|
||||
新增版本
|
||||
</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>
|
||||
{page === "templates" && (
|
||||
<Button
|
||||
theme="solid"
|
||||
disabled={!!busy || dirty}
|
||||
onClick={() => setDetailTab("prepare")}
|
||||
>
|
||||
下一步:回测准备
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
{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>
|
||||
<p className="research-hint">
|
||||
配置展开方式与模拟设置 → 生成候选研究集合 →
|
||||
生成回测确认预览 → 确认后开始回测。
|
||||
</p>
|
||||
<div aria-label="回测表达式">
|
||||
<h4>表达式</h4>
|
||||
<pre>{template.expression}</pre>
|
||||
</div>
|
||||
{page === "templates" && (
|
||||
<>
|
||||
<label>
|
||||
固定研究输入
|
||||
数据准备
|
||||
<ResearchDataInput
|
||||
label="选择回测数据准备"
|
||||
ids={inputIds}
|
||||
@@ -789,20 +804,6 @@ export function ResearchWorkspace({
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
本次研究假设
|
||||
<TextArea
|
||||
aria-label="回测研究假设"
|
||||
value={hypothesis}
|
||||
onChange={setHypothesis}
|
||||
autosize={{ minRows: 2, maxRows: 5 }}
|
||||
/>
|
||||
</label>
|
||||
<p>
|
||||
模板:{template.name || "未命名"} ·{" "}
|
||||
{asset ? `v${asset.version}` : "未保存"} · 组合规模:
|
||||
{combination}
|
||||
</p>
|
||||
{dirty && (
|
||||
<Banner
|
||||
type="warning"
|
||||
@@ -857,20 +858,22 @@ export function ResearchWorkspace({
|
||||
disabled={
|
||||
dirty ||
|
||||
!inputIds.length ||
|
||||
!hypothesis ||
|
||||
(page === "variants" && !hypothesis) ||
|
||||
!!busy ||
|
||||
!settingsValid
|
||||
}
|
||||
onClick={() => void task("生成候选", expand)}
|
||||
>
|
||||
生成候选研究集合
|
||||
生成候选集合
|
||||
</Button>
|
||||
{(!inputIds.length || !hypothesis || !settingsValid) && (
|
||||
{(!inputIds.length ||
|
||||
(page === "variants" && !hypothesis) ||
|
||||
!settingsValid) && (
|
||||
<p className="research-hint">
|
||||
待完成:
|
||||
{[
|
||||
!inputIds.length && "选择固定研究输入",
|
||||
!hypothesis && "填写研究假设",
|
||||
page === "variants" && !hypothesis && "填写研究假设",
|
||||
!settingsValid && "检查模拟设置",
|
||||
]
|
||||
.filter(Boolean)
|
||||
@@ -919,65 +922,6 @@ export function ResearchWorkspace({
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{page === "templates" && detailTab === "editor" && (
|
||||
<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} />
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user