ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

A股全量日线快照重建实践:用baostock批量抓取与Parquet落库方案

A股全量日线快照重建实践:用baostock批量抓取与Parquet落库方案 简介面向股票量化分析、技术指标研究与交易策略回测场景这份数据集覆盖沪深股票自上市以来至2022年1月10日的日线行情字段包含开盘价、收盘价、最高最低价、振幅、成交量、成交额、换手率并预计算了MACD、CCI及同花顺手机版多空指标可直接用于趋势判断、买卖信号识别与策略验证。压缩包共1个文件为SQL格式数据库文件包体约424.52MB可使用SQL语句按股票、日期或指标条件做筛选与聚合适合具备数据库基础或金融数据分析经验的投资者、研究人员使用。目前已有605人学习下载。整体来看这份数据免去了自行爬取清洗历史行情的大量工作既能用于个股历史走势复盘也可批量计算技术指标、构建回测样本还可结合成交量与换手率观察市场活跃度为金融分析与模型研究提供较完整的数据底座。1. 要重建的是可复现的沪深全量日线第一次接到历史以来到 2022-01-10 的全部日线这种需求时直觉是先找个接口开拉拉完本地就多了一堆 CSV。实际做一遍就会发现真正的难点不在循环抓取而在口径前复权数据会随后续除权重新计算同一只股票在不同截止日拉出来历史值不一样接口返回的成交量有手和股之分停牌期间根本没有日线记录。所以全部日线这四个字交付的不只是四千多只股票的文件而是一套以 2022-01-10 为固定快照、文件可重放、增量可拼接的本地行情目录。量化策略校验、模型训练底库、数据库重建这三类场景都会用到这套东西下文按数据源选型、批量抓取、落盘、校验的顺序把方案铺开结论建立在免费接口之上换任何一家数据源都适用。2. 数据源选型免费接口的定位与日线口径差异2.1 三个常见接口各自适合什么社区里活跃的免费行情源里baostock、akshare、tushare 是绕不开的三个。它们的定位并不完全重叠首次全市场落地前先看一张对比表。数据源接入方式全市场拉取友好度复权支持主要取舍baostockPython 库登录后走 socket 协议高自带股票列表和交易日历接口前复权、后复权、不复权用 adjustflag 切换返回全部是字符串需要统一转类型更新频率和覆盖范围要自查akshare爬取东方财富、新浪等公开页面中适合按单只补数或校验通过 adjust 参数传 qfq、hfq 或空串列名是中文接口偶发限流批量循环要加重试tushareHTTP API注册拿 token中高pro 接口功能全复权因子独立提供自行算部分字段和数据量受积分限制免费档有频控结论很直接全量首次落地优先 baostock因为股票列表、交易日历、日线在同一个连接里拿信息闭环akshare 适合抓单只股票做交叉验证tushare 适合要长期维护行情库、后续接财务字段的场景。下面代码以 baostock 为主线但第 4 章的落库结构不绑任何一家。2.2 复权、成交量、涨跌幅三个口径必须先钉死动手拉数据前在代码里写死三个约定。复权库里面永远保存不复权价格需要前复权或后复权时临时计算绝不直接覆盖原值。原因是前复权以最新价为基准回算历史2022-01-10 之后一旦再除权除息用到今天的方式重新拉同一段历史数字会整体变掉后复权也有类似问题只是锚点不同。固定住不复权 复权因子两层结构快照才真正可重现。成交量baostock 的 volume 字段单位是股akshare 返回的成交量单位是手东方财富页面展示的又是手。落库前统一换算成股换算逻辑写成一行的工具函数别散落在各处。涨跌幅计算用当日 close 与 preclose 做差不要用前复权价格序列自算。除权日当天前复权价格会在跳空处衔接用复权价算出来的涨跌幅是大涨大跌的假象回测里会变成脏信号。2.3 把结束日钉在 2022-01-10 的理由这个日期不是随便填的。所有数据源给出的历史以来都隐含一个截止今天今天一变前复权历史就变。把 end_date 固定为 2022-01-10等于给整份数据打了版本号任何人用相同源、相同字段、相同复权参数拉同一区间能得到一致的行数和接近一致的内容。2022-01-10 是星期一作为交易日窗口右端点干净正好落在常规交易周内不容易被节假日边界咬掉尾巴。后面想续到更晚日期时按第 4 章的增量方式拼接不需要推翻重拉。3. 用 baostock 批量拉取全市场日线忽略限流的写法3.1 先取当天仍在交易的 A 股清单baostock 的 query_all_stock 可以在指定日期返回全市场证券列表包含股票、指数、可转债。参数 day 传 2022-01-10先取回原始表再过滤。import baostock as bs import pandas as pd lg bs.login() rs bs.query_all_stock(day2022-01-10) rows [] while rs.error_code 0 and rs.next(): rows.append(rs.get_row_data()) all_securities pd.DataFrame(rows, columnsrs.fields) bs.logout() print(all_securities.head())query_all_stock 返回字段包括 code、tradeStatus、code_name、type其中 type1 表示股票tradeStatus1 表示当天正常交易。列表只代表该日上市且交易已经退市的不在里面这与题目历史以来并不矛盾拿 2022-01-10 当快照日当日还在交易的股票从上市首日到该日的日线全都要。过滤 A 股代码前缀单独写一个函数方便复用到别的项目。def keep_a_stock(row) - bool: if row[type] ! 1: return False if row[tradeStatus] ! 1: return False mkt, sym row[code].split(.) if mkt not in (sh, sz): return False if sym.startswith((600, 601, 603, 605, 000, 001, 002, 003, 300, 301, 688, 689)): return True return False stock_list all_securities[ all_securities.apply(keep_a_stock, axis1) ].reset_index(dropTrue) print(stock_list.shape)过滤条件里 30x 开头的深市是创业板688 和 689 是科创板其余是沪深主板代码段。8 开头是北交所标的900/200 开头是 B 股都不在沪深 A 股范围内。过滤后股票数量在四千多只的量级以接口返回为准。3.2 按股票循环、按年切分查询窗口逐只拉取是最稳的做法但每只股票从上市首日到 2022-01-10 的区间长度不同。为了网络失败时能精确定位到哪个区间按自然年切分查询窗口1990 到 2021 各一整年2022 年只切到 01-10。单次查询数据量被限制在一年以内中断重试时只补对应年份。3.2.1 完整循环与断点续传from pathlib import Path import time import pandas as pd import numpy as np import baostock as bs OUT Path(data/daily) OUT.mkdir(parentsTrue, exist_okTrue) end_date 2022-01-10 years list(range(1990, 2023)) # 1990 到 2022 lg bs.login() failed [] for code in stock_list[code].tolist(): target OUT / f{code}.parquet if target.exists(): continue # 断点续传已落盘就跳过 frames [] for year in years: if year 2022: start, stop 2022-01-01, end_date else: start, stop f{year}-01-01, f{year}-12-31 rs bs.query_history_k_data_plus( code, date,code,open,high,low,close,preclose,volume,amount,pctChg,turn, start_datestart, end_datestop, frequencyd, adjustflag3, # 3不复权1后复权2前复权 ) while rs.error_code 0 and rs.next(): frames.append(rs.get_row_data()) time.sleep(0.02) # 轻微限速避免触发连接保护 if not frames: failed.append((code, no_data)) continue df pd.DataFrame(frames, columnsrs.fields) df df.replace(, np.nan) df df.astype({ open: float64, high: float64, low: float64, close: float64, preclose: float64, volume: float64, amount: float64, pctChg: float64, turn: float64, }) df.to_parquet(target, indexFalse) bs.logout()代码逻辑分三段看。断点续传target.exists() 成立就跳过这只股票第一次跑中断后第二次执行只补充未落盘的部分不用重跑全部。why 不判断部分年份缺失因为中断通常中断在整个脚本的某一只股票上已完成的都是完整文件以文件为单位做断点粒度足够。分年窗口1990 年是沪深最早一批股票上市的年份早于它没有日线接口返回空也不影响结果2022 年单独处理右端严格钉在 end_date。数据处理返回的都是字符串空字段先替换成 np.nan再统一做 float64 强转否则个别空串会让 astype 直接抛 ValueError。提示baostock 返回的空缺字段是空字符串而不是 null直接 astype(float64) 会报错。replace(, np.nan) 要放在 astype 前面。3.3 重试时不从头再来失败清单与连接复用循环里出现失败时把 code 和失败原因追加到 fail.log一行一个代码。全部循环结束后再对 fail.log 里的代码做第二遍拉取目标目录已存在的文件会跳过。实际操作中真正反复失败的通常集中在个别字段异常或代码新上市不久单独处理即可。这个阶段最常见的错误写法是每只股票重新 login 和 logout。连接建立开销大速度骤降还可能被接口限流。正确的是外层 login 一次循环里反复 query最后统一 logout。如果出现长时间无响应先把 time.sleep(0.02) 调到 0.1 观察再不行才是接口侧的频率控制问题。4. 落库Parquet 目录、Schema 固定与增量合并4.1 为什么不用一张大 CSV 或一个 SQLite四千多只股票、每只平均几千行拼成一张上亿行的宽表读进内存就会占掉几个 GB。策略代码通常按股票做切片访问按 code 拆文件才是最舒服的形态读某只股票只加载一个文件内存占用与样本数成正比以后要横向加字段比如补财务数据也只影响对应文件。选 parquet 而不是 CSV主要是三点自带列的类型 schema日期列不会被读成字符串压缩后体积约为 CSV 的五分之一pyarrow 读取时可以直接做列裁剪。SQLite 是行存更适合按交易日的横截面查询比如2022-01-10 当天所有股票收盘价而日线策略大多是某只股票一段时间的序列parquet 目录结构正好贴合这个访问模式。4.2 文件布局与读取函数目录设计如下明细文件落在 daily 目录交易日历和股票清单放根目录后续脚本只依赖这三类输入。data/ calendar.parquet # 1990-2022 交易日历含 is_trading_day 标记 stock_list.parquet # 过滤后的沪深 A 股清单 daily/ sh.600000.parquet sz.000001.parquet ...对应的读取函数很短from pathlib import Path import pandas as pd DATA Path(data) def load_calendar() - pd.DataFrame: return pd.read_parquet(DATA / calendar.parquet) def load_daily(code: str) - pd.DataFrame: return pd.read_parquet(DATA / daily / f{code}.parquet)约定明细文件的日期字段统一为 date字符串格式 YYYY-MM-DD不转 datetime64。pyarrow 写入时会把字符串列自动推断为 string 类型读取时不会变回 object。字段名全小写下划线跨语言消费时省去大小写适配。4.3 增量扩展把 2022-01-10 之后的数据平滑拼上快照做完下一步通常是把 2022-01-10 之后的数据续上。增量逻辑很固定读本地该股票的最大日期加一天作为新 start_date拉一段到新的 end_date再并回原文件。def incremental_update(code: str, new_end: str): old load_daily(code) last_date old[date].max() start (pd.Timestamp(last_date) pd.Timedelta(days1)).strftime(%Y-%m-%d) new fetch_kline(code, start, new_end) # 内部逻辑同第 3 章不复权 if new.empty: return old merged pd.concat([old, new], ignore_indexTrue) merged merged.drop_duplicates(subset[code, date], keeplast) merged merged.sort_values(date).reset_index(dropTrue) merged.to_parquet(DATA / daily / f{code}.parquet, indexFalse) return mergedmerged 里可能出现的重复来自老文件与增量重叠的那一天比如上次落盘写到 2022-01-10这次又从 01-10 开始拉同一个交易日出现两条记录。drop_duplicates 按 code 和 date 去重keeplast 保留新拉的这条。排序保证消费方拿到的时序一定单调。跨源拼接时比如 baostock 不再覆盖后续区间需要保持两边列名一致缺失列用 NaN 补齐否则 concat 出来的 schema 会带上额外列prquet 文件结构就乱了。5. 用交易日历和复权因子做全量体检5.1 缺失日期先比对交易日历再区分停牌把 1990-12-19 到 2022-01-10 的交易日历拉出来与每只股票的实际日期取差集得到这只股票的所有缺失交易日。缺失本身不一定是异常新股上市前的日期天然不在范围内停牌期间也没有日线。真正可疑的是上市后连续很多个交易日没有记录。用compress_gaps把缺失日期压缩成连续区间挑长度超过 10 的打印出来人工核查def compress_gaps(dates): if not dates: return [] gaps [] start prev dates[0] for d in dates[1:]: if (pd.Timestamp(d) - pd.Timestamp(prev)).days 1: prev d else: gaps.append((start, prev)) start prev d gaps.append((start, prev)) return [g for g in gaps if (pd.Timestamp(g[1]) - pd.Timestamp(g[0])).days 10] cal load_calendar() cal cal[cal[is_trading_day] 1][calendar_date].tolist() bad_codes [] for code in stock_list[code].tolist(): df load_daily(code) existing set(df[date].tolist()) missing sorted(set(cal) - existing) if compress_gaps(missing): bad_codes.append((code, compress_gaps(missing))) print(len(bad_codes), 只股票存在可疑连续缺失)上市前的日期不在 cal 范围内天然不会进 missing所以新股不会被误报连续缺失超过 10 个交易日的基本都是停牌或者数据源漏数逐个打开 K 线看图就能定性。5.2 前复权与不复权的除权日交叉验证随机抽 20 只股票用 baostock 分别拉 adjustflag2 的前复权和 adjustflag3 的不复权计算两者价格之比。除权日当天这个比值会发生跳变跳变量应该与送转股和分红除息大致自洽如果跳变出现在没有任何公告成分的普通交易日基本可以判定数据错位。这一招不需要引入外部财务数据复杂度低几分钟就能把全市场数据源的质量摸个底。5.3 快照报告收尾落库完成后生成一份报告总股票数、总行数、每只股票的首末日期。2022-01-10 沪深 A 股数量在四千只上下明细文件数应与 stock_list 行数一致任意一只股票的最后一条记录应落在 2022-01-10或者该股停牌前的最后一个交易日。把 bad_codes 打印结果逐只看一眼确认只剩明确停牌注释的条目再抽两只见不到的除权日验证过这批 2022-01-10 的全市场日线快照就真正闭环了。本文还有配套的精品资源点击获取
返回列表