通过调度系统接入

This commit is contained in:
zlt 2026-08-03 16:33:23 +08:00
parent f91a2f86e7
commit de7c340342
4 changed files with 424 additions and 1 deletions

5
api.py
View File

@ -10,7 +10,8 @@ cron 的 docker exec 构建/出计划照旧,互不影响。局域网内部服
GET /plan/dates 可用日期列表
POST /plan/refresh?date=... 重新生成该日计划文件data/plan/*.md
将来接 XXL-JOB / 事件回调触发器打这层即可不必进容器
统一任务调度平台XXL-JOB触发入口挂在 /api/v1/xxl/* xxl.py2026-08-03
盘前链build plan push-pool可由平台拉起并回调结案.env XXL_TRIGGER_KEY 才启用
"""
import pandas as pd
from fastapi import FastAPI, HTTPException
@ -18,8 +19,10 @@ from fastapi.responses import PlainTextResponse
import db
import plan
from xxl import router as xxl_router
app = FastAPI(title="akg-factor-bridge · 每日选股计划", version="0.1")
app.include_router(xxl_router)
@app.get("/health")

View File

@ -147,6 +147,12 @@ ENABLE_TRACK_GATE = os.environ.get("ENABLE_TRACK_GATE", "0") == "1"
# 因此不进主榜、观察档、计划与升降档。研究口径想看全貌时置 0 关闭。
EXCLUDE_RISK_NAMES = os.environ.get("EXCLUDE_RISK_NAMES", "1") == "1"
# --- 统一任务调度平台触发08-03键名与决策系统保持一致便于平台侧统一配置----
# 空串 = /api/v1/xxl/* 整组端点禁用(安全默认)。回调契约见 xxl.py 模块说明。
XXL_TRIGGER_KEY = os.environ.get("XXL_TRIGGER_KEY", "")
XXL_CALLBACK_READ_TIMEOUT = float(os.environ.get("XXL_CALLBACK_READ_TIMEOUT", "10"))
XXL_CALLBACK_MAX_RETRIES = int(os.environ.get("XXL_CALLBACK_MAX_RETRIES", "3"))
# --- 选股计划入池08-03 定稿;规则与流程见 docs/选股计划入池_对接说明.md------
# 写 Mongo stock_groups 的独立分组,决策系统每晚扫描按分组并集覆盖 → 候选票自动
# 获得夜间推理。入池范围与 PMS 候选同口径:强传导主榜前 POOL_TOP 只 + 当前持仓。

208
test_xxl_trigger.py Normal file
View File

@ -0,0 +1,208 @@
# -*- coding: utf-8 -*-
"""平台触发入口xxl.py的单测——子进程与回调都打桩不跑真链、不发真请求。
运行: docker compose exec -T akg-factor-bridge python test_xxl_trigger.py
全过输出 "ALL PASS (n cases)"任一失败退出码 1
"""
import sys
import time
import traceback
from fastapi import HTTPException
import config
import xxl
RESULTS = []
def case(name):
def deco(fn):
RESULTS.append((name, fn))
return fn
return deco
def _reset(key="test-key"):
"""每例开跑前清场:配置好 key、清任务表、装默认桩。"""
config.XXL_TRIGGER_KEY = key
config.XXL_CALLBACK_MAX_RETRIES = 0
xxl._jobs.clear()
xxl._running_id = None
ran, sent = [], []
xxl._run_step = lambda cmd: (ran.append(list(cmd)), (0, "ok"))[1]
xxl._post_callback = lambda url, data: (sent.append((url, data)), "delivered http=200")[1]
return ran, sent
def _wait_done(task_id, timeout=5.0):
"""等到任务连同结案回调都处理完closed=True避免断言撞上后台线程的时间差。"""
t0 = time.time()
while time.time() - t0 < timeout:
if xxl._jobs[task_id].get("closed"):
return xxl._jobs[task_id]
time.sleep(0.02)
raise AssertionError("任务超时未结束")
def _trigger(**kw):
args = dict(callbackUrl=None, steps="build,plan,push-pool", date=None,
key="test-key", x_job_key=None)
args.update(kw)
return xxl.trigger_daily_build(**args)
@case("没配 XXL_TRIGGER_KEY → 整组端点禁用 (403),与决策系统同款安全默认")
def _():
_reset(key="")
try:
_trigger(key="whatever")
raise AssertionError("该 403 没 403")
except HTTPException as e:
assert e.status_code == 403, e
@case("key 不对 → 401对了 → 受理并返回 task_id")
def _():
ran, _ = _reset()
try:
_trigger(key="wrong")
raise AssertionError("该 401 没 401")
except HTTPException as e:
assert e.status_code == 401, e
r = _trigger()
assert r["status"] == "accepted" and r["task_id"], r
_wait_done(r["task_id"])
@case("三步按固定顺序跑完 → success成功回调是空包")
def _():
ran, sent = _reset()
r = _trigger(callbackUrl="http://xxl/callback?logId=1")
job = _wait_done(r["task_id"])
assert job["state"] == "success", job
assert [c[1] for c in ran] == ["build", "plan", "push-pool"], ran
assert sent and sent[0][0].startswith("http://xxl/callback"), sent
assert sent[0][1] == b"", sent # 成功=空包,对端按成功结案
@case("中间一步失败 → 后面的步骤不跑,失败回调带 handleCode=500 与原因")
def _():
ran, sent = _reset()
xxl._run_step = lambda cmd: (ran.append(list(cmd)),
(1, "boom") if cmd[1] == "plan" else (0, "ok"))[1]
r = _trigger(callbackUrl="http://xxl/cb")
job = _wait_done(r["task_id"])
assert job["state"] == "failed" and "plan" in job["note"], job
assert [c[1] for c in ran] == ["build", "plan"], ran # push-pool 没跑
body = sent[0][1].decode()
assert "handleCode=500" in body and "plan" in body, body
@case("同一时刻只允许一条链: 在跑时再触发 → 409")
def _():
_reset()
gate = {"open": False}
def slow(cmd):
while not gate["open"]:
time.sleep(0.01)
return 0, "ok"
xxl._run_step = slow
r1 = _trigger(steps="build")
try:
_trigger(steps="build")
raise AssertionError("该 409 没 409")
except HTTPException as e:
assert e.status_code == 409, e
finally:
gate["open"] = True
_wait_done(r1["task_id"])
r2 = _trigger(steps="build") # 跑完后可以再触发
_wait_done(r2["task_id"])
@case("steps 白名单与顺序: 乱序请求被摆正, 未知步骤 400")
def _():
ran, _ = _reset()
r = _trigger(steps="push-pool,build")
_wait_done(r["task_id"])
assert [c[1] for c in ran] == ["build", "push-pool"], ran # 顺序固定
try:
_trigger(steps="build,发大财")
raise AssertionError("该 400 没 400")
except HTTPException as e:
assert e.status_code == 400, e
@case("date 参数透传到每一步命令")
def _():
ran, _ = _reset()
r = _trigger(steps="build,plan", date="2026-08-01")
_wait_done(r["task_id"])
for cmd in ran:
assert cmd[-2:] == ["--date", "2026-08-01"], cmd
@case("无 callbackUrl 也能正常结案 (只记日志, 平台走状态查询)")
def _():
_, sent = _reset()
r = _trigger(callbackUrl=None, steps="build")
job = _wait_done(r["task_id"])
assert job["state"] == "success" and not sent
@case("状态接口: 能查到 state/steps/tail; 不认识的 task_id 404; 也要带 key")
def _():
_reset()
r = _trigger(steps="build")
_wait_done(r["task_id"])
st = xxl.job_status(r["task_id"], key="test-key", x_job_key=None)
assert st["state"] == "success" and st["steps"] == ["build"] and st["tail"]
try:
xxl.job_status("no-such-id", key="test-key", x_job_key=None)
raise AssertionError("该 404 没 404")
except HTTPException as e:
assert e.status_code == 404, e
try:
xxl.job_status(r["task_id"], key="wrong", x_job_key=None)
raise AssertionError("该 401 没 401")
except HTTPException as e:
assert e.status_code == 401, e
@case("回调连接层失败: 重试次数用尽后放弃, 任务状态不受影响")
def _():
_reset()
config.XXL_CALLBACK_MAX_RETRIES = 0 # 失败一次即放弃, 免得测试睡 30 秒
def dead(url, data):
raise OSError("connect refused")
xxl._post_callback = dead
r = _trigger(callbackUrl="http://xxl/cb", steps="build")
job = _wait_done(r["task_id"])
assert job["state"] == "success", job # 回调失败不改任务结论
assert any("放弃" in ln for ln in job["tail"]), job["tail"]
# ---------------------------------------------------------------- runner
def main():
passed, failed = 0, 0
for name, fn in RESULTS:
try:
fn()
print(f" PASS {name}")
passed += 1
except Exception:
print(f" FAIL {name}")
traceback.print_exc()
failed += 1
print("-" * 60)
if failed:
print(f"FAILED: {failed} / {passed + failed}")
sys.exit(1)
print(f"ALL PASS ({passed} cases)")
if __name__ == "__main__":
main()

206
xxl.py Normal file
View File

@ -0,0 +1,206 @@
"""统一任务调度平台XXL-JOB触发入口 —— 盘前链的外部拉起与结案回调。
参照决策系统 bionic_trader 的同款接入app/api/xxl_jobs.py trigger_daily_scan +
tasks_periodic notify_xxl对平台表现完全一致
触发: GET|POST /api/v1/xxl/daily-build?key=...&steps=...&date=...
立即返回 200 + task_id平台会自动在 URL 末尾追加 &callbackUrl=...
状态: GET /api/v1/xxl/status/{task_id} 回调未送达时的兜底查询
结案: 任务真正跑完后 POST callbackUrl
成功 = 空包对端默认按成功结案
失败 = 表单 {"handleCode": 500, "handleMsg": 原因}
HTTP 响应 4xx/5xx即算送达不重试只有连接层失败不可达/超时
才隔 30 秒重试最多 XXL_CALLBACK_MAX_RETRIES 放弃后靠状态查询兜底
与决策系统那套只有一处实现差别桥没有 celery任务由 API 进程起一条后台线程
逐步以**子进程**方式执行 `python run.py <步骤>`用子进程而不是进程内调用是为了
保住每次执行都重读 .env重载代码的既有性质常驻 API 自身不会自动重载
但它派生的子进程会和宿主 cron docker exec 语义完全一致
安全默认与决策系统相同.env 不配 XXL_TRIGGER_KEY这一组端点整体禁用
同一时刻只允许一条链在跑已有任务未结束时再触发回 409由平台按失败重试策略处理
步骤白名单steps 参数逗号分隔默认三步全跑执行顺序固定
build 因子构建run.py build all --mode daily
plan 生成当日选股计划文件
push-pool 计划写入股票池 + 触发决策系统增量补扫
"""
import datetime as dt
import os
import subprocess
import sys
import threading
import time
import urllib.error
import urllib.parse
import urllib.request
import uuid
from fastapi import APIRouter, Header, HTTPException, Query
import config
router = APIRouter(prefix="/api/v1/xxl", tags=["xxl"])
HERE = os.path.dirname(os.path.abspath(__file__))
LOG_PATH = os.path.join(HERE, "data", "xxl_build.log")
# 步骤 → 命令(顺序即执行顺序;--date 由触发参数统一追加)
STEP_ORDER = ("build", "plan", "push-pool")
STEP_CMDS = {
"build": ["run.py", "build", "all", "--mode", "daily"],
"plan": ["run.py", "plan"],
"push-pool": ["run.py", "push-pool"],
}
STEP_TIMEOUT_SEC = 3600 # 单步上限一小时,防呆死(正常盘前链全程分钟级)
_lock = threading.Lock()
_jobs: dict = {} # task_id -> 状态字典(常驻进程内存,重启即清空)
_running_id = None
_KEEP_JOBS = 20 # 只留最近 20 条状态,防内存慢涨
def _require_key(x_job_key, key):
cfg = config.XXL_TRIGGER_KEY
if not cfg:
raise HTTPException(status_code=403,
detail="XXL trigger disabled: XXL_TRIGGER_KEY not set")
if (x_job_key or key) != cfg:
raise HTTPException(status_code=401, detail="invalid key")
def _log(job, line: str):
stamp = dt.datetime.now().strftime("%H:%M:%S")
job["tail"].append(f"[{stamp}] {line}")
job["tail"] = job["tail"][-60:]
try:
os.makedirs(os.path.dirname(LOG_PATH), exist_ok=True)
with open(LOG_PATH, "a", encoding="utf-8") as f:
f.write(f"{dt.datetime.now().isoformat(timespec='seconds')} "
f"[{job['task_id'][:8]}] {line}\n")
except Exception: # noqa: BLE001 —— 落盘日志失败不影响任务本体
pass
def _run_step(cmd: list) -> tuple:
"""跑一步(子进程)。单测在这里打桩。返回 (退出码, 合并输出)。"""
p = subprocess.run([sys.executable] + cmd, cwd=HERE, capture_output=True,
text=True, timeout=STEP_TIMEOUT_SEC)
return p.returncode, (p.stdout or "") + (p.stderr or "")
def _post_callback(url: str, data: bytes) -> str:
"""回调一跳(单测在这里打桩)。返回送达说明;连接层失败抛异常。"""
req = urllib.request.Request(url, data=data, method="POST")
try:
with urllib.request.urlopen(req, timeout=config.XXL_CALLBACK_READ_TIMEOUT) as resp:
return f"delivered http={resp.status}"
except urllib.error.HTTPError as e:
return f"delivered http={e.code}" # 对端有响应即算送达,不重试
def _notify(job, callback_url: str, ok: bool, note: str):
"""结案回调,契约与决策系统 notify_xxl 一致。"""
if not callback_url:
_log(job, "无 callbackUrl跳过结案回调平台可查状态接口")
return
data = (b"" if ok else urllib.parse.urlencode(
{"handleCode": 500, "handleMsg": (note or "job failed")[:500]}).encode())
for attempt in range(config.XXL_CALLBACK_MAX_RETRIES + 1):
try:
_log(job, f"结案回调 ok={ok}: {_post_callback(callback_url, data)}")
return
except Exception as e: # noqa: BLE001 —— 连接层失败才会走到这
if attempt >= config.XXL_CALLBACK_MAX_RETRIES:
_log(job, f"结案回调最终未达,放弃(靠状态查询兜底): {e!r}")
return
_log(job, f"结案回调连接失败30 秒后第 {attempt + 1} 次重试: {e!r}")
time.sleep(30)
def _run_job(task_id: str, steps: list, date, callback_url):
global _running_id
job = _jobs[task_id]
ok, note = True, ""
try:
for step in steps:
job["step"] = step
cmd = list(STEP_CMDS[step]) + (["--date", date] if date else [])
_log(job, f"开始 {step}: python {' '.join(cmd)}")
try:
code, out = _run_step(cmd)
except subprocess.TimeoutExpired:
ok, note = False, f"{step} 超过 {STEP_TIMEOUT_SEC} 秒未结束,判失败"
_log(job, note)
break
for line in (out or "").strip().splitlines()[-15:]:
_log(job, f" {line}")
if code != 0:
ok, note = False, f"{step} 退出码 {code}"
_log(job, note)
break
_log(job, f"完成 {step}")
except Exception as e: # noqa: BLE001 —— 线程里绝不让异常无声消失
ok, note = False, f"意外异常: {type(e).__name__}: {e}"
_log(job, note)
job.update(state="success" if ok else "failed", note=note,
ended_at=dt.datetime.now().isoformat(timespec="seconds"), step=None)
with _lock:
_running_id = None
_notify(job, callback_url, ok, note)
job["closed"] = True # 结案回调也处理完了(送达或放弃),状态接口可据此判断
@router.api_route("/daily-build", methods=["GET", "POST"])
def trigger_daily_build(
callbackUrl: str = Query(None, description="平台下发的结案回调地址(自动追加)"),
steps: str = Query("build,plan,push-pool",
description="要跑哪几步,逗号分隔;顺序固定 build→plan→push-pool"),
date: str = Query(None, description="补跑指定数据日 YYYY-MM-DD空=最新数据日"),
key: str = Query(None),
x_job_key: str = Header(None),
):
"""触发盘前链。立即返回 task_id跑完由结案回调通知平台。
平台执行参数示例方式 GETkey 写进 URL平台会自己追加 callbackUrl
http://<桥机IP>:8300/api/v1/xxl/daily-build?key=<KEY>
"""
global _running_id
_require_key(x_job_key, key)
wanted = [s.strip() for s in (steps or "").split(",") if s.strip()]
bad = [s for s in wanted if s not in STEP_CMDS]
if bad or not wanted:
raise HTTPException(status_code=400,
detail=f"steps 只认 {list(STEP_ORDER)},收到 {wanted}")
ordered = [s for s in STEP_ORDER if s in wanted]
with _lock:
if _running_id and _jobs.get(_running_id, {}).get("state") == "running":
raise HTTPException(status_code=409,
detail=f"已有任务在跑task_id={_running_id}),本次拒绝")
task_id = str(uuid.uuid4())
_jobs[task_id] = {"task_id": task_id, "state": "running", "steps": ordered,
"step": None, "date": date, "note": "", "tail": [],
"started_at": dt.datetime.now().isoformat(timespec="seconds"),
"ended_at": None, "closed": False}
while len(_jobs) > _KEEP_JOBS: # 只留最近 N 条
oldest = next(iter(_jobs))
if oldest == task_id:
break
_jobs.pop(oldest)
_running_id = task_id
threading.Thread(target=_run_job, args=(task_id, ordered, date, callbackUrl),
daemon=True).start()
return {"status": "accepted", "task_id": task_id, "steps": ordered, "date": date}
@router.get("/status/{task_id}")
def job_status(task_id: str, key: str = Query(None), x_job_key: str = Header(None)):
"""状态兜底running / success / failed + 当前步骤 + 最近输出。"""
_require_key(x_job_key, key)
job = _jobs.get(task_id)
if not job:
raise HTTPException(status_code=404,
detail="task_id 不存在API 重启会清空历史状态)")
return job