feat(job): D2 全市场选股 Job 化(kind=selection 异步)

- Job kind=selection:executor 分支用 SelectionService(spec=SelectionQuery),结果
  落 result_json、自动归档 Experiment(summary:as_of + 选出 N);/api/jobs 与
  /api/experiments 的 result 解码支持 SelectionResult
- POST /api/selections/jobs:提交 SelectionQuery 为异步 Job(BackgroundTasks/subprocess)
- Web 选股页新增「异步(全市场)」按钮:提交 Job → waitJob 轮询结果(解决同步 60s+)
- tests/test_selection_job.py:executor 执行归档(SelectionResult/Experiment kind)、
  API 提交→轮询→结果与实验列表;全量 pytest + tsc 通过
This commit is contained in:
Simon
2026-09-09 07:43:35 +08:00
parent ed54096331
commit 861a4051ca
6 changed files with 211 additions and 13 deletions
+4
View File
@@ -36,8 +36,12 @@ router = APIRouter(prefix="/jobs", tags=["jobs"])
def _decode_result(kind: str, result_json: str | None):
from app.domain.entities.selection import SelectionResult
if result_json is None:
return None
if kind == "selection":
return SelectionResult.model_validate_json(result_json)
model = BacktestResult if kind == "backtest" else FactorTestReport
return model.model_validate_json(result_json)
+25 -3
View File
@@ -9,18 +9,20 @@ GET /api/selections 历史选股元数据(可过滤 as_of/method)
from __future__ import annotations
from datetime import date
from datetime import date, datetime
from typing import Annotated
from fastapi import APIRouter, HTTPException, Query
from fastapi import APIRouter, BackgroundTasks, HTTPException, Query
from pydantic import BaseModel
from app.api.deps import (
DbSession,
JobRepoDep,
SelectionRepoDep,
SelectionServiceDep,
)
from app.application.services.job_executor import new_id
from app.application.services.job_executor import new_id, run_job_background
from app.domain.entities.research import JobRecord, JobStatus
from app.domain.entities.selection import SelectionMeta, SelectionQuery, SelectionResult
router = APIRouter(prefix="/selections", tags=["selections"])
@@ -31,6 +33,26 @@ class SelectionRun(BaseModel):
result: SelectionResult
@router.post("/jobs", summary="提交全市场/长任务选股为异步 Job")
def submit_selection_job(
query: SelectionQuery,
background: BackgroundTasks,
session: DbSession,
job_repo: JobRepoDep,
) -> dict:
job = JobRecord(
id=new_id("JOB"),
kind="selection",
spec_json=query.model_dump_json(),
status=JobStatus.QUEUED,
created_at=datetime.now(),
)
job_repo.create(job)
session.commit()
background.add_task(run_job_background, job.id)
return {"job_id": job.id, "status": job.status}
@router.post("", response_model=SelectionRun, summary="执行一次选股(同步)并落库")
def run_selection(
query: SelectionQuery,
@@ -61,12 +61,16 @@ def _git_short_rev() -> str | None:
return None
def _summary_text(kind: str, result: BacktestResult | FactorTestReport) -> str | None:
def _summary_text(kind: str, result) -> str | None:
from app.domain.entities.selection import SelectionResult
if kind == "backtest" and isinstance(result, BacktestResult):
s = result.summary
return f"总收益 {s.total_return_pct:.2f}% · 年化 {s.annual_return_pct:.2f}% · 回撤 {s.max_drawdown_pct:.2f}%"
if isinstance(result, FactorTestReport):
return f"IC {result.ic_mean:.4f} · RankIC {result.rank_ic_mean:.4f} · 样本 {result.sample_days} 日"
if kind == "selection" and isinstance(result, SelectionResult):
return f"as_of {result.as_of_date} · 选出 {result.statistics.selected} / 评估 {result.statistics.evaluated}"
return None
@@ -91,10 +95,20 @@ def _execute_inner(
job_repo.update(job)
session.commit()
try:
spec = ResearchSpec.model_validate_json(job.spec_json)
service = ResearchService(
stock_repo_factory(session), daily_repo_factory(session), engine
)
is_selection = job.kind == "selection"
if is_selection:
from app.application.services.selection_service import SelectionService
from app.domain.entities.selection import SelectionQuery
spec = SelectionQuery.model_validate_json(job.spec_json)
service = SelectionService(
stock_repo_factory(session), daily_repo_factory(session)
)
else:
spec = ResearchSpec.model_validate_json(job.spec_json)
service = ResearchService(
stock_repo_factory(session), daily_repo_factory(session), engine
)
def _set_stage(name: str) -> None:
"""阶段上报(v3 §23):独立短会话写 job.stage 并 commit(子进程同样走 DB)。"""
@@ -108,7 +122,10 @@ def _execute_inner(
except Exception: # noqa: BLE001 —— 阶段上报失败不阻断执行
pass
if spec.type == "backtest":
if is_selection:
_set_stage("selection")
result = service.select(spec)
elif spec.type == "backtest":
result = service.run_backtest(spec, on_stage=_set_stage)
else:
result = service.run_factor_test(spec, on_stage=_set_stage)
@@ -116,10 +133,10 @@ def _execute_inner(
result_json = json.dumps(result.model_dump(mode="json"), ensure_ascii=False)
experiment = ExperimentRecord(
id=new_id("EXP"),
kind=spec.type,
kind=job.kind,
spec_json=job.spec_json,
result_json=result_json,
summary_text=_summary_text(spec.type, result),
summary_text=_summary_text(job.kind, result),
code_version=_git_short_rev(),
job_id=job.id,
created_at=datetime.now(),