refactor: unify data preparations and research input snapshots
Deploy production / deploy (push) Successful in 53s

This commit is contained in:
yuxuanhui
2026-09-12 01:24:02 +08:00
parent 849f86fef7
commit 394438e753
82 changed files with 4146 additions and 2076 deletions
+15
View File
@@ -1,3 +1,6 @@
import { FieldDirectory } from "./preparations/FieldDirectory";
import { DataPreparationPage } from "./preparations/DataPreparationPage";
import { SnapshotDialog } from "./preparations/SnapshotDialog";
import { useCallback, useEffect, useRef, useState } from "react";
import {
Badge,
@@ -390,6 +393,15 @@ export default function App() {
onTask={taskCreated}
/>
</div>
<div className="alpha-page-view" hidden={page !== "fields"}>
<FieldDirectory
active={page === "fields"}
revision={completedVersion}
/>
</div>
<div className="alpha-page-view" hidden={page !== "preparations"}>
<DataPreparationPage active={page === "preparations"} />
</div>
<div className="alpha-page-view" hidden={page !== "datasets"}>
<DatasetPage
onContext={setDatasetContext}
@@ -507,6 +519,7 @@ export default function App() {
<ModelSettingsPanel />
<WorkspacePreferences account={account} onChange={actionDone} />
</SideSheet>
<SnapshotDialog action={aiAction} />
<JobPanel
visible={showJobs && !(viewport < 1440 && chatOpen)}
chatOffset={chatOffset}
@@ -560,6 +573,8 @@ export default function App() {
: { page: "variants" as const },
alphas: alphaContext,
datasets: datasetContext,
fields: { page: "fields" as const },
preparations: { page: "preparations" as const },
backtests: backtestContext,
account: { page: "account" as const },
"mcp-keys": { page: "account" as const },
+3 -1
View File
@@ -17,6 +17,8 @@ export type PageContext = {
| "alphas"
| "account"
| "datasets"
| "fields"
| "preparations"
| "backtests"
| "operators"
| "templates"
@@ -36,7 +38,7 @@ export type PageContext = {
dataset_id?: string;
field_id?: string;
collection_version?: string;
template_input_id?: string;
input_snapshot_id?: string;
unsaved_field_selection?: boolean;
backtest_run_id?: string;
backtest_preview_id?: string;
+6 -4
View File
@@ -12,7 +12,7 @@ export function contextReferences(context: PageContext): ResearchReference[] {
add("scope", "范围", `${scope.region}/${scope.universe}/D${scope.delay}`);
add("dataset", "数据集", context.dataset_id);
add("field", "字段", context.field_id);
add("input", "固定输入", context.template_input_id);
add("input", "固定输入", context.input_snapshot_id);
add("version", "集合版本", context.collection_version);
add("asset", "研究素材", context.research_asset_id);
add("experiment", "实验", context.research_experiment_id);
@@ -29,7 +29,7 @@ export function researchPrompts(context: PageContext): string[] {
case "datasets":
return context.unsaved_field_selection
? ["解释当前数据集的字段和适用场景"]
: context.template_input_id
: context.input_snapshot_id
? [
"分析这个固定输入中的字段,提出可验证的研究假设",
"基于这个固定输入构建候选,并预览回测",
@@ -63,6 +63,8 @@ const contextLabels: Record<
PageContext["page"],
(context: PageContext) => string
> = {
fields: () => "上下文:字段目录",
preparations: () => "上下文:数据准备",
home: () => "上下文:首页看板",
alphas: (context) =>
`上下文:${context.alpha_id ? `Alpha ${context.alpha_id}` : "Alpha 列表"}${context.selected_ids?.length ? ` · 已选 ${context.selected_ids.length} 条` : ""}`,
@@ -78,7 +80,7 @@ const contextLabels: Record<
account: () => "上下文:个人信息页",
backtests: () => "上下文:回测研究",
datasets: (context) =>
`上下文:${context.dataset_id ?? "数据目录"}${context.catalog_scope ? ` · ${context.catalog_scope.region}/${context.catalog_scope.universe}/D${context.catalog_scope.delay}` : ""}${context.template_input_id ? " · 固定研究输入" : context.unsaved_field_selection ? " · 请先保存字段选择" : ""}(不发送未保存备注)`,
`上下文:${context.dataset_id ?? "数据目录"}${context.catalog_scope ? ` · ${context.catalog_scope.region}/${context.catalog_scope.universe}/D${context.catalog_scope.delay}` : ""}${context.input_snapshot_id ? " · 固定研究输入" : context.unsaved_field_selection ? " · 请先保存字段选择" : ""}(不发送未保存备注)`,
};
export function contextLabel(context: PageContext): string {
@@ -103,7 +105,7 @@ const destinations: Record<UIAction["type"], Destination> = {
open_experiment: { page: "templates", chat: "responsive" },
open_variant: { page: "variants", chat: "responsive" },
open_conversation: { chat: "open" },
open_research_input: { page: "datasets", chat: "close" },
open_research_input: { page: "preparations", chat: "close" },
open_backtest: { page: "backtests", chat: "responsive" },
open_backtest_preview: { page: "backtests", chat: "responsive" },
open_alpha: { page: "alphas", chat: "responsive" },
+1
View File
@@ -82,6 +82,7 @@ export const stateOptions = Object.entries(stateLabels).map(
([value, label]) => ({ value, label }),
);
export const jobLabels: Record<string, string> = {
catalog_full_sync: "全量同步数据目录与字段",
catalog_sync: "同步数据集目录",
field_sync: "同步数据字段",
full_sync: "全量同步 Alpha",
+29 -1
View File
@@ -1,3 +1,8 @@
import {
ResearchDataInput,
researchSelection,
} from "../preparations/ResearchDataInput";
import type { InputSnapshot } from "../research/workspaceTypes";
import { useCallback, useEffect, useState } from "react";
import {
Banner,
@@ -70,6 +75,8 @@ export function BacktestPage({
const [editor, setEditor] = useState(false);
const [draft, setDraft] = useState<Draft | null>(null);
const [name, setName] = useState("");
const [inputIds, setInputIds] = useState<string[]>([]);
const [inputs, setInputs] = useState<InputSnapshot[]>([]);
const [source, setSource] = useState<Source>({ kind: "manual" });
const [text, setText] = useState("");
const [mode, setMode] = useState("lines");
@@ -215,6 +222,8 @@ export function BacktestPage({
setDraft(null);
setName("");
setSource({ kind: "manual" });
setInputIds([]);
setInputs([]);
setText("");
setSettings(initialSettings);
setMode("lines");
@@ -241,7 +250,7 @@ export function BacktestPage({
}));
if (!Array.isArray(candidates) || !candidates.length)
throw new Error("请提供非空候选集合");
return { name, source, candidates };
return { name, source, candidates, ...researchSelection(inputIds, inputs) };
}
async function loadDraft(id: string) {
setSettingsDraft(null);
@@ -249,6 +258,8 @@ export function BacktestPage({
setDraft(d);
setName(d.name);
setSource(d.source);
setInputIds(d.source.input_snapshot_ids ?? []);
setInputs([]);
setMode("json");
setText(JSON.stringify(d.candidates, null, 2));
setPreview(null);
@@ -600,6 +611,23 @@ export function BacktestPage({
maxLength={200}
/>
</label>
<ResearchDataInput
label="选择回测数据准备(可选)"
ids={inputIds}
inputs={inputs}
onChange={(ids, rows) => {
setInputIds(ids);
setInputs(rows);
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,
}));
}}
/>
<div className="backtest-form-grid">
<label>
来源
+2 -1
View File
@@ -39,7 +39,8 @@ export type Source = {
kind: string;
reference?: string | null;
batch_id?: string | null;
template_input_id?: string | null;
input_snapshot_ids?: string[];
input_snapshot_id?: string | null;
research_id?: string | null;
parent_run_id?: string | null;
hypothesis?: string | null;
+7
View File
@@ -18,6 +18,13 @@ import type { WorkspacePage } from "../ai/workspace";
const navigation = [
{ id: "home", label: "首页看板", icon: IconGridView, group: "概览" },
{ id: "datasets", label: "数据目录", icon: IconList, group: "数据与素材" },
{ id: "fields", label: "字段目录", icon: IconList, group: "数据与素材" },
{
id: "preparations",
label: "数据准备",
icon: IconList,
group: "数据与素材",
},
{ id: "operators", label: "算子库", icon: IconList, group: "数据与素材" },
{
id: "templates",
+8
View File
@@ -93,6 +93,14 @@ export function JobPanel({
{job.checkpoint.dates_completed} / {job.checkpoint.dates_total} 天
</p>
)}
{job.kind === "catalog_full_sync" && (
<p>
数据集 {String(job.checkpoint?.dataset_id ?? "目录")} · 已完成{" "}
{String(job.checkpoint?.datasets_completed ?? 0)} /{" "}
{String(job.checkpoint?.datasets_total ?? "待统计")}
{!!job.error && <span> · {job.error}</span>}
</p>
)}
{job.kind === "pnl_backfill" && (
<p className="muted">
{job.total === 0
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,421 @@
import { useEffect, useState } from "react";
import {
Banner,
Button,
Input,
Modal,
Pagination,
Table,
TextArea,
Toast,
Tabs,
TabPane,
} from "@douyinfe/semi-ui-19";
import { api, patch, post, queryString, formatTime } from "../api";
import {
type Page,
type Preparation,
type FieldRecord,
defaultScope,
fieldReference,
scopeKey,
scopeLabel,
} from "./types";
import { ScopeControls } from "./ScopeControls";
import { FieldTable } from "./FieldTable";
import { FieldBrowser } from "./FieldDirectory";
import "./style.css";
export function DataPreparationPage({ active }: { active: boolean }) {
const [data, setData] = useState<Page<Preparation>>({
items: [],
total: 0,
limit: 25,
offset: 0,
}),
[q, setQ] = useState(""),
[page, setPage] = useState(1),
[rev, setRev] = useState(0);
const [selected, setSelected] = useState<Record<string, Preparation>>({}),
[scope, setScope] = useState(defaultScope),
[scopeFilter, setScopeFilter] = useState(false);
const [detail, setDetail] = useState<Preparation | null>(null),
[creating, setCreating] = useState(false),
[name, setName] = useState(""),
[note, setNote] = useState("");
const [fields, setFields] = useState<Page<FieldRecord>>({
items: [],
total: 0,
limit: 25,
offset: 0,
}),
[fieldPage, setFieldPage] = useState(1),
[fieldQ, setFieldQ] = useState("");
const [fieldSelection, setFieldSelection] = useState<string[]>([]),
[adding, setAdding] = useState(false),
[tab, setTab] = useState("local");
const [error, setError] = useState(""),
[busy, setBusy] = useState(false),
[deleting, setDeleting] = useState<Preparation[]>([]);
useEffect(() => {
if (!active) return;
const c = new AbortController();
api<Page<Preparation>>(
`/data-preparations?${queryString({ q, offset: (page - 1) * 25, scope_key: scopeFilter ? scopeKey(scope) : undefined })}`,
{ signal: c.signal },
)
.then((r) => {
setData(r);
setPage((p) => Math.min(p, Math.max(1, Math.ceil(r.total / 25))));
})
.catch((e) => {
if (!c.signal.aborted) setError(e.message);
});
return () => c.abort();
}, [active, q, page, rev, scopeFilter, scopeKey(scope)]);
useEffect(() => {
if (!detail) return;
const c = new AbortController();
api<Page<FieldRecord>>(
`/data-preparations/${detail.id}/fields?${queryString({ q: fieldQ, offset: (fieldPage - 1) * 25 })}`,
{ signal: c.signal },
)
.then(setFields)
.catch((e) => {
if (!c.signal.aborted) setError(e.message);
});
return () => c.abort();
}, [detail, fieldQ, fieldPage]);
async function work(fn: () => Promise<void>) {
setBusy(true);
setError("");
try {
await fn();
setRev((v) => v + 1);
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
}
function open(row: Preparation) {
setDetail(row);
setName(row.name);
setNote(row.note);
setFieldQ("");
setFieldPage(1);
setFieldSelection([]);
setError("");
}
useEffect(() => {
if (!active) return;
const id = new URLSearchParams(location.hash.split("?")[1] ?? "").get("id");
if (id)
void work(async () =>
open(
await api<Preparation>(
`/data-preparations/${encodeURIComponent(id)}`,
),
),
);
}, [active]);
return (
<section className="preparation-page">
{error && <Banner type="danger" description={error} />}
<div className="preparation-tools">
<Input
aria-label="搜索准备集合"
placeholder="搜索名称或备注"
value={q}
onChange={(v) => {
setQ(v);
setPage(1);
}}
/>
<Button
theme="solid"
onClick={() => {
setCreating(true);
setName("新数据准备集合");
setNote("");
}}
>
新建集合
</Button>
<Button
onClick={() => {
setScopeFilter((v) => !v);
setPage(1);
}}
>
{scopeFilter ? "全部范围" : "按范围筛选"}
</Button>
<Button
type="danger"
disabled={!Object.keys(selected).length}
onClick={() => setDeleting(Object.values(selected))}
>
删除所选
</Button>
</div>
{scopeFilter && (
<ScopeControls
local
value={scope}
onChange={(s) => {
setScope(s);
setPage(1);
setSelected({});
}}
/>
)}
<Table<Preparation>
rowKey="id"
size="small"
dataSource={data.items}
pagination={false}
scroll={{ x: 1000, y: "100%" }}
rowSelection={{
selectedRowKeys: Object.keys(selected),
onChange: (keys) =>
setSelected((old) => {
const n: Record<string, Preparation> = {};
for (const k of keys ?? []) {
const r = data.items.find((x) => x.id === k) ?? old[k];
if (r) n[k] = r;
}
return n;
}),
}}
columns={[
{
title: "集合名称",
render: (_, r) => (
<Button theme="borderless" onClick={() => open(r)}>
{r.name}
</Button>
),
},
{ title: "范围", render: (_, r) => scopeLabel(r.scope) },
{ title: "字段数", dataIndex: "field_count" },
{ title: "数据集数", dataIndex: "dataset_count" },
{ title: "更新时间", render: (_, r) => formatTime(r.updated_at) },
{
title: "操作",
render: (_, r) => (
<div className="preparation-tools">
<Button theme="borderless" onClick={() => open(r)}>
查看
</Button>
<Button
theme="borderless"
disabled={busy}
onClick={() =>
void work(async () => {
const copy = await post<Preparation>(
`/data-preparations/${r.id}/copy`,
{ version: r.version },
);
open(copy);
})
}
>
复制
</Button>
<Button
theme="borderless"
type="danger"
onClick={() => setDeleting([r])}
>
删除
</Button>
</div>
),
},
]}
/>
<Pagination
currentPage={page}
total={data.total}
pageSize={25}
onPageChange={setPage}
/>
<Modal
visible={creating && active}
title="新建数据准备集合"
closeOnEsc
onCancel={() => setCreating(false)}
confirmLoading={busy}
okButtonProps={{ disabled: !name.trim() || busy }}
onOk={() =>
work(async () => {
const row = await post<Preparation>("/data-preparations", {
name,
note,
scope,
});
setCreating(false);
open(row);
})
}
>
{error && <Banner type="danger" description={error} />}
<Input aria-label="集合名称" value={name} onChange={setName} />
<ScopeControls value={scope} onChange={setScope} />
<TextArea aria-label="集合备注" value={note} onChange={setNote} />
</Modal>
<Modal
visible={!!detail && active && !adding && !deleting.length}
title="数据准备详情"
width="min(1200px, 96vw)"
closeOnEsc
footer={null}
onCancel={() => setDetail(null)}
>
{detail && (
<div className="preparation-detail">
{error && <Banner type="danger" description={error} />}
<div>
{scopeLabel(detail.scope)} · v{detail.version} ·{" "}
{detail.field_count} 字段 / {detail.dataset_count} 数据集
</div>
<label>
集合名称
<Input aria-label="集合名称" value={name} onChange={setName} />
</label>
<label>
备注
<TextArea aria-label="集合备注" value={note} onChange={setNote} />
</label>
<div className="preparation-tools">
<Button
disabled={busy || !name.trim()}
onClick={() =>
void work(async () =>
setDetail(
await patch(`/data-preparations/${detail.id}`, {
version: detail.version,
name,
note,
}),
),
)
}
>
保存名称和备注
</Button>
<Button
disabled={busy}
onClick={() =>
void work(async () =>
open(await api(`/data-preparations/${detail.id}`)),
)
}
>
重新载入
</Button>
<Button onClick={() => setAdding(true)}>添加字段</Button>
<Button
disabled={busy || !fieldSelection.length}
onClick={() =>
void work(async () => {
setDetail(
await patch(`/data-preparations/${detail.id}/fields`, {
version: detail.version,
remove_ids: fieldSelection.map((k) => k.split("|")[1]),
}),
);
setFieldSelection([]);
})
}
>
移除所选字段
</Button>
<Input
aria-label="集合内搜索字段"
value={fieldQ}
placeholder="搜索字段名称、ID、描述"
onChange={(v) => {
setFieldQ(v);
setFieldPage(1);
}}
/>
</div>
<FieldTable
items={fields.items}
selected={fieldSelection}
onSelect={setFieldSelection}
/>
<Pagination
currentPage={fieldPage}
total={fields.total}
pageSize={25}
onPageChange={setFieldPage}
/>
</div>
)}
</Modal>
<Modal
visible={adding && active}
title="添加同范围字段"
width="min(1250px,96vw)"
footer={null}
closeOnEsc
onCancel={() => setAdding(false)}
>
{error && <Banner type="danger" description={error} />}
<Tabs activeKey={tab} onChange={setTab} tabPaneMotion={false}>
{(["local", "worldquant"] as const).map((source) => (
<TabPane
key={source}
itemKey={source}
tab={source === "local" ? "本地同步" : "worldquant接口"}
>
{detail && (
<FieldBrowser
active={adding && tab === source}
source={source}
fixedScope={detail.scope}
onAdd={(chosen) =>
void work(async () => {
setDetail(
await patch(`/data-preparations/${detail.id}/fields`, {
version: detail.version,
fields: chosen.map(fieldReference),
}),
);
setAdding(false);
})
}
/>
)}
</TabPane>
))}
</Tabs>
</Modal>
<Modal
visible={!!deleting.length}
title="删除数据准备集合"
closeOnEsc
onCancel={() => setDeleting([])}
confirmLoading={busy}
okType="danger"
okText="删除"
onOk={() =>
work(async () => {
await post("/data-preparations/batch-delete", {
items: deleting.map((r) => ({ id: r.id, version: r.version })),
});
if (deleting.some((r) => r.id === detail?.id)) setDetail(null);
setDeleting([]);
setSelected({});
Toast.success("集合已删除,已有研究快照保留");
})
}
>
{error && <Banner type="danger" description={error} />}删除{" "}
{deleting.length} 个集合:{deleting.map((r) => r.name).join("、")}
。已创建的研究保留使用时的快照。
</Modal>
</section>
);
}
@@ -0,0 +1,215 @@
import { useEffect, useState } from "react";
import {
Banner,
Button,
Input,
Modal,
Pagination,
Table,
} from "@douyinfe/semi-ui-19";
import { api, queryString } from "../api";
import { FieldTable } from "./FieldTable";
import {
type FieldRecord,
type Page,
type Preparation,
type PreparationRef,
type Scope,
scopeKey,
scopeLabel,
} from "./types";
export function DataPreparationPicker({
visible,
onCancel,
onConfirm,
scope,
multiple = true,
allowEmpty = false,
}: {
visible: boolean;
onCancel: () => void;
onConfirm: (
refs: PreparationRef[],
rows: Preparation[],
) => void | Promise<void>;
scope?: Scope;
multiple?: boolean;
allowEmpty?: boolean;
}) {
const [q, setQ] = useState(""),
[page, setPage] = useState(1),
[data, setData] = useState<Page<Preparation>>({
items: [],
total: 0,
limit: 25,
offset: 0,
});
const [selected, setSelected] = useState<Record<string, Preparation>>({}),
[error, setError] = useState(""),
[busy, setBusy] = useState(false);
const [preview, setPreview] = useState<Preparation | null>(null),
[fields, setFields] = useState<FieldRecord[]>([]),
[fieldPage, setFieldPage] = useState(1);
useEffect(() => {
if (visible) {
setSelected({});
setPreview(null);
setError("");
}
}, [visible, scope && scopeKey(scope)]);
useEffect(() => {
if (!visible) return;
const c = new AbortController();
setBusy(true);
api<Page<Preparation>>(
`/data-preparations?${queryString({ q, limit: 25, offset: (page - 1) * 25, scope_key: scope ? scopeKey(scope) : undefined })}`,
{ signal: c.signal },
)
.then(setData)
.catch((e) => {
if (!c.signal.aborted) setError(e.message);
})
.finally(() => {
if (!c.signal.aborted) setBusy(false);
});
return () => c.abort();
}, [visible, q, page, scope && scopeKey(scope)]);
useEffect(() => {
if (!preview) return;
const c = new AbortController();
setFields([]);
api<Page<FieldRecord>>(
`/data-preparations/${preview.id}/fields?limit=25&offset=${(fieldPage - 1) * 25}`,
{ signal: c.signal },
)
.then((r) => setFields(r.items))
.catch((e) => {
if (!c.signal.aborted) setError(e.message);
});
return () => c.abort();
}, [preview, fieldPage]);
return (
<Modal
visible={visible}
title="选择数据准备集合"
width="min(1080px, 96vw)"
closeOnEsc
onCancel={onCancel}
confirmLoading={busy}
okText="选择"
okButtonProps={{
"aria-label": "选择",
disabled: !Object.keys(selected).length || busy,
}}
onOk={async () => {
setBusy(true);
try {
const rows = Object.values(selected);
await onConfirm(
rows.map((r) => ({ id: r.id, version: r.version })),
rows,
);
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
}}
>
{error && <Banner type="danger" description={error} />}
<div className="preparation-tools">
<Input
aria-label="搜索数据准备集合"
placeholder="搜索名称或备注"
value={q}
onChange={(v) => {
setQ(v);
setPage(1);
}}
/>
<span>
{scope ? scopeLabel(scope) : "各集合保持各自范围"} · 已选{" "}
{Object.keys(selected).length}
</span>
</div>
<Table<Preparation>
rowKey="id"
dataSource={data.items}
size="small"
pagination={false}
loading={busy}
rowSelection={
multiple
? {
selectedRowKeys: Object.keys(selected),
getCheckboxProps: (r) => ({
disabled: !allowEmpty && !r.field_count,
}),
onChange: (keys) =>
setSelected((old) => {
const next: Record<string, Preparation> = {};
for (const k of keys ?? []) {
const row = data.items.find((r) => r.id === k) ?? old[k];
if (row) next[k] = row;
}
return next;
}),
}
: undefined
}
columns={[
{
title: "集合名称",
render: (_, r) => (
<Button
theme="borderless"
onClick={() => {
setPreview(r);
setFieldPage(1);
}}
>
{r.name}
</Button>
),
},
{ title: "范围", render: (_, r) => scopeLabel(r.scope) },
{ title: "字段数", dataIndex: "field_count" },
{ title: "数据集数", dataIndex: "dataset_count" },
...(!multiple
? [
{
title: "操作",
render: (_: unknown, r: Preparation) => (
<Button
disabled={!allowEmpty && !r.field_count}
onClick={() => setSelected({ [r.id]: r })}
>
{selected[r.id] ? "已选择" : "选择"}
</Button>
),
},
]
: []),
]}
/>
<Pagination
currentPage={page}
total={data.total}
pageSize={25}
onPageChange={setPage}
/>
{preview && (
<section>
<h4>{preview.name} · 字段预览</h4>
<FieldTable items={fields} />
<Pagination
currentPage={fieldPage}
total={preview.field_count}
pageSize={25}
onPageChange={setFieldPage}
/>
</section>
)}
</Modal>
);
}
@@ -0,0 +1,409 @@
import { useEffect, useState } from "react";
import {
Banner,
Button,
Input,
Modal,
Pagination,
TabPane,
Tabs,
Toast,
} from "@douyinfe/semi-ui-19";
import { api, patch, post, queryString } from "../api";
import { ResearchSelect } from "../research/ResearchSelect";
import { ScopeControls } from "./ScopeControls";
import { DataPreparationPicker } from "./DataPreparationPicker";
import { FieldTable } from "./FieldTable";
import {
defaultScope,
fieldReference,
scopeKey,
scopeLabel,
type FieldRecord,
type Page,
type Preparation,
type Scope,
} from "./types";
import "./style.css";
export function AddFieldsDialog({
fields,
onClose,
onDone,
}: {
fields: FieldRecord[];
onClose: () => void;
onDone?: () => void;
}) {
const [name, setName] = useState("新数据准备集合"),
[picker, setPicker] = useState(false),
[error, setError] = useState(""),
[busy, setBusy] = useState(false);
async function save(target?: Preparation) {
setBusy(true);
try {
const refs = fields.map(fieldReference);
const result = target
? await patch<Preparation>(`/data-preparations/${target.id}/fields`, {
version: target.version,
fields: refs,
})
: await post<Preparation>("/data-preparations", {
name,
scope: fields[0].scope,
fields: refs,
});
Toast.success(`已加入 ${result.name}`);
onDone?.();
onClose();
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
}
return (
<>
<Modal
visible={!picker}
title="加入数据准备"
closeOnEsc
onCancel={onClose}
onOk={() => save()}
okText="新建集合并加入"
confirmLoading={busy}
okButtonProps={{
"aria-label": "新建集合并加入",
disabled: !name.trim() || busy,
}}
>
{error && <Banner type="danger" description={error} />}
<p>
{fields.length} 个字段 · {scopeLabel(fields[0].scope)}
</p>
<Input aria-label="新集合名称" value={name} onChange={setName} />
<Button theme="borderless" onClick={() => setPicker(true)}>
追加到现有集合
</Button>
</Modal>
<DataPreparationPicker
visible={picker}
onCancel={() => setPicker(false)}
scope={fields[0].scope}
multiple={false}
allowEmpty
onConfirm={async (_, rows) => {
await save(rows[0]);
setPicker(false);
}}
/>
</>
);
}
export function FieldBrowser({
active = true,
source = "local",
fixedScope,
onAdd,
revision = "",
}: {
active?: boolean;
source?: "local" | "worldquant";
fixedScope?: Scope;
onAdd?: (fields: FieldRecord[]) => void;
revision?: string;
}) {
const [scope, setScope] = useState<Scope>(fixedScope ?? defaultScope),
[draft, setDraft] = useState<Record<string, string>>({}),
[filters, setFilters] = useState<Record<string, string>>({});
const [page, setPage] = useState(1),
[size, setSize] = useState(25),
[refresh, setRefresh] = useState(0),
[advanced, setAdvanced] = useState(false);
const [data, setData] = useState<Page<FieldRecord>>({
items: [],
total: 0,
limit: 25,
offset: 0,
}),
[selected, setSelected] = useState<Record<string, FieldRecord>>({});
const [error, setError] = useState(""),
[loading, setLoading] = useState(false),
[adding, setAdding] = useState(false);
const actual = fixedScope ?? scope,
key = scopeKey(actual);
useEffect(() => {
setSelected({});
setData({ items: [], total: 0, limit: 25, offset: 0 });
setPage(1);
}, [key]);
useEffect(() => {
if (!active) return;
const c = new AbortController();
setLoading(true);
setError("");
api<Page<FieldRecord>>(
`/catalog/${source === "local" ? "fields" : "worldquant/fields"}?${queryString({ ...actual, ...filters, limit: size, offset: (page - 1) * size })}`,
{ signal: c.signal },
)
.then((r) => {
setData(r);
setSelected((old) => {
const next = { ...old };
for (const f of r.items) {
const k = `${f.dataset_id}|${f.id}`;
if (next[k]) next[k] = f;
}
return next;
});
})
.catch((e) => {
if (!c.signal.aborted) setError(e.message);
})
.finally(() => {
if (!c.signal.aborted) setLoading(false);
});
return () => c.abort();
}, [active, source, key, filters, page, size, refresh, revision]);
const update = (k: string, v: string) =>
setDraft((old) => ({ ...old, [k]: v }));
return (
<div className="preparation-fields">
{!fixedScope && (
<ScopeControls
value={scope}
onChange={setScope}
local={source === "local"}
active={active}
revision={revision}
/>
)}
<div className="preparation-tools">
<Input
aria-label="搜索字段"
placeholder="字段 ID、名称或描述"
value={draft.q ?? ""}
onChange={(v) => update("q", v)}
/>
<Input
aria-label="字段数据集"
placeholder="数据集 ID"
value={draft.dataset_id ?? ""}
onChange={(v) => update("dataset_id", v)}
/>
<ResearchSelect
label="字段类型"
placeholder="全部类型"
showClear
value={draft.field_type || undefined}
optionList={["MATRIX", "VECTOR", "GROUP"].map((v) => ({
label: v,
value: v,
}))}
onChange={(v) => update("field_type", String(v ?? ""))}
/>
<Button
onClick={() => {
setFilters({
...draft,
synced_from: draft.synced_from
? new Date(draft.synced_from).toISOString()
: "",
synced_to: draft.synced_to
? new Date(draft.synced_to).toISOString()
: "",
});
setPage(1);
setRefresh((v) => v + 1);
}}
>
查询
</Button>
<Button onClick={() => setAdvanced((v) => !v)}>更多筛选</Button>
<Button
onClick={() => {
setDraft({});
setFilters({});
setPage(1);
}}
>
重置
</Button>
</div>
{advanced && (
<div className="preparation-filters">
{source === "local" &&
["category", "subcategory"].map((k, i) => (
<label key={k}>
{i ? "子分类" : "分类"}
<Input
aria-label={i ? "字段子分类" : "字段分类"}
value={draft[k] ?? ""}
onChange={(v) => update(k, v)}
/>
</label>
))}
{[
["coverage", "覆盖率(0–1)"],
["user_count", "用户数"],
["alpha_count", "Alpha 数"],
].flatMap(([k, label]) =>
["min", "max"].map((suffix) => (
<label key={k + suffix}>
{label}{" "}
{source === "worldquant"
? suffix === "min"
? ">"
: "<"
: suffix === "min"
? "≥"
: "≤"}
<Input
type="number"
aria-label={`${label}${suffix === "min" ? "下限" : "上限"}`}
value={draft[k + "_" + suffix] ?? ""}
onChange={(v) => update(k + "_" + suffix, v)}
/>
</label>
)),
)}
{source === "local" && (
<>
<label>
同步起始时间
<input
type="datetime-local"
value={draft.synced_from ?? ""}
onChange={(e) => update("synced_from", e.target.value)}
/>
</label>
<label>
同步结束时间
<input
type="datetime-local"
value={draft.synced_to ?? ""}
onChange={(e) => update("synced_to", e.target.value)}
/>
</label>
<ResearchSelect
label="字段排序"
value={draft.sort ?? "id"}
optionList={[
"id",
"name",
"dataset_id",
"coverage",
"user_count",
"alpha_count",
"synced_at",
].map((v) => ({ value: v, label: v }))}
onChange={(v) => update("sort", String(v))}
/>
<ResearchSelect
label="排序方向"
value={draft.direction ?? "asc"}
optionList={[
{ value: "asc", label: "升序" },
{ value: "desc", label: "降序" },
]}
onChange={(v) => update("direction", String(v))}
/>
</>
)}
</div>
)}
{error && <Banner type="danger" description={error} />}
<div className="preparation-tools">
<Button
theme="solid"
disabled={!Object.keys(selected).length || loading || !!error}
onClick={() =>
onAdd ? onAdd(Object.values(selected)) : setAdding(true)
}
>
加入数据准备
</Button>
<span>已选 {Object.keys(selected).length} 个字段 · 表头选择当前页</span>
{!!Object.keys(selected).length && (
<Button onClick={() => setSelected({})}>清空选择</Button>
)}
</div>
<FieldTable
items={data.items}
loading={loading}
selected={Object.keys(selected)}
onSelect={(ids) =>
setSelected((old) => {
const next: Record<string, FieldRecord> = {};
for (const id of ids) {
const f =
data.items.find((r) => `${r.dataset_id}|${r.id}` === id) ??
old[id];
if (f) next[id] = f;
}
return next;
})
}
/>
<div className="preparation-tools">
<span>
{data.total_known === false
? "平台未提供总数"
: `共 ${data.total} 个字段`}
</span>
<Pagination
currentPage={page}
pageSize={size}
total={data.total}
onPageChange={setPage}
/>
<ResearchSelect
label="字段每页条数"
value={size}
optionList={[25, 50, 100].map((v) => ({
value: v,
label: `${v} 条/页`,
}))}
onChange={(v) => {
setSize(Number(v));
setPage(1);
}}
/>
</div>
{adding && (
<AddFieldsDialog
fields={Object.values(selected)}
onClose={() => setAdding(false)}
onDone={() => setSelected({})}
/>
)}
</div>
);
}
export function FieldDirectory({
active,
revision,
}: {
active: boolean;
revision?: string;
}) {
const [tab, setTab] = useState("worldquant");
return (
<section className="preparation-page">
<Tabs activeKey={tab} onChange={setTab} keepDOM tabPaneMotion={false}>
<TabPane tab="worldquant接口" itemKey="worldquant">
<FieldBrowser
active={active && tab === "worldquant"}
source="worldquant"
revision={revision}
/>
</TabPane>
<TabPane tab="本地同步" itemKey="local">
<FieldBrowser
active={active && tab === "local"}
revision={revision}
/>
</TabPane>
</Tabs>
</section>
);
}
+94
View File
@@ -0,0 +1,94 @@
import { Button, Table } from "@douyinfe/semi-ui-19";
import type { ColumnProps } from "@douyinfe/semi-ui-19/lib/es/table/interface";
import { displayValue, formatTime } from "../api";
import type { FieldRecord } from "./types";
export function FieldTable({
items,
loading = false,
selected,
onSelect,
onOpen,
}: {
items: FieldRecord[];
loading?: boolean;
onOpen?: (field: FieldRecord) => void;
selected?: string[];
onSelect?: (ids: string[]) => void;
}) {
const columns: ColumnProps<FieldRecord>[] = [
{
title: "字段",
dataIndex: "id",
width: 220,
render: (_, r) =>
onOpen ? (
<Button theme="borderless" onClick={() => onOpen(r)}>
{r.id}
</Button>
) : (
r.id
),
},
{
title: "数据集",
width: 200,
render: (_, r) => <span title={r.dataset_name}>{r.dataset_id}</span>,
},
{
title: "字段描述",
dataIndex: "description",
width: 340,
render: displayValue,
},
{
title: "类型",
dataIndex: "field_type",
width: 100,
render: displayValue,
},
{
title: "覆盖率",
width: 100,
render: (_, r) =>
r.coverage == null ? "未提供" : `${(r.coverage * 100).toFixed(1)}%`,
},
{
title: "用户数",
dataIndex: "user_count",
width: 90,
render: displayValue,
},
{
title: "Alpha 数",
dataIndex: "alpha_count",
width: 100,
render: displayValue,
},
{
title: "同步时间",
width: 170,
render: (_, r) => (r.synced_at ? formatTime(r.synced_at) : "在线查询"),
},
];
return (
<Table<FieldRecord>
size="small"
className="preparation-table"
rowKey={(r) => `${r?.dataset_id}|${r?.id}`}
dataSource={items}
columns={columns}
loading={loading}
pagination={false}
scroll={{ x: 1420, y: 400 }}
rowSelection={
onSelect
? {
selectedRowKeys: selected ?? [],
onChange: (keys) => onSelect((keys ?? []).map(String)),
}
: undefined
}
empty="暂无匹配字段"
/>
);
}
@@ -0,0 +1,103 @@
import { useEffect, useState } from "react";
import { Banner, Button } from "@douyinfe/semi-ui-19";
import { api } from "../api";
import type { InputSnapshot } from "../research/workspaceTypes";
import { DataPreparationPicker } from "./DataPreparationPicker";
import { scopeLabel, type Scope } from "./types";
import "./style.css";
export function researchSelection(ids: string[], rows: InputSnapshot[]) {
const selected = rows.filter((r) => ids.includes(r.id));
return {
input_ids: ids.filter(
(id) => !selected.find((r) => r.id === id)?.preparation_ref,
),
preparation_refs: selected.flatMap((r) =>
r.preparation_ref ? [r.preparation_ref] : [],
),
};
}
export function ResearchDataInput({
ids,
inputs,
onChange,
scope,
multiple = true,
label = "选择数据准备",
}: {
ids: string[];
inputs: InputSnapshot[];
onChange: (ids: string[], inputs: InputSnapshot[]) => void;
scope?: Scope;
multiple?: boolean;
label?: string;
}) {
const [open, setOpen] = useState(false),
[error, setError] = useState("");
useEffect(() => {
const missing = ids.filter((id) => !inputs.some((r) => r.id === id));
if (!missing.length) return;
let live = true;
Promise.all(
missing.map((id) =>
api<InputSnapshot>(
`/research/input-snapshots/${encodeURIComponent(id)}`,
),
),
)
.then((rows) => {
if (live) onChange(ids, [...inputs, ...rows]);
})
.catch((e) => {
if (live) setError(e.message);
});
return () => {
live = false;
};
}, [ids.join("|"), inputs]);
return (
<div>
<div className="preparation-tools">
<Button aria-label={label} onClick={() => setOpen(true)}>
{label}
</Button>
{!!ids.length && (
<Button onClick={() => onChange([], inputs)}>清空选择</Button>
)}
</div>
{error && <Banner type="danger" description={error} />}
{inputs
.filter((r) => ids.includes(r.id))
.map((r) => (
<p key={r.id}>
{r.name} · {scopeLabel(r.scope)} · {r.field_ids.length} 字段
{r.preparation_ref
? ` · v${r.preparation_ref.version}`
: " · 研究快照"}
</p>
))}
<DataPreparationPicker
visible={open}
onCancel={() => setOpen(false)}
scope={scope}
multiple={multiple}
onConfirm={async (refs) => {
const rows = await Promise.all(
refs.map((r) =>
api<InputSnapshot>(
`/data-preparations/${r.id}/selection?version=${r.version}`,
),
),
);
onChange(
rows.map((r) => r.id),
[
...inputs.filter((old) => !rows.some((r) => r.id === old.id)),
...rows,
],
);
setOpen(false);
}}
/>
</div>
);
}
+132
View File
@@ -0,0 +1,132 @@
import { useEffect, useState } from "react";
import { Banner, Button } from "@douyinfe/semi-ui-19";
import { api } from "../api";
import { ResearchSelect } from "../research/ResearchSelect";
import { type Scope } from "./types";
type Option = Omit<Scope, "universe"> & { universes: string[] };
export function ScopeControls({
value,
onChange,
local = false,
active = true,
revision: sourceRevision = "",
}: {
value: Scope;
onChange: (s: Scope) => void;
local?: boolean;
active?: boolean;
revision?: string;
}) {
const [rows, setRows] = useState<Option[]>([]),
[error, setError] = useState(""),
[revision, setRevision] = useState(0);
useEffect(() => {
if (!active) return;
const c = new AbortController();
api<{ instrument_options: Option[] }>(
local ? "/catalog/local-scopes" : "/catalog/scopes",
{ signal: c.signal },
)
.then((r) => {
setRows(r.instrument_options);
setError("");
})
.catch((e) => {
if (!c.signal.aborted) setError(e.message);
});
return () => c.abort();
}, [local, revision, sourceRevision, active]);
function update(next: Scope) {
const row =
rows.find(
(r) =>
r.instrument_type === next.instrument_type &&
r.region === next.region &&
r.delay === next.delay,
) ??
rows.find(
(r) =>
r.instrument_type === next.instrument_type &&
r.region === next.region,
) ??
rows.find((r) => r.instrument_type === next.instrument_type) ??
rows[0];
onChange(
row
? {
...next,
instrument_type: row.instrument_type,
region: row.region,
delay: row.delay,
universe: row.universes.includes(next.universe)
? next.universe
: row.universes[0],
}
: next,
);
}
const option = rows.find(
(r) =>
r.instrument_type === value.instrument_type &&
r.region === value.region &&
r.delay === value.delay,
);
return (
<>
<div className="preparation-tools">
{(["instrument_type", "region", "universe", "delay"] as const).map(
(key) => {
const values =
key === "universe"
? (option?.universes ?? [value.universe])
: rows
.filter(
(r) =>
key === "instrument_type" ||
(r.instrument_type === value.instrument_type &&
(key !== "delay" || r.region === value.region)),
)
.map((r) => r[key]);
return (
<ResearchSelect
key={key}
label={
{
instrument_type: "InstrumentType",
region: "Region",
universe: "Universe",
delay: "Delay",
}[key]
}
value={value[key]}
optionList={[...new Set([value[key], ...values])].map((v) => ({
value: v,
label: key === "delay" ? `Delay ${v}` : String(v),
}))}
onChange={(v) =>
update({
...value,
[key]: key === "delay" ? Number(v) : String(v),
})
}
/>
);
},
)}
</div>
{error && (
<Banner
type="warning"
description={
<>
{error}{" "}
<Button onClick={() => setRevision((v) => v + 1)}>
重试范围选项
</Button>
</>
}
/>
)}
</>
);
}
@@ -0,0 +1,47 @@
import { useEffect, useState } from "react";
import { Banner, Modal } from "@douyinfe/semi-ui-19";
import { api } from "../api";
import type { UIAction } from "../ai/types";
import type { InputSnapshot } from "../research/workspaceTypes";
import { FieldTable } from "./FieldTable";
import { scopeLabel } from "./types";
export function SnapshotDialog({ action }: { action: UIAction | null }) {
const [snapshot, setSnapshot] = useState<InputSnapshot | null>(null),
[error, setError] = useState(""),
[open, setOpen] = useState(false);
useEffect(() => {
if (action?.type !== "open_research_input") return;
const c = new AbortController();
setOpen(true);
setSnapshot(null);
setError("");
api<InputSnapshot>(`/research/input-snapshots/${action.input_id}`, {
signal: c.signal,
})
.then(setSnapshot)
.catch((e) => {
if (!c.signal.aborted) setError(e.message);
});
return () => c.abort();
}, [action]);
return (
<Modal
visible={open}
title="研究输入快照"
width="min(1200px,96vw)"
closeOnEsc
footer={null}
onCancel={() => setOpen(false)}
>
{error && <Banner type="danger" description={error} />}
{snapshot && (
<>
<p>
{snapshot.name} · {scopeLabel(snapshot.scope)}
</p>
<FieldTable items={snapshot.fields} />
</>
)}
</Modal>
);
}
+68
View File
@@ -0,0 +1,68 @@
.preparation-page {
display: flex;
flex-direction: column;
gap: 12px;
height: 100%;
min-height: 0;
padding: 16px;
overflow: auto;
background: var(--semi-color-bg-0);
}
.preparation-tools {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
margin: 8px 0;
}
.preparation-tools > .semi-input-wrapper {
width: 240px;
}
.preparation-tools > .semi-select {
min-width: 130px;
}
.preparation-table {
min-height: 0;
width: 100%;
}
.preparation-tools .semi-button {
white-space: nowrap;
}
.preparation-fields {
display: flex;
flex-direction: column;
gap: 8px;
min-height: 0;
}
.preparation-filters {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 10px;
margin: 10px 0;
}
.preparation-filters label {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 12px;
color: var(--semi-color-text-2);
}
.preparation-detail {
padding: 4px;
display: flex;
flex-direction: column;
gap: 12px;
}
.preparation-detail > label {
display: flex;
flex-direction: column;
gap: 6px;
}
@media (max-width: 700px) {
.preparation-page {
padding: 8px;
}
.preparation-tools > .semi-input-wrapper {
width: 100%;
}
}
+62
View File
@@ -0,0 +1,62 @@
export type Scope = {
instrument_type: string;
region: string;
universe: string;
delay: number;
};
export const defaultScope: Scope = {
instrument_type: "EQUITY",
region: "USA",
universe: "TOP3000",
delay: 1,
};
export const scopeKey = (s: Scope) =>
`${s.instrument_type}|${s.region}|${s.universe}|${s.delay}`;
export const scopeLabel = (s: Scope) =>
`${s.region} / ${s.universe} / Delay ${s.delay}`;
export type FieldRecord = {
id: string;
field_id: string;
name: string | null;
description: string | null;
dataset_id: string;
dataset_name: string;
field_type: string | null;
scope: Scope;
source: "local" | "worldquant";
collection_version: string | null;
coverage: number | null;
user_count: number | null;
alpha_count: number | null;
category?: string | null;
subcategory?: string | null;
synced_at: string | null;
fetched_at: string;
};
export type Preparation = {
id: string;
name: string;
note: string;
scope: Scope;
version: number;
field_count: number;
dataset_count: number;
created_at: string;
updated_at: string;
};
export type PreparationRef = { id: string; version: number };
export type Page<T> = {
items: T[];
total: number;
limit: number;
offset: number;
has_more?: boolean;
total_known?: boolean;
};
export const fieldReference = (f: FieldRecord) => ({
field_id: f.id,
dataset_id: f.dataset_id,
scope: f.scope,
source: f.source,
collection_version: f.collection_version,
});
+1 -1
View File
@@ -222,7 +222,7 @@ export function ExperimentView({
})
}
>
{input.dataset_id} · {input.scope.region}/{input.scope.universe}/D
{input.name} · {input.scope.region}/{input.scope.universe}/D
{input.scope.delay}
</Button>
))}
+17 -17
View File
@@ -1,3 +1,7 @@
import {
ResearchDataInput,
researchSelection,
} from "../preparations/ResearchDataInput";
import { ResearchList, ResearchDetail } from "./ResearchList";
import { useEffect, useRef, useState } from "react";
import {
@@ -66,9 +70,7 @@ export function FeaturesPage({
`/research/assets?kind=feature&q=${encodeURIComponent(q)}&offset=${(page - 1) * 25}`,
{ signal: controller.signal },
),
api<{ items: InputSnapshot[] }>("/research/inputs", {
signal: controller.signal,
}),
Promise.resolve({ items: inputs }),
])
.then(([list, fixed]) => {
setItems(list.items);
@@ -128,7 +130,7 @@ export function FeaturesPage({
body: JSON.stringify({
kind: "feature",
version: asset?.version,
content: draft,
content: { ...draft, ...researchSelection(draft.input_ids, inputs) },
}),
},
);
@@ -317,7 +319,7 @@ export function FeaturesPage({
.filter((i) => draft.input_ids.includes(i.id))
.map((i) => ({
id: i.id,
name: `${i.dataset_id} · ${i.scope.region}/${i.scope.universe}/D${i.scope.delay} · ${i.field_ids.length} 字段 · ${i.id}`,
name: `${i.name} · ${i.scope.region}/${i.scope.universe}/D${i.scope.delay} · ${i.field_ids.length} 字段 · ${i.id}`,
}))}
disabled={!!busy}
busy={busy === "generate"}
@@ -330,7 +332,7 @@ export function FeaturesPage({
await post<FeatureAsset>("/research/generate", {
name: draft.name,
hypothesis: draft.hypothesis,
input_ids: draft.input_ids,
...researchSelection(draft.input_ids, inputs),
method: "feature",
}),
);
@@ -347,23 +349,21 @@ export function FeaturesPage({
</section>
<label>
固定输入
<ResearchSelect
label="特征固定输入"
multiple
filter
value={draft.input_ids}
optionList={inputs.map((i) => ({
value: i.id,
label: `${i.dataset_id} · ${i.scope.region}/${i.scope.universe}/D${i.scope.delay} · ${i.field_ids.length} 字段 · ${i.id.slice(0, 8)}`,
}))}
onChange={(v) => setDraft({ ...draft, input_ids: v as string[] })}
<ResearchDataInput
label="选择特征数据准备"
ids={draft.input_ids}
inputs={inputs}
onChange={(ids, rows) => {
setInputs(rows);
setDraft({ ...draft, input_ids: ids });
}}
/>
</label>
{inputs
.filter((i) => draft.input_ids.includes(i.id))
.map((i) => (
<details key={i.id}>
<summary>{i.dataset_id} 的固定字段</summary>
<summary>{i.name} 的固定字段</summary>
<p className="muted">{i.field_ids.join("、")}</p>
</details>
))}
+24 -21
View File
@@ -1,3 +1,7 @@
import {
ResearchDataInput,
researchSelection,
} from "../preparations/ResearchDataInput";
import { useEffect, useState } from "react";
import {
Banner,
@@ -54,7 +58,7 @@ export function FlowLaunchForm({
useEffect(() => {
const c = new AbortController();
Promise.all([
api<{ items: InputSnapshot[] }>("/research/inputs", { signal: c.signal }),
Promise.resolve({ items: inputs }),
api<{ items: Asset[] }>("/research/assets?kind=template&limit=100", {
signal: c.signal,
}),
@@ -73,7 +77,7 @@ export function FlowLaunchForm({
setConfirmation({
request_id: crypto.randomUUID(),
name,
input_ids: ids,
...researchSelection(ids, inputs),
hypothesis,
settings,
budget,
@@ -133,22 +137,17 @@ export function FlowLaunchForm({
</label>
<label>
固定数据范围
<ResearchSelect
label="自动研究固定输入"
multiple
filter
value={ids}
optionList={inputs.map((i) => ({
value: i.id,
label: `${i.dataset_id} · ${i.scope.region}/${i.scope.universe}/D${i.scope.delay} · ${i.id.slice(0, 8)}`,
}))}
onChange={(v) => {
const next = v as string[];
setIds(next);
const first = inputs.find((i) => i.id === next[0]);
<ResearchDataInput
label="选择研究数据准备"
ids={ids}
inputs={inputs}
onChange={(ids, rows) => {
setInputs(rows);
setIds(ids);
const first = rows.find((r) => r.id === ids[0]);
if (first)
setSettings((s) => ({
...s,
setSettings((old) => ({
...old,
region: first.scope.region,
universe: first.scope.universe,
delay: first.scope.delay,
@@ -290,8 +289,10 @@ export function FlowLaunchForm({
</p>
)}
<p>
{confirmation.input_ids.length} 个固定输入 · {settings.region}/
{settings.universe}/D{settings.delay}
{confirmation.input_ids.length +
(confirmation.preparation_refs?.length ?? 0)}{" "}
个固定输入 · {settings.region}/{settings.universe}/D
{settings.delay}
</p>
<ul>
{confirmation.input_ids.map((id) => {
@@ -299,7 +300,7 @@ export function FlowLaunchForm({
return (
<li key={id}>
{input
? `${input.dataset_id} · ${input.scope.region}/${input.scope.universe}/D${input.scope.delay}`
? `${input.name} · ${input.scope.region}/${input.scope.universe}/D${input.scope.delay}`
: id}{" "}
· {id.slice(0, 8)}
</li>
@@ -315,7 +316,9 @@ export function FlowLaunchForm({
{confirmation.template_id && (
<p>
初始模板:
{templates.find((t) => t.id === confirmation.template_id)?.name}{" "}
{
templates.find((t) => t.id === confirmation.template_id)?.name
}{" "}
· v{confirmation.template_version}
</p>
)}
+31 -28
View File
@@ -1,3 +1,7 @@
import {
ResearchDataInput,
researchSelection,
} from "../preparations/ResearchDataInput";
import { ResearchList, ResearchDetail } from "./ResearchList";
import { ResearchSelect } from "./ResearchSelect";
import { useEffect, useRef, useState } from "react";
@@ -101,7 +105,7 @@ export function ResearchWorkspace({
api<{ items: Asset[]; total: number }>(
`/research/assets?kind=template&limit=25&offset=${assetPage * 25}&q=${encodeURIComponent(search)}`,
),
api<{ items: InputSnapshot[] }>("/research/inputs"),
Promise.resolve({ items: inputs }),
api<{ items: typeof history; total: number }>(
`/research/experiments?limit=25&offset=${historyPage * 25}`,
),
@@ -129,7 +133,11 @@ export function ResearchWorkspace({
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,
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(() => {
@@ -180,17 +188,6 @@ export function ResearchWorkspace({
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: {
@@ -236,7 +233,7 @@ export function ResearchWorkspace({
const next = await post<Asset>("/research/generate", {
name: template.name,
hypothesis,
input_ids: inputIds,
...researchSelection(inputIds, inputs),
parent_alpha_ids: parent ? parent.split(/[,,\s]+/).filter(Boolean) : [],
method: page === "variants" ? "structure" : "template",
});
@@ -274,7 +271,7 @@ export function ResearchWorkspace({
const next = await post<Experiment>("/research/experiments", {
asset_id: asset!.id,
version: asset!.version,
input_ids: inputIds,
...researchSelection(inputIds, inputs),
hypothesis,
settings,
mode,
@@ -580,20 +577,26 @@ export function ResearchWorkspace({
)}
<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[])}
<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>
@@ -624,7 +627,7 @@ export function ResearchWorkspace({
references={[
...selectedInputs.map((input) => ({
id: input.id,
name: `${input.dataset_id} · ${input.scope.region}/${input.scope.universe}/D${input.scope.delay} · ${input.field_ids.length} 字段 · ${input.id}`,
name: `${input.name} · ${input.scope.region}/${input.scope.universe}/D${input.scope.delay} · ${input.field_ids.length} 字段 · ${input.id}`,
})),
...(page === "variants" && parent
? [
@@ -857,7 +860,7 @@ export function ResearchWorkspace({
setExperiment(
await post("/research/variants/settings", {
alpha_id: parent,
input_ids: inputIds,
...researchSelection(inputIds, inputs),
...(hypothesis ? { hypothesis } : {}),
}),
);
+12 -3
View File
@@ -62,19 +62,28 @@ export function SourceDetails({
打开研究会话
</Button>
)}
{source.template_input_id && (
{[
...new Set([
...(source.input_snapshot_ids ?? []),
...(source.input_snapshot_id ? [source.input_snapshot_id] : []),
]),
].map((id, index) => (
<Button
key={id}
onClick={() =>
onAction({
type: "open_research_input",
input_id: source.template_input_id!,
input_id: id,
nonce: Date.now(),
})
}
>
查看研究输入
{(source.input_snapshot_ids?.length ?? 0) > 1
? ` ${index + 1}`
: ""}
</Button>
)}
))}
{source.parent_run_id && (
<Button
onClick={() =>
+1
View File
@@ -21,6 +21,7 @@ export type FlowLaunch = {
request_id: string;
name: string;
input_ids: string[];
preparation_refs?: { id: string; version: number }[];
hypothesis: string;
settings: SimulationSettings;
budget: Budget;
+7 -2
View File
@@ -1,3 +1,4 @@
import type { FieldRecord, PreparationRef } from "../preparations/types";
import type { Candidate } from "../backtests/types";
export type Variable = {
kind:
@@ -43,10 +44,14 @@ export type InputSnapshot = {
universe: string;
delay: 0 | 1;
};
dataset_id: string;
name: string;
dataset_ids: string[];
fields: FieldRecord[];
preparation_ref?: PreparationRef;
field_ids: string[];
field_types: Record<string, string>;
collection_version: string;
preparation_id?: string;
preparation_version?: number;
created_at: string;
};
export type ResearchCandidate = Candidate & {
+3
View File
@@ -139,6 +139,9 @@ export type Job = {
};
checkpoint: {
date?: string;
dataset_id?: string;
datasets_completed?: number;
datasets_total?: number;
dates_completed?: number;
dates_total?: number;
alpha_id?: string;
+105 -248
View File
@@ -1,14 +1,12 @@
import { test, expect, type Page } from "@playwright/test";
import { choosePreparation } from "./preparation-helpers";
const headers = { "X-WQ-Request": "1" };
const scope = {
instrument_type: "EQUITY",
region: "USA",
universe: "TOP3000",
delay: 1,
};
const query = new URLSearchParams(
Object.entries(scope).map(([k, v]) => [k, String(v)]),
).toString();
const headers = { "X-WQ-Request": "1" };
async function setup(page: Page) {
await page.goto("/#datasets");
await page.getByLabel("密码", { exact: true }).fill("browser-test-password");
@@ -20,273 +18,132 @@ async function setup(page: Page) {
headers,
data: { email: "test@example.com", password: "synthetic-only" },
});
const connect = await (
const job = await (
await page.request.post("/api/v1/account/connect", { headers })
).json();
await expect
.poll(
async () =>
(
await (
await page.request.get(`/api/v1/sync-jobs/${connect.id}`)
).json()
).status,
(await (await page.request.get(`/api/v1/sync-jobs/${job.id}`)).json())
.status,
)
.toBe("completed");
await page.getByRole("button", { name: "同步目录", exact: true }).click();
await page.reload();
}
async function sync(page: Page, dataset_id: string | null) {
const job = await (
await page.request.post("/api/v1/catalog/sync-jobs", {
headers,
data: { scope, dataset_id },
})
).json();
await expect
.poll(
async () =>
(await (await page.request.get(`/api/v1/sync-jobs/${job.id}`)).json())
.status,
)
.toBe("completed");
}
test("数据集完整同步、使用、集合编辑与研究选择", async ({ page }) => {
const errors: string[] = [];
page.on("pageerror", (e) => errors.push(e.message));
await setup(page);
await sync(page, null);
await page.reload();
const row = page.getByRole("row").filter({ hasText: "TEST 财务报表" });
await expect(
row.getByRole("button", { name: "使用", exact: true }),
).toBeDisabled();
await row.getByRole("button", { name: "同步", exact: true }).click();
await expect
.poll(
async () =>
(
await (
await page.request.get(`/api/v1/catalog/datasets?${query}`)
await page.request.get(
"/api/v1/catalog/datasets/TEST_FIN?region=USA&universe=TOP3000&delay=1",
)
).json()
).total,
).complete_count,
)
.toBe(3);
.toBe(123);
await page.keyboard.press("Escape");
await expect(
page.getByRole("button", { name: "TEST 财务报表", exact: true }),
).toBeVisible();
}
async function openFields(page: Page) {
await page
.getByRole("row")
.filter({ hasText: "TEST 财务报表" })
.getByRole("button", { name: "查看字段", exact: true })
.click();
const sync = page
.getByRole("dialog", { name: "数据字段", exact: true })
.getByRole("button", { name: "同步全部字段", exact: true });
if (await sync.isVisible()) {
await sync.click();
await expect
.poll(
async () =>
(
await (
await page.request.get(
`/api/v1/catalog/datasets/TEST_FIN/fields?${query}`,
)
).json()
).complete_count,
)
.toBe(123);
await page.keyboard.press("Escape");
}
}
test("目录筛选、双层详情、完整输入、排除、备注与刷新", async ({ page }) => {
const errors: string[] = [];
page.on("pageerror", (e) => errors.push(e.message));
await page.setViewportSize({ width: 1440, height: 1000 });
await setup(page);
await page.getByLabel("分类", { exact: true }).click();
await page.getByRole("option", { name: /基本面/ }).click();
await page.getByLabel("子分类", { exact: true }).click();
await page.getByRole("option", { name: /财务报表/ }).click();
await openFields(page);
const fields = page.getByRole("dialog", { name: "数据字段", exact: true });
await expect(fields).toBeVisible();
await expect(fields).toContainText("全部 123 个字段");
expect((await fields.boundingBox())!.width).toBeCloseTo(864, 0);
await page.getByLabel("搜索字段").fill("字段 12");
await expect(fields.getByRole("button", { name: /TEST 字段/ })).toHaveCount(
3,
);
await page
.getByRole("button", { name: "TEST 字段 122", exact: true })
.click();
const detail = page.getByRole("dialog", { name: "字段详情", exact: true });
await expect(detail).toContainText("FUTURE_TYPE");
expect((await detail.boundingBox())!.width).toBeCloseTo(864, 0);
await page.getByLabel("研究备注", { exact: true }).fill("保留字段研究假设");
await page.getByRole("button", { name: "保存备注", exact: true }).click();
await expect(page.getByText("研究备注已保存")).toBeVisible();
await page.keyboard.press("Escape");
await expect(detail).not.toBeVisible();
await expect(page.getByLabel("搜索字段")).toHaveValue("字段 12");
await expect(
page.getByRole("button", { name: "TEST 字段 122", exact: true }),
).toBeFocused();
await page
.getByRole("button", { name: "用于 Alpha 模板", exact: true })
.click();
await page.getByRole("button", { name: "保存输入草稿", exact: true }).click();
await expect(
page.getByRole("heading", { name: "输入草稿已保存" }),
).toBeVisible();
expect(
(
await (await page.request.get(`/api/v1/catalog/inputs?${query}`)).json()
)[0].field_ids,
).toHaveLength(123);
await page.keyboard.press("Escape");
await page
.getByRole("checkbox", { name: "选择TEST 字段 122", exact: true })
.press("Space");
await page.getByRole("button", { name: "覆盖率排序" }).click();
await expect(fields).toContainText("122 / 123 个字段");
await page
.getByRole("button", { name: "用于 Alpha 模板", exact: true })
.click();
await page.getByRole("button", { name: "保存输入草稿", exact: true }).click();
await expect(
page.getByRole("heading", { name: "输入草稿已保存" }),
).toBeVisible();
const subset = (
await (await page.request.get(`/api/v1/catalog/inputs?${query}`)).json()
)[0];
expect(subset.field_ids).toHaveLength(122);
expect(subset.field_ids).not.toContain("TEST_FIN_122");
await page.keyboard.press("Escape");
await page.getByRole("button", { name: "恢复全选" }).click();
await page
.getByRole("checkbox", { name: "选择本数据集全部字段" })
.press("Space");
await expect(
page.getByRole("button", { name: "用于 Alpha 模板", exact: true }),
).toBeDisabled();
await page.getByRole("button", { name: "恢复全选" }).click();
await page
.getByRole("button", { name: "TEST 字段 122", exact: true })
.click();
await expect(page.getByLabel("研究备注", { exact: true })).toHaveValue(
"保留字段研究假设",
);
await page.screenshot({ path: "../output/playwright/dataset-desktop.png" });
await page.keyboard.press("Escape");
await page.keyboard.press("Escape");
await expect(page.getByLabel("分类", { exact: true })).toContainText(
"基本面",
);
await page.reload();
await row.getByRole("button", { name: "查看", exact: true }).click();
const detail = page.getByRole("dialog", {
name: "TEST 财务报表",
exact: true,
});
await expect(detail).toContainText("TEST_FIN_001");
await detail.getByLabel("研究备注").fill("验证备注");
await detail.getByRole("button", { name: "保存备注", exact: true }).click();
await page.keyboard.press("Escape");
await row.getByRole("button", { name: "使用", exact: true }).click();
await page.getByRole("link", { name: "打开数据准备" }).click();
const prep = page.getByRole("dialog", { name: "数据准备详情", exact: true });
await expect(prep).toContainText("123 字段 / 1 数据集");
await prep.getByLabel("集合名称", { exact: true }).fill("浏览器准备集合");
await prep.getByRole("button", { name: "保存名称和备注" }).click();
await expect(prep).toContainText("v2");
await prep
.getByRole("row")
.filter({ hasText: "TEST_FIN_000" })
.locator("span.semi-checkbox")
.click();
await prep.getByRole("button", { name: "移除所选字段" }).click();
await expect(prep).toContainText("122 字段");
await page.keyboard.press("Escape");
await page
.getByRole("navigation", { name: "主导航" })
.getByRole("button", { name: "特征工程", exact: true })
.click();
await page.getByRole("button", { name: "新建特征方案", exact: true }).click();
await choosePreparation(page, "浏览器准备集合", "选择特征数据准备");
await expect(
page.getByRole("button", { name: "已保存输入 (2)" }),
page.locator("p").filter({ hasText: /浏览器准备集合 ·.*122 字段/ }),
).toBeVisible();
expect(errors).toEqual([]);
});
test("窄屏抽屉、键盘隔离和研究范围切换", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
test("在线字段跨页多选直接准备,本地只展示完整同步字段", async ({ page }) => {
await setup(page);
await openFields(page);
const fields = page.getByRole("dialog", { name: "数据字段", exact: true });
await expect(fields).toBeVisible();
expect((await fields.boundingBox())!.width).toBeCloseTo(234, 0);
await page
.getByRole("button", { name: "TEST 字段 000", exact: true })
.getByRole("navigation", { name: "主导航" })
.getByRole("button", { name: "字段目录", exact: true })
.click();
await expect(
page.getByRole("dialog", { name: "字段详情", exact: true }),
).toBeVisible();
await page.keyboard.press("Escape");
await expect(fields).toBeVisible();
expect(
await page.evaluate(
() => document.documentElement.scrollWidth <= innerWidth,
),
).toBe(true);
await page.screenshot({ path: "../output/playwright/dataset-mobile.png" });
await page.keyboard.press("Escape");
await page.getByLabel("Region", { exact: true }).click();
await page.getByRole("option", { name: /CHN/ }).click();
await expect(page.getByLabel("Universe", { exact: true })).toContainText(
"TOP2000",
);
await expect(
page.getByRole("button", { name: "用于 Alpha 模板", exact: true }),
).toBeDisabled();
});
test("非首页排除、聊天暂存详情、遮罩逐层关闭和多尺寸", async ({ page }) => {
const errors: string[] = [];
page.on("pageerror", (e) => errors.push(e.message));
await page.setViewportSize({ width: 1280, height: 900 });
await setup(page);
await openFields(page);
const fields = page.getByRole("dialog", { name: "数据字段", exact: true });
await fields.getByRole("button", { name: "Next", exact: true }).click();
await expect(
page.getByRole("button", { name: "TEST 字段 025", exact: true }),
).toBeVisible();
await page
.getByRole("checkbox", { name: "选择TEST 字段 025", exact: true })
.press("Space");
await page.getByLabel("搜索字段").fill("字段 12");
await expect(fields).toContainText("122 / 123 个字段");
await page.getByLabel("字段类型", { exact: true }).click();
await page.getByRole("option", { name: /VECTOR/ }).click();
await expect(
page.getByRole("button", { name: "TEST 字段 120", exact: true }),
).toBeVisible();
await page
.getByRole("button", { name: "TEST 字段 120", exact: true })
const online = page.getByRole("tabpanel", { name: "worldquant接口" });
await expect(online.getByText("TEST_FIN_001", { exact: true })).toBeVisible();
await online
.getByRole("row")
.filter({ hasText: "TEST_FIN_001" })
.locator("span.semi-checkbox")
.click();
await page.getByLabel("研究备注", { exact: true }).fill("未保存草稿需要恢复");
await page
.getByRole("dialog", { name: "字段详情", exact: true })
.getByRole("button", { name: "AI 助手", exact: true })
await online.locator(".semi-page-item").filter({ hasText: /^2$/ }).click();
await online
.getByRole("row")
.filter({ hasText: "TEST_FIN_025" })
.locator("span.semi-checkbox")
.click();
await expect(fields).not.toBeVisible();
await page.keyboard.press("Escape");
await expect(page.getByLabel("研究备注", { exact: true })).toHaveValue(
"未保存草稿需要恢复",
);
for (const width of [850, 390, 1920]) {
await page.setViewportSize({ width, height: 900 });
await expect(
page.getByRole("button", { name: "关闭详情", exact: true }),
).toBeVisible();
expect(
await page.evaluate(
() => document.documentElement.scrollWidth <= innerWidth,
),
).toBe(true);
}
await page.setViewportSize({ width: 1440, height: 900 });
await page.mouse.click(10, 400);
await expect(
page.getByRole("dialog", { name: "字段详情", exact: true }),
).not.toBeVisible();
await expect(fields).toBeVisible();
await expect(fields).toContainText("122 / 123 个字段");
await page.mouse.click(10, 400);
await expect(fields).not.toBeVisible();
expect(errors).toEqual([]);
});
test("平台选项失败可重试,地区联动限制 Delay 和 Universe", async ({ page }) => {
await setup(page);
await page.getByLabel("Delay", { exact: true }).click();
await page.getByRole("option", { name: /Delay 0/ }).click();
await page.getByLabel("Region", { exact: true }).click();
await page.getByRole("option", { name: /IND/ }).click();
await expect(page.getByLabel("Universe", { exact: true })).toContainText(
"TOP500",
);
await expect(page.getByLabel("Delay", { exact: true })).toContainText(
"Delay 1",
);
await page.getByLabel("Delay", { exact: true }).click();
await expect(page.getByRole("option", { name: /Delay 0/ })).toHaveCount(0);
await page.keyboard.press("Escape");
await page.route("**/api/v1/catalog/scopes", (route) =>
route.fulfill({
status: 502,
contentType: "application/json",
body: JSON.stringify({ detail: "测试平台选项不可用" }),
}),
);
await page.getByRole("button", { name: "Alpha 管理", exact: true }).click();
await page.getByRole("button", { name: "数据目录", exact: true }).click();
await expect(page.getByText("测试平台选项不可用")).toBeVisible();
await expect(
page.getByRole("button", { name: "同步目录", exact: true }),
).toBeDisabled();
await page.unroute("**/api/v1/catalog/scopes");
await page.getByRole("button", { name: "重试获取选项" }).click();
await expect(
page.getByRole("button", { name: "同步目录", exact: true }),
).toBeEnabled();
await expect(online.getByText(/已选 2 个字段/)).toBeVisible();
await online
.getByRole("button", { name: "加入数据准备", exact: true })
.click();
const add = page.getByRole("dialog", { name: "加入数据准备", exact: true });
await add.getByLabel("新集合名称").fill("在线选取集合");
await add.getByRole("button", { name: "新建集合并加入" }).click();
await expect(add).not.toBeVisible();
const collections = await (
await page.request.get("/api/v1/data-preparations?q=在线选取集合")
).json();
expect(collections.items[0].field_count).toBe(2);
await page.getByRole("tab", { name: "本地同步", exact: true }).click();
const local = page.getByRole("tabpanel", { name: "本地同步" });
// Other browser tests share this isolated server, so verify the unsynced dataset explicitly.
await local.getByLabel("字段数据集").fill("TEST_NEWS");
await local.getByRole("button", { name: "查询", exact: true }).click();
await expect(local.getByText("共 0 个字段", { exact: true })).toBeVisible();
await page.screenshot({
path: "test-results/preparations-fields.png",
fullPage: true,
});
});
+21
View File
@@ -0,0 +1,21 @@
import { expect, type Page } from "@playwright/test";
export async function choosePreparation(
page: Page,
name: string,
label = "选择研究数据准备",
) {
await page.getByRole("button", { name: label, exact: true }).click();
const picker = page.getByRole("dialog", {
name: "选择数据准备集合",
exact: true,
});
await picker.getByLabel("搜索数据准备集合").fill(name);
await picker
.getByRole("row")
.filter({ hasText: name })
.first()
.locator("span.semi-checkbox")
.click();
await picker.getByRole("button", { name: "选择", exact: true }).click();
await expect(picker).not.toBeVisible();
}
+3 -7
View File
@@ -1,3 +1,4 @@
import { choosePreparation } from "./preparation-helpers";
import { expect, test } from "@playwright/test";
const headers = { "X-WQ-Request": "1" };
const scope = {
@@ -71,13 +72,12 @@ test("native QuantFlow canvas saves versions, branches and restores artifacts",
await page.request.get(`/api/v1/catalog/datasets/TEST_FIN/fields?${params}`)
).json();
const fixed = await (
await page.request.post("/api/v1/catalog/inputs", {
await page.request.post("/api/v1/data-preparations/from-dataset", {
headers,
data: {
scope,
dataset_id: "TEST_FIN",
collection_version: fields.collection_version,
selection: "all",
},
})
).json();
@@ -175,11 +175,7 @@ test("native QuantFlow canvas saves versions, branches and restores artifacts",
.getByRole("button", { name: "启动已保存流程", exact: true })
.click();
await page.getByLabel("自动研究假设").fill("画布条件分支与固定版本");
await page.getByRole("combobox", { name: "自动研究固定输入" }).click();
await page
.getByRole("option")
.filter({ hasText: fixed.id.slice(0, 8) })
.click();
await choosePreparation(page, fixed.name, "选择研究数据准备");
await page.getByLabel("自动研究假设").click();
for (const [label, value] of [
["最大轮数", "1"],
+3 -7
View File
@@ -1,3 +1,4 @@
import { choosePreparation } from "./preparation-helpers";
import { expect, test } from "@playwright/test";
const headers = { "X-WQ-Request": "1" };
const scope = {
@@ -71,13 +72,12 @@ test("finite pipeline confirms scope and budget, executes native steps and resto
await page.request.get(`/api/v1/catalog/datasets/TEST_FIN/fields?${params}`)
).json();
const fixed = await (
await page.request.post("/api/v1/catalog/inputs", {
await page.request.post("/api/v1/data-preparations/from-dataset", {
headers,
data: {
scope,
dataset_id: "TEST_FIN",
collection_version: fields.collection_version,
selection: "all",
},
})
).json();
@@ -97,11 +97,7 @@ test("finite pipeline confirms scope and budget, executes native steps and resto
await page.getByRole("button", { name: "新建自动研究", exact: true }).click();
await page.getByLabel("自动研究名称").fill("浏览器有限研究");
await page.getByLabel("自动研究假设").fill("研究排名稳定性");
await page.getByRole("combobox", { name: "自动研究固定输入" }).click();
await page
.getByRole("option")
.filter({ hasText: fixed.id.slice(0, 8) })
.click();
await choosePreparation(page, fixed.name, "选择研究数据准备");
await page.getByLabel("自动研究名称").click();
for (const [label, value] of [
["最大轮数", "1"],
+23 -20
View File
@@ -68,17 +68,27 @@ test("fixed input, AI approval, backtest and research source remain connected",
const fields = await (
await page.request.get(`/api/v1/catalog/datasets/TEST_FIN/fields?${params}`)
).json();
const saved = await page.request.post("/api/v1/catalog/inputs", {
headers,
data: {
scope,
dataset_id: "TEST_FIN",
collection_version: fields.collection_version,
selection: "all",
const saved = await page.request.post(
"/api/v1/data-preparations/from-dataset",
{
headers,
data: {
scope,
dataset_id: "TEST_FIN",
collection_version: fields.collection_version,
},
},
});
);
expect(saved.status()).toBe(201);
const input = await saved.json();
const preparation = await saved.json();
const input = (
await (
await page.request.post("/api/v1/data-preparations/freeze", {
headers,
data: { items: [{ id: preparation.id, version: preparation.version }] },
})
).json()
).items[0];
const conversation = await (
await page.request.post("/api/v1/ai/conversations", { headers })
).json();
@@ -96,7 +106,7 @@ test("fixed input, AI approval, backtest and research source remain connected",
page: "datasets",
catalog_scope: scope,
dataset_id: "TEST_FIN",
template_input_id: input.id,
input_snapshot_id: input.id,
},
},
},
@@ -130,17 +140,10 @@ test("fixed input, AI approval, backtest and research source remain connected",
.click();
await expect(chat).not.toBeVisible();
const inputSheet = page.getByRole("dialog", {
name: "模板输入草稿",
name: "研究输入快照",
exact: true,
});
await expect(
inputSheet.getByRole("heading", { name: "输入草稿已保存", exact: true }),
).toBeVisible();
await inputSheet
.getByRole("button", { name: "用此输入研究", exact: true })
.click();
await expect(
chat.locator(".research-ai-reference").filter({ hasText: "固定输入" }),
).toContainText(input.id);
await expect(inputSheet).toContainText("TEST_FIN_001");
await expect(inputSheet).toContainText("TEST_FIN");
expect(errors).toEqual([]);
});
+9 -10
View File
@@ -1,3 +1,4 @@
import { choosePreparation } from "./preparation-helpers";
import { expect, test } from "@playwright/test";
const headers = { "X-WQ-Request": "1" };
const scope = {
@@ -50,13 +51,12 @@ test("feature conversion, saved views and immutable evaluations", async ({
await page.request.get(`/api/v1/catalog/datasets/TEST_FIN/fields?${params}`)
).json();
const fixed = await (
await page.request.post("/api/v1/catalog/inputs", {
await page.request.post("/api/v1/data-preparations/from-dataset", {
headers,
data: {
scope,
dataset_id: "TEST_FIN",
collection_version: fields.collection_version,
selection: "all",
},
})
).json();
@@ -67,11 +67,7 @@ test("feature conversion, saved views and immutable evaluations", async ({
await page
.getByLabel("特征经济假设", { exact: true })
.fill("排名可降低异常值影响");
await page.getByRole("combobox", { name: "特征固定输入" }).click();
await page
.getByRole("option")
.filter({ hasText: fixed.id.slice(0, 8) })
.click();
await choosePreparation(page, fixed.name, "选择特征数据准备");
await page.getByLabel("特征方案名称", { exact: true }).click();
await page.getByRole("button", { name: "添加处理步骤" }).click();
await page.getByLabel("步骤 1 名称").fill("横截面排名");
@@ -95,9 +91,12 @@ test("feature conversion, saved views and immutable evaluations", async ({
"/api/v1/research/assets?kind=template&q=特征转换模板",
)
).json();
expect(templates.items[0].provenance.feature.content.input_ids).toEqual([
fixed.id,
]);
const snapshot = await (
await page.request.get(
`/api/v1/research/input-snapshots/${templates.items[0].provenance.feature.content.input_ids[0]}`,
)
).json();
expect(snapshot.preparation_id).toBe(fixed.id);
await page.keyboard.press("Escape");
await page.getByRole("button", { name: "Alpha 管理", exact: true }).click();
await page.getByRole("button", { name: "保存为新视图" }).click();
+30 -15
View File
@@ -1,9 +1,17 @@
import { choosePreparation } from "./preparation-helpers";
import { expect, test } from "@playwright/test";
// UI contract fixtures: no external model call or simulation is started by these tests.
const fixed = {
id: "semi-fixed-input",
dataset_id: "TEST_FIN",
name: "Semi 准备集合",
note: "",
version: 1,
field_count: 1,
dataset_count: 1,
dataset_ids: ["TEST_FIN"],
fields: [],
preparation_ref: { id: "semi-fixed-input", version: 1 },
field_ids: ["TEST_FIN_001"],
field_types: { TEST_FIN_001: "MATRIX" },
collection_version: "fixed-v1",
@@ -20,8 +28,19 @@ for (const mode of ["templates", "variants", "features"] as const) {
}) => {
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
await page.route("**/api/v1/research/inputs", (route) =>
route.fulfill({ json: { items: [fixed] } }),
await page.route("**/api/v1/data-preparations?*", (route) =>
route.fulfill({
json: { items: [fixed], total: 1, limit: 25, offset: 0 },
}),
);
await page.route(
"**/api/v1/data-preparations/semi-fixed-input/selection?*",
(route) => route.fulfill({ json: fixed }),
);
await page.route(
"**/api/v1/research/input-snapshots/semi-fixed-input",
(route) =>
route.fulfill({ json: { ...fixed, preparation_ref: undefined } }),
);
await page.route(
"**/api/v1/research/assets/semi-generated/versions",
@@ -49,23 +68,18 @@ for (const mode of ["templates", "variants", "features"] as const) {
exact: true,
});
await expect(generate).toBeDisabled();
await sheet
.getByRole("combobox", {
name: feature ? "特征固定输入" : "固定研究输入",
exact: true,
})
.click();
await page
.getByRole("option")
.filter({ hasText: fixed.id.slice(0, 8) })
.click();
await choosePreparation(
page,
fixed.name,
feature ? "选择特征数据准备" : "选择数据准备",
);
await input.click();
if (mode === "variants")
await sheet.getByLabel("种子 Alpha", { exact: true }).fill("TEST0001");
const hypothesis = "检验 <收益> 与规模\n第二轮保留原始假设";
await input.fill(hypothesis);
await expect(
sheet.locator(".research-ai-reference").filter({ hasText: fixed.id }),
sheet.locator("p").filter({ hasText: /Semi 准备集合 ·/ }),
).toBeVisible();
let release!: () => void;
const pending = new Promise<void>((resolve) => {
@@ -117,7 +131,8 @@ for (const mode of ["templates", "variants", "features"] as const) {
.poll(() => requestBody)
.toMatchObject({
hypothesis,
input_ids: [fixed.id],
input_ids: [],
preparation_refs: [fixed.preparation_ref],
method: feature
? "feature"
: mode === "variants"