refactor: unify data preparations and research input snapshots
Deploy production / deploy (push) Successful in 53s
Deploy production / deploy (push) Successful in 53s
This commit is contained in:
@@ -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 },
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
来源
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
+416
-1362
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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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 } : {}),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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={() =>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 & {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user