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:
gao
2026-06-15 15:36:09 +08:00
commit 05635b76b9
55 changed files with 6307 additions and 0 deletions
+20
View File
@@ -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",
]
+392
View File
@@ -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())