上一篇我们把项目重构成了分层结构,行情躺在 data/raw/ 的 Parquet 文件里。但数据是会「过期」的:今天的回测用截止昨天的数据,明天就差了一天。手动每天开脚本拉数据?三天新鲜感一过必然忘记。本篇讲怎么让数据自己更新自己:增量拉取、幂等写库、定时调度、更新后校验——四个环节组成一条完整的数据管道(Data Pipeline)。
本篇目标
- 能实现增量更新:只拉本地缺失日期之后的数据,不重复下载
- 能做到幂等写库:脚本重跑一次、两次、十次,结果一致
- 能用 Windows 任务计划程序(或 Linux cron)把更新脚本变成每日定时任务
- 能在更新后自动做数据校验:行数、日期连续性、异常值筛查
- 能把更新结果写成日志,早上看一眼就知道昨晚的数据到没到
核心概念
增量更新:只拉缺的
最朴素的下载方式是每次全量拉 2015 年至今——浪费带宽、接口限流、速度慢。增量更新的思路:读本地数据的最大日期,只拉这之后的部分,再拼接。核心是准确找到「本地最后一行」和「远端第一行」的接缝:拉取起点 = 本地最大日期 + 1 天,拼接后 drop_duplicates(subset="date", keep="last") 兜底。
幂等:重跑无害
幂等(Idempotent)是管道工程的第一美德:同一个脚本不管跑几次,落库结果都一样。实现要点有二:其一,接缝处用「先删后写」或 drop_duplicates 处理重叠——网络中断后重跑,重叠部分不会变成两行;其二,脚本内部不维护「只该跑一次」的可变状态,一切以落库数据的现状为准。
划重点:非幂等管道的典型事故——脚本跑到一半断网,写了一半数据;你重跑一次,接缝处日期重叠,行情里出现两天「同一天」,后续所有 rolling 计算全部错位。drop_duplicates(subset=['code','date'], keep='last') 是数据管道的保险丝,宁可多写不可不写。
定时调度:任务计划程序与 cron
Windows 上用「任务计划程序」(Task Scheduler):图形界面建任务,或用 schtasks 命令行。Linux/macOS 用 cron。要点相同:触发器设为「每天收盘后」,如工作日 17:00(A 股 15:00 收盘,留两小时给数据源清算落库);动作指向 python.exe + 脚本绝对路径;起止目录设对(脚本里的相对路径 data/raw/ 依赖工作目录)。
数据校验:更新完自查
更新不是拉完就结束,三道自查:行数合理性(今天该来 1 行,来了 0 行或 3 行都要报警);日期连续性(交易日之间不该有缺日,可用交易日历比对);数值合理性(收盘价 > 0、单日涨跌幅 |x| < 20% 之外的要标记)。校验结果写进日志——第 43 篇会把日志升级成完整的监控体系。
完整代码
本篇依赖:uv pip install pandas numpy(akshare 可选,fallback 已内置)。沿用第 41 篇的目录结构。
# ---------- scripts/update_data.py:完整的数据更新脚本 ----------
import numpy as np
import pandas as pd
from pathlib import Path
from datetime import datetime, timedelta
DATA_DIR = Path("data/raw")
FALLBACK_DIR = Path("data/fallback_seed") # 首次运行的种子数据
def local_max_date(code):
"""读本地 Parquet 的最大日期;无文件返回 None"""
f = DATA_DIR / f"{code}.parquet"
if not f.exists():
return None
df = pd.read_parquet(f)
return pd.to_datetime(df["date"]).max()
def fetch_remote(code, start, end):
"""从数据源拉 [start, end] 的日线。akshare 优先,失败用模拟增长。"""
try:
import akshare as ak
df = ak.stock_zh_a_hist(symbol=code, period="daily",
start_date=start.strftime("%Y%m%d"),
end_date=end.strftime("%Y%m%d"),
adjust="qfq")
df = df.rename(columns={"日期": "date", "收盘": "close"})
return df[["date", "close"]]
except Exception:
# 教学fallback:模拟每天 +0.1% 附近的增长,保证管道可演示
days = pd.bdate_range(start, end)
if len(days) == 0:
return pd.DataFrame(columns=["date", "close"])
rng = np.random.default_rng(abs(hash(code)) % (2**32))
base = 100.0
close = base * np.exp(np.cumsum(rng.normal(0.001, 0.01, len(days))))
return pd.DataFrame({"date": days, "close": close})
def validate(df, code):
"""三道校验,返回告警列表"""
warns = []
if (df["close"] <= 0).any():
warns.append(f"{code}: 存在非正收盘价")
dup = df.duplicated(subset=["date"]).sum()
if dup > 0:
warns.append(f"{code}: 存在 {dup} 行重复日期")
ret = df["close"].pct_change().abs()
n_extreme = (ret > 0.2).sum()
if n_extreme > 0:
warns.append(f"{code}: {n_extreme} 个单日涨跌幅超20%")
return warns
def update(code, end=None):
end = end or datetime.now()
last = local_max_date(code)
if last is None: # 首次:全量拉 2 年
start = end - timedelta(days=730)
new_df = fetch_remote(code, start, end)
merged = new_df
mode = "INIT"
else: # 增量:从本地最大日+1拉起
start = last + timedelta(days=1)
if start > end:
print(f"[{code}] 已是最新,无需更新")
return
new_df = fetch_remote(code, start, end)
if new_df.empty:
print(f"[{code}] 区间无新数据")
return
old_df = pd.read_parquet(DATA_DIR / f"{code}.parquet")
merged = pd.concat([old_df, new_df], ignore_index=True)
mode = "APPEND"
merged["date"] = pd.to_datetime(merged["date"])
merged = (merged.sort_values("date")
.drop_duplicates(subset=["date"], keep="last")
.reset_index(drop=True)) # 幂等保险丝
DATA_DIR.mkdir(parents=True, exist_ok=True)
merged.to_parquet(DATA_DIR / f"{code}.parquet", index=False)
print(f"[{code}] {mode}: 新增 {len(new_df)} 行, 总计 {len(merged)} 行, "
f"最新日期 {merged['date'].max().date()}")
for w in validate(merged, code):
print(" 告警:", w)
if __name__ == "__main__":
update("600519")
# ---------- 验证幂等性:连跑两次,第二次应无新增 ----------
update("600519") # 第一次:INIT 或 APPEND
update("600519") # 第二次:应显示"已是最新"
# ---------- 注册 Windows 计划任务(管理员 PowerShell 执行一次即可) ----------
schtasks /Create /TN "QuantDailyUpdate" /TR "python C:\quant\scripts\update_data.py" /SC WEEKLY /D MON,TUE,WED,THU,FRI /ST 17:00
# 查看与手动触发:
schtasks /Query /TN "QuantDailyUpdate" /V
schtasks /Run /TN "QuantDailyUpdate"
代码逐段解释
- local_max_date:增量更新的「起点探测器」。读本地 Parquet 取 date 列最大值;文件不存在返回 None 触发首次全量。整个增量逻辑的地基就这一个函数。
- fetch_remote:真实源 akshare,失败时 fallback 到模拟数据(保证你在没网/接口变更时也能完整演练管道)。真实项目里 fallback 应该是「报错退出」而不是造数据——教学演示才用模拟,这是两者的边界。
- update 主流程:三分支——本地无文件走 INIT(全量两年)、有文件但已最新走「无需更新」、有缺口走 APPEND。合并后
sort_values + drop_duplicates(keep='last')是幂等的关键:接缝重叠时保留新数据,重跑多少次结果都一样。 - validate:三道校验——非正价格、重复日期、单日 |涨跌幅|>20%。返回告警列表打印出来;生产环境应接入第 43 篇的告警通道而不是只打印。
- schtasks:Windows 计划任务的命令行注册方式——工作日 17:00 触发。
/TR里务必用绝对路径(python 解释器与脚本都要),否则任务在系统默认工作目录下运行,脚本里的相对路径data/raw/会指错地方。
示例输出(示意,首次运行):
[600519] INIT: 新增 505 行, 总计 505 行, 最新日期 2026-09-28
示例输出(示意,立即重跑一次):
[600519] 已是最新,无需更新
「连跑两次、第二次静默」就是幂等性的直观证明。第二天 17:00 计划任务触发时,你会看到 APPEND: 新增 1 行——管道就正式转起来了。
常见坑与小练习
常见坑
- 计划任务的工作目录坑:
schtasks默认工作目录是C:\Windows\System32,脚本里Path("data/raw")全部失效。对策:脚本开头os.chdir(Path(__file__).parent.parent)或全部用绝对路径。 - 时区与「数据未就绪」:17:00 拉不到当天数据很正常(数据源清算延迟)。管道应把「拉到 0 行」当正常分支处理,别让一个 KeyError 炸掉整个任务。
- 增量起点差一天:
start = last + 1天若用自然日而数据源按交易日对齐,碰到周末节假日会拉到空集——代码里new_df.empty的分支必须存在。 - 校验只做不记:告警 print 完就消失,没人回看。至少把校验结果追加写入
logs/update.log(下一章的 logging 正式解决)。 - 多标的更新没有失败隔离:一个标的接口超时,整个循环崩掉,后面的标的全部没更新。多标的循环必须 try/except 包住单个标的,失败记日志继续。
小练习
- 把
update改造成支持标的列表(如["600519", "000858", "300750"]),单个标的失败不中断循环,最后汇总打印成功/失败清单。思路提示:外层 for + try/except,收集
(code, status)元组,循环后统一打印。 - 在 validate 里加「日期连续性」校验:用
pd.bdate_range(首日, 末日)与实际日期列比对,缺失超过 5% 告警。思路提示:
set(expected) - set(actual)得缺失集合;注意 bdate_range 只排周末,不含节假日,所以阈值放宽点。 - 用
schtasks /Create注册一个每 5 分钟触发一次的测试任务,观察日志确认触发,再schtasks /Delete删除——先在安全频率上演练调度,再上生产频率。思路提示:
/SC MINUTE /MO 5;确认能触发后立刻删除,避免骚扰。
本文策略仅用于教学,不构成投资建议,实盘前请充分测试。