fix(data): data_platform硬化(增量merge/verify+raw_redownload/run_daily_update)+测试

merge_increment/verify_increment 增量staging→验证→合并工具; raw_redownload/run_daily_update/import_vnpy_daily 强化; 补 data_platform 与 index_downloader 测试.
This commit is contained in:
2026-07-15 07:12:46 +08:00
parent 54f9ab4c4f
commit e3b688354f
10 changed files with 1341 additions and 24 deletions
+191
View File
@@ -0,0 +1,191 @@
#!/usr/bin/env python3
"""合并 staging 增量到主库(task: 截断 bug 修复)。
**核心不变量(截断 bug 回归测试核心)**:
合并后 main 的行数 >= 合并前 main 的行数(绝不截断)。
raw_redownload.py 的 save_one() 用"本次拉的几天"覆盖写整年文件 → 整年数据被截
NAS 2026 从 121 行截成 4 行就是这 bug)。本脚本负责按 symbol-year 安全合并:
pd.concat([main, staging]).drop_duplicates(subset=['date'], keep='last')
staging 的新数据/修订值优先(keep='last'),main 已有的历史保留。
用法:
python3 merge_increment.py --staging data_cache/daily_update/raw --main data_cache/raw
python3 merge_increment.py --staging ... --main ... --dry-run
约定:
- staging 与 main 同构:`{root}/{year}/{sh|sz}{code}_daily.parquet`
- staging 文件不修改/不删除(保留供排查),只写 main
"""
from __future__ import annotations
import argparse
import json
import logging
import os
import sys
import pandas as pd
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("merge_inc")
# drop_duplicates 的去重键——日线按 date 唯一
DEDUP_KEY = ["date"]
def symbol_from_filename(fname: str) -> str:
"""`sh600000_daily.parquet` → `600000`(剥 exchange 前缀和 _daily.parquet 后缀)。"""
base = fname
if base.endswith("_daily.parquet"):
base = base[: -len("_daily.parquet")]
if base[:2] in ("sh", "sz", "bj"):
base = base[2:]
return base
def merge_one(staging_file: str, main_file: str, dry_run: bool = False) -> dict:
"""合并一个 staging parquet 到 main。
返回 stats: {symbol, before, after, added, action}。
若 main 不存在 → action="created"(直接搬 staging 过去)。
若 main 存在 → action="merged",按 DEDUP_KEY 去重,staging 值优先(keep='last')。
"""
sym = symbol_from_filename(os.path.basename(staging_file))
staging_df = pd.read_parquet(staging_file)
if not os.path.exists(main_file):
if not dry_run:
os.makedirs(os.path.dirname(main_file), exist_ok=True)
staging_df.to_parquet(main_file, index=False)
return {"symbol": sym, "before": 0, "after": len(staging_df),
"added": len(staging_df), "action": "created"}
main_df = pd.read_parquet(main_file)
before = len(main_df)
# concat → drop_duplicates(keep='last' 让 staging 的新/修订值覆盖 main) → sort
merged = pd.concat([main_df, staging_df], ignore_index=True)
merged = merged.drop_duplicates(subset=DEDUP_KEY, keep="last")
merged = merged.sort_values(by="date").reset_index(drop=True)
after = len(merged)
# 核心不变量:绝不能让 main 变少
if after < before:
raise RuntimeError(
f"INVARIANT VIOLATED: {main_file} {before}{after} (staging={staging_file})"
)
if not dry_run:
merged.to_parquet(main_file, index=False)
return {"symbol": sym, "before": before, "after": after,
"added": after - before, "action": "merged"}
def walk_staging(staging_root: str) -> list[tuple[str, str]]:
"""收集 staging 下所有 `{year}/{sym}_daily.parquet`,返回 [(staging_file, main_file_relpath)]。
main_file_relpath 是相对 staging_root 的路径(如 `2026/sh600000_daily.parquet`),
拼到 main_root 即得 main 文件全路径,保持两边同构。
"""
pairs: list[tuple[str, str]] = []
for year in sorted(os.listdir(staging_root)):
ydir = os.path.join(staging_root, year)
if not os.path.isdir(ydir):
continue
for fname in sorted(os.listdir(ydir)):
if not fname.endswith("_daily.parquet"):
continue
rel = os.path.join(year, fname)
pairs.append((os.path.join(ydir, fname), rel))
return pairs
def run_merge(staging_root: str, main_root: str, dry_run: bool = False) -> dict:
"""合并 staging → main,返回汇总统计。"""
if not os.path.isdir(staging_root):
raise FileNotFoundError(f"staging dir 不存在: {staging_root}")
pairs = walk_staging(staging_root)
if not pairs:
log.warning("staging 无 parquet: %s", staging_root)
return {"merged": 0, "created": 0, "skipped": 0, "total_new_rows": 0,
"details": [], "dry_run": dry_run, "ok": True,
"invariant_violations": []}
os.makedirs(main_root, exist_ok=True)
details: list[dict] = []
merged_n = created_n = skipped_n = total_new = 0
invariant_violations: list[str] = []
for i, (staging_file, rel) in enumerate(pairs, 1):
main_file = os.path.join(main_root, rel)
try:
stat = merge_one(staging_file, main_file, dry_run=dry_run)
except RuntimeError as e:
# 不变式违反:立刻停(绝不能继续写入更小的 main)
invariant_violations.append(str(e))
log.error("[%d/%d] INVARIANT %s", i, len(pairs), e)
continue
except Exception as e: # noqa: BLE001
log.error("[%d/%d] %s ERR %s", i, len(pairs), rel, e)
skipped_n += 1
continue
details.append(stat)
if stat["action"] == "created":
created_n += 1
else:
merged_n += 1
total_new += stat["added"]
if i % 1000 == 0 or i == len(pairs):
log.info("[%d/%d] %s %s +%d (main %d%d)",
i, len(pairs), stat["symbol"], stat["action"],
stat["added"], stat["before"], stat["after"])
summary = {
"merged": merged_n,
"created": created_n,
"skipped": skipped_n,
"total_new_rows": total_new,
"details": details,
"dry_run": dry_run,
"invariant_violations": invariant_violations,
}
if invariant_violations:
# 不变式违反致命——即使个别合并成功也判失败
summary["ok"] = False
else:
summary["ok"] = True
return summary
def main():
ap = argparse.ArgumentParser(description="合并 staging 增量到主库(绝不截断)")
ap.add_argument("--staging", required=True, help="staging 根(如 data_cache/daily_update/raw")
ap.add_argument("--main", required=True, help="主库根(如 data_cache/raw")
ap.add_argument("--dry-run", action="store_true", help="只报不写")
ap.add_argument("--summary-json", default=None, help="把汇总写到该 JSON 文件")
args = ap.parse_args()
summary = run_merge(args.staging, args.main, dry_run=args.dry_run)
mode = "[DRY-RUN] " if args.dry_run else ""
log.info("=== %s合并完成: merged=%d created=%d skipped=%d 新增行=%d ok=%s ===",
mode, summary["merged"], summary["created"], summary["skipped"],
summary["total_new_rows"], summary["ok"])
if summary["invariant_violations"]:
log.error("不变式违反 %d 条(main 被截),详情见上", len(summary["invariant_violations"]))
if args.summary_json:
with open(args.summary_json, "w") as f:
json.dump(summary, f, ensure_ascii=False, indent=2, default=str)
# 不变式违反 → 退 1(即便部分成功,也提示 main 可能已损坏需排查)
sys.exit(0 if summary["ok"] else 1)
if __name__ == "__main__":
main()