feat: 完善 Alpha 详情页布局与提交检查,增加描述生成与验证功能
Deploy production / deploy (push) Successful in 54s

This commit is contained in:
yuxuanhui
2026-09-10 11:45:05 +08:00
parent f67b4d99b7
commit 9334b6f651
12 changed files with 500 additions and 122 deletions
+119 -39
View File
@@ -5,6 +5,7 @@ import {
Checkbox,
Descriptions,
Empty,
Divider,
Input,
Select,
SideSheet,
@@ -108,7 +109,7 @@ export function AlphaDetail({
}, [id, version]);
useEffect(() => {
let active = true;
if (id && tab === "pnl")
if (id && tab === "metrics")
api<Pnl>(`/alphas/${id}/pnl`)
.then((p) => {
if (active) setPnl(p);
@@ -163,7 +164,7 @@ export function AlphaDetail({
closeOnEsc={!taskPanelOpen}
className="alpha-detail"
visible={Boolean(id) && !suspended}
width={`min(60vw, calc(100vw - ${chatOffset}px))`}
width={`min(80vw, calc(100vw - ${chatOffset}px))`}
style={{ right: chatOffset }}
maskStyle={{ right: chatOffset }}
title={
@@ -220,27 +221,29 @@ export function AlphaDetail({
<Button>在 BRAIN 中打开 ↗</Button>
</a>
</div>
{detail.alpha_type === "REGULAR" &&
detail.language === "FASTEXPR" && (
<Button
onClick={() =>
onAction({
type: "open_variant",
alpha_id: detail.id,
nonce: Date.now(),
})
}
>
研究此 Alpha 的变体
</Button>
)}
<Tabs activeKey={tab} onChange={setTab}>
<Tabs
className="alpha-detail-tabs"
activeKey={tab}
onChange={setTab}
collapsible="auto"
tabPaneMotion={false}
keepDOM
>
<TabPane tab="概览与表达式" itemKey="overview">
<div className="detail-section">
<Descriptions
size="small"
align="left"
layout="horizontal"
column={3}
data={["region", "universe", "stage"].map((key) => ({
key,
value: displayValue(detail[key as keyof Detail]),
value: (
<DescriptionValue
field={key}
value={detail[key as keyof Detail]}
/>
),
}))}
/>
<p className="muted">
@@ -261,19 +264,25 @@ export function AlphaDetail({
{detail.combo !== null && (
<CodeBlock title="Combo" value={detail.combo} />
)}
<Divider />
<h3>回测设置</h3>
<Descriptions
size="small"
align="left"
layout="horizontal"
column={3}
data={Object.entries(detail.settings).map(([key, value]) => ({
key,
value: displayValue(value),
value: <DescriptionValue field={key} value={value} />,
}))}
/>
</div>
</TabPane>
<TabPane tab="指标与检查" itemKey="metrics">
<TabPane tab="指标" itemKey="metrics">
<MetricTable title="IS 指标" values={detail.is_metrics} />
<Divider />
<MetricTable title="OS 指标" values={detail.os_metrics} />
<Divider />
<h3>平台已有检查结果</h3>
<p className="muted">
展示已同步结果,查看此页不会触发新的平台检查。
@@ -301,8 +310,7 @@ export function AlphaDetail({
) : (
<Empty description="平台尚未返回检查结果" />
)}
</TabPane>
<TabPane tab="PnL 曲线" itemKey="pnl">
<Divider />
<div className="section-toolbar">
<div>
<h3>累计 PnL</h3>
@@ -327,21 +335,20 @@ export function AlphaDetail({
<Empty description="点击获取 PnL,从平台读取并缓存曲线数据" />
)}
</TabPane>
<TabPane tab="本地自相关" itemKey="correlation">
<TabPane tab="相关性检查" itemKey="correlation">
{tab === "correlation" && id && (
<SelfCorrelationPanel
key={id}
key={`correlation-${id}`}
id={id}
version={version}
timezone={timezone}
onTask={onTask}
/>
)}
</TabPane>
<TabPane tab="Description 与提交检查" itemKey="submission">
{tab === "submission" && id && (
<Divider />
{tab === "correlation" && id && (
<SubmissionPanel
key={id}
key={`submission-${id}`}
id={id}
version={version}
onTask={onTask}
@@ -353,28 +360,39 @@ export function AlphaDetail({
<ComparisonPanel key={detail.id} baseline={detail.id} />
)}
</TabPane>
<TabPane tab="评估报告" itemKey="evaluations">
{tab === "evaluations" && id && (
<EvaluationPanel key={id} alphaId={id} />
)}
</TabPane>
<TabPane tab="变体关系" itemKey="lineage">
{detail.alpha_type === "REGULAR" &&
detail.language === "FASTEXPR" && (
<Button
onClick={() =>
onAction({
type: "open_variant",
alpha_id: detail.id,
nonce: Date.now(),
})
}
>
研究此 Alpha 的变体
</Button>
)}
{tab === "lineage" && id && (
<LineagePanel key={id} alphaId={id} onAction={onAction} />
)}
</TabPane>
<TabPane tab="研究来源" itemKey="sources">
{tab === "sources" && id && (
<TabPane tab="研究信息" itemKey="research">
<h3>研究来源</h3>
{tab === "research" && id && (
<AlphaSources
key={id}
key={`sources-${id}`}
id={id}
version={version}
timezone={timezone}
onAction={onAction}
/>
)}
</TabPane>
<TabPane tab="研究记录" itemKey="research">
<Divider />
<h3>研究记录</h3>
<div className="detail-section">
<p className="muted">
这些记录只保存在你的工作空间,平台同步不会覆盖。
@@ -442,6 +460,10 @@ export function AlphaDetail({
</Button>
</div>
</div>
<Divider />
{tab === "research" && id && (
<EvaluationPanel key={`evaluation-${id}`} alphaId={id} />
)}
</TabPane>
</Tabs>
</>
@@ -473,9 +495,13 @@ function MetricTable({
<h3>{title}</h3>
{entries.length ? (
<Descriptions
size="small"
align="left"
layout="horizontal"
column={3}
data={entries.map(([key, value]) => ({
key,
value: displayValue(value),
value: <DescriptionValue field={key} value={value} />,
}))}
/>
) : (
@@ -484,3 +510,57 @@ function MetricTable({
</div>
);
}
/** Render structured metric values without flattening their field hierarchy. */
function DescriptionValue({ field, value }: { field: string; value: unknown }) {
if (value !== null && typeof value === "object") {
const entries = Object.entries(value);
return entries.length ? (
<Descriptions
className="nested-descriptions"
size="small"
align="left"
data={entries.map(([key, child]) => ({
key,
value: <DescriptionValue field={key} value={child} />,
}))}
/>
) : (
<span>{Array.isArray(value) ? "[]" : "{}"}</span>
);
}
// Use field semantics for states; numbers and ordinary text remain plain values.
const stateField = /(?:^|_)(status|state|stage|result|verdict)$/.test(
field.replace(/([a-z])([A-Z])/g, "$1_$2").toLowerCase(),
);
const state = typeof value === "string" ? value.toUpperCase() : "";
if (
value !== null &&
value !== undefined &&
value !== "" &&
(typeof value === "boolean" ||
stateField ||
state === "ON" ||
state === "OFF")
) {
const color =
value === true ||
["PASS", "PASSED", "SUCCESS", "COMPLETED", "ON"].includes(state)
? "green"
: ["FAIL", "FAILED", "ERROR", "BLOCK"].includes(state)
? "red"
: ["WARNING", "PENDING", "PARTIAL", "REVIEW"].includes(state)
? "orange"
: "grey";
return (
<Tag size="small" color={color}>
{typeof value === "boolean"
? value
? "是"
: "否"
: displayValue(value)}
</Tag>
);
}
return <span>{displayValue(value)}</span>;
}
@@ -107,6 +107,10 @@ export function SelfCorrelationPanel({
</span>
</div>
<Descriptions
size="small"
align="left"
layout="horizontal"
column={3}
data={[
{
key: "最大相关系数",
@@ -132,6 +136,12 @@ export function SelfCorrelationPanel({
]}
/>
{result.reason && <p>{result.reason}</p>}
{result.status === "insufficient_data" &&
result.candidate_count === 0 && (
<p className="muted">
没有本地比较基准时可继续下方的平台提交检查;这不代表已通过相关性检查。
</p>
)}
{result.status === "partial" && (
<p>已有样本低于阈值,但部分基准无法比较,不能据此判断全部样本。</p>
)}
+41 -46
View File
@@ -3,27 +3,27 @@ import { Banner, Button, Spin, TextArea, Toast } from "@douyinfe/semi-ui-19";
import { api, formatTime, jobStateLabels, post } from "../api";
import type { Job } from "../types";
type Description = {
idea: string;
data_rationale: string;
operator_rationale: string;
};
type Submission = {
snapshot: string;
sections: Record<string, { code: string | null; description: string }>;
descriptions: Record<string, Description>;
descriptions: Record<string, string>;
model: string;
can_generate: boolean;
can_check: boolean;
job: Job | null;
};
const fields = [
["idea", "Idea"],
["data_rationale", "Rationale for data used"],
["operator_rationale", "Rationale for operators used"],
] as const;
const render = (value: Description) =>
fields.map(([key, title]) => `${title}: ${value[key].trim()}`).join("\n");
const validDescription = (text: string) => {
const paragraphs = text.match(
/^\s*Idea:\s*(.*?)\s*Rationale for data used:\s*(.*?)\s*Rationale for operators used:\s*(.*?)\s*$/s,
);
const length = Array.from(text).length;
return (
length >= 100 &&
length <= 500 &&
!!paragraphs &&
paragraphs.slice(1).every((part) => part.trim())
);
};
const activeStates = [
"queued",
"running",
@@ -41,7 +41,7 @@ export function SubmissionPanel({
onTask: () => void;
}) {
const [data, setData] = useState<Submission | null>(null);
const [draft, setDraft] = useState<Record<string, Description>>({});
const [draft, setDraft] = useState<Record<string, string>>({});
const [snapshot, setSnapshot] = useState("");
const [busy, setBusy] = useState("");
const [error, setError] = useState("");
@@ -76,7 +76,7 @@ export function SubmissionPanel({
setBusy("generate");
setError("");
try {
const result = await post<{ descriptions: Record<string, Description> }>(
const result = await post<{ descriptions: Record<string, string> }>(
`/alphas/${id}/description/generate`,
{ snapshot },
);
@@ -115,23 +115,19 @@ export function SubmissionPanel({
const conflict = snapshot !== data.snapshot;
const valid =
Object.keys(draft).length > 0 &&
Object.values(draft).every(
(value) =>
fields.every(([key]) => value[key].trim()) &&
Array.from(render(value)).length >= 100,
);
Object.values(draft).every(validDescription);
return (
<div className="detail-section">
<h3>Description 与 Check submission</h3>
<h3>Description 与提交检查</h3>
<p className="muted">
AI 生成后可逐段修改。写回并检查会更新 BRAIN 的
AI 一次生成完整的三段内容,可在下方统一修改。写回并检查会更新 BRAIN 的
Description,随后获取平台提交检查结果,不会正式提交 Alpha。
</p>
{error && <Banner type="danger" description={error} />}
{!data.can_check && (
<Banner
type="info"
description="写回并检查需要待提交 Alpha,并先取得有效、样本完整且低于阈值的本地自相关结果。"
description="写回并检查需要待提交 Alpha。有本地比较基准时,需取得有效、完整且低于阈值的自相关结果;没有本地基准时可直接继续平台检查。"
/>
)}
{!data.can_generate && (
@@ -145,7 +141,7 @@ export function SubmissionPanel({
loading={busy === "generate"}
disabled={!!busy || running || conflict || !data.can_generate}
>
AI 生成三段 Description
AI 生成 Description
</Button>
{data.model && <span className="muted">模型:{data.model}</span>}
</div>
@@ -172,28 +168,27 @@ export function SubmissionPanel({
</pre>
</details>
)}
{fields.map(([key, title]) => (
<label key={key}>
{title}:
<TextArea
aria-label={`${section} ${title}`}
value={value[key]}
rows={3}
maxCount={6000}
disabled={!!busy || running}
onChange={(text) => {
dirty.current = true;
setDraft((current) => ({
...current,
[section]: { ...current[section], [key]: text },
}));
}}
/>
</label>
))}
<label>
完整 Description
<TextArea
aria-label={`${section} Description`}
value={value}
rows={7}
maxCount={500}
placeholder={
"Idea: 策略假设\n\nRationale for data used: 数据选择理由\n\nRationale for operators used: 算子使用理由"
}
disabled={!!busy || running}
onChange={(text) => {
dirty.current = true;
setDraft((current) => ({ ...current, [section]: text }));
}}
/>
</label>
<p className="muted">
总长度:{Array.from(render(value)).length} 字符,至少 100
字符(含标题和换行)。三段内容均需填写。
总长度:{Array.from(value).length} / 500 字符;至少 100
字符,包含标题与换行。 三个段落分别以 Idea:、Rationale for data
used:、Rationale for operators used: 开头。
</p>
</section>
))}
@@ -222,7 +217,7 @@ export function SubmissionPanel({
<div role="status">
<p>
最近任务:{jobStateLabels[data.job.status] ?? data.job.status}
{data.job.status === "completed" && ",请在“指标与检查”查看结果"}
{data.job.status === "completed" && ",请在“指标”查看结果"}
{data.job.next_retry_at &&
` · 下次轮询:${formatTime(data.job.next_retry_at)}`}
</p>
+61
View File
@@ -875,3 +875,64 @@ label:not(.semi-checkbox):not(.semi-radio) {
gap: var(--space-3);
}
}
/* The sheet header and tab bar stay outside the single scrolling flex child. */
.alpha-detail .semi-sidesheet-body {
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.alpha-detail .detail-title {
flex-shrink: 0;
margin-bottom: 8px;
}
.alpha-detail-tabs {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
min-width: 0;
}
.alpha-detail-tabs > .semi-tabs-bar {
flex-shrink: 0;
}
.alpha-detail-tabs > .semi-tabs-content {
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
padding-top: 12px;
}
.alpha-detail .detail-section {
padding: 8px 0;
}
.alpha-detail h3 {
margin: 8px 0;
}
.alpha-detail h4 {
margin: 6px 0;
}
.alpha-detail label {
margin-bottom: 8px;
}
.alpha-detail .semi-divider-horizontal {
margin: 16px 0;
}
.alpha-detail .semi-descriptions table {
table-layout: fixed;
width: 100%;
}
.alpha-detail .semi-descriptions th,
.alpha-detail .semi-descriptions td {
padding-bottom: 6px;
vertical-align: top;
overflow-wrap: anywhere;
text-align: left;
}
.alpha-detail .nested-descriptions {
margin-bottom: 0;
}
.alpha-detail .nested-descriptions th {
width: 45%;
}