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>
|
|||
|
|
);
|
|||
|
|
}
|