2026-09-07 14:54:20 +08:00
|
|
|
|
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,
|
2026-09-07 23:02:55 +08:00
|
|
|
|
ApiError,
|
2026-09-07 14:54:20 +08:00
|
|
|
|
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,
|
2026-09-07 23:02:55 +08:00
|
|
|
|
suspended = false,
|
|
|
|
|
|
chatOffset = 0,
|
2026-09-07 14:54:20 +08:00
|
|
|
|
}: {
|
|
|
|
|
|
id: string | null;
|
|
|
|
|
|
version: string;
|
|
|
|
|
|
timezone?: string;
|
|
|
|
|
|
taskPanelOpen: boolean;
|
|
|
|
|
|
onClose: () => void;
|
|
|
|
|
|
onSaved: () => void;
|
|
|
|
|
|
onTask: () => void;
|
2026-09-07 23:02:55 +08:00
|
|
|
|
suspended?: boolean;
|
|
|
|
|
|
chatOffset?: number;
|
2026-09-07 14:54:20 +08:00
|
|
|
|
}) {
|
|
|
|
|
|
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);
|
2026-09-07 23:02:55 +08:00
|
|
|
|
const [conflict, setConflict] = useState(false);
|
2026-09-07 14:54:20 +08:00
|
|
|
|
const [tab, setTab] = useState("overview");
|
|
|
|
|
|
const dirty = useRef(false);
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
dirty.current = false;
|
2026-09-07 23:02:55 +08:00
|
|
|
|
setConflict(false);
|
2026-09-07 14:54:20 +08:00
|
|
|
|
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(", "));
|
2026-09-07 23:02:55 +08:00
|
|
|
|
} else if (
|
|
|
|
|
|
research &&
|
|
|
|
|
|
value.research.version !== research.version
|
|
|
|
|
|
) {
|
|
|
|
|
|
setConflict(true);
|
2026-09-07 14:54:20 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
.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;
|
2026-09-07 23:02:55 +08:00
|
|
|
|
setConflict(false);
|
2026-09-07 14:54:20 +08:00
|
|
|
|
Toast.success("研究记录已保存");
|
|
|
|
|
|
onSaved();
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
Toast.error((e as Error).message);
|
2026-09-07 23:02:55 +08:00
|
|
|
|
if (e instanceof ApiError && e.status === 409) {
|
|
|
|
|
|
setConflict(true);
|
|
|
|
|
|
try {
|
|
|
|
|
|
setDetail(await api<Detail>(`/alphas/${id}`));
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
/* Preserve the draft if refresh fails. */
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-09-07 14:54:20 +08:00
|
|
|
|
} finally {
|
|
|
|
|
|
setSaving(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return (
|
|
|
|
|
|
<SideSheet
|
|
|
|
|
|
closeOnEsc={!taskPanelOpen}
|
|
|
|
|
|
className="alpha-detail"
|
2026-09-07 23:02:55 +08:00
|
|
|
|
visible={Boolean(id) && !suspended}
|
|
|
|
|
|
width={Math.min(800, window.innerWidth - chatOffset)}
|
|
|
|
|
|
style={{ right: chatOffset }}
|
|
|
|
|
|
maskStyle={{ right: chatOffset }}
|
2026-09-07 14:54:20 +08:00
|
|
|
|
title={
|
|
|
|
|
|
<span>
|
|
|
|
|
|
Alpha 详情 <code className="muted">{id}</code>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
}
|
|
|
|
|
|
onCancel={onClose}
|
|
|
|
|
|
>
|
|
|
|
|
|
{error ? (
|
|
|
|
|
|
<Banner type="danger" description={error} />
|
|
|
|
|
|
) : !detail || !research ? (
|
|
|
|
|
|
<Spin />
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<>
|
2026-09-07 23:02:55 +08:00
|
|
|
|
{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>
|
|
|
|
|
|
}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
2026-09-07 14:54:20 +08:00
|
|
|
|
<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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|