feat: Phase 4 — Experiment 自动归档 + 异步 Job(状态机 / SSE / 一键复跑)
- 数据表:job / experiment(spec/result JSON 存档、code_version),Alembic 迁移 53113c80257f
- Job:queued→running→(success|failed) 状态机,BackgroundTasks 本地执行 + 失败兜底标记;结果与 Experiment 关联
- Experiment:每次研究成功自动归档(含 git commit 与收益摘要),支持一键复跑(同 spec 重建 Job)
- API:POST /api/jobs、GET /api/jobs/{id}(内嵌结果)、SSE /api/jobs/{id}/events、/api/experiments 列表/详情/rerun
- 前端:新增「实验」页(列表 / 详情 / 复跑 + Job 轮询);导航更新
- 端到端验证:真实 20 股 job 提交→后台执行→success→EXP 归档(-12.41%);executor 成功/失败路径单测
- 测试 73 passed(新增 5 项 Job/Experiment)/ ruff clean / 前端 tsc + build 通过
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { apiGet, apiPost } from "@/lib/api";
|
||||
import type { BacktestResult } from "@/lib/types";
|
||||
|
||||
interface ExperimentMeta {
|
||||
id: string;
|
||||
kind: string;
|
||||
factors: string[];
|
||||
period?: [string, string] | null;
|
||||
rebalance?: string;
|
||||
top_n?: number | null;
|
||||
summary_text?: string | null;
|
||||
code_version?: string | null;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
interface ExperimentDetail extends ExperimentMeta {
|
||||
spec: { universe?: Record<string, unknown> };
|
||||
result: BacktestResult | null;
|
||||
}
|
||||
|
||||
export default function ExperimentsPage() {
|
||||
const [exps, setExps] = useState<ExperimentMeta[]>([]);
|
||||
const [detail, setDetail] = useState<ExperimentDetail | null>(null);
|
||||
const [jobId, setJobId] = useState("");
|
||||
const [jobStatus, setJobStatus] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
function load() {
|
||||
apiGet<ExperimentMeta[]>("/experiments")
|
||||
.then(setExps)
|
||||
.catch((e: Error) => setError(e.message));
|
||||
}
|
||||
|
||||
useEffect(load, []);
|
||||
|
||||
function open(id: string) {
|
||||
apiGet<ExperimentDetail>(`/experiments/${id}`)
|
||||
.then(setDetail)
|
||||
.catch((e: Error) => setError(e.message));
|
||||
}
|
||||
|
||||
function rerun(exp: ExperimentMeta) {
|
||||
setError("");
|
||||
setJobId("");
|
||||
setJobStatus("submitting");
|
||||
apiPost<{ job_id: string; status: string }>(`/experiments/${exp.id}/rerun`, {})
|
||||
.then((r) => {
|
||||
setJobId(r.job_id);
|
||||
setJobStatus(r.status);
|
||||
poll(r.job_id);
|
||||
})
|
||||
.catch((e: Error) => setError(e.message));
|
||||
}
|
||||
|
||||
function poll(id: string) {
|
||||
let tries = 0;
|
||||
const timer = window.setInterval(() => {
|
||||
tries += 1;
|
||||
apiGet<{ status: string }>(`/jobs/${id}`)
|
||||
.then((j) => {
|
||||
setJobStatus(j.status);
|
||||
if (j.status === "success" || j.status === "failed" || tries > 60) {
|
||||
window.clearInterval(timer);
|
||||
load();
|
||||
}
|
||||
})
|
||||
.catch(() => window.clearInterval(timer));
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
const s = detail?.result?.summary;
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1>实验归档(Experiment · 可复现)</h1>
|
||||
{error && <div className="error">{error}</div>}
|
||||
{jobId && (
|
||||
<div className="card">
|
||||
复跑 Job <b>{jobId}</b> → 状态:<b>{jobStatus}</b>
|
||||
</div>
|
||||
)}
|
||||
<div className="card">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>类型</th>
|
||||
<th>因子</th>
|
||||
<th>区间</th>
|
||||
<th>摘要</th>
|
||||
<th>代码版本</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{exps.map((e) => (
|
||||
<tr key={e.id}>
|
||||
<td>{e.id}</td>
|
||||
<td>{e.kind}</td>
|
||||
<td>{e.factors.join(", ")}</td>
|
||||
<td>
|
||||
{e.period ? `${e.period[0]} ~ ${e.period[1]}` : "—"}
|
||||
</td>
|
||||
<td>{e.summary_text ?? "—"}</td>
|
||||
<td>{e.code_version ?? "—"}</td>
|
||||
<td>
|
||||
<button onClick={() => open(e.id)}>详情</button>
|
||||
<button className="primary" onClick={() => rerun(e)}>
|
||||
复跑
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{exps.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={7} className="muted">
|
||||
暂无实验。先在「回测」或「因子研究」中运行一次,即会自动归档。
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{detail && s && (
|
||||
<div className="card">
|
||||
<h2>
|
||||
{detail.id} · {detail.factors.join(", ")} · {detail.period?.[0]}~{detail.period?.[1]}
|
||||
</h2>
|
||||
<div className="stat-grid">
|
||||
<div className="stat">
|
||||
<div className="label">总收益</div>
|
||||
<div className="value">{s.total_return_pct.toFixed(2)}%</div>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<div className="label">年化</div>
|
||||
<div className="value">{s.annual_return_pct.toFixed(2)}%</div>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<div className="label">Sharpe</div>
|
||||
<div className="value">{s.sharpe.toFixed(2)}</div>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<div className="label">最大回撤</div>
|
||||
<div className="value bad">{s.max_drawdown_pct.toFixed(2)}%</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="muted" style={{ marginTop: 8 }}>
|
||||
spec(可复现输入):{JSON.stringify(detail.spec).slice(0, 400)}…
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -17,6 +17,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
<Link href="/stocks">股票池</Link>
|
||||
<Link href="/factors">因子研究</Link>
|
||||
<Link href="/backtest">回测</Link>
|
||||
<Link href="/experiments">实验</Link>
|
||||
</nav>
|
||||
<main>{children}</main>
|
||||
</body>
|
||||
|
||||
Reference in New Issue
Block a user