Files
worldquant-alpha-system/frontend/src/research/EvaluationPanel.tsx
T

255 lines
7.7 KiB
TypeScript
Raw Normal View History

import { useEffect, useState } from "react";
import {
Banner,
Button,
Empty,
InputNumber,
Tag,
Toast,
} from "@douyinfe/semi-ui-19";
import { api, formatNumber, formatTime, post } from "../api";
import { ResearchSelect } from "./ResearchSelect";
export type Evaluation = {
id: string;
created_at: string;
report: {
verdict: string;
rules: {
version: string;
sharpe_min: number;
fitness_min: number;
turnover_max: number;
};
records: {
alpha_id: string | null;
client_item_id: string | null;
verdict: string;
missing: string[];
failed: string[];
evidence: {
metric: string;
value: number | null;
bound: number;
direction: string;
status: string;
}[];
snapshot: unknown;
}[];
model_advice: {
summary: string;
risks: string[];
suggestions: string[];
} | null;
previous_evaluation_id?: string;
};
};
const labels: Record<string, string> = {
pass: "通过本地筛选",
review: "数据不足 / 待核实",
block: "未通过规则",
};
export function EvaluationPanel({
alphaId,
experimentId,
backtestRunId,
}: {
alphaId?: string;
experimentId?: string;
backtestRunId?: string;
}) {
const [items, setItems] = useState<Evaluation[]>([]);
const [selected, setSelected] = useState<Evaluation | null>(null);
const [rules, setRules] = useState({
version: "research-v1",
sharpe_min: 1,
fitness_min: 0.5,
turnover_max: 0.7,
});
const [busy, setBusy] = useState("");
const [page, setPage] = useState(1);
const [revision, setRevision] = useState(0);
useEffect(() => {
const controller = new AbortController();
api<{ items: Evaluation[] }>(
`/research/evaluations?${alphaId ? `alpha_id=${encodeURIComponent(alphaId)}` : `experiment_id=${encodeURIComponent(experimentId || "")}`}&offset=${(page - 1) * 25}`,
{ signal: controller.signal },
)
.then((r) => setItems(r.items))
.catch((e) => {
if (!controller.signal.aborted) Toast.error(e.message);
});
return () => controller.abort();
}, [alphaId, experimentId, page, revision]);
async function perform(name: string, work: () => Promise<Evaluation>) {
setBusy(name);
try {
setSelected(await work());
setPage(1);
setRevision((n) => n + 1);
} catch (e) {
Toast.error((e as Error).message);
} finally {
setBusy("");
}
}
return (
<div className="detail-section">
<h3>版本化研究评估</h3>
<p className="muted">
每份报告固定输入快照、规则与指标证据。模型建议单独保存,不改变规则判断或本地研究状态。
</p>
<div className="research-form-grid">
{(
[
["sharpe_min", "Sharpe 下限"],
["fitness_min", "Fitness 下限"],
["turnover_max", "Turnover 上限"],
] as const
).map(([key, label]) => (
<label key={key}>
{label}
<InputNumber
aria-label={label}
value={rules[key]}
step={0.1}
onChange={(v) => {
if (typeof v === "number") setRules({ ...rules, [key]: v });
}}
/>
</label>
))}
</div>
<div className="inline-actions">
<Button
disabled={Boolean(busy) || (!alphaId && !backtestRunId)}
loading={busy === "evaluate"}
onClick={() =>
void perform("evaluate", () =>
post("/research/evaluations", {
alpha_id: alphaId,
experiment_id: experimentId,
backtest_run_id: backtestRunId,
rules,
}),
)
}
>
保存规则评估
</Button>
<ResearchSelect
label="历史评估报告"
placeholder="查看历史报告"
value={selected?.id}
optionList={items.map((i) => ({
value: i.id,
label: `${formatTime(i.created_at)} · ${labels[i.report.verdict]}`,
}))}
onChange={(v) => setSelected(items.find((i) => i.id === v) || null)}
/>
<Button disabled={page <= 1} onClick={() => setPage((n) => n - 1)}>
上一页
</Button>
<Button
disabled={items.length < 25}
onClick={() => setPage((n) => n + 1)}
>
下一页
</Button>
</div>
{!selected ? (
<Empty description="选择历史报告,或按当前规则保存新评估" />
) : (
<>
<div className="section-toolbar">
<Tag
color={
selected.report.verdict === "pass"
? "green"
: selected.report.verdict === "block"
? "red"
: "orange"
}
>
{labels[selected.report.verdict]}
</Tag>
<span className="muted">
{formatTime(selected.created_at)} ·{" "}
{selected.report.rules.version}
</span>
</div>
<p>
规则:Sharpe ≥ {selected.report.rules.sharpe_min},Fitness ≥{" "}
{selected.report.rules.fitness_min},Turnover ≤{" "}
{selected.report.rules.turnover_max}
</p>
{selected.report.records.map((r, index) => (
<div className="research-card" key={index}>
<h4>
{r.alpha_id || r.client_item_id || "未返回 Alpha"} ·{" "}
{labels[r.verdict]}
</h4>
{r.evidence.map((e) => (
<div className="check-row" key={e.metric}>
<span>
{e.metric}: {formatNumber(e.value)}
</span>
<span>
{e.direction === "min" ? "≥" : "≤"} {e.bound}
</span>
<Tag>
{e.status === "unknown"
? "缺失"
: e.status === "pass"
? "通过"
: "失败"}
</Tag>
</div>
))}
{r.missing.length > 0 && (
<p>缺失或未决:{r.missing.join("、")}</p>
)}
{r.failed.length > 0 && <p>未通过:{r.failed.join("、")}</p>}
<details>
<summary>当时的输入快照</summary>
<pre className="code-block">
{JSON.stringify(r.snapshot, null, 2)}
</pre>
</details>
</div>
))}
<Banner
type="info"
description="这是本地研究筛选,不代表官方提交资格。已有平台检查缺失时保留为待核实。"
/>
{selected.report.model_advice && (
<div className="research-card">
<h4>模型建议</h4>
<p>{selected.report.model_advice.summary}</p>
<p>
风险:
{selected.report.model_advice.risks.join(";") || "未提供"}
</p>
<p>
建议:
{selected.report.model_advice.suggestions.join(";") ||
"未提供"}
</p>
</div>
)}
<Button
disabled={Boolean(busy)}
loading={busy === "advice"}
onClick={() =>
void perform("advice", () =>
post(`/research/evaluations/${selected.id}/advice`, {}),
)
}
>
生成建议并保存为新报告
</Button>
</>
)}
</div>
);
}