【Python量化系统工程化 #06】100只股票跑到一半进程崩了?进度条+JSON检查点+自动续跑不丢活

痛点开场

上篇我们让网络抖动不再"一报错就全盘失败"——tenacity 把 503 自动重试,4xx 直接跳过。

但有个更隐蔽的杀手——进程崩了。

真实场景:

  1. 你凌晨 2 点跑一个"全市场 5000 只股票拉 250 日 K 线"任务
  2. 跑到第 4237 只时,Windows 自动更新重启了你的机器
  3. 你早上到公司——任务丢了,要从头来

或者更糟:

  • SSH 误关,远程 Python 进程被 kill
  • OOM 被系统强杀
  • 同事误按 Ctrl+C
  • 停电

这些都是"非业务错"的工程灾难。重试解决不了——因为重试意味着程序还在;进程没了,retry 也就没了。

本文解决的就是:让任务在任意时刻被中断后,能从中断的位置续跑,而不是从 0 重来。

本文你将得到什么

  1. 一个真正能"续跑"的工程方案:JSON 检查点 + tqdm 进度条 + 重启自动识别
  2. 三轮实测演示:第 1 轮崩溃 @ 35/100 → 第 2 轮从 35/100 自动续 → 跑完 98/100 入库
  3. 检查点的保存时机与失败不入检查点两个关键陷阱
  4. 真实 SDK 套用同一框架,10 只样本全部入库

一、为什么需要 checkpoint

最直觉做法——不用 checkpoint,反正崩了重跑。

代价:

  • 100 只股票 × 1.5s/只(含 503 重试)= 150 秒
  • 崩在 35 只 = 150 秒全白费
  • 一天崩 3 次 = 重新花半天时间

checkpoint 方案:

  • 每完成 5 只把"已完成列表"落 JSON
  • 重启时读 JSON,跳过已完成的
  • 35 只崩了 = 从 36 只继续,只多花 1.5 秒

回报:崩一次省 95% 时间。

二、检查点设计:JSON 而非数据库

最简单的检查点是 JSON 文件:

import json
import time
from pathlib import Path

class Checkpoint:
"""JSON 状态点:记录已完成的股票 + 元数据"""

def init(self, path: Path, total: int):
self.path = path
self.total = total
if path.exists():
data = json.loads(path.read_text(encoding="utf-8"))
self.done_codes: set[str] = set(data.get("done_codes", []))
self.started_at: str = data.get("started_at", "")
self.restart_count: int = data.get("restart_count", 0)
else:
self.done_codes = set()
self.started_at = time.strftime("%Y-%m-%dT%H:%M:%S")
self.restart_count = 0

def is_done(self, code: str) -> bool:
return code in self.done_codes

def mark(self, code: str) -> None:
self.done_codes.add(code)
self.save()

def save(self) -> None:
data = {
"started_at": self.started_at,
"updated_at": time.strftime("%Y-%m-%dT%H:%M:%S"),
"total": self.total,
"done_count": len(self.done_codes),
"done_codes": sorted(self.done_codes),
"restart_count": self.restart_count,
}
self.path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")

为什么是 JSON 不是 SQLite?

  • 检查点是元数据,不是业务数据
  • 业务数据可以落 SQLite,但"任务进度"是临时状态——崩了能删
  • JSON 简单、可读、可 diff、方便人肉排查

restart_count 字段的价值:观察任务稳定性。如果某任务一直 restart_count 上升,说明任务可能在固定位置崩(比如某只股票服务端返回特殊状态导致本地段错误),人工排查时第一线索就是它。

三、保存时机:每 5 只 save 一次

不是每只都 save(太频繁、IO 开销大),也不是 100 只才 save(崩了丢 50%)。

from tqdm import tqdm

checkpoint_every = 5 # 每 5 只落一次
completed_since_save = 0

codes = [...] # 100 只
cp = Checkpoint(Path("_checkpoint.json"), total=len(codes))
remaining = [c for c in codes if not cp.is_done(c)] # 关键:跳过已完成

with tqdm(remaining, desc="拉取", unit="只") as pbar:
for code in pbar:
try:
rows = fetch_with_retry(code)
db.insert(code, rows)
except Exception:
continue # 单只失败不入库,也不入 checkpoint(下次重试)
cp.mark(code)
completed_since_save += 1
if completed_since_save >= checkpoint_every:
cp.save()
completed_since_save = 0

cp.save() # 末尾再 save 一次

保存频率经验值:

  • 任务粒度 < 1 秒:每 10 只 save 一次
  • 任务粒度 1–10 秒:每 5 只 save 一次 ← 本例
  • 任务粒度 > 30 秒:每 1 只 save 一次

核心权衡:save 的成本 vs 崩了丢的工作量。5 只 × 1 秒 ≈ 5 秒工作量,丢失 5% 是可接受的;丢失 50% 不能接受。

四、最关键的陷阱:失败不入 checkpoint

for code in remaining:
try:
rows = fetch_with_retry(code)
except Exception as e:
tqdm.write(f"{code}: 失败 {e}(不计入 checkpoint,下次重试)")
continue # ⚠️ 关键:continue 不 mark

db.insert(code, rows)
cp.mark(code) # ⚠️ 只在成功后才 mark

为什么失败不入 checkpoint?

如果某只股票代码拼错(比如 6000505.SH),永远拉不到数据。如果把它加入 done_codes,下次启动会永久跳过它——整个任务永远少 1 只。

正确语义:done_codes 是"已成功入库的",不是"已尝试的"。

五、崩溃模拟:第 1 轮跑到 35 只主动 raise

crash_after = 35 # 仅用于演示
for idx, code in enumerate(remaining):
if idx == crash_after:
raise SystemExit(f"模拟进程崩溃 @ {code}")
...

$ python main.py
[第 1 轮] 从 0 开始,跑到崩溃……
拉取: 35%|███▌ | 35/100 [00:00<00:01, 56.28只/s]
→ 进程崩溃:模拟进程崩溃 @ 600035.SH(已完成 35 只)
# 注:崩溃 _checkpoint.json 已自动保存,下次重启从 36 只起

重启:

$ python main.py
[续跑] 检测到 checkpoint,已完成 35/100 只,历史重启 1 次
拉取: 100%|██████████| 65/65 [00:00<00:00, 71.78只/s]

[汇总] SQLite 入库验证:
入库股票数 = 98, 总行数 = 24500

六、tqdm 的正确打开方式

tqdm 是个神奇的库,会在每次循环里重写同一行显示进度。如果直接 print 会被进度条覆盖混乱:

错误:print 散落到进度条里

for code in tqdm(codes):
if error:
print(f"{code}: 失败") # 会被进度条覆盖
fetch(code)

正确:用 tqdm.write 输出非进度区文字

for code in tqdm(codes):
if error:
tqdm.write(f"{code}: 失败") # 不会污染进度条
fetch(code)

进阶:pbar.set_postfix 显示实时统计

pbar = tqdm(sample, desc="SDK 拉取")
for code in pbar:
pbar.set_description(f"{code}") # 进度条描述替换
pbar.set_postfix(入库=count_ok) # 右侧实时统计
fetch_and_save(code)

输出形如:600519.SH: 50%|██████████ | 5/10 [00:00<00:00, 12.5只/s, 入库=5]

七、真实 SDK 套用:3 → 10 只样本验证

真实工程的 checkpoint 同样适用——以下为简化演示(10 只样本):

from mairui import Client
from tqdm import tqdm

api = mairui.Client("LICENCE-66D8-9F96-0C7F0FBCD073") # 已用演示证书

conn = sqlite3.connect("kline.db")
conn.execute("CREATE TABLE IF NOT EXISTS kline_real (code TEXT, t TEXT, c REAL, "
"PRIMARY KEY(code, t))")

codes = ["600519.SH", "000001.SZ", "000002.SZ", "600000.SH", "601318.SH",
"000858.SZ", "601398.SH", "600036.SH", "000333.SZ", "600276.SH"]

ok = 0
with tqdm(codes, desc="SDK 拉取", unit="只") as pbar:
for code in pbar:
pbar.set_description(f"{code}")
try:
rows = api.stock_history(code=code, period="d", st="", et="20260817",
dividend="n", lt=10)
if rows:
conn.executemany(
"INSERT OR IGNORE INTO kline_real (code, t, c) VALUES (?, ?, ?)",
[(code, r["t"], r["c"]) for r in rows]
)
conn.commit()
ok += 1
pbar.set_postfix(入库=ok)
except Exception as e:
pbar.set_postfix(失败=str(e)[:30])
conn.close()

实测 入库 10/10(600519/000001/000002 等 10 只蓝筹股)。

八、常见坑

8.1 checkpoint 文件被 git 跟踪

.gitignore 必须忽略 _checkpoint.json:

_checkpoint.json
*.db

否则 commit 时把进度点也提交,团队其他成员拉下来直接"完成了 35/100",永远续不上。

8.2 checkpoint 文件写在网络盘

危险:网络盘写失败可能被吞

cp.save() # 网络中断 → 静默失败 → 下次启动丢进度

安全:写本地、传对象存储、定期 back up

8.3 save 用 pickle/json.dump 序列化时间

错误:序列化"什么时候开始"用 time.time()(浮点秒)

data = {"started_at": time.time()} # 跨平台不可读

正确:用 ISO 8601 字符串

data = {"started_at": time.strftime("%Y-%m-%dT%H:%M:%S")}

8.4 done_codes 用 list 而非 set 性能差

1000 只股票时,list 的 in 操作 O(n);

set 的 in 是 O(1)

创建时一定要用 set

self.done_codes: set[str] = set(data.get("done_codes", []))

8.5 漏掉"全部完成清理 checkpoint"

if len(cp.done_codes) == cp.total:
cp.cleanup() # 全部跑完才删,否则下次启动误判为"上次崩了"

九、小结 + 下篇预告

06 一句话:崩了就崩了——只要把"已完成"持久化,重启时从断点续跑比从头来快 20 倍。

下篇(#07)我们换一个角度:怎么让脚本自动定时跑(每天 9:30 拉一次数据)?schedule / cron / Airflow 哪个更适合个人量化?我们会演示单机版的最轻方案。

免责声明

本文所有代码与数据仅用于量化工程方法演示,不构成任何投资建议,亦不构成收益承诺。投资有风险,决策需谨慎。


下篇预告:【Python量化系统工程化 #07】跑一次还行,每天定时怎么搞?schedule / cron / Airflow 三选一

参考代码

代码与更多模板已在 GitHub 开放:https://github.com/MaiRuiApi 仓库 SDK 与配套文章(README 中获取 SDK 与接口文档)。

posted @ 2026-09-17 19:52  量化分析码农  阅读(10)  评论(0)    收藏  举报