【魔码量化工程实战进阶 #05】增量更新与去重:每天只拉新发生的那点事
【魔码量化工程实战进阶 #05】增量更新与去重:每天只拉"新发生的那点事"
入门系列第 04 篇(M04)提过"增量更新只拉新数据",但只给了一行
INSERT OR IGNORE。本篇把它讲透:增量更新的三大支柱(水位线 / 唯一键 / 迟到修正),以及为什么"历史数据拉一次就锁死"是量化里最隐蔽的坑。
本文你将得到什么
- 水位线(watermark)怎么选,才不会每天全量重拉浪费额度
- 唯一键 + UPSERT 让"重复写"变成"无操作"
- 一个反直觉真相:复权数据会迟到修正历史,所以历史不能只拉一次
- SQLite 增量更新的可跑代码(含"全量首拉 / 增量续拉 / 复权回补"三态)
一、痛点:全量重拉与重复写入
新手每天盘后都跑一次"全量拉 5000 只 × 全年日 K"。两个代价:
- 额度爆炸:免费证根本扛不住每天全量重拉;
- 重复写入:没有去重,同一天一行变两行,策略算出来的指标全歪。
正确做法:首拉一次全量建底,之后每天只拉"新发生的那点事"。
二、工程方案:三支柱
2.1 水位线(watermark)
水位线 = "我本地已经有什么,从哪之后才是新的"。对日 K,水位线就是本地 max(trade_dt)。增量拉取只问"比水位线更新的部分":
# 首拉:不限时间,建底
rows = get_history(code, st="20000101", et=today)
# 增量:只拉水位线之后
wm = max_local_date(code) # 本地水位线
rows = get_history(code, st=wm, et=today)
2.2 唯一键 + UPSERT
光按 code 去重不够——同一只不同日期必须都能写。唯一键必须是 (code, trade_dt)。写入分两种语义,别混:
- 纯跳过(
INSERT OR IGNORE):已存在就不动。适合"增量只补新行、绝不改历史"的场景。 - 幂等覆盖(UPSERT):已存在就用新值覆盖。适合"复权回补修正历史"——交易所改了某天收盘价,你要把旧的改成新的。
CREATE UNIQUE INDEX uniq_code_dt ON kline(code, trade_dt);
-- SQLite:冲突则覆盖(适配复权回补)
INSERT INTO kline VALUES (...) ON CONFLICT(code,t) DO UPDATE SET c=excluded.c;
-- MySQL 等价写法
INSERT INTO kline VALUES (...) ON DUPLICATE KEY UPDATE c=VALUES(c);
2.3 迟到修正(最隐蔽的坑)
A 股分红、送股、拆细发生后,券商/交易所会回补修正历史 K 线(尤其是后复权价)。也就是说:你三个月前拉的某只 2024-01-05 后复权收盘价,今天可能已经变了。如果"历史只拉一次就锁死",你的回测用的是错误历史。
工程处理:每天增量拉取时,额外回补最近 N 天(如 5 个交易日)的数据用 UPSERT 覆盖——这几天最可能被修正,成本极低(5 天 × 5000 只远小于全量)。
三、可跑代码(SQLite 增量三态,零 SDK;演示数据用本地生成)
import sqlite3, random
random.seed(0)
conn = sqlite3.connect("inc.sqlite")
conn.execute("""CREATE TABLE IF NOT EXISTS kline(
code TEXT, t TEXT, c REAL, PRIMARY KEY(code, t))""")
def upsert(rows):
# 幂等 UPSERT:冲突(同 code+t)则覆盖,无冲突则插入
# 既保证不重复写第二行,又能覆盖复权回补修正后的值
conn.executemany(
"INSERT INTO kline VALUES (?,?,?) "
"ON CONFLICT(code,t) DO UPDATE SET c=excluded.c", rows)
conn.commit()
# 态1:首拉全量建底(模拟 3 只 × 5 天)
base = [(f"00000{i}.SZ", f"2024-03-{d:02d}", 10+i) for i in range(3) for d in range(1,6)]
upsert(base)
print("首拉后行数:", conn.execute("SELECT COUNT(*) FROM kline").fetchone()[0]) # 15
# 态2:增量续拉(新的一天 03-06)
inc = [("000000.SZ","2024-03-06",11),("000001.SZ","2024-03-06",12),("000002.SZ","2024-03-06",13)]
upsert(inc)
print("增量后行数:", conn.execute("SELECT COUNT(*) FROM kline").fetchone()[0]) # 18
# 态3:复权回补(03-05 的价被修正,UPSERT 覆盖,行数不变)
revision = [("000000.SZ","2024-03-05",99)] # 交易所回补修正
upsert(revision)
print("回补后行数:", conn.execute("SELECT COUNT(*) FROM kline").fetchone()[0]) # 仍 18
print("000000.SZ 03-05 修正后价:", conn.execute(
"SELECT c FROM kline WHERE code='000000.SZ' AND t='2024-03-05'").fetchone()[0]) # 99
运行后你会看到:
首拉后行数: 15
增量后行数: 18
回补后行数: 18 # 行数不变,说明去重生效,没有重复写
000000.SZ 03-05 修正后价: 99 # UPSERT 把旧值覆盖成了修正值
upsert 统一用 ON CONFLICT(code,t) DO UPDATE——所以既保证重复的不写第二行(行数稳在 18),又能把复权回补的旧值覆盖成新值(99)。若你只要"纯跳过"语义、绝不允许改写历史,把这句换成 INSERT OR IGNORE 即可。
四、原理深挖:四个真实坑
- 水位线选错字段:有人用"本地行数"当水位线,结果中途删了一行就误判要全量。水位线必须是单调的业务日期
max(t),与行数无关。 - 唯一键缺
t:只(code)唯一 → 同一只每天互相顶掉,只剩一行。必须(code, t)。 - 历史锁死:如 2.3 所述,后复权历史会被交易所回补。固定"每周回补最近 5 个交易日"是低成本保险。
- 增量边界重叠:
st=wm会和上次末日重叠,靠INSERT OR IGNORE/UPSERT兜住,重叠不报错、不重复。
五、小结
增量更新的本质是用"水位线"避免全量重拉、用"唯一键 + UPSERT"避免重复写、用"定期回补最近 N 日"对抗复权迟到修正。三者合一,你的本地库每天只动"新发生的那点事",额度省 90%+,历史还能保持正确。
水位线 + 增量能让你用最小配额维护"全市场日 K";但要维护"全市场分钟 K 线"这种量级,回补与存储压力会指数上升——这正是魔码量化 Pro 包(完整历史分钟 K 线)要解决的。详见文末。
免责声明:本文所有示例数据仅用于接口演示,不构成任何投资建议;市场有风险,投资需谨慎。
系列持续更新中。 想要亲手跑通上面的代码?前往 魔码证书申请页 免费领取你的专属证书,复制即用、按次计费、稳定可用。
