- 数据表: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 通过
82 lines
2.6 KiB
Python
82 lines
2.6 KiB
Python
"""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}
|