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

859 lines
25 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,
Select,
Table,
Tag,
TextArea,
Toast,
} from "@douyinfe/semi-ui-19";
import {
IconSearch,
IconRefresh,
IconDownload,
IconPlus,
IconSetting,
IconStar,
} from "@douyinfe/semi-icons";
import type { ColumnProps } from "@douyinfe/semi-ui-19/lib/es/table/interface";
import {
api,
formatNumber,
formatTime,
patch,
post,
queryString,
stateLabels,
stateOptions,
} from "../api";
import type { Account, Alpha, AlphaPage as Page, Facets } from "../types";
import { AlphaDetail } from "../components/AlphaDetail";
import type { PageContext, UIAction } from "../ai/types";
const metricLabels = {
sharpe: "Sharpe",
fitness: "Fitness",
returns: "Returns",
turnover: "Turnover",
margin: "Margin",
drawdown: "Drawdown",
};
const initialColumns = [
"name",
"expression",
"region",
"status",
"sharpe",
"fitness",
"turnover",
"research",
];
const columnLabels: Record<string, string> = {
name: "Alpha",
expression: "表达式",
region: "地区 / Universe",
status: "平台状态",
sharpe: "Sharpe",
fitness: "Fitness",
returns: "Returns",
turnover: "Turnover",
margin: "Margin",
drawdown: "Drawdown",
research: "研究状态",
tags: "本地标签",
language: "语言",
created: "创建时间",
};
export function AlphaPage({
account,
taskPanelOpen,
version,
onTask,
onAccount,
active,
overlaySuspended,
chatOffset,
onContext,
action,
onOverlay,
}: {
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;
}) {
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 [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 [bulkVersions, setBulkVersions] = useState<Record<string, number>>({});
const actionNonce = useRef(0);
const [bulk, setBulk] = useState({
add_tags: "",
remove_tags: "",
state: "",
});
const [localVersion, setLocalVersion] = useState(0);
useEffect(() => {
if (account) {
setPageSize(account.page_size);
setPage(1);
}
}, [account?.page_size]);
const params = useMemo(
() => ({
...filters,
sort,
direction,
limit: pageSize,
offset: (page - 1) * pageSize,
}),
[filters, 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 {
const {
sort: nextSort,
direction: nextDirection,
limit: _limit,
offset: _offset,
...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 (nextSort) setSort(String(nextSort));
if (nextDirection) setDirection(String(nextDirection));
}
}, [action, onOverlay]);
const refresh = useCallback(() => setLocalVersion((n) => n + 1), []);
useEffect(() => {
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();
}, [query, version, localVersion]);
useEffect(() => {
setSelected([]);
}, [query]);
function updateDraft(key: string, value: unknown) {
setDraft((previous) => ({
...previous,
[key]: value === undefined || value === null ? "" : String(value),
}));
}
function applyFilters() {
setFilters({ ...draft });
setPage(1);
}
function clearFilters() {
setDraft({});
setFilters({});
setPage(1);
}
async function newTask(kind: string, ids: string[] = []) {
setBusy(kind);
try {
await post("/sync-jobs", { kind, alpha_ids: ids });
setImporting(false);
setIdText("");
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("");
}
}
const columnDefinitions: ColumnProps<Alpha>[] = [
{
key: "name",
title: "Alpha",
dataIndex: "name",
fixed: "left",
width: 190,
render: (_, row) => (
<button
className="alpha-link"
onClick={() => {
setDetailId(row!.id);
onOverlay();
}}
>
<span>
{row!.research.favorite && <IconStar className="favorite-icon" />}
{row!.name || row!.id}
</span>
<small>
{row!.name ? row!.id : (row!.alpha_type ?? "未提供类型")}
</small>
</button>
),
},
{
key: "expression",
title: "表达式",
width: 260,
render: (_, row) => (
<code className="expression-cell" title={row!.expression_preview}>
{row!.expression_preview || "—"}
</code>
),
},
{
key: "region",
title: "地区 / Universe",
width: 155,
render: (_, row) => (
<div className="stacked-cell">
<span>{row!.region ?? "—"}</span>
<small>{row!.universe ?? "—"}</small>
</div>
),
},
{
key: "status",
title: "平台状态",
width: 150,
render: (_, row) => (
<div className="stacked-cell">
<span>{row!.status ?? "未提供"}</span>
<small>
{row!.stage ?? "—"}
{row!.hidden ? " · 已隐藏" : ""}
</small>
</div>
),
},
...Object.entries(metricLabels).map(
([key, title]): ColumnProps<Alpha> => ({
key,
title,
width: 112,
align: "right",
render: (_, row) => (
<span className="numeric">
{formatNumber(row![key as keyof Alpha], key === "margin" ? 6 : 3)}
</span>
),
}),
),
{
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>
))}
</div>
),
},
{ key: "language", title: "语言", width: 125, dataIndex: "language" },
{
key: "created",
title: "创建时间",
width: 165,
render: (_, row) => formatTime(row!.date_created, account?.timezone),
},
];
const columns = columnDefinitions.filter((column) =>
visibleColumns.includes(String(column.key)),
);
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";
return (
<>
<div className="page-heading">
<div>
<div className="eyebrow">ALPHA LIBRARY</div>
<h1>Alpha 管理</h1>
<p>集中查看平台信号,记录每一步研究判断。</p>
</div>
<div className="inline-actions">
<Button icon={<IconPlus />} onClick={() => setImporting(true)}>
导入 Alpha ID
</Button>
<Button
theme="solid"
icon={<IconRefresh />}
loading={busy === "full_sync"}
disabled={!connected}
onClick={() => void newTask("full_sync")}
>
同步平台数据
</Button>
</div>
</div>
<div className="library-stats">
<div>
<span>已同步 Alpha</span>
<strong>{Number(facets.total ?? 0).toLocaleString()}</strong>
</div>
<div>
<span>我的收藏</span>
<strong>{Number(facets.favorites ?? 0).toLocaleString()}</strong>
</div>
<div>
<span>覆盖地区</span>
<strong>
{Array.isArray(facets.region) ? facets.region.length : 0}
</strong>
</div>
<div className="last-sync">
<span>最近数据更新</span>
<strong>
{formatTime(
typeof facets.last_sync === "string" ? facets.last_sync : null,
account?.timezone,
)}
</strong>
</div>
</div>
{!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">
<form
className="filter-panel"
onSubmit={(e) => {
e.preventDefault();
applyFilters();
}}
>
<div className="primary-filters">
<Input
aria-label="搜索 Alpha"
prefix={<IconSearch />}
placeholder="搜索 ID、名称或表达式"
value={draft.q ?? ""}
onChange={(v) => updateDraft("q", v)}
showClear
/>
<Select
aria-label="地区"
placeholder="全部地区"
showClear
value={draft.region || undefined}
optionList={options("region")}
onChange={(v) => updateDraft("region", v)}
/>
<Select
aria-label="平台状态"
placeholder="全部平台状态"
showClear
value={draft.status || undefined}
optionList={options("status")}
onChange={(v) => updateDraft("status", v)}
/>
<Select
aria-label="研究状态筛选"
placeholder="全部研究状态"
showClear
value={draft.research_state || undefined}
optionList={stateOptions}
onChange={(v) => updateDraft("research_state", v)}
/>
<Button htmlType="submit" theme="solid">
查询
</Button>
<Button theme="borderless" onClick={clearFilters}>
重置
</Button>
</div>
<details className="advanced-filters">
<summary>
更多筛选{" "}
{activeFilterCount ? `· ${activeFilterCount} 项已应用` : ""}
</summary>
<div className="advanced-grid">
{[
["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>
本地标签
<Select
showClear
filter
value={draft.tag || undefined}
optionList={options("tags")}
placeholder="全部标签"
onChange={(v) => updateDraft("tag", 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>
</form>
<div className="table-toolbar">
<div>
<strong>全部 Alpha</strong>
<span className="count-pill">{data.total.toLocaleString()}</span>
{selected.length > 0 && (
<span className="selection-text">
已选择 {selected.length} 条
</span>
)}
</div>
<div className="inline-actions">
{selected.length > 0 && (
<>
<Button
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
size="small"
disabled={!connected}
loading={busy === "alpha_refresh"}
onClick={() => void newTask("alpha_refresh", selected)}
>
刷新选中
</Button>
</>
)}
<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);
}}
/>
<a href={`/api/v1/alphas/export?${query}`}>
<Button icon={<IconDownload />} size="small">
导出
</Button>
</a>
<Popover
trigger="click"
position="bottomRight"
content={
<div className="columns-picker">
<strong>显示列</strong>
<CheckboxGroup
direction="vertical"
value={visibleColumns}
options={Object.entries(columnLabels).map(
([value, label]) => ({
value,
label,
disabled: value === "name",
}),
)}
onChange={(values) => {
const next = values.map(String);
setVisibleColumns(next);
localStorage.setItem(
"alpha-columns",
JSON.stringify(next),
);
}}
/>
</div>
}
>
<Button
aria-label="显示列设置"
icon={<IconSetting />}
size="small"
/>
</Popover>
</div>
</div>
<Table<Alpha>
columns={columns}
dataSource={data.items}
rowKey="id"
loading={loading}
size="middle"
scroll={{
y: "max(260px, calc(100vh - 670px))",
x: columns.reduce((sum, c) => sum + Number(c.width || 120), 0) + 60,
}}
rowSelection={{
selectedRowKeys: selected,
onChange: (keys) => setSelected((keys ?? []).map(String)),
fixed: true,
}}
pagination={{
currentPage: page,
pageSize,
total: data.total,
showSizeChanger: true,
pageSizeOpts: [25, 50, 100],
onPageChange: (next) => setPage(next),
onPageSizeChange: (size) => {
setPageSize(size);
setPage(1);
},
}}
empty={
<Empty
className="alpha-empty"
image={<div className="empty-alpha">α</div>}
title={
activeFilterCount
? "没有符合条件的 Alpha"
: "从第一个 Alpha 开始"
}
description={
activeFilterCount
? "调整筛选条件,探索更多信号。"
: "连接 WorldQuant 并同步数据,或通过 Alpha ID 导入已有研究。"
}
/>
}
/>
</section>
<p className="page-footnote">
平台数据与本地研究记录独立保存 · 记录不因再次同步而覆盖
</p>
<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="用逗号分隔"
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>
<AlphaDetail
suspended={overlaySuspended}
chatOffset={chatOffset}
taskPanelOpen={taskPanelOpen}
id={detailId}
version={`${version}:${localVersion}`}
timezone={account?.timezone}
onClose={() => setDetailId(null)}
onSaved={refresh}
onTask={onTask}
/>
</>
);
}