Files
worldquant-alpha-system/frontend/src/pages/AlphaPage.tsx
T

1395 lines
42 KiB
TypeScript
Raw Normal View History

import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Banner,
Button,
Checkbox,
CheckboxGroup,
Empty,
Input,
Modal,
Popover,
Pagination,
Select,
Table,
Tag,
TextArea,
Toast,
Typography,
} from "@douyinfe/semi-ui-19";
import {
IconFilter,
IconSetting,
IconSort,
IconSearch,
IconClose,
IconDownload,
IconUpload,
IconRefresh,
} from "@douyinfe/semi-icons";
import "./AlphaPage.css";
import { formatAlphaMetric } from "../alphaMetrics";
import type { ColumnProps } from "@douyinfe/semi-ui-19/lib/es/table/interface";
import {
api,
correlationLabels,
formatNumber,
formatTime,
patch,
post,
queryString,
stateLabels,
stateOptions,
} from "../api";
import type {
Account,
Alpha,
AlphaPage as Page,
Facets,
Job,
Submission,
} from "../types";
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 { SavedViews } from "../research/SavedViews";
import { sourceLabel } from "../research/SourceDetails";
const metricLabels = {
sharpe: "Sharpe",
fitness: "Fitness",
returns: "Returns",
turnover: "Turnover",
margin: "Margin",
drawdown: "Drawdown",
sub_universe_sharpe: "子股票池 Sharpe",
robust_universe_sharpe: "稳健股票池 Sharpe",
two_year_sharpe: "近两年 Sharpe",
prod_correlation: "平台生产相关性",
pnl: "IS PnL",
};
const metricColorThresholds: Record<string, [number, number, number]> = {
sharpe: [1.5, 1.0, 0.5],
fitness: [0.75, 0.5, 0.25],
returns: [0.15, 0.1, 0.05],
};
/** Preserve the legacy list's strict thresholds; missing values have no rating. */
function metricColor(key: string, value: unknown): string | undefined {
const thresholds = metricColorThresholds[key];
if (!thresholds || typeof value !== "number" || !Number.isFinite(value))
return undefined;
if (value > thresholds[0]) return "var(--semi-color-success)";
if (value > thresholds[1]) return "var(--semi-color-info)";
if (value > thresholds[2]) return "var(--semi-color-warning)";
return "var(--semi-color-danger)";
}
/** Format metrics only for display; filtering and sorting still use raw values. */
function formatMetric(key: string, value: unknown): string {
const headline = formatAlphaMetric(key, value);
if (headline !== undefined) return headline;
if (key !== "margin") return formatNumber(value, 3);
return typeof value === "number" && Number.isFinite(value)
? `${formatNumber(value * 10000, 2)}bps`
: "—";
}
const checkLabels = {
PENDING: "待检查",
PRE_CHECK: "预检通过",
PASS: "检查通过",
FAIL_1: "FAIL=1",
FAIL_2: "FAIL≥2",
};
const initialColumns = [
"name",
"expression",
"region",
"status",
"check_type",
"failed_checks",
"tags",
"sharpe",
"fitness",
"turnover",
"correlation",
"research",
"source",
];
const columnLabels: Record<string, string> = {
source: "研究来源",
name: "Alpha",
expression: "表达式",
region: "地区 / Universe",
status: "平台状态",
sharpe: "Sharpe",
fitness: "Fitness",
returns: "Returns",
turnover: "Turnover",
margin: "Margin",
drawdown: "Drawdown",
research: "研究状态",
tags: "自定义标签",
check_type: "检查结果",
failed_checks: "失败项",
neutralization: "中性化",
synced: "同步时间",
sub_universe_sharpe: metricLabels.sub_universe_sharpe,
robust_universe_sharpe: metricLabels.robust_universe_sharpe,
two_year_sharpe: metricLabels.two_year_sharpe,
prod_correlation: metricLabels.prod_correlation,
pnl: metricLabels.pnl,
language: "语言",
created: "创建时间",
submitted: "提交时间",
correlation: "本地自相关",
};
export function AlphaPage({
account,
taskPanelOpen,
version,
onTask,
onAccount,
active,
overlaySuspended,
chatOffset,
onContext,
action,
onOverlay,
onAction,
}: {
account: Account | null;
taskPanelOpen: boolean;
version: string;
onTask: () => void;
onAccount: () => void;
active: boolean;
overlaySuspended: boolean;
chatOffset: number;
onContext: (context: PageContext) => void;
action: UIAction | null;
onOverlay: () => void;
onAction: (action: WorkspaceAction) => void;
}) {
const [data, setData] = useState<Page>({
items: [],
total: 0,
limit: 25,
offset: 0,
});
const [facets, setFacets] = useState<Facets>({});
const [draft, setDraft] = useState<Record<string, string>>({});
const [filters, setFilters] = useState<Record<string, string>>({});
const [filterOpen, setFilterOpen] = useState(false);
const [selectedViewId, setSelectedViewId] = useState<string | null>(null);
const [submissionBlocked, setSubmissionBlocked] = useState(false);
const [submission, setSubmission] = useState<Submission>("UNSUBMITTED");
const [syncingScope, setSyncingScope] = useState<Submission | null>(null);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(25);
const [sort, setSort] = useState("date_created");
const [direction, setDirection] = useState("desc");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [selected, setSelected] = useState<string[]>([]);
const [visibleColumns, setVisibleColumns] = useState<string[]>(() => {
try {
const stored: unknown = JSON.parse(
localStorage.getItem("alpha-columns") || "null",
);
return Array.isArray(stored)
? [
...new Set([
"name",
...stored.filter(
(v): v is string => typeof v === "string" && v in columnLabels,
),
]),
]
: initialColumns;
} catch {
return initialColumns;
}
});
const [detailId, setDetailId] = useState<string | null>(null);
const [importing, setImporting] = useState(false);
const [idText, setIdText] = useState("");
const [busy, setBusy] = useState("");
const [bulkOpen, setBulkOpen] = useState(false);
const [tagEditor, setTagEditor] = useState<{
id: string;
version: number;
text: string;
} | null>(null);
const [bulkVersions, setBulkVersions] = useState<Record<string, number>>({});
const actionNonce = useRef(0);
const [bulk, setBulk] = useState({
add_tags: "",
remove_tags: "",
state: "",
});
const [localVersion, setLocalVersion] = useState(0);
const tablePanel = useRef<HTMLElement>(null);
const [compact, setCompact] = useState(
() => matchMedia("(max-width: 760px)").matches,
);
useEffect(() => {
const media = matchMedia("(max-width: 760px)");
const change = () => setCompact(media.matches);
media.addEventListener("change", change);
return () => media.removeEventListener("change", change);
}, []);
useEffect(() => {
if (account) {
setPageSize(account.page_size);
setPage(1);
}
}, [account?.page_size]);
const params = useMemo(
() => ({
...filters,
submission,
submission_blocked: submissionBlocked || undefined,
sort,
direction,
limit: pageSize,
offset: (page - 1) * pageSize,
}),
[filters, submission, submissionBlocked, sort, direction, pageSize, page],
);
const query = queryString(params);
useEffect(() => {
if (!active) return;
onContext({
page: "alphas",
alpha_id: detailId,
selected_ids: selected,
filters: Object.fromEntries(
Object.entries(params).filter(
([, value]) => value !== "" && value !== undefined && value !== null,
),
),
});
}, [active, detailId, selected, params, onContext]);
useEffect(() => {
if (!action || actionNonce.current === action.nonce) return;
actionNonce.current = action.nonce;
if (action.type === "open_alpha") {
setDetailId(action.alpha_id);
onOverlay();
} else {
setSelectedViewId(null);
setFilterOpen(false);
const {
sort: nextSort,
direction: nextDirection,
limit: _limit,
offset: _offset,
submission: nextSubmission,
submission_blocked: nextBlocked,
...values
} = action.filters;
const next = Object.fromEntries(
Object.entries(values)
.filter(([, v]) => v !== null && v !== undefined)
.map(([k, v]) => [k, String(v)]),
);
setDraft(next);
setFilters(next);
setPage(1);
setSelected([]);
if (nextSubmission === "SUBMITTED" || nextSubmission === "UNSUBMITTED")
setSubmission(nextSubmission);
setSubmissionBlocked(nextBlocked === true || nextBlocked === "true");
if (nextSort) setSort(String(nextSort));
if (nextDirection) setDirection(String(nextDirection));
}
}, [action, onOverlay]);
const refresh = useCallback(() => setLocalVersion((n) => n + 1), []);
useEffect(() => {
if (!active) return;
const controller = new AbortController();
setLoading(true);
setError("");
Promise.all([
api<Page>(`/alphas?${query}`, { signal: controller.signal }),
api<Facets>("/alphas/facets", { signal: controller.signal }),
])
.then(([results, nextFacets]) => {
if (!controller.signal.aborted) {
setData(results);
setFacets(nextFacets);
}
})
.catch((e) => {
if (!controller.signal.aborted) setError(e.message);
})
.finally(() => {
if (!controller.signal.aborted) setLoading(false);
});
return () => controller.abort();
}, [active, query, version, localVersion]);
useEffect(() => {
setSelected([]);
tablePanel.current?.querySelector(".semi-table-body")?.scrollTo({ top: 0 });
}, [query]);
function updateDraft(key: string, value: unknown) {
setDraft((previous) => ({
...previous,
[key]: value === undefined || value === null ? "" : String(value),
}));
}
function applyFilters() {
setFilterOpen(false);
setFilters({ ...draft });
setPage(1);
}
function clearFilters() {
setFilterOpen(false);
setDraft({});
setFilters({});
setPage(1);
}
function changeSubmission(value: string) {
setSelectedViewId(null);
setFilterOpen(false);
setDirection("desc");
setSubmission(value === "SUBMITTED" ? "SUBMITTED" : "UNSUBMITTED");
setSubmissionBlocked(value === "SUBMISSION_BLOCKED");
setDraft({});
setFilters({});
setPage(1);
setSelected([]);
setSort(value === "SUBMITTED" ? "date_submitted" : "date_created");
}
async function newTask(kind: string, ids: string[] = []) {
setBusy(kind);
try {
const job = await post<Job>("/sync-jobs", {
kind,
alpha_ids: ids,
...(kind === "full_sync" ? { submission: "SUBMITTED" } : {}),
});
setImporting(false);
setIdText("");
if (kind === "pnl_backfill" && job.total === 0)
Toast.success("已提交 Alpha 的 PnL 已齐全,无需补取");
onTask();
} catch (e) {
Toast.error((e as Error).message);
} finally {
setBusy("");
}
}
async function bulkSave() {
setBusy("bulk");
const split = (value: string) =>
value
.split(/[,,]/)
.map((t) => t.trim())
.filter(Boolean);
try {
await patch("/alphas/research/bulk", {
alpha_ids: Object.keys(bulkVersions),
versions: bulkVersions,
add_tags: split(bulk.add_tags),
remove_tags: split(bulk.remove_tags),
state: bulk.state || null,
});
setBulkOpen(false);
setBulk({ add_tags: "", remove_tags: "", state: "" });
refresh();
Toast.success("批量研究记录已更新");
} catch (e) {
Toast.error((e as Error).message);
} finally {
setBusy("");
}
}
async function saveTags() {
if (!tagEditor) return;
setBusy("tags");
try {
await patch(`/alphas/${tagEditor.id}/research`, {
version: tagEditor.version,
tags: tagEditor.text
.split(/[,,]/)
.map((tag) => tag.trim())
.filter(Boolean),
});
setTagEditor(null);
refresh();
Toast.success("自定义标签已保存");
} catch (e) {
Toast.error((e as Error).message);
} finally {
setBusy("");
}
}
const columnDefinitions: ColumnProps<Alpha>[] = [
{
key: "name",
title: "Alpha",
dataIndex: "name",
fixed: "left",
width: 190,
render: (_, row) => (
<button
className="alpha-link"
title={`${row!.id} · ${row!.alpha_type ?? ""}`}
onClick={() => {
setDetailId(row!.id);
onOverlay();
}}
>
<span>
{row!.research.favorite && <Tag size="small">收藏</Tag>}
{row!.name || row!.id}
</span>
{row!.name && <small className="muted">{row!.id}</small>}
</button>
),
},
{
key: "expression",
title: "表达式",
width: 260,
render: (_, row) => (
<code className="expression-cell" title={row!.expression_preview}>
{row!.expression_preview || "—"}
</code>
),
},
{
key: "source",
title: "研究来源",
width: 150,
render: (_, row) =>
row!.source_kinds?.map(sourceLabel).join("、") || "暂无本地来源",
},
{
key: "region",
title: "地区 / Universe",
width: 155,
render: (_, row) => (
<div className="stacked-cell">
<span>{row!.region ?? "—"}</span>
<span className="muted"> / {row!.universe ?? "—"}</span>
</div>
),
},
{
key: "status",
title: "平台状态",
width: 150,
render: (_, row) => (
<div className="stacked-cell">
<span>{row!.status ?? "未提供"}</span>
<span className="muted">
{" "}
· {row!.stage ?? "—"}
{row!.hidden ? " · 隐藏" : ""}
</span>
</div>
),
},
...Object.entries(metricLabels).map(
([key, title]): ColumnProps<Alpha> => ({
key,
title,
width:
key in
{
sub_universe_sharpe: 1,
robust_universe_sharpe: 1,
two_year_sharpe: 1,
prod_correlation: 1,
}
? 155
: 112,
align: "right",
render: (_, row) => (
<span
className="numeric"
style={{ color: metricColor(key, row![key as keyof Alpha]) }}
>
{formatMetric(key, row![key as keyof Alpha])}
</span>
),
}),
),
{
key: "check_type",
title: "检查结果",
width: 125,
render: (_, row) => (
<Tag
color={
row!.check_type?.startsWith("FAIL")
? "red"
: row!.check_type === "PASS"
? "green"
: row!.check_type === "PRE_CHECK"
? "blue"
: "grey"
}
>
{checkLabels[row!.check_type] || "待检查"}
</Tag>
),
},
{
key: "failed_checks",
title: "失败项",
width: 230,
render: (_, row) => (
<Typography.Text
className="alpha-failed-checks"
size="inherit"
style={{ width: "100%" }}
ellipsis={{
showTooltip: {
opts: {
content: row!.failed_checks?.join("\n"),
style: {
whiteSpace: "pre-line",
overflowWrap: "anywhere",
maxWidth: 420,
},
},
},
}}
>
{row!.failed_checks?.join("、") || "—"}
</Typography.Text>
),
},
{
key: "neutralization",
title: "中性化",
width: 155,
render: (_, row) => row!.neutralization || "—",
},
{
key: "research",
fixed: "right",
title: "研究状态",
width: 115,
render: (_, row) => (
<Tag
color={
row!.research.state === "candidate"
? "green"
: row!.research.state === "optimizing"
? "blue"
: "grey"
}
>
{stateLabels[row!.research.state]}
</Tag>
),
},
{
key: "tags",
title: "自定义标签",
width: 180,
render: (_, row) => (
<div className="inline-tags">
{row!.research.tags.map((t) => (
<Tag key={t}>{t}</Tag>
))}
<button
className="text-link"
aria-label={`编辑 ${row!.id} 的标签`}
onClick={() => {
setTagEditor({
id: row!.id,
version: row!.research.version,
text: row!.research.tags.join(", "),
});
onOverlay();
}}
>
编辑标签
</button>
</div>
),
},
{ key: "language", title: "语言", width: 125, dataIndex: "language" },
{
key: "correlation",
title: "本地自相关",
width: 160,
render: (_, row) => {
const value = row!.local_correlation;
return value ? (
<span
title={`计算于 ${formatTime(value.calculated_at, account?.timezone)}`}
style={{
color:
value.stale ||
value.max_correlation === null ||
!Number.isFinite(value.max_correlation)
? "var(--semi-color-text-2)"
: value.max_correlation > 0.75
? "var(--semi-color-danger)"
: value.max_correlation >= 0.7
? "rgb(var(--semi-yellow-7))"
: "var(--semi-color-success)",
}}
>
{value.stale ? "待重算" : correlationLabels[value.status]}
{value.max_correlation !== null &&
` · ${formatNumber(value.max_correlation, 3)}`}
</span>
) : (
<span className="muted">未检测</span>
);
},
},
{
key: "synced",
title: "同步时间",
width: 165,
render: (_, row) => formatTime(row!.synced_at, account?.timezone),
},
{
key: "submitted",
title: "提交时间",
width: 165,
render: (_, row) => formatTime(row!.date_submitted, account?.timezone),
},
{
key: "created",
title: "创建时间",
width: 165,
render: (_, row) => formatTime(row!.date_created, account?.timezone),
},
];
// Submitted alphas always expose the platform production correlation, including saved views.
const displayedColumns =
submission === "SUBMITTED"
? [...new Set([...visibleColumns, "prod_correlation"])]
: visibleColumns;
const columns = columnDefinitions
.filter((column) => displayedColumns.includes(String(column.key)))
.map((column) => (compact ? { ...column, fixed: undefined } : column));
const activeFilterCount = Object.values(filters).filter(Boolean).length;
const options = (key: string) =>
(Array.isArray(facets[key]) ? (facets[key] as string[]) : []).map(
(value) => ({ value, label: value }),
);
const connected =
account?.connection_status === "connected" ||
account?.connection_status === "expired";
const filterPanel = (
<form
className="alpha-filter-panel"
aria-label="Alpha 筛选条件"
onSubmit={(e) => {
e.preventDefault();
applyFilters();
}}
>
<header className="alpha-filter-header">
<div>
<h2>筛选 Alpha</h2>
<p className="muted">设置搜索和筛选条件,查询后应用到当前视图。</p>
</div>
<Button
icon={<IconClose aria-hidden="true" />}
theme="borderless"
type="tertiary"
aria-label="关闭筛选"
onClick={() => setFilterOpen(false)}
/>
</header>
<div className="alpha-filter-body">
<div className="alpha-filter-fields">
<label className="alpha-filter-search">
搜索
<Input
prefix={<IconSearch aria-hidden="true" />}
aria-label="搜索 Alpha"
placeholder="搜索 ID、名称或表达式"
value={draft.q ?? ""}
onChange={(v) => updateDraft("q", v)}
showClear
/>
</label>
<label>
地区
<Select
aria-label="地区"
placeholder="全部地区"
showClear
value={draft.region || undefined}
optionList={options("region")}
onChange={(v) => updateDraft("region", v)}
/>
</label>
<label>
平台状态
<Select
aria-label="平台状态"
placeholder="全部平台状态"
showClear
value={draft.status || undefined}
optionList={options("status")}
onChange={(v) => updateDraft("status", v)}
/>
</label>
<label>
研究状态
<Select
aria-label="研究状态筛选"
placeholder="全部研究状态"
showClear
value={draft.research_state || undefined}
optionList={stateOptions}
onChange={(v) => updateDraft("research_state", v)}
/>
</label>
<label>
检查结果
<Select
aria-label="检查结果筛选"
placeholder="全部检查结果"
showClear
value={draft.check_type || undefined}
optionList={Object.entries(checkLabels).map(([value, label]) => ({
value,
label,
}))}
onChange={(v) => updateDraft("check_type", v)}
/>
</label>
<label>
自定义标签
<Select
aria-label="自定义标签筛选"
showClear
filter
value={draft.tag || undefined}
optionList={options("tags")}
placeholder="全部标签"
onChange={(v) => updateDraft("tag", v)}
/>
</label>
</div>
<details className="advanced-filters">
<summary>更多筛选</summary>
<div className="alpha-filter-fields">
<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 类型"],
["language", "语言"],
["stage", "IS / OS"],
].map(([key, label]) => (
<label key={key}>
{label}
<Select
showClear
aria-label={label}
value={draft[key] || undefined}
optionList={options(key)}
placeholder="全部"
onChange={(v) => updateDraft(key, v)}
/>
</label>
))}
<label>
平台隐藏状态
<Select
showClear
value={draft.hidden || undefined}
optionList={[
{ value: "false", label: "可见" },
{ value: "true", label: "隐藏" },
]}
placeholder="全部"
onChange={(v) => updateDraft("hidden", v)}
/>
</label>
<label>
中性化
<Input
aria-label="中性化筛选"
value={draft.neutralization || ""}
placeholder="例如 INDUSTRY"
onChange={(v) => updateDraft("neutralization", v)}
/>
</label>
<label>
创建时间起(UTC)
<Input
type="date"
value={draft.created_from?.slice(0, 10) || ""}
onChange={(v) =>
updateDraft("created_from", v ? `${v}T00:00:00Z` : "")
}
/>
</label>
<label>
创建时间止(UTC)
<Input
type="date"
value={draft.created_to?.slice(0, 10) || ""}
onChange={(v) =>
updateDraft("created_to", v ? `${v}T23:59:59.999999Z` : "")
}
/>
</label>
{Object.entries(metricLabels).map(([key, label]) => (
<label key={key}>
{label}
<div className="range-inputs">
<Input
type="number"
aria-label={`${label} 最小值`}
placeholder="最小值"
value={draft[`${key}_min`] || ""}
onChange={(v) => updateDraft(`${key}_min`, v)}
/>
<span>—</span>
<Input
type="number"
aria-label={`${label} 最大值`}
placeholder="最大值"
value={draft[`${key}_max`] || ""}
onChange={(v) => updateDraft(`${key}_max`, v)}
/>
</div>
</label>
))}
<Checkbox
checked={draft.favorite === "true"}
onChange={(e) =>
updateDraft("favorite", e.target.checked ? "true" : "")
}
>
只看收藏
</Checkbox>
</div>
<p className="muted">
数值筛选使用平台原始值,例如 Turnover 0.15 表示 15%。
检查结果仅汇总已同步的平台检查;预检通过不代表可以提交,活动资格可用自定义标签记录。
</p>
</details>
</div>
<footer className="alpha-filter-footer">
<Button type="tertiary" theme="borderless" onClick={clearFilters}>
重置
</Button>
<Button htmlType="submit" theme="solid">
查询
</Button>
</footer>
</form>
);
return (
<>
{!connected && (
<Banner
type="info"
description={
<span>
连接 WorldQuant 后即可同步平台数据。
<button className="text-link" onClick={onAccount}>
前往个人信息
</button>
</span>
}
/>
)}
{error && <Banner type="danger" description={error} />}
<section className="library-panel" ref={tablePanel}>
<SavedViews
submission={submission}
submissionBlocked={submissionBlocked}
selectedId={selectedViewId}
onSelect={setSelectedViewId}
onSubmissionChange={changeSubmission}
suspended={overlaySuspended || !active}
onOverlay={onOverlay}
filters={{
...filters,
submission,
submission_blocked: submissionBlocked || undefined,
sort,
direction,
limit: pageSize,
}}
columns={displayedColumns}
onRestore={(view) => {
setFilterOpen(false);
const {
submission: savedSubmission,
submission_blocked: savedBlocked,
sort: savedSort,
direction: savedDirection,
limit,
offset: _offset,
...rest
} = view.filters;
const values = Object.fromEntries(
Object.entries(rest)
.filter(([, v]) => v !== null && v !== undefined)
.map(([k, v]) => [k, String(v)]),
);
setFilters(values);
setDraft(values);
setPage(1);
setSelected([]);
setSubmission(
savedSubmission === "SUBMITTED" ? "SUBMITTED" : "UNSUBMITTED",
);
setSubmissionBlocked(
savedBlocked === true || savedBlocked === "true",
);
setSort(String(savedSort || "date_created"));
setDirection(String(savedDirection || "desc"));
if (typeof limit === "number") setPageSize(limit);
const next = [
...new Set([
"name",
...view.columns.filter((c) => c in columnLabels),
]),
];
setVisibleColumns(next);
localStorage.setItem("alpha-columns", JSON.stringify(next));
}}
/>
<div
className="table-toolbar alpha-toolbar"
aria-label="Alpha 表格工具栏"
>
<div className="inline-actions alpha-view-tools">
<Popover
trigger="custom"
position="bottomLeft"
visible={filterOpen && active && !overlaySuspended}
onClickOutSide={() => setFilterOpen(false)}
onEscKeyDown={() => setFilterOpen(false)}
content={filterPanel}
>
<Button
icon={<IconFilter aria-hidden="true" />}
type={filterOpen || activeFilterCount ? "primary" : "tertiary"}
theme={filterOpen || activeFilterCount ? "light" : "borderless"}
aria-label="筛选 Alpha"
onClick={() => {
setFilterOpen((open) => !open);
onOverlay();
}}
>
筛选
{activeFilterCount > 0 && (
<span className="alpha-filter-count">
{activeFilterCount}
</span>
)}
</Button>
</Popover>
<Popover
trigger="click"
position="bottomRight"
content={
<div className="columns-picker">
<strong>显示列</strong>
<CheckboxGroup
direction="vertical"
value={displayedColumns}
options={Object.entries(columnLabels).map(
([value, label]) => ({
value,
label,
disabled:
value === "name" ||
(submission === "SUBMITTED" &&
value === "prod_correlation"),
}),
)}
onChange={(values) => {
const next = values.map(String);
setVisibleColumns(next);
localStorage.setItem(
"alpha-columns",
JSON.stringify(next),
);
}}
/>
</div>
}
>
<Button
icon={<IconSetting aria-hidden="true" />}
theme="borderless"
type="tertiary"
aria-label="显示列设置"
>
列设置
</Button>
</Popover>
<Popover
trigger="click"
position="bottomLeft"
content={
<div className="alpha-sort-panel">
<h2>排序</h2>
<Select
aria-label="排序字段"
size="small"
value={sort}
optionList={[
{ value: "date_created", label: "创建时间" },
{ value: "date_submitted", label: "提交时间" },
{ value: "synced_at", label: "同步时间" },
...Object.entries(metricLabels).map(([value, label]) => ({
value,
label,
})),
{ value: "name", label: "名称" },
]}
onChange={(v) => {
setSort(String(v));
setPage(1);
}}
/>
<Select
aria-label="排序方向"
size="small"
value={direction}
optionList={[
{ value: "desc", label: "降序" },
{ value: "asc", label: "升序" },
]}
onChange={(v) => {
setDirection(String(v));
setPage(1);
}}
/>
</div>
}
>
<Button
icon={<IconSort aria-hidden="true" />}
theme="borderless"
type="tertiary"
aria-label="排序设置"
>
排序
</Button>
</Popover>
</div>
<div className="inline-actions alpha-table-actions">
<a href={`/api/v1/alphas/export?${query}`}>
<Button
icon={<IconDownload aria-hidden="true" />}
theme="borderless"
type="tertiary"
>
导出
</Button>
</a>
<Button
icon={<IconUpload aria-hidden="true" />}
theme="borderless"
type="tertiary"
onClick={() => {
setImporting(true);
onOverlay();
}}
>
导入 Alpha ID
</Button>
<Button
icon={<IconRefresh aria-hidden="true" />}
theme="solid"
disabled={!connected}
onClick={() => {
setSyncingScope(submission);
onOverlay();
}}
>
按天同步
</Button>
{submission === "SUBMITTED" && (
<Button
size="small"
loading={busy === "pnl_backfill"}
disabled={!connected || Boolean(busy)}
title="补取全部本地已提交 Alpha 缺失的 PnL,不受筛选和分页影响"
onClick={() => void newTask("pnl_backfill")}
>
检查pnl
</Button>
)}
{submission === "SUBMITTED" && (
<Button
size="small"
loading={busy === "full_sync"}
disabled={!connected}
onClick={() => void newTask("full_sync")}
>
全量同步已提交
</Button>
)}
</div>
</div>
{selected.length > 0 && (
<div className="alpha-selection-actions inline-actions">
<span className="selection-text">已选择 {selected.length} 条</span>
<Button
type="tertiary"
size="small"
loading={busy === "self_correlation"}
onClick={() => void newTask("self_correlation", selected)}
>
检测自相关
</Button>
<Button
type="tertiary"
size="small"
onClick={() => {
setBulkVersions(
Object.fromEntries(
data.items
.filter((row) => selected.includes(row.id))
.map((row) => [row.id, row.research.version]),
),
);
setBulkOpen(true);
onOverlay();
}}
>
批量编辑
</Button>
<Button
type="tertiary"
size="small"
disabled={!connected}
loading={busy === "alpha_refresh"}
onClick={() => void newTask("alpha_refresh", selected)}
>
刷新选中
</Button>
<Button
size="small"
theme="borderless"
type="tertiary"
onClick={() => setSelected([])}
>
取消选择
</Button>
</div>
)}
<Table<Alpha>
columns={columns}
dataSource={data.items}
rowKey="id"
loading={loading}
className="alpha-table"
size="small"
scroll={{
y: "100%",
x: columns.reduce((sum, c) => sum + Number(c.width || 120), 0) + 60,
}}
rowSelection={{
selectedRowKeys: selected,
onChange: (keys) => setSelected((keys ?? []).map(String)),
fixed: !compact,
}}
pagination={false}
empty={
<Empty
className="alpha-empty"
title={
activeFilterCount
? "没有符合条件的 Alpha"
: "从第一个 Alpha 开始"
}
description={
activeFilterCount
? "调整筛选条件,探索更多信号。"
: "连接 WorldQuant 并同步数据,或通过 Alpha ID 导入已有研究。"
}
/>
}
/>
<footer className="table-pagination" aria-label="Alpha 分页">
<div className="alpha-table-meta">
<span className="muted">
{submissionBlocked
? "提交受阻"
: submission === "UNSUBMITTED"
? "待提交"
: "已提交"}{" "}
Alpha{" "}
<span className="count-pill">{data.total.toLocaleString()}</span>{" "}
条
</span>
<span className="alpha-last-sync">
最近数据更新{" "}
<span>
{formatTime(
typeof facets.last_sync === "string"
? facets.last_sync
: null,
account?.timezone,
)}
</span>
</span>
</div>
<Pagination
size={compact ? "small" : "default"}
currentPage={page}
pageSize={pageSize}
total={data.total}
showSizeChanger
pageSizeOpts={[25, 50, 100]}
preventPageChangeOnPageSizeChange
onPageChange={setPage}
onPageSizeChange={(size) => {
setPageSize(size);
setPage(1);
}}
/>
</footer>
</section>
<Modal
title={`编辑自定义标签 · ${tagEditor?.id || ""}`}
visible={Boolean(tagEditor) && !overlaySuspended}
onCancel={() => setTagEditor(null)}
confirmLoading={busy === "tags"}
okText="保存标签"
onOk={() => void saveTags()}
>
<p>
用逗号分隔多个标签,例如
PPAC、待活动提交。标签保存在本地,平台同步不会覆盖。
</p>
<Input
aria-label="自定义标签"
value={tagEditor?.text || ""}
placeholder="例如 PPAC, 待活动提交"
onChange={(text) =>
setTagEditor((current) =>
current ? { ...current, text } : current,
)
}
/>
</Modal>
<Modal
title="导入 Alpha ID"
visible={importing && !overlaySuspended}
onCancel={() => setImporting(false)}
confirmLoading={busy === "alpha_refresh"}
okText="开始导入"
onOk={() => {
const ids = [...new Set(idText.split(/[\s,,]+/).filter(Boolean))];
if (!ids.length || ids.length > 100) {
Toast.warning("每次请输入 1–100 个 Alpha ID");
return;
}
void newTask("alpha_refresh", ids);
}}
>
<p>
读取指定 Alpha 的平台详情。每次最多 100 个,使用空格、逗号或换行分隔。
</p>
<TextArea
aria-label="Alpha IDs"
value={idText}
onChange={setIdText}
rows={6}
placeholder="例如:aB12cD34"
/>
</Modal>
<Modal
title={`批量编辑 ${Object.keys(bulkVersions).length} 条研究记录`}
visible={bulkOpen && !overlaySuspended}
onCancel={() => setBulkOpen(false)}
confirmLoading={busy === "bulk"}
onOk={() => void bulkSave()}
okText="保存修改"
>
<label>
添加标签
<Input
aria-label="批量添加标签"
placeholder="用逗号分隔,例如 PPAC, 待活动提交"
value={bulk.add_tags}
onChange={(add_tags) => setBulk({ ...bulk, add_tags })}
/>
</label>
<label>
移除标签
<Input
aria-label="批量移除标签"
value={bulk.remove_tags}
onChange={(remove_tags) => setBulk({ ...bulk, remove_tags })}
/>
</label>
<label>
研究状态
<Select
showClear
placeholder="保持现有状态"
value={bulk.state || undefined}
optionList={stateOptions}
onChange={(state) =>
setBulk({ ...bulk, state: state ? String(state) : "" })
}
/>
</label>
</Modal>
{syncingScope && (
<AlphaSyncDialog
submission={syncingScope}
suspended={overlaySuspended}
onClose={() => setSyncingScope(null)}
onTask={onTask}
/>
)}
<AlphaDetail
onAction={onAction}
suspended={overlaySuspended}
chatOffset={chatOffset}
taskPanelOpen={taskPanelOpen}
id={detailId}
version={`${version}:${localVersion}`}
timezone={account?.timezone}
onClose={() => setDetailId(null)}
onSaved={refresh}
onTask={onTask}
/>
</>
);
}