缺陷(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
92 lines
3.1 KiB
Python
92 lines
3.1 KiB
Python
#!/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())
|