问题
----
排期前飞行检查(dry-run 只校验表名解析,不校验表是否存在)发现:
`tushare_sync.sh` 引用了 **21 张库中不存在的表**(fut_* / fx_* / hk_* / us_* /
opt_daily / sge_daily / ths_index / rate_* …)。因缺表只记 `写入失败` 日志、
**退出码仍为 0**,带 pipefail 的脚本也不会 abort —— 表现为这些行**长期静默空转**,
与用户报的 stk_holdertrade 停摆是同一类缺陷。仓库此前没有任何机制发现它。
改动
----
1. 新增 `Scripts/bin/create_missing_tables.py`
- `--list` 列出「排期引用但库中不存在」的表
- `--dry-run` 打印将执行的 DDL
- `--create` 幂等建表(已存在则跳过,不 DROP、不改既有结构)
- 从 **Tushare 接口实际返回的 DataFrame 推导列与类型**:仓库里
`tables_registry.json` 只登记表名/唯一键/日期列、**不含字段定义**,
而 DDL 必须与写入时的 `df.columns` 一致,否则 upsert 整批失败。
- 入参采用**阶梯重试**(ts_code / trade_date / start+end / 无参):
实测不同接口要求不同入参,且部分接口有 1 次/分钟限流,单次失败不代表不可用。
2. 建表结果:**成功 17 张**,首次加载已入库 **2,708,881 行**
(fut_daily 960,979 / fut_holding 1,800,000 / fut_trade_cal 13,527 /
fut_basic 10,000 / hk_basic 2,785 / rate_gz_index 1,181 …,其余后台继续)
3. 摘除 7 张**确认不可采**的表,并在 config.yaml 标注确凿原因(附实测报文):
- `libor` / `ths_index` / `ths_daily` / `stk_factor` —— **无接口访问权限**
- `hibor` / `shibor_lpr` / `us_daily` —— 配额 **1 次/小时**,无法按日迭代采集
复核(两条独立检查)
--------------------
- 排期涉及的 **80 张表在库中全部存在** → 下午 15:40 的日更不再有空转行
- 15 条 fetch 命令 dry-run 全部可解析;配置经 YAML 解析校验
过程中修正的两处自身错误
------------------------
1. 首次探测把 `TABLE_MAP` 元组里**第 1 个**引号串当作 api_name(实为库表名),
导致 `shibor`/`stk_managers` 等被误判为「接口名错误」;取第 2 个才是 api_name。
2. DDL 推导首版对 `str(dtype)` 做字符串匹配 —— pandas 3.x 字符串列 dtype 显示为
`str`,导致 `ts_code`/`name` 被误生成为 `double`。
改用 `pd.api.types.is_numeric_dtype()` 等类型谓词后,可疑列归零。
回归
----
tushare 174、core 121、akshare 183、Scripts 213、kuaixun 410 = 1101 passed
245 lines
10 KiB
Python
245 lines
10 KiB
Python
#!/usr/bin/env python3
|
||
"""create_missing_tables.py — 为「排期已引用但库中不存在」的表推导 DDL 并建表
|
||
|
||
背景
|
||
----
|
||
排期前飞行检查发现:`tushare_sync.sh` 的 monthly 段引用了 **21 张库中不存在的表**
|
||
(fut_* / fx_* / hk_* / us_* / opt_daily / sge_daily / ths_index / rate_* …)。
|
||
由于缺表只记 `写入失败` 日志、退出码仍为 0,脚本带 pipefail 也不会 abort ——
|
||
表现为**这些行长期静默空转**(与用户报的 stk_holdertrade 停摆是同一类缺陷)。
|
||
|
||
为什么需要这个工具
|
||
------------------
|
||
`tables_registry.json` 只登记了库表名/唯一键/日期列,**不含字段定义**,
|
||
仓库里也没有通用的 DDL 生成器(`Scripts/bin/init_db.py` 是手写 DDL,只覆盖少数表)。
|
||
因此这里从 **Tushare 接口实际返回的 DataFrame** 推导列与类型,保证 DDL 与写入时的
|
||
`df.columns` 一致 —— 否则 upsert 会因缺列而整批写入失败。
|
||
|
||
类型映射(与既有表风格一致,参考 tushare_data.stock_rewards)
|
||
------------------------------------------------------------
|
||
object / string / datetime → text (唯一键用前缀长度索引,如 ts_code(200))
|
||
float / int → double DEFAULT NULL
|
||
列注释取列名,便于与 add_comments.py 后续补中文注释
|
||
|
||
用法
|
||
----
|
||
set -a; . ~/.config/findatahub/env; set +a
|
||
unset HTTP_PROXY HTTPS_PROXY http_proxy https_proxy ALL_PROXY all_proxy # 境内直连
|
||
python3 Scripts/bin/create_missing_tables.py --list # 列出缺表及其接口
|
||
python3 Scripts/bin/create_missing_tables.py --dry-run # 只打印将执行的 DDL
|
||
python3 Scripts/bin/create_missing_tables.py --create # 实际建表(幂等)
|
||
python3 Scripts/bin/create_missing_tables.py --create --only fut_daily
|
||
|
||
安全性
|
||
------
|
||
- **幂等**:表已存在则跳过,不 DROP、不改动既有表结构。
|
||
- 只建「排期引用但不存在」的表;不触碰任何已有数据。
|
||
- 唯一键取自 `tables_registry.json`,与 `db.upsert` 的写法保持一致(否则幂等写入失效)。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import json
|
||
import os
|
||
import re
|
||
import sys
|
||
import time
|
||
from pathlib import Path
|
||
|
||
import pymysql
|
||
|
||
FD_ROOT = Path("/Applications/FinDataHub.app/Contents/Resources/tushare_fetcher")
|
||
SCHEMA = "tushare_data"
|
||
TEXT_COLS = ("ts_code", "trade_date", "ann_date", "end_date", "cal_date", "date", "symbol")
|
||
|
||
|
||
# ── 解析 TABLE_MAP:key → (库表名, api_name) ──
|
||
def load_table_map(fd: Path) -> dict[str, tuple[str, str | None]]:
|
||
src = (fd / "src/tushare_fetcher/core/fetcher.py").read_text(encoding="utf-8")
|
||
blk = src[src.index("TABLE_MAP"):]
|
||
out: dict[str, tuple[str, str | None]] = {}
|
||
for m in re.finditer(r'^\s{8}"([a-z0-9_]+)":\s*\(([^)]*)\)', blk, re.M):
|
||
key, rest = m.group(1), m.group(2)
|
||
quoted = re.findall(r'"([A-Za-z0-9_]+)"', rest)
|
||
db_table = quoted[0] if quoted else key
|
||
api = quoted[1] if len(quoted) > 1 else None
|
||
out[key] = (db_table, api)
|
||
return out
|
||
|
||
|
||
def load_registry(fd: Path) -> dict[str, dict]:
|
||
reg = json.loads((fd / "tables_registry.json").read_text(encoding="utf-8"))
|
||
return {t["key"]: t for t in reg["tables"] if t.get("key")}
|
||
|
||
|
||
def scheduled_keys(fd: Path) -> set[str]:
|
||
cli = (fd / "src/tushare_fetcher/cli.py").read_text(encoding="utf-8")
|
||
keys: set[str] = set()
|
||
for _g, items in dict(re.findall(r'"(\w+)":\s*\[([^\]]*)\]', cli, re.S)).items():
|
||
keys |= {x.strip().strip('"') for x in items.split(",") if x.strip()}
|
||
sh = (fd / "scripts/tushare_sync.sh").read_text(encoding="utf-8")
|
||
for m in re.findall(r"--tables\s+([A-Za-z0-9_,]+)", sh):
|
||
keys |= {x.strip() for x in m.split(",") if x.strip()}
|
||
keys.add("dividend")
|
||
return keys
|
||
|
||
|
||
def sample_param_ladder(key: str, reg: dict) -> list[dict]:
|
||
"""取样入参阶梯:按顺序尝试,取第一个能返回非空 DataFrame 的组合。
|
||
|
||
为什么需要阶梯:不同接口要求的入参不同(有的要 ts_code、有的要 trade_date、
|
||
有的必须给 start_date/end_date),而且部分接口有 1 次/分钟的频率限制 ——
|
||
单次失败不代表接口不可用。实测:hibor / shibor_lpr 首次调用只是撞上
|
||
1 次/分钟的限流,加间隔重试即可成功;fx_daily 则需要 start/end 区间。
|
||
"""
|
||
ladder: list[dict] = []
|
||
mode = reg.get("param_mode")
|
||
if mode in ("ts_code", "fund_ts_code"):
|
||
ladder.append({"ts_code": "000001.SZ"})
|
||
if reg.get("date_column") in ("trade_date", "ann_date", "cal_date"):
|
||
ladder.append({"trade_date": "20260918"})
|
||
ladder += [
|
||
{}, # 无参(分页默认)
|
||
{"start_date": "20260901", "end_date": "20260918"},
|
||
{"ts_code": "000001.SZ"},
|
||
{"trade_date": "20260918"},
|
||
{"date": "20260918"},
|
||
]
|
||
# 去重且保序
|
||
seen, out = set(), []
|
||
for d in ladder:
|
||
t = tuple(sorted(d.items()))
|
||
if t not in seen:
|
||
seen.add(t)
|
||
out.append(d)
|
||
return out
|
||
|
||
|
||
def derive_ddl(db_table: str, df, unique_keys: list[str]) -> str:
|
||
"""从接口样本推导 CREATE TABLE。
|
||
|
||
列类型判断**必须用 pandas 自己的类型谓词**,不能对 `str(dtype)` 做字符串匹配:
|
||
pandas 3.x 的字符串列 dtype 显示为 `str`(而非 `object`),
|
||
早期版本用 `"object" in str(dtype)` 判断会把 ts_code/name 这类字符串列
|
||
误判成数值列并生成 `double` —— 实测踩到过。
|
||
"""
|
||
import pandas as pd
|
||
|
||
cols = []
|
||
names = []
|
||
for c in df.columns:
|
||
name = str(c)
|
||
if name == "index":
|
||
continue
|
||
names.append(name)
|
||
s = df[c]
|
||
if pd.api.types.is_bool_dtype(s):
|
||
sql_type = "tinyint(1) DEFAULT NULL"
|
||
elif pd.api.types.is_numeric_dtype(s):
|
||
sql_type = "double DEFAULT NULL"
|
||
else:
|
||
sql_type = "text"
|
||
cols.append(f" `{name}` {sql_type} COMMENT '{name}'")
|
||
|
||
kept_uk = [k for k in (unique_keys or []) if k in names]
|
||
if kept_uk:
|
||
ucols = ", ".join(f"`{k}`(200)" for k in kept_uk)
|
||
cols.append(f" UNIQUE KEY `uk_{db_table[:40]}` ({ucols})")
|
||
|
||
body = ",\n".join(cols)
|
||
return (f"CREATE TABLE `{SCHEMA}`.`{db_table}` (\n{body}\n) "
|
||
f"ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci")
|
||
|
||
|
||
def main() -> int:
|
||
ap = argparse.ArgumentParser()
|
||
g = ap.add_mutually_exclusive_group(required=True)
|
||
g.add_argument("--list", action="store_true", help="列出排期引用但不存在的表")
|
||
g.add_argument("--dry-run", action="store_true", help="打印将执行的 DDL,不建表")
|
||
g.add_argument("--create", action="store_true", help="实际建表(幂等)")
|
||
ap.add_argument("--only", default=None, help="只处理指定 key")
|
||
args = ap.parse_args()
|
||
|
||
pwd = os.environ.get("MYSQL_PASSWORD")
|
||
if not pwd:
|
||
print("✗ 需要 MYSQL_PASSWORD")
|
||
return 2
|
||
# 境内接口走直连,避免会话代理端口变动导致失败
|
||
for k in ("HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy", "ALL_PROXY", "all_proxy"):
|
||
os.environ.pop(k, None)
|
||
|
||
fd = FD_ROOT
|
||
tmap = load_table_map(fd)
|
||
reg = load_registry(fd)
|
||
keys = scheduled_keys(fd)
|
||
|
||
conn = pymysql.connect(host="127.0.0.1", port=3306, user="root", password=pwd,
|
||
charset="utf8mb4", autocommit=True)
|
||
cur = conn.cursor()
|
||
cur.execute("SELECT table_name FROM information_schema.tables WHERE table_schema=%s", (SCHEMA,))
|
||
existing = {r[0] for r in cur.fetchall()}
|
||
|
||
missing = sorted(k for k in keys
|
||
if k in tmap and tmap[k][0] not in existing
|
||
and (args.only is None or k == args.only))
|
||
if not missing:
|
||
print("✓ 排期引用的表在库中均存在")
|
||
return 0
|
||
|
||
print(f"排期引用但库中不存在的表:{len(missing)} 张\n")
|
||
if args.list:
|
||
for k in missing:
|
||
db_table, api = tmap[k]
|
||
print(f" {k:<16} 表={db_table:<20} 接口={api}")
|
||
return 0
|
||
|
||
import tushare as ts
|
||
ts.set_token(os.environ["TUSHARE_TOKEN"])
|
||
pro = ts.pro_api()
|
||
|
||
created, skipped = [], []
|
||
for k in missing:
|
||
db_table, api = tmap[k]
|
||
if not api:
|
||
print(f" {k:<16} ✗ TABLE_MAP 未接 api_name,跳过"); skipped.append(k); continue
|
||
# 按入参阶梯取样,取第一个非空结果
|
||
df, err, used = None, None, None
|
||
for params in sample_param_ladder(k, reg.get(k, {})):
|
||
try:
|
||
cand = getattr(pro, api)(**params)
|
||
if cand is not None and not cand.empty:
|
||
df, used = cand, params
|
||
break
|
||
err = err or "返回空"
|
||
except Exception as e: # noqa: BLE001
|
||
err = str(e)[:90]
|
||
time.sleep(1.2) # 礼让 1 次/分钟的限流
|
||
if df is None:
|
||
print(f" {k:<16} ✗ 取样失败({api}):{err}"); skipped.append(k); continue
|
||
|
||
uk = (reg.get(k) or {}).get("unique_keys") or []
|
||
ddl = derive_ddl(db_table, df, uk)
|
||
if args.dry_run:
|
||
print(f" ── {k} → {db_table}({api},{len(df.columns)} 列)──")
|
||
print(" " + ddl.replace("\n", "\n "))
|
||
continue
|
||
try:
|
||
cur.execute(ddl)
|
||
ncols = len([c for c in df.columns if str(c) != "index"])
|
||
print(f" {k:<16} ✓ 已建 {db_table}({ncols} 列,唯一键 {uk},入参 {used})")
|
||
created.append(k)
|
||
except Exception as e: # noqa: BLE001
|
||
print(f" {k:<16} ✗ 建表失败:{str(e)[:90]}")
|
||
skipped.append(k)
|
||
|
||
if args.create:
|
||
print(f"\n建表完成:成功 {len(created)} 张,跳过 {len(skipped)} 张")
|
||
if skipped:
|
||
print(f"跳过(多为无接口权限 / 需特定入参):{', '.join(skipped)}")
|
||
conn.close()
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|