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

255 lines
7.7 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, 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>
);
}