简介:基于Baostock金融数据接口的K线数据自动化下载与本地存储工具,面向股票分析师、量化研究者及普通投资者,解决多市场指数与A股全量股票历史K线数据获取不便的问题。工具通过Python脚本调用Baostock接口,支持上证指数、深证成指、沪深300、创业板指等主要指数的日K线与5分钟K线数据抓取,同时覆盖A股所有上市股票的对应周期数据,并将结果自动保存至本地,便于后续分析。资源包共4个文件,以main.py主脚本为核心,配套README.md说明文档、说明文件txt及附赠资源docx,整体约36KB,结构精简,适合直接参考或二次修改。已有146人学习下载,适用于搭建个人行情数据仓库、策略回测前的数据准备以及入门金融数据接口调用等场景。
1. 先把话说清楚:这个工具解决的是“要数据时永远差一截”的问题
做量化回测或者指数分析的人,大多经历过这种场景:想验证一个多因子策略,打开行情软件发现本地只有日线,5分钟数据要手动补,补到一半又断网;想拉全市场5000多只股票的K线,跑了一整夜,第二天发现少了几百只;更麻烦的是,不同来源的数据口径还不一样,前复权、后复权、不复权混在一起,回测结果根本没法对齐。基于Baostock金融数据接口的这套下载与本地存储方案,就是为了把这些零散的痛点收拢成一个可重复执行的工程化流程。它会覆盖上证指数、深证成指、沪深300、创业板指等主要市场指数,以及A股全部上市股票的日K线和5分钟K线历史数据,最终落到本地目录和压缩包里,后续做因子计算、复盘、回测都能直接从本地读取,不再依赖网络。
这套东西适合谁?适合正在做个股或指数策略研究的从业者,也适合刚接触量化、不想在数据采集上反复折腾的新手。Baostock本身是免费接口,没有授权费,也不限制个人研究用途,但接口的调用方式偏底层,需要自己处理会话、字段、增量更新和容错。这篇文章就把这些脏活讲透,从接口原理到可复现的脚本,最后到避坑经验,一次讲完。
2. 原理与选型:Baostock的数据口径和K线接口参数
2.1 登录会话、交易日历与数据边界
Baostock的API是一个Python库,使用时先调用bs.login()建立会话,结束再bs.logout()。这个会话不需要账号密码,但每次脚本运行都应该显式登录和退出,不要想着一次登录长期挂着。保持长会话在很多情况下会触发服务端断开连接,之后所有查询返回错误码,但你的脚本不会主动报错,只会返回空DataFrame,最后写进本地文件的全是空表,这个坑我在实践中碰到过不止一次。
交易日历方面,Baostock提供query_trade_dates(start_date, end_date),返回字段是calendar_date和is_trading_day。在规划本地存储之前,我强烈建议先把交易日历下载下来存一份,之后做数据完整性校验全靠它。比如沪深300成分股的日K线缺了某一天,你要能判断这一天到底是停牌还是非交易日,否则很容易把停牌误判成缺失。
数据边界也要提前想清楚。Baostock对单次查询的历史深度有内部限制,拉全市场个股的时候不能无限往回追溯。实际处理中,我会把起止日期按年分段,每段单独查询,避免一次请求数据量过大被截断或超时。比如5分钟K线,单只股票一年的数据量就有上万行,全市场叠加起来对内存和磁盘的压力都不小,分段拉取是最稳妥的方案。
2.2 日K与5分钟K线的字段差异和频率参数
日K线查询用query_history_k_data_plus,核心参数是fields、start_date、end_date、frequency和adjustflag。常见字段包括:
| 字段 | 含义 | 备注 |
|---|---|---|
| date | 交易日期 | 格式YYYY-MM-DD |
| code | 证券代码 | 带交易所前缀,如sh.600000 |
| open / high / low / close | 开高低收 | 浮点数 |
| preclose | 昨收 | 可用于计算收益率 |
| volume / amount | 成交量 / 成交额 | volume单位是股,amount单位是元 |
| turn | 换手率 | 百分比 |
| tradestatus | 交易状态 | 1表示正常交易 |
| pctChg | 涨跌幅 | 百分比 |
| isST | 是否ST | 1是0否 |
| adjustflag | 复权标记 | 1后复权、2前复权、3不复权 |
adjustflag是很多人在初始化数据时最纠结的参数。做回测通常用前复权,因为价格连续;做事件分析或分红统计用后复权更合理;做技术指标拟合用不复权也能接受。我的习惯是本地分别存一份不复权和一份前复权,不复权作为原始底稿,前复权作为计算层,这样后续切换口径不需要重新下载。
5分钟K线调用方式类似,frequency="5"即可。但要注意,5分钟数据的字段和日K不同,它没有preclose、turn、tradestatus这些日频衍生字段,只有date、time、code、open、high、low、close、volume、amount、adjustflag。其中time字段的格式是YYYYMMDDHHMMSSsss,毫秒部分通常是000,解析时要用datetime.strptime(row["time"], "%Y%m%d%H%M%S%f")这类方式处理,不能直接当字符串截取。
2.3 常用指数代码映射与全市场股票列表获取
指数代码在Baostock里是带交易所前缀的字符串,这个前缀不能省。主要指数映射如下:
| 指数名称 | Baostock代码 |
|---|---|
| 上证指数 | sh.000001 |
| 深证成指 | sz.399001 |
| 沪深300 | sh.000300 |
| 创业板指 | sz.399006 |
| 上证50 | sh.000016 |
| 中证500 | sh.000905 |
注意上证指数是sh.000001,和A股第一只股票sh.600000完全不一样;创业板指是sz.399006,不是sz.300xxx开头的个股。写脚本时建议把这些映射关系放进字典,避免每次手敲出错。
全市场股票列表通过query_all_stock(day)获取,参数day是日期字符串。返回值里有一个tradeStatus字段,1表示当日正常交易,0表示停牌或退市。如果只取当日可交易股票,会漏掉历史退市和长期停牌的标的,后面做全市场回测时样本就有偏差。更好的方式是维护一个静态股票池,每天增量更新,把所有出现过的代码都保存下来,再逐个拉历史K线。这样即使某只股票后来退市,历史数据也完整。这一点我在后面避坑章节里还会展开。
3. 自动化下载与本地存储:从指数到全市场股票的落地脚本
3.1 目录规划与文件命名约定
本地目录规划是我的习惯动作,它决定了后面所有脚本读数据时逻辑是否清晰。我常用结构是:
data/ ├── index/ │ ├── daily/ │ └── minute_5/ ├── stock/ │ ├── daily/ │ └── minute_5/ ├── calendar/ ├── logs/ └── archive/文件名用代码去掉点号的方式命名,比如sh_000300.csv、sz_399006.csv。不建议直接用sh.000300.csv,因为Windows和Linux对点号的处理虽然一致,但后续用shell脚本批量操作时,点号可能被某些工具当作扩展名分隔符,产生不必要的麻烦。每只股票的日K文件里再固定包含code列,即使文件名改了也能从内容追溯。
日历文件存在calendar/下,文件名就叫trade_calendar.csv,字段定义为calendar_date,is_trading_day。这文件只在初始化时下载一次,之后每周更新一次即可。
3.2 指数日K下载脚本
指数日K的下载逻辑最简单,代码量也少,先把它跑通能快速验证环境是否正常。下面是我实际在用的脚本骨架:
import baostock as bs import pandas as pd import datetime from pathlib import Path # 指数代码与中文名映射 INDEX_LIST = { "sh.000001": "上证指数", "sz.399001": "深证成指", "sh.000300": "沪深300", "sz.399006": "创业板指", "sh.000016": "上证50", "sh.000905": "中证500", } OUTPUT_DIR = Path("data/index/daily") OUTPUT_DIR.mkdir(parents=True, exist_ok=True) lg = bs.login() print("login:", lg.error_code, lg.error_msg) today = datetime.date.today().strftime("%Y-%m-%d") fields = "date,code,open,high,low,close,volume,amount" for code, name in INDEX_LIST.items(): rs = bs.query_history_k_data_plus( code, fields, start_date="1990-01-01", end_date=today, frequency="d", adjustflag="3", ) if rs.error_code != "0": print(f"[error] {name}: {rs.error_msg}") continue df = rs.get_data() if df.empty: print(f"[empty] {name} no data") continue out_path = OUTPUT_DIR / f"{code.replace('.', '_')}.csv" df.to_csv(out_path, index=False, encoding="utf-8-sig") print(f"[done] {name}: {len(df)} rows -> {out_path}") bs.logout()这个脚本有几处值得说明。adjustflag="3"表示不复权,指数本身没有复权概念,所以这里固定填3。start_date从1990年开始,目的是让脚本覆盖指数发布前的空区间,Baostock对空区间会返回空DataFrame而不是报错,所以不用刻意改起始日期。rs.error_code != "0"是判断查询是否成功的标准方式,如果接口参数写错,这一步能直接打印出原因,避免把空数据写进文件。
文件用utf-8-sig编码保存,这是给Excel用户留的后路。后面用pandas或第三方库读数据时,utf-8-sig也能正常读,属于兼容性最好的选择之一。如果后面发现某只指数下载的行数和预期差异很大,优先检查交易日历,而不是怀疑脚本本身。
3.3 全市场个股日K下载与增量更新
全市场个股的日K下载比指数麻烦在两点:一是股票数量多,二是需要断点续下。我常写的逻辑是先构建股票代码列表,然后逐个查询,查询时优先跳过已经存在且非空的文件,实现增量更新。
import baostock as bs import pandas as pd import datetime import time import os from pathlib import Path DATA_DIR = Path("data/stock/daily") DATA_DIR.mkdir(parents=True, exist_ok=True) bs.login() # 获取当前交易日可交易股票 today = datetime.date.today().strftime("%Y-%m-%d") rs = bs.query_all_stock(day=today) stock_codes = [] while rs.next(): row = rs.get_row_data() if row[2] == "1": # tradeStatus stock_codes.append(row[0]) stock_codes = sorted(stock_codes) print("total tradable stocks:", len(stock_codes)) fields = "date,code,open,high,low,close,preclose,volume,amount,turn,tradestatus,pctChg,isST" for i, code in enumerate(stock_codes, start=1): out_file = DATA_DIR / f"{code.replace('.', '_')}.csv" # 增量更新:已存在且非空则跳过 if out_file.exists() and out_file.stat().st_size > 100: print(f"[skip] {code} already exists") continue rs = bs.query_history_k_data_plus( code, fields, start_date="1990-01-01", end_date=today, frequency="d", adjustflag="2", # 前复权 ) if rs.error_code != "0": print(f"[error] {code}: {rs.error_msg}") time.sleep(1) continue df = rs.get_data() if not df.empty: df.to_csv(out_file, index=False, encoding="utf-8-sig") print(f"[{i}/{len(stock_codes)}] {code} {len(df)} rows") else: print(f"[empty] {code} no data") time.sleep(0.2) # 控制请求频率 bs.logout()这段代码的增量更新逻辑依赖文件大小大于100字节作为“已下载”的判断条件,因为正常股票即使只有几十个交易日,CSV文件也会超过1KB。如果某只股票文件存在但下载中断,导致只有表头没有数据,文件大小会是几十字节,就会被重新下载。这个阈值可以根据实际数据量调整,不要设成0,否则空文件会一直被跳过。
全市场股票列表来自query_all_stock,它的局限是只返回查询日当天可交易的股票。历史退市和长期停牌个股不会出现在列表里,所以如果要建全历史股票池,需要另写一个维护脚本,定期从历史交易日逐个查询并合并代码集合。不过对于大多数个人研究者,先覆盖当前正常交易股票已经足够,退市股票的分析属于低频场景。
3.4 5分钟K线下载与节流控制
5分钟K线下载是整套方案里最容易翻车的部分。量级感要先建立起来:日K大约一年240条记录,5分钟K一年大约是48倍,也就是大约11520条。单只股票还好,全市场5000只股票一年的5分钟数据就是几千万行,存成CSV要到几十GB。所以5分钟数据的下载策略必须和日K不一样,不能一股脑全市场全历史拉取。
我的建议是分两步走。第一步确定范围:只下载最近N个月,或者只下载某个指数池子里的成分股,不要一上来就全市场。第二步分段下载:每只股票按季度拆分区间,每段之间sleep至少0.3秒。
import baostock as bs import pandas as pd from pathlib import Path import time import datetime OUTPUT_DIR = Path("data/stock/minute_5") OUTPUT_DIR.mkdir(parents=True, exist_ok=True) bs.login() code = "sh.600000" # 示例个股 start = "2024-10-01" end = "2024-12-31" # 5分钟K线字段 fields = "date,time,code,open,high,low,close,volume,amount,adjustflag" rs = bs.query_history_k_data_plus( code, fields, start_date=start, end_date=end, frequency="5", adjustflag="3", ) if rs.error_code != "0": print("error:", rs.error_msg) else: df = rs.get_data() print("rows:", len(df)) # 时间字段解析 df["trade_time"] = df["time"].apply( lambda x: datetime.datetime.strptime(x, "%Y%m%d%H%M%S%f") ) out_path = OUTPUT_DIR / f"{code.replace('.', '_')}_2024Q4.csv" df.to_csv(out_path, index=False, encoding="utf-8-sig") print("saved:", out_path) bs.logout()adjustflag="3"在5分钟数据下基本是唯一正常的选择,因为Baostock的5分钟K线不支持前复权计算,强行用复权结果会出现数值漂移。如果需要前复权的5分钟数据,只能自己用日线复权因子对分钟价格做处理,这个动作放到后续数据预处理阶段更合适。
另外,5分钟数据天然包含集合竞价和收盘集合竞价的记录吗?Baostock的时间轴是从9:30开始,到11:30结束,下午从13:00到15:00,但不同数据源对尾盘那根K线的归属有差异,所以下载后要主动过滤时间范围,避免把不属于连续竞价时段的异常点混进因子计算。具体过滤方法在避坑章节里讲,这里先记住“下载不等于干净”。
4. 避坑排查:从全量下载到增量更新的5个典型问题
4.1 现象一:第一天下载正常,第二天大量股票返回空数据
现象描述:脚本前一天还能正常拉数据,第二天重启后,上千只股票返回空DataFrame,文件里只有表头。一开始容易怀疑是Baostock接口挂了,但其实是自己的调用方式踩了坑。
原因分析:Baostock的登录会话在多次请求后会被服务端主动断开,或者触发限流。断开后继续调用query_history_k_data_plus不会抛异常,而是返回error_code非0或空DataFrame。这时候脚本还按正常流程写入文件,就会把好数据覆盖成空表。
解决办法:每500到800次请求主动bs.logout()再bs.login();每次查询后都要检查rs.error_code,不是"0"就跳过并记录日志,不要写文件;另外把单次下载的股票数量控制在200只以内跑一批,批与批之间sleep 1秒。老脚本里bs.login()只调用一次直接跑全市场,这种写法在网络波动时非常脆弱。
4.2 现象二:指数代码一直提示参数错误
现象描述:用000001或sh000001查询上证指数,返回error_msg提示参数错误。
原因分析:Baostock对证券代码有严格要求,必须是小写的交易所前缀加点号加数字,比如sh.000001、sz.399001。去掉点、大写字谜、在指数代码后面加.XSHG后缀,都会导致查询失败。这个问题经常在大规模写代码时出现,因为股票代码通常不带前缀,容易下意识复制过来用。
解决办法:指数代码建一个映射表直接抄,不要用手拼字符串的方式生成。个股代码如果要带前缀,可以用sh.加6位数字、sz.加0/3开头数字的规则做转换,但最好也先打印几个样本核对再批量跑。
4.3 现象三:5分钟K线的时间轴怎么都对不上交易时段
现象描述:下载某只股票的5分钟K线后,发现数据里出现9:25、9:30、11:30、15:00这些时间点,和通达信或同花顺里的K线图对不上,要么多一根要么少一根。
原因分析:不同数据商对5分钟K线的边界处理不统一。有的把9:25集合竞价算作一根,有的把11:30的收盘K线和下午13:00的开盘K线合并处理,有的则是不加任何过滤直接落盘。Baostock返回的是按自然时间生成的K线轴,它对边界K线的处理方式和某些行情软件不一致,所以本土数据源反而要对齐。
解决办法:下载后统一过滤时间,只保留9:30到11:30、13:00到15:00区间的K线,然后按“每根K线的时间标记为该区间终点”的方式重采样。比如9:30-9:35的数据标记为9:35,标准的5分钟周期不会有9:30这个起始标记,除非收盘那根特殊。具体过滤代码可以用pandas.between_time实现,效率高又能直接对齐周期。
4.4 现象四:CSV文件用Excel打开乱码,用pandas读取报列名不一致
现象描述:脚本跑完,CSV文件在终端用cat看是中文表头,用Excel打开全是乱码;或者pandas读出来的列名是date但文件里第一行看起来是date,第二行却开始错位。
原因分析:这是典型的编码不一致问题。utf-8编码的中文在Excel里不会被自动识别,Excel在Windows下默认用ANSI编码打开CSV,所以乱码;pandas读取时如果指定encoding="utf-8"、文件尾却带了BOM,列名解析也会出问题。
解决办法:保存CSV时统一用encoding="utf-8-sig",它会写入BOM头,Excel能正确识别;pandas读取时用encoding="utf-8-sig"也不会报错。另外,在所有脚本里固定字段顺序,不要依赖列名动态索引,否则字段顺序一变,之前存的CSV就全废了。
4.5 现象五:下载到一半进程被杀,重启后不知道断在哪
现象描述:全市场股票下载跑到3000多只时,电脑休眠或断电,进程被杀。重启脚本后从第一只开始跑,之前的进度全丢了。或者是脚本跳过已存在文件,但有些文件只写了一半,被误判为已完成。
原因分析:没有记录断点,也没有对“完成”状态做严格定义。文件存在但内容不完整,大小判断失效;文件里只有表头没有数据的空文件,也会被认为是已下载。
解决办法:引入一个done标记文件,每只股票写完CSV后,额外写一个同名的.done空文件。增量判断只看.done文件是否存在,不存在就重新下载。这个方案逻辑简单但非常可靠,比单靠文件大小判断要严谨得多。同时脚本启动后先扫描已有.done数量,打印“已完成/总数”的比例,一眼就能看出进度。
5. 存储与校验:把海量CSV整理成可回放的本地数据集
5.1 交易日历与数据完整性校验
本地数据攒多了以后,最怕的不是没数据,而是数据悄悄缺了一天。K线自己不会告诉你哪根是缺的,只有拿交易日历比对才能发现。所以我建议在批量下载完成后,加一层基于交易日历的校验逻辑。
import pandas as pd from pathlib import Path CAL_FILE = Path("data/calendar/trade_calendar.csv") INDEX_FILE = Path("data/index/daily/sh_000300.csv") # 读取交易日历 cal = pd.read_csv(CAL_FILE) trade_days = set(cal.loc[cal["is_trading_day"] == "1", "calendar_date"].tolist()) # 读取指数K线 df = pd.read_csv(INDEX_FILE) data_days = set(df["date"].tolist()) # 取交集范围 common_days = sorted(trade_days & data_days) if common_days: start_day, end_day = common_days[0], common_days[-1] expected = set(cal.loc[ (cal["is_trading_day"] == "1") & (cal["calendar_date"] >= start_day) & (cal["calendar_date"] <= end_day), "calendar_date" ].tolist()) missing = sorted(expected - data_days) print("missing days:", len(missing)) print(missing[:20])这段代码先找到K线数据和交易日历都有记录的范围,再在这个范围内查找缺失日期。如果缺失日期集中在早期或者个股停牌期,那是正常的;如果最近几个月频繁缺失,说明增量更新逻辑出了问题。对于指数数据,理论上不应该有缺失,因为指数是连续计算的,一旦发现缺日期,优先怀疑下载脚本的跳过逻辑有bug。
5.2 收盘价极值校验与复权一致性检查
数据不是下载完就能直接用,还要做最基础的四价一致性检查。某次我在做因子预处理时发现一只股票某天的low比high还高,追溯下去是下载过程中CSV被截断导致的。增加一个校验脚本能提前暴露这类问题。
import pandas as pd from pathlib import Path file_path = Path("data/stock/daily/sh_600000.csv") df = pd.read_csv(file_path) # 四价逻辑检查 bad = df[(df["high"] < df["low"]) | (df["close"] > df["high"]) | (df["open"] < df["low"])] print("bad rows:", len(bad)) if not bad.empty: print(bad[["date", "open", "high", "low", "close"]].head()) # 复权后价格不能为负 neg = df[df["close"] <= 0] print("non-positive close:", len(neg))前复权数据在早期可能出现接近0甚至负数的价格,这是复权计算的正常现象,因为当时股价经过多次分红除权后折算到当前基准。但如果close普遍为负且数量巨大,大概率是复权参数用错或字段错位,需要重新下载。这个脚本可以作为批量任务每次下载完自动执行,只把异常结果打印出来,不阻塞流程。
5.3 数据压缩归档与按周期目录管理
数据量积累到一定程度,磁盘占用会开始影响日常使用。CSV文件本身没有压缩,5分钟K线全市场一年就要消耗几十GB,归档压缩是必须的一步。我通常用Python内置的zipfile做,不额外依赖系统命令:
import zipfile from pathlib import Path SRC_DIR = Path("data/stock/daily") ARCHIVE_PATH = Path("data/archive/stock_daily_2024.zip") with zipfile.ZipFile(ARCHIVE_PATH, "w", zipfile.ZIP_DEFLATED) as zf: for i, csv_file in enumerate(SRC_DIR.glob("*.csv"), start=1): zf.write(csv_file, arcname=csv_file.name) if i % 1000 == 0: print(f"packed {i} files")归档完后用ZipFile.testzip()检查一遍完整性,这步别省。压缩后的文件不删除原CSV,只是多一份备份;等真正要跑大规模回测时,直接从压缩包读取比从几千个分散文件里读要快得多。至于按周期管理,我的习惯是日K按年归档,5分钟K按季度归档,文件名带周期范围,比如stock_daily_2024.zip、stock_minute5_2024Q4.zip。
6. 进阶:把下载工具变成日常维护脚本
数据下载完成只是第一步,真正的价值在于让这个流程自动化地持续运转。定时增量任务我一般安排在收盘后半小时到一小时,比如下午4点后,因为此时当天数据已经定型,避免盘中数据回刷造成本地文件多次重写。定时任务本身用系统自带机制就行,不需要额外搭服务。
30 16 * * 1-5 cd /path/to/stock_downloader && python download_daily.py >> logs/download.log 2>&1日志要按周滚动,不能无限追加进同一个文件,否则几个月后排查问题要从几十万行日志里找线索。我习惯在下载脚本里写入每批任务的起始时间、股票总数、成功数、失败数和耗时,这些字段后面做稳定性分析时非常有用。
有了稳定更新的本地数据,接下来就是和策略计算无缝衔接。这里有个细节值得专门说:前复权数据在每次分红除权后,历史价格都会发生偏移,所以如果本地存储的是前复权数据,持续累积快一年后,早期数据会和新数据不一致,需要对整段历史重新执行一次前复权刷新。更省事的做法是本地始终保存不复权原始数据,需要前复权时再实时计算,这样原始数据永远不做覆盖,可回溯性最好。代价是每次计算前多一步复权处理,但对于本地数据量来说,这个耗时完全可以接受。
我早期做下载工具时,一股脑把全市场5分钟K线拉到底,结果磁盘空间告急,中途数据还因为字段乱序混了几个月,后来才意识到问题不在接口,而在工程习惯。现在我的顺序永远是:先定周期,再定复权规则,最后写存储逻辑。这个习惯帮我省了无数次返工的麻烦,希望帮到你。
本文还有配套的精品资源,点击获取