feat(job): D1 Job 阶段上报(v3 §23)+ 列表/取消端点
- ResearchService.run_backtest/run_factor_test 支持 on_stage 回调(data_loading →
factor_calculation/backtesting → analysis);job_executor 经独立短会话把 stage 写库
(子进程模式同样走 DB),收尾回读最后阶段避免覆盖
- /api/jobs:GET 列表(kind/limit)、POST /{id}/cancel(queued/running → CANCELLED +
终止子进程 terminate_active;父进程兜底已跳过 CANCELLED)
- SSE /jobs/{id}/events 现会携带 stage
- tests/test_job_stages.py:成功 job 终态 stage=analysis;executor 取消 queued/幂等/
已完成不可取消;API 列表+终态不可取消+404;全量 pytest 通过
This commit is contained in:
@@ -0,0 +1,164 @@
|
||||
"""D1 Job 阶段上报 / 取消 / 列表测试。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
|
||||
import pytest
|
||||
from app.api import deps
|
||||
from app.application.services import job_executor as je
|
||||
from app.domain.entities.research import JobRecord, JobStatus, ResearchSpec
|
||||
from app.infrastructure.persistence.sqlalchemy.base import Base
|
||||
from app.infrastructure.persistence.sqlalchemy.repositories.jobs_impl import (
|
||||
SqlAlchemyExperimentRepository,
|
||||
SqlAlchemyJobRepository,
|
||||
)
|
||||
from app.infrastructure.persistence.sqlalchemy.repositories.market_impl import (
|
||||
SqlAlchemyDailyBarRepository,
|
||||
SqlAlchemyStockRepository,
|
||||
)
|
||||
from app.main import app
|
||||
from app.quant.engine import LocalEngine
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from conftest_quant import bars_dataframe_to_daily_bars, synthetic_daily
|
||||
|
||||
_SYMS = ["60000" + str(i) + ".SH" for i in range(5)]
|
||||
|
||||
|
||||
def _spec(**kw) -> ResearchSpec:
|
||||
base = dict(
|
||||
type="backtest",
|
||||
universe={"exclude_st": False, "min_listing_days": 0, "symbols": _SYMS},
|
||||
factors=[{"name": "momentum_60", "weight": 1.0}],
|
||||
selection={"top_n": 2},
|
||||
rebalance="monthly",
|
||||
period=[date(2024, 5, 1), date(2024, 8, 31)],
|
||||
)
|
||||
base.update(kw)
|
||||
return ResearchSpec(**base)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def sf(tmp_path):
|
||||
engine = create_engine(f"sqlite:///{tmp_path / 'job.db'}", future=True)
|
||||
Base.metadata.create_all(engine)
|
||||
Session = sessionmaker(bind=engine, expire_on_commit=False)
|
||||
df = synthetic_daily({s: 0.006 - 0.0015 * i for i, s in enumerate(_SYMS)}, n=320)
|
||||
with Session() as session:
|
||||
SqlAlchemyStockRepository(session).upsert_many(
|
||||
[
|
||||
__import__("app.domain.entities.market", fromlist=["Stock"]).Stock(
|
||||
symbol=s, name=f"测试{i}", list_date=date(1999, 1, 1)
|
||||
)
|
||||
for i, s in enumerate(_SYMS)
|
||||
]
|
||||
)
|
||||
SqlAlchemyDailyBarRepository(session).upsert_many(bars_dataframe_to_daily_bars(df))
|
||||
session.commit()
|
||||
return Session
|
||||
|
||||
|
||||
def _create(sf, job_id, spec=None):
|
||||
with sf() as session:
|
||||
SqlAlchemyJobRepository(session).create(
|
||||
JobRecord(id=job_id, kind="backtest", spec_json=(spec or _spec()).model_dump_json(),
|
||||
status=JobStatus.QUEUED, created_at=datetime.now())
|
||||
)
|
||||
session.commit()
|
||||
|
||||
|
||||
class TestJobStages:
|
||||
def test_stage_reported_and_success(self, sf) -> None:
|
||||
job_id = "JOB-STAGE-1"
|
||||
_create(sf, job_id)
|
||||
from app.infrastructure.persistence.sqlalchemy.repositories.market_impl import (
|
||||
SqlAlchemyDailyBarRepository as D,
|
||||
)
|
||||
from app.infrastructure.persistence.sqlalchemy.repositories.market_impl import (
|
||||
SqlAlchemyStockRepository as S,
|
||||
)
|
||||
|
||||
je.execute_job(
|
||||
job_id,
|
||||
session_factory=sf,
|
||||
job_repo_factory=lambda s: SqlAlchemyJobRepository(s),
|
||||
experiment_repo_factory=lambda s: SqlAlchemyExperimentRepository(s),
|
||||
stock_repo_factory=lambda s: S(s),
|
||||
daily_repo_factory=lambda s: D(s),
|
||||
engine=LocalEngine(),
|
||||
)
|
||||
with sf() as session:
|
||||
job = SqlAlchemyJobRepository(session).get(job_id)
|
||||
assert job is not None and job.status == JobStatus.SUCCESS
|
||||
# 阶段在成功时保留最后 analysis(v3 §23 阶段名)
|
||||
assert job.stage == "analysis"
|
||||
|
||||
def test_cancel_queued_and_idempotent(self, sf) -> None:
|
||||
job_id = "JOB-CANCEL-1"
|
||||
_create(sf, job_id)
|
||||
assert je.cancel_job(job_id, session_factory=sf,
|
||||
job_repo_factory=lambda s: SqlAlchemyJobRepository(s)) is True
|
||||
with sf() as session:
|
||||
job = SqlAlchemyJobRepository(session).get(job_id)
|
||||
assert job is not None and job.status == JobStatus.CANCELLED
|
||||
# 已取消不可再次取消
|
||||
assert je.cancel_job(job_id, session_factory=sf,
|
||||
job_repo_factory=lambda s: SqlAlchemyJobRepository(s)) is False
|
||||
|
||||
def test_cancel_completed_false(self, sf) -> None:
|
||||
job_id = "JOB-DONE"
|
||||
_create(sf, job_id)
|
||||
with sf() as session:
|
||||
repo = SqlAlchemyJobRepository(session)
|
||||
job = repo.get(job_id)
|
||||
job.status = JobStatus.SUCCESS
|
||||
repo.update(job)
|
||||
session.commit()
|
||||
assert je.cancel_job(job_id, session_factory=sf,
|
||||
job_repo_factory=lambda s: SqlAlchemyJobRepository(s)) is False
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(tmp_path, monkeypatch):
|
||||
engine = create_engine(f"sqlite:///{tmp_path / 'api.db'}", future=True)
|
||||
Base.metadata.create_all(engine)
|
||||
Session = sessionmaker(bind=engine, expire_on_commit=False)
|
||||
# 后台执行(JOB_MODE=local)走全局 SessionLocal → 指向同一测试库
|
||||
from app.infrastructure.persistence.sqlalchemy import session as sess_mod
|
||||
|
||||
monkeypatch.setattr(sess_mod, "SessionLocal", Session)
|
||||
|
||||
def _session_override():
|
||||
with Session() as s:
|
||||
yield s
|
||||
|
||||
app.dependency_overrides[deps.get_session] = _session_override
|
||||
with TestClient(app) as c:
|
||||
yield c
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
class TestJobsApi:
|
||||
def test_list_and_cancel_finished(self, client) -> None:
|
||||
# 提交一个会失败(未知因子)的 job:后台同步执行完 → failed
|
||||
payload = _spec(factors=[{"name": "no_such", "weight": 1.0}]).model_dump(mode="json")
|
||||
resp = client.post("/api/jobs", json=payload)
|
||||
assert resp.status_code == 200
|
||||
job_id = resp.json()["job_id"]
|
||||
# 等待后台任务终态(TestClient 同步执行后台任务于响应后)
|
||||
state = None
|
||||
for _ in range(30):
|
||||
state = client.get(f"/api/jobs/{job_id}").json()
|
||||
if state["status"] in ("success", "failed", "cancelled"):
|
||||
break
|
||||
assert state["status"] == "failed" # 未知因子 → 失败
|
||||
# 列表含该 job
|
||||
rows = client.get("/api/jobs").json()
|
||||
assert any(r["id"] == job_id for r in rows)
|
||||
# 已完成(失败)不可取消
|
||||
cancel = client.post(f"/api/jobs/{job_id}/cancel").json()
|
||||
assert cancel["cancelled"] is False
|
||||
assert client.post("/api/jobs/JOB-NOPE/cancel").status_code == 404
|
||||
Reference in New Issue
Block a user