Files
qlib/backend/app/api/jobs.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

111 lines
3.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.
"""异步研究 Job API(Phase 4):提交 / 查询 / SSE 进度。
POST /api/jobs 创建 Job(BackgroundTasks 本地执行),立即返回 job_id
GET /api/jobs/{id} 状态 + 结果(成功时内嵌 result)
GET /api/jobs/{id}/events SSE 进度(queued→running→success|failed)
"""
from __future__ import annotations
import asyncio
import json
from datetime import datetime
from fastapi import APIRouter, BackgroundTasks, HTTPException
from fastapi.responses import StreamingResponse
from app.api.deps import DbSession, JobRepoDep, _engine_factory
from app.application.services.job_executor import execute_job, new_id
from app.domain.entities.research import (
BacktestResult,
FactorTestReport,
JobRecord,
JobStatus,
ResearchSpec,
)
from app.infrastructure.persistence.sqlalchemy.repositories.jobs_impl import (
SqlAlchemyExperimentRepository,
SqlAlchemyJobRepository,
)
from app.infrastructure.persistence.sqlalchemy.repositories.market_impl import (
SqlAlchemyDailyBarRepository,
SqlAlchemyStockRepository,
)
from app.infrastructure.persistence.sqlalchemy.session import SessionLocal
router = APIRouter(prefix="/jobs", tags=["jobs"])
def _bg_factories():
return {
"session_factory": SessionLocal,
"job_repo_factory": lambda s: SqlAlchemyJobRepository(s),
"experiment_repo_factory": lambda s: SqlAlchemyExperimentRepository(s),
"stock_repo_factory": lambda s: SqlAlchemyStockRepository(s),
"daily_repo_factory": lambda s: SqlAlchemyDailyBarRepository(s),
"engine": _engine_factory(),
}
def _decode_result(kind: str, result_json: str | None):
if result_json is None:
return None
model = BacktestResult if kind == "backtest" else FactorTestReport
return model.model_validate_json(result_json)
def _job_view(job: JobRecord) -> dict:
view = job.model_dump()
view["result"] = _decode_result(job.kind, job.result_json)
view.pop("result_json", None)
view["spec"] = json.loads(job.spec_json)
return view
@router.post("", summary="创建异步研究 Job")
def create_job(
spec: ResearchSpec,
background: BackgroundTasks,
session: DbSession,
job_repo: JobRepoDep,
) -> dict:
job = JobRecord(
id=new_id("JOB"),
kind=spec.type,
spec_json=spec.model_dump_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}
@router.get("/{job_id}", summary="查询 Job 状态与结果")
def get_job(job_id: str, job_repo: JobRepoDep) -> dict:
job = job_repo.get(job_id)
if job is None:
raise HTTPException(status_code=404, detail=f"Job {job_id} 不存在")
return _job_view(job)
@router.get("/{job_id}/events", summary="Job 进度 SSE")
async def job_events(job_id: str) -> StreamingResponse:
async def gen():
while True:
with SessionLocal() as session:
job = SqlAlchemyJobRepository(session).get(job_id)
if job is None:
yield "event: error\ndata: job not found\n\n"
return
payload = json.dumps(
{"job_id": job.id, "status": job.status, "stage": job.stage}, ensure_ascii=False
)
yield f"data: {payload}\n\n"
if job.status in (JobStatus.SUCCESS, JobStatus.FAILED, JobStatus.CANCELLED):
return
await asyncio.sleep(0.4)
return StreamingResponse(gen(), media_type="text/event-stream")