chore: 重构前基线 — 9 个 sync task 全部 ok + akshare 移除 + mairui 资金流接入
状态: - 9 个 sync task(stock_basic / kline_daily / kline_index / kline_5min / moneyflow / industry_sector / sector_features / share_snapshot / market_regime) - 数据源:baostock + mairui + 雪球(pysnowball) + 新浪(4 个) - 项目级约束:永远不用 akshare(已落实) - kline_5min 改用 DB 快照统一全量/增量逻辑 - 零后端 Chrome 扩展 xueqiu_sync(独立项目)
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
"""scheduler 子包入口。"""
|
||||
from app.core.scheduler.scheduler import (
|
||||
start_scheduler,
|
||||
stop_scheduler,
|
||||
get_scheduler_status,
|
||||
seed_schedule_configs,
|
||||
register_job,
|
||||
is_trading_day,
|
||||
next_trading_day,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"start_scheduler",
|
||||
"stop_scheduler",
|
||||
"get_scheduler_status",
|
||||
"seed_schedule_configs",
|
||||
"register_job",
|
||||
"is_trading_day",
|
||||
"next_trading_day",
|
||||
]
|
||||
@@ -0,0 +1,392 @@
|
||||
"""轻量级定时任务调度器(照搬 dashboard/api/services/scheduler.py 模式)。
|
||||
|
||||
后台线程每 N 秒扫描 config 表中 category='schedule' 的任务,到达触发时间时
|
||||
执行对应的 job 函数。执行记录写回 config 表。
|
||||
|
||||
任务定义字段(存在 config 表,value 是 JSON):
|
||||
{
|
||||
"name": "...",
|
||||
"time": "15:40", # 触发时间(HH:MM),intervalSeconds=0 时必填
|
||||
"intervalSeconds": 0, # > 0 时按秒触发
|
||||
"condition": "trading_day", # always | trading_day
|
||||
"job": "sync_kline_daily", # 已注册的 job 名
|
||||
"enabled": true,
|
||||
"lastRun": "", "lastStatus": "", "lastMessage": ""
|
||||
}
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
from datetime import date, datetime, timedelta
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.db import ops as db_ops
|
||||
from app.core.utils.logging import get_logger
|
||||
|
||||
logger = get_logger("scheduler")
|
||||
|
||||
|
||||
# ── 交易日判断 ─────────────────────────────────────────────────────────
|
||||
|
||||
_KNOWN_HOLIDAYS: set[str] = set()
|
||||
|
||||
|
||||
def _init_holidays() -> None:
|
||||
"""从 config 表读出休市日(key=trading_calendar_holidays, category=general)。"""
|
||||
global _KNOWN_HOLIDAYS
|
||||
r = db_ops.fetch_config_by_key("trading_calendar_holidays")
|
||||
if r and r.get("category") == "general":
|
||||
try:
|
||||
holidays = json.loads(r.get("value", "") or "[]")
|
||||
if isinstance(holidays, list):
|
||||
_KNOWN_HOLIDAYS = {h for h in holidays if isinstance(h, str)}
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
|
||||
def is_trading_day(d: Optional[date] = None) -> bool:
|
||||
"""判断 A 股交易日(简化:周一至周五 + 非 _KNOWN_HOLIDAYS)。"""
|
||||
if d is None:
|
||||
d = date.today()
|
||||
if d.weekday() >= 5:
|
||||
return False
|
||||
if d.isoformat() in _KNOWN_HOLIDAYS:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def next_trading_day(after: Optional[date] = None) -> date:
|
||||
d = after or date.today()
|
||||
while not is_trading_day(d):
|
||||
d += timedelta(days=1)
|
||||
return d
|
||||
|
||||
|
||||
# ── Job registry ──────────────────────────────────────────────────────
|
||||
|
||||
_job_registry: dict[str, Callable[[], dict]] = {}
|
||||
|
||||
|
||||
def register_job(name: str):
|
||||
"""装饰器:注册一个 job 函数。函数应返回 {status, message, ...}。"""
|
||||
def decorator(fn):
|
||||
_job_registry[name] = fn
|
||||
return fn
|
||||
return decorator
|
||||
|
||||
|
||||
# ── Scheduler 状态 ────────────────────────────────────────────────────
|
||||
|
||||
_lock = threading.Lock()
|
||||
_running = False
|
||||
_thread: Optional[threading.Thread] = None
|
||||
|
||||
# 配置缓存:避免每次 tick 全量查 config 表
|
||||
_config_cache_ts: float = 0.0
|
||||
_config_cache: list[dict] = []
|
||||
_CONFIG_CACHE_TTL = 10.0 # 缓存 10 秒,减少 DB 查询
|
||||
|
||||
|
||||
def get_scheduler_status() -> dict[str, Any]:
|
||||
with _lock:
|
||||
return {
|
||||
"running": _running,
|
||||
"jobs": _load_job_statuses(),
|
||||
}
|
||||
|
||||
|
||||
def _load_schedule_configs() -> list[dict]:
|
||||
"""从 config 表读出所有 category='schedule' 的任务定义(带缓存)。"""
|
||||
global _config_cache_ts, _config_cache
|
||||
now = time.time()
|
||||
if now - _config_cache_ts < _CONFIG_CACHE_TTL and _config_cache:
|
||||
return _config_cache
|
||||
result = []
|
||||
for r in db_ops.fetch_configs_by_category("schedule"):
|
||||
try:
|
||||
obj = json.loads(r.get("value", "") or "{}")
|
||||
obj["_key"] = r["key"]
|
||||
result.append(obj)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
continue
|
||||
_config_cache = result
|
||||
_config_cache_ts = now
|
||||
return result
|
||||
|
||||
|
||||
def _load_job_statuses() -> list[dict]:
|
||||
cfgs = _load_schedule_configs()
|
||||
return [
|
||||
{
|
||||
"key": c["_key"],
|
||||
"name": c.get("name", ""),
|
||||
"time": c.get("time", ""),
|
||||
"intervalSeconds": c.get("intervalSeconds", 0),
|
||||
"condition": c.get("condition", "always"),
|
||||
"job": c.get("job", ""),
|
||||
"enabled": c.get("enabled", True),
|
||||
"lastRun": c.get("lastRun", ""),
|
||||
"lastStatus": c.get("lastStatus", ""),
|
||||
"lastMessage": c.get("lastMessage", ""),
|
||||
}
|
||||
for c in cfgs
|
||||
]
|
||||
|
||||
|
||||
def update_job_status(key: str, status: str, message: str) -> None:
|
||||
"""更新 job 执行状态 — 使用 fetch_config_by_key 高效查找。"""
|
||||
r = db_ops.fetch_config_by_key(key)
|
||||
if r is None or r.get("category") != "schedule":
|
||||
return
|
||||
try:
|
||||
obj = json.loads(r.get("value", "") or "{}")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
obj = {}
|
||||
obj["lastRun"] = datetime.now().isoformat(timespec="seconds")
|
||||
obj["lastStatus"] = status
|
||||
obj["lastMessage"] = message
|
||||
db_ops.upsert_config(
|
||||
key, json.dumps(obj, ensure_ascii=False),
|
||||
category="schedule", description=r.get("description", ""),
|
||||
)
|
||||
# 更新后立即刷新缓存
|
||||
global _config_cache_ts
|
||||
_config_cache_ts = 0.0
|
||||
|
||||
|
||||
# ── 调度循环 ──────────────────────────────────────────────────────────
|
||||
|
||||
def _loop() -> None:
|
||||
global _running
|
||||
_init_holidays()
|
||||
|
||||
last_fired: dict[str, str] = {} # key → date string
|
||||
last_fired_ts: dict[str, float] = {} # key → timestamp
|
||||
|
||||
while _running:
|
||||
try:
|
||||
now = datetime.now()
|
||||
today_str = now.strftime("%Y-%m-%d")
|
||||
current_time = now.strftime("%H:%M")
|
||||
now_ts = time.time()
|
||||
|
||||
for cfg in _load_schedule_configs():
|
||||
if not cfg.get("enabled", True):
|
||||
continue
|
||||
|
||||
key = cfg["_key"]
|
||||
job_time = cfg.get("time", "")
|
||||
condition = cfg.get("condition", "always")
|
||||
job_name = cfg.get("job", "")
|
||||
interval = int(cfg.get("intervalSeconds", 0) or 0)
|
||||
persisted_last_run = str(cfg.get("lastRun", "") or "").strip()
|
||||
|
||||
# 启动时记住今天已跑过的时间触发任务
|
||||
if not interval and persisted_last_run and key not in last_fired:
|
||||
try:
|
||||
persisted_date = datetime.fromisoformat(persisted_last_run).strftime("%Y-%m-%d")
|
||||
except ValueError:
|
||||
persisted_date = persisted_last_run[:10]
|
||||
if persisted_date == today_str:
|
||||
last_fired[key] = today_str
|
||||
|
||||
if interval > 0:
|
||||
if now_ts - last_fired_ts.get(key, 0) < interval:
|
||||
continue
|
||||
else:
|
||||
if current_time < job_time:
|
||||
continue
|
||||
if last_fired.get(key) == today_str:
|
||||
continue
|
||||
|
||||
if condition == "trading_day" and not is_trading_day():
|
||||
continue
|
||||
|
||||
# 触发
|
||||
fn = _job_registry.get(job_name)
|
||||
if fn is None:
|
||||
update_job_status(key, "error", f"未注册的 job: {job_name}")
|
||||
continue
|
||||
|
||||
logger.info(f"[scheduler] 触发 {cfg.get('name', key)} @ {current_time}")
|
||||
try:
|
||||
result = fn() or {}
|
||||
status = result.get("status", "ok")
|
||||
message = result.get("message", "")
|
||||
update_job_status(key, status, message)
|
||||
logger.info(f"[scheduler] 完成 {cfg.get('name', key)} → {status}: {message}")
|
||||
except Exception as e:
|
||||
err = f"{type(e).__name__}: {e}"
|
||||
update_job_status(key, "error", err[:200])
|
||||
logger.error(f"[scheduler] 失败 {cfg.get('name', key)}: {err}")
|
||||
|
||||
last_fired[key] = today_str
|
||||
last_fired_ts[key] = now_ts
|
||||
except Exception as e:
|
||||
logger.error(f"[scheduler] loop 异常: {e}")
|
||||
|
||||
time.sleep(max(5, settings.scheduler_tick_seconds))
|
||||
|
||||
|
||||
def start_scheduler() -> None:
|
||||
global _running, _thread
|
||||
with _lock:
|
||||
if _running:
|
||||
return
|
||||
_running = True
|
||||
_thread = threading.Thread(target=_loop, daemon=True, name="market-data-sync-scheduler")
|
||||
_thread.start()
|
||||
logger.info("[scheduler] 已启动")
|
||||
|
||||
|
||||
def stop_scheduler() -> None:
|
||||
global _running
|
||||
with _lock:
|
||||
_running = False
|
||||
logger.info("[scheduler] 已停止")
|
||||
|
||||
|
||||
# ── 默认任务 seed ────────────────────────────────────────────────────
|
||||
|
||||
DEFAULT_SCHEDULES: list[tuple[str, dict, str]] = [
|
||||
(
|
||||
"schedule_stocks_basic",
|
||||
{
|
||||
"name": "开盘前同步股票信息",
|
||||
"time": "09:00",
|
||||
"condition": "trading_day",
|
||||
"job": "stock_basic",
|
||||
"enabled": True,
|
||||
},
|
||||
"每个交易日 09:00 拉取全市场股票基础信息 + 股本快照",
|
||||
),
|
||||
(
|
||||
"schedule_kline_index",
|
||||
{
|
||||
"name": "盘后指数日K线",
|
||||
"time": "15:30",
|
||||
"condition": "trading_day",
|
||||
"job": "kline_index",
|
||||
"enabled": True,
|
||||
},
|
||||
"每个交易日 15:30 拉取六大指数日K线",
|
||||
),
|
||||
(
|
||||
"schedule_kline_daily",
|
||||
{
|
||||
"name": "盘后日K线增量",
|
||||
"time": "15:40",
|
||||
"condition": "trading_day",
|
||||
"job": "kline_daily",
|
||||
"enabled": True,
|
||||
},
|
||||
"每个交易日 15:40 增量同步全市场日K线(多源交叉校验)",
|
||||
),
|
||||
(
|
||||
"schedule_kline_5min",
|
||||
{
|
||||
"name": "盘后5分钟K线",
|
||||
"time": "16:00",
|
||||
"condition": "trading_day",
|
||||
"job": "kline_5min",
|
||||
"enabled": True,
|
||||
},
|
||||
"每个交易日 16:00 拉取 5 分钟 K 线(数据量大,可选启用)",
|
||||
),
|
||||
(
|
||||
"schedule_moneyflow",
|
||||
{
|
||||
"name": "盘后资金流",
|
||||
"time": "16:30",
|
||||
"condition": "trading_day",
|
||||
"job": "moneyflow",
|
||||
"enabled": True,
|
||||
},
|
||||
"每个交易日 16:30 拉取个股资金流",
|
||||
),
|
||||
(
|
||||
"schedule_industry_sector",
|
||||
{
|
||||
"name": "周一行业映射",
|
||||
"time": "09:30",
|
||||
"condition": "trading_day",
|
||||
"job": "industry_sector",
|
||||
"enabled": True,
|
||||
},
|
||||
"每个交易日 09:30 全量更新股票-行业映射(开销大,可改为 weekly)",
|
||||
),
|
||||
(
|
||||
"schedule_market_regime",
|
||||
{
|
||||
"name": "盘后市场情绪",
|
||||
"time": "16:15",
|
||||
"condition": "trading_day",
|
||||
"job": "market_regime",
|
||||
"enabled": True,
|
||||
},
|
||||
"每个交易日 16:15 聚合市场情绪(基于本地日K线)",
|
||||
),
|
||||
(
|
||||
"schedule_share_snapshot",
|
||||
{
|
||||
"name": "盘后股本快照",
|
||||
"time": "16:20",
|
||||
"condition": "trading_day",
|
||||
"job": "share_snapshot",
|
||||
"enabled": True,
|
||||
},
|
||||
"每个交易日 16:20 刷新个股总股本/流通股本快照",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def seed_schedule_configs() -> None:
|
||||
"""写入默认计划任务配置到 config 表。"""
|
||||
existing = {r["key"]: r for r in db_ops.fetch_configs_by_category("schedule")}
|
||||
for key, obj, desc in DEFAULT_SCHEDULES:
|
||||
full = {
|
||||
**obj,
|
||||
"lastRun": "",
|
||||
"lastStatus": "",
|
||||
"lastMessage": "",
|
||||
}
|
||||
if key in existing:
|
||||
# 保留 lastRun / lastStatus 等
|
||||
try:
|
||||
old = json.loads(existing[key].get("value", "") or "{}")
|
||||
for k in ("lastRun", "lastStatus", "lastMessage"):
|
||||
if k in old:
|
||||
full[k] = old[k]
|
||||
except Exception:
|
||||
pass
|
||||
db_ops.upsert_config(
|
||||
key, json.dumps(full, ensure_ascii=False), category="schedule", description=desc,
|
||||
)
|
||||
|
||||
|
||||
# ── 注册 job(延迟导入避免循环依赖)────────────────────────────────────
|
||||
|
||||
|
||||
def register_sync_jobs() -> None:
|
||||
"""把 8 个 sync 任务注册成 job。
|
||||
|
||||
注:job 函数不接受任何参数,trigger_source 默认为 'schedule'。
|
||||
"""
|
||||
from app.tasks import get_task
|
||||
|
||||
for dataset_id in [
|
||||
"stock_basic", "kline_daily", "kline_index", "kline_5min",
|
||||
"moneyflow", "industry_sector", "share_snapshot", "market_regime",
|
||||
]:
|
||||
|
||||
def _make_job(did=dataset_id):
|
||||
def _job() -> dict:
|
||||
task = get_task(did)
|
||||
return task.run(trigger_source="schedule")
|
||||
_job.__name__ = f"job_{did}"
|
||||
return _job
|
||||
|
||||
register_job(dataset_id)(_make_job())
|
||||
Reference in New Issue
Block a user