feat: add versioned research templates and alpha variants

This commit is contained in:
yuxuanhui
2026-09-08 21:29:21 +08:00
parent b604e6050e
commit f89ae211d2
55 changed files with 5650 additions and 435 deletions
+91
View File
@@ -0,0 +1,91 @@
import { useState } from "react";
import { Banner, Button, Input } from "@douyinfe/semi-ui-19";
import { post, formatNumber, formatTime } from "../api";
import { PnlChart } from "../components/PnlChart";
import type { SimulationSettings } from "../backtests/types";
type Comparison = {
window: { from: string; to: string } | null;
different_settings: boolean;
note: string;
items: {
alpha_id: string;
expression: string;
settings: SimulationSettings;
metrics: Record<string, unknown>;
observed_at: string;
pnl: { date: string; value: number }[];
}[];
};
export function ComparisonPanel({ baseline = "" }: { baseline?: string }) {
const [ids, setIds] = useState(baseline);
const [result, setResult] = useState<Comparison | null>(null);
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
async function compare() {
setBusy(true);
setError("");
try {
setResult(
await post("/research/compare", {
alpha_ids: ids.split(/[,,\s]+/).filter(Boolean),
}),
);
} catch (error) {
setError((error as Error).message);
} finally {
setBusy(false);
}
}
return (
<section className="research-card">
<h3>基线与候选比较</h3>
<p>首个 Alpha 为基线,后续为候选。比较本地快照和共同日期窗口的 PnL。</p>
<div className="research-toolbar">
<Input
aria-label="比较 Alpha 编号"
value={ids}
onChange={setIds}
placeholder="基线 Alpha ID, 候选 Alpha ID"
/>
<Button loading={busy} onClick={() => void compare()}>
比较
</Button>
</div>
{error && <Banner type="danger" description={error} />}
{result && (
<>
<Banner
type={result.different_settings ? "warning" : "info"}
description={`${result.different_settings ? "存在不同市场或设置,请结合范围解读。" : ""}${result.window ? `共同窗口 ${result.window.from} 至 ${result.window.to}` : "没有共同 PnL 窗口"}。${result.note}`}
/>
<div className="research-comparison-grid">
{result.items.map((item, index) => (
<article key={item.alpha_id}>
<h4>
{index === 0 ? "基线" : "候选"} · {item.alpha_id}
</h4>
<code>{item.expression}</code>
<p>
{item.settings.region} / {item.settings.universe} / D
{item.settings.delay} · {item.settings.neutralization} · decay{" "}
{item.settings.decay}
</p>
<p>
Sharpe {formatNumber(item.metrics.sharpe)} · Fitness{" "}
{formatNumber(item.metrics.fitness)} · Turnover{" "}
{formatNumber(item.metrics.turnover)}
</p>
<small>指标快照 {formatTime(item.observed_at)}</small>
<PnlChart points={item.pnl} />
<details>
<summary>完整设置差异依据</summary>
<pre>{JSON.stringify(item.settings, null, 2)}</pre>
</details>
</article>
))}
</div>
</>
)}
</section>
);
}
+213
View File
@@ -0,0 +1,213 @@
import { useState } from "react";
import { Banner, Button, Checkbox, Tag, Toast } from "@douyinfe/semi-ui-19";
import { post, formatTime } from "../api";
import type { UIAction } from "../ai/types";
import type { Experiment } from "./workspaceTypes";
import { validationLabel } from "./workspaceTypes";
export function ExperimentView({
experiment,
onAction,
}: {
experiment: Experiment;
onAction: (action: UIAction) => void;
}) {
const [selected, setSelected] = useState<string[]>([]);
const [busy, setBusy] = useState(false);
const valid = experiment.candidates.filter(
(c) => c.validation.status === "valid",
);
async function preview() {
setBusy(true);
try {
const result = await post<{ preview_id: string }>(
`/research/experiments/${experiment.id}/preview`,
selected.length ? { candidate_ids: selected } : {},
);
onAction({
type: "open_backtest_preview",
preview_id: result.preview_id,
nonce: Date.now(),
});
} catch (error) {
Toast.error((error as Error).message);
} finally {
setBusy(false);
}
}
return (
<section className="research-card" aria-label="候选与来源">
<div className="research-section-heading">
<div>
<h3>{experiment.name}</h3>
<p>
{formatTime(experiment.created_at)} · {experiment.candidates.length}{" "}
个候选 · {valid.length} 个可预览
</p>
</div>
<Button
theme="solid"
disabled={!valid.length}
loading={busy}
onClick={() => void preview()}
>
生成回测确认预览{selected.length ? `(${selected.length})` : ""}
</Button>
</div>
<p>{experiment.hypothesis}</p>
<Banner
type="info"
description="候选已保存为不可变研究记录。本地校验不保证平台可执行;在回测预览中确认后才开始模拟。"
/>
<div className="research-table-scroll">
<table className="research-table">
<thead>
<tr>
<th>选择</th>
<th>候选表达式</th>
<th>市场与设置</th>
<th>校验</th>
</tr>
</thead>
<tbody>
{experiment.candidates.map((candidate) => (
<tr key={candidate.client_item_id}>
<td>
<Checkbox
aria-label={`选择候选 ${candidate.client_item_id}`}
disabled={candidate.validation.status !== "valid"}
checked={selected.includes(candidate.client_item_id)}
onChange={(event) =>
setSelected((old) =>
event.target.checked
? [...old, candidate.client_item_id]
: old.filter((id) => id !== candidate.client_item_id),
)
}
/>
</td>
<td>
<code>{candidate.expression}</code>
<details>
<summary>绑定与改动</summary>
<pre>
{JSON.stringify(
{
bindings: candidate.bindings,
changes: candidate.changes,
},
null,
2,
)}
</pre>
</details>
</td>
<td>
{candidate.settings.region} / {candidate.settings.universe} /
D{candidate.settings.delay}
<br />
{candidate.settings.neutralization} · decay{" "}
{candidate.settings.decay}
</td>
<td>
<Tag
color={
candidate.validation.status === "valid"
? "green"
: "orange"
}
>
{validationLabel[candidate.validation.status]}
</Tag>
{(["syntax", "types", "availability"] as const).map((key) =>
candidate.validation[key].map((issue, index) => (
<p key={`${key}-${index}`}>
{
{
syntax: "语法",
types: "类型",
availability: "可用性",
}[key]
}
:{issue}
</p>
)),
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="research-lineage">
<strong>研究来源</strong>
<span>实验 {experiment.id}</span>
{experiment.evidence.template?.id && (
<Button
theme="borderless"
onClick={() =>
onAction({
type: "open_template",
asset_id: experiment.evidence.template!.id,
version: experiment.evidence.template!.version,
nonce: Date.now(),
})
}
>
模板 v{experiment.evidence.template.version}
</Button>
)}
{experiment.inputs.map((input) => (
<Button
key={input.id}
theme="borderless"
onClick={() =>
onAction({
type: "open_research_input",
input_id: input.id,
nonce: Date.now(),
})
}
>
{input.dataset_id} · {input.scope.region}/{input.scope.universe}/D
{input.scope.delay}
</Button>
))}
{experiment.parents.map((parent) => (
<Button
key={`${parent.kind}:${parent.id}`}
theme="borderless"
onClick={() =>
onAction(
parent.kind === "alpha"
? {
type: "open_alpha",
alpha_id: parent.id,
nonce: Date.now(),
}
: {
type: "open_experiment",
experiment_id: parent.id,
nonce: Date.now(),
},
)
}
>
父来源:{parent.id}
</Button>
))}
{experiment.backtest_run_ids.map((id) => (
<Button
key={id}
theme="borderless"
onClick={() =>
onAction({ type: "open_backtest", run_id: id, nonce: Date.now() })
}
>
查看关联回测
</Button>
))}
</div>
</section>
);
}
@@ -0,0 +1,79 @@
import { useEffect, useState } from "react";
import { Banner, Button } from "@douyinfe/semi-ui-19";
import { api, post, queryString, formatTime } from "../api";
type Scope = {
instrument_type: string;
region: string;
universe: string;
delay: number;
};
type Snapshot = {
content: { status?: string; reason?: string; items?: Scope[] };
fetched_at: string | null;
};
export function FieldAvailability({
field,
scope,
}: {
field: string;
scope: Scope;
}) {
const [snapshot, setSnapshot] = useState<Snapshot | null>(null);
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const query = queryString(scope);
useEffect(() => {
let live = true;
api<Snapshot>(
`/catalog/field-availability/${encodeURIComponent(field)}?${query}`,
)
.then((value) => {
if (live) setSnapshot(value);
})
.catch((error) => {
if (live) setError(error.message);
});
return () => {
live = false;
};
}, [field, query]);
async function refresh() {
setBusy(true);
setError("");
try {
setSnapshot(
await post("/catalog/field-availability/refresh", {
field_id: field,
scope,
}),
);
} catch (error) {
setError((error as Error).message);
} finally {
setBusy(false);
}
}
return (
<section>
<h3>字段可用范围</h3>
<p>{formatTime(snapshot?.fetched_at)}</p>
{error && <Banner type="danger" description={error} />}
{snapshot?.content.items?.length ? (
snapshot.content.items.map((item, index) => (
<p key={index}>
{item.instrument_type} · {item.region} / {item.universe} / D
{item.delay}
</p>
))
) : (
<p>
{snapshot?.content.reason ||
"尚未取得可用性证据,请查询平台。未知不代表可用。"}
</p>
)}
<Button loading={busy} onClick={() => void refresh()}>
查询字段可用范围
</Button>
</section>
);
}
+231
View File
@@ -0,0 +1,231 @@
import { ResearchSelect } from "./ResearchSelect";
import { useEffect, useState } from "react";
import {
Banner,
Button,
Checkbox,
Input,
TextArea,
} from "@douyinfe/semi-ui-19";
import { api, patch, post, formatTime } from "../api";
import "./workspace.css";
type Operator = {
name: string;
category: string;
description: string;
definition: string;
example: string;
parameters: unknown;
scope: string[];
local: { note: string; favorite: boolean; version: number };
};
type Results = {
items: Operator[];
total: number;
categories: string[];
fetched_at: string | null;
};
export function OperatorsPage({ active }: { active: boolean }) {
const [query, setQuery] = useState("");
const [category, setCategory] = useState("");
const [favorite, setFavorite] = useState(false);
const [offset, setOffset] = useState(0);
const [result, setResult] = useState<Results>({
items: [],
total: 0,
categories: [],
fetched_at: null,
});
const [selected, setSelected] = useState<Operator | null>(null);
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
async function load() {
setResult(
await api(
`/catalog/operators?${new URLSearchParams({ q: query, category, favorite: String(favorite), limit: "25", offset: String(offset) })}`,
),
);
}
async function task(operation: () => Promise<void>) {
setBusy(true);
setError("");
try {
await operation();
} catch (error) {
setError((error as Error).message);
} finally {
setBusy(false);
}
}
useEffect(() => {
if (active) void task(load);
}, [active, query, category, favorite, offset]);
return (
<div className="research-workspace">
<header className="research-page-heading">
<div>
<h2>算子库</h2>
<p>
平台定义只读,本地收藏和研究备注独立保存。同步时间:
{formatTime(result.fetched_at)}
</p>
</div>
<Button
theme="solid"
loading={busy}
onClick={() =>
void task(async () => {
await post("/catalog/operators/refresh");
await load();
})
}
>
同步算子目录
</Button>
</header>
{error && <Banner type="danger" description={error} />}
<div className="research-toolbar">
<Input
aria-label="搜索算子"
placeholder="名称、参数或描述"
value={query}
onChange={(value) => {
setQuery(value);
setOffset(0);
}}
/>
<ResearchSelect
label="算子分类"
value={category}
optionList={[
{ value: "", label: "全部分类" },
...result.categories.map((value) => ({ value, label: value })),
]}
onChange={(value) => {
setCategory(value as string);
setOffset(0);
}}
/>
<Checkbox
checked={favorite}
onChange={(event) => {
setFavorite(!!event.target.checked);
setOffset(0);
}}
>
只看收藏
</Checkbox>
</div>
<div className="research-operator-layout">
<section className="research-card">
<table className="research-table">
<thead>
<tr>
<th>算子</th>
<th>分类</th>
<th>定义</th>
</tr>
</thead>
<tbody>
{result.items.map((item) => (
<tr key={item.name}>
<td>
<Button
theme="borderless"
onClick={() => setSelected(item)}
>
{item.local.favorite ? "已收藏 · " : ""}
{item.name}
</Button>
</td>
<td>{item.category}</td>
<td>
<code>{item.definition}</code>
</td>
</tr>
))}
</tbody>
</table>
{!result.items.length && (
<p>暂无算子。连接平台并同步目录,或调整筛选条件。</p>
)}
<div className="research-toolbar">
<span>共 {result.total} 个</span>
<Button
disabled={!offset}
onClick={() => setOffset(Math.max(0, offset - 25))}
>
上一页
</Button>
<Button
disabled={offset + 25 >= result.total}
onClick={() => setOffset(offset + 25)}
>
下一页
</Button>
</div>
</section>
{selected && (
<section className="research-card">
<h3>{selected.name}</h3>
<p>{selected.description}</p>
<code>{selected.definition}</code>
{selected.example && <pre>{selected.example}</pre>}
{!!selected.parameters && (
<pre>{JSON.stringify(selected.parameters, null, 2)}</pre>
)}
<p>适用:{selected.scope?.join("、") || "平台未提供"}</p>
<label>
研究备注
<TextArea
aria-label="算子研究备注"
value={selected.local.note}
onChange={(note) =>
setSelected({
...selected,
local: { ...selected.local, note },
})
}
autosize={{ minRows: 4, maxRows: 8 }}
/>
</label>
<Checkbox
checked={selected.local.favorite}
onChange={(event) =>
setSelected({
...selected,
local: {
...selected.local,
favorite: !!event.target.checked,
},
})
}
>
收藏算子
</Checkbox>
<div className="research-toolbar">
<Button
disabled={busy}
onClick={() =>
void task(async () => {
const result = await patch<{ version: number }>(
`/catalog/operators/${encodeURIComponent(selected.name)}/research`,
selected.local,
);
setSelected({
...selected,
local: { ...selected.local, version: result.version },
});
await load();
})
}
>
保存本地备注
</Button>
</div>
</section>
)}
</div>
</div>
);
}
+17
View File
@@ -0,0 +1,17 @@
import { useId, type ComponentProps } from "react";
import { Select } from "@douyinfe/semi-ui-19";
/** Semi Select exposes its accessible name through aria-labelledby. */
export function ResearchSelect({
label,
...props
}: ComponentProps<typeof Select> & { label: string }) {
const id = useId();
return (
<>
<span id={id} className="research-sr-only">
{label}
</span>
<Select {...props} aria-labelledby={id} />
</>
);
}
@@ -0,0 +1,39 @@
import { Button } from "@douyinfe/semi-ui-19";
import type { ToolCardProps } from "../ai/types";
export function ResearchToolCard({ call, onAction }: ToolCardProps) {
const result = call.result || {};
return (
<div>
<p>
{typeof result.name === "string" ? result.name : "研究资料"}
{typeof result.version === "number" ? ` · v${result.version}` : ""}
</p>
{typeof result.id === "string" && (
<Button
onClick={() =>
onAction(
result.content
? {
type: "open_template",
asset_id: result.id as string,
version: result.version as number,
nonce: Date.now(),
}
: {
type: "open_experiment",
experiment_id: result.id as string,
nonce: Date.now(),
},
)
}
>
打开研究记录
</Button>
)}
<details>
<summary>查看资料与校验依据</summary>
<pre>{JSON.stringify(result, null, 2)}</pre>
</details>
</div>
);
}
+682
View File
@@ -0,0 +1,682 @@
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>
);
}
+26 -3
View File
@@ -4,9 +4,16 @@ import type { UIAction } from "../ai/types";
import "./style.css";
export const sourceLabel = (kind: string) =>
({ chatbox: "Chatbox 研究", manual: "手工研究", ai: "AI 研究(历史)" })[
kind
] ?? kind;
({
chatbox: "Chatbox 研究",
manual: "手工研究",
ai: "AI 研究(历史)",
template: "模板研究",
variant: "Alpha 变体",
feature: "特征工程",
pipeline: "研究流水线",
quantflow: "QuantFlow",
})[kind] ?? kind;
export function SourceDetails({
source,
@@ -25,6 +32,22 @@ export function SourceDetails({
<p>来源引用:{source.reference}</p>
)}
<div className="inline-actions">
{source.research_id &&
["template", "variant", "feature", "pipeline", "quantflow"].includes(
source.kind,
) && (
<Button
onClick={() =>
onAction({
type: "open_experiment",
experiment_id: source.research_id!,
nonce: Date.now(),
})
}
>
查看研究实验
</Button>
)}
{source.kind === "chatbox" && source.reference && (
<Button
onClick={() =>
+180
View File
@@ -0,0 +1,180 @@
import { ResearchSelect } from "./ResearchSelect";
import { Button, Input, TextArea } from "@douyinfe/semi-ui-19";
import type { Template, Variable, InputSnapshot } from "./workspaceTypes";
const kinds = {
field: "字段",
operator: "算子",
integer: "整数",
number: "数值",
group: "分组",
string: "字符串",
fragment: "表达式片段",
};
export function TemplateEditor({
value,
onChange,
inputs,
}: {
value: Template;
onChange: (value: Template) => void;
inputs: InputSnapshot[];
}) {
function expression(text: string) {
const names = [
...new Set(
[...text.matchAll(/\{([A-Za-z_][A-Za-z0-9_]*)\}/g)].map(
(match) => match[1],
),
),
];
onChange({
...value,
expression: text,
variables: Object.fromEntries(
names.map((name) => [
name,
value.variables[name] || {
kind: "field",
field_type: "MATRIX",
values: [],
},
]),
),
});
}
function variable(name: string, next: Variable) {
onChange({ ...value, variables: { ...value.variables, [name]: next } });
}
return (
<div className="research-editor">
<div className="research-form-grid">
<label>
模板名称
<Input
aria-label="模板名称"
value={value.name}
onChange={(name) => onChange({ ...value, name })}
/>
</label>
<label>
类别
<ResearchSelect
label="模板类别"
value={value.category}
optionList={[
{ value: "template", label: "研究模板" },
{ value: "fragment", label: "算子组合片段" },
]}
onChange={(category) =>
onChange({ ...value, category: category as Template["category"] })
}
/>
</label>
</div>
<label>
研究解释
<TextArea
aria-label="模板研究解释"
value={value.description}
onChange={(description) => onChange({ ...value, description })}
autosize={{ minRows: 2, maxRows: 5 }}
/>
</label>
<label>
表达式模板
<TextArea
aria-label="表达式模板"
className="research-code-editor"
value={value.expression}
onChange={expression}
autosize={{ minRows: 3, maxRows: 10 }}
placeholder="rank({field})"
/>
</label>
<p className="research-hint">
使用 {"{name}"}{" "}
声明变量;重复占位符共享同一个取值。组合片段只复用表达式,不修改平台算子定义。
</p>
{Object.entries(value.variables).map(([name, item]) => (
<div className="research-variable" key={name}>
<strong>{`{${name}}`}</strong>
<ResearchSelect
label={`${name} 变量类型`}
value={item.kind}
optionList={Object.entries(kinds).map(([value, label]) => ({
value,
label,
}))}
onChange={(kind) =>
variable(name, {
kind: kind as Variable["kind"],
values: [],
...(kind === "field" ? { field_type: "MATRIX" } : {}),
})
}
/>
{item.kind === "field" && (
<ResearchSelect
label={`${name} 字段类型`}
value={item.field_type}
optionList={["MATRIX", "VECTOR", "GROUP"].map((value) => ({
value,
label: value,
}))}
onChange={(field_type) =>
variable(name, {
...item,
field_type: field_type as Variable["field_type"],
values: [],
})
}
/>
)}
<TextArea
aria-label={`${name} 候选取值`}
placeholder={
item.kind === "fragment"
? "每行一个完整表达式片段"
: "每行一个取值"
}
value={item.values.join("\n")}
autosize={{ minRows: 2, maxRows: 6 }}
onChange={(text) =>
variable(name, {
...item,
values: text
.split("\n")
.map((v) =>
["integer", "number"].includes(item.kind) && v.trim() !== ""
? Number(v)
: v,
),
})
}
/>
{item.kind === "field" && (
<Button
disabled={!inputs.length}
onClick={() =>
variable(name, {
...item,
values: [
...new Set(
inputs.flatMap((input) =>
input.field_ids.filter(
(id) => input.field_types[id] === item.field_type,
),
),
),
],
})
}
>
使用固定输入中的 {item.field_type} 字段
</Button>
)}
</div>
))}
</div>
);
}
+228
View File
@@ -0,0 +1,228 @@
.research-workspace {
height: 100%;
overflow: auto;
padding: 24px 28px;
color: var(--semi-color-text-0);
min-width: 0;
}
.research-page-heading,
.research-section-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.research-page-heading {
margin-bottom: 24px;
}
.research-page-heading h2 {
font-size: 22px;
margin: 0 0 8px;
}
.research-workspace p,
.research-hint {
font-size: 13px;
color: var(--semi-color-text-2);
line-height: 1.6;
}
.research-layout {
display: grid;
grid-template-columns: 220px minmax(0, 1fr);
gap: 24px;
align-items: start;
}
.research-library {
position: sticky;
top: 0;
max-height: calc(100vh - 180px);
overflow: auto;
padding-right: 12px;
}
.research-library h3,
.research-card h3 {
font-size: 16px;
margin: 0 0 12px;
}
.research-library > h3 {
margin-top: 28px;
}
.research-library-item {
display: flex;
flex-direction: column;
gap: 6px;
background: transparent;
border: 0;
border-radius: 6px;
padding: 12px;
text-align: left;
width: 100%;
color: inherit;
cursor: pointer;
margin: 4px 0;
}
.research-library-item.selected,
.research-library-item:hover {
background: var(--semi-color-fill-0);
}
.research-library-item span {
font-size: 12px;
color: var(--semi-color-text-2);
}
.research-main {
min-width: 0;
display: grid;
gap: 20px;
}
.research-card {
border: 1px solid var(--semi-color-border);
border-radius: 8px;
padding: 20px;
background: var(--semi-color-bg-1);
min-width: 0;
}
.research-card label,
.research-editor label {
display: flex;
flex-direction: column;
gap: 8px;
font-size: 13px;
margin: 12px 0;
min-width: 0;
}
.research-form-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 16px;
}
.research-toolbar,
.research-methods,
.research-lineage {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12px;
margin: 16px 0;
}
.research-toolbar > .semi-input-wrapper {
flex: 1;
min-width: 160px;
}
.research-lineage {
font-size: 12px;
overflow-wrap: anywhere;
}
.research-variable {
display: grid;
grid-template-columns: minmax(80px, 0.5fr) repeat(2, minmax(100px, 1fr));
gap: 10px;
align-items: start;
padding: 14px 0;
border-top: 1px solid var(--semi-color-border);
}
.research-variable > .semi-input-textarea-wrapper {
grid-column: 1 / -1;
}
.research-variable > button {
grid-column: 1 / -1;
justify-self: start;
}
.research-code-editor textarea,
.research-workspace code {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.research-workspace pre {
white-space: pre-wrap;
overflow-wrap: anywhere;
font-size: 12px;
max-height: 320px;
overflow: auto;
background: var(--semi-color-fill-0);
padding: 12px;
border-radius: 4px;
}
.research-table-scroll {
overflow-x: auto;
}
.research-table {
border-collapse: collapse;
width: 100%;
font-size: 13px;
}
.research-table th {
color: var(--semi-color-text-2);
font-weight: 500;
background: var(--semi-color-fill-0);
}
.research-table th,
.research-table td {
text-align: left;
vertical-align: top;
padding: 12px;
border-bottom: 1px solid var(--semi-color-border);
}
.research-table td {
max-width: 350px;
overflow-wrap: anywhere;
}
.research-table details {
margin-top: 8px;
}
.research-workspace summary {
cursor: pointer;
font-size: 13px;
}
.research-comparison-grid,
.research-operator-layout {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 20px;
}
.research-operator-layout {
grid-template-columns: minmax(0, 1.5fr) minmax(280px, 1fr);
}
.research-comparison-grid article {
padding-top: 12px;
}
.sidebar-group-heading {
color: var(--semi-color-text-2);
font-size: 11px;
padding: 18px 12px 8px;
}
@media (max-width: 1100px) {
.research-layout {
grid-template-columns: 170px minmax(0, 1fr);
gap: 16px;
}
.research-workspace {
padding: 20px;
}
.research-operator-layout,
.research-comparison-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 760px) {
.research-layout {
display: block;
}
.research-library {
position: static;
max-height: 200px;
margin-bottom: 16px;
}
.research-page-heading {
align-items: start;
}
}
.research-sr-only {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
+82
View File
@@ -0,0 +1,82 @@
import type { Candidate } from "../backtests/types";
export type Variable = {
kind:
| "field"
| "operator"
| "integer"
| "number"
| "group"
| "string"
| "fragment";
values: (string | number)[];
field_type?: "MATRIX" | "VECTOR" | "GROUP" | null;
};
export type Template = {
name: string;
description: string;
expression: string;
variables: Record<string, Variable>;
category: "template" | "fragment";
scope?: InputSnapshot["scope"] | null;
};
export type Asset = {
id: string;
name: string;
kind: string;
version: number;
content: Template;
created_at?: string;
};
export type InputSnapshot = {
id: string;
scope: {
instrument_type: string;
region: string;
universe: string;
delay: 0 | 1;
};
dataset_id: string;
field_ids: string[];
field_types: Record<string, string>;
collection_version: string;
created_at: string;
};
export type ResearchCandidate = Candidate & {
validation: {
status: string;
syntax: string[];
types: string[];
availability: string[];
};
bindings: Record<string, unknown>;
changes: unknown;
};
export type Experiment = {
id: string;
name: string;
hypothesis: string;
kind: string;
inputs: InputSnapshot[];
parents: { kind: string; id: string; expression?: string }[];
candidates: ResearchCandidate[];
evidence: {
template?: Asset;
combination_count?: string;
seed?: number;
method?: string;
};
backtest_run_ids: string[];
created_at: string;
};
export const blankTemplate = (): Template => ({
name: "新研究模板",
description: "",
expression: "rank({field})",
variables: { field: { kind: "field", field_type: "MATRIX", values: [] } },
category: "template",
});
export const validationLabel: Record<string, string> = {
valid: "本地校验通过",
needs_review: "待核实",
invalid: "校验失败",
};