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,126 @@
|
||||
"""统一配置:从 .env / 环境变量读 MySQL、调度器、数据源开关等参数。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
try:
|
||||
from dotenv import load_dotenv
|
||||
_HAS_DOTENV = True
|
||||
except ImportError:
|
||||
_HAS_DOTENV = False
|
||||
|
||||
|
||||
_PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
|
||||
# 启动时自动把 .env 加载到 os.environ,使下游 `os.environ.get(...)` 代码
|
||||
# (如 app/sources/mairui.py / xueqiu.py)能拿到 credentials。
|
||||
if _HAS_DOTENV:
|
||||
_env_file = _PROJECT_ROOT / ".env"
|
||||
if _env_file.exists():
|
||||
load_dotenv(_env_file, override=False)
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""应用配置。所有字段都可以通过环境变量或 .env 文件覆盖。"""
|
||||
|
||||
# MySQL
|
||||
mysql_host: str = "127.0.0.1"
|
||||
mysql_port: int = 3306
|
||||
mysql_user: str = "root"
|
||||
mysql_password: str = ""
|
||||
# 同步任务写入的目标库(原始市场数据)
|
||||
mysql_database: str = "market_data_sync_db"
|
||||
# 简版库(对照测试,可选)
|
||||
mysql_database_lite: str = "grid_seeker"
|
||||
# 业务主库(保留— 同步任务不再写入)
|
||||
mysql_database_business: str = "grid_seeker_model_base"
|
||||
|
||||
# Scheduler
|
||||
scheduler_tick_seconds: int = 5
|
||||
scheduler_timezone: str = "Asia/Shanghai"
|
||||
scheduler_auto_seed: bool = True
|
||||
|
||||
# API
|
||||
api_host: str = "0.0.0.0"
|
||||
api_port: int = 8100
|
||||
|
||||
# Logging
|
||||
log_level: str = "INFO"
|
||||
log_dir: str = "./logs"
|
||||
|
||||
# Datasources
|
||||
ds_baostock_enabled: bool = True
|
||||
ds_sina_enabled: bool = True
|
||||
xueqiu_token: str = ""
|
||||
|
||||
# Trading calendar
|
||||
trading_holidays: str = ""
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=str(_PROJECT_ROOT / ".env"),
|
||||
env_file_encoding="utf-8",
|
||||
case_sensitive=False,
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
@property
|
||||
def project_root(self) -> Path:
|
||||
return _PROJECT_ROOT
|
||||
|
||||
@property
|
||||
def log_path(self) -> Path:
|
||||
p = Path(self.log_dir)
|
||||
if not p.is_absolute():
|
||||
p = _PROJECT_ROOT / p
|
||||
p.mkdir(parents=True, exist_ok=True)
|
||||
return p
|
||||
|
||||
@property
|
||||
def holidays_list(self) -> list[str]:
|
||||
if not self.trading_holidays:
|
||||
return []
|
||||
return [d.strip() for d in self.trading_holidays.split(",") if d.strip()]
|
||||
|
||||
def mysql_url(self, database: Optional[str] = None) -> str:
|
||||
"""构造 SQLAlchemy URL。"""
|
||||
db = database or self.mysql_database
|
||||
return (
|
||||
f"mysql+pymysql://{self.mysql_user}:{self.mysql_password}"
|
||||
f"@{self.mysql_host}:{self.mysql_port}/{db}?charset=utf8mb4"
|
||||
)
|
||||
|
||||
def pymysql_connect_kwargs(self, database: Optional[str] = None) -> dict:
|
||||
"""构造 pymysql.connect 的关键字参数。
|
||||
|
||||
内网自签名证书场景自动禁用 SSL。
|
||||
"""
|
||||
db = database or self.mysql_database
|
||||
return {
|
||||
"host": self.mysql_host,
|
||||
"port": self.mysql_port,
|
||||
"user": self.mysql_user,
|
||||
"password": self.mysql_password,
|
||||
"database": db,
|
||||
"charset": "utf8mb4",
|
||||
"autocommit": False,
|
||||
# 兼容自签名证书的内网 MySQL — 禁用 SSL
|
||||
"ssl": None,
|
||||
}
|
||||
|
||||
|
||||
_settings: Optional[Settings] = None
|
||||
|
||||
|
||||
def get_settings() -> Settings:
|
||||
"""全局单例。"""
|
||||
global _settings
|
||||
if _settings is None:
|
||||
_settings = Settings()
|
||||
return _settings
|
||||
|
||||
|
||||
settings = get_settings()
|
||||
@@ -0,0 +1,22 @@
|
||||
"""数据源层:DataSource 抽象 + 适配器 + 注册表 + 健康检查。"""
|
||||
from app.core.datasource.base import DataSource, FetchResult, registry
|
||||
from app.core.datasource.registry import (
|
||||
build_default_registry,
|
||||
run_health_check,
|
||||
get_health_status,
|
||||
is_source_ready,
|
||||
seed_datasource_configs,
|
||||
pick_source,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"DataSource",
|
||||
"FetchResult",
|
||||
"registry",
|
||||
"build_default_registry",
|
||||
"run_health_check",
|
||||
"get_health_status",
|
||||
"is_source_ready",
|
||||
"seed_datasource_configs",
|
||||
"pick_source",
|
||||
]
|
||||
@@ -0,0 +1,108 @@
|
||||
"""DataSource 抽象基类 + 全局注册器。
|
||||
|
||||
每个数据源实现一个 DataSource 子类,注册到全局 registry。
|
||||
fetch_* 方法返回 FetchResult,便于上层做交叉校验。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import abc
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
import pandas as pd
|
||||
|
||||
|
||||
@dataclass
|
||||
class FetchResult:
|
||||
"""数据源拉取结果。"""
|
||||
df: pd.DataFrame
|
||||
source_key: str = ""
|
||||
validated: bool = False
|
||||
detail: str = ""
|
||||
|
||||
@property
|
||||
def empty(self) -> bool:
|
||||
return self.df is None or self.df.empty
|
||||
|
||||
|
||||
class DataSource(abc.ABC):
|
||||
"""数据源抽象。
|
||||
|
||||
子类必须实现:
|
||||
- key / name / provides / requires_credential
|
||||
- is_available(): bool 是否就绪(凭证已配 + 通过连通性测试)
|
||||
- health_check(): dict {success, message}
|
||||
- fetch_* 方法:每个提供的数据类型一个
|
||||
"""
|
||||
|
||||
key: str = ""
|
||||
name: str = ""
|
||||
provides: list[str] = [] # e.g. ["kline_daily", "stock_basic", "index_daily", "share", "industry", "kline_5min", "moneyflow"]
|
||||
requires_credential: bool = False
|
||||
credential_key: str = "" # e.g. "XUEQIU_TOKEN"
|
||||
|
||||
@abc.abstractmethod
|
||||
def is_available(self) -> tuple[bool, str]:
|
||||
"""返回 (是否就绪, 原因)。"""
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
def health_check(self) -> dict[str, Any]:
|
||||
"""连通性测试,返回 {success: bool, message: str}。"""
|
||||
...
|
||||
|
||||
# ── 通用可选方法(按 provides 类型实现)──
|
||||
|
||||
def fetch_stock_basic(self) -> list[dict[str, Any]]:
|
||||
"""全市场股票基础信息。每条: {code, name, exchange, list_date, listing_status}"""
|
||||
raise NotImplementedError
|
||||
|
||||
def fetch_kline_daily(self, code6: str, start: str, end: str) -> pd.DataFrame:
|
||||
"""日 K 线。返回标准列:trade_date, open, high, low, close, volume"""
|
||||
raise NotImplementedError
|
||||
|
||||
def fetch_index_daily(self, index_code: str, start: str, end: str) -> pd.DataFrame:
|
||||
"""指数日 K。"""
|
||||
raise NotImplementedError
|
||||
|
||||
def fetch_share_snapshot(self, code6: str) -> Optional[dict[str, Any]]:
|
||||
"""单只股票的最新股本快照。返回 {total_share, float_share, trade_date}(单位:亿股)"""
|
||||
raise NotImplementedError
|
||||
|
||||
def fetch_industry_map(self) -> list[dict[str, Any]]:
|
||||
"""股票-行业映射。每条: {code, industry_name, industry_classification, update_date}"""
|
||||
raise NotImplementedError
|
||||
|
||||
def fetch_kline_5min(self, code6: str, start: str, end: str) -> pd.DataFrame:
|
||||
"""5 分钟 K 线。"""
|
||||
raise NotImplementedError
|
||||
|
||||
def fetch_moneyflow(self, code6: str, start: str, end: str) -> pd.DataFrame:
|
||||
"""资金流。每条: {trade_date, main_net_inflow, large_net_inflow, medium_net_inflow, small_net_inflow}"""
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
# ── 全局注册器(程序启动时往里塞)──────────────────────────────────
|
||||
|
||||
class _GlobalRegistry:
|
||||
def __init__(self):
|
||||
self._sources: dict[str, DataSource] = {}
|
||||
|
||||
def register(self, source: DataSource) -> None:
|
||||
if not source.key:
|
||||
raise ValueError(f"DataSource {source!r} missing .key")
|
||||
self._sources[source.key] = source
|
||||
|
||||
def get(self, key: str) -> Optional[DataSource]:
|
||||
return self._sources.get(key)
|
||||
|
||||
def all(self) -> list[DataSource]:
|
||||
return list(self._sources.values())
|
||||
|
||||
def by_provides(self, capability: str) -> list[DataSource]:
|
||||
return [s for s in self._sources.values() if capability in s.provides]
|
||||
|
||||
def clear(self) -> None:
|
||||
self._sources.clear()
|
||||
|
||||
|
||||
registry = _GlobalRegistry()
|
||||
@@ -0,0 +1,250 @@
|
||||
"""数据源注册表 + 健康度监控。
|
||||
|
||||
模式照搬 dashboard/api/services/datasource_health.py,但适配新项目结构。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Optional
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.db import ops as db_ops
|
||||
from app.core.datasource.base import DataSource, registry
|
||||
from app.core.utils.logging import get_logger
|
||||
|
||||
logger = get_logger("datasource_health")
|
||||
|
||||
|
||||
# ── 数据源定义持久化到 config 表(datasource category)──────────────────
|
||||
|
||||
|
||||
_DATASOURCE_SEED: list[tuple[str, dict, str]] = [
|
||||
(
|
||||
"datasource_xinlang",
|
||||
{
|
||||
"name": "新浪财经",
|
||||
"provides": ["kline_daily", "index_daily"],
|
||||
"requiresCredential": False,
|
||||
"note": "免费 K 线主数据源",
|
||||
},
|
||||
"日 K 线 + 指数 K 线主源",
|
||||
),
|
||||
(
|
||||
"datasource_baostock",
|
||||
{
|
||||
"name": "Baostock",
|
||||
"provides": ["kline_daily", "stock_basic", "industry"],
|
||||
"requiresCredential": False,
|
||||
"note": "免费,提供股票基础信息 + 行业映射 + K 线复核",
|
||||
},
|
||||
"股票基础信息 + 行业映射 + 复核源",
|
||||
),
|
||||
(
|
||||
"datasource_xueqiu",
|
||||
{
|
||||
"name": "雪球",
|
||||
"provides": ["kline_daily", "share"],
|
||||
"requiresCredential": True,
|
||||
"credentialKey": "XUEQIU_TOKEN",
|
||||
"note": "通过 pysnowball 补齐股本快照与 K 线复核",
|
||||
},
|
||||
"股本快照(需 Token)+ 复核源",
|
||||
),
|
||||
(
|
||||
"datasource_mairui",
|
||||
{
|
||||
"name": "麦蕊智数(mairui.club)",
|
||||
"provides": ["kline_daily", "kline_5min", "index_daily"],
|
||||
"requiresCredential": True,
|
||||
"credentialKey": "MAIRUI_LICENCE",
|
||||
"note": "K 线主源(日线 + 5min + 指数),免费 licence 1 分钟 300 次",
|
||||
},
|
||||
"K 线主源(日线 + 5min + 指数)",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def seed_datasource_configs() -> None:
|
||||
"""把数据源定义写入 config 表 category='datasource'。"""
|
||||
for key, obj, desc in _DATASOURCE_SEED:
|
||||
# 取已存在的 credentialValue(防止覆盖用户填的 token)
|
||||
existing = next(
|
||||
(r for r in db_ops.fetch_all_config()
|
||||
if r.get("key") == key and r.get("category") == "datasource"),
|
||||
None,
|
||||
)
|
||||
if existing and not obj.get("credentialValue"):
|
||||
try:
|
||||
old = json.loads(existing.get("value", "") or "{}")
|
||||
if old.get("credentialValue"):
|
||||
obj["credentialValue"] = old["credentialValue"]
|
||||
except Exception:
|
||||
pass
|
||||
db_ops.upsert_config(
|
||||
key, json.dumps(obj, ensure_ascii=False), category="datasource", description=desc,
|
||||
)
|
||||
|
||||
|
||||
def _read_datasource_configs() -> dict[str, dict]:
|
||||
"""从 config 表读出所有数据源定义。"""
|
||||
result = {}
|
||||
for r in db_ops.fetch_all_config():
|
||||
if r.get("category") != "datasource":
|
||||
continue
|
||||
try:
|
||||
obj = json.loads(r.get("value", "") or "{}")
|
||||
obj["_key"] = r["key"]
|
||||
result[r["key"]] = obj
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
continue
|
||||
return result
|
||||
|
||||
|
||||
# ── 健康监控状态(线程安全 dict)──────────────────────────────────────
|
||||
|
||||
|
||||
_health_lock = threading.Lock()
|
||||
_health_results: dict[str, dict[str, Any]] = {}
|
||||
_health_started = False
|
||||
_health_thread: Optional[threading.Thread] = None
|
||||
_HEALTH_INTERVAL = 30 * 60 # 30 分钟
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def run_health_check(source_key: Optional[str] = None) -> list[dict[str, Any]]:
|
||||
"""跑一次连通性测试,更新内部缓存。
|
||||
|
||||
Args:
|
||||
source_key: 跑单个;为 None 跑所有已注册的数据源。
|
||||
"""
|
||||
results: list[dict[str, Any]] = []
|
||||
targets = [source_key] if source_key else [s.key for s in registry.all()]
|
||||
|
||||
for key in targets:
|
||||
src = registry.get(key)
|
||||
if src is None:
|
||||
results.append({
|
||||
"key": key,
|
||||
"success": False,
|
||||
"message": f"数据源 {key} 未注册",
|
||||
"testedAt": _now_iso(),
|
||||
})
|
||||
continue
|
||||
try:
|
||||
r = src.health_check()
|
||||
results.append({
|
||||
"key": key,
|
||||
"name": src.name,
|
||||
"success": bool(r.get("success", False)),
|
||||
"message": str(r.get("message", "")),
|
||||
"testedAt": _now_iso(),
|
||||
"provides": src.provides,
|
||||
"requiresCredential": src.requires_credential,
|
||||
})
|
||||
except Exception as e:
|
||||
results.append({
|
||||
"key": key,
|
||||
"name": getattr(src, "name", key),
|
||||
"success": False,
|
||||
"message": f"健康检查异常: {e}",
|
||||
"testedAt": _now_iso(),
|
||||
})
|
||||
|
||||
with _health_lock:
|
||||
for r in results:
|
||||
_health_results[r["key"]] = r
|
||||
return results
|
||||
|
||||
|
||||
def get_health_status(source_key: Optional[str] = None) -> list[dict[str, Any]]:
|
||||
"""读取最近一次健康检查结果(不重新跑)。"""
|
||||
with _health_lock:
|
||||
if source_key:
|
||||
r = _health_results.get(source_key)
|
||||
return [dict(r)] if r else []
|
||||
return [dict(_health_results[k]) for k in sorted(_health_results)]
|
||||
|
||||
|
||||
def is_source_ready(source_key: str) -> tuple[bool, str]:
|
||||
"""综合判断:是否在 registry + 凭证已配 + 健康检查通过。"""
|
||||
src = registry.get(source_key)
|
||||
if src is None:
|
||||
return False, f"数据源 {source_key} 未注册"
|
||||
if src.requires_credential:
|
||||
token = os.environ.get(src.credential_key, "").strip()
|
||||
if not token:
|
||||
return False, f"凭证 {src.credential_key} 未配置"
|
||||
ok, reason = src.is_available()
|
||||
if not ok:
|
||||
return False, reason
|
||||
with _health_lock:
|
||||
last = _health_results.get(source_key)
|
||||
if last is None:
|
||||
return False, "健康检查尚未完成"
|
||||
if not last.get("success"):
|
||||
return False, f"连通性测试失败: {last.get('message', '未知错误')}"
|
||||
return True, "就绪"
|
||||
|
||||
|
||||
def pick_source(capability: str) -> Optional[DataSource]:
|
||||
"""从所有就绪数据源里挑一个能提供某能力的数据源,按注册顺序。"""
|
||||
for s in registry.all():
|
||||
if capability in s.provides:
|
||||
ok, _ = is_source_ready(s.key)
|
||||
if ok:
|
||||
return s
|
||||
return None
|
||||
|
||||
|
||||
# ── 后台线程定期跑健康检查 ──────────────────────────────────────────
|
||||
|
||||
|
||||
def _health_loop() -> None:
|
||||
while True:
|
||||
try:
|
||||
run_health_check()
|
||||
logger.info("[datasource_health] 全数据源健康检查完成")
|
||||
except Exception as e:
|
||||
logger.error(f"[datasource_health] 健康检查异常: {e}")
|
||||
time.sleep(_HEALTH_INTERVAL)
|
||||
|
||||
|
||||
def start_health_monitor() -> None:
|
||||
global _health_started, _health_thread
|
||||
with _health_lock:
|
||||
if _health_started:
|
||||
return
|
||||
_health_started = True
|
||||
_health_thread = threading.Thread(target=_health_loop, daemon=True, name="datasource-health")
|
||||
_health_thread.start()
|
||||
|
||||
|
||||
# ── 启动时构建默认 registry ──────────────────────────────────────────
|
||||
|
||||
|
||||
def build_default_registry() -> None:
|
||||
"""启动时调用:把默认 4 个数据源注册到全局 registry。"""
|
||||
# 避免重复注册
|
||||
if registry.all():
|
||||
return
|
||||
from app.sources.sina import SinaSource
|
||||
from app.sources.baostock import BaostockSource
|
||||
from app.sources.xueqiu import XueqiuSource
|
||||
from app.sources.mairui import MairuiSource
|
||||
|
||||
registry.register(SinaSource())
|
||||
registry.register(BaostockSource())
|
||||
registry.register(MairuiSource())
|
||||
if settings.xueqiu_token:
|
||||
registry.register(XueqiuSource())
|
||||
else:
|
||||
# 即使没 token 也注册,is_available() 会拦截
|
||||
registry.register(XueqiuSource())
|
||||
# akshare 已移除(项目级决策:永远不用)
|
||||
@@ -0,0 +1,115 @@
|
||||
"""数据源公共工具:代码转换、K 线标准化、日期过滤。
|
||||
|
||||
参考 dashboard/api/services/datasource/fetch_kline.py,但适配新项目。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
import pandas as pd
|
||||
|
||||
KLINE_COLS = ["trade_date", "open", "high", "low", "close", "volume"]
|
||||
KLINE_5MIN_COLS = ["bar_time", "open", "high", "low", "close", "volume", "amount", "turnover_rate"]
|
||||
|
||||
|
||||
def to_code6(code: str) -> str:
|
||||
"""'SH600000' / 'sh.600000' / '600000' / 'SH600000.SH' → '600000'"""
|
||||
c = str(code).strip().upper()
|
||||
c = re.sub(r"^(SH|SZ|BJ)", "", c)
|
||||
c = c.split(".")[0]
|
||||
return c.zfill(6)
|
||||
|
||||
|
||||
def code6_to_exchange(code6: str) -> str:
|
||||
"""6 位代码 → 交易所前缀。"""
|
||||
if code6.startswith(("5", "6", "9")):
|
||||
return "SH"
|
||||
if code6.startswith(("4", "8")):
|
||||
return "BJ"
|
||||
return "SZ"
|
||||
|
||||
|
||||
def code6_to_sina(code6: str) -> str:
|
||||
"""新浪财经 symbol:'sh600036' / 'sz000001'。"""
|
||||
return f"{code6_to_exchange(code6).lower()}{code6}"
|
||||
|
||||
|
||||
def code6_to_baostock(code6: str) -> str:
|
||||
"""Baostock 风格:'sh.600000'。"""
|
||||
return f"{code6_to_exchange(code6).lower()}.{code6}"
|
||||
|
||||
|
||||
def code6_to_xueqiu(code6: str) -> str:
|
||||
"""雪球 symbol:'SH600036'。"""
|
||||
return f"{code6_to_exchange(code6)}{code6}"
|
||||
|
||||
|
||||
def code6_to_mairui(code6: str) -> str:
|
||||
"""麦蕊智数 symbol:'600036.SH'。"""
|
||||
return f"{code6}.{code6_to_exchange(code6)}"
|
||||
|
||||
|
||||
def normalize_kline(df: pd.DataFrame) -> pd.DataFrame:
|
||||
"""标准化日 K 线 DataFrame。
|
||||
|
||||
输入列名可能是 date / day / vol 等,输出固定为 KLINE_COLS。
|
||||
含 OHLC 合法性校验(high >= max(o,l,c), low <= min(o,h,c))。
|
||||
"""
|
||||
if df is None or df.empty:
|
||||
return pd.DataFrame(columns=KLINE_COLS)
|
||||
df = df.rename(columns={"date": "trade_date", "day": "trade_date", "vol": "volume"}).copy()
|
||||
missing = [c for c in KLINE_COLS if c not in df.columns]
|
||||
if missing:
|
||||
return pd.DataFrame(columns=KLINE_COLS)
|
||||
df = df[KLINE_COLS]
|
||||
df["trade_date"] = pd.to_datetime(df["trade_date"], errors="coerce").astype("datetime64[ns]")
|
||||
for col in ["open", "high", "low", "close", "volume"]:
|
||||
df[col] = pd.to_numeric(df[col], errors="coerce")
|
||||
df = df.dropna(subset=["trade_date", "open", "high", "low", "close"])
|
||||
df["volume"] = df["volume"].fillna(0)
|
||||
valid = (
|
||||
(df["open"] > 0) & (df["high"] > 0) & (df["low"] > 0) & (df["close"] > 0)
|
||||
& (df["volume"] >= 0)
|
||||
& (df["high"] >= df[["open", "low", "close"]].max(axis=1))
|
||||
& (df["low"] <= df[["open", "high", "close"]].min(axis=1))
|
||||
)
|
||||
df = df.loc[valid].sort_values("trade_date")
|
||||
return df.drop_duplicates(subset=["trade_date"], keep="last").reset_index(drop=True)
|
||||
|
||||
|
||||
def filter_date_range(df: pd.DataFrame, start: str, end: str) -> pd.DataFrame:
|
||||
if df.empty:
|
||||
return df
|
||||
df = df[
|
||||
(df["trade_date"] >= pd.Timestamp(start))
|
||||
& (df["trade_date"] <= pd.Timestamp(end))
|
||||
]
|
||||
return df.reset_index(drop=True)
|
||||
|
||||
|
||||
def normalize_5min(df: pd.DataFrame) -> pd.DataFrame:
|
||||
"""标准化 5 分钟 K 线。"""
|
||||
if df is None or df.empty:
|
||||
return pd.DataFrame(columns=KLINE_5MIN_COLS)
|
||||
df = df.rename(columns={
|
||||
"时间": "bar_time", "time": "bar_time", "date": "bar_time",
|
||||
"成交量": "volume", "成交额": "amount", "换手率": "turnover_rate",
|
||||
}).copy()
|
||||
if "bar_time" not in df.columns:
|
||||
return pd.DataFrame(columns=KLINE_5MIN_COLS)
|
||||
df["bar_time"] = pd.to_datetime(df["bar_time"], errors="coerce")
|
||||
for col in ["open", "high", "low", "close", "volume", "amount", "turnover_rate"]:
|
||||
if col in df.columns:
|
||||
df[col] = pd.to_numeric(df[col], errors="coerce")
|
||||
df = df.dropna(subset=["bar_time"])
|
||||
keep = ["bar_time"] + [c for c in KLINE_5MIN_COLS[1:] if c in df.columns]
|
||||
df = df[keep].sort_values("bar_time").drop_duplicates(subset=["bar_time"], keep="last")
|
||||
return df.reset_index(drop=True)
|
||||
|
||||
|
||||
def is_a_share_code(code6: str) -> bool:
|
||||
"""判断 6 位代码是否是主板/创业板/科创板(排除北证 8 字头、可转债等)。"""
|
||||
if not code6 or len(code6) != 6 or not code6.isdigit():
|
||||
return False
|
||||
if code6.startswith(("4", "8")):
|
||||
return False
|
||||
return True
|
||||
@@ -0,0 +1,4 @@
|
||||
"""DB 子包:连接管理 + schema + 业务 CRUD。"""
|
||||
from app.core.db.connection import get_mysql, init_mysql_schema, close_mysql
|
||||
|
||||
__all__ = ["get_mysql", "init_mysql_schema", "close_mysql"]
|
||||
@@ -0,0 +1,104 @@
|
||||
"""MySQL 连接管理(thread-local,模式照搬 dashboard,但适配 market_data_sync 项目)。
|
||||
|
||||
要点:
|
||||
- pymysql 连接不是线程安全的,每个线程各持一份
|
||||
- 内网自签名证书场景:传 ssl_disabled=True 跳过 SSL 校验
|
||||
- DATE / DATETIME / TIMESTAMP 统一转字符串,避免下游类型处理
|
||||
- DictCursor:返回字典风格行,方便业务拼装
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from typing import Optional
|
||||
|
||||
import pymysql
|
||||
import pymysql.constants.FIELD_TYPE
|
||||
from pymysql.converters import conversions as _pymysql_conv, convert_date, convert_datetime
|
||||
from pymysql.cursors import DictCursor
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
_local = threading.local()
|
||||
|
||||
|
||||
def _strdate(obj):
|
||||
d = convert_date(obj)
|
||||
return d.strftime("%Y-%m-%d") if d else None
|
||||
|
||||
|
||||
def _strdatetime(obj):
|
||||
d = convert_datetime(obj)
|
||||
return d.strftime("%Y-%m-%d %H:%M:%S") if d else None
|
||||
|
||||
|
||||
# 包装默认 converter,让 DATE / DATETIME / TIMESTAMP 一律出字符串
|
||||
_conv = _pymysql_conv.copy()
|
||||
_conv[pymysql.constants.FIELD_TYPE.DATE] = _strdate
|
||||
_conv[pymysql.constants.FIELD_TYPE.DATETIME] = _strdatetime
|
||||
_conv[pymysql.constants.FIELD_TYPE.TIMESTAMP] = _strdatetime
|
||||
|
||||
|
||||
def get_mysql(database: Optional[str] = None) -> pymysql.connections.Connection:
|
||||
"""取当前线程的 MySQL 连接。无则新建。
|
||||
|
||||
关键修复:高并发场景下,连接可能被服务端超时断开 (wait_timeout)。
|
||||
用 ping(reconnect=True) 让 pymysql 自动重连,避免 stale conn 导致
|
||||
2013 Lost connection 错误。
|
||||
"""
|
||||
attr = f"conn_{database or 'default'}"
|
||||
conn = getattr(_local, attr, None)
|
||||
if conn is not None:
|
||||
try:
|
||||
conn.ping(reconnect=True)
|
||||
except Exception:
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
conn = None
|
||||
if conn is None:
|
||||
kwargs = dict(
|
||||
host=settings.mysql_host,
|
||||
port=settings.mysql_port,
|
||||
user=settings.mysql_user,
|
||||
password=settings.mysql_password,
|
||||
database=database or settings.mysql_database,
|
||||
charset="utf8mb4",
|
||||
cursorclass=DictCursor,
|
||||
autocommit=False,
|
||||
conv=_conv,
|
||||
)
|
||||
# 内网自签名证书:禁用 SSL(用户场景)
|
||||
kwargs["ssl"] = None
|
||||
# 高并发时设较短 read_timeout / write_timeout
|
||||
kwargs["read_timeout"] = 30
|
||||
kwargs["write_timeout"] = 30
|
||||
conn = pymysql.connect(**kwargs)
|
||||
setattr(_local, attr, conn)
|
||||
return conn
|
||||
|
||||
|
||||
def close_mysql() -> None:
|
||||
"""关闭当前线程的所有连接。"""
|
||||
for attr in list(vars(_local).keys()):
|
||||
conn = getattr(_local, attr, None)
|
||||
if conn is not None:
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
delattr(_local, attr)
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
|
||||
# ── Schema DDL 入口 ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def init_mysql_schema() -> None:
|
||||
"""集中创建/补齐所有表。被 db/ops.py 在第一次访问时调用。"""
|
||||
# 延后导入避免循环依赖
|
||||
from app.core.db.schema import ensure_all_tables
|
||||
|
||||
ensure_all_tables(get_mysql())
|
||||
@@ -0,0 +1,728 @@
|
||||
"""MySQL 业务 CRUD 函数集合(参考 dashboard/api/services/mysql_ops.py)。
|
||||
|
||||
本文件只包含 market_data_sync 同步任务需要的最小函数集,不涉及 dashboard 的
|
||||
accounts / positions / trade_record / scoring 等业务表。
|
||||
|
||||
设计要点:
|
||||
- 每个 ensure_xxx_table() 调用会触发 init_mysql_schema()(幂等)
|
||||
- 批量 upsert 走 executemany,效率高
|
||||
- 统一使用 DictCursor
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime
|
||||
from typing import Any, Iterable, Optional
|
||||
|
||||
from app.core.db.connection import get_mysql, init_mysql_schema
|
||||
|
||||
|
||||
# ── 内部 helpers ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _now_local_ts() -> str:
|
||||
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
|
||||
def _today_str() -> str:
|
||||
return datetime.now().strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
def _date_or_none(val) -> str | None:
|
||||
if val is None:
|
||||
return None
|
||||
s = str(val).strip()
|
||||
return s if s else None
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _cursor():
|
||||
"""带 init_mysql_schema 的 cursor 上下文。"""
|
||||
init_mysql_schema()
|
||||
conn = get_mysql()
|
||||
cur = conn.cursor()
|
||||
try:
|
||||
yield cur
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
cur.close()
|
||||
|
||||
|
||||
# ── config 表 ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def fetch_all_config() -> list[dict[str, Any]]:
|
||||
init_mysql_schema()
|
||||
conn = get_mysql()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT `key`, `value`, category, description, updated_at FROM config")
|
||||
return cur.fetchall()
|
||||
|
||||
|
||||
def fetch_config_by_key(key: str) -> dict[str, Any] | None:
|
||||
"""按 key 查单条 config(比 fetch_all_config 高效)。"""
|
||||
init_mysql_schema()
|
||||
conn = get_mysql()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT `key`, `value`, category, description, updated_at FROM config WHERE `key` = %s",
|
||||
(key,),
|
||||
)
|
||||
return cur.fetchone()
|
||||
|
||||
|
||||
def fetch_configs_by_category(category: str) -> list[dict[str, Any]]:
|
||||
"""按 category 查 config(比 fetch_all_config + 内存过滤高效)。"""
|
||||
init_mysql_schema()
|
||||
conn = get_mysql()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT `key`, `value`, category, description, updated_at FROM config WHERE category = %s",
|
||||
(category,),
|
||||
)
|
||||
return cur.fetchall()
|
||||
|
||||
|
||||
def upsert_config(key: str, value: str, category: str = "general", description: str = "") -> None:
|
||||
with _cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO config (`key`, `value`, category, description)
|
||||
VALUES (%s, %s, %s, %s)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
`value` = VALUES(`value`),
|
||||
category = VALUES(category),
|
||||
description = VALUES(description)
|
||||
""",
|
||||
(key, value, category, description),
|
||||
)
|
||||
|
||||
|
||||
def delete_config(key: str) -> None:
|
||||
with _cursor() as cur:
|
||||
cur.execute("DELETE FROM config WHERE `key` = %s", (key,))
|
||||
|
||||
|
||||
# ── dataset_registry ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def upsert_dataset_registry_rows(rows: list[dict[str, Any]]) -> None:
|
||||
"""批量 upsert 同步任务定义到 dataset_registry。"""
|
||||
if not rows:
|
||||
return
|
||||
with _cursor() as cur:
|
||||
for r in rows:
|
||||
dep_json = r.get("dependency_ids")
|
||||
if isinstance(dep_json, (list, dict)):
|
||||
dep_json = json.dumps(dep_json, ensure_ascii=False)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO dataset_registry
|
||||
(dataset_id, name, description, storage_uri, storage_layer,
|
||||
management_role, source, sync_script, dependency_ids,
|
||||
enabled, sort_order)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
name = VALUES(name),
|
||||
description = VALUES(description),
|
||||
storage_uri = VALUES(storage_uri),
|
||||
storage_layer = VALUES(storage_layer),
|
||||
management_role = VALUES(management_role),
|
||||
source = VALUES(source),
|
||||
sync_script = VALUES(sync_script),
|
||||
dependency_ids = VALUES(dependency_ids),
|
||||
enabled = VALUES(enabled),
|
||||
sort_order = VALUES(sort_order)
|
||||
""",
|
||||
(
|
||||
r.get("dataset_id", ""),
|
||||
r.get("name", ""),
|
||||
r.get("description", ""),
|
||||
r.get("storage_uri", ""),
|
||||
r.get("storage_layer", ""),
|
||||
r.get("management_role", ""),
|
||||
r.get("source", ""),
|
||||
r.get("sync_script", ""),
|
||||
dep_json or "[]",
|
||||
r.get("enabled", 1),
|
||||
r.get("sort_order", 0),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def fetch_dataset_registry_rows() -> list[dict[str, Any]]:
|
||||
init_mysql_schema()
|
||||
conn = get_mysql()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT * FROM dataset_registry ORDER BY sort_order, dataset_id")
|
||||
return cur.fetchall()
|
||||
|
||||
|
||||
def fetch_dataset_registry_map() -> dict[str, dict[str, Any]]:
|
||||
rows = fetch_dataset_registry_rows()
|
||||
return {r["dataset_id"]: r for r in rows}
|
||||
|
||||
|
||||
def update_dataset_registry_state(dataset_id: str, **kwargs: Any) -> None:
|
||||
"""按需更新 dataset_registry 的状态字段。"""
|
||||
allowed = {
|
||||
"name", "description", "storage_uri", "storage_layer", "management_role",
|
||||
"source", "sync_script", "dependency_ids", "enabled", "sort_order",
|
||||
"status", "trigger_source", "started_at", "finished_at",
|
||||
"last_success_at", "last_failure_at", "message", "last_error",
|
||||
"needs_resync", "progress_current", "progress_total", "current_step",
|
||||
"updated_at",
|
||||
}
|
||||
payload = {k: v for k, v in kwargs.items() if k in allowed}
|
||||
if not payload:
|
||||
return
|
||||
if "updated_at" not in payload:
|
||||
payload["updated_at"] = _now_local_ts()
|
||||
set_clause = ", ".join(f"`{k}` = %s" for k in payload)
|
||||
values = list(payload.values()) + [dataset_id]
|
||||
with _cursor() as cur:
|
||||
cur.execute(
|
||||
f"UPDATE dataset_registry SET {set_clause} WHERE dataset_id = %s",
|
||||
values,
|
||||
)
|
||||
|
||||
|
||||
def recover_interrupted_dataset_registry() -> int:
|
||||
"""把状态卡在 running 的同步任务标记为 failed(启动时调用)。"""
|
||||
with _cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE dataset_registry
|
||||
SET status = 'failed',
|
||||
finished_at = %s,
|
||||
last_failure_at = %s,
|
||||
last_error = CONCAT('进程在状态为 running 时被中断,已自动恢复。', IFNULL(last_error, '')),
|
||||
message = CONCAT('[recovered] ', IFNULL(message, '同步被中断')),
|
||||
needs_resync = 1
|
||||
WHERE status = 'running'
|
||||
""",
|
||||
(_now_local_ts(), _now_local_ts()),
|
||||
)
|
||||
return cur.rowcount
|
||||
|
||||
|
||||
# ── stocks ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def upsert_stock(
|
||||
code: str,
|
||||
name: str,
|
||||
exchange: str,
|
||||
list_date: str = "",
|
||||
listing_status: str = "normal",
|
||||
industry: str = "",
|
||||
) -> None:
|
||||
"""upsert 一只股票基础信息(单条)。
|
||||
|
||||
注意:MySQL 9.7.0 在 ON DUPLICATE KEY UPDATE 阶段对 DATE 字段的 VALUES()/new.col
|
||||
求值存在 bug(会强制把空串塞进去,触发 1292 严格模式错误)。所以这里:
|
||||
- list_date 字段在首次 INSERT 时写入;UPDATE 分支不更新(保持原值或 NULL)
|
||||
- industry 字段也避开这个 bug
|
||||
|
||||
批量写入请用 upsert_stocks_bulk(),性能高 10-20 倍。
|
||||
"""
|
||||
del list_date # 显式不接受 list_date 更新(旧值保留)
|
||||
del industry # 同上
|
||||
with _cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO stocks (code, name, exchange, listing_status)
|
||||
VALUES (%s, %s, %s, %s)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
name = VALUES(name),
|
||||
exchange = VALUES(exchange),
|
||||
listing_status = VALUES(listing_status)
|
||||
""",
|
||||
(code, name, exchange, listing_status),
|
||||
)
|
||||
|
||||
|
||||
def upsert_stocks_bulk(rows: list[dict[str, Any]], chunk_size: int = 500) -> int:
|
||||
"""批量 upsert 股票基础信息。
|
||||
|
||||
每条 row 需有: code, name, exchange, listing_status(可选 list_date / industry)
|
||||
性能:~5000 只股票从 ~50s 降到 ~3s。
|
||||
"""
|
||||
if not rows:
|
||||
return 0
|
||||
sql = (
|
||||
"INSERT INTO stocks (code, name, exchange, listing_status) "
|
||||
"VALUES (%s, %s, %s, %s) "
|
||||
"ON DUPLICATE KEY UPDATE "
|
||||
" name = VALUES(name), "
|
||||
" exchange = VALUES(exchange), "
|
||||
" listing_status = VALUES(listing_status)"
|
||||
)
|
||||
with _cursor() as cur:
|
||||
total = 0
|
||||
for i in range(0, len(rows), chunk_size):
|
||||
chunk = rows[i: i + chunk_size]
|
||||
values = [
|
||||
(r.get("code", ""), r.get("name", ""), r.get("exchange", ""), r.get("listing_status", "normal"))
|
||||
for r in chunk
|
||||
]
|
||||
cur.executemany(sql, values)
|
||||
total += len(chunk)
|
||||
return total
|
||||
|
||||
|
||||
def update_stock_share_snapshot(code: str, total_share: float, float_share: float, trade_date: str = "") -> None:
|
||||
with _cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE stocks
|
||||
SET total_share = %s, float_share = %s,
|
||||
share_updated_at = COALESCE(NULLIF(%s, ''), CURDATE())
|
||||
WHERE code = %s
|
||||
""",
|
||||
(float(total_share), float(float_share), trade_date or "", code),
|
||||
)
|
||||
|
||||
|
||||
def update_stock_kline_synced_at(code: str, synced_at: str) -> None:
|
||||
with _cursor() as cur:
|
||||
cur.execute(
|
||||
"UPDATE stocks SET kline_synced_at = %s WHERE code = %s",
|
||||
(_date_or_none(synced_at), code),
|
||||
)
|
||||
|
||||
|
||||
def fetch_all_stocks() -> list[dict[str, Any]]:
|
||||
init_mysql_schema()
|
||||
conn = get_mysql()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT * FROM stocks ORDER BY code")
|
||||
return cur.fetchall()
|
||||
|
||||
|
||||
def fetch_stock_by_code(code: str) -> Optional[dict[str, Any]]:
|
||||
init_mysql_schema()
|
||||
conn = get_mysql()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT * FROM stocks WHERE code = %s", (code,))
|
||||
row = cur.fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def stock_count() -> int:
|
||||
init_mysql_schema()
|
||||
conn = get_mysql()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT COUNT(*) AS cnt FROM stocks")
|
||||
row = cur.fetchone()
|
||||
return int(row["cnt"]) if row else 0
|
||||
|
||||
|
||||
def iter_stock_codes(active_only: bool = True) -> Iterable[str]:
|
||||
"""yield 6 位股票代码(去除 SH/SZ/BJ 前缀)。"""
|
||||
for s in fetch_all_stocks():
|
||||
if active_only and s.get("listing_status") == "delisted":
|
||||
continue
|
||||
code = str(s.get("code", "")).strip()
|
||||
if not code:
|
||||
continue
|
||||
for prefix in ("SH", "SZ", "BJ"):
|
||||
if code.startswith(prefix):
|
||||
code = code[len(prefix):]
|
||||
break
|
||||
if code.isdigit() and len(code) == 6:
|
||||
yield code
|
||||
|
||||
|
||||
# ── indices ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def upsert_index(
|
||||
index_code: str,
|
||||
index_name: str,
|
||||
market: str = "",
|
||||
category: str = "",
|
||||
source: str = "",
|
||||
enabled: bool = True,
|
||||
) -> None:
|
||||
with _cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO indices (index_code, index_name, market, category, source, enabled)
|
||||
VALUES (%s, %s, %s, %s, %s, %s)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
index_name = VALUES(index_name),
|
||||
market = VALUES(market),
|
||||
category = VALUES(category),
|
||||
source = VALUES(source),
|
||||
enabled = VALUES(enabled)
|
||||
""",
|
||||
(index_code, index_name, market, category, source, int(enabled)),
|
||||
)
|
||||
|
||||
|
||||
# ── kline 通用批量 upsert ──────────────────────────────────────────────
|
||||
|
||||
|
||||
def _bulk_upsert(
|
||||
cur,
|
||||
table: str,
|
||||
columns: list[str],
|
||||
rows: list[dict[str, Any]],
|
||||
conflict_keys: list[str],
|
||||
chunk_size: int = 1000,
|
||||
) -> int:
|
||||
"""通用批量 INSERT ... ON DUPLICATE KEY UPDATE 工具。"""
|
||||
if not rows:
|
||||
return 0
|
||||
placeholders = ", ".join(["%s"] * len(columns))
|
||||
col_list = ", ".join(f"`{c}`" for c in columns)
|
||||
update_clause = ", ".join(
|
||||
f"`{c}` = VALUES(`{c}`)"
|
||||
for c in columns
|
||||
if c not in conflict_keys
|
||||
)
|
||||
sql = (
|
||||
f"INSERT INTO {table} ({col_list}) VALUES ({placeholders}) "
|
||||
f"ON DUPLICATE KEY UPDATE {update_clause}"
|
||||
)
|
||||
total = 0
|
||||
for i in range(0, len(rows), chunk_size):
|
||||
chunk = rows[i: i + chunk_size]
|
||||
values = [
|
||||
tuple(_date_or_none(r.get(c)) if c.endswith("date") else r.get(c) for c in columns)
|
||||
for r in chunk
|
||||
]
|
||||
cur.executemany(sql, values)
|
||||
total += len(chunk)
|
||||
return total
|
||||
|
||||
|
||||
def upsert_kline_stock(rows: list[dict[str, Any]]) -> int:
|
||||
"""rows: stock_code, trade_date, open, high, low, close, volume"""
|
||||
with _cursor() as cur:
|
||||
return _bulk_upsert(
|
||||
cur, "kline_stock",
|
||||
["stock_code", "trade_date", "open", "high", "low", "close", "volume"],
|
||||
rows, conflict_keys=["stock_code", "trade_date"],
|
||||
)
|
||||
|
||||
|
||||
def upsert_kline_index(rows: list[dict[str, Any]]) -> int:
|
||||
"""rows: index_code, trade_date, open, high, low, close, volume"""
|
||||
with _cursor() as cur:
|
||||
return _bulk_upsert(
|
||||
cur, "kline_index",
|
||||
["index_code", "trade_date", "open", "high", "low", "close", "volume"],
|
||||
rows, conflict_keys=["index_code", "trade_date"],
|
||||
)
|
||||
|
||||
|
||||
def upsert_kline_5min(rows: list[dict[str, Any]]) -> int:
|
||||
"""rows: stock_code, bar_time, open, high, low, close, volume, amount, turnover_rate"""
|
||||
with _cursor() as cur:
|
||||
return _bulk_upsert(
|
||||
cur, "kline_5min",
|
||||
["stock_code", "bar_time", "open", "high", "low", "close", "volume", "amount", "turnover_rate"],
|
||||
rows, conflict_keys=["stock_code", "bar_time"],
|
||||
)
|
||||
|
||||
|
||||
def get_kline_5min_snapshots() -> dict[str, Optional[datetime]]:
|
||||
"""一次 SQL 拉 kline_5min 全表每只股票的最新 bar_time。
|
||||
|
||||
返回:{stock_code (6位): max(bar_time) 或 None}
|
||||
用于 kline_5min 任务的"全量/增量"统一规划:
|
||||
- DB 里有 → 增量(从 max - 2 天 到今天)
|
||||
- DB 里没 → 全量(从 mairui 历史深度起点 到今天)
|
||||
|
||||
DB 里 stock_code 形如 "000001.SZ"(mairui 写入格式),函数剥掉
|
||||
交易所后缀统一为 6 位。bar_time 是 DATETIME 字段,pymysql DictCursor
|
||||
在某些连接参数下会返回 str,这里手动解析为 datetime(解析失败置 None)。
|
||||
"""
|
||||
init_mysql_schema()
|
||||
conn = get_mysql()
|
||||
out: dict[str, Optional[datetime]] = {}
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT stock_code, MAX(bar_time) AS latest FROM kline_5min GROUP BY stock_code")
|
||||
for r in cur.fetchall():
|
||||
raw_code = str(r["stock_code"] or "").strip()
|
||||
code6 = raw_code.split(".")[0] # "000001.SZ" → "000001"
|
||||
if len(code6) != 6 or not code6.isdigit():
|
||||
continue
|
||||
latest = r["latest"]
|
||||
if isinstance(latest, datetime):
|
||||
pass
|
||||
elif isinstance(latest, str) and latest:
|
||||
try:
|
||||
latest = datetime.strptime(latest[:19], "%Y-%m-%d %H:%M:%S")
|
||||
except ValueError:
|
||||
latest = None
|
||||
else:
|
||||
latest = None
|
||||
out[code6] = latest
|
||||
return out
|
||||
|
||||
|
||||
def upsert_moneyflow(rows: list[dict[str, Any]]) -> int:
|
||||
"""rows: stock_code, trade_date, main_net_inflow, large_net_inflow, medium_net_inflow, small_net_inflow"""
|
||||
with _cursor() as cur:
|
||||
return _bulk_upsert(
|
||||
cur, "moneyflow",
|
||||
["stock_code", "trade_date", "main_net_inflow", "large_net_inflow", "medium_net_inflow", "small_net_inflow"],
|
||||
rows, conflict_keys=["stock_code", "trade_date"],
|
||||
)
|
||||
|
||||
|
||||
def upsert_share(rows: list[dict[str, Any]]) -> int:
|
||||
"""rows: stock_code, trade_date, total_share, float_share"""
|
||||
with _cursor() as cur:
|
||||
return _bulk_upsert(
|
||||
cur, "share",
|
||||
["stock_code", "trade_date", "total_share", "float_share"],
|
||||
rows, conflict_keys=["stock_code", "trade_date"],
|
||||
)
|
||||
|
||||
|
||||
# ── 行业 / 概念板块 ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def replace_all_industries(rows: list[dict[str, Any]]) -> None:
|
||||
"""全量替换 industry 表。rows: code, industry_name, industry_classification, update_date"""
|
||||
if not rows:
|
||||
return
|
||||
with _cursor() as cur:
|
||||
cur.execute("DELETE FROM industry")
|
||||
for r in rows:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO industry (code, industry_name, industry_classification, update_date)
|
||||
VALUES (%s, %s, %s, %s)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
industry_name = VALUES(industry_name),
|
||||
industry_classification = VALUES(industry_classification),
|
||||
update_date = VALUES(update_date)
|
||||
""",
|
||||
(
|
||||
str(r.get("code", "")).zfill(6),
|
||||
r.get("industry_name") or None,
|
||||
r.get("industry_classification") or None,
|
||||
_date_or_none(r.get("update_date")),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def fetch_all_industries() -> list[dict[str, Any]]:
|
||||
init_mysql_schema()
|
||||
conn = get_mysql()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT code, industry_name, industry_classification, update_date FROM industry")
|
||||
return cur.fetchall()
|
||||
|
||||
|
||||
def replace_all_sectors(rows: list[dict[str, Any]]) -> None:
|
||||
"""rows: sector_key, sector_name, taxonomy, level, source, enabled"""
|
||||
if not rows:
|
||||
return
|
||||
with _cursor() as cur:
|
||||
cur.execute("DELETE FROM sectors")
|
||||
for r in rows:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO sectors (sector_key, sector_name, taxonomy, level, source, enabled)
|
||||
VALUES (%s, %s, %s, %s, %s, %s)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
sector_name = VALUES(sector_name),
|
||||
taxonomy = VALUES(taxonomy),
|
||||
level = VALUES(level),
|
||||
source = VALUES(source),
|
||||
enabled = VALUES(enabled)
|
||||
""",
|
||||
(
|
||||
r.get("sector_key", ""),
|
||||
r.get("sector_name", ""),
|
||||
r.get("taxonomy", ""),
|
||||
r.get("level", ""),
|
||||
r.get("source", ""),
|
||||
int(r.get("enabled", 1)),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def replace_all_stock_sector_map(rows: list[dict[str, Any]]) -> None:
|
||||
"""rows: stock_code, sector_key"""
|
||||
if not rows:
|
||||
return
|
||||
with _cursor() as cur:
|
||||
cur.execute("DELETE FROM stock_sector_map")
|
||||
for r in rows:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO stock_sector_map (stock_code, sector_key)
|
||||
VALUES (%s, %s)
|
||||
ON DUPLICATE KEY UPDATE sector_key = VALUES(sector_key)
|
||||
""",
|
||||
(str(r.get("stock_code", "")).zfill(6), r.get("sector_key", "")),
|
||||
)
|
||||
|
||||
|
||||
# ── 行业聚合(衍生)─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def replace_all_sector_indices(rows: list[dict[str, Any]]) -> None:
|
||||
"""rows: trade_date, sector_name, close, sector_amplitude"""
|
||||
if not rows:
|
||||
return
|
||||
with _cursor() as cur:
|
||||
for r in rows:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO sector_indices (trade_date, sector_name, `close`, sector_amplitude)
|
||||
VALUES (%s, %s, %s, %s)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
`close` = VALUES(`close`),
|
||||
sector_amplitude = VALUES(sector_amplitude)
|
||||
""",
|
||||
(
|
||||
_date_or_none(r.get("trade_date")),
|
||||
r.get("sector_name", ""),
|
||||
float(r.get("close") or 0),
|
||||
float(r.get("sector_amplitude") or 0),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def replace_all_sector_features(rows: list[dict[str, Any]]) -> None:
|
||||
"""rows: trade_date, sector_name, sector_ret, sector_amplitude, close, ema10, ema20, ema200, score"""
|
||||
if not rows:
|
||||
return
|
||||
with _cursor() as cur:
|
||||
for r in rows:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO sector_features_daily
|
||||
(trade_date, sector_name, sector_ret, sector_amplitude, `close`, ema10, ema20, ema200, score)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
sector_ret = VALUES(sector_ret),
|
||||
sector_amplitude = VALUES(sector_amplitude),
|
||||
`close` = VALUES(`close`),
|
||||
ema10 = VALUES(ema10),
|
||||
ema20 = VALUES(ema20),
|
||||
ema200 = VALUES(ema200),
|
||||
score = VALUES(score)
|
||||
""",
|
||||
(
|
||||
_date_or_none(r.get("trade_date")),
|
||||
r.get("sector_name", ""),
|
||||
float(r.get("sector_ret") or 0),
|
||||
float(r.get("sector_amplitude") or 0),
|
||||
float(r.get("close") or 0),
|
||||
float(r.get("ema10") or 0),
|
||||
float(r.get("ema20") or 0),
|
||||
float(r.get("ema200") or 0),
|
||||
int(r.get("score") or 0),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# ── 市场情绪(衍生)─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def upsert_market_regime_rows(rows: list[dict[str, Any]]) -> None:
|
||||
"""rows: trade_date, advancers, decliners, advance_ratio, turnover, turnover_avg_5d, turnover_ratio_5d, source, is_extreme_panic"""
|
||||
if not rows:
|
||||
return
|
||||
with _cursor() as cur:
|
||||
for r in rows:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO market_regime_daily
|
||||
(trade_date, advancers, decliners, advance_ratio, turnover,
|
||||
turnover_avg_5d, turnover_ratio_5d, source, is_extreme_panic)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
advancers = VALUES(advancers),
|
||||
decliners = VALUES(decliners),
|
||||
advance_ratio = VALUES(advance_ratio),
|
||||
turnover = VALUES(turnover),
|
||||
turnover_avg_5d = VALUES(turnover_avg_5d),
|
||||
turnover_ratio_5d = VALUES(turnover_ratio_5d),
|
||||
source = VALUES(source),
|
||||
is_extreme_panic = VALUES(is_extreme_panic)
|
||||
""",
|
||||
(
|
||||
_date_or_none(r.get("trade_date")),
|
||||
float(r.get("advancers") or 0),
|
||||
float(r.get("decliners") or 0),
|
||||
float(r.get("advance_ratio") or 0),
|
||||
float(r.get("turnover") or 0),
|
||||
float(r.get("turnover_avg_5d") or 0),
|
||||
float(r.get("turnover_ratio_5d") or 0),
|
||||
r.get("source", ""),
|
||||
int(r.get("is_extreme_panic") or 0),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# ── kline 查询(同步时用于判断增量起点)─────────────────────────────────
|
||||
|
||||
|
||||
def get_stock_kline_max_date(code: str) -> Optional[str]:
|
||||
init_mysql_schema()
|
||||
conn = get_mysql()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT MAX(trade_date) AS d FROM kline_stock WHERE stock_code = %s",
|
||||
(code,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row and row.get("d"):
|
||||
return str(row["d"])
|
||||
return None
|
||||
|
||||
|
||||
def get_index_kline_max_date(index_code: str) -> Optional[str]:
|
||||
init_mysql_schema()
|
||||
conn = get_mysql()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT MAX(trade_date) AS d FROM kline_index WHERE index_code = %s",
|
||||
(index_code,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row and row.get("d"):
|
||||
return str(row["d"])
|
||||
return None
|
||||
|
||||
|
||||
def get_5min_max_bar_time(code: str) -> Optional[str]:
|
||||
init_mysql_schema()
|
||||
conn = get_mysql()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT MAX(bar_time) AS d FROM kline_5min WHERE stock_code = %s",
|
||||
(code,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row and row.get("d"):
|
||||
return str(row["d"])
|
||||
return None
|
||||
|
||||
|
||||
def get_moneyflow_max_date(code: str) -> Optional[str]:
|
||||
init_mysql_schema()
|
||||
conn = get_mysql()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT MAX(trade_date) AS d FROM moneyflow WHERE stock_code = %s",
|
||||
(code,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row and row.get("d"):
|
||||
return str(row["d"])
|
||||
return None
|
||||
@@ -0,0 +1,264 @@
|
||||
"""Schema DDL — 集中所有 CREATE TABLE IF NOT EXISTS。
|
||||
|
||||
参考 dashboard/api/services/mysql_conn.py::init_mysql_schema(),但补齐 dashboard 缺的几张表
|
||||
(kline_stock / kline_5min / kline_index / moneyflow / share / sector_features_daily / sector_indices
|
||||
/ stock_scores / registry),这些是 grid_seeker_model_base 生产库已存在但 mysql_conn.py 没建的表。
|
||||
|
||||
所有 DDL 写在同一个文件,方便审阅、diff 和补字段。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import pymysql.connections
|
||||
|
||||
|
||||
# DDL 列表(顺序无关,全部 IF NOT EXISTS)
|
||||
_DDL: list[str] = [
|
||||
# ─── config ─────────────────────────────────────────────────────────
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS config (
|
||||
`key` VARCHAR(128) PRIMARY KEY,
|
||||
`value` TEXT NOT NULL,
|
||||
category VARCHAR(32) NOT NULL DEFAULT 'general',
|
||||
description VARCHAR(255) DEFAULT '',
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
""",
|
||||
# ─── dataset_registry (同步状态机) ────────────────────────────────────
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS dataset_registry (
|
||||
dataset_id VARCHAR(64) PRIMARY KEY,
|
||||
name VARCHAR(128) NOT NULL DEFAULT '',
|
||||
description TEXT,
|
||||
storage_uri VARCHAR(255) DEFAULT '',
|
||||
storage_layer VARCHAR(32) DEFAULT '',
|
||||
management_role VARCHAR(32) DEFAULT '',
|
||||
source VARCHAR(255) DEFAULT '',
|
||||
sync_script VARCHAR(255) DEFAULT '',
|
||||
dependency_ids VARCHAR(4096) DEFAULT '[]',
|
||||
enabled INT DEFAULT 1,
|
||||
sort_order INT DEFAULT 0,
|
||||
status VARCHAR(16) DEFAULT 'idle',
|
||||
trigger_source VARCHAR(32) DEFAULT '',
|
||||
started_at DATETIME DEFAULT NULL,
|
||||
finished_at DATETIME DEFAULT NULL,
|
||||
last_success_at DATETIME DEFAULT NULL,
|
||||
last_failure_at DATETIME DEFAULT NULL,
|
||||
message TEXT,
|
||||
last_error TEXT,
|
||||
needs_resync INT DEFAULT 0,
|
||||
progress_current INT DEFAULT 0,
|
||||
progress_total INT DEFAULT 0,
|
||||
current_step VARCHAR(255) DEFAULT '',
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_dataset_registry_sort_order (sort_order)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
""",
|
||||
# ─── stocks (基础信息 + 股本字段) ─────────────────────────────────────
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS stocks (
|
||||
code VARCHAR(10) PRIMARY KEY,
|
||||
name VARCHAR(32) NOT NULL DEFAULT '',
|
||||
exchange VARCHAR(8) NOT NULL DEFAULT '',
|
||||
list_date DATE DEFAULT NULL,
|
||||
listing_status VARCHAR(16) NOT NULL DEFAULT 'normal',
|
||||
industry VARCHAR(64) DEFAULT '',
|
||||
total_share DOUBLE DEFAULT 0,
|
||||
float_share DOUBLE DEFAULT 0,
|
||||
share_updated_at DATE DEFAULT NULL,
|
||||
kline_synced_at DATE DEFAULT NULL,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
""",
|
||||
# ─── indices (指数字典) ───────────────────────────────────────────────
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS indices (
|
||||
index_code VARCHAR(10) PRIMARY KEY,
|
||||
index_name VARCHAR(64) NOT NULL DEFAULT '',
|
||||
market VARCHAR(16) DEFAULT '',
|
||||
category VARCHAR(16) DEFAULT '',
|
||||
source VARCHAR(32) DEFAULT '',
|
||||
enabled INT DEFAULT 1,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
""",
|
||||
# ─── kline_stock (日 K 线,按股票) ────────────────────────────────────
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS kline_stock (
|
||||
stock_code VARCHAR(10) NOT NULL,
|
||||
trade_date DATE NOT NULL,
|
||||
`open` DOUBLE NOT NULL,
|
||||
`high` DOUBLE NOT NULL,
|
||||
`low` DOUBLE NOT NULL,
|
||||
`close` DOUBLE NOT NULL,
|
||||
volume DOUBLE NOT NULL,
|
||||
PRIMARY KEY (stock_code, trade_date),
|
||||
INDEX idx_kline_stock_date (trade_date)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
""",
|
||||
# ─── kline_index (指数日 K 线) ────────────────────────────────────────
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS kline_index (
|
||||
index_code VARCHAR(10) NOT NULL,
|
||||
trade_date DATE NOT NULL,
|
||||
`open` DOUBLE NOT NULL,
|
||||
`high` DOUBLE NOT NULL,
|
||||
`low` DOUBLE NOT NULL,
|
||||
`close` DOUBLE NOT NULL,
|
||||
volume DOUBLE NOT NULL,
|
||||
PRIMARY KEY (index_code, trade_date),
|
||||
INDEX idx_kline_index_date (trade_date)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
""",
|
||||
# ─── kline_5min (5 分钟 K 线) ────────────────────────────────────────
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS kline_5min (
|
||||
stock_code VARCHAR(10) NOT NULL,
|
||||
bar_time DATETIME NOT NULL,
|
||||
`open` DOUBLE DEFAULT NULL,
|
||||
`high` DOUBLE DEFAULT NULL,
|
||||
`low` DOUBLE DEFAULT NULL,
|
||||
`close` DOUBLE DEFAULT NULL,
|
||||
volume DOUBLE DEFAULT NULL,
|
||||
amount DOUBLE DEFAULT NULL,
|
||||
turnover_rate DOUBLE DEFAULT NULL,
|
||||
PRIMARY KEY (stock_code, bar_time),
|
||||
INDEX idx_kline_5min_bar_time (bar_time)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
""",
|
||||
# ─── moneyflow (资金流,主力/大/中/小单净额) ──────────────────────────
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS moneyflow (
|
||||
stock_code VARCHAR(10) NOT NULL,
|
||||
trade_date DATE NOT NULL,
|
||||
main_net_inflow DOUBLE DEFAULT 0,
|
||||
large_net_inflow DOUBLE DEFAULT 0,
|
||||
medium_net_inflow DOUBLE DEFAULT 0,
|
||||
small_net_inflow DOUBLE DEFAULT 0,
|
||||
PRIMARY KEY (stock_code, trade_date),
|
||||
INDEX idx_moneyflow_date (trade_date)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
""",
|
||||
# ─── share (股本快照) ────────────────────────────────────────────────
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS share (
|
||||
stock_code VARCHAR(10) NOT NULL,
|
||||
trade_date DATE NOT NULL,
|
||||
total_share DOUBLE NOT NULL,
|
||||
float_share DOUBLE NOT NULL,
|
||||
PRIMARY KEY (stock_code, trade_date)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
""",
|
||||
# ─── sectors (行业字典) ──────────────────────────────────────────────
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS sectors (
|
||||
sector_key VARCHAR(64) PRIMARY KEY,
|
||||
sector_name VARCHAR(64) NOT NULL DEFAULT '',
|
||||
taxonomy VARCHAR(64) DEFAULT '',
|
||||
level VARCHAR(16) DEFAULT '',
|
||||
source VARCHAR(32) DEFAULT '',
|
||||
enabled INT DEFAULT 1,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_sectors_sector_name (sector_name)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
""",
|
||||
# ─── stock_sector_map (股票→行业映射) ─────────────────────────────────
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS stock_sector_map (
|
||||
stock_code VARCHAR(6) PRIMARY KEY,
|
||||
sector_key VARCHAR(64) NOT NULL DEFAULT '',
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_stock_sector_map_sector_key (sector_key)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
""",
|
||||
# ─── industry (股票-行业映射,datasource 实际写入用这张) ──────────────
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS industry (
|
||||
code VARCHAR(6) PRIMARY KEY,
|
||||
industry_name VARCHAR(64) DEFAULT '',
|
||||
industry_classification VARCHAR(32) DEFAULT '',
|
||||
update_date DATE DEFAULT NULL,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_industry_industry_name (industry_name)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
""",
|
||||
# ─── sector_indices (行业聚合指数时序) ─────────────────────────────────
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS sector_indices (
|
||||
trade_date DATE NOT NULL,
|
||||
sector_name VARCHAR(64) NOT NULL,
|
||||
`close` DOUBLE DEFAULT 0,
|
||||
sector_amplitude DOUBLE DEFAULT 0,
|
||||
PRIMARY KEY (trade_date, sector_name),
|
||||
INDEX idx_sector_indices_sector_name (sector_name)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
""",
|
||||
# ─── sector_features_daily (行业特征衍生) ─────────────────────────────
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS sector_features_daily (
|
||||
trade_date DATE NOT NULL,
|
||||
sector_name VARCHAR(64) NOT NULL,
|
||||
sector_ret DOUBLE DEFAULT 0,
|
||||
sector_amplitude DOUBLE DEFAULT 0,
|
||||
`close` DOUBLE DEFAULT 0,
|
||||
ema10 DOUBLE DEFAULT 0,
|
||||
ema20 DOUBLE DEFAULT 0,
|
||||
ema200 DOUBLE DEFAULT 0,
|
||||
score INT DEFAULT 0,
|
||||
PRIMARY KEY (trade_date, sector_name),
|
||||
INDEX idx_sector_features_sector_name (sector_name)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
""",
|
||||
# ─── market_regime_daily (市场情绪) ───────────────────────────────────
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS market_regime_daily (
|
||||
trade_date DATE PRIMARY KEY,
|
||||
advancers DOUBLE DEFAULT 0,
|
||||
decliners DOUBLE DEFAULT 0,
|
||||
advance_ratio DOUBLE DEFAULT 0,
|
||||
turnover DOUBLE DEFAULT 0,
|
||||
turnover_avg_5d DOUBLE DEFAULT 0,
|
||||
turnover_ratio_5d DOUBLE DEFAULT 0,
|
||||
source VARCHAR(32) DEFAULT '',
|
||||
is_extreme_panic INT DEFAULT 0,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_market_regime_daily_date (trade_date)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
""",
|
||||
# ─── registry (模型注册,dashboard 业务无关但库里有) ─────────────────
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS registry (
|
||||
model_name VARCHAR(64) NOT NULL,
|
||||
role VARCHAR(32) NOT NULL,
|
||||
version VARCHAR(16) DEFAULT NULL,
|
||||
artifact_path VARCHAR(256) NOT NULL,
|
||||
architecture VARCHAR(32) DEFAULT NULL,
|
||||
enabled TINYINT(1) DEFAULT 1,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (model_name, role)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
""",
|
||||
# ─── stock_scores (评分快照,dashboard 业务无关但库里有) ─────────────
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS stock_scores (
|
||||
`date` DATE NOT NULL,
|
||||
stock_code VARCHAR(10) NOT NULL,
|
||||
predicted_rounds DOUBLE DEFAULT 0,
|
||||
stacking_probability DOUBLE DEFAULT 0,
|
||||
meta_ranker_score DOUBLE DEFAULT NULL,
|
||||
latest_close DOUBLE DEFAULT 0,
|
||||
`rank` INT DEFAULT 0,
|
||||
PRIMARY KEY (`date`, stock_code),
|
||||
INDEX idx_stock_scores_date (`date`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
""",
|
||||
]
|
||||
|
||||
|
||||
def ensure_all_tables(conn) -> None:
|
||||
"""遍历 _DDL 列表,逐条执行 CREATE TABLE IF NOT EXISTS。"""
|
||||
with conn.cursor() as cur:
|
||||
for ddl in _DDL:
|
||||
cur.execute(ddl)
|
||||
conn.commit()
|
||||
@@ -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())
|
||||
@@ -0,0 +1,24 @@
|
||||
"""sync 子包入口。"""
|
||||
from app.core.sync.registry import (
|
||||
mark_sync_running,
|
||||
mark_sync_progress,
|
||||
mark_sync_success,
|
||||
mark_sync_failed,
|
||||
mark_sync_blocked,
|
||||
get_registry_status,
|
||||
recover_interrupted_syncs,
|
||||
seed_sync_registry,
|
||||
SYNC_DEFINITIONS,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"mark_sync_running",
|
||||
"mark_sync_progress",
|
||||
"mark_sync_success",
|
||||
"mark_sync_failed",
|
||||
"mark_sync_blocked",
|
||||
"get_registry_status",
|
||||
"recover_interrupted_syncs",
|
||||
"seed_sync_registry",
|
||||
"SYNC_DEFINITIONS",
|
||||
]
|
||||
@@ -0,0 +1,141 @@
|
||||
"""SyncTask 基类。
|
||||
|
||||
提供统一的:
|
||||
- 状态自动更新(running → success / failed)
|
||||
- 进度回调
|
||||
- 异常捕获 → 写 dataset_registry
|
||||
|
||||
子类只需实现 _run() 即可。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
import traceback
|
||||
from datetime import datetime, time as dtime
|
||||
from typing import Any, Optional
|
||||
|
||||
from app.core.sync.registry import (
|
||||
mark_sync_failed,
|
||||
mark_sync_progress,
|
||||
mark_sync_running,
|
||||
mark_sync_success,
|
||||
)
|
||||
from app.core.utils.logging import get_logger
|
||||
|
||||
logger = get_logger("sync.task")
|
||||
|
||||
|
||||
def _is_market_closed() -> bool:
|
||||
"""是否超过 15:30(盘后才完整,避免盘中部分数据)。"""
|
||||
return datetime.now().time() >= dtime(15, 30)
|
||||
|
||||
|
||||
def _effective_sync_end() -> str:
|
||||
"""若盘后则今天,否则昨天。"""
|
||||
today = datetime.now()
|
||||
if today.time() >= dtime(15, 30):
|
||||
return today.strftime("%Y-%m-%d")
|
||||
# 昨天
|
||||
from datetime import timedelta
|
||||
return (today - timedelta(days=1)).strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
class SyncTask:
|
||||
"""同步任务基类。"""
|
||||
|
||||
dataset_id: str = "" # 子类必须设置
|
||||
|
||||
def __init__(self, dataset_id: Optional[str] = None):
|
||||
if dataset_id:
|
||||
self.dataset_id = dataset_id
|
||||
if not self.dataset_id:
|
||||
raise ValueError(f"{self.__class__.__name__} must set .dataset_id")
|
||||
|
||||
# ── 公开入口 ─────────────────────────────────────────
|
||||
|
||||
def run(self, *, trigger_source: str = "manual", **kwargs) -> dict[str, Any]:
|
||||
"""统一入口:自动包 mark_running / mark_success / mark_failed。"""
|
||||
# 1. 确保 health check 跑过(CLI / 手动触发场景下 schedule 后台不会先跑)
|
||||
self._ensure_health_checked()
|
||||
|
||||
# 2. 启动
|
||||
mark_sync_running(
|
||||
self.dataset_id,
|
||||
trigger_source=trigger_source,
|
||||
message=f"开始同步 {self.dataset_id}...",
|
||||
)
|
||||
t0 = time.time()
|
||||
try:
|
||||
result = self._run(trigger_source=trigger_source, **kwargs)
|
||||
elapsed = round(time.time() - t0, 1)
|
||||
status = result.get("status", "ok")
|
||||
message = result.get("message", "")
|
||||
if status == "ok":
|
||||
mark_sync_success(self.dataset_id, message=message)
|
||||
elif status == "blocked":
|
||||
mark_sync_failed(
|
||||
self.dataset_id,
|
||||
message=message,
|
||||
error=result.get("error", message),
|
||||
status="blocked",
|
||||
)
|
||||
else: # warning / partial
|
||||
mark_sync_failed(
|
||||
self.dataset_id,
|
||||
message=message,
|
||||
error=result.get("error", message),
|
||||
status="warning",
|
||||
)
|
||||
result["elapsed_sec"] = elapsed
|
||||
logger.info(f"[{self.dataset_id}] 完成 {status}: {message} ({elapsed}s)")
|
||||
return result
|
||||
except Exception as e:
|
||||
elapsed = round(time.time() - t0, 1)
|
||||
tb = traceback.format_exc(limit=2)
|
||||
err_msg = f"{type(e).__name__}: {e}"
|
||||
mark_sync_failed(self.dataset_id, message=err_msg, error=tb)
|
||||
logger.error(f"[{self.dataset_id}] 失败: {err_msg}\n{tb}")
|
||||
return {
|
||||
"status": "error",
|
||||
"message": err_msg,
|
||||
"error": err_msg,
|
||||
"elapsed_sec": elapsed,
|
||||
}
|
||||
|
||||
def _ensure_health_checked(self) -> None:
|
||||
"""若内存里 health_results 为空,跑一次。"""
|
||||
try:
|
||||
from app.core.datasource.registry import get_health_status, run_health_check
|
||||
if not get_health_status():
|
||||
run_health_check()
|
||||
except Exception as e:
|
||||
logger.warning(f"health check 启动失败(不影响任务继续): {e}")
|
||||
|
||||
# ── 子类实现 ─────────────────────────────────────────
|
||||
|
||||
def _run(self, *, trigger_source: str = "manual", **kwargs) -> dict[str, Any]:
|
||||
"""子类实现:实际同步逻辑。
|
||||
|
||||
返回 dict:
|
||||
{
|
||||
"status": "ok" | "warning" | "blocked",
|
||||
"message": "...",
|
||||
"ok": int, "fail": int, "skip": int, # 可选统计
|
||||
...
|
||||
}
|
||||
任何异常会被外层捕获并标记 failed。
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
# ── 进度更新辅助 ─────────────────────────────────────
|
||||
|
||||
def _progress(self, *, message: str | None = None,
|
||||
current: int | None = None, total: int | None = None,
|
||||
current_step: str | None = None) -> None:
|
||||
mark_sync_progress(
|
||||
self.dataset_id,
|
||||
message=message,
|
||||
progress_current=current,
|
||||
progress_total=total,
|
||||
current_step=current_step,
|
||||
)
|
||||
@@ -0,0 +1,249 @@
|
||||
"""sync registry 状态机封装。
|
||||
|
||||
模式照搬 dashboard/api/services/dataset_registry.py:
|
||||
- dataset_id 是唯一键
|
||||
- mark_sync_running / mark_sync_progress / mark_sync_success / mark_sync_failed / mark_sync_blocked
|
||||
- recover_interrupted_syncs 启动时把 status='running' 的任务改为 failed
|
||||
- seed_sync_registry 把所有同步任务定义 seed 到 dataset_registry 表
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from app.core.db import ops as db_ops
|
||||
from app.core.utils.logging import get_logger
|
||||
|
||||
logger = get_logger("sync.registry")
|
||||
|
||||
|
||||
# ── 同步任务定义(写入 dataset_registry 表)──────────────────────────────
|
||||
|
||||
|
||||
SYNC_DEFINITIONS: list[dict[str, Any]] = [
|
||||
{
|
||||
"dataset_id": "stock_basic",
|
||||
"name": "股票基础信息(含最新股本)",
|
||||
"description": "全市场 A 股代码 / 名称 / 交易所 / 上市状态 + 股本快照(雪球)",
|
||||
"storage_uri": "MySQL market_data_sync_db.stocks",
|
||||
"storage_layer": "mysql",
|
||||
"management_role": "基础字典",
|
||||
"source": "Baostock query_stock_basic + 雪球 quote_detail",
|
||||
"sync_script": "app.tasks.task_stocks_basic:run",
|
||||
"dependency_ids": [],
|
||||
"sort_order": 10,
|
||||
},
|
||||
{
|
||||
"dataset_id": "kline_daily",
|
||||
"name": "原始日K线数据",
|
||||
"description": "全市场 A 股日频 OHLCV,多源交叉校验(雪球/新浪/Baostock)",
|
||||
"storage_uri": "MySQL market_data_sync_db.kline_stock",
|
||||
"storage_layer": "mysql",
|
||||
"management_role": "原始源",
|
||||
"source": "雪球主源 + 新浪/Baostock 复核",
|
||||
"sync_script": "app.tasks.task_kline_daily:run",
|
||||
"dependency_ids": ["stock_basic"],
|
||||
"sort_order": 20,
|
||||
},
|
||||
{
|
||||
"dataset_id": "kline_index",
|
||||
"name": "六大指数日线",
|
||||
"description": "上证 / 深证 / 创业板 / 沪深300 / 中证500 / 中证1000",
|
||||
"storage_uri": "MySQL market_data_sync_db.kline_index + indices",
|
||||
"storage_layer": "mysql",
|
||||
"management_role": "原始源",
|
||||
"source": "新浪指数日K",
|
||||
"sync_script": "app.tasks.task_kline_index:run",
|
||||
"dependency_ids": [],
|
||||
"sort_order": 30,
|
||||
},
|
||||
{
|
||||
"dataset_id": "kline_5min",
|
||||
"name": "5 分钟 K 线",
|
||||
"description": "全市场 A 股 5 分钟 K 线(mairui 源)",
|
||||
"storage_uri": "MySQL market_data_sync_db.kline_5min",
|
||||
"storage_layer": "mysql",
|
||||
"management_role": "原始源",
|
||||
"source": "mairui stockMin",
|
||||
"sync_script": "app.tasks.task_kline_5min:run",
|
||||
"dependency_ids": ["stock_basic"],
|
||||
"sort_order": 40,
|
||||
},
|
||||
{
|
||||
"dataset_id": "moneyflow",
|
||||
"name": "资金流",
|
||||
"description": "个股资金流(主力/大/中/小单净额),mairui 源",
|
||||
"storage_uri": "MySQL market_data_sync_db.moneyflow",
|
||||
"storage_layer": "mysql",
|
||||
"management_role": "原始源",
|
||||
"source": "mairui hsstock/history/transaction",
|
||||
"sync_script": "app.tasks.task_moneyflow:run",
|
||||
"dependency_ids": ["stock_basic"],
|
||||
"sort_order": 50,
|
||||
},
|
||||
{
|
||||
"dataset_id": "industry_sector",
|
||||
"name": "股票-行业映射",
|
||||
"description": "股票-行业映射 + 行业字典(Baostock)",
|
||||
"storage_uri": "MySQL market_data_sync_db.industry + sectors + stock_sector_map",
|
||||
"storage_layer": "mysql",
|
||||
"management_role": "基础字典",
|
||||
"source": "Baostock query_stock_industry",
|
||||
"sync_script": "app.tasks.task_industry_sector:run",
|
||||
"dependency_ids": ["stock_basic"],
|
||||
"sort_order": 60,
|
||||
},
|
||||
{
|
||||
"dataset_id": "sector_features",
|
||||
"name": "行业聚合特征",
|
||||
"description": "由 kline_stock + industry 衍生:行业日收益 / 行业指数 close / EMA10-20-200 / score。纯本地计算,无外部 API。",
|
||||
"storage_uri": "MySQL market_data_sync_db.sector_indices + sector_features_daily",
|
||||
"storage_layer": "mysql",
|
||||
"management_role": "衍生源",
|
||||
"source": "本地计算(kline_stock + industry)",
|
||||
"sync_script": "app.tasks.task_sector_features:run",
|
||||
"dependency_ids": ["kline_daily", "industry_sector"],
|
||||
"sort_order": 65,
|
||||
},
|
||||
{
|
||||
"dataset_id": "share_snapshot",
|
||||
"name": "股本快照",
|
||||
"description": "全市场 A 股最新股本(雪球 quote_detail,单位亿股)",
|
||||
"storage_uri": "MySQL market_data_sync_db.stocks.total_share/float_share + share 表",
|
||||
"storage_layer": "mysql",
|
||||
"management_role": "基础字典",
|
||||
"source": "雪球 quote_detail",
|
||||
"sync_script": "app.tasks.task_share_snapshot:run",
|
||||
"dependency_ids": ["stock_basic"],
|
||||
"sort_order": 70,
|
||||
},
|
||||
{
|
||||
"dataset_id": "market_regime",
|
||||
"name": "市场情绪 (Market Regime)",
|
||||
"description": "基于本地日K线聚合的 advance_ratio / 恐慌标记(衍生源)",
|
||||
"storage_uri": "MySQL market_data_sync_db.market_regime_daily",
|
||||
"storage_layer": "mysql",
|
||||
"management_role": "衍生源",
|
||||
"source": "本地日K线聚合(无外部接口)",
|
||||
"sync_script": "app.tasks.task_market_regime:run",
|
||||
"dependency_ids": ["kline_daily"],
|
||||
"sort_order": 80,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def seed_sync_registry() -> None:
|
||||
"""把 SYNC_DEFINITIONS 写入 dataset_registry 表。"""
|
||||
rows = []
|
||||
for d in SYNC_DEFINITIONS:
|
||||
rows.append({
|
||||
**d,
|
||||
"dependency_ids": json.dumps(d.get("dependency_ids", []), ensure_ascii=False),
|
||||
"enabled": 1,
|
||||
})
|
||||
db_ops.upsert_dataset_registry_rows(rows)
|
||||
|
||||
|
||||
# ── 状态机 API ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _now_ts() -> str:
|
||||
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
|
||||
def mark_sync_running(
|
||||
dataset_id: str,
|
||||
*,
|
||||
trigger_source: str,
|
||||
message: str = "",
|
||||
progress_current: int = 0,
|
||||
progress_total: int = 0,
|
||||
current_step: str = "",
|
||||
) -> None:
|
||||
db_ops.update_dataset_registry_state(
|
||||
dataset_id,
|
||||
status="running",
|
||||
trigger_source=trigger_source,
|
||||
started_at=_now_ts(),
|
||||
finished_at=None,
|
||||
message=message,
|
||||
last_error=None,
|
||||
needs_resync=0,
|
||||
progress_current=progress_current,
|
||||
progress_total=progress_total,
|
||||
current_step=current_step,
|
||||
updated_at=_now_ts(),
|
||||
)
|
||||
|
||||
|
||||
def mark_sync_progress(
|
||||
dataset_id: str,
|
||||
*,
|
||||
message: str | None = None,
|
||||
progress_current: int | None = None,
|
||||
progress_total: int | None = None,
|
||||
current_step: str | None = None,
|
||||
) -> None:
|
||||
payload: dict[str, Any] = {"updated_at": _now_ts()}
|
||||
if message is not None:
|
||||
payload["message"] = message
|
||||
if progress_current is not None:
|
||||
payload["progress_current"] = progress_current
|
||||
if progress_total is not None:
|
||||
payload["progress_total"] = progress_total
|
||||
if current_step is not None:
|
||||
payload["current_step"] = current_step
|
||||
db_ops.update_dataset_registry_state(dataset_id, **payload)
|
||||
|
||||
|
||||
def mark_sync_success(dataset_id: str, *, message: str = "") -> None:
|
||||
now = _now_ts()
|
||||
db_ops.update_dataset_registry_state(
|
||||
dataset_id,
|
||||
status="success",
|
||||
finished_at=now,
|
||||
last_success_at=now,
|
||||
message=message,
|
||||
last_error=None,
|
||||
needs_resync=0,
|
||||
current_step="",
|
||||
progress_current=0,
|
||||
progress_total=0,
|
||||
updated_at=now,
|
||||
)
|
||||
|
||||
|
||||
def mark_sync_failed(
|
||||
dataset_id: str,
|
||||
*,
|
||||
message: str = "",
|
||||
error: str = "",
|
||||
status: str = "failed",
|
||||
) -> None:
|
||||
now = _now_ts()
|
||||
final_message = message or error or "同步失败"
|
||||
db_ops.update_dataset_registry_state(
|
||||
dataset_id,
|
||||
status=status,
|
||||
finished_at=now,
|
||||
last_failure_at=now,
|
||||
message=final_message,
|
||||
last_error=error or final_message,
|
||||
needs_resync=1,
|
||||
current_step="",
|
||||
updated_at=now,
|
||||
)
|
||||
|
||||
|
||||
def mark_sync_blocked(dataset_id: str, *, message: str) -> None:
|
||||
mark_sync_failed(dataset_id, message=message, error=message, status="blocked")
|
||||
|
||||
|
||||
def recover_interrupted_syncs() -> int:
|
||||
"""启动时把 status='running' 的卡死任务标记为 failed + needs_resync=1。"""
|
||||
return db_ops.recover_interrupted_dataset_registry()
|
||||
|
||||
|
||||
def get_registry_status() -> list[dict[str, Any]]:
|
||||
return db_ops.fetch_dataset_registry_rows()
|
||||
@@ -0,0 +1,4 @@
|
||||
"""通用工具。"""
|
||||
from app.core.utils.logging import get_logger, setup_logging
|
||||
|
||||
__all__ = ["get_logger", "setup_logging"]
|
||||
@@ -0,0 +1,60 @@
|
||||
"""统一日志:同时输出到控制台 + 文件。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from pathlib import Path
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
_INITIALIZED = False
|
||||
|
||||
|
||||
def _build_formatter() -> logging.Formatter:
|
||||
return logging.Formatter(
|
||||
"[%(asctime)s] %(levelname)-5s %(name)s | %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
|
||||
|
||||
def setup_logging(level: str | None = None) -> None:
|
||||
global _INITIALIZED
|
||||
if _INITIALIZED:
|
||||
return
|
||||
|
||||
lvl = (level or settings.log_level or "INFO").upper()
|
||||
root = logging.getLogger()
|
||||
root.setLevel(lvl)
|
||||
# 清掉 uvicorn / sqlalchemy 默认 handler,避免重复输出
|
||||
for h in list(root.handlers):
|
||||
root.removeHandler(h)
|
||||
|
||||
fmt = _build_formatter()
|
||||
|
||||
console = logging.StreamHandler(sys.stdout)
|
||||
console.setFormatter(fmt)
|
||||
root.addHandler(console)
|
||||
|
||||
log_path: Path = settings.log_path
|
||||
file_handler = RotatingFileHandler(
|
||||
log_path / "market_data_sync.log",
|
||||
maxBytes=20 * 1024 * 1024,
|
||||
backupCount=5,
|
||||
encoding="utf-8",
|
||||
)
|
||||
file_handler.setFormatter(fmt)
|
||||
root.addHandler(file_handler)
|
||||
|
||||
# 抑制第三方库过度刷屏
|
||||
logging.getLogger("baostock").setLevel(logging.WARNING)
|
||||
logging.getLogger("urllib3").setLevel(logging.WARNING)
|
||||
logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)
|
||||
|
||||
_INITIALIZED = True
|
||||
|
||||
|
||||
def get_logger(name: str) -> logging.Logger:
|
||||
if not _INITIALIZED:
|
||||
setup_logging()
|
||||
return logging.getLogger(name)
|
||||
Reference in New Issue
Block a user