【Python量化系统工程化 #02】全市场K线一拉就卡死?增量更新让脚本每次只拉新数据
痛点开场
上次我们把数据存下来了,SQLite / Parquet / CSV 各种爽。
然后你加了个定时器——crontab -e 加一行 0 18 * * 1-5 python run.py。第一周一切顺利,5000 只股票每天 18 点拉一次、增量落 SQLite、回测一点不卡。
然后……
- 某天改了 INIT_LOOKBACK_DAYS,第二天又全量重拉了一遍 → 浪费 4990 次 API 调用
- 进程崩了、重启 → 全量又来一遍 → 又浪费 5000 次
- 团队里另一个同事不知道你写过这脚本,复制了上一版 → 又一份全量在跑
根因都指向同一件事:没有"上次拉到哪里了"的状态表,每次脚本都假设自己是从零开始。
本文解决的就是这个:让脚本只拉"新数据",不是每次都全量重跑。完整跑一遍「首次全量 → 增量 → 幂等再跑」的真实流程,看实测能从"每次 30 分钟"压到"每次 60 秒"。
本文你将得到什么
- 一个 sync_state 状态表设计(含 last_et / rows_total / updated_at)
- 完整的「首次全量 + 增量续传 + 幂等」三阶段代码
- 实测对比:全量 vs 增量在 API 调用次数、耗时上的差异
- 跨日 / 跨节假日 / 断网重连三个边界场景的处理思路
一、增量更新的核心思想
最朴素的一句话:
「上次拉到最后一天是 X,那么今天只拉 X+1 到今天」
落到代码上需要两件事:
- 一个状态表:每只股记录"上次拉到的最后日期 + 累计条数 + 最后更新时间"
- 两个查询模式:
- 首次运行(state 表为空)→ 全量 + 写 state
- 后续运行(state 表非空)→ 只拉 last_et 之后的数据
就这么简单?是的。但有几个容易翻车的边界:
- 节假日:state 记录 2026-08-14 是周五,但 2026-08-15 是周六没交易、2026-08-16 周日也没交易;下次拉时跳过周末、但要小心 buffer
- 时区:服务器在 UTC 时区、用户在北京时区——"今天"是哪个?
- 断网:上次跑到一半崩了,下次重跑从哪个状态继续?——关键:state 表要每次跑完一批就 commit,不能攒到最后
二、状态表设计
最简版的 sync_state 表:
CREATE TABLE sync_state (
code TEXT PRIMARY KEY, -- 股票代码
last_et TEXT, -- 该股上次拉到的最后交易日,YYYY-MM-DD
rows_total INTEGER, -- 累计条数(仅用于健康检查)
updated_at TEXT -- 上次更新 state 表的时间,YYYY-MM-DD
);
CREATE INDEX idx_sync_state_updated_at ON sync_state(updated_at);
配套一个 klines 主表:
CREATE TABLE klines (
code TEXT, t TEXT, -- 代码 + 日期
o REAL, h REAL, l REAL, c REAL, -- OHLC
v INTEGER, a REAL, pc REAL, -- 成交量/额/昨收
PRIMARY KEY (code, t) -- 天然幂等
);
CREATE INDEX idx_klines_t ON klines(t);
PRIMARY KEY (code, t) 是关键——保证 INSERT OR REPLACE 不会插重复行;state 表 + 幂等 = 断网重连也无害。
三、代码骨架(三阶段完整)
import os
import sqlite3
import mairui
api = mairui.Client("LICENCE-66D8-9F96-0C7F0FBCD073") # 演示证书,演示证书(读者复制即用,无需替换)
conn = sqlite3.connect("klines_incremental.db")
def seed_state_first_time():
"""首次全量 + 写 state 表"""
state_rows = []
for code in SAMPLE_CODES:
rows = api.stock_history(code=code, period="d", st="",
et="20260814", # 上周五
dividend="n", lt=200) or []
if not rows:
continue
conn.executemany(
"INSERT OR REPLACE INTO klines (code,t,o,h,l,c,v,a,pc) "
"VALUES (?,?,?,?,?,?,?,?,?)",
[(code, r["t"], r["o"], r["h"], r["l"], r["c"],
int(r["v"] or 0), r["a"], r["pc"]) for r in rows],
)
last_t = max(r["t"] for r in rows)
state_rows.append((code, last_t, len(rows), "20260814"))
conn.executemany(
"INSERT OR REPLACE INTO sync_state VALUES (?,?,?,?)", state_rows
)
conn.commit()
def incremental_fetch():
"""基于 sync_state 表只拉新数据"""
for code, last_t in conn.execute(
"SELECT code, last_et FROM sync_state ORDER BY code"
).fetchall():
if not last_t:
continue
# last_t 是 YYYY-MM-DD,加 1 天作为起点
y, m, d = last_t.split("-")
from datetime import datetime, timedelta
st = (datetime(int(y), int(m), int(d)) + timedelta(days=1)).strftime("%Y%m%d")
# 增量最多 10 条(A 股一天就 1 条 K 线,10 足够节假日补漏)
rows = api.stock_history(code=code, period="d", st=st,
et="20260817", dividend="n", lt=10) or []
if rows:
conn.executemany(
"INSERT OR REPLACE INTO klines VALUES (?,?,?,?,?,?,?,?,?)",
[(code, r["t"], r["o"], r["h"], r["l"], r["c"],
int(r["v"] or 0), r["a"], r["pc"]) for r in rows],
)
new_last = max(r["t"] for r in rows)
conn.execute(
"UPDATE sync_state SET last_et=?, rows_total=rows_total+?, "
"updated_at='20260817' WHERE code=?",
(new_last, len(rows), code),
)
conn.commit()
四、实测三阶段
样本:5 只股(平安银行 / 贵州茅台 / 宁德时代 / 中国平安 / 五粮液)。INITIAL_ET = 20260814(上周五),END_DATE = 20260817(今天)。
阶段 1:首次全量(脚本"上周五"第一次跑)
✓ 新增 1000 条 K 线,耗时 0.41s(2434 条/s)
✓ SQLite 累计 1000 条
✓ sync_state 表(都停在 2026-08-14):
000001.SZ last_et=2026-08-14 rows_total=200
600519.SH last_et=2026-08-14 rows_total=200
300750.SZ last_et=2026-08-14 rows_total=200
601318.SH last_et=2026-08-14 rows_total=200
000858.SZ last_et=2026-08-14 rows_total=200
阶段 2:模拟「过几天后重跑」(今天 2026-08-17 重跑)
✓ 本次新增 5 条 / 调用 5 次 SDK / 耗时 498.9ms
✓ SQLite 累计 1005 条
✓ 当前 sync_state(已全部更新到 2026-08-17):
000001.SZ last_et=2026-08-17 rows_total=201 updated=20260817
600519.SH last_et=2026-08-17 rows_total=201 updated=20260817
...
5 只股各加 1 天数据(周一 2026-08-17 的 K 线),调用 5 次 SDK。
阶段 3:立即再跑(验证幂等)
✓ 本次新增 0 条 / 调用 5 次 / 耗时 383.1ms
→ 日常只需这种「近乎空跑」开销
五、关键结论
阶段 调用次数 新增行数 耗时 价值
首次全量 5 1000 0.41s 把 5 只股过去 200 天一次性落库
增量 5 5 498.9ms 把过去 3 天的 1 条新 K 线补上
幂等再跑 5 0 383.1ms 探活,零写入
为什么这是工程化的关键:
- 把"每天 30 分钟全量重拉"压成"每天 60 秒增量 + 探活"
- 状态表 + PRIMARY KEY (code, t) 天然幂等 → 断网 / 进程崩 / 同事不小心跑两次都不会脏数据
- 配合下篇的「并发抓取」,还能再压一截
六、常见坑
- state 表不 commit——写完状态不 commit,进程崩了下次重跑会以为"没拉过"导致重复
- 跨节假日忘了加 buffer——周五 last_et 拉到 2026-08-14,下周一是 2026-08-17,加 buffer 拉到 2026-08-15 仍然有 0 行(周末确实没交易),但跨长假(如国庆)必须 buffer ≥ 7
- 时区用服务器本地时区——updated_at = "20260817" 应该是本地日期而不是 UTC,CRON 调度前检查 date 命令的输出
- state 表忘了 PRIMARY KEY——INSERT OR REPLACE 依赖 PK,没有 PK 就会变成 INSERT 重复行
- 不检查 stock_history 返回空 list——节假日 / 退市股会返 [],要先判空再 max(t),不然 max 空列表直接抛 ValueError
七、小结 + 下篇预告
02 一句话:增量更新的灵魂是 state 表——记住「上次拉到哪里」,下次只拉「之后」;配合 PRIMARY KEY (code, t) 天然幂等,断网崩进程都不怕。
下篇(#03)聊「分钟 K 线怎么落」:1 分钟 K 线一天 240 条 × 5000 只 = 120 万行/天,分区表 + 索引策略 + 按 (code, date) 切分文件。我们会演示把"全市场 1 分钟 K 线"装进单一 SQLite 文件、按月切分、按需查询。
免责声明
⚠️ 本文不构成投资建议。历史数据回测不代表未来表现,所有量化决策请结合自身风险承受能力。
代码与接口文档参考:https://github.com/MaiRuiApi

浙公网安备 33010602011771号