683 lines
24 KiB
TypeScript
683 lines
24 KiB
TypeScript
|
|
import { ResearchSelect } from "./ResearchSelect";
|
|||
|
|
import { useEffect, useRef, useState } from "react";
|
|||
|
|
import {
|
|||
|
|
Banner,
|
|||
|
|
Button,
|
|||
|
|
Input,
|
|||
|
|
InputNumber,
|
|||
|
|
TextArea,
|
|||
|
|
Toast,
|
|||
|
|
} from "@douyinfe/semi-ui-19";
|
|||
|
|
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 { 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 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);
|
|||
|
|
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 (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);
|
|||
|
|
});
|
|||
|
|
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 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">
|
|||
|
|
<header className="research-page-heading">
|
|||
|
|
<div>
|
|||
|
|
<h2>{page === "templates" ? "模板工坊" : "Alpha 变体"}</h2>
|
|||
|
|
<p>
|
|||
|
|
{page === "templates"
|
|||
|
|
? "从固定输入生成候选,保存每一次模板版本与研究依据。"
|
|||
|
|
: "围绕已有 Alpha 研究结构、字段与市场设置的变化。"}
|
|||
|
|
</p>
|
|||
|
|
</div>
|
|||
|
|
<Button loading={!!busy} onClick={() => void task("刷新", refresh)}>
|
|||
|
|
刷新素材
|
|||
|
|
</Button>
|
|||
|
|
</header>
|
|||
|
|
{error && <Banner type="danger" description={error} />}
|
|||
|
|
<div className="research-layout">
|
|||
|
|
<aside className="research-library">
|
|||
|
|
<div className="research-section-heading">
|
|||
|
|
<h3>模板库</h3>
|
|||
|
|
<Button
|
|||
|
|
size="small"
|
|||
|
|
onClick={() => {
|
|||
|
|
setAsset(null);
|
|||
|
|
setTemplate(blankTemplate());
|
|||
|
|
}}
|
|||
|
|
>
|
|||
|
|
新建
|
|||
|
|
</Button>
|
|||
|
|
</div>
|
|||
|
|
<Input
|
|||
|
|
aria-label="搜索模板"
|
|||
|
|
placeholder="搜索名称"
|
|||
|
|
value={search}
|
|||
|
|
onChange={(value) => {
|
|||
|
|
setSearch(value);
|
|||
|
|
setAssetPage(0);
|
|||
|
|
}}
|
|||
|
|
/>
|
|||
|
|
{assets.map((item) => (
|
|||
|
|
<button
|
|||
|
|
className={`research-library-item ${asset?.id === item.id ? "selected" : ""}`}
|
|||
|
|
key={item.id}
|
|||
|
|
onClick={() =>
|
|||
|
|
void task("读取模板", async () => {
|
|||
|
|
const next = await api<Asset>(`/research/assets/${item.id}`);
|
|||
|
|
setAsset(next);
|
|||
|
|
setTemplate(next.content);
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
>
|
|||
|
|
<strong>{item.name}</strong>
|
|||
|
|
<span>v{item.version}</span>
|
|||
|
|
</button>
|
|||
|
|
))}
|
|||
|
|
{!assets.length && <p>还没有模板。新建或导入一个模板开始研究。</p>}
|
|||
|
|
<div className="research-toolbar">
|
|||
|
|
<small>共 {assetTotal} 个模板</small>
|
|||
|
|
<Button
|
|||
|
|
size="small"
|
|||
|
|
disabled={!assetPage}
|
|||
|
|
onClick={() => setAssetPage(assetPage - 1)}
|
|||
|
|
>
|
|||
|
|
上一页模板
|
|||
|
|
</Button>
|
|||
|
|
<Button
|
|||
|
|
size="small"
|
|||
|
|
disabled={(assetPage + 1) * 25 >= assetTotal}
|
|||
|
|
onClick={() => setAssetPage(assetPage + 1)}
|
|||
|
|
>
|
|||
|
|
下一页模板
|
|||
|
|
</Button>
|
|||
|
|
</div>
|
|||
|
|
<h3>生成记录</h3>
|
|||
|
|
{history.map((item) => (
|
|||
|
|
<button
|
|||
|
|
className="research-library-item"
|
|||
|
|
key={item.id}
|
|||
|
|
onClick={() =>
|
|||
|
|
void task("读取实验", async () =>
|
|||
|
|
setExperiment(await api(`/research/experiments/${item.id}`)),
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
>
|
|||
|
|
<strong>{item.name}</strong>
|
|||
|
|
<span>
|
|||
|
|
{item.total} 个候选 · {formatTime(item.created_at)}
|
|||
|
|
</span>
|
|||
|
|
</button>
|
|||
|
|
))}
|
|||
|
|
<div className="research-toolbar">
|
|||
|
|
<small>共 {historyTotal} 次生成</small>
|
|||
|
|
<Button
|
|||
|
|
size="small"
|
|||
|
|
disabled={!historyPage}
|
|||
|
|
onClick={() => setHistoryPage(historyPage - 1)}
|
|||
|
|
>
|
|||
|
|
上一页记录
|
|||
|
|
</Button>
|
|||
|
|
<Button
|
|||
|
|
size="small"
|
|||
|
|
disabled={(historyPage + 1) * 25 >= historyTotal}
|
|||
|
|
onClick={() => setHistoryPage(historyPage + 1)}
|
|||
|
|
>
|
|||
|
|
下一页记录
|
|||
|
|
</Button>
|
|||
|
|
</div>
|
|||
|
|
</aside>
|
|||
|
|
<div className="research-main">
|
|||
|
|
<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>
|
|||
|
|
<label>
|
|||
|
|
本次研究假设
|
|||
|
|
<TextArea
|
|||
|
|
aria-label="本次研究假设"
|
|||
|
|
value={hypothesis}
|
|||
|
|
onChange={setHypothesis}
|
|||
|
|
autosize={{ minRows: 2, maxRows: 5 }}
|
|||
|
|
/>
|
|||
|
|
</label>
|
|||
|
|
</section>
|
|||
|
|
{page !== "variants" || method === "structure" ? (
|
|||
|
|
<section className="research-card">
|
|||
|
|
<div className="research-section-heading">
|
|||
|
|
<h3>
|
|||
|
|
编辑与展开{asset && ` · v${asset.version}`}
|
|||
|
|
{dirty && " · 未保存"}
|
|||
|
|
</h3>
|
|||
|
|
<Button
|
|||
|
|
disabled={!inputIds.length || !hypothesis || !!busy}
|
|||
|
|
loading={busy === "AI 生成"}
|
|||
|
|
onClick={() => void task("AI 生成", generate)}
|
|||
|
|
>
|
|||
|
|
AI 生成模板
|
|||
|
|
</Button>
|
|||
|
|
</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}
|
|||
|
|
onClick={() => void task("保存模板", save)}
|
|||
|
|
>
|
|||
|
|
保存模板版本
|
|||
|
|
</Button>
|
|||
|
|
<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}
|
|||
|
|
/>
|
|||
|
|
)}
|
|||
|
|
{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>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|