Files
shy3130 badab07619 feat(adj): 除权因子独立同步 + 事件明细扩列 + 按需窗口优化
- 扩列: adj_factor 表扩至 8 列(dividend/bonus/allot/allot_price/prev_close 明细, 可空), fuyao 从事件 dump 原始成分回填; merge 变更检测按 ex_factor diff>1e-9, 仅明细回填不计入受影响列表; 字段说明补齐
- 独立同步: /api/pipeline/adj-factor/run 端点(共用任务机制: 单飞+重任务槽+行情暂停), Data 页除权因子卡片设置齿轮弹窗(生效数据源/能力徽标/TickFlow 无明细提示), 因子变更个股自动 enriched 局部重算
- 按需优化: 独立同步事件窗口钳到本地日K起点(更早事件在前复权 cum/total 两侧抵消, 对已存价格零影响); fuyao 配价由全有或全无改为逐只按需(远古事件只拖累自己, 不再整轮回退单标的接口); 实测全市场因子阶段 15min+ → ~11s, 配价 3676/3676 走本地 dump 零回退
- 盘后管道步骤条显示各能力路由徽标(矩阵驱动); README 补桌面端 macOS 安装注意事项
- 测试: 明细列 merge 语义 4 例 / 独立同步联动 5 例 / 远古事件不连累全场回归; 全量回归通过(2 例历史遗留失败与本改动无关, 基线同样失败)
2026-10-07 21:45:40 +08:00

163 lines
6.3 KiB
Python
Raw Permalink 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.
"""盘后管道 API — 异步触发 + 进度跟踪。"""
from __future__ import annotations
import asyncio
import concurrent.futures as _cf
import logging
from fastapi import APIRouter, HTTPException, Request
from app.jobs import daily_pipeline
from app.services.pipeline_jobs import (
JobCancelledError,
job_store,
release_run_slot,
run_with_capacity,
try_acquire_run_slot,
)
from app.api.data import invalidate_storage_cache
# 长时间任务专用线程池(隔离于 FastAPI 默认线程池,防止阻塞请求处理)
_long_task_executor = _cf.ThreadPoolExecutor(max_workers=2, thread_name_prefix="long-task")
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/pipeline", tags=["pipeline"])
@router.post("/run")
async def run_now(request: Request) -> dict:
"""异步触发盘后管道,立即返回 job_id。客户端轮询 /jobs/{id} 拿进度。"""
return await trigger_pipeline_job(
request.app.state.repo,
request.app.state.capabilities,
getattr(request.app.state, "quote_service", None),
)
async def trigger_pipeline_job(repo, capset, quote_service=None) -> dict:
"""触发盘后管道后台任务(HTTP 端点与 AI 助手共用同一条触发路径)。
若已有任务在跑,**返回该任务 id 而不是开新任务**(防止并发拉数据撞限流)。
卡死判定按「进度停滞」而非总时长(慢带宽下长任务不会被误杀), 见 reap_stale。
"""
return await _submit_job(
repo, quote_service,
lambda progress: (lambda: daily_pipeline.run_now(repo, capset, on_progress=progress)),
)
async def _submit_job(repo, quote_service, make_run) -> dict:
"""任务化执行公共机制: 单飞 create → 重任务槽 → 行情暂停 → 缓存失效。
make_run(progress) 返回实际执行闭包(阻塞 IO + CPU, 在 executor 里跑)。
盘后管道与独立除权因子同步共用 —— 两者写同一批 parquet, 必须共享
单飞与重任务槽防并发覆写。
"""
# 检测卡死的 running job (如 reload 后孤儿 task / 网络读无限阻塞)。
# reap_stale 会在 /run 和 /jobs/{id} 轮询端点都调用,保证卡死后能自愈。
job_store.reap_stale()
# 单飞: 复用任何活跃 (pending∨running) 任务, is_new=False 时不再调度新任务
job_id, is_new = job_store.create()
if not is_new:
return {"job_id": job_id, "reused": True}
def progress(stage: str, pct: int, msg: str, stage_pct: int | None = None,
skip_log: bool = False) -> None:
job_store.progress(job_id, stage, pct, msg, stage_pct=stage_pct, skip_log=skip_log)
run = make_run(progress)
# 在 executor 里跑同步任务(pipeline 内部都是阻塞 IO + CPU)
async def task() -> None:
# 重任务执行槽: 防僵尸并发(reap 后线程仍活时新任务不得并行写 parquet)
if not try_acquire_run_slot(job_id):
job_store.fail(job_id, "已有数据任务在运行(或上一次任务卡死未结束),请稍后再试")
return
# 任务运行期间暂停实时行情取数, 防止覆写同一批 parquet 竞态
qs = quote_service
try:
loop = asyncio.get_event_loop()
def _run() -> dict:
try:
if qs:
with qs.paused():
return run()
return run()
finally:
repo.refresh_cache()
result = await loop.run_in_executor(_long_task_executor, run_with_capacity, job_id, _run)
job_store.succeed(job_id, result)
invalidate_storage_cache()
except JobCancelledError:
# 已被 reap/手动取消终止: job 状态已由 terminate() 写为 failed,
# 拉取线程在分块回调处自行退出, 这里无需(也无法)再写状态。
logger.warning("pipeline job %s cancelled", job_id)
except Exception as e: # noqa: BLE001
logger.exception("pipeline job failed")
job_store.fail(job_id, str(e))
invalidate_storage_cache()
finally:
release_run_slot(job_id)
asyncio.create_task(task())
return {"job_id": job_id, "reused": False}
@router.post("/adj-factor/run")
async def run_adj_factor_now(request: Request) -> dict:
"""独立同步除权因子(全历史 + 受影响个股 enriched 局部重算), 任务化+进度轮询。
与盘后管道共用单飞与重任务槽 (两者写同一批 parquet); 门控与矩阵
usable 同口径: TickFlow 档位不足且未路由到其他可用源时 400。
"""
from app.services import preferences
from app.tickflow.capabilities import Cap
capset = request.app.state.capabilities
if not (capset.has(Cap.ADJ_FACTOR) or preferences.get_adj_factor_provider() != "tickflow"):
raise HTTPException(
status_code=400,
detail="除权因子能力不可用: 请先在 设置 → 数据源 中配置可用数据源",
)
repo = request.app.state.repo
return await _submit_job(
repo,
getattr(request.app.state, "quote_service", None),
lambda progress: (lambda: daily_pipeline.run_adj_factor_sync(repo, capset, on_progress=progress)),
)
@router.get("/jobs/{job_id}")
def get_job(job_id: str) -> dict:
# 每次轮询都检查卡死 job — 前端持续轮询, 进度停滞超阈值后必定自愈,
# 无需用户再次手动点「同步」。
job_store.reap_stale()
j = job_store.get(job_id)
if not j:
raise HTTPException(status_code=404, detail="job not found")
return j
@router.post("/jobs/{job_id}/cancel")
def cancel_job(job_id: str) -> dict:
"""手动取消一个 running 的 job(协作式: 拉取线程在当前分块完成后自行退出)。"""
j = job_store.get(job_id)
if not j:
raise HTTPException(status_code=404, detail="job not found")
if j["status"] not in ("running", "pending"):
raise HTTPException(status_code=400, detail=f"job status is {j['status']}, cannot cancel")
job_store.terminate(job_id, "用户手动取消")
return {"cancelled": job_id}
@router.get("/jobs")
def list_jobs(limit: int = 20) -> dict:
return {
"active_id": job_store.active_id(),
"jobs": job_store.list_recent(limit=limit),
}