feat: integrate chatbox research with datasets and backtests
This commit is contained in:
+33
-16
@@ -52,6 +52,9 @@ export default function App() {
|
||||
const [backtestContext, setBacktestContext] = useState<PageContext>({
|
||||
page: "backtests",
|
||||
});
|
||||
const [datasetContext, setDatasetContext] = useState<PageContext>({
|
||||
page: "datasets",
|
||||
});
|
||||
const [aiAction, setAIAction] = useState<UIAction | null>(null);
|
||||
const chatOffset = viewport >= 1440 && chatOpen ? chatWidth : 0;
|
||||
const focusBusiness = useCallback(() => {
|
||||
@@ -150,6 +153,25 @@ export default function App() {
|
||||
location.hash = next;
|
||||
setPage(next);
|
||||
};
|
||||
const handleAction = (action: UIAction) => {
|
||||
if (action.type === "open_conversation") {
|
||||
setChatOpen(true);
|
||||
} else {
|
||||
focusBusiness();
|
||||
if (action.type === "open_research_input") {
|
||||
setChatOpen(false);
|
||||
changePage("datasets");
|
||||
} else {
|
||||
changePage(
|
||||
action.type === "open_backtest" ||
|
||||
action.type === "open_backtest_preview"
|
||||
? "backtests"
|
||||
: "alphas",
|
||||
);
|
||||
}
|
||||
}
|
||||
setAIAction(action);
|
||||
};
|
||||
const logout = async () => {
|
||||
try {
|
||||
await post("/auth/logout");
|
||||
@@ -160,6 +182,8 @@ export default function App() {
|
||||
setJobs([]);
|
||||
setAIAction(null);
|
||||
setAlphaContext({ page: "alphas" });
|
||||
setDatasetContext({ page: "datasets" });
|
||||
setBacktestContext({ page: "backtests" });
|
||||
} catch (e) {
|
||||
Toast.error((e as Error).message);
|
||||
}
|
||||
@@ -301,6 +325,8 @@ export default function App() {
|
||||
</div>
|
||||
<div className="alpha-page-view" hidden={page !== "datasets"}>
|
||||
<DatasetPage
|
||||
onContext={setDatasetContext}
|
||||
action={aiAction}
|
||||
account={account}
|
||||
jobs={jobs}
|
||||
active={page === "datasets"}
|
||||
@@ -313,6 +339,7 @@ export default function App() {
|
||||
</div>
|
||||
<div className="alpha-page-view" hidden={page !== "alphas"}>
|
||||
<AlphaPage
|
||||
onAction={handleAction}
|
||||
taskPanelOpen={showJobs}
|
||||
account={account}
|
||||
version={`${refreshKey}:${completedVersion}`}
|
||||
@@ -342,11 +369,7 @@ export default function App() {
|
||||
timezone={account?.timezone}
|
||||
action={aiAction}
|
||||
onContext={setBacktestContext}
|
||||
onAction={(action) => {
|
||||
focusBusiness();
|
||||
changePage("alphas");
|
||||
setAIAction(action);
|
||||
}}
|
||||
onAction={handleAction}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -384,6 +407,7 @@ export default function App() {
|
||||
)}
|
||||
<div inert={catalogModal && !chatOpen}>
|
||||
<ChatPanel
|
||||
action={aiAction}
|
||||
jobs={jobs}
|
||||
open={chatOpen}
|
||||
width={chatWidth}
|
||||
@@ -394,7 +418,9 @@ export default function App() {
|
||||
? alphaContext
|
||||
: page === "backtests"
|
||||
? backtestContext
|
||||
: { page: page === "datasets" ? "datasets" : "account" }
|
||||
: page === "datasets"
|
||||
? datasetContext
|
||||
: { page: "account" }
|
||||
}
|
||||
timezone={account?.timezone}
|
||||
onSettings={() => {
|
||||
@@ -407,16 +433,7 @@ export default function App() {
|
||||
);
|
||||
}}
|
||||
onChanged={actionDone}
|
||||
onAction={(action) => {
|
||||
focusBusiness();
|
||||
changePage(
|
||||
action.type === "open_backtest" ||
|
||||
action.type === "open_backtest_preview"
|
||||
? "backtests"
|
||||
: "alphas",
|
||||
);
|
||||
setAIAction(action);
|
||||
}}
|
||||
onAction={handleAction}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
stateLabels,
|
||||
} from "../api";
|
||||
import { BacktestToolCard } from "../backtests/BacktestToolCard";
|
||||
import { CatalogToolCard } from "../research/CatalogToolCard";
|
||||
import { PnlChart } from "../components/PnlChart";
|
||||
import type { Alpha, Job, Pnl, Research } from "../types";
|
||||
import { chatTransport } from "./transport";
|
||||
@@ -45,6 +46,7 @@ export function ChatPanel({
|
||||
onWidth,
|
||||
timezone,
|
||||
jobs,
|
||||
action,
|
||||
}: {
|
||||
open: boolean;
|
||||
context: PageContext;
|
||||
@@ -56,6 +58,7 @@ export function ChatPanel({
|
||||
onWidth: (width: number) => void;
|
||||
timezone?: string;
|
||||
jobs: Job[];
|
||||
action: UIAction | null;
|
||||
}) {
|
||||
const [settings, setSettings] = useState<ModelSettings | null>(null);
|
||||
const [conversations, setConversations] = useState<Conversation[]>([]);
|
||||
@@ -179,6 +182,32 @@ export function ChatPanel({
|
||||
setLoading(true);
|
||||
void refreshConversation().finally(() => setLoading(false));
|
||||
}, [conversationId, refreshConversation]);
|
||||
useEffect(() => {
|
||||
if (action?.type !== "open_conversation") return;
|
||||
let live = true;
|
||||
void api<ConversationDetail>(
|
||||
`/ai/conversations/${encodeURIComponent(action.conversation_id)}`,
|
||||
)
|
||||
.then(async (detail) => {
|
||||
if (!live) return;
|
||||
await chat.stop();
|
||||
if (!live) return;
|
||||
setConversations((items) =>
|
||||
items.some((item) => item.id === detail.id)
|
||||
? items
|
||||
: [{ id: detail.id, title: detail.title }, ...items],
|
||||
);
|
||||
setConversationId(detail.id);
|
||||
setText("");
|
||||
setFailure("");
|
||||
})
|
||||
.catch((e) => {
|
||||
if (live) setFailure(e.message);
|
||||
});
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [action]);
|
||||
const streaming = chat.status === "streaming" || chat.status === "submitted";
|
||||
const activeRun = runs.find((run) =>
|
||||
["running", "waiting_approval"].includes(run.status),
|
||||
@@ -375,7 +404,10 @@ export function ChatPanel({
|
||||
{!chat.messages.length && (
|
||||
<div className="ai-empty">
|
||||
<h3>从当前研究出发</h3>
|
||||
<p>可以让我筛选 Alpha、解释已有指标,或提出研究记录修改。</p>
|
||||
<p>
|
||||
可以让我选择数据字段、构建候选并预览回测,也可以查询 Alpha
|
||||
和已有结果。
|
||||
</p>
|
||||
{!conversationId && (
|
||||
<Button
|
||||
type="tertiary"
|
||||
@@ -440,7 +472,7 @@ export function ChatPanel({
|
||||
{context.page === "backtests"
|
||||
? "上下文:回测研究"
|
||||
: context.page === "datasets"
|
||||
? "上下文:数据目录(未发送字段与备注)"
|
||||
? `上下文:${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.page === "account"
|
||||
? "上下文:个人信息页"
|
||||
: `上下文:${context.alpha_id ? `Alpha ${context.alpha_id}` : "Alpha 列表"}${context.selected_ids?.length ? ` · 已选 ${context.selected_ids.length} 条` : ""}`}
|
||||
@@ -547,6 +579,7 @@ function BusinessCard({
|
||||
{call.name.includes("backtest") && (
|
||||
<BacktestToolCard call={call} onAction={onAction} />
|
||||
)}
|
||||
<CatalogToolCard call={call} onAction={onAction} />
|
||||
{call.preview.targets?.map((target) => (
|
||||
<details
|
||||
key={target.alpha_id}
|
||||
|
||||
@@ -12,6 +12,17 @@ export type ModelSettings = {
|
||||
};
|
||||
export type PageContext = {
|
||||
page: "alphas" | "account" | "datasets" | "backtests";
|
||||
catalog_scope?: {
|
||||
instrument_type: string;
|
||||
region: string;
|
||||
universe: string;
|
||||
delay: number;
|
||||
};
|
||||
dataset_id?: string;
|
||||
field_id?: string;
|
||||
collection_version?: string;
|
||||
template_input_id?: string;
|
||||
unsaved_field_selection?: boolean;
|
||||
backtest_run_id?: string;
|
||||
backtest_preview_id?: string;
|
||||
backtest_draft_id?: string;
|
||||
@@ -23,6 +34,8 @@ export type AlphaUIAction =
|
||||
| { type: "open_alpha"; alpha_id: string; nonce: number }
|
||||
| { type: "apply_filters"; filters: Record<string, unknown>; nonce: number };
|
||||
export type UIAction =
|
||||
| { type: "open_conversation"; conversation_id: string; nonce: number }
|
||||
| { type: "open_research_input"; input_id: string; nonce: number }
|
||||
| { type: "open_backtest"; run_id: string; nonce: number }
|
||||
| { type: "open_backtest_preview"; preview_id: string; nonce: number }
|
||||
| { type: "open_alpha"; alpha_id: string; nonce: number }
|
||||
@@ -75,6 +88,14 @@ export const runLabels: Record<string, string> = {
|
||||
interrupted: "执行中断",
|
||||
};
|
||||
export const toolLabels: Record<string, string> = {
|
||||
get_catalog_scopes: "读取研究范围",
|
||||
search_catalog: "查询数据集与字段",
|
||||
get_catalog_detail: "读取数据详情",
|
||||
prepare_research_input: "固定研究输入",
|
||||
get_research_input: "读取固定研究输入",
|
||||
prepare_research_backtest: "构建研究候选与预览",
|
||||
get_backtest_draft: "读取候选草稿",
|
||||
get_alpha_sources: "查询 Alpha 研究来源",
|
||||
get_backtest_capabilities: "读取回测能力",
|
||||
prepare_backtest: "准备回测预览",
|
||||
get_backtest_preview: "查看回测预览",
|
||||
|
||||
@@ -28,6 +28,7 @@ import type {
|
||||
Source,
|
||||
} from "./types";
|
||||
import "./style.css";
|
||||
import { SourceDetails, sourceLabel } from "../research/SourceDetails";
|
||||
|
||||
export function BacktestPage({
|
||||
active,
|
||||
@@ -47,6 +48,8 @@ export function BacktestPage({
|
||||
onContext: (context: PageContext) => void;
|
||||
}) {
|
||||
const [view, setView] = useState("runs");
|
||||
const [sourceFilter, setSourceFilter] = useState("");
|
||||
const [sourceKinds, setSourceKinds] = useState<string[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [runs, setRuns] = useState<Page<Run>>({
|
||||
items: [],
|
||||
@@ -97,15 +100,19 @@ export function BacktestPage({
|
||||
const [itemDetail, setItemDetail] = useState<Item | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
const [r, d, c] = await Promise.all([
|
||||
api<Page<Run>>(`/backtests/runs?offset=${(page - 1) * 25}`),
|
||||
const [r, d, c, kinds] = await Promise.all([
|
||||
api<Page<Run>>(
|
||||
`/backtests/runs?offset=${(page - 1) * 25}&source=${encodeURIComponent(sourceFilter)}`,
|
||||
),
|
||||
api<Page<DraftSummary>>(`/backtests/drafts?offset=${(page - 1) * 25}`),
|
||||
api<Scheduler>("/backtests/config"),
|
||||
api<string[]>("/backtests/sources"),
|
||||
]);
|
||||
setRuns(r);
|
||||
setDrafts(d);
|
||||
setConfig(c);
|
||||
}, [page]);
|
||||
setSourceKinds(kinds);
|
||||
}, [page, sourceFilter]);
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
let alive = true;
|
||||
@@ -293,6 +300,22 @@ export function BacktestPage({
|
||||
]}
|
||||
/>
|
||||
<span className="muted">REGULAR · FASTEXPR</span>
|
||||
{view === "runs" && (
|
||||
<Select
|
||||
aria-label="回测研究来源筛选"
|
||||
value={sourceFilter || undefined}
|
||||
placeholder="全部研究来源"
|
||||
showClear
|
||||
optionList={sourceKinds.map((kind) => ({
|
||||
value: kind,
|
||||
label: sourceLabel(kind),
|
||||
}))}
|
||||
onChange={(value) => {
|
||||
setSourceFilter(String(value ?? ""));
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className="backtest-spacer" />
|
||||
<Button
|
||||
type="tertiary"
|
||||
@@ -337,7 +360,11 @@ export function BacktestPage({
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
{ title: "来源", width: 120, render: (_, r) => r!.source.kind },
|
||||
{
|
||||
title: "来源",
|
||||
width: 120,
|
||||
render: (_, r) => sourceLabel(r!.source.kind),
|
||||
},
|
||||
{
|
||||
title: "状态",
|
||||
width: 160,
|
||||
@@ -438,8 +465,9 @@ export function BacktestPage({
|
||||
</div>
|
||||
<p>
|
||||
{preview.name} · {preview.total} 条候选 · {preview.batch_count}{" "}
|
||||
个平台批次 · 来源 {preview.source.kind}
|
||||
个平台批次 · 来源 {sourceLabel(preview.source.kind)}
|
||||
</p>
|
||||
<SourceDetails source={preview.source} onAction={onAction} />
|
||||
{preview.duplicate_count > 0 && (
|
||||
<Banner
|
||||
type="warning"
|
||||
@@ -763,10 +791,11 @@ export function BacktestPage({
|
||||
/>
|
||||
)}
|
||||
<p className="muted">
|
||||
来源 {run.source.kind}{" "}
|
||||
来源 {sourceLabel(run.source.kind)}{" "}
|
||||
{run.source.batch_id ? `· 批次 ${run.source.batch_id}` : ""} ·{" "}
|
||||
{formatTime(run.created_at, timezone)}
|
||||
</p>
|
||||
<SourceDetails source={run.source} onAction={onAction} />
|
||||
{run.scheduler.blocked_reason && (
|
||||
<Banner
|
||||
type="warning"
|
||||
|
||||
@@ -4,6 +4,8 @@ import type { ToolCard, UIAction } from "../ai/types";
|
||||
import { api } from "../api";
|
||||
import { controlLabels, labels } from "./types";
|
||||
import type { Run } from "./types";
|
||||
import type { Source } from "./types";
|
||||
import { SourceDetails } from "../research/SourceDetails";
|
||||
|
||||
export function BacktestToolCard({
|
||||
call,
|
||||
@@ -54,6 +56,12 @@ export function BacktestToolCard({
|
||||
{String(preview.batch_count)} 个批次
|
||||
</p>
|
||||
<p>重复提示 {String(preview.duplicate_count)} 条,确认后独立执行。</p>
|
||||
{preview.source && (
|
||||
<SourceDetails
|
||||
source={preview.source as Source}
|
||||
onAction={onAction}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
onClick={() =>
|
||||
onAction({
|
||||
|
||||
@@ -41,6 +41,7 @@ export type Source = {
|
||||
template_input_id?: string | null;
|
||||
research_id?: string | null;
|
||||
parent_run_id?: string | null;
|
||||
hypothesis?: string | null;
|
||||
};
|
||||
export type Draft = {
|
||||
id: string;
|
||||
|
||||
@@ -32,6 +32,8 @@ import type {
|
||||
} from "../types";
|
||||
import { PnlChart } from "./PnlChart";
|
||||
import { SelfCorrelationPanel } from "./SelfCorrelationPanel";
|
||||
import { AlphaSources } from "../research/AlphaSources";
|
||||
import type { UIAction } from "../ai/types";
|
||||
|
||||
export function AlphaDetail({
|
||||
id,
|
||||
@@ -41,6 +43,7 @@ export function AlphaDetail({
|
||||
onClose,
|
||||
onSaved,
|
||||
onTask,
|
||||
onAction,
|
||||
suspended = false,
|
||||
chatOffset = 0,
|
||||
}: {
|
||||
@@ -51,6 +54,7 @@ export function AlphaDetail({
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
onTask: () => void;
|
||||
onAction: (action: UIAction) => void;
|
||||
suspended?: boolean;
|
||||
chatOffset?: number;
|
||||
}) {
|
||||
@@ -316,6 +320,17 @@ export function AlphaDetail({
|
||||
/>
|
||||
)}
|
||||
</TabPane>
|
||||
<TabPane tab="研究来源" itemKey="sources">
|
||||
{tab === "sources" && id && (
|
||||
<AlphaSources
|
||||
key={id}
|
||||
id={id}
|
||||
version={version}
|
||||
timezone={timezone}
|
||||
onAction={onAction}
|
||||
/>
|
||||
)}
|
||||
</TabPane>
|
||||
<TabPane tab="研究记录" itemKey="research">
|
||||
<div className="detail-section">
|
||||
<p className="muted">
|
||||
|
||||
@@ -38,6 +38,8 @@ import type {
|
||||
import { AlphaDetail } from "../components/AlphaDetail";
|
||||
import { AlphaSyncDialog } from "../components/AlphaSyncDialog";
|
||||
import type { PageContext, AlphaUIAction as UIAction } from "../ai/types";
|
||||
import type { UIAction as WorkspaceAction } from "../ai/types";
|
||||
import { sourceLabel } from "../research/SourceDetails";
|
||||
|
||||
const metricLabels = {
|
||||
sharpe: "Sharpe",
|
||||
@@ -57,8 +59,10 @@ const initialColumns = [
|
||||
"turnover",
|
||||
"correlation",
|
||||
"research",
|
||||
"source",
|
||||
];
|
||||
const columnLabels: Record<string, string> = {
|
||||
source: "研究来源",
|
||||
name: "Alpha",
|
||||
expression: "表达式",
|
||||
region: "地区 / Universe",
|
||||
@@ -89,6 +93,7 @@ export function AlphaPage({
|
||||
onContext,
|
||||
action,
|
||||
onOverlay,
|
||||
onAction,
|
||||
}: {
|
||||
account: Account | null;
|
||||
taskPanelOpen: boolean;
|
||||
@@ -101,6 +106,7 @@ export function AlphaPage({
|
||||
onContext: (context: PageContext) => void;
|
||||
action: UIAction | null;
|
||||
onOverlay: () => void;
|
||||
onAction: (action: WorkspaceAction) => void;
|
||||
}) {
|
||||
const [data, setData] = useState<Page>({
|
||||
items: [],
|
||||
@@ -225,6 +231,7 @@ export function AlphaPage({
|
||||
}, [action, onOverlay]);
|
||||
const refresh = useCallback(() => setLocalVersion((n) => n + 1), []);
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
const controller = new AbortController();
|
||||
setLoading(true);
|
||||
setError("");
|
||||
@@ -245,7 +252,7 @@ export function AlphaPage({
|
||||
if (!controller.signal.aborted) setLoading(false);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [query, version, localVersion]);
|
||||
}, [active, query, version, localVersion]);
|
||||
useEffect(() => {
|
||||
setSelected([]);
|
||||
tablePanel.current?.querySelector(".semi-table-body")?.scrollTo({ top: 0 });
|
||||
@@ -348,6 +355,13 @@ export function AlphaPage({
|
||||
</code>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "source",
|
||||
title: "研究来源",
|
||||
width: 150,
|
||||
render: (_, row) =>
|
||||
row!.source_kinds?.map(sourceLabel).join("、") || "暂无本地来源",
|
||||
},
|
||||
{
|
||||
key: "region",
|
||||
title: "地区 / Universe",
|
||||
@@ -594,6 +608,28 @@ export function AlphaPage({
|
||||
{activeFilterCount ? `· ${activeFilterCount} 项已应用` : ""}
|
||||
</summary>
|
||||
<div className="advanced-grid">
|
||||
<label>
|
||||
研究来源
|
||||
<Select
|
||||
aria-label="研究来源筛选"
|
||||
showClear
|
||||
value={draft.source || undefined}
|
||||
optionList={options("source").map((option) => ({
|
||||
...option,
|
||||
label: sourceLabel(option.value),
|
||||
}))}
|
||||
placeholder="全部来源"
|
||||
onChange={(v) => updateDraft("source", v)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
研究编号
|
||||
<Input
|
||||
aria-label="研究编号筛选"
|
||||
value={draft.research_id || ""}
|
||||
onChange={(v) => updateDraft("research_id", v)}
|
||||
/>
|
||||
</label>
|
||||
{[
|
||||
["universe", "Universe"],
|
||||
["alpha_type", "Alpha 类型"],
|
||||
@@ -938,6 +974,7 @@ export function AlphaPage({
|
||||
/>
|
||||
)}
|
||||
<AlphaDetail
|
||||
onAction={onAction}
|
||||
suspended={overlaySuspended}
|
||||
chatOffset={chatOffset}
|
||||
taskPanelOpen={taskPanelOpen}
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
queryString,
|
||||
} from "../api";
|
||||
import type { Account, Job } from "../types";
|
||||
import type { PageContext, UIAction } from "../ai/types";
|
||||
import "./dataset.css";
|
||||
|
||||
type Scope = {
|
||||
@@ -66,6 +67,7 @@ type Draft = {
|
||||
dataset_id: string;
|
||||
scope: Scope;
|
||||
field_ids: string[];
|
||||
field_types: Record<string, string | null>;
|
||||
collection_version: string;
|
||||
selection: string;
|
||||
created_at: string;
|
||||
@@ -114,6 +116,8 @@ export function DatasetPage({
|
||||
onTask,
|
||||
onModal,
|
||||
onChat,
|
||||
onContext,
|
||||
action,
|
||||
}: {
|
||||
account: Account | null;
|
||||
version: string;
|
||||
@@ -123,6 +127,8 @@ export function DatasetPage({
|
||||
onTask: () => void;
|
||||
onModal: (value: boolean) => void;
|
||||
onChat: () => void;
|
||||
onContext: (context: PageContext) => void;
|
||||
action: UIAction | null;
|
||||
}) {
|
||||
const [scope, setScope] = useState<Scope>({
|
||||
instrument_type: "EQUITY",
|
||||
@@ -185,6 +191,61 @@ export function DatasetPage({
|
||||
);
|
||||
const total = selected?.complete_count ?? 0;
|
||||
const chosen = Math.max(0, total - excluded.size);
|
||||
const contextDraft = inputOpen ? draft : null;
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
onContext({
|
||||
page: "datasets",
|
||||
catalog_scope: contextDraft?.scope ?? scope,
|
||||
dataset_id: contextDraft?.dataset_id ?? selected?.id,
|
||||
field_id: contextDraft ? undefined : detail?.field,
|
||||
collection_version:
|
||||
contextDraft?.collection_version ??
|
||||
selected?.collection_version ??
|
||||
undefined,
|
||||
template_input_id: contextDraft?.id,
|
||||
unsaved_field_selection: !contextDraft && excluded.size > 0,
|
||||
});
|
||||
}, [
|
||||
active,
|
||||
scopeQuery,
|
||||
contextDraft,
|
||||
selected?.id,
|
||||
selected?.collection_version,
|
||||
detail?.field,
|
||||
excluded.size,
|
||||
onContext,
|
||||
]);
|
||||
useEffect(() => {
|
||||
if (action?.type !== "open_research_input") return;
|
||||
let live = true;
|
||||
void api<Draft>(`/catalog/inputs/${encode(action.input_id)}`)
|
||||
.then((saved) => {
|
||||
if (!live) return;
|
||||
changeScope(saved.scope);
|
||||
setDraft(saved);
|
||||
setInputOpen(true);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (live) setError(e.message);
|
||||
});
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [action]);
|
||||
const researchInput = (saved: Draft) => {
|
||||
setDraft(saved);
|
||||
setHistoryOpen(false);
|
||||
setInputOpen(true);
|
||||
onContext({
|
||||
page: "datasets",
|
||||
catalog_scope: saved.scope,
|
||||
dataset_id: saved.dataset_id,
|
||||
collection_version: saved.collection_version,
|
||||
template_input_id: saved.id,
|
||||
});
|
||||
onChat();
|
||||
};
|
||||
const top = historyOpen
|
||||
? "history"
|
||||
: inputOpen
|
||||
@@ -1131,6 +1192,11 @@ export function DatasetPage({
|
||||
保存输入草稿
|
||||
</Button>
|
||||
)}
|
||||
{draft && (
|
||||
<Button theme="solid" onClick={() => researchInput(draft)}>
|
||||
用此输入研究
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="catalog-details-body">
|
||||
<h2>{draft ? "输入草稿已保存" : selected?.name}</h2>
|
||||
@@ -1140,10 +1206,24 @@ export function DatasetPage({
|
||||
: `${preparation.current?.count} 个字段`}
|
||||
</p>
|
||||
<p>
|
||||
{scope.region} · {scope.universe} · Delay {scope.delay}
|
||||
{(draft?.scope ?? scope).region} ·{" "}
|
||||
{(draft?.scope ?? scope).universe} · Delay{" "}
|
||||
{(draft?.scope ?? scope).delay}
|
||||
</p>
|
||||
<p>模板消费方尚未接入</p>
|
||||
<p>保存后可交给 Chatbox 研究,构建候选并预览回测。</p>
|
||||
{draft && <p>草稿 ID:{draft.id}</p>}
|
||||
{draft && (
|
||||
<details>
|
||||
<summary>查看固定字段集合</summary>
|
||||
<p>
|
||||
{draft.field_ids
|
||||
.map(
|
||||
(id) => `${id} (${draft.field_types[id] ?? "类型未知"})`,
|
||||
)
|
||||
.join(", ")}
|
||||
</p>
|
||||
</details>
|
||||
)}
|
||||
<p>
|
||||
集合版本:
|
||||
{draft?.collection_version ?? preparation.current?.version}
|
||||
@@ -1177,6 +1257,7 @@ export function DatasetPage({
|
||||
{d.scope.delay}
|
||||
</p>
|
||||
<p>{formatTime(d.created_at, account?.timezone)}</p>
|
||||
<Button onClick={() => researchInput(d)}>用此输入研究</Button>
|
||||
<details>
|
||||
<summary>查看固定字段集合</summary>
|
||||
<p>{d.field_ids.join(", ")}</p>
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Banner, Button, Empty, Pagination, Spin } from "@douyinfe/semi-ui-19";
|
||||
import { api, formatTime } from "../api";
|
||||
import type { Source, Page } from "../backtests/types";
|
||||
import type { UIAction } from "../ai/types";
|
||||
import { SourceDetails } from "./SourceDetails";
|
||||
|
||||
type Origin = {
|
||||
item_id: string;
|
||||
backtest_run_id: string;
|
||||
name: string;
|
||||
source: Source;
|
||||
observed_at: string;
|
||||
};
|
||||
|
||||
export function AlphaSources({
|
||||
id,
|
||||
version,
|
||||
timezone,
|
||||
onAction,
|
||||
}: {
|
||||
id: string;
|
||||
version: string;
|
||||
timezone?: string;
|
||||
onAction: (action: UIAction) => void;
|
||||
}) {
|
||||
const [page, setPage] = useState(1);
|
||||
const [data, setData] = useState<Page<Origin> | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setError("");
|
||||
setData(null);
|
||||
api<Page<Origin>>(
|
||||
`/alphas/${encodeURIComponent(id)}/sources?offset=${(page - 1) * 25}`,
|
||||
{ signal: controller.signal },
|
||||
)
|
||||
.then((value) => {
|
||||
if (!controller.signal.aborted) setData(value);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!controller.signal.aborted) setError(e.message);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [id, page, version]);
|
||||
if (error) return <Banner type="danger" description={error} />;
|
||||
if (!data) return <Spin />;
|
||||
return (
|
||||
<>
|
||||
<p className="muted">
|
||||
保留每次已保存回测的研究来源;平台同步不会覆盖这些记录。
|
||||
</p>
|
||||
{data.items.map((item) => (
|
||||
<article key={item.item_id}>
|
||||
<Button
|
||||
onClick={() =>
|
||||
onAction({
|
||||
type: "open_backtest",
|
||||
run_id: item.backtest_run_id,
|
||||
nonce: Date.now(),
|
||||
})
|
||||
}
|
||||
>
|
||||
{item.name}
|
||||
</Button>
|
||||
<span className="muted">
|
||||
{" "}
|
||||
· {formatTime(item.observed_at, timezone)}
|
||||
</span>
|
||||
<SourceDetails source={item.source} onAction={onAction} />
|
||||
</article>
|
||||
))}
|
||||
{!data.total && <Empty description="暂无本地回测研究来源" />}
|
||||
{data.total > 25 && (
|
||||
<Pagination
|
||||
currentPage={page}
|
||||
pageSize={25}
|
||||
total={data.total}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { Button } from "@douyinfe/semi-ui-19";
|
||||
import type { ToolCard, UIAction } from "../ai/types";
|
||||
import type { Source } from "../backtests/types";
|
||||
import { SourceDetails } from "./SourceDetails";
|
||||
|
||||
export function CatalogToolCard({
|
||||
call,
|
||||
onAction,
|
||||
}: {
|
||||
call: ToolCard;
|
||||
onAction: (action: UIAction) => void;
|
||||
}) {
|
||||
if (call.status !== "completed" || !call.result || call.result.error)
|
||||
return null;
|
||||
const result = call.result;
|
||||
const items = Array.isArray(result.items)
|
||||
? (result.items as Record<string, unknown>[])
|
||||
: [];
|
||||
if (call.name === "get_alpha_sources")
|
||||
return (
|
||||
<>
|
||||
<p>
|
||||
本页 {items.length} / 共 {String(result.total)} 条来源记录
|
||||
</p>
|
||||
{items.map((item) => (
|
||||
<div key={String(item.item_id)}>
|
||||
<Button
|
||||
onClick={() =>
|
||||
onAction({
|
||||
type: "open_backtest",
|
||||
run_id: String(item.backtest_run_id),
|
||||
nonce: Date.now(),
|
||||
})
|
||||
}
|
||||
>
|
||||
{String(item.name)}
|
||||
</Button>
|
||||
<SourceDetails source={item.source as Source} onAction={onAction} />
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
const paged = [
|
||||
"search_catalog",
|
||||
"get_research_input",
|
||||
"prepare_research_input",
|
||||
].includes(call.name);
|
||||
if (paged)
|
||||
return (
|
||||
<>
|
||||
<p>
|
||||
{String(result.dataset_id ?? "数据目录")} · 本页 {items.length} / 匹配{" "}
|
||||
{String(result.total)} 条
|
||||
</p>
|
||||
{typeof result.field_count === "number" && (
|
||||
<p>固定输入共 {result.field_count} 个字段</p>
|
||||
)}
|
||||
<ul>
|
||||
{items.map((item) => (
|
||||
<li key={String(item.id)}>
|
||||
{String(item.id)} · {String(item.name ?? "未命名")}{" "}
|
||||
{item.field_type ? `· ${item.field_type}` : ""}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{call.name !== "search_catalog" && typeof result.id === "string" && (
|
||||
<Button
|
||||
onClick={() =>
|
||||
onAction({
|
||||
type: "open_research_input",
|
||||
input_id: String(result.id),
|
||||
nonce: Date.now(),
|
||||
})
|
||||
}
|
||||
>
|
||||
查看固定研究输入
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
if (
|
||||
["get_catalog_detail", "get_catalog_scopes", "get_backtest_draft"].includes(
|
||||
call.name,
|
||||
)
|
||||
)
|
||||
return (
|
||||
<details>
|
||||
<summary>查看读取内容</summary>
|
||||
<pre>{JSON.stringify(result, null, 2)}</pre>
|
||||
</details>
|
||||
);
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Button } from "@douyinfe/semi-ui-19";
|
||||
import type { Source } from "../backtests/types";
|
||||
import type { UIAction } from "../ai/types";
|
||||
import "./style.css";
|
||||
|
||||
export const sourceLabel = (kind: string) =>
|
||||
({ chatbox: "Chatbox 研究", manual: "手工研究", ai: "AI 研究(历史)" })[
|
||||
kind
|
||||
] ?? kind;
|
||||
|
||||
export function SourceDetails({
|
||||
source,
|
||||
onAction,
|
||||
}: {
|
||||
source: Source;
|
||||
onAction: (action: UIAction) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="detail-section research-source">
|
||||
<p>研究来源:{sourceLabel(source.kind)}</p>
|
||||
{source.hypothesis && <p>{source.hypothesis}</p>}
|
||||
{source.batch_id && <p>业务批次:{source.batch_id}</p>}
|
||||
{source.research_id && <p>研究编号:{source.research_id}</p>}
|
||||
{source.reference && source.kind !== "chatbox" && (
|
||||
<p>来源引用:{source.reference}</p>
|
||||
)}
|
||||
<div className="inline-actions">
|
||||
{source.kind === "chatbox" && source.reference && (
|
||||
<Button
|
||||
onClick={() =>
|
||||
onAction({
|
||||
type: "open_conversation",
|
||||
conversation_id: source.reference!,
|
||||
nonce: Date.now(),
|
||||
})
|
||||
}
|
||||
>
|
||||
打开研究会话
|
||||
</Button>
|
||||
)}
|
||||
{source.template_input_id && (
|
||||
<Button
|
||||
onClick={() =>
|
||||
onAction({
|
||||
type: "open_research_input",
|
||||
input_id: source.template_input_id!,
|
||||
nonce: Date.now(),
|
||||
})
|
||||
}
|
||||
>
|
||||
查看研究输入
|
||||
</Button>
|
||||
)}
|
||||
{source.parent_run_id && (
|
||||
<Button
|
||||
onClick={() =>
|
||||
onAction({
|
||||
type: "open_backtest",
|
||||
run_id: source.parent_run_id!,
|
||||
nonce: Date.now(),
|
||||
})
|
||||
}
|
||||
>
|
||||
查看原回测
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
.research-source {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.research-source .inline-actions {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
@@ -57,6 +57,7 @@ export type Alpha = {
|
||||
synced_at: string;
|
||||
research: Research;
|
||||
local_correlation: CorrelationSummary | null;
|
||||
source_kinds: string[];
|
||||
};
|
||||
export type AlphaDetail = Alpha & {
|
||||
expression: string | null;
|
||||
|
||||
Reference in New Issue
Block a user