问题(用户报告) ---------------- `stk_holdertrade`(股东增减持)与 `repurchase`(股票回购)双双停在 `20260618`,而行情已到 `20260917` —— **停摆约 65 个交易日且无任何告警**。 根因 ---- 两张表在 `cli.py` 的 `_TYPE_TABLES` 任何分组里都不存在,也不在任何 `--tables` 列表里 —— **从未被任何调度任务调用过**。 `config.yaml` 里的 `tables:` 启用开关不救场:该开关只在 「未显式指定要抓哪些表」时才生效(`fetcher.py:239-243`), 而两个 launchd 调度脚本总是显式传 `--type` / `--tables`,开关被完全绕过。 于是「配置里写着 true」与「实际在不在跑」是两件事,且没有任何对账机制。 改动 ---- 1. `scripts/tushare_sync.sh` 补排期 - daily:新增 `stk_holdertrade,repurchase` - monthly:新增 `fund_div,fund_manager,fund_share,fund_nav` 2. 新增 `Scripts/bin/audit_schedule_coverage.py` 把三份来源交叉对账:TABLE_MAP 定义 / 调度实际覆盖 / config 声明启用。 输出「启用但无任何排期」的真缺陷清单与「孤儿且未声明」的参考清单。 —— 这类缺陷的特征就是**静默**,必须有机制主动对账。 补数结果(实测) ---------------- stk_holdertrade 51,030 → 52,937 行,最新 20260618 → 20260918 repurchase 43,584 → 45,901 行,最新 20260618 → 20260918 最近交易日分布正常(20260918 新增 11 条、20260917 新增 29 条) 审计复核 -------- 调度覆盖 55 → 61 张;「启用但无排期」真缺陷 12 → 7 张 仍未处理(本次已查明,需单独决策) ---------------------------------- 剩余 7 张「启用但无排期」中,6 张**表根本不存在**,从未建过: hk_hold / shibor / stk_managers / stk_rewards / ths_daily / us_daily (需先建表 DDL 再首次全量加载,属独立工作量) 另有 `stk_factor`:接口限 5 次/天,**无法批量抓取**, `fetcher.py` 已显式跳过并打 WARNING —— 应从 config 里改为停用,避免长期假"启用"。 另有 44 张「孤儿且 config 未声明」的历史表,需人工确认是否应纳入采集。
119 lines
4.7 KiB
Python
119 lines
4.7 KiB
Python
#!/usr/bin/env python3
|
||
"""audit_schedule_coverage.py — 排查「定义了但没有任何调度任务采集」的孤儿表
|
||
|
||
背景(2026-09-19)
|
||
------------------
|
||
用户报告 `stk_holdertrade` / `repurchase` 停摆约 65 个交易日。
|
||
排查结论:这两张表在 `_TYPE_TABLES` 的任何分组里都不存在,
|
||
也不在任何 `--tables` 列表里 —— **从未被任何调度任务调用过**。
|
||
|
||
`config.yaml` 里的 `tables:` 启用开关不救场:它只在
|
||
「未显式指定要抓哪些表」时才生效(`fetcher.py:239-243`),
|
||
而两个 launchd 调度脚本总是显式传 `--type` / `--tables`,开关被完全绕过。
|
||
于是「配置里写着 true」与「实际有没有在跑」是两件事,且没有任何机制对账。
|
||
|
||
本脚本就是那个对账机制:把
|
||
① TABLE_MAP 定义的表
|
||
② 调度脚本 + CLI 分组实际覆盖的表
|
||
③ config.yaml 声明启用的表
|
||
三者交叉比对,输出:
|
||
· 启用但无排期(真缺陷,会导致采集静默停摆)
|
||
· 定义的但 config 未声明(多半是历史遗留,需人工确认是否该采集)
|
||
|
||
用法
|
||
----
|
||
python3 Scripts/bin/audit_schedule_coverage.py
|
||
python3 Scripts/bin/audit_schedule_coverage.py --root /Applications/FinDataHub.app/Contents/Resources/tushare_fetcher
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import re
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
|
||
def parse_table_map_keys(fetcher_py: Path) -> set[str]:
|
||
src = fetcher_py.read_text(encoding="utf-8")
|
||
start = src.index("TABLE_MAP")
|
||
# TABLE_MAP 内的键都缩进 8 空格
|
||
return set(re.findall(r'^\s{8}"([a-z0-9_]+)":\s*\(', src[start:], re.M))
|
||
|
||
|
||
def parse_cli_groups(cli_py: Path) -> set[str]:
|
||
src = cli_py.read_text(encoding="utf-8")
|
||
covered: set[str] = set()
|
||
for _name, items in re.findall(r'"(\w+)":\s*\[([^\]]*)\]', src, re.S):
|
||
covered |= {x.strip().strip('"') for x in items.split(",") if x.strip()}
|
||
return covered
|
||
|
||
|
||
def parse_sync_tables(sync_sh: Path) -> set[str]:
|
||
if not sync_sh.exists():
|
||
return set()
|
||
src = sync_sh.read_text(encoding="utf-8")
|
||
covered: set[str] = set()
|
||
for m in re.findall(r"--tables\s+([A-Za-z0-9_,]+)", src):
|
||
covered |= {x.strip() for x in m.split(",") if x.strip()}
|
||
return covered
|
||
|
||
|
||
def parse_config_enabled(cfg_yaml: Path) -> tuple[set[str], set[str]]:
|
||
if not cfg_yaml.exists():
|
||
return set(), set()
|
||
src = cfg_yaml.read_text(encoding="utf-8")
|
||
if "\ntables:" not in src:
|
||
return set(), set()
|
||
sec = src[src.index("\ntables:"):]
|
||
enabled, disabled = set(), set()
|
||
for name, val in re.findall(r'^\s{2}([a-z0-9_]+):\s*(true|false)\s*$', sec, re.M):
|
||
(enabled if val == "true" else disabled).add(name)
|
||
return enabled, disabled
|
||
|
||
|
||
def main() -> int:
|
||
ap = argparse.ArgumentParser()
|
||
ap.add_argument("--root", default="/Applications/FinDataHub.app/Contents/Resources/tushare_fetcher",
|
||
help="tushare_fetcher 根目录(默认取部署位)")
|
||
args = ap.parse_args()
|
||
root = Path(args.root)
|
||
if not (root / "src/tushare_fetcher/core/fetcher.py").exists():
|
||
print(f"✗ 未找到 {root}/src/tushare_fetcher/core/fetcher.py")
|
||
return 2
|
||
|
||
keys = parse_table_map_keys(root / "src/tushare_fetcher/core/fetcher.py")
|
||
covered = parse_cli_groups(root / "src/tushare_fetcher/cli.py")
|
||
covered |= parse_sync_tables(root / "scripts/tushare_sync.sh")
|
||
# 分红由独立脚本按报告期刷新,不在 --tables 里
|
||
covered.add("dividend")
|
||
enabled, disabled = parse_config_enabled(root / "config.yaml")
|
||
|
||
orphan = keys - covered
|
||
defect = sorted(orphan & enabled)
|
||
undeclared = sorted(orphan - enabled)
|
||
|
||
print(f"TABLE_MAP 定义的表 : {len(keys)}")
|
||
print(f"其中被调度任务覆盖 : {len(keys & covered)}")
|
||
print(f"config.yaml 启用 / 停用 : {len(enabled)} / {len(disabled)}")
|
||
print()
|
||
|
||
print(f"★ 真缺陷 — config 启用但无任何排期 : {len(defect)} 张")
|
||
if defect:
|
||
print(" 这些表声明为启用,却不在任何 --type / --tables 里 → 采集静默停摆。")
|
||
for i in range(0, len(defect), 4):
|
||
print(" " + " ".join(f"{t:<22}" for t in defect[i:i + 4]))
|
||
print()
|
||
|
||
print(f"(参考)孤儿且 config 未声明 : {len(undeclared)} 张")
|
||
print(" 多半是历史遗留或从未启用的表;需人工确认是否应纳入采集。")
|
||
for i in range(0, len(undeclared), 4):
|
||
print(" " + " ".join(f"{t:<22}" for t in undeclared[i:i + 4]))
|
||
|
||
print()
|
||
print("提示:`tables:` 开关只在未显式指定表时生效,因此**不要以 config 为准判断某项是否在跑**。")
|
||
return 1 if defect else 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|