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
+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()