【Python量化系统工程化 #06】100只股票跑到一半进程崩了?进度条+JSON检查点+自动续跑不丢活
痛点开场
上篇我们让网络抖动不再"一报错就全盘失败"——tenacity 把 503 自动重试,4xx 直接跳过。
但有个更隐蔽的杀手——进程崩了。
真实场景:
- 你凌晨 2 点跑一个"全市场 5000 只股票拉 250 日 K 线"任务
- 跑到第 4237 只时,Windows 自动更新重启了你的机器
- 你早上到公司——任务丢了,要从头来
或者更糟:
- SSH 误关,远程 Python 进程被 kill
- OOM 被系统强杀
- 同事误按 Ctrl+C
- 停电
这些都是"非业务错"的工程灾难。重试解决不了——因为重试意味着程序还在;进程没了,retry 也就没了。
本文解决的就是:让任务在任意时刻被中断后,能从中断的位置续跑,而不是从 0 重来。
本文你将得到什么
- 一个真正能"续跑"的工程方案:JSON 检查点 + tqdm 进度条 + 重启自动识别
- 三轮实测演示:第 1 轮崩溃 @ 35/100 → 第 2 轮从 35/100 自动续 → 跑完 98/100 入库
- 检查点的保存时机与失败不入检查点两个关键陷阱
- 真实 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 与接口文档)。

浙公网安备 33010602011771号