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