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

1356 lines
41 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 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)";
}
/** Convert Margin only for display; filtering and sorting still use raw values. */
function formatMetric(key: string, value: unknown): string {
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 [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,
sort,
direction,
limit: pageSize,
offset: (page - 1) * pageSize,
}),
[filters, submission, 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,
...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);
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 as Submission);
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"
: "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)}`}
>
{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}
selectedId={selectedViewId}
onSelect={setSelectedViewId}
onSubmissionChange={changeSubmission}
suspended={overlaySuspended || !active}
onOverlay={onOverlay}
filters={{ ...filters, submission, sort, direction, limit: pageSize }}
columns={displayedColumns}
onRestore={(view) => {
setFilterOpen(false);
const {
submission: savedSubmission,
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",
);
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">
{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}
/>
</>
);
}