【Python量化数据工程实战 #04】每天全量拉要十分钟?增量更新让脚本只拉新数据
本文是「Python 量化数据工程实战」系列第 4 篇。前面三篇我们把数据拉下来、清洗、验证、做特征——全链路就位。但每次跑脚本都"全量重拉 5000 只 × 120 日",10 分钟过去了,进度条还在 30%——这种浪费每天都在发生。真相是:每天真正新增的数据只有 5-50 条/只。本文教你用 SQLite 主键 + 元数据表实现"幂等增量更新",5 只股票从分钟级降到秒级。
本文你将得到什么
- 全量 vs 增量的真实成本对比,理解"为什么每天只需要 5 条/只"
- SQLite PRIMARY KEY 实现自然幂等的写法,重复运行不写脏数据
- meta 元数据表记录每只股票的最后拉取时间,跨进程、跨重启保持状态
- INSERT OR IGNORE vs REPLACE 的取舍,演示两种幂等策略
- 一个可直接复用的 incremental_update.py,5 只股票首次 250 条、后续 0 条
一、问题:每天全量重拉是在浪费什么
很多量化脚本的"日常任务"长这样:
反例:每天全量重拉
for code in all_5000_stocks:
rows = client.stock_history(code, period="d", lt=120) # 120 天
save_to_db(rows)
算一下账:
- 5000 只 × 120 日 = 60 万条数据
- 接口耗时:单只 0.2s,5000 只 = 1000 秒 ≈ 17 分钟
- 实际"今天新增"的:5000 只 × 1 条 = 5000 条
- 浪费比例:99.2%
如果改成"只拉 last_t 之后":
- 5000 只 × 1 条 = 5000 条
- 接口耗时:5-10 秒
- API 额度:5000 次/只 × 1 次/天 = 5000 次(5000 只 × 1 次)
- 省 99% 流量,省 99% 时间
二、解法:SQLite 主键 + 元数据表
增量更新的核心是两个表:
-- 1. 行情主表:PRIMARY KEY (code, t) 保证天然幂等
CREATE TABLE klines (
code TEXT NOT NULL,
t TEXT NOT NULL, -- 日期 YYYY-MM-DD
o REAL, h REAL, l REAL, c REAL, v REAL, pc REAL,
PRIMARY KEY (code, t) -- ← 同一只股票同一天只能存在一条
);
-- 2. 元数据表:记录每只股票拉到哪里了
CREATE TABLE meta (
key TEXT PRIMARY KEY,
value TEXT
);
-- 典型数据:last_pull_at=2026-08-27 10:00:00
PRIMARY KEY 的好处是天然幂等:你用 INSERT OR IGNORE 写一条已存在的 (code, t),数据库会直接跳过、不报错、不写脏数据。再也不用写"先查再插"那种并发不安全的代码。
三、完整实现:5 只股票 × 4 阶段测试
import sqlite3
import os
from datetime import datetime
import pandas as pd
from mairui import Client
client = Client("LICENCE-66D8-9F96-0C7F0FBCD073")
DB_PATH = "kline_incremental.db"
CODES = ["000001", "000002", "600519", "600000", "000858"]
def setup_db():
"""建表(演示用:每次重置 DB)"""
if os.path.exists(DB_PATH):
os.remove(DB_PATH)
with sqlite3.connect(DB_PATH) as conn:
conn.execute('''
CREATE TABLE klines (
code TEXT NOT NULL, t TEXT NOT NULL,
o REAL, h REAL, l REAL, c REAL, v REAL, pc REAL,
PRIMARY KEY (code, t)
)
''')
conn.execute('CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT)')
def pull_full(lt=120):
"""首次全量拉取"""
inserted_total = 0
with sqlite3.connect(DB_PATH) as conn:
for code in CODES:
rows = client.stock_history(code, period="d", dividend="n", lt=lt)
if not rows:
continue
df = pd.DataFrame(rows)
for col in ["o", "h", "l", "c", "v", "pc"]:
if col in df.columns:
df[col] = pd.to_numeric(df[col], errors="coerce")
df["code"] = code
payload = list(df[["code", "t", "o", "h", "l", "c", "v", "pc"]]
.itertuples(index=False, name=None))
cur = conn.executemany(
"INSERT OR IGNORE INTO klines (code, t, o, h, l, c, v, pc) "
"VALUES (?,?,?,?,?,?,?,?)", payload)
inserted_total += cur.rowcount
return inserted_total
def pull_incremental():
"""增量拉取:每只股票只取 t > MAX(t) 的部分"""
with sqlite3.connect(DB_PATH) as conn:
c = conn.cursor()
last_t_map = dict(c.execute(
"SELECT code, MAX(t) FROM klines GROUP BY code"
).fetchall())
inserted_total = 0
for code in CODES:
last_t = last_t_map.get(code)
kwargs = {"period": "d", "dividend": "n", "lt": 5}
if last_t:
kwargs["st"] = last_t.replace("-", "")
rows = client.stock_history(code, **kwargs)
if not rows:
continue
df = pd.DataFrame(rows)
for col in ["o", "h", "l", "c", "v", "pc"]:
if col in df.columns:
df[col] = pd.to_numeric(df[col], errors="coerce")
df["code"] = code
if last_t:
df = df[df["t"] > last_t]
if len(df) == 0:
continue
payload = list(df[["code", "t", "o", "h", "l", "c", "v", "pc"]]
.itertuples(index=False, name=None))
cur = conn.executemany(
"INSERT OR IGNORE INTO klines (code, t, o, h, l, c, v, pc) "
"VALUES (?,?,?,?,?,?,?,?)", payload)
inserted_total += cur.rowcount
return inserted_total
阶段 1:首次全量
setup_db()
n_full = pull_full(lt=120)
print(f"首次写入: {n_full} 条")
真实输出(演示证 50 条/只):
首次写入: 250 条
表内总计: 250 条
各股票覆盖:
000001: 50 条 2025-09-15 ~ 2025-12-01
000002: 50 条 2025-09-15 ~ 2025-12-01
000858: 50 条 2025-09-15 ~ 2025-12-01
600000: 50 条 2025-09-15 ~ 2025-12-01
600519: 50 条 2025-09-15 ~ 2025-12-01
四、阶段 2:再次运行——验证幂等
阶段 2:再次运行相同代码
n_idem = pull_full(lt=120)
print(f"再次灌库: {n_idem} 条 (应为 0)")
真实输出:
再次灌库: 0 条 (应为 0)
PASS: 幂等性 = True
PRIMARY KEY (code, t) 自动保证:第二次全量拉相同的 250 条数据,数据库全部跳过、一行不写。这就是"幂等"——你可以无限次重复运行,结果不会变脏。
五、阶段 3:增量拉取——只取新数据
阶段 3:增量
n_inc = pull_incremental()
print(f"增量新增: {n_inc} 条")
真实输出:
000001: 全部已存在,0 行新增
000002: 全部已存在,0 行新增
600519: 全部已存在,0 行新增
600000: 全部已存在,0 行新增
000858: 全部已存在,0 行新增
增量新增: 0 条
演示证下每天数据静态、不会"出现新日期",所以增量是 0。真实生产证下每天会新增 1-5 条/只(5 只股票约 5-25 条新增),这就是我们要的"只拉新数据"。
六、阶段 4:异常场景——手工插入"未来"数据
阶段 4:手动插入 1 条 2099 年的假数据
with sqlite3.connect(DB_PATH) as conn:
conn.execute('''
INSERT OR REPLACE INTO klines (code, t, o, h, l, c, v, pc)
VALUES ('000001', '2099-01-01', 99.99, 99.99, 99.99, 99.99, 999, 11.61)
''')
n_inc2 = pull_incremental()
print(f"再次增量: {n_inc2} 条 (因 last_t=2099-01-01 已覆盖所有历史)")
真实输出:
插入未来假数据后: 251 条
再次增量: 0 条
表内总计: 251 条
这一步验证了"幂等"在边界场景下也成立——数据库里有一条 2099 年的假数据,MAX(t) 返回 2099-01-01,下次增量会传 st=20990101,接口返回空(未来没数据),脚本优雅退出。
七、INSERT OR IGNORE vs INSERT OR REPLACE:怎么选
-- IGNORE:冲突时跳过(保留最早插入的数据)
INSERT OR IGNORE INTO klines ... PRIMARY KEY (code, t)
-- REPLACE:冲突时替换(保留最新数据)
INSERT OR REPLACE INTO klines ... PRIMARY KEY (code, t)
场景 推荐 原因
历史 K 线(不可变) IGNORE 同一 (code, t) 的历史收盘价不会变,跳过即可
实时行情(会变) REPLACE 当日最新价在持续更新,需要覆盖
复权因子 REPLACE 除权除息日因子会重算
衍生指标 REPLACE 模型计算结果重跑时需覆盖
生产环境的真实写法:K 线表用 IGNORE,日终的"指标表"用 REPLACE,两类表分开放。
八、增量更新的两个边角问题
8.1 复权导致历史重算
如果你用 dividend='y' 拉前复权数据,除权除息日之后的所有历史 bar 都会重算——你昨天的"增量"会变成今天的"全量重写"。
对策:用元数据表记录每只股票的"复权基准日",基准日之前的数据永远不动,基准日之后的全量重写一次,之后再增量。
8.2 退市 / 停牌
如果某只股票今天退市了:
- MAX(t) 会卡在最后交易日
- 接口返回空,脚本正常退出(不报错)
- 但 meta 表里没有标记"已退市",下次还要查这只
- 长期会让 meta 越来越大、接口白查
对策:在 meta 表加一列 status(active/delisted),退市时人工标 delisted,增量脚本只查 status='active' 的股票。
九、把增量更新接进定时任务
把 pull_incremental() 包成命令行:
run_incremental.py
if name == "main":
setup_db() if "--init" in sys.argv else None
n = pull_incremental()
print(f"[{datetime.now()}] 增量新增 {n} 条")
然后用 cron(Linux)或 Task Scheduler(Windows)每天 16:00 跑:
crontab
0 16 * * 1-5 python /opt/quant/run_incremental.py >> /var/log/quant.log 2>&1
完整链路:全量首跑 → 每日增量 → 跨年重置 → 失败重试,覆盖量化数据工程的"日常运维"全场景。
十、小结
增量更新不是"优化",是量化数据工程的"基本姿势":
- PRIMARY KEY 幂等:永远不用担心重复运行写脏
- 元数据表记进度:跨进程、跨重启不丢状态
- 5 只股票首跑 250 条、后续 0 条:真实生产环境下每天只需 5-50 条/只
- IGNORE/REPLACE 各有用场:历史用 IGNORE、实时用 REPLACE
下一篇我们讲多源数据合并——把 K 线、财报、公告对齐到统一时间轴,跨表做"事件驱动策略"。
注意:本文为技术分享,所有接口调用均使用官方演示证书返回的演示数据(需替换为自己的实际证书),不构成任何投资建议。市场有风险,决策需谨慎。
代码与文档:更多 SDK 用法与接口文档可参考 https://github.com/MaiRuiApi

浙公网安备 33010602011771号