Files
worldquant-alpha-system/frontend/src/components/AlphaDetail.tsx
T

417 lines
12 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 { useEffect, useRef, useState } from "react";
import {
Banner,
Button,
Checkbox,
Descriptions,
Empty,
Input,
Select,
SideSheet,
Spin,
TabPane,
Tabs,
Tag,
TextArea,
Toast,
} from "@douyinfe/semi-ui-19";
import {
api,
ApiError,
displayValue,
formatTime,
patch,
post,
stateOptions,
} from "../api";
import type {
AlphaDetail as Detail,
Pnl,
Research,
ResearchState,
} from "../types";
import { PnlChart } from "./PnlChart";
export function AlphaDetail({
id,
version,
timezone,
taskPanelOpen,
onClose,
onSaved,
onTask,
suspended = false,
chatOffset = 0,
}: {
id: string | null;
version: string;
timezone?: string;
taskPanelOpen: boolean;
onClose: () => void;
onSaved: () => void;
onTask: () => void;
suspended?: boolean;
chatOffset?: number;
}) {
const [detail, setDetail] = useState<Detail | null>(null);
const [pnl, setPnl] = useState<Pnl | null>(null);
const [research, setResearch] = useState<Research | null>(null);
const [tagText, setTagText] = useState("");
const [error, setError] = useState("");
const [saving, setSaving] = useState(false);
const [conflict, setConflict] = useState(false);
const [tab, setTab] = useState("overview");
const dirty = useRef(false);
useEffect(() => {
dirty.current = false;
setConflict(false);
setDetail(null);
setResearch(null);
setPnl(null);
setError("");
setTab("overview");
}, [id]);
useEffect(() => {
let active = true;
if (id)
api<Detail>(`/alphas/${id}`)
.then((value) => {
if (active) {
setDetail(value);
setError("");
if (!dirty.current) {
setResearch(value.research);
setTagText(value.research.tags.join(", "));
} else if (
research &&
value.research.version !== research.version
) {
setConflict(true);
}
}
})
.catch((e) => {
if (active) setError(e.message);
});
return () => {
active = false;
};
}, [id, version]);
useEffect(() => {
let active = true;
if (id && tab === "pnl")
api<Pnl>(`/alphas/${id}/pnl`)
.then((p) => {
if (active) setPnl(p);
})
.catch((e) => {
if (active) Toast.error(e.message);
});
return () => {
active = false;
};
}, [id, tab, version]);
async function fetchPnl() {
try {
await post("/sync-jobs", { kind: "pnl_refresh", alpha_ids: [id] });
onTask();
} catch (e) {
Toast.error((e as Error).message);
}
}
async function save() {
if (!research) return;
setSaving(true);
try {
const { updated_at: _, ...editable } = research;
await patch(`/alphas/${id}/research`, {
...editable,
tags: tagText
.split(/[,,]/)
.map((s) => s.trim())
.filter(Boolean),
});
dirty.current = false;
setConflict(false);
Toast.success("研究记录已保存");
onSaved();
} catch (e) {
Toast.error((e as Error).message);
if (e instanceof ApiError && e.status === 409) {
setConflict(true);
try {
setDetail(await api<Detail>(`/alphas/${id}`));
} catch {
/* Preserve the draft if refresh fails. */
}
}
} finally {
setSaving(false);
}
}
return (
<SideSheet
closeOnEsc={!taskPanelOpen}
className="alpha-detail"
visible={Boolean(id) && !suspended}
width={Math.min(800, window.innerWidth - chatOffset)}
style={{ right: chatOffset }}
maskStyle={{ right: chatOffset }}
title={
<span>
Alpha 详情 <code className="muted">{id}</code>
</span>
}
onCancel={onClose}
>
{error ? (
<Banner type="danger" description={error} />
) : !detail || !research ? (
<Spin />
) : (
<>
{conflict && (
<Banner
type="warning"
description={
<span>
研究记录已被其他操作更新,你的草稿已保留。请先复制需要保留的内容,再
<button
className="text-link"
onClick={() => {
if (detail) {
dirty.current = false;
setResearch(detail.research);
setTagText(detail.research.tags.join(", "));
setConflict(false);
}
}}
>
载入最新记录
</button>
。
</span>
}
/>
)}
<div className="detail-title">
<div>
<h2>{detail.name || detail.id}</h2>
<div className="inline-actions">
<Tag>{detail.alpha_type ?? "类型未提供"}</Tag>
<Tag>{detail.language ?? "语言未提供"}</Tag>
<Tag>{detail.status ?? "状态未提供"}</Tag>
</div>
</div>
<a
href={`https://platform.worldquantbrain.com/alpha/${encodeURIComponent(detail.id)}`}
target="_blank"
rel="noreferrer"
>
<Button>在 BRAIN 中打开 ↗</Button>
</a>
</div>
<Tabs activeKey={tab} onChange={setTab}>
<TabPane tab="概览与表达式" itemKey="overview">
<div className="detail-section">
<Descriptions
data={["region", "universe", "stage"].map((key) => ({
key,
value: displayValue(detail[key as keyof Detail]),
}))}
/>
<p className="muted">
平台创建:{formatTime(detail.date_created, timezone)} ·
最近同步:{formatTime(detail.synced_at, timezone)}
</p>
{detail.expression !== null && (
<CodeBlock
title={
detail.language === "PYTHON" ? "Python 代码" : "表达式"
}
value={detail.expression}
/>
)}
{detail.selection !== null && (
<CodeBlock title="Selection" value={detail.selection} />
)}
{detail.combo !== null && (
<CodeBlock title="Combo" value={detail.combo} />
)}
<h3>回测设置</h3>
<Descriptions
align="left"
data={Object.entries(detail.settings).map(([key, value]) => ({
key,
value: displayValue(value),
}))}
/>
</div>
</TabPane>
<TabPane tab="指标与检查" itemKey="metrics">
<MetricTable title="IS 指标" values={detail.is_metrics} />
<MetricTable title="OS 指标" values={detail.os_metrics} />
<h3>平台已有检查结果</h3>
<p className="muted">
展示已同步结果,查看此页不会触发新的平台检查。
</p>
{detail.checks.length ? (
detail.checks.map((check, i) => (
<div className="check-row" key={i}>
<span>{displayValue(check.name)}</span>
<span>
{displayValue(check.value)} / {displayValue(check.limit)}
</span>
<Tag
color={
check.result === "PASS"
? "green"
: check.result === "FAIL"
? "red"
: "grey"
}
>
{displayValue(check.result)}
</Tag>
</div>
))
) : (
<Empty description="平台尚未返回检查结果" />
)}
</TabPane>
<TabPane tab="PnL 曲线" itemKey="pnl">
<div className="section-toolbar">
<div>
<h3>累计 PnL</h3>
<p className="muted">
{pnl?.fetched_at
? `缓存更新于 ${formatTime(pnl.fetched_at, timezone)}`
: "尚未获取 PnL"}
</p>
</div>
<Button onClick={() => void fetchPnl()}>
{pnl?.cached ? "刷新 PnL" : "获取 PnL"}
</Button>
</div>
{pnl?.cached ? (
<>
<PnlChart points={pnl.points} />
<p className="muted">
{pnl.points.length} 条记录 · 缺失数据保留为断点
</p>
</>
) : (
<Empty description="点击获取 PnL,从平台读取并缓存曲线数据" />
)}
</TabPane>
<TabPane tab="研究记录" itemKey="research">
<div className="detail-section">
<p className="muted">
这些记录只保存在你的工作空间,平台同步不会覆盖。
</p>
<label>
研究状态
<Select
aria-label="研究状态"
optionList={stateOptions}
value={research.state}
onChange={(state) => {
dirty.current = true;
setResearch({
...research,
state: state as ResearchState,
});
}}
/>
</label>
<label>
标签
<Input
aria-label="研究标签"
value={tagText}
onChange={(value) => {
dirty.current = true;
setTagText(value);
}}
placeholder="以逗号分隔,例如 低换手, 分析师预期"
/>
</label>
<label>
研究备注
<TextArea
aria-label="研究备注"
value={research.note}
onChange={(note) => {
dirty.current = true;
setResearch({ ...research, note });
}}
rows={8}
maxCount={20000}
placeholder="记录假设、观察、下一步实验……"
/>
</label>
<Checkbox
checked={research.favorite}
onChange={(e) => {
dirty.current = true;
setResearch({
...research,
favorite: Boolean(e.target.checked),
});
}}
>
收藏此 Alpha
</Checkbox>
<div className="detail-save">
<Button
theme="solid"
loading={saving}
onClick={() => void save()}
>
保存研究记录
</Button>
</div>
</div>
</TabPane>
</Tabs>
</>
)}
</SideSheet>
);
}
function CodeBlock({ title, value }: { title: string; value: string }) {
return (
<>
<h3>{title}</h3>
<pre className="code-block">
<code>{value}</code>
</pre>
</>
);
}
function MetricTable({
title,
values,
}: {
title: string;
values: Record<string, unknown>;
}) {
const entries = Object.entries(values).filter(([key]) => key !== "checks");
return (
<div className="detail-section">
<h3>{title}</h3>
{entries.length ? (
<Descriptions
data={entries.map(([key, value]) => ({
key,
value: displayValue(value),
}))}
/>
) : (
<p className="muted">未提供</p>
)}
</div>
);
}