Files
FinDataHub/Scripts/bin/audit_date_format.py
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

92 lines
3.1 KiB
Python
Raw Permalink 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
"""扫描三个库中所有"日期样"字符列,判定是否混存带横线/无横线两种格式。
用途:为 P0-6(trade_date 同列混存两格式)确定迁移影响面。
只做探测(每条 LIMIT 1),不修改任何数据。
用法:
set -a; . ~/.config/findatahub/env; set +a
python3 Scripts/bin/audit_date_format.py
"""
import os
import sys
from collections import Counter
import pymysql
SCHEMAS = ["tushare_data", "akshare_data", "fin_data_clean"]
def main() -> int:
pwd = os.environ.get("MYSQL_PASSWORD")
if not pwd:
print("✗ 需要 MYSQL_PASSWORD 环境变量")
return 2
conn = 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=300,
)
cur = conn.cursor()
# 用占位符绑定,避免 SQL 里的 % 与格式化冲突
cur.execute(
"SELECT table_schema, table_name, column_name, data_type, collation_name "
"FROM information_schema.columns "
"WHERE table_schema IN (%s, %s, %s) "
"AND data_type IN ('varchar', 'char') "
"AND (column_name LIKE %s OR column_name LIKE %s) "
"ORDER BY table_schema, table_name, column_name",
SCHEMAS + ["%date%", "%_dt%"],
)
cols = cur.fetchall()
print(f"候选日期样字符列:{len(cols)} 个")
mixed, dashed_only, plain_only, skipped = [], [], [], []
for sch, tbl, col, dtype, coll in cols:
try:
cur.execute(f"SELECT 1 FROM `{sch}`.`{tbl}` WHERE `{col}` LIKE '%-%' LIMIT 1")
has_dash = cur.fetchone() is not None
cur.execute(
f"SELECT 1 FROM `{sch}`.`{tbl}` "
f"WHERE `{col}` NOT LIKE '%-%' AND `{col}` <> '' LIMIT 1"
)
has_plain = cur.fetchone() is not None
except Exception as e: # noqa: BLE001
skipped.append((f"{sch}.{tbl}.{col}", str(e)[:70]))
continue
label = f"{sch}.{tbl}.{col} [{coll or '-'} {dtype}]"
if has_dash and has_plain:
mixed.append(label)
elif has_dash:
dashed_only.append(label)
elif has_plain:
plain_only.append(label)
else:
skipped.append((f"{sch}.{tbl}.{col}", "空列"))
def show(title, rows, limit=45):
print(f"\n── {title}({len(rows)})──")
for r in rows[:limit]:
print(f" {r}")
if len(rows) > limit:
print(f" …另 {len(rows) - limit} 项")
show("★ 同列混存两种格式(必须迁移)", mixed)
show("仅带横线(统一口径时也要改)", dashed_only)
show("仅无横线(已统一,无需改)", plain_only, limit=8)
if skipped:
show("跳过/异常", [f"{p} → {e}" for p, e in skipped], limit=8)
print("\n── 涉及列的 collation 分布 ──")
for k, v in Counter(
r.split("[")[1].split()[0] for r in (mixed + dashed_only + plain_only)
).items():
print(f" {k}: {v}")
conn.close()
return 0
if __name__ == "__main__":
sys.exit(main())