Files
sanguo_vnpy_v2/scripts/data_platform/download_15m_xtdata.py
T
claude_dev ee27313644 feat(data): 全市场5m/15m下载+灌库+每日增量链
xtdata逐只下载(非批量download_history_data2,后者5201只触发xtquant死锁),
先5m后15m(15m依赖5m),看get bars判成败(ret=None正常非失败)。
download_15m_xtdata.py + relaunch_15m_wrapper.ps1(auto-restart兜底segfault)。
import_vnpy_minute_fast.py灌库(INSERT OR REPLACE,interval存5m/15m,8028万行)。
_run_daily.ps1加5m/15m增量段(每天16:30)。validate_import.py校验。

全市场5201只,5m 6020万/15m 2007万bar,2025-07-17~2026-07-17。
2026-07-18 18:45:24 +08:00

638 lines
27 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""全市场 5m + 15m 双周期一次性下载(raw + qfq 双源)。VPS 后台跑(需 miniQMT 常驻)。
【Main Agent 实证真根因 — 勿再质疑】
1. download_history_data / download_history_data2 返回 None 是正常的,绝非失败!
成败只看下一步 get_market_data_ex 的 bars 数(>0=成功)。
2. 15m 依赖 5m 基础数据:先下 5m 再下 15m(顺序不可反、不可并行)。
实证:先 5m=11670 bars、再 15m=3890 bars(全板块一致)。
3. 全市场 1m/5m 直接可下(不需订阅、不是权限问题)。
【复权机制】download 只下原始 none 数据一次,读时用 dividend_type='front' 转 qfq。
每周期 download 一次,raw/qfq 两份在读时分流出。别为 qfq 单独 download。
(同 daily_update_xtdata.pydownload_history_data2 批量 → get_market_data_ex 分别
读 none/front 两份)。
【两轮下载顺序】
Phase 1: 全市场 download_history_data2 批量下 5m(→ xtdata 本地缓存)
Phase 1.5: 逐只 get_market_data_ex 读 5mraw + qfq)→ 写 parquet
Phase 2: 全市场 download_history_data2 批量下 15m(依赖 5m 基础)
Phase 2.5: 逐只 get_market_data_ex 读 15mraw + qfq)→ 写 parquet
Phase 3: 校验 → 写 _result.json
【存储】C:\\sanguo_vnpy_v2\\data\\minute_5\\{raw,qfq}\\<code>_5m.parquet
C:\\sanguo_vnpy_v2\\data\\minute_15\\{raw,qfq}\\<code>_15m.parquet
schema: datetime,open,high,low,close,volumevolume ×100 手→股)。
【断点续传】文件已存在且 bars ≥ MIN_BARS_RESUME_{5M,15M} 则跳过该只写盘。
【单线程 paced】批量 download_history_data2 服务端内部并行;轮次间 sleep 2s。
【进度】_progress.json 每 200 只刷新。
用法(VPS):
C:\\Python310\\python.exe -X utf8 download_15m_xtdata.py
ETF 本轮未下(universe 取"沪深A股"约 5201 只)。
退出码:0=PASS;1=FAIL 或有失败;2=致命错误(universe 空 / miniQMT 未连)。
"""
import os
import sys
import json
import time
import random
import datetime as dt
from typing import Any
import pandas as pd
ROOT = r"C:\sanguo_vnpy_v2"
DATA_5M = os.path.join(ROOT, "data", "minute_5")
DATA_15M = os.path.join(ROOT, "data", "minute_15")
RAW_5M = os.path.join(DATA_5M, "raw")
QFQ_5M = os.path.join(DATA_5M, "qfq")
RAW_15M = os.path.join(DATA_15M, "raw")
QFQ_15M = os.path.join(DATA_15M, "qfq")
PROGRESS_FILE = os.path.join(DATA_15M, "_progress.json")
RESULT_FILE = os.path.join(DATA_15M, "_result.json")
CURSOR_FILE = os.path.join(DATA_15M, "_cursor.json") # crash 前正在处理的股票(poison-pill 探测)
BLOCKLIST_FILE = os.path.join(DATA_15M, "_blocklist.json") # 累积 poison-pill 名单(跨重启)
START_TIME = "20250717"
END_TIME = dt.datetime.now().strftime("%Y%m%d")
# 实证预期:5m≈11670 bars、15m≈3890 bars(全板块一致,2025-07-17~today ~240 交易日)
EXPECTED_BARS_5M = 11670
EXPECTED_BARS_15M = 3890
MIN_BARS_RESUME_5M = 11400 # 断点续传阈值(允许 ±2.5%
MIN_BARS_RESUME_15M = 3888 # 15m 断点续传阈值(接近完整 3890,旧文件 600000=2040 会重下)
EXPECTED_BARS_PER_DAY_5M = 48 # A 股 4h 交易日 × 12 bar/h
EXPECTED_BARS_PER_DAY_15M = 16 # A 股 4h 交易日 × 4 bar/h
DOWNLOAD_BATCH = 200 # download_history_data2 批量大小(与 daily_update_xtdata 一致)
SLEEP_BETWEEN_BATCH = 2.0 # 批次间 sleep(不猛打券商后端)
SLEEP_BETWEEN_PHASES = 5.0 # 5m → 15m 轮次切换间 sleep
SLEEP_BETWEEN_STOCKS_15M = 0.2 # 15m 逐只 download 间隔(paced,绝不并发)
PROGRESS_FLUSH_EVERY = 200 # 进度文件刷新间隔(写盘阶段每 N 只)
FAILED_LIST_CAP = 200 # _progress.json 内 failed 列表截断
DISK_ALERT_GB = 20.0 # 磁盘 free < 此值告警
from xtquant import xtdata as xd
T0 = time.time()
def log(m: str) -> None:
print(f"[15M {time.time()-T0:.0f}s] {m}", flush=True)
def disk_free_gb() -> float:
"""C:\\ 剩余空间 GB(shutil 跨平台)。失败返回 -1。"""
try:
import shutil
return shutil.disk_usage("C:\\").free / (1024 ** 3)
except Exception: # noqa: BLE001
return -1.0
def count_parquet(directory: str, suffix: str) -> int:
if not os.path.isdir(directory):
return 0
try:
return len([f for f in os.listdir(directory) if f.endswith(f"_{suffix}.parquet")])
except Exception: # noqa: BLE001
return 0
def load_blocklist() -> set[str]:
"""读累积 poison-pill 名单。crash 后 wrapper 重启时,cursor 里那只会被加入此处。"""
if not os.path.exists(BLOCKLIST_FILE):
return set()
try:
with open(BLOCKLIST_FILE, "r", encoding="utf-8") as f:
return set(json.load(f).get("blocklist", []))
except Exception: # noqa: BLE001
return set()
def add_to_blocklist(code: str, reason: str) -> None:
"""把 poison-pill 股加入持久 blocklist(跨重启累积)。"""
bl = load_blocklist()
if code in bl:
return
bl.add(code)
payload = {
"blocklist": sorted(bl),
"last_added": code,
"last_reason": reason,
"last_added_at": dt.datetime.now().isoformat(),
"count": len(bl),
}
tmp = BLOCKLIST_FILE + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump(payload, f, ensure_ascii=False, indent=2)
os.replace(tmp, BLOCKLIST_FILE)
def write_cursor(code: str) -> None:
"""download_history_data 调用前写 cursor。crash 后重启时读 cursor → 该 code 是 poison-pill。"""
try:
tmp = CURSOR_FILE + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump({"processing": code, "ts": dt.datetime.now().isoformat()}, f,
ensure_ascii=False)
os.replace(tmp, CURSOR_FILE)
except Exception: # noqa: BLE001
pass
def clear_cursor() -> None:
"""单只处理完清除 cursor(成功路径)。"""
try:
if os.path.exists(CURSOR_FILE):
os.remove(CURSOR_FILE)
except Exception: # noqa: BLE001
pass
def read_stale_cursor() -> str | None:
"""启动时读 cursor。若存在 → 上次 crash 时正在处理的那只 = poison-pill。"""
if not os.path.exists(CURSOR_FILE):
return None
try:
with open(CURSOR_FILE, "r", encoding="utf-8") as f:
return json.load(f).get("processing")
except Exception: # noqa: BLE001
return None
def parse_dt_index(idx) -> pd.DatetimeIndex:
"""xtdata 时间索引(14 位 str/int 'yyyymmddHHMMSS' 或带毫秒)→ DatetimeIndex。"""
s = pd.Series([str(i) for i in idx]).str.slice(0, 14)
return pd.to_datetime(s, format="%Y%m%d%H%M%S", errors="coerce")
def to_pdf(df: pd.DataFrame) -> pd.DataFrame:
"""xtdata DataFrame → 统一 schema。volume ×100 手→股。"""
return pd.DataFrame({
"datetime": parse_dt_index(df.index),
"open": df["open"].astype(float).values,
"high": df["high"].astype(float).values,
"low": df["low"].astype(float).values,
"close": df["close"].astype(float).values,
"volume": (df["volume"].astype(float) * 100.0).values, # xtdata 手→股(memory 铁证)
}).dropna(subset=["datetime"]).sort_values("datetime").reset_index(drop=True)
def atomic_write(path: str, df: pd.DataFrame) -> None:
os.makedirs(os.path.dirname(path), exist_ok=True)
tmp = path + ".tmp"
df.to_parquet(tmp, index=False)
os.replace(tmp, path)
def write_progress(payload: dict[str, Any]) -> None:
payload = {**payload, "elapsed_sec": round(time.time() - T0, 1),
"updated": dt.datetime.now().isoformat()}
os.makedirs(os.path.dirname(PROGRESS_FILE), exist_ok=True)
tmp = PROGRESS_FILE + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump(payload, f, ensure_ascii=False, indent=2)
os.replace(tmp, PROGRESS_FILE)
def fetch(code: str, period: str, dividend_type: str) -> pd.DataFrame | None:
"""从 xtdata 本地缓存 get 出 bars。返回原始 xtdata DataFrame 或 None。"""
r = xd.get_market_data_ex([], [code], period=period, start_time=START_TIME,
end_time=END_TIME, dividend_type=dividend_type)
return r.get(code) if r else None
def batch_download(period: str, universe: list[str]) -> tuple[int, int]:
"""批量 download_history_data2 全市场 → xtdata 本地缓存。
成败不看 download 返回/回调,以下一步 get bars 为准(Main Agent 实证)。
返回 (n_batches, n_err_batches)。
"""
n_batches = (len(universe) + DOWNLOAD_BATCH - 1) // DOWNLOAD_BATCH
err_batches = 0
def _cb(data: Any, prog: float) -> None:
# callback 只 flush 进度,不做成败判定
if prog >= 100.0:
log(f" dl_{period} batch done (prog={prog:.1f})")
for bi in range(n_batches):
chunk = universe[bi * DOWNLOAD_BATCH:(bi + 1) * DOWNLOAD_BATCH]
try:
# download_history_data2 批量版,ret 可能为 None(正常),不看
xd.download_history_data2(chunk, period, START_TIME, END_TIME, _cb)
except Exception as e: # noqa: BLE001
err_batches += 1
log(f" dl_{period} batch#{bi} err: {e}(继续,单批 fail 不致命)")
if (bi + 1) % 5 == 0 or (bi + 1) == n_batches:
log(f" dl_{period} batch {bi+1}/{n_batches} ({(bi+1)*100/n_batches:.1f}%) err_batches={err_batches}")
time.sleep(SLEEP_BETWEEN_BATCH)
return (n_batches, err_batches)
def write_one_period(code: str, period: str, raw_dir: str, qfq_dir: str,
min_bars: int) -> dict[str, Any]:
"""download 阶段已完成,从 xtdata 缓存 get → 写 raw/qfq parquet。返回 dict。"""
raw_path = os.path.join(raw_dir, f"{code}_{period}.parquet")
qfq_path = os.path.join(qfq_dir, f"{code}_{period}.parquet")
res: dict[str, Any] = {"code": code, "period": period,
"raw_bars": 0, "qfq_bars": 0, "err": None, "skipped": False}
# 断点续传:两源都已存在且 bar 数 ≥ 阈值 → 跳过
if os.path.exists(raw_path) and os.path.exists(qfq_path):
try:
r_old = pd.read_parquet(raw_path)
q_old = pd.read_parquet(qfq_path)
if len(r_old) >= min_bars and len(q_old) >= min_bars:
res["raw_bars"] = len(r_old)
res["qfq_bars"] = len(q_old)
res["skipped"] = True
return res
except Exception:
pass # 文件损坏,下面重读重写
# get raw (none)
try:
rdf = fetch(code, period, "none")
if rdf is not None and len(rdf):
pdf = to_pdf(rdf)
if len(pdf):
atomic_write(raw_path, pdf)
res["raw_bars"] = len(pdf)
except Exception as e: # noqa: BLE001
res["err"] = f"raw:{e}"
# get qfq (front) —— 同份缓存读,不为 qfq 单独 download
try:
qdf = fetch(code, period, "front")
if qdf is not None and len(qdf):
pdf = to_pdf(qdf)
if len(pdf):
atomic_write(qfq_path, pdf)
res["qfq_bars"] = len(pdf)
except Exception as e: # noqa: BLE001
res["err"] = (res["err"] or "") + f" qfq:{e}"
return res
def write_phase(period: str, universe: list[str], raw_dir: str, qfq_dir: str,
min_bars: int, failed: list[dict], done_counter: dict[str, int],
counter_key: str) -> int:
"""逐只 get → 写 raw/qfq。失败(raw_bars=0)记 failed 不中断。返回 done 数。"""
done = 0
last_stock = "(start)"
fail_phase = 0
for i, code in enumerate(universe):
last_stock = code
try:
res = write_one_period(code, period, raw_dir, qfq_dir, min_bars)
if not res["skipped"] and res["raw_bars"] == 0 and res["qfq_bars"] == 0:
failed.append({"code": code, "period": period, "err": res["err"] or "no_data"})
fail_phase += 1
except Exception as e: # noqa: BLE001
failed.append({"code": code, "period": period, "err": str(e)})
fail_phase += 1
done = i + 1
done_counter[counter_key] = done
if done % PROGRESS_FLUSH_EVERY == 0 or done == len(universe):
write_progress({
"phase": f"write_{period}",
"total": len(universe),
"done_5m": done_counter.get("5m", 0),
"done_15m": done_counter.get("15m", 0),
"failed_count": len(failed),
"failed": failed[:FAILED_LIST_CAP],
"failed_truncated": len(failed) > FAILED_LIST_CAP,
"last_stock": last_stock,
"pct": round(done * 100 / len(universe), 2),
})
log(f" write_{period} {done}/{len(universe)} ({done*100/len(universe):.1f}%) "
f"phase_fail={fail_phase} total_fail={len(failed)} last={last_stock}")
return done
def download_and_write_one_15m(code: str, min_bars: int) -> dict[str, Any]:
"""15m 逐只 download_history_data(code,'15m',start,end) + get + 写 raw/qfq。
替代原 batch_download('15m')+write_one_period 组合。成败看 get bars >0
download_history_data 返回 None 是正常,Main Agent 实证)。
"""
raw_path = os.path.join(RAW_15M, f"{code}_15m.parquet")
qfq_path = os.path.join(QFQ_15M, f"{code}_15m.parquet")
res: dict[str, Any] = {"code": code, "period": "15m",
"raw_bars": 0, "qfq_bars": 0, "err": None, "skipped": False}
# 断点续传:两源已存在且 bars ≥ 阈值 → 跳过 download/get
if os.path.exists(raw_path) and os.path.exists(qfq_path):
try:
r_old = pd.read_parquet(raw_path)
q_old = pd.read_parquet(qfq_path)
if len(r_old) >= min_bars and len(q_old) >= min_bars:
res["raw_bars"] = len(r_old)
res["qfq_bars"] = len(q_old)
res["skipped"] = True
return res
except Exception:
pass # 文件损坏 → 重下
# 单只 downloadret=None 正常,看后续 get bars
try:
xd.download_history_data(code, "15m", START_TIME, END_TIME)
except Exception as e: # noqa: BLE001
res["err"] = f"dl:{e}"
# 不在此 return——download 报错也可能只是"已存在",继续 get
# get raw (none) → 写
try:
rdf = fetch(code, "15m", "none")
if rdf is not None and len(rdf):
pdf = to_pdf(rdf)
if len(pdf):
atomic_write(raw_path, pdf)
res["raw_bars"] = len(pdf)
except Exception as e: # noqa: BLE001
res["err"] = (res["err"] or "") + f" raw:{e}"
# get qfq (front) → 写(同份缓存读,不为 qfq 单独 download
try:
qdf = fetch(code, "15m", "front")
if qdf is not None and len(qdf):
pdf = to_pdf(qdf)
if len(pdf):
atomic_write(qfq_path, pdf)
res["qfq_bars"] = len(pdf)
except Exception as e: # noqa: BLE001
res["err"] = (res["err"] or "") + f" qfq:{e}"
return res
def phase_15m_per_stock(universe: list[str], failed: list[dict],
done_counter: dict[str, int]) -> int:
"""15m 逐只 download+write 循环。替代原 Phase 2 (batch_download) + Phase 2.5 (write_phase)。
单线程 paced(每只 sleep 0.2s),绝不并发。成败看 get bars >0。
【poison-pill 防护】
- 启动时读 _cursor.json:若存在 → 上次 crash 时正在处理的那只 = 嫌疑 poison-pill → 加入 blocklist
- 每只 download 前写 cursor,成功后清除
- blocklist 内的 code 跳过(记 failederr=blocked_poison_pill
"""
# 启动:检查 stale cursor → poison-pill
blocklist = load_blocklist()
stale = read_stale_cursor()
if stale:
log(f" ⚠️ detected stale cursor from crash: {stale} → adding to blocklist")
add_to_blocklist(stale, f"crash during download_history_data at {dt.datetime.now().isoformat()}")
blocklist = load_blocklist()
clear_cursor()
if blocklist:
log(f" blocklist ({len(blocklist)}): {sorted(blocklist)[:10]}{'...' if len(blocklist)>10 else ''}")
done = 0
fail_phase = 0
last_stock = "(start)"
for i, code in enumerate(universe):
last_stock = code
if code in blocklist:
failed.append({"code": code, "period": "15m", "err": "blocked_poison_pill"})
fail_phase += 1
done = i + 1
done_counter["15m"] = done
time.sleep(SLEEP_BETWEEN_STOCKS_15M)
continue
write_cursor(code) # 标记:即将处理此 code(crash 后重启可识别)
try:
res = download_and_write_one_15m(code, MIN_BARS_RESUME_15M)
if not res["skipped"] and res["raw_bars"] == 0 and res["qfq_bars"] == 0:
failed.append({"code": code, "period": "15m", "err": res["err"] or "no_data"})
fail_phase += 1
except Exception as e: # noqa: BLE001
failed.append({"code": code, "period": "15m", "err": str(e)})
fail_phase += 1
clear_cursor() # 成功路径:清除 cursor
done = i + 1
done_counter["15m"] = done
if done % PROGRESS_FLUSH_EVERY == 0 or done == len(universe):
dfree = disk_free_gb()
write_progress({
"phase": "write_15m",
"total": len(universe),
"done_5m": done_counter.get("5m", 0),
"done_15m": done,
"failed_count": len(failed),
"failed": failed[:FAILED_LIST_CAP],
"failed_truncated": len(failed) > FAILED_LIST_CAP,
"last_stock": last_stock,
"pct": round(done * 100 / len(universe), 2),
"disk_free_gb": round(dfree, 2),
"blocklist_size": len(blocklist),
})
log(f" write_15m {done}/{len(universe)} ({done*100/len(universe):.1f}%) "
f"phase_fail={fail_phase} total_fail={len(failed)} last={last_stock} "
f"disk_free={dfree:.1f}GB bl={len(blocklist)}")
if dfree >= 0 and dfree < DISK_ALERT_GB:
log(f" ⚠️ DISK LOW: {dfree:.1f}GB < {DISK_ALERT_GB}GB")
time.sleep(SLEEP_BETWEEN_STOCKS_15M)
return done
def validate(universe: list[str]) -> dict[str, Any]:
"""校验 5m + 15m 双周期 → 返回 result dict。"""
out: dict[str, Any] = {"periods": {}}
for period, raw_dir, qfq_dir, expected_bars_per_day, min_bars_floor in [
("5m", RAW_5M, QFQ_5M, EXPECTED_BARS_PER_DAY_5M, 5000),
("15m", RAW_15M, QFQ_15M, EXPECTED_BARS_PER_DAY_15M, 5000),
]:
raw_files = [f for f in os.listdir(raw_dir) if f.endswith(f"_{period}.parquet")] \
if os.path.isdir(raw_dir) else []
qfq_files = [f for f in os.listdir(qfq_dir) if f.endswith(f"_{period}.parquet")] \
if os.path.isdir(qfq_dir) else []
n_raw = len(raw_files)
n_qfq = len(qfq_files)
total_bars = 0
min_dt = None
max_dt = None
for f in raw_files:
try:
d = pd.read_parquet(os.path.join(raw_dir, f), columns=["datetime"])
if len(d):
total_bars += len(d)
lo = pd.to_datetime(d["datetime"]).min()
hi = pd.to_datetime(d["datetime"]).max()
if min_dt is None or lo < min_dt:
min_dt = lo
if max_dt is None or hi > max_dt:
max_dt = hi
except Exception:
continue
# 抽样 5 只校验完整性
samples = random.sample(raw_files, min(5, len(raw_files))) if raw_files else []
sample_results = []
complete_count = 0
for sf in samples:
try:
d = pd.read_parquet(os.path.join(raw_dir, sf))
d["_d"] = pd.to_datetime(d["datetime"]).dt.date
grp = d.groupby("_d").size()
incomplete_days = int((grp < expected_bars_per_day).sum())
sample_results.append({
"file": sf, "bars": int(len(d)), "days": int(len(grp)),
"incomplete_days": incomplete_days,
"first": str(d["datetime"].min()),
"last": str(d["datetime"].max()),
})
if incomplete_days == 0:
complete_count += 1
except Exception as e:
sample_results.append({"file": sf, "err": str(e)})
failed_stocks = [c for c in universe if f"{c}_{period}.parquet" not in set(raw_files)]
min_str = str(min_dt)[:19] if min_dt is not None else None
max_str = str(max_dt)[:19] if max_dt is not None else None
date_ok = False
if min_str and max_str:
earliest_limit = "2025-07-20" # 容忍首日 7/17~7/20 起步
latest_floor = (dt.datetime.now() - dt.timedelta(days=5)).strftime("%Y-%m-%d")
date_ok = min_str[:10] <= earliest_limit and max_str[:10] >= latest_floor
reasons = []
if n_raw < min_bars_floor:
reasons.append(f"raw 文件数 {n_raw} < {min_bars_floor}")
if len(samples) > 0 and complete_count < len(samples):
reasons.append(f"抽样完整 {complete_count}/{len(samples)}(有缺失日)")
if not date_ok:
reasons.append(f"日期范围异常: {min_str}~{max_str}")
verdict = "PASS" if (n_raw >= min_bars_floor
and (len(samples) == 0 or complete_count == len(samples))
and date_ok) else "FAIL"
out["periods"][period] = {
"verdict": verdict,
"fail_reasons": reasons,
"raw_files": n_raw,
"qfq_files": n_qfq,
"total_bars": int(total_bars),
"min_datetime": min_str,
"max_datetime": max_str,
"sample_size": len(samples),
"sample_complete_count": complete_count,
"sample": sample_results,
"failed_count": len(failed_stocks),
"failed_sample": failed_stocks[:30],
}
overall = "PASS" if all(p["verdict"] == "PASS" for p in out["periods"].values()) else "FAIL"
out["verdict"] = overall
out["universe_size"] = len(universe)
out["generated_at"] = dt.datetime.now().isoformat()
return out
def main() -> None:
log(f"START window={START_TIME}~{END_TIME} (先5m→后15m 两轮批量 download_history_data2)")
for d in (RAW_5M, QFQ_5M, RAW_15M, QFQ_15M):
os.makedirs(d, exist_ok=True)
# universe
try:
u = xd.get_stock_list_in_sector("沪深A股") or []
except Exception as e: # noqa: BLE001
log(f"FATAL: get_stock_list err: {e}")
os._exit(2)
if not u:
log("FATAL: empty universeminiQMT 未连?)")
os._exit(2)
log(f"universe={len(u)}(沪深A股,ETF 本轮未下)")
failed: list[dict] = []
done_counter = {"5m": 0, "15m": 0}
write_progress({
"phase": "init",
"total": len(u),
"done_5m": 0, "done_15m": 0,
"failed_count": 0, "failed": [], "last_stock": "(init)", "pct": 0.0,
})
# ===================== Phase 1 + 1.5: 5m(已下完则整体跳过) =====================
n5r = count_parquet(RAW_5M, "5m")
n5q = count_parquet(QFQ_5M, "5m")
skip_5m = (n5r >= len(u) and n5q >= len(u))
log(f"5m 现状: raw={n5r} qfq={n5q} universe={len(u)}"
f"{'SKIP(已下完)' if skip_5m else 'GO(需下)'} disk_free={disk_free_gb():.1f}GB")
if skip_5m:
log("PHASE 1+1.5 SKIP: 5m 已全量下完(断点续传保留)")
done_counter["5m"] = len(u)
else:
log("PHASE 1: 批量 download_history_data2 5m(成败看后续 get bars,不看 download ret")
nb1, errb1 = batch_download("5m", u)
log(f"PHASE 1 done: 5m batches={nb1} err_batches={errb1}")
log("PHASE 1.5: 逐只 get_market_data_ex 读 5m → 写 raw/qfq parquet")
write_phase("5m", u, RAW_5M, QFQ_5M, MIN_BARS_RESUME_5M, failed, done_counter, "5m")
log(f"PHASE 1.5 done: 5m write done={done_counter['5m']} total_fail={len(failed)}")
time.sleep(SLEEP_BETWEEN_PHASES)
# ===================== Phase 2: 15m 逐只 download+write(替代卡死的批量 download_history_data2 =====================
# Main Agent 实证:全市场批量 download_history_data2('15m', 5201 只一次)卡死 30min 0 产出;
# 但逐只 download_history_data(code,'15m',start,end) 实测稳定(600051/300001/688981/000001 均 3890 bars)。
log(f"PHASE 2: 逐只 download_history_data('15m') + get + write raw/qfq "
f"total={len(u)} disk_free={disk_free_gb():.1f}GB")
phase_15m_per_stock(u, failed, done_counter)
log(f"PHASE 2 done: 15m write done={done_counter['15m']} total_fail={len(failed)} "
f"disk_free={disk_free_gb():.1f}GB")
write_progress({
"phase": "validate_pending",
"total": len(u),
"done_5m": done_counter["5m"], "done_15m": done_counter["15m"],
"failed_count": len(failed), "failed": failed[:FAILED_LIST_CAP],
"failed_truncated": len(failed) > FAILED_LIST_CAP,
"last_stock": "(validate)", "pct": 100.0,
})
# ===================== Phase 3: 校验 =====================
log("PHASE 3: 校验 5m + 15m 双周期")
result = validate(u)
tmp = RESULT_FILE + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump(result, f, ensure_ascii=False, indent=2)
os.replace(tmp, RESULT_FILE)
write_progress({
"phase": f"done:{result['verdict']}",
"total": len(u),
"done_5m": done_counter["5m"], "done_15m": done_counter["15m"],
"failed_count": len(failed), "failed": failed[:FAILED_LIST_CAP],
"failed_truncated": len(failed) > FAILED_LIST_CAP,
"last_stock": "(done)", "pct": 100.0,
})
for period, info in result["periods"].items():
log(f"VERDICT[{period}]={info['verdict']} raw={info['raw_files']} qfq={info['qfq_files']} "
f"bars={info['total_bars']} sample_complete={info['sample_complete_count']}/{info['sample_size']} "
f"failed={info['failed_count']}")
if info["min_datetime"] and info["max_datetime"]:
log(f" range[{period}] {info['min_datetime']} ~ {info['max_datetime']}")
if info["fail_reasons"]:
log(f" fail_reasons[{period}]: {info['fail_reasons']}")
log(f"OVERALL VERDICT={result['verdict']} total_failed={len(failed)}")
sys.stdout.flush()
os._exit(0 if result["verdict"] == "PASS" else 1)
if __name__ == "__main__":
main()