@@ -102,6 +125,7 @@ export default function App() {
.then(() => setAuthenticated(true))
.catch(() => setAuthenticated(false));
const expired = () => {
+ setSettingsOpen(false);
setAuthenticated(false);
setAccount(null);
setJobs([]);
@@ -171,6 +195,7 @@ export default function App() {
const logout = async () => {
try {
await post("/auth/logout");
+ setSettingsOpen(false);
setAuthenticated(false);
setChatOpen(false);
setShowJobs(false);
@@ -199,110 +224,85 @@ export default function App() {
}}
/>
) : (
-
-
+ setSettingsOpen(false)}
+ >
+
+
+
- {!chatOpen && !catalogModal && (
-
- )}
{chatOpen && viewport < 1440 && (
setChatOpen(false)}
context={
{
+ operators: { page: "operators" as const },
+ templates:
+ researchContext.page === "templates"
+ ? researchContext
+ : { page: "templates" as const },
+ variants:
+ researchContext.page === "variants"
+ ? researchContext
+ : { page: "variants" as const },
alphas: alphaContext,
datasets: datasetContext,
backtests: backtestContext,
@@ -419,15 +453,7 @@ export default function App() {
}[page]
}
timezone={account?.timezone}
- onSettings={() => {
- focusBusiness();
- changePage("account");
- requestAnimationFrame(() =>
- document
- .getElementById("model-settings")
- ?.scrollIntoView({ block: "start" }),
- );
- }}
+ onSettings={openSettings}
onChanged={aiChanged}
onAction={handleAction}
/>
diff --git a/frontend/src/ai/ToolCard.tsx b/frontend/src/ai/ToolCard.tsx
index fdc72fd..4772353 100644
--- a/frontend/src/ai/ToolCard.tsx
+++ b/frontend/src/ai/ToolCard.tsx
@@ -1,5 +1,6 @@
import type { ComponentType } from "react";
import { Button, Tag } from "@douyinfe/semi-ui-19";
+import { ResearchToolCard } from "../research/ResearchToolCard";
import { BacktestToolCard } from "../backtests/BacktestToolCard";
import { CatalogToolCard } from "../research/CatalogToolCard";
import { AlphaToolCard } from "./AlphaToolCard";
@@ -9,6 +10,7 @@ import type { ToolCardProps } from "./types";
// The server owns labels, effects and refresh targets. This is the only place
// where a new presentation shape joins the workspace; tool names are domain-local.
const renderers: Record> = {
+ research: ResearchToolCard,
alpha: AlphaToolCard,
jobs: JobToolCard,
catalog: CatalogToolCard,
diff --git a/frontend/src/ai/types.ts b/frontend/src/ai/types.ts
index 510c4f5..2526a95 100644
--- a/frontend/src/ai/types.ts
+++ b/frontend/src/ai/types.ts
@@ -11,7 +11,16 @@ export type ModelSettings = {
test_results: Record;
};
export type PageContext = {
- page: "alphas" | "account" | "datasets" | "backtests";
+ page:
+ | "alphas"
+ | "account"
+ | "datasets"
+ | "backtests"
+ | "operators"
+ | "templates"
+ | "variants";
+ research_asset_id?: string;
+ research_experiment_id?: string;
catalog_scope?: {
instrument_type: string;
region: string;
@@ -34,6 +43,9 @@ export type AlphaUIAction =
| { type: "open_alpha"; alpha_id: string; nonce: number }
| { type: "apply_filters"; filters: Record; nonce: number };
export type UIAction =
+ | { type: "open_template"; asset_id: string; version?: number; nonce: number }
+ | { type: "open_experiment"; experiment_id: string; nonce: number }
+ | { type: "open_variant"; alpha_id: string; nonce: number }
| { 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 }
diff --git a/frontend/src/ai/workspace.ts b/frontend/src/ai/workspace.ts
index ecfb171..3054991 100644
--- a/frontend/src/ai/workspace.ts
+++ b/frontend/src/ai/workspace.ts
@@ -4,6 +4,10 @@ export type WorkspacePage = PageContext["page"];
const contextLabels: Record string> = {
alphas: (context) =>
`上下文:${context.alpha_id ? `Alpha ${context.alpha_id}` : "Alpha 列表"}${context.selected_ids?.length ? ` · 已选 ${context.selected_ids.length} 条` : ""}`,
+ operators: () => "上下文:算子库",
+ templates: (context) =>
+ `上下文:模板工坊${context.research_asset_id ? ` · ${context.research_asset_id}` : ""}${context.research_experiment_id ? ` · 实验 ${context.research_experiment_id}` : ""}`,
+ variants: () => "上下文:Alpha 变体",
account: () => "上下文:个人信息页",
backtests: () => "上下文:回测研究",
datasets: (context) =>
@@ -27,6 +31,9 @@ type Destination = {
};
// Exhaustive action destinations prevent a new action silently falling into Alpha.
const destinations: Record = {
+ open_template: { page: "templates", chat: "responsive" },
+ open_experiment: { page: "templates", chat: "responsive" },
+ open_variant: { page: "variants", chat: "responsive" },
open_conversation: { chat: "open" },
open_research_input: { page: "datasets", chat: "close" },
open_backtest: { page: "backtests", chat: "responsive" },
diff --git a/frontend/src/components/AlphaDetail.tsx b/frontend/src/components/AlphaDetail.tsx
index ef4e15e..a2d106a 100644
--- a/frontend/src/components/AlphaDetail.tsx
+++ b/frontend/src/components/AlphaDetail.tsx
@@ -32,6 +32,7 @@ import type {
} from "../types";
import { PnlChart } from "./PnlChart";
import { SelfCorrelationPanel } from "./SelfCorrelationPanel";
+import { ComparisonPanel } from "../research/ComparisonPanel";
import { AlphaSources } from "../research/AlphaSources";
import type { UIAction } from "../ai/types";
@@ -216,6 +217,20 @@ export function AlphaDetail({
+ {detail.alpha_type === "REGULAR" &&
+ detail.language === "FASTEXPR" && (
+
+ )}
@@ -320,6 +335,11 @@ export function AlphaDetail({
/>
)}
+
+ {tab === "compare" && (
+
+ )}
+
{tab === "sources" && id && (
void;
+ onNavigate: (page: WorkspacePage) => void;
+ onLogout: () => void;
+ onTheme: () => void;
+}) {
+ const [userOpen, setUserOpen] = useState(false);
+ const [commandOpen, setCommandOpen] = useState(false);
+ const [recent, setRecent] = useState([page]);
+ const name = account?.display_name || "研究员";
+ useEffect(() => {
+ setRecent((old) => [page, ...old.filter((id) => id !== page)].slice(0, 4));
+ }, [page]);
+ useEffect(() => {
+ if (blocked) {
+ setUserOpen(false);
+ setCommandOpen(false);
+ }
+ const shortcut = (event: KeyboardEvent) => {
+ if (
+ !blocked &&
+ (event.metaKey || event.ctrlKey) &&
+ event.key.toLowerCase() === "k"
+ ) {
+ event.preventDefault();
+ setCommandOpen((open) => !open);
+ }
+ };
+ window.addEventListener("keydown", shortcut);
+ return () => window.removeEventListener("keydown", shortcut);
+ }, [blocked]);
+ function navigate(id: WorkspacePage) {
+ setUserOpen(false);
+ setCommandOpen(false);
+ onNavigate(id);
+ }
+ function closeRecent(id: WorkspacePage) {
+ if (recent.length <= 1) return;
+ const next = recent.filter((item) => item !== id);
+ setRecent(next);
+ if (id === page) navigate(next[0]);
+ }
+ return (
+ <>
+
+ {!collapsed && (
+
+
+
+ )}
+
+
+ {!collapsed && (
+
+ 最近访问
+
+
+ )}
+
+
+
+
+
+
+
+
+ setCommandOpen(false)}
+ width={420}
+ >
+
+ {navigation.map(({ id, label, icon: Icon }) => (
+
+ ))}
+
+
+ >
+ );
+}
diff --git a/frontend/src/components/WorkspacePreferences.tsx b/frontend/src/components/WorkspacePreferences.tsx
new file mode 100644
index 0000000..2e71b20
--- /dev/null
+++ b/frontend/src/components/WorkspacePreferences.tsx
@@ -0,0 +1,126 @@
+import { useEffect, useState } from "react";
+import { Button, Input, Select, Toast } from "@douyinfe/semi-ui-19";
+import { formatTime, patch } from "../api";
+import type { Account } from "../types";
+
+export function WorkspacePreferences({
+ account,
+ onChange,
+}: {
+ account: Account | null;
+ onChange: () => void;
+}) {
+ const [busy, setBusy] = useState(false);
+ const [preferences, setPreferences] = useState({
+ display_name: "研究员",
+ theme: "light",
+ timezone: "Asia/Shanghai",
+ page_size: 25,
+ });
+ useEffect(() => {
+ if (account)
+ setPreferences({
+ display_name: account.display_name,
+ theme: account.theme,
+ timezone: account.timezone,
+ page_size: account.page_size,
+ });
+ }, [
+ account?.display_name,
+ account?.theme,
+ account?.timezone,
+ account?.page_size,
+ ]);
+ if (!account) return null;
+ async function save() {
+ setBusy(true);
+ try {
+ await patch("/account/preferences", preferences);
+ onChange();
+ Toast.success("已保存");
+ } catch (error) {
+ Toast.error((error as Error).message);
+ } finally {
+ setBusy(false);
+ }
+ }
+ return (
+
+
+
工作空间偏好
+
+ 更新于 {formatTime(account.last_synced_at, account.timezone)}
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/sidebar.css b/frontend/src/components/sidebar.css
new file mode 100644
index 0000000..15f5f11
--- /dev/null
+++ b/frontend/src/components/sidebar.css
@@ -0,0 +1,284 @@
+.workspace {
+ --sidebar-width: 230px;
+ padding-left: var(--sidebar-width);
+ transition: padding-left 200ms ease-out;
+}
+.workspace.sidebar-collapsed {
+ --sidebar-width: 64px;
+}
+.app-sidebar {
+ position: fixed;
+ inset: 0 auto 0 0;
+ z-index: 30;
+ width: var(--sidebar-width);
+ display: flex;
+ flex-direction: column;
+ background: var(--semi-color-bg-1);
+ border-right: 1px solid var(--semi-color-border);
+ color: var(--semi-color-text-1);
+ transition: width 200ms ease-out;
+}
+.app-sidebar button,
+.sidebar-user-menu button,
+.command-items button {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ border: 0;
+ background: transparent;
+ color: inherit;
+ text-align: left;
+ border-radius: var(--semi-border-radius-large);
+}
+.app-sidebar button:hover,
+.sidebar-user-menu button:hover,
+.command-items button:hover {
+ background: var(--semi-color-fill-0);
+ color: var(--semi-color-text-0);
+}
+.app-sidebar button:focus-visible {
+ outline: 2px solid var(--semi-color-primary-light-active);
+ outline-offset: -2px;
+}
+.sidebar-user-row {
+ height: 56px;
+ padding: 0 12px;
+ display: flex;
+ align-items: center;
+ flex-shrink: 0;
+}
+.sidebar-user-trigger {
+ width: 100%;
+ height: 44px;
+ padding: 0 8px;
+}
+.sidebar-user-trigger .semi-avatar {
+ flex-shrink: 0;
+}
+.sidebar-user-trigger .account-name {
+ flex: 1;
+ min-width: 0;
+ font-size: 14px;
+ font-weight: 600;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.sidebar-command {
+ padding: 0 12px 12px;
+ flex-shrink: 0;
+}
+.sidebar-command button {
+ height: 36px;
+ padding: 0 8px;
+ width: 100%;
+ border: 1px solid var(--semi-color-border);
+ border-radius: var(--semi-border-radius-medium);
+ color: var(--semi-color-text-2);
+}
+.sidebar-command button > span:not(.semi-icon) {
+ flex: 1;
+}
+.sidebar-command kbd {
+ font-size: 11px;
+ line-height: 20px;
+ font-family: inherit;
+ padding: 0 6px;
+ border: 1px solid var(--semi-color-border);
+ border-radius: 4px;
+}
+.sidebar-nav {
+ flex: 1;
+ min-height: 0;
+ overflow-y: auto;
+ padding: 0 8px 8px;
+}
+.sidebar-nav-item {
+ width: 100%;
+ height: 40px;
+ margin-bottom: 4px;
+ padding: 0 12px;
+ font-weight: 500;
+}
+.sidebar-nav-item > span:last-child,
+.recent-link > span:last-child {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.sidebar-nav-item .semi-icon {
+ flex-shrink: 0;
+ color: var(--semi-color-text-2);
+}
+.app-sidebar .active,
+.app-sidebar .active:hover,
+.app-sidebar .active .semi-icon {
+ background: var(--semi-color-primary-light-default);
+ color: var(--semi-color-primary);
+}
+.sidebar-footer {
+ flex-shrink: 0;
+ border-top: 1px solid var(--semi-color-border);
+ padding: 8px;
+}
+.recent-heading {
+ height: 32px;
+ padding: 0 8px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ color: var(--semi-color-text-2);
+ font-size: 12px;
+}
+.recent-heading button {
+ height: 28px;
+ padding: 0 4px;
+ font-size: 12px;
+}
+.recent-item {
+ height: 36px;
+ margin-bottom: 4px;
+ display: flex;
+ align-items: center;
+ border-radius: var(--semi-border-radius-medium);
+}
+.recent-link {
+ flex: 1;
+ min-width: 0;
+ height: 36px;
+ padding: 0 8px;
+ gap: 8px !important;
+ font-size: 12px;
+}
+.recent-close {
+ width: 24px;
+ height: 24px;
+ padding: 0;
+ margin-right: 4px;
+ justify-content: center;
+ opacity: 0;
+}
+.recent-item:hover .recent-close,
+.recent-close:focus-visible {
+ opacity: 1;
+}
+.recent-close:disabled {
+ cursor: default;
+ opacity: 0;
+}
+.sidebar-collapse {
+ margin-top: 12px;
+ border-top: 1px solid var(--semi-color-border);
+ padding-top: 12px;
+}
+.sidebar-collapse button {
+ height: 40px;
+ width: 100%;
+ padding: 0 12px;
+ font-weight: 500;
+}
+.is-collapsed .sidebar-user-trigger {
+ height: 40px;
+ width: 40px;
+ padding: 0;
+ justify-content: center;
+}
+.is-collapsed .sidebar-nav {
+ padding-bottom: 12px;
+}
+.is-collapsed .sidebar-nav-item,
+.is-collapsed .recent-link,
+.is-collapsed .sidebar-collapse button {
+ padding: 0;
+ justify-content: center;
+}
+.sidebar-user-menu {
+ min-width: 208px;
+ max-width: 300px;
+ padding: 4px;
+ background: var(--semi-color-bg-3);
+ box-shadow: var(--semi-shadow-elevated);
+ border-radius: var(--semi-border-radius-medium);
+}
+.sidebar-user-details {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ padding: 12px 8px;
+ overflow-wrap: anywhere;
+}
+.sidebar-user-details span {
+ font-size: 12px;
+ color: var(--semi-color-text-2);
+}
+.sidebar-user-menu button,
+.command-items button {
+ min-height: 36px;
+ width: 100%;
+ padding: 0 8px;
+}
+.main-shell {
+ padding-top: 36px;
+}
+.workspace-tools {
+ position: fixed;
+ top: 0;
+ right: var(--chat-space, 0px);
+ z-index: 1002;
+ background: var(--surface);
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 4px;
+ height: 36px;
+ flex-shrink: 0;
+ padding: 2px 16px;
+}
+.workspace-tools .semi-button {
+ width: 28px;
+ height: 28px;
+ padding: 0;
+}
+.main-shell .page-content {
+ padding-top: 0;
+}
+@media (max-width: 760px) {
+ .workspace,
+ .workspace.sidebar-collapsed {
+ padding-left: 0;
+ }
+ .app-sidebar {
+ display: none;
+ }
+ .workspace-tools {
+ padding-right: 12px;
+ }
+}
+@media (prefers-reduced-motion: reduce) {
+ .workspace,
+ .app-sidebar {
+ transition: none;
+ }
+}
+.settings-drawer .ai-settings-grid {
+ grid-template-columns: minmax(0, 1fr);
+}
+.settings-drawer .ai-settings {
+ min-width: 0;
+}
+
+.settings-drawer .workspace-preferences {
+ margin-top: 24px;
+ padding-top: 24px;
+ border-top: 1px solid var(--line);
+}
+.settings-drawer .preferences-form {
+ flex-direction: column;
+ align-items: stretch;
+}
+.settings-drawer .preferences-grid {
+ grid-template-columns: minmax(0, 1fr);
+}
+.settings-drawer .preferences-form > .semi-button {
+ align-self: flex-start;
+}
diff --git a/frontend/src/pages/AccountPage.tsx b/frontend/src/pages/AccountPage.tsx
index 967c3ba..964ac24 100644
--- a/frontend/src/pages/AccountPage.tsx
+++ b/frontend/src/pages/AccountPage.tsx
@@ -4,14 +4,12 @@ import {
Banner,
Button,
Input,
- Select,
Spin,
Tag,
Toast,
} from "@douyinfe/semi-ui-19";
-import { api, displayValue, formatTime, patch, post } from "../api";
+import { api, displayValue, formatTime, post } from "../api";
import type { Account, AccountUsage } from "../types";
-import { ModelSettingsPanel } from "../ai/ModelSettingsPanel";
const connectionLabels: Record = {
connected: "已连接",
@@ -57,29 +55,9 @@ export function AccountPage({
const [password, setPassword] = useState("");
const [busy, setBusy] = useState("");
const [settingsOpen, setSettingsOpen] = useState(false);
- const [preferences, setPreferences] = useState({
- display_name: "研究员",
- theme: "light",
- timezone: "Asia/Shanghai",
- page_size: 25,
- });
useEffect(() => {
if (account) setEmail(account.email ?? "");
}, [account?.email]);
- useEffect(() => {
- if (account)
- setPreferences({
- display_name: account.display_name,
- theme: account.theme,
- timezone: account.timezone,
- page_size: account.page_size,
- });
- }, [
- account?.display_name,
- account?.theme,
- account?.timezone,
- account?.page_size,
- ]);
if (!account)
return (
@@ -161,29 +139,6 @@ export function AccountPage({
];
return (
<>
-
-
个人信息
-
-
-
-
-
{account.connection_error && (
+
+
+
+
{(settingsOpen || !account.configured) && (
-
-
-
-
工作空间偏好
-
- 更新于 {formatTime(account.last_synced_at, account.timezone)}
-
-
-
-
>
);
diff --git a/frontend/src/pages/DatasetPage.tsx b/frontend/src/pages/DatasetPage.tsx
index 9c5d472..fc11005 100644
--- a/frontend/src/pages/DatasetPage.tsx
+++ b/frontend/src/pages/DatasetPage.tsx
@@ -27,6 +27,7 @@ import {
} from "../api";
import type { Account, Job } from "../types";
import type { PageContext, UIAction } from "../ai/types";
+import { FieldAvailability } from "../research/FieldAvailability";
import "./dataset.css";
type Scope = {
@@ -1237,6 +1238,13 @@ export function DatasetPage({
+ {detail?.field && (
+
+ )}
{conflict && (
;
+ observed_at: string;
+ pnl: { date: string; value: number }[];
+ }[];
+};
+export function ComparisonPanel({ baseline = "" }: { baseline?: string }) {
+ const [ids, setIds] = useState(baseline);
+ const [result, setResult] = useState(null);
+ const [error, setError] = useState("");
+ const [busy, setBusy] = useState(false);
+ async function compare() {
+ setBusy(true);
+ setError("");
+ try {
+ setResult(
+ await post("/research/compare", {
+ alpha_ids: ids.split(/[,,\s]+/).filter(Boolean),
+ }),
+ );
+ } catch (error) {
+ setError((error as Error).message);
+ } finally {
+ setBusy(false);
+ }
+ }
+ return (
+
+ 基线与候选比较
+ 首个 Alpha 为基线,后续为候选。比较本地快照和共同日期窗口的 PnL。
+
+
+
+
+ {error && }
+ {result && (
+ <>
+
+
+ {result.items.map((item, index) => (
+
+
+ {index === 0 ? "基线" : "候选"} · {item.alpha_id}
+
+ {item.expression}
+
+ {item.settings.region} / {item.settings.universe} / D
+ {item.settings.delay} · {item.settings.neutralization} · decay{" "}
+ {item.settings.decay}
+
+
+ Sharpe {formatNumber(item.metrics.sharpe)} · Fitness{" "}
+ {formatNumber(item.metrics.fitness)} · Turnover{" "}
+ {formatNumber(item.metrics.turnover)}
+
+ 指标快照 {formatTime(item.observed_at)}
+
+
+ 完整设置差异依据
+ {JSON.stringify(item.settings, null, 2)}
+
+
+ ))}
+
+ >
+ )}
+
+ );
+}
diff --git a/frontend/src/research/ExperimentView.tsx b/frontend/src/research/ExperimentView.tsx
new file mode 100644
index 0000000..4c7bb51
--- /dev/null
+++ b/frontend/src/research/ExperimentView.tsx
@@ -0,0 +1,213 @@
+import { useState } from "react";
+import { Banner, Button, Checkbox, Tag, Toast } from "@douyinfe/semi-ui-19";
+import { post, formatTime } from "../api";
+import type { UIAction } from "../ai/types";
+import type { Experiment } from "./workspaceTypes";
+import { validationLabel } from "./workspaceTypes";
+
+export function ExperimentView({
+ experiment,
+ onAction,
+}: {
+ experiment: Experiment;
+ onAction: (action: UIAction) => void;
+}) {
+ const [selected, setSelected] = useState([]);
+ const [busy, setBusy] = useState(false);
+ const valid = experiment.candidates.filter(
+ (c) => c.validation.status === "valid",
+ );
+ async function preview() {
+ setBusy(true);
+ try {
+ const result = await post<{ preview_id: string }>(
+ `/research/experiments/${experiment.id}/preview`,
+ selected.length ? { candidate_ids: selected } : {},
+ );
+ onAction({
+ type: "open_backtest_preview",
+ preview_id: result.preview_id,
+ nonce: Date.now(),
+ });
+ } catch (error) {
+ Toast.error((error as Error).message);
+ } finally {
+ setBusy(false);
+ }
+ }
+ return (
+
+
+
+
{experiment.name}
+
+ {formatTime(experiment.created_at)} · {experiment.candidates.length}{" "}
+ 个候选 · {valid.length} 个可预览
+
+
+
+
+ {experiment.hypothesis}
+
+
+
+
+
+ | 选择 |
+ 候选表达式 |
+ 市场与设置 |
+ 校验 |
+
+
+
+ {experiment.candidates.map((candidate) => (
+
+ |
+
+ setSelected((old) =>
+ event.target.checked
+ ? [...old, candidate.client_item_id]
+ : old.filter((id) => id !== candidate.client_item_id),
+ )
+ }
+ />
+ |
+
+ {candidate.expression}
+
+ 绑定与改动
+
+ {JSON.stringify(
+ {
+ bindings: candidate.bindings,
+ changes: candidate.changes,
+ },
+ null,
+ 2,
+ )}
+
+
+ |
+
+ {candidate.settings.region} / {candidate.settings.universe} /
+ D{candidate.settings.delay}
+
+ {candidate.settings.neutralization} · decay{" "}
+ {candidate.settings.decay}
+ |
+
+
+ {validationLabel[candidate.validation.status]}
+
+ {(["syntax", "types", "availability"] as const).map((key) =>
+ candidate.validation[key].map((issue, index) => (
+
+ {
+ {
+ syntax: "语法",
+ types: "类型",
+ availability: "可用性",
+ }[key]
+ }
+ :{issue}
+
+ )),
+ )}
+ |
+
+ ))}
+
+
+
+
+ 研究来源
+ 实验 {experiment.id}
+ {experiment.evidence.template?.id && (
+
+ )}
+ {experiment.inputs.map((input) => (
+
+ ))}
+ {experiment.parents.map((parent) => (
+
+ ))}
+ {experiment.backtest_run_ids.map((id) => (
+
+ ))}
+
+
+ );
+}
diff --git a/frontend/src/research/FieldAvailability.tsx b/frontend/src/research/FieldAvailability.tsx
new file mode 100644
index 0000000..919aeb2
--- /dev/null
+++ b/frontend/src/research/FieldAvailability.tsx
@@ -0,0 +1,79 @@
+import { useEffect, useState } from "react";
+import { Banner, Button } from "@douyinfe/semi-ui-19";
+import { api, post, queryString, formatTime } from "../api";
+type Scope = {
+ instrument_type: string;
+ region: string;
+ universe: string;
+ delay: number;
+};
+type Snapshot = {
+ content: { status?: string; reason?: string; items?: Scope[] };
+ fetched_at: string | null;
+};
+export function FieldAvailability({
+ field,
+ scope,
+}: {
+ field: string;
+ scope: Scope;
+}) {
+ const [snapshot, setSnapshot] = useState(null);
+ const [error, setError] = useState("");
+ const [busy, setBusy] = useState(false);
+ const query = queryString(scope);
+ useEffect(() => {
+ let live = true;
+ api(
+ `/catalog/field-availability/${encodeURIComponent(field)}?${query}`,
+ )
+ .then((value) => {
+ if (live) setSnapshot(value);
+ })
+ .catch((error) => {
+ if (live) setError(error.message);
+ });
+ return () => {
+ live = false;
+ };
+ }, [field, query]);
+ async function refresh() {
+ setBusy(true);
+ setError("");
+ try {
+ setSnapshot(
+ await post("/catalog/field-availability/refresh", {
+ field_id: field,
+ scope,
+ }),
+ );
+ } catch (error) {
+ setError((error as Error).message);
+ } finally {
+ setBusy(false);
+ }
+ }
+ return (
+
+ 字段可用范围
+ {formatTime(snapshot?.fetched_at)}
+ {error && }
+ {snapshot?.content.items?.length ? (
+ snapshot.content.items.map((item, index) => (
+
+ {item.instrument_type} · {item.region} / {item.universe} / D
+ {item.delay}
+
+ ))
+ ) : (
+
+ {snapshot?.content.reason ||
+ "尚未取得可用性证据,请查询平台。未知不代表可用。"}
+
+ )}
+
+
+ );
+}
diff --git a/frontend/src/research/OperatorsPage.tsx b/frontend/src/research/OperatorsPage.tsx
new file mode 100644
index 0000000..e70380f
--- /dev/null
+++ b/frontend/src/research/OperatorsPage.tsx
@@ -0,0 +1,231 @@
+import { ResearchSelect } from "./ResearchSelect";
+import { useEffect, useState } from "react";
+import {
+ Banner,
+ Button,
+ Checkbox,
+ Input,
+ TextArea,
+} from "@douyinfe/semi-ui-19";
+import { api, patch, post, formatTime } from "../api";
+import "./workspace.css";
+type Operator = {
+ name: string;
+ category: string;
+ description: string;
+ definition: string;
+ example: string;
+ parameters: unknown;
+ scope: string[];
+ local: { note: string; favorite: boolean; version: number };
+};
+type Results = {
+ items: Operator[];
+ total: number;
+ categories: string[];
+ fetched_at: string | null;
+};
+export function OperatorsPage({ active }: { active: boolean }) {
+ const [query, setQuery] = useState("");
+ const [category, setCategory] = useState("");
+ const [favorite, setFavorite] = useState(false);
+ const [offset, setOffset] = useState(0);
+ const [result, setResult] = useState({
+ items: [],
+ total: 0,
+ categories: [],
+ fetched_at: null,
+ });
+ const [selected, setSelected] = useState(null);
+ const [error, setError] = useState("");
+ const [busy, setBusy] = useState(false);
+ async function load() {
+ setResult(
+ await api(
+ `/catalog/operators?${new URLSearchParams({ q: query, category, favorite: String(favorite), limit: "25", offset: String(offset) })}`,
+ ),
+ );
+ }
+ async function task(operation: () => Promise) {
+ setBusy(true);
+ setError("");
+ try {
+ await operation();
+ } catch (error) {
+ setError((error as Error).message);
+ } finally {
+ setBusy(false);
+ }
+ }
+ useEffect(() => {
+ if (active) void task(load);
+ }, [active, query, category, favorite, offset]);
+ return (
+
+
+ {error &&
}
+
+ {
+ setQuery(value);
+ setOffset(0);
+ }}
+ />
+ ({ value, label: value })),
+ ]}
+ onChange={(value) => {
+ setCategory(value as string);
+ setOffset(0);
+ }}
+ />
+ {
+ setFavorite(!!event.target.checked);
+ setOffset(0);
+ }}
+ >
+ 只看收藏
+
+
+
+
+
+
+
+ | 算子 |
+ 分类 |
+ 定义 |
+
+
+
+ {result.items.map((item) => (
+
+ |
+
+ |
+ {item.category} |
+
+ {item.definition}
+ |
+
+ ))}
+
+
+ {!result.items.length && (
+ 暂无算子。连接平台并同步目录,或调整筛选条件。
+ )}
+
+ 共 {result.total} 个
+
+
+
+
+ {selected && (
+
+ {selected.name}
+ {selected.description}
+ {selected.definition}
+ {selected.example && {selected.example}}
+ {!!selected.parameters && (
+ {JSON.stringify(selected.parameters, null, 2)}
+ )}
+ 适用:{selected.scope?.join("、") || "平台未提供"}
+
+
+ setSelected({
+ ...selected,
+ local: {
+ ...selected.local,
+ favorite: !!event.target.checked,
+ },
+ })
+ }
+ >
+ 收藏算子
+
+
+
+
+
+ )}
+
+
+ );
+}
diff --git a/frontend/src/research/ResearchSelect.tsx b/frontend/src/research/ResearchSelect.tsx
new file mode 100644
index 0000000..3e6d0d4
--- /dev/null
+++ b/frontend/src/research/ResearchSelect.tsx
@@ -0,0 +1,17 @@
+import { useId, type ComponentProps } from "react";
+import { Select } from "@douyinfe/semi-ui-19";
+/** Semi Select exposes its accessible name through aria-labelledby. */
+export function ResearchSelect({
+ label,
+ ...props
+}: ComponentProps & { label: string }) {
+ const id = useId();
+ return (
+ <>
+
+ {label}
+
+
+ >
+ );
+}
diff --git a/frontend/src/research/ResearchToolCard.tsx b/frontend/src/research/ResearchToolCard.tsx
new file mode 100644
index 0000000..6480eb7
--- /dev/null
+++ b/frontend/src/research/ResearchToolCard.tsx
@@ -0,0 +1,39 @@
+import { Button } from "@douyinfe/semi-ui-19";
+import type { ToolCardProps } from "../ai/types";
+export function ResearchToolCard({ call, onAction }: ToolCardProps) {
+ const result = call.result || {};
+ return (
+
+
+ {typeof result.name === "string" ? result.name : "研究资料"}
+ {typeof result.version === "number" ? ` · v${result.version}` : ""}
+
+ {typeof result.id === "string" && (
+
+ )}
+
+ 查看资料与校验依据
+ {JSON.stringify(result, null, 2)}
+
+
+ );
+}
diff --git a/frontend/src/research/ResearchWorkspace.tsx b/frontend/src/research/ResearchWorkspace.tsx
new file mode 100644
index 0000000..5bef766
--- /dev/null
+++ b/frontend/src/research/ResearchWorkspace.tsx
@@ -0,0 +1,682 @@
+import { ResearchSelect } from "./ResearchSelect";
+import { useEffect, useRef, useState } from "react";
+import {
+ Banner,
+ Button,
+ Input,
+ InputNumber,
+ TextArea,
+ Toast,
+} from "@douyinfe/semi-ui-19";
+import { api, post, formatTime } from "../api";
+import type { PageContext, UIAction } from "../ai/types";
+import { initialSettings, type SimulationSettings } from "../backtests/types";
+import {
+ blankTemplate,
+ type Asset,
+ type Experiment,
+ type InputSnapshot,
+ type Template,
+} from "./workspaceTypes";
+import { TemplateEditor } from "./TemplateEditor";
+import { ExperimentView } from "./ExperimentView";
+import { ComparisonPanel } from "./ComparisonPanel";
+import "./workspace.css";
+
+type ImportResult = {
+ templates: Template[];
+ digest: string;
+ conflicts: unknown[];
+ errors: unknown[];
+ differences?: unknown[];
+};
+export function ResearchWorkspace({
+ page,
+ active,
+ action,
+ onAction,
+ onContext,
+}: {
+ page: "templates" | "variants";
+ active: boolean;
+ action: UIAction | null;
+ onAction: (action: UIAction) => void;
+ onContext: (context: PageContext) => void;
+}) {
+ const processedAction = useRef(null);
+ const [assets, setAssets] = useState([]);
+ const [inputs, setInputs] = useState([]);
+ const [inputIds, setInputIds] = useState([]);
+ const [template, setTemplate] = useState(blankTemplate);
+ const [asset, setAsset] = useState(null);
+ const [hypothesis, setHypothesis] = useState("");
+ const [parent, setParent] = useState("");
+ const [method, setMethod] = useState("structure");
+ const [settings, setSettings] = useState(initialSettings);
+ const [neutralizations, setNeutralizations] = useState([]);
+ const [mode, setMode] = useState("all");
+ const [limit, setLimit] = useState(100);
+ const [seed, setSeed] = useState(0);
+ const [experiment, setExperiment] = useState(null);
+ const [history, setHistory] = useState<
+ { id: string; name: string; created_at: string; total: number }[]
+ >([]);
+ const [busy, setBusy] = useState("");
+ const [error, setError] = useState("");
+ const [search, setSearch] = useState("");
+ const [assetPage, setAssetPage] = useState(0);
+ const [historyPage, setHistoryPage] = useState(0);
+ const [assetTotal, setAssetTotal] = useState(0);
+ const [historyTotal, setHistoryTotal] = useState(0);
+ const refreshSequence = useRef(0);
+ const [importText, setImportText] = useState("");
+ const [importResult, setImportResult] = useState(null);
+ const selectedInputs = inputs.filter((input) => inputIds.includes(input.id));
+ const dirty =
+ !asset || JSON.stringify(template) !== JSON.stringify(asset.content);
+ async function task(label: string, action: () => Promise) {
+ setBusy(label);
+ setError("");
+ try {
+ await action();
+ } catch (error) {
+ setError((error as Error).message);
+ } finally {
+ setBusy("");
+ }
+ }
+ async function refresh() {
+ const sequence = ++refreshSequence.current;
+ const [nextAssets, nextInputs, nextHistory] = await Promise.all([
+ api<{ items: Asset[]; total: number }>(
+ `/research/assets?kind=template&limit=25&offset=${assetPage * 25}&q=${encodeURIComponent(search)}`,
+ ),
+ api<{ items: InputSnapshot[] }>("/research/inputs"),
+ api<{ items: typeof history; total: number }>(
+ `/research/experiments?limit=25&offset=${historyPage * 25}`,
+ ),
+ ]);
+ if (sequence !== refreshSequence.current) return;
+ setAssetTotal(nextAssets.total);
+ setHistoryTotal(nextHistory.total);
+ setAssets(nextAssets.items);
+ setInputs(nextInputs.items);
+ setHistory(nextHistory.items);
+ }
+ useEffect(() => {
+ if (active) void task("读取研究素材", refresh);
+ }, [active, search, assetPage, historyPage]);
+ useEffect(() => {
+ if (active)
+ onContext({
+ page,
+ research_asset_id: asset?.id,
+ research_experiment_id: experiment?.id,
+ alpha_id: parent.split(/[,,\s]+/)[0] || undefined,
+ template_input_id: inputIds.length === 1 ? inputIds[0] : undefined,
+ });
+ }, [active, page, asset?.id, experiment?.id, parent, inputIds, onContext]);
+ useEffect(() => {
+ if (!active || !action || processedAction.current === action.nonce) return;
+ processedAction.current = action.nonce;
+ if (action.type === "open_experiment")
+ void task("读取实验", async () =>
+ setExperiment(
+ await api(`/research/experiments/${action.experiment_id}`),
+ ),
+ );
+ if (action.type === "open_template")
+ void task("读取模板", async () => {
+ const next = await api(
+ `/research/assets/${action.asset_id}${action.version ? `?version=${action.version}` : ""}`,
+ );
+ setAsset(next);
+ setTemplate(next.content);
+ });
+ if (action.type === "open_variant") {
+ setParent(action.alpha_id);
+ setMethod("structure");
+ }
+ }, [active, action]);
+ function selectInputs(ids: string[]) {
+ setInputIds(ids);
+ const first = inputs.find((item) => item.id === ids[0]);
+ if (first)
+ setSettings((old) => ({
+ ...old,
+ region: first.scope.region,
+ universe: first.scope.universe,
+ delay: first.scope.delay,
+ }));
+ }
+ async function refreshSettings() {
+ const data = await post<{
+ content: {
+ items: {
+ region: string;
+ universe: string;
+ delay: number;
+ neutralizations: string[];
+ }[];
+ };
+ }>("/catalog/setting-options/refresh");
+ const options = data.content.items
+ .filter(
+ (row) =>
+ row.region === settings.region &&
+ row.universe === settings.universe &&
+ row.delay === settings.delay,
+ )
+ .flatMap((row) => row.neutralizations);
+ setNeutralizations([...new Set(options)]);
+ Toast.success("合法设置已同步");
+ }
+ async function save() {
+ const next = asset
+ ? await api(`/research/assets/${asset.id}`, {
+ method: "PUT",
+ body: JSON.stringify({
+ kind: "template",
+ version: asset.version,
+ content: template,
+ }),
+ })
+ : await post("/research/assets", {
+ kind: "template",
+ content: template,
+ });
+ setAsset(next);
+ setTemplate(next.content);
+ await refresh();
+ Toast.success(`已保存模板 v${next.version}`);
+ }
+ async function generate() {
+ const next = await post("/research/generate", {
+ name: template.name,
+ hypothesis,
+ input_ids: inputIds,
+ parent_alpha_ids: parent ? parent.split(/[,,\s]+/).filter(Boolean) : [],
+ method: page === "variants" ? "structure" : "template",
+ });
+ setAsset(next);
+ setTemplate(next.content);
+ await refresh();
+ }
+ async function expand() {
+ if (dirty) throw new Error("请先保存模板版本,再生成可追溯候选");
+ const next = await post("/research/experiments", {
+ asset_id: asset!.id,
+ version: asset!.version,
+ input_ids: inputIds,
+ hypothesis,
+ settings,
+ mode,
+ limit,
+ seed,
+ parent_alpha_ids: parent ? parent.split(/[,,\s]+/).filter(Boolean) : [],
+ });
+ setExperiment(next);
+ await refresh();
+ }
+ const combination = Object.values(template.variables)
+ .reduce(
+ (size, variable) =>
+ size * BigInt(new Set(variable.values.map(String)).size),
+ 1n,
+ )
+ .toString();
+ return (
+
+
+ {error &&
}
+
+
+
+
+ 研究输入与假设
+ {page === "variants" && (
+ <>
+
+
+
+
+
+ {method === "structure"
+ ? "神笔马良:保留种子与改动依据,研究新的表达式。"
+ : "缘分一道桥:保持表达式,为各目标市场重新固定输入后检查可用范围。"}
+
+
+ >
+ )}
+
+
+ 在数据目录中保存字段选择。跨数据集分别关联输入,跨市场使用目标范围的独立输入。
+
+
+
+ {page !== "variants" || method === "structure" ? (
+
+
+
+ 编辑与展开{asset && ` · v${asset.version}`}
+ {dirty && " · 未保存"}
+
+
+
+
+ {asset && (
+
+
版本记录
+
item.id === asset.id)
+ ?.version || asset.version,
+ },
+ (_, i) => ({ value: i + 1, label: `v${i + 1}` }),
+ )}
+ onChange={(version) =>
+ void task("读取历史版本", async () => {
+ const next = await api(
+ `/research/assets/${asset.id}?version=${version}`,
+ );
+ setAsset(next);
+ setTemplate(next.content);
+ })
+ }
+ />
+
+
+ )}
+
+
+ 组合规模:{combination}
+
+
+
+
+
+
+
+
模拟设置
+
+
+
+ {settings.region || "未选择市场"} /{" "}
+ {settings.universe || "未选择股票池"} / D{settings.delay}
+ (来自固定输入)
+
+
+
+
+
+
+
+ {(["pasteurization", "nanHandling", "maxTrade"] as const).map(
+ (key) => (
+
+ ),
+ )}
+
+
+
+ ) : (
+
+ 目标范围检查
+
+ 保留种子表达式和模拟参数,按各目标输入的市场、股票池与延迟生成设置变体。局部变量不会当作字段;无法核实的字段不会被跳过。
+
+
+
+ )}
+ {experiment && (
+
+ )}
+ {page === "templates" && (
+
+ 导入旧模板
+
+ 粘贴 cnhk 模板 JSON
+ 或模板数组。先预览转换结果,同名模板不会自动覆盖。
+
+
+ )}
+ {page === "variants" && (
+
+ )}
+
+
+
+ );
+}
diff --git a/frontend/src/research/SourceDetails.tsx b/frontend/src/research/SourceDetails.tsx
index be92518..3df5a70 100644
--- a/frontend/src/research/SourceDetails.tsx
+++ b/frontend/src/research/SourceDetails.tsx
@@ -4,9 +4,16 @@ import type { UIAction } from "../ai/types";
import "./style.css";
export const sourceLabel = (kind: string) =>
- ({ chatbox: "Chatbox 研究", manual: "手工研究", ai: "AI 研究(历史)" })[
- kind
- ] ?? kind;
+ ({
+ chatbox: "Chatbox 研究",
+ manual: "手工研究",
+ ai: "AI 研究(历史)",
+ template: "模板研究",
+ variant: "Alpha 变体",
+ feature: "特征工程",
+ pipeline: "研究流水线",
+ quantflow: "QuantFlow",
+ })[kind] ?? kind;
export function SourceDetails({
source,
@@ -25,6 +32,22 @@ export function SourceDetails({
来源引用:{source.reference}
)}
+ {source.research_id &&
+ ["template", "variant", "feature", "pipeline", "quantflow"].includes(
+ source.kind,
+ ) && (
+
+ )}
{source.kind === "chatbox" && source.reference && (