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:
@@ -11,8 +11,10 @@ import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
DB_PATH = os.environ.get('VNPY_DB_PATH', '/tmp/quant_trading_import.db')
|
||||
DAILY_DIR = '/Volumes/stock/A股数据/日线数据/daily/'
|
||||
DB_PATH = os.environ.get('VNPY_DB_PATH', '/volume1/stock/sanguo_vnpy/data/quant_trading.db')
|
||||
# 默认 raw(真实价)—— vnpy CTA 回测撮合用真实价;v1 daily/ 口径已停。
|
||||
# 灌 qfq 改 DAILY_DIR=.../qfq/。
|
||||
DAILY_DIR = os.environ.get('DAILY_DIR', '/volume1/stock/A股数据/日线数据/raw/')
|
||||
|
||||
BATCH_SIZE = 50000 # 每批插入行数
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -24,8 +24,15 @@ import argparse
|
||||
import csv
|
||||
import logging
|
||||
import os
|
||||
import socket
|
||||
import sys
|
||||
import time
|
||||
from collections import deque
|
||||
|
||||
# 进程级 socket 超时(关键):akshare 内部 requests 默认无 timeout,
|
||||
# 遇源限速/慢响应会无限挂起整进程(实测 35 只后 socket 死等 → hang)。
|
||||
# 15s 足够区分正常慢响应与挂死,超时即抛 → download_one 捕获 → fail → 继续下一只。
|
||||
socket.setdefaulttimeout(15)
|
||||
|
||||
# 直连:进程级 unset 代理(用户约束)
|
||||
for _k in ["HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy", "ALL_PROXY", "all_proxy"]:
|
||||
@@ -42,12 +49,33 @@ RAW_DIR = os.environ.get("RAW_DIR", "/tmp/stock_dl/A股数据/日线数据/raw")
|
||||
SLEEP = float(os.environ.get("SLEEP", "1.0"))
|
||||
DEFAULT_STOCK_LIST = "/tmp/stock_dl/A股数据/stock_info/stock_basic_info_raw_20260326_113530.csv"
|
||||
|
||||
# 断路器(借鉴 15min backfill 自愈范式)
|
||||
CIRCUIT_BREAKER_WINDOW = 200 # 滚动窗口大小
|
||||
CIRCUIT_BREAKER_FAIL_RATE = 0.30 # 触发阈值:失败率 > 30%
|
||||
# 北交所码新浪不支持(必 fail,从断路器分母扣除,防误触发)
|
||||
KNOWN_UNSUPPORTED_PREFIX = ("920", "921", "83", "87")
|
||||
|
||||
|
||||
def prefix_for(code: str) -> str:
|
||||
"""sh/sz 前缀(与 datareader.guess_exchange 一致)。"""
|
||||
return "sh" if code.startswith(("60", "68", "51", "56", "58")) else "sz"
|
||||
|
||||
|
||||
def check_circuit_breaker(recent_results: list) -> bool:
|
||||
"""检查滚动窗口内失败率是否超阈值(断路器触发判定)。
|
||||
|
||||
recent_results: [(code, ok: bool), ...] 最近下载结果。
|
||||
北交所已知不支持码从分子分母同时扣除(它们必 fail,计入会误触发)。
|
||||
返回 True 表示应触发断路器。
|
||||
"""
|
||||
counted = [(c, ok) for c, ok in recent_results
|
||||
if not c.startswith(KNOWN_UNSUPPORTED_PREFIX)]
|
||||
if len(counted) < CIRCUIT_BREAKER_WINDOW:
|
||||
return False
|
||||
fails = sum(1 for _, ok in counted if not ok)
|
||||
return (fails / len(counted)) > CIRCUIT_BREAKER_FAIL_RATE
|
||||
|
||||
|
||||
def download_one(ak, code: str, start: str, end: str, adjust: str = ""):
|
||||
"""新浪源拉日线(adjust="" raw / "qfq" 前复权),返回 (df, None) 或 (None, err)。"""
|
||||
sym = f"{prefix_for(code)}{code}"
|
||||
@@ -79,15 +107,30 @@ def save_one(code: str, df) -> int:
|
||||
return n
|
||||
|
||||
|
||||
def exists(code: str, start_year: int) -> bool:
|
||||
"""symbol 在 start_year 是否已有 parquet(断点续传)。
|
||||
def latest_trading_day(end: str) -> str:
|
||||
"""end 往前最近的工作日(周一~周五)。忽略节假日——增量天天跑会自愈。"""
|
||||
d = pd.Timestamp(end)
|
||||
while d.weekday() >= 5: # 5=Sat, 6=Sun
|
||||
d -= pd.Timedelta(days=1)
|
||||
return d.strftime("%Y-%m-%d")
|
||||
|
||||
同范围续跑 → skip;扩范围(start 更早)→ 新 start_year 不存在 → 重下全量覆盖。
|
||||
|
||||
def is_fresh(code: str, end: str, start_year: int) -> bool:
|
||||
"""symbol 的 parquet 数据是否已到最新交易日(增量断点续传)。
|
||||
|
||||
文件不存在 / 损坏 / 最新日期 < latest_trading_day(end) → 不 fresh → 重下。
|
||||
旧版 exists() 只查文件在不在,对"文件存在但数据旧"的增量场景失效
|
||||
(raw/2026 已有旧 parquet → 全 skip → 补数永远不写入)。
|
||||
"""
|
||||
pref = prefix_for(code)
|
||||
return os.path.exists(
|
||||
os.path.join(RAW_DIR, str(start_year), f"{pref}{code}_daily.parquet")
|
||||
)
|
||||
f = os.path.join(RAW_DIR, str(start_year), f"{pref}{code}_daily.parquet")
|
||||
if not os.path.exists(f):
|
||||
return False
|
||||
try:
|
||||
maxd = pd.read_parquet(f, columns=["date"])["date"].max()
|
||||
except Exception: # noqa: BLE001 损坏文件 → 重下
|
||||
return False
|
||||
return pd.Timestamp(maxd) >= pd.Timestamp(latest_trading_day(end))
|
||||
|
||||
|
||||
def load_all_codes(stock_list: str) -> list[str]:
|
||||
@@ -135,13 +178,16 @@ def main():
|
||||
|
||||
start_year = int(args.start[:4])
|
||||
ok = fail = rows = skipped = 0
|
||||
circuit_recent: deque = deque(maxlen=CIRCUIT_BREAKER_WINDOW)
|
||||
circuit_count = 0 # 非北交所码下载计数(断路器窗口)
|
||||
for i, code in enumerate(codes, 1):
|
||||
if not args.force and exists(code, start_year):
|
||||
if not args.force and is_fresh(code, end, start_year):
|
||||
skipped += 1
|
||||
if skipped % 500 == 0:
|
||||
log.info("[%d/%d] ... skipped %d 已存在", i, len(codes), skipped)
|
||||
log.info("[%d/%d] ... skipped %d 已最新", i, len(codes), skipped)
|
||||
continue
|
||||
df, err = download_one(ak, code, args.start, end, args.adjust)
|
||||
download_ok = False
|
||||
if df is None:
|
||||
fail += 1
|
||||
log.warning("[%d/%d] %s FAIL %s", i, len(codes), code, err)
|
||||
@@ -150,10 +196,22 @@ def main():
|
||||
n = save_one(code, df)
|
||||
ok += 1
|
||||
rows += n
|
||||
download_ok = True
|
||||
log.info("[%d/%d] %s ok %d rows", i, len(codes), code, n)
|
||||
except Exception as e: # noqa: BLE001
|
||||
fail += 1
|
||||
log.error("[%d/%d] %s SAVE FAIL %s", i, len(codes), code, e)
|
||||
# 断路器:北交所码不计入(必 fail 会误触发)
|
||||
if not code.startswith(KNOWN_UNSUPPORTED_PREFIX):
|
||||
circuit_recent.append((code, download_ok))
|
||||
circuit_count += 1
|
||||
if circuit_count % CIRCUIT_BREAKER_WINDOW == 0:
|
||||
if check_circuit_breaker(list(circuit_recent)):
|
||||
fails = sum(1 for _, ok2 in circuit_recent if not ok2)
|
||||
rate = fails / len(circuit_recent) * 100
|
||||
log.error("断路器触发:最近 %d 只失败率 %.1f%%,abort",
|
||||
len(circuit_recent), rate)
|
||||
sys.exit(3)
|
||||
time.sleep(SLEEP) # 限速(用户约束)
|
||||
|
||||
log.info("=== 完成: ok=%d skip=%d fail=%d rows=%d,raw_dir=%s ===", ok, skipped, fail, rows, RAW_DIR)
|
||||
|
||||
@@ -1,27 +1,49 @@
|
||||
#!/bin/bash
|
||||
# 每日数据增量(C-S3 实走用):raw 真实价 + qfq 前复权,最近 N 天 → NAS。
|
||||
#
|
||||
# **安全流程(截断 bug 修复后)**:staging → 验证 → 合并主库 → rsync 主库 NAS。
|
||||
# raw_redownload.py 的 save_one() 用"本次几天"覆盖写整年 → 历史被截。
|
||||
# 现在下载只写 staging,verify_increment 把关,merge_increment 合并进主库
|
||||
# (按 date 去重 keep='last',绝不截断),最后推主库到 NAS。
|
||||
#
|
||||
# 跳过 v1 daily_all_update(新浪源接口坏 KeyError:date,C-S3 不用 daily mixed)。
|
||||
# raw/qfq 用 raw_redownload.py(akshare 新浪 stock_zh_a_daily,全量 29600 已验证)。
|
||||
# 15min baostock 增量 = 分期项(默认跳,日内策略落地时加)。
|
||||
#
|
||||
# **长任务防休眠**:本脚本被 nohup / harness 后台跑前,先 `caffeinate -i -s &`
|
||||
# (Mac Mini 空闲睡眠会挂进程,详见 memory/feedback-unattended-tasks-prevent-sleep)。
|
||||
#
|
||||
# env:
|
||||
# DAYS=5 增量回看天数(C-S3 实走需当日,5 天兜底停牌/补缺)
|
||||
# DAYS=7 增量回看天数(C-S3 实走需当日,7 天兜底停牌/补缺/周末)
|
||||
# STOCK_LIST=... 全市场 csv(默认 data_cache/stock_info)
|
||||
# SKIP_PROBE=1 跳探针预检(调试用)
|
||||
# SKIP_RAW=1 跳 raw 增量
|
||||
# SKIP_QFQ=1 跳 qfq 增量
|
||||
# SKIP_VERIFY=1 跳 verify(调试用,默认把关)
|
||||
# SKIP_MERGE=1 跳 merge(调试用)
|
||||
# SKIP_NAS=1 跳推 NAS(烟测用)
|
||||
set -uo pipefail # 不用 -e:单只失败不退(raw_redownload 内部已容错记 fail)
|
||||
|
||||
NAS=sanguo-nas
|
||||
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
LOCAL=${STOCK_MOUNT:-$ROOT/data_cache/daily_update} # 持久目录(不进 /tmp,重启不丢)
|
||||
MAIN=$ROOT/data_cache # 主库(canonical 全量,绝不被下载直接写)
|
||||
STAGING=$ROOT/data_cache/daily_update # staging(增量暂存,每次清空重下)
|
||||
SL=${STOCK_LIST:-$ROOT/data_cache/stock_info/stock_basic_info_raw_20260326_113530.csv}
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
DAYS=${DAYS:-5}
|
||||
DAYS=${DAYS:-7}
|
||||
START=$(python3 -c "import datetime;print((datetime.date.today()-datetime.timedelta(days=$DAYS)).isoformat())")
|
||||
START_YEAR=${START:0:4}
|
||||
|
||||
mkdir -p "$LOCAL/raw" "$LOCAL/qfq"
|
||||
# 持久日志:stdout+stderr 同时 tee 到文件(保留实时显示 + 持久化)
|
||||
LOGDIR="$ROOT/data_cache/daily_update/logs"
|
||||
mkdir -p "$LOGDIR"
|
||||
LOGFILE="$LOGDIR/daily_$(date +%Y%m%d_%H%M%S).log"
|
||||
exec > >(tee -a "$LOGFILE") 2>&1
|
||||
|
||||
mkdir -p "$STAGING/raw" "$STAGING/qfq" "$MAIN/raw" "$MAIN/qfq"
|
||||
echo "=== 日志: $LOGFILE ==="
|
||||
echo "=== $(date) 每日增量 start=$START DAYS=$DAYS ==="
|
||||
echo " MAIN=$MAIN STAGING=$STAGING"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
# 拉 stock_info(代码列表)若本地缺
|
||||
@@ -30,22 +52,95 @@ if [ ! -f "$SL" ]; then
|
||||
rsync -az "$NAS:/volume1/stock/A股数据/stock_info/" "$(dirname "$SL")/" || true
|
||||
fi
|
||||
|
||||
# P. 探针预检(新浪可用性,省时防傻跑 5h 才发现源挂了)
|
||||
if [ "${SKIP_PROBE:-0}" != "1" ]; then
|
||||
echo "=== P. 探针预检:sh600000 最近 7 天 ==="
|
||||
PROBE_START=$(python3 -c "import datetime;print((datetime.date.today()-datetime.timedelta(days=7)).isoformat())")
|
||||
PROBE_END=$(python3 -c "import datetime;print(datetime.date.today().isoformat())")
|
||||
if ! PROBE_START="$PROBE_START" PROBE_END="$PROBE_END" timeout 30 python3 -c '
|
||||
import os, warnings
|
||||
for k in ["HTTP_PROXY","HTTPS_PROXY","http_proxy","https_proxy","ALL_PROXY","all_proxy"]:
|
||||
os.environ.pop(k, None)
|
||||
os.environ["NO_PROXY"] = "*"
|
||||
os.environ["no_proxy"] = "*"
|
||||
warnings.filterwarnings("ignore")
|
||||
import akshare as ak
|
||||
df = ak.stock_zh_a_daily(
|
||||
symbol="sh600000",
|
||||
start_date=os.environ["PROBE_START"].replace("-",""),
|
||||
end_date=os.environ["PROBE_END"].replace("-",""),
|
||||
adjust="",
|
||||
)
|
||||
if df is None or df.empty:
|
||||
print("PROBE: empty result")
|
||||
raise SystemExit(1)
|
||||
print("PROBE OK: %d rows" % len(df))
|
||||
'; then
|
||||
echo "!!! 探针失败:新浪不可用,abort 省时"
|
||||
exit 2
|
||||
fi
|
||||
fi
|
||||
|
||||
# 0. 清空 staging(每次干净增量——staging 是"本次拉的几天",不能累积)
|
||||
echo "=== 0. 清空 staging parquet ==="
|
||||
find "$STAGING/raw" -name '*.parquet' -delete 2>/dev/null || true
|
||||
find "$STAGING/qfq" -name '*.parquet' -delete 2>/dev/null || true
|
||||
|
||||
# 1. raw 增量(C-S3 撮合真实价,adjustflag=3 / akshare adjust="")
|
||||
if [ "${SKIP_RAW:-0}" != "1" ]; then
|
||||
echo "=== 1. raw 增量(最近 $DAYS 天 → $LOCAL/raw)==="
|
||||
STOCK_LIST="$SL" RAW_DIR="$LOCAL/raw" SLEEP=0.5 \
|
||||
python3 raw_redownload.py --all --start "$START" --adjust "" || echo " raw warn(单只失败已记)"
|
||||
echo "=== 1. raw 增量(最近 $DAYS 天 → $STAGING/raw)==="
|
||||
STOCK_LIST="$SL" RAW_DIR="$STAGING/raw" SLEEP=0.5 \
|
||||
python3 raw_redownload.py --all --start "$START" --adjust ""
|
||||
rc=$?
|
||||
if [ "$rc" -eq 3 ]; then
|
||||
echo "!!! 断路器触发(新浪限流/故障),abort 整个流程"
|
||||
exit 3
|
||||
elif [ "$rc" -ne 0 ]; then
|
||||
echo " raw warn(单只失败已记, exit=$rc)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 2. qfq 增量(C-S3 warmup 信号,无除权缺口)
|
||||
if [ "${SKIP_QFQ:-0}" != "1" ]; then
|
||||
echo "=== 2. qfq 增量(最近 $DAYS 天 → $LOCAL/qfq)==="
|
||||
STOCK_LIST="$SL" RAW_DIR="$LOCAL/qfq" SLEEP=0.5 \
|
||||
python3 raw_redownload.py --all --start "$START" --adjust qfq || echo " qfq warn(单只失败已记)"
|
||||
echo "=== 2. qfq 增量(最近 $DAYS 天 → $STAGING/qfq)==="
|
||||
STOCK_LIST="$SL" RAW_DIR="$STAGING/qfq" SLEEP=0.5 \
|
||||
python3 raw_redownload.py --all --start "$START" --adjust qfq
|
||||
rc=$?
|
||||
if [ "$rc" -eq 3 ]; then
|
||||
echo "!!! 断路器触发(新浪限流/故障),abort 整个流程"
|
||||
exit 3
|
||||
elif [ "$rc" -ne 0 ]; then
|
||||
echo " qfq warn(单只失败已记, exit=$rc)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 3. rsync 推 NAS(raw_dir + qfq_dir)
|
||||
echo "=== 3. rsync → NAS ==="
|
||||
rsync -az "$LOCAL/raw/" "$NAS:/volume1/stock/A股数据/日线数据/raw/" || echo " raw push warn"
|
||||
rsync -az "$LOCAL/qfq/" "$NAS:/volume1/stock/A股数据/日线数据/qfq/" || echo " qfq push warn"
|
||||
# 3. verify staging(安全闸门:不通过则不合并、不推 NAS,staging 留存供排查)
|
||||
if [ "${SKIP_VERIFY:-0}" != "1" ]; then
|
||||
echo "=== 3. verify staging ==="
|
||||
for KIND in raw qfq; do
|
||||
[ -d "$STAGING/$KIND/$START_YEAR" ] || { echo " $KIND/$START_YEAR 不存在,跳 verify"; continue; }
|
||||
if ! python3 verify_increment.py --staging "$STAGING/$KIND" --start "$START"; then
|
||||
echo " !!! $KIND verify FAILED —— 不合并、不推 NAS,staging 留存排查"
|
||||
echo " !!! 详见上方 JSON 输出(failed_symbols / fatal_samples)"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# 4. 合并 staging → 主库(按 date 去重 keep='last',绝不截断)
|
||||
if [ "${SKIP_MERGE:-0}" != "1" ]; then
|
||||
echo "=== 4. merge staging → main ==="
|
||||
for KIND in raw qfq; do
|
||||
[ -d "$STAGING/$KIND/$START_YEAR" ] || { echo " $KIND/$START_YEAR 不存在,跳 merge"; continue; }
|
||||
python3 merge_increment.py --staging "$STAGING/$KIND" --main "$MAIN/$KIND" \
|
||||
|| { echo " !!! $KIND merge 失败(可能不变式违反),不推 NAS"; exit 1; }
|
||||
done
|
||||
fi
|
||||
|
||||
# 5. rsync 主库 → NAS(改:推 $MAIN 不是 $STAGING;原脚本推 staging 是 bug 之一)
|
||||
if [ "${SKIP_NAS:-0}" != "1" ]; then
|
||||
echo "=== 5. rsync 主库 → NAS ==="
|
||||
rsync -az "$MAIN/raw/" "$NAS:/volume1/stock/A股数据/日线数据/raw/" || echo " raw push warn"
|
||||
rsync -az "$MAIN/qfq/" "$NAS:/volume1/stock/A股数据/日线数据/qfq/" || echo " qfq push warn"
|
||||
fi
|
||||
echo "=== 完成 $(date) ==="
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
#!/usr/bin/env python3
|
||||
"""验证 staging 增量质量(task: 截断 bug 修复的安全闸门)。
|
||||
|
||||
在 merge_increment 之前跑:校验 staging 的 raw/qfq 增量数据是否合格,
|
||||
不合格则 run_daily_update.sh 不合并、不推 NAS(staging 留存供排查)。
|
||||
|
||||
**复用 scripts/data_platform/validator.py 的 DataValidator(七条 fatal)**:
|
||||
D1 价格>0 / D2 OHLC 一致 / D3 volume≥0 / D6 日期不重复 / D7 非未来日期 / ...
|
||||
|
||||
阈值(Main Agent 已定,硬编码常量):
|
||||
MIN_SUCCESS_RATE = 0.95 成功率(分母扣除北交所已知不支持码)
|
||||
MIN_FRESH_RATE = 0.95 最大日期 >= 最近交易日的 symbol 占比
|
||||
KNOWN_UNSUPPORTED_PREFIX 新浪 stock_zh_a_daily 不支持的北交所码(从分母扣)
|
||||
|
||||
用法:
|
||||
python3 verify_increment.py --staging data_cache/daily_update/raw \\
|
||||
--stock-list data_cache/stock_info/stock_basic_info_raw_*.csv \\
|
||||
--start 2026-07-01
|
||||
|
||||
退出码:passed=0,否则 1。输出 JSON 到 stdout。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import pandas as pd
|
||||
|
||||
# 同目录 import(脚本运行目录 = scripts/data_platform/)
|
||||
_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
if _SCRIPT_DIR not in sys.path:
|
||||
sys.path.insert(0, _SCRIPT_DIR)
|
||||
|
||||
from validator import DataValidator # noqa: E402
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||
log = logging.getLogger("verify_inc")
|
||||
|
||||
# 阈值(Main Agent 已定)
|
||||
MIN_SUCCESS_RATE = 0.95
|
||||
MIN_FRESH_RATE = 0.95
|
||||
KNOWN_UNSUPPORTED_PREFIX = ("920", "921", "83", "87")
|
||||
MAX_FATAL_SAMPLES = 5 # 输出里只放前 N 个失败样本(避免输出爆掉)
|
||||
|
||||
|
||||
def _latest_available_trading_day(now: datetime | None = None) -> str:
|
||||
"""A 股收盘感知:返回 now 时点最近的可获取交易日(YYYY-MM-DD)。
|
||||
|
||||
catch-up 跨夜场景:下载发生在昨晚、verify 跑在今天盘前,
|
||||
今天数据尚未生成(15:00 才收盘)→ 目标应是昨天而非今天。
|
||||
|
||||
- 工作日 >= 15:00 → 今天(已收盘,数据可获取)
|
||||
- 工作日 < 15:00 → 上一交易日(今天未收盘,回退到工作日)
|
||||
- 周末 → 上周五
|
||||
|
||||
注意:与 raw_redownload.latest_trading_day 不同——那个只回退周末、
|
||||
用于 is_fresh 决定是否重下;本函数额外感知收盘时间,用于 verify 闸门。
|
||||
"""
|
||||
if now is None:
|
||||
now = datetime.now()
|
||||
d = now
|
||||
if d.weekday() >= 5: # 周末 → 回退到周五
|
||||
d = d - timedelta(days=(d.weekday() - 4))
|
||||
elif d.hour < 15: # 工作日盘前 → 上一交易日
|
||||
d = d - timedelta(days=1)
|
||||
while d.weekday() >= 5: # 回退周末
|
||||
d = d - timedelta(days=1)
|
||||
return d.strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
def symbol_from_filename(fname: str) -> str:
|
||||
"""`sh600000_daily.parquet` → `600000`。"""
|
||||
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 verify(staging_root: str, start: str, _now: datetime | None = None) -> dict:
|
||||
"""校验 staging 下 start_year 目录的所有 parquet。
|
||||
|
||||
返回 dict: {passed, success_rate, fresh_rate, total, unsupported_skipped,
|
||||
success, fresh, failed_symbols, fatal_samples, latest_trading_day}。
|
||||
|
||||
_now 仅用于测试注入固定时间;生产留空取 datetime.now()。
|
||||
"""
|
||||
start_year = int(start[:4])
|
||||
year_dir = os.path.join(staging_root, str(start_year))
|
||||
if not os.path.isdir(year_dir):
|
||||
raise FileNotFoundError(f"staging year dir 不存在: {year_dir}")
|
||||
|
||||
now = _now or datetime.now()
|
||||
latest = _latest_available_trading_day(now)
|
||||
latest_ts = pd.Timestamp(latest)
|
||||
log.info("最近可获取交易日=%s(now=%s)", latest, now.strftime("%Y-%m-%d %H:%M"))
|
||||
|
||||
validator = DataValidator()
|
||||
|
||||
total = 0
|
||||
unsupported_skipped = 0
|
||||
success = 0
|
||||
fresh = 0
|
||||
failed_symbols: list[str] = []
|
||||
fatal_samples: list[dict] = []
|
||||
|
||||
files = sorted(f for f in os.listdir(year_dir) if f.endswith("_daily.parquet"))
|
||||
for fname in files:
|
||||
total += 1
|
||||
code = symbol_from_filename(fname)
|
||||
# 北交所码新浪不支持(log 大量 920xxx KeyError:'date' 确认)→ 从分母扣
|
||||
if code.startswith(KNOWN_UNSUPPORTED_PREFIX):
|
||||
unsupported_skipped += 1
|
||||
continue
|
||||
|
||||
fpath = os.path.join(year_dir, fname)
|
||||
try:
|
||||
df = pd.read_parquet(fpath)
|
||||
except Exception as e: # noqa: BLE001 损坏文件记 fail
|
||||
failed_symbols.append(code)
|
||||
if len(fatal_samples) < MAX_FATAL_SAMPLES:
|
||||
fatal_samples.append({"symbol": code, "errors": [f"read_parquet: {type(e).__name__}: {str(e)[:80]}"]})
|
||||
continue
|
||||
|
||||
result = validator.validate(df, "daily")
|
||||
if not result.passed:
|
||||
failed_symbols.append(code)
|
||||
if len(fatal_samples) < MAX_FATAL_SAMPLES:
|
||||
fatal_samples.append({"symbol": code, "errors": result.fatal_errors[:3]})
|
||||
continue
|
||||
|
||||
success += 1
|
||||
# 新鲜度:最大日期 >= 最近交易日(staging 是增量,多数会 == latest)
|
||||
try:
|
||||
maxd = pd.Timestamp(df["date"].max())
|
||||
except Exception: # noqa: BLE001 无 date 列记为不 fresh(但已过校验,理论上不会)
|
||||
maxd = pd.Timestamp("1970-01-01")
|
||||
if maxd >= latest_ts:
|
||||
fresh += 1
|
||||
|
||||
denom = total - unsupported_skipped
|
||||
success_rate = (success / denom) if denom > 0 else 0.0
|
||||
fresh_rate = (fresh / success) if success > 0 else 0.0
|
||||
passed = (success_rate >= MIN_SUCCESS_RATE) and (fresh_rate >= MIN_FRESH_RATE) and (denom > 0)
|
||||
|
||||
return {
|
||||
"passed": passed,
|
||||
"success_rate": round(success_rate, 4),
|
||||
"fresh_rate": round(fresh_rate, 4),
|
||||
"total": total,
|
||||
"unsupported_skipped": unsupported_skipped,
|
||||
"success": success,
|
||||
"fresh": fresh,
|
||||
"denom": denom,
|
||||
"failed_count": len(failed_symbols),
|
||||
"failed_symbols": failed_symbols,
|
||||
"fatal_samples": fatal_samples,
|
||||
"latest_trading_day": latest,
|
||||
"thresholds": {
|
||||
"MIN_SUCCESS_RATE": MIN_SUCCESS_RATE,
|
||||
"MIN_FRESH_RATE": MIN_FRESH_RATE,
|
||||
"KNOWN_UNSUPPORTED_PREFIX": list(KNOWN_UNSUPPORTED_PREFIX),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="验证 staging 增量质量")
|
||||
ap.add_argument("--staging", required=True, help="staging 根(如 data_cache/daily_update/raw)")
|
||||
ap.add_argument("--stock-list", default=None, help="stock_basic_info csv(保留接口,本版未强用)")
|
||||
ap.add_argument("--start", required=True, help="增量起点 YYYY-MM-DD(决定看哪个 year 目录)")
|
||||
ap.add_argument("--summary-json", default=None, help="把结果写到该 JSON 文件")
|
||||
args = ap.parse_args()
|
||||
|
||||
try:
|
||||
result = verify(args.staging, args.start)
|
||||
except FileNotFoundError as e:
|
||||
log.error("%s", e)
|
||||
sys.exit(2)
|
||||
|
||||
# 输出
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2, default=str))
|
||||
|
||||
if args.summary_json:
|
||||
with open(args.summary_json, "w") as f:
|
||||
json.dump(result, f, ensure_ascii=False, indent=2, default=str)
|
||||
|
||||
verdict = "PASSED" if result["passed"] else "FAILED"
|
||||
log.info("=== verify %s: success_rate=%.2f fresh_rate=%.2f total=%d skipped=%d failed=%d ===",
|
||||
verdict, result["success_rate"], result["fresh_rate"],
|
||||
result["total"], result["unsupported_skipped"], result["failed_count"])
|
||||
|
||||
sys.exit(0 if result["passed"] else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user