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
+66 -8
View File
@@ -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=%draw_dir=%s ===", ok, skipped, fail, rows, RAW_DIR)