chore: baseline — ORM migration + systemd deploy + MCP server + daily check
- ORM migration: models/ops 重构 - deploy: 标准化 systemd timer/service 部署体系 - MCP server: 5 个只读工具(任务/状态/数据集) - daily check: 数据一致性巡检 - watch: task_watch 后台监控 - kline_daily: 麦蕊优先,时间门禁15:00 - 删除: task_industry_sector, task_sector_features
This commit is contained in:
@@ -1,15 +1,146 @@
|
||||
"""数据源公共工具:代码转换、K 线标准化、日期过滤。
|
||||
|
||||
参考 dashboard/api/services/datasource/fetch_kline.py,但适配新项目。"""
|
||||
参考 dashboard/api/services/datasource/fetch_kline.py,但适配新项目。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import threading
|
||||
from datetime import datetime, time as dtime, timedelta
|
||||
from typing import Any, Callable, TypeVar
|
||||
|
||||
import pandas as pd
|
||||
|
||||
logger = logging.getLogger("datasource.utils")
|
||||
|
||||
KLINE_COLS = ["trade_date", "open", "high", "low", "close", "volume"]
|
||||
KLINE_5MIN_COLS = ["bar_time", "open", "high", "low", "close", "volume", "amount", "turnover_rate"]
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
_THREAD_POOL: dict[int, threading.Thread] = {}
|
||||
|
||||
|
||||
def call_with_timeout(
|
||||
func: Callable[..., T],
|
||||
*args: Any,
|
||||
timeout: float = 20.0,
|
||||
on_timeout: T | None = None,
|
||||
description: str = "",
|
||||
**kwargs: Any,
|
||||
) -> T | None:
|
||||
"""用 daemon 线程跑 `func(*args, **kwargs)`,超时返回 `on_timeout`。
|
||||
|
||||
Why: 之前用 ThreadPoolExecutor,但超时后 `future.cancel()` 无法终止
|
||||
已在运行的线程,pool 的 `shutdown(wait=True)` 会等待卡死的函数返回,
|
||||
把 20s 超时变成无限阻塞——这是 kline_daily 3406 只股票批量失败的根因。
|
||||
|
||||
改用 daemon 线程:超时后直接返回,线程成为孤儿进程,不会阻塞任何调用方。
|
||||
daemon=True 确保进程退出时不会等待这些孤儿线程。
|
||||
|
||||
Args:
|
||||
func: 要跑的同步函数
|
||||
*args/**kwargs: 透传给 func
|
||||
timeout: 秒数,默认 20s(对标 mairui/sina 的 urlopen timeout)
|
||||
on_timeout: 超时返回的占位值(默认 None,调用方需处理 None)
|
||||
description: 日志里的调用描述(如 'xueqiu.kline SH600519')
|
||||
|
||||
Returns: func 的返回值,或 on_timeout(超时时)
|
||||
"""
|
||||
result: list[T | None | BaseException] = [None]
|
||||
done = threading.Event()
|
||||
|
||||
def _wrapper() -> None:
|
||||
try:
|
||||
result[0] = func(*args, **kwargs)
|
||||
except BaseException as e:
|
||||
result[0] = e
|
||||
finally:
|
||||
done.set()
|
||||
|
||||
t = threading.Thread(
|
||||
target=_wrapper,
|
||||
daemon=True,
|
||||
name=f"ds-timeout-{description or func.__name__}",
|
||||
)
|
||||
t.start()
|
||||
|
||||
if not done.wait(timeout=timeout):
|
||||
logger.warning(
|
||||
"[%s] %s 超时 (>%ss), 返回 on_timeout",
|
||||
description or func.__name__, description or func.__name__, timeout,
|
||||
)
|
||||
return on_timeout
|
||||
|
||||
if isinstance(result[0], BaseException):
|
||||
raise result[0] # type: ignore[misc]
|
||||
return result[0]
|
||||
|
||||
|
||||
def effective_market_date(
|
||||
now: datetime | None = None,
|
||||
holidays: set[str] | None = None,
|
||||
) -> str:
|
||||
"""返回"数据生效日"(最近已完成交易日)。
|
||||
|
||||
15:30 是 A 股收盘时刻;之后今天的数据才完整,之前应取昨天。
|
||||
然后向前回退直到找到一个交易日(跳过周末和法定假期)。
|
||||
用于数据源给"无明确来源日期"的数据(snowball quote_detail 的股本快照等)
|
||||
打 trade_date 时用 — 永远不要用 datetime.now() 直接当 trade_date。
|
||||
|
||||
Why: 任务运行日不等于数据交易日。例如 7月9日 早盘 9:30 跑 share_snapshot,
|
||||
此时拿到的股本快照实际对应 7月8日 收盘,不能标 7月9日。早期代码用
|
||||
`datetime.now().strftime("%Y-%m-%d")` 导致 share 表里 7月9日 跑出来的
|
||||
行标成 7月9日,与实际数据日不符,统计/回溯会出错。
|
||||
|
||||
2026-07-12 增强: 加入交易日历回退逻辑。如果 15:30 后是法定节假日
|
||||
(如春节/国庆),会向前回退到最后一个交易日,不会把节假日当天当 trade_date。
|
||||
|
||||
Args:
|
||||
now: 测试用注入点(默认 datetime.now())
|
||||
holidays: 已知的 A 股休市日集合(YYYY-MM-DD 格式)。
|
||||
默认从 config 表 trading_calendar_holidays 加载。
|
||||
|
||||
Returns: 'YYYY-MM-DD' 字符串(永远是合法的交易日)
|
||||
"""
|
||||
t = now or datetime.now()
|
||||
if t.time() >= dtime(15, 0):
|
||||
candidate = t
|
||||
else:
|
||||
candidate = t - timedelta(days=1)
|
||||
|
||||
# 加载交易日历(如果调用方未提供)
|
||||
if holidays is None:
|
||||
try:
|
||||
from app.core.db import ops as db_ops
|
||||
h_row = db_ops.fetch_config_by_key("trading_calendar_holidays")
|
||||
if h_row and h_row.get("category") == "general":
|
||||
import json
|
||||
vals = json.loads(h_row.get("value", "") or "[]")
|
||||
if isinstance(vals, list):
|
||||
holidays = {h for h in vals if isinstance(h, str)}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if holidays is None:
|
||||
holidays = set()
|
||||
|
||||
# 向前回退直到找到交易日
|
||||
max_iter = 30 # 安全上限,避免死循环
|
||||
while max_iter > 0:
|
||||
date_str = candidate.strftime("%Y-%m-%d")
|
||||
if candidate.weekday() < 5 and date_str not in holidays:
|
||||
return date_str
|
||||
candidate -= timedelta(days=1)
|
||||
max_iter -= 1
|
||||
|
||||
# 兜底(正常情况下不会走到这里)
|
||||
t2 = now or datetime.now()
|
||||
if t2.time() >= dtime(15, 30):
|
||||
return t2.strftime("%Y-%m-%d")
|
||||
return (t2 - timedelta(days=1)).strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
def to_code6(code: str) -> str:
|
||||
"""'SH600000' / 'sh.600000' / '600000' / 'SH600000.SH' → '600000'"""
|
||||
|
||||
Reference in New Issue
Block a user