Files
qlib/backend/app/api/experiments.py
T
Simon 0ea229d766 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 通过
2026-09-06 17:18:46 +08:00

82 lines
2.6 KiB
Python
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.
"""Experiment API(Phase 4):列表 / 详情 / 一键复跑。"""
from __future__ import annotations
import json
from datetime import datetime
from fastapi import APIRouter, BackgroundTasks, HTTPException
from app.api.deps import DbSession, ExperimentRepoDep, JobRepoDep
from app.api.jobs import _bg_factories
from app.application.services.job_executor import execute_job, new_id
from app.domain.entities.research import (
ExperimentRecord,
JobRecord,
JobStatus,
)
router = APIRouter(prefix="/experiments", tags=["experiments"])
def _experiment_meta(exp: ExperimentRecord) -> dict:
spec = json.loads(exp.spec_json)
return {
"id": exp.id,
"kind": exp.kind,
"factors": [f["name"] for f in spec.get("factors", [])],
"period": spec.get("period"),
"rebalance": spec.get("rebalance"),
"top_n": spec.get("selection", {}).get("top_n"),
"summary_text": exp.summary_text,
"code_version": exp.code_version,
"created_at": exp.created_at,
}
def _experiment_full(exp: ExperimentRecord) -> dict:
from app.api.jobs import _decode_result
return {
**_experiment_meta(exp),
"spec": json.loads(exp.spec_json),
"result": _decode_result(exp.kind, exp.result_json),
}
@router.get("", summary="Experiment 列表")
def list_experiments(experiment_repo: ExperimentRepoDep) -> list[dict]:
return [_experiment_meta(e) for e in experiment_repo.list_recent(limit=50)]
@router.get("/{experiment_id}", summary="Experiment 详情(含完整结果)")
def get_experiment(experiment_id: str, experiment_repo: ExperimentRepoDep) -> dict:
exp = experiment_repo.get(experiment_id)
if exp is None:
raise HTTPException(status_code=404, detail=f"Experiment {experiment_id} 不存在")
return _experiment_full(exp)
@router.post("/{experiment_id}/rerun", summary="一键复跑(AGENT §21:历史实验可重放)")
def rerun_experiment(
experiment_id: str,
background: BackgroundTasks,
session: DbSession,
experiment_repo: ExperimentRepoDep,
job_repo: JobRepoDep,
) -> dict:
exp = experiment_repo.get(experiment_id)
if exp is None:
raise HTTPException(status_code=404, detail=f"Experiment {experiment_id} 不存在")
job = JobRecord(
id=new_id("JOB"),
kind=exp.kind,
spec_json=exp.spec_json,
status=JobStatus.QUEUED,
created_at=datetime.now(),
)
job_repo.create(job)
session.commit()
background.add_task(execute_job, job.id, **_bg_factories())
return {"job_id": job.id, "status": job.status, "origin_experiment": exp.id}