chore: 重构前基线 — 9 个 sync task 全部 ok + akshare 移除 + mairui 资金流接入

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