提交
This commit is contained in:
114
backend/app/scheduler.py
Normal file
114
backend/app/scheduler.py
Normal file
@@ -0,0 +1,114 @@
|
||||
"""夜间定时任务:收盘后自动全市场同步(A股 + ETF)+ 过期会话清理。
|
||||
|
||||
进程内 asyncio 循环(单进程部署假设)。start_sync 均幂等(已在跑直接返回),
|
||||
即使多 worker / 手动触发与定时撞车也不会重复跑。关闭:NIGHTLY_SYNC_ENABLED=false。
|
||||
|
||||
补跑语义:进程启动时若已过触发点、今天是交易日、且当日 candles 尚未落库
|
||||
(例如定时点机器没开机),立即补跑一次,不让数据断档等到第二天。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from sqlalchemy import delete, func, or_, select
|
||||
|
||||
from .auth import utcnow
|
||||
from .config import settings
|
||||
from .models import AuthSession, Candle, TradeCalendar
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_TRIGGER_MINUTE = 5 # 触发点 = nightly_sync_hour:05(避开整点拥挤,纯本地任务习惯)
|
||||
|
||||
|
||||
def _seconds_until_next_run() -> float:
|
||||
now = datetime.now()
|
||||
target = now.replace(hour=settings.nightly_sync_hour, minute=_TRIGGER_MINUTE,
|
||||
second=0, microsecond=0)
|
||||
if target <= now:
|
||||
target += timedelta(days=1)
|
||||
return (target - now).total_seconds()
|
||||
|
||||
|
||||
async def cleanup_sessions() -> int:
|
||||
"""删除过期 / 吊销超 7 天的会话行(登录路径只清本人,长跑进程需要兜底)。"""
|
||||
from .db import async_session # 延迟导入避免循环
|
||||
|
||||
now = utcnow()
|
||||
async with async_session() as s:
|
||||
res = await s.execute(delete(AuthSession).where(or_(
|
||||
AuthSession.expires_at < now,
|
||||
AuthSession.revoked_at.is_not(None) & (AuthSession.revoked_at < now - timedelta(days=7)),
|
||||
)))
|
||||
await s.commit()
|
||||
return res.rowcount or 0
|
||||
|
||||
|
||||
async def _nightly_routine() -> None:
|
||||
"""当日例行:A 股全市场同步 -> ETF 同步(先后跑,避免两路 tushare 控频挤兑)-> 会话清理。"""
|
||||
from .data import etf_sync
|
||||
from .db import async_session
|
||||
from .screener import market_sync
|
||||
|
||||
log.info("夜间任务开始:全市场同步窗口 %d 个交易日", settings.screener_market_days)
|
||||
try:
|
||||
async with async_session() as session:
|
||||
await market_sync.start_sync(session, days=settings.screener_market_days, force=False)
|
||||
# 等日线同步收尾再触发 ETF(轮询模块内状态;start_sync 是即发即忘的)
|
||||
while market_sync._sync_state["running"]:
|
||||
await asyncio.sleep(30)
|
||||
except Exception: # noqa: BLE001
|
||||
log.exception("夜间 A 股同步触发失败")
|
||||
try:
|
||||
await etf_sync.start_sync(full=False)
|
||||
except Exception: # noqa: BLE001
|
||||
log.exception("夜间 ETF 同步触发失败")
|
||||
try:
|
||||
n = await cleanup_sessions()
|
||||
if n:
|
||||
log.info("夜间会话清理:%d 行", n)
|
||||
except Exception: # noqa: BLE001
|
||||
log.exception("夜间会话清理失败")
|
||||
log.info("夜间任务结束")
|
||||
|
||||
|
||||
async def _should_run_on_startup() -> bool:
|
||||
"""启动补跑判定:已过触发点 + 今天是交易日 + 当日 candles 还没落库。"""
|
||||
now = datetime.now()
|
||||
if now.hour * 60 + now.minute < settings.nightly_sync_hour * 60 + _TRIGGER_MINUTE:
|
||||
return False
|
||||
today8 = now.strftime("%Y%m%d")
|
||||
from .db import async_session
|
||||
|
||||
async with async_session() as s:
|
||||
is_trade_day = bool(await s.scalar(
|
||||
select(TradeCalendar.id).where(TradeCalendar.trade_date == today8).limit(1)))
|
||||
if not is_trade_day:
|
||||
return False
|
||||
# 当日未收盘/数据未生成时 max(ts) < 今日,同步会拉到空——那正是要补跑的信号
|
||||
latest = await s.scalar(select(func.max(Candle.ts)).where(Candle.timeframe == "1d"))
|
||||
return latest is None or latest.strftime("%Y%m%d") < today8
|
||||
|
||||
|
||||
async def run_nightly_loop() -> None:
|
||||
"""每日触发点跑一次;启动时满足补跑条件先补跑。由 main.lifespan 拉起。"""
|
||||
if not settings.nightly_sync_enabled:
|
||||
log.info("夜间自动同步未启用(NIGHTLY_SYNC_ENABLED=false)")
|
||||
return
|
||||
try:
|
||||
if await _should_run_on_startup():
|
||||
log.info("启动补跑:已过 %02d:%02d 且当日数据未落库", settings.nightly_sync_hour, _TRIGGER_MINUTE)
|
||||
await _nightly_routine()
|
||||
except Exception: # noqa: BLE001
|
||||
log.exception("启动补跑判定失败(跳过,等待每日定时点)")
|
||||
while True:
|
||||
try:
|
||||
await asyncio.sleep(_seconds_until_next_run())
|
||||
await _nightly_routine()
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception: # noqa: BLE001 —— 循环体不能死
|
||||
log.exception("夜间任务循环异常,60s 后继续")
|
||||
await asyncio.sleep(60)
|
||||
Reference in New Issue
Block a user