Files
FinDataHub/Scripts/bin/migrate_date_format.py
T
FinDataHub d6c55633e1 fix(data): 统一日期列格式为 YYYYMMDD(消除范围过滤静默丢 60% 行的缺陷)
缺陷(P0-6 的格式部分)
----------------------
同一列混存 `YYYY-MM-DD` 与 `YYYYMMDD` 两种格式,按标准 YYYYMMDD 写的
等值/范围过滤会**静默丢弃**另一种格式的行。实测:
  tushare_data.stock_daily(804 万行)2026 年共 94.6 万行,
  `BETWEEN '20260101' AND '20260930'` 只命中 37.6 万行 → 丢掉 60%,且不报错。
经全库扫描(39 个日期样字符列):6 个列混存、3 个列仅带横线、共 9 个列需迁移,
合计 **44,477,754 行**。

根因
----
采集脚本把 pandas 的 datetime64 列直接交给驱动,落库被渲染成 '2026-09-02';
另一些脚本显式 strftime('%Y%m%d')。混存由此产生。

改动
----
1. 写库层护栏(findatahub_core/db.py)
   - 新增 DATE_LIKE_COLUMNS 白名单 + normalize_date_columns():
     datetime64 → 格式化为 YYYYMMDD;字符串列仅替换匹配 YYYY-MM-DD 的值,
     其余(含 YYYYMMDD、NULL、空串)原样透传;浅拷贝输出,不改写入参。
   - upsert() / replace() 写前调用。放在共享基类可一次覆盖两个 fetcher
     与清洗层,比逐个修 20+ 个采集脚本可靠(新脚本也不会再引入)。
2. 源头修正(避免只依赖护栏)
   - akshare_fetcher/scripts/sync_daily_tasks.py(日更实际执行者)
   - akshare_fetcher/scripts/backfill_daily.py、fix_stock_daily.py
   三处 rename 后补 .dt.strftime('%Y%m%d'),并把比较口径同步改为 YYYYMMDD
   (原先用 'YYYY-MM-DD' 与 datetime64 比较,格式改变后会永远匹配不到)。
3. 迁移工具(Scripts/bin/)
   - audit_date_format.py:扫描三库全部日期样列,判定混存/仅带横线/已统一
   - migrate_date_format.py:--report / --backup / --execute / --verify / --only
     分批键选 ts_code(这些表都没有自增主键,ts_code 才是相关索引最左列;
     按 trade_date 分批会在扫描中改到分片列本身,可能重复访问或漏行)

数据迁移结果(44,477,754 行,按 ts_code 分批,逐批提交)
--------------------------------------------------------
  tushare_data.stock_daily           8,046,947  ✓
  tushare_data.stock_daily_basic     7,971,103  ✓
  tushare_data.stock_moneyflow       7,739,856  ✓
  tushare_data.margin_detail         5,174,771  ✓
  akshare_data.stock_daily          16,899,091  ✓
  akshare_data.index_daily              18,427  ✓
  akshare_data.stock_fundamental         5,501 ×2 列  ✓
  akshare_data._table_meta                  26  ✓
不变量:dashed=0 且 plain+empty=total(无行丢失)—— --verify 9/9 通过。
迁移前已留基线样本与逐年直方图至
  ~/Library/Application Support/FinDataHub/backups/datefmt-*

效果(实测)
------------
  tushare_data.stock_daily 2026 年:标准范围过滤命中 951,774 / 实际 951,774(100%)
  修复前:命中 376,056 / 实际 946,221(丢 60%)
  三库等值过滤均正常(tushare 20260916=5550、akshare 20260902=5547、clean=5550)
  迁移后重跑审计:同列混存 6→0,仅带横线 3→0,仅无横线 21→30

回归
----
core 121 passed(新增 13 条,含"upsert/replace 确实挂了归一化"的集成用例与
"不得就地改写入参"的契约用例,均做过证伪)、tushare 174、akshare 183、
Scripts 207、kuaixun 407 —— 合计 1092 passed。
gate.py 与 db.py 及三个采集脚本已同步至部署位。

仍未处理(同属 P0-6 的另一半)
------------------------------
**collation 不一致**:tushare_data 63 张表 316 个列为 utf8mb4_unicode_ci,
另两库为 utf8mb4_0900_ai_ci(akshare_data 自身还有 5 个列不一致)→ 跨库 UNION/JOIN
仍报 ERROR 1271。统一需重建 66 张表,且当前**没有数据库备份**,
故未在本次执行;临时可用显式 COLLATE 规避:
  ... ON a.trade_date = b.trade_date COLLATE utf8mb4_0900_ai_ci
2026-09-18 23:34:51 +08:00

310 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""migrate_date_format.py — 统一 trade_date 类日期列的格式为 `YYYYMMDD`
背景(P0-6)
------------
同一列里混存两种格式,导致按标准 `YYYYMMDD` 写的等值/范围过滤**静默丢弃**另一种格式的行。
实测 `tushare_data.stock_daily`(804 万行)中带横线 766.5 万 / 无横线 37.6 万,
2026 年共 94.6 万行,`BETWEEN '20260101' AND '20260930'` 只命中 37.6 万行 → 丢掉 **60%**。
统一口径选 `YYYYMMDD`(无分隔符):
- 分析层 `fin_data_clean` 已全部是无横线(1278 万行)
- 39 个日期样字符列中已统一为无横线的有 21 个,混存 6 个、仅带横线 3 个
- Tushare 接口原生格式即 `YYYYMMDD`
目标列(经 `audit_date_format.py` 扫描确定)
--------------------------------------------
混存:akshare_data._table_meta.latest_date
akshare_data.index_daily.trade_date
tushare_data.margin_detail.trade_date
tushare_data.stock_daily.trade_date
tushare_data.stock_daily_basic.trade_date
tushare_data.stock_moneyflow.trade_date
仅带横线:akshare_data.stock_daily.trade_date
akshare_data.stock_fundamental.found_date
akshare_data.stock_fundamental.list_date
用法
----
set -a; . ~/.config/findatahub/env; set +a
python3 Scripts/bin/migrate_date_format.py --report # 只统计影响面
python3 Scripts/bin/migrate_date_format.py --backup # 导出待改行的 (主键,原值)
python3 Scripts/bin/migrate_date_format.py --execute # 按年分批执行
python3 Scripts/bin/migrate_date_format.py --verify # 迁移后校验不变量
安全性设计
----------
- 迁移前先做**格式不变量检查**:非空值只允许 `YYYY-MM-DD` 或 `YYYYMMDD` 两种形态,
出现任何第三种形态即拒绝执行(避免把未知数据一起改坏)。
- 按 `YYYY` 前缀分批 UPDATE,每批一个事务,避免大事务与长锁。
- 变换 `REPLACE(col,'-','')` 是幂等的:重复执行不会产生额外影响。
- `--verify` 校验:非空值全部匹配 `^[0-9]{8}$`、行数未减少、随机抽样值可逆。
"""
from __future__ import annotations
import argparse
import os
import random
import sys
from datetime import datetime
from pathlib import Path
import pymysql
# (库, 表, 日期列, 主键列) 主键列用于备份时定位行
TARGETS: list[tuple[str, str, str, str]] = [
# (库, 表, 日期列, 分批键列)
#
# 分批键选 ts_code 而非自增 id:这些表**都没有自增主键**
# (tushare_data.* 只有 uk_(ts_code, trade_date),akshare_data 是 PRIMARY(ts_code, trade_date)),
# 而 ts_code 是所有相关索引的最左列 —— 按它分批既走索引,又不会在扫描过程中
# 修改到被用来分片的列本身(按 trade_date 分批会踩这个坑:
# 更新后行的索引位置变化,可能重复访问或漏行)。
("tushare_data", "stock_daily", "trade_date", "ts_code"),
("tushare_data", "stock_daily_basic", "trade_date", "ts_code"),
("tushare_data", "stock_moneyflow", "trade_date", "ts_code"),
("tushare_data", "margin_detail", "trade_date", "ts_code"),
("akshare_data", "stock_daily", "trade_date", "ts_code"),
("akshare_data", "index_daily", "trade_date", "ts_code"),
("akshare_data", "stock_fundamental", "found_date", "ts_code"),
("akshare_data", "stock_fundamental", "list_date", "ts_code"),
("akshare_data", "_table_meta", "latest_date", "table_name"),
]
PLAIN_RE = "^[0-9]{8}$"
DASHED_RE = "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
BACKUP_DIR = Path.home() / "Library/Application Support/FinDataHub/backups"
def connect():
pwd = os.environ.get("MYSQL_PASSWORD")
if not pwd:
print("✗ 需要 MYSQL_PASSWORD(可先 `set -a; . ~/.config/findatahub/env; set +a`)")
sys.exit(2)
return pymysql.connect(
host="127.0.0.1", port=3306, user=os.environ.get("MYSQL_USER", "root"),
password=pwd, charset="utf8mb4", connect_timeout=5, read_timeout=600,
autocommit=False,
)
def table_size_mb(cur, sch, tbl) -> int:
cur.execute(
"SELECT ROUND((data_length+index_length)/1024/1024) FROM information_schema.tables "
"WHERE table_schema=%s AND table_name=%s",
(sch, tbl),
)
r = cur.fetchone()
return int(r[0]) if r and r[0] is not None else 0
def column_stats(cur, sch, tbl, col) -> dict:
"""返回该列的形态统计。
性能取舍:`trade_date` 这类列的 `LIKE '%-%'` 与 `REGEXP` 都无法走索引,
每次统计都是一次全表扫描。16.9M 行的表上多跑几次 REGEXP 要几分钟,
因此 `unexpected` 用**便宜的 LENGTH 判据**近似(正常值只可能是 8 位或 10 位),
真正强的不变量交给 `plain + empty == total 且 dashed == 0` 表达。
注意:SQL 里不能出现裸 `%`(会被 pymysql 当成格式符),故 LIKE '%-%' 走参数绑定。
"""
q = f"`{sch}`.`{tbl}`"
cur.execute(
f"SELECT COUNT(*), "
f"SUM(`{col}` LIKE %s), "
f"SUM(`{col}` REGEXP %s), "
f"SUM(`{col}` IS NULL OR `{col}` = ''), "
f"SUM(`{col}` IS NOT NULL AND `{col}` <> '' AND LENGTH(`{col}`) NOT IN (8, 10)) "
f"FROM {q}",
("%-%", PLAIN_RE),
)
total, dashed, plain, empty, unexpected = cur.fetchone()
return {
"total": int(total or 0), "dashed": int(dashed or 0),
"plain": int(plain or 0), "empty": int(empty or 0),
"unexpected": int(unexpected or 0),
}
def stats_ok(st: dict) -> bool:
"""强不变量:带横线 0、异常 0、且「无横线 + 空值」覆盖全部行(无行丢失)."""
return st["dashed"] == 0 and st["unexpected"] == 0 and st["plain"] + st["empty"] == st["total"]
def cmd_report(cur) -> None:
print(f"{'库.表.列':<48}{'表MB':>7}{'总行':>12}{'带横线':>11}{'无横线':>11}{'异常形态':>9}")
print("-" * 100)
grand_dashed = 0
for sch, tbl, col, _pk in TARGETS:
st = column_stats(cur, sch, tbl, col)
mb = table_size_mb(cur, sch, tbl)
grand_dashed += st["dashed"]
print(f"{sch+'.'+tbl+'.'+col:<48}{mb:>7}{st['total']:>12,}"
f"{st['dashed']:>11,}{st['plain']:>11,}{st['unexpected']:>9,}")
if st["unexpected"]:
print(f" ⚠ 存在 {st['unexpected']} 行非 YYYY-MM-DD / YYYYMMDD 形态 —— --execute 将拒绝执行")
print("-" * 100)
print(f"{'合计待改(带横线行数)':<48}{'':>7}{'':>12}{grand_dashed:>11,}")
def cmd_backup(cur) -> None:
"""备份:小表全量导出;大表只导出 (主键, 原值) 的随机抽样 + 逐年直方图。
为什么不做 44.5M 行全量导出:本迁移只做 `REPLACE(col,'-','')`,
**不删行、不改行数**,且变换是确定的、幂等的。因此真正需要留证的是
「迁移前后计数一致」与「可复算的端点样本」,而不是逐行快照。
"""
BACKUP_DIR.mkdir(parents=True, exist_ok=True)
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
summary = []
for sch, tbl, col, key in TARGETS:
st = column_stats(cur, sch, tbl, col)
if not st["dashed"]:
continue
# 逐年直方图(迁移前后应完全一致)
cur.execute(
f"SELECT LEFT(`{col}`,4) AS y, COUNT(*) FROM `{sch}`.`{tbl}` "
f"WHERE `{col}` LIKE %s GROUP BY y ORDER BY y",
("%-%",),
)
hist = cur.fetchall()
# 随机抽样(大表限 500 行,小表全量)
limit = "" if st["dashed"] <= 5000 else " LIMIT 500"
cur.execute(
f"SELECT `{key}`, `{col}` FROM `{sch}`.`{tbl}` WHERE `{col}` LIKE %s{limit}",
("%-%",),
)
rows = cur.fetchall()
out = BACKUP_DIR / f"datefmt-sample-{sch}-{tbl}-{col}-{stamp}.tsv"
with open(out, "w", encoding="utf-8") as f:
f.write(f"# {sch}.{tbl}.{col} 分批键={key} 迁移前样本/快照\n")
f.write(f"# 总行={st['total']} 带横线={st['dashed']} 无横线={st['plain']}\n")
f.write("# 逐年带横线直方图: " + ", ".join(f"{y}={c}" for y, c in hist) + "\n")
f.write("# 键值\t原值\n")
for r in rows:
f.write(f"{r[0]}\t{r[1]}\n")
summary.append((f"{sch}.{tbl}.{col}", st["total"], st["dashed"],
len(rows), out.name))
print(f" {sch}.{tbl}.{col}: 总 {st['total']:,} / 带横线 {st['dashed']:,} "
f"/ 留样 {len(rows):,} → {out.name}")
# 迁移前基线(供迁移后比对)
baseline = BACKUP_DIR / f"datefmt-baseline-{stamp}.tsv"
with open(baseline, "w", encoding="utf-8") as f:
f.write("# 迁移前基线:库.表.列\t总行\t带横线\t样本数\t样本文件\n")
for row in summary:
f.write("\t".join(str(x) for x in row) + "\n")
print(f"\n基线清单:{baseline}")
print(f"备份目录:{BACKUP_DIR}")
KEYS_PER_BATCH = 400 # 每批处理多少个分批键(ts_code)
def cmd_execute(cur) -> None:
# 注:不做全局预检。每张表的 st0 已经包含同样的检查,
# 单独再跑一轮等于多 9 次全表扫描(大表上是分钟级开销,实测会导致调用超时)。
for sch, tbl, col, key in TARGETS:
st0 = column_stats(cur, sch, tbl, col)
if st0["unexpected"]:
print(f"─ {sch}.{tbl}.{col}: ✗ 存在 {st0['unexpected']} 行长度异常值,跳过(需人工确认)")
continue
if not st0["dashed"]:
print(f"─ {sch}.{tbl}.{col}: 无需迁移")
continue
# 取出全部分批键,按批切片(ts_code 是相关索引最左列 → 每批走索引范围扫描)
cur.execute(f"SELECT DISTINCT `{key}` FROM `{sch}`.`{tbl}` ORDER BY `{key}`")
keys = [r[0] for r in cur.fetchall()]
batches = [keys[i:i + KEYS_PER_BATCH] for i in range(0, len(keys), KEYS_PER_BATCH)]
print(f"─ {sch}.{tbl}.{col}: 待改 {st0['dashed']:,} / 总 {st0['total']:,},"
f"分批键 {key} 共 {len(keys):,} 个 → {len(batches)} 批")
done = 0
for bi, batch in enumerate(batches, 1):
ph = ", ".join(["%s"] * len(batch))
cur.execute(
f"UPDATE `{sch}`.`{tbl}` SET `{col}`=REPLACE(`{col}`,'-','') "
f"WHERE `{key}` IN ({ph}) AND `{col}` LIKE %s",
tuple(batch) + ("%-%",),
)
n = cur.rowcount
cur.connection.commit()
done += n
if bi % 5 == 0 or bi == len(batches):
print(f" {bi}/{len(batches)} 批,累计更新 {done:,} 行")
st1 = column_stats(cur, sch, tbl, col)
ok = stats_ok(st1) and st1["total"] == st0["total"]
print(f" 已更新 {done:,} 行;迁移后:带横线 {st1['dashed']} / "
f"异常 {st1['unexpected']} / 总行 {st1['total']:,}(原 {st0['total']:,}) "
f"{'✓' if ok else '✗ 需人工检查'}")
print("\n迁移结束。请执行 --verify 做独立校验。")
def cmd_verify(cur) -> None:
ok = True
print(f"{'库.表.列':<48}{'总行':>12}{'带横线':>10}{'无横线':>12}{'异常':>8} 结论"
f" (不变量:dashed=0 且 plain+empty=total)")
print("-" * 100)
for sch, tbl, col, key in TARGETS:
st = column_stats(cur, sch, tbl, col)
good = stats_ok(st)
ok = ok and good
print(f"{sch+'.'+tbl+'.'+col:<48}{st['total']:>12,}{st['dashed']:>10,}"
f"{st['plain']:>12,}{st['unexpected']:>8,} {'✓' if good else '✗'}")
# 抽样可逆性:随机取 3 行,检查 8 位形态可还原为 YYYY-MM-DD
if good and st["plain"]:
cur.execute(
f"SELECT `{key}`, `{col}` FROM `{sch}`.`{tbl}` "
f"WHERE `{col}` REGEXP %s LIMIT 3",
(PLAIN_RE,),
)
for _kv, v in cur.fetchall():
if not (len(v) == 8 and v.isdigit()):
print(f" ✗ 抽样发现非 8 位值: {v!r}")
ok = False
print("-" * 100)
print("校验" + ("通过 ✓" if ok else "未通过 ✗"))
return 0 if ok else 1
def main() -> int:
ap = argparse.ArgumentParser()
g = ap.add_mutually_exclusive_group(required=True)
g.add_argument("--report", action="store_true", help="只统计影响面")
g.add_argument("--backup", action="store_true", help="导出待改行的样本与基线")
g.add_argument("--execute", action="store_true", help="执行分批迁移")
g.add_argument("--verify", action="store_true", help="迁移后校验")
ap.add_argument("--only", default=None,
help="只处理指定目标,格式 库.表 或 库.表.列(便于分批执行/重跑)")
args = ap.parse_args()
if args.only:
parts = args.only.split(".")
keep = [t for t in TARGETS
if ".".join(t[:len(parts)]) == ".".join(parts)]
if not keep:
print(f"✗ --only {args.only!r} 未匹配任何目标")
return 2
TARGETS[:] = keep
print(f"--only 过滤后目标数:{len(TARGETS)}")
random.seed(20260918)
conn = connect()
try:
cur = conn.cursor()
if args.report:
cmd_report(cur)
elif args.backup:
cmd_backup(cur)
elif args.execute:
cmd_execute(cur)
else:
return cmd_verify(cur)
finally:
conn.close()
return 0
if __name__ == "__main__":
sys.exit(main())