系列名:《从零搭建你的 A 股量化系统》| 专栏 S3 数据工程 | 第 7 篇 / 共 15 篇 | M2 分钟线
标签:#量化投资 #数据工程 #分钟数据 #BaoStock #Parquet #DuckDB #A股 #散户 #存储成本
上篇(S3-006)我们把日线库接上了增量管道——按标的、按天两条路线,get_last_date 算起点、append_daily 原子追加、跨布局取全局 max 定断点。到这儿日线底座已经稳了。
这一篇换个粒度:日线之上还有分钟线(5/15/30/60 分)和 Tick(逐笔),它们在免费源里的可获取性、数据形态、存储布局跟日线完全不是一回事。整篇文章回答四个问题:
和 S3-006 的分工是清楚的:S3-006 讲日线每天怎么只拉增量,本篇讲换个粒度之后能拿到什么、怎么存、查起来什么姿势——前者是维护问题,后者是可行性边界 + 存储布局问题。
配套脚本 src/s3_007_minute_fetch.py(含三个可直接跑的诊断模式),离线自检 10/10、pytest 45 passed;所有数字来自 2026-09-29 本机实跑(conda ashare / baostock 0.9.3),并已逐条对照 BaoStock 官方文档复核——其中三处官方说法与实测不符(起点、指数边界、批量接口),后文详述。
🔄 官方文档复核修订(2026-09-29):本篇按 BaoStock 官方 home.md(数据范围与版本记录)与 stockKData.md(K 线接口字段定义)逐条复核,改了三处 ——
① 起点由 2020-01-03 改为 2020-01-02:官方文档写 2020-01-03,实测 2020-01-02 已有 48 根(元旦假后首个交易日),照官方写会默认漏掉一整天;
② “指数不给分钟线”改为“指数分钟线不可依赖”:实测沪市指数(sh.000xxx)确实完全没有,但深市指数(sz.399xxx)能返回数据 —— 只有 5/15 分钟、且只覆盖 2023-08-09 ~ 2024-09-18 一段(距今天已断档);
③ “无全市场批量”限定为分钟线:V0.9.3(2026-07-10)起日线 / ETF / 复权因子有按日全市场接口,但都不含分钟粒度。
三处均已同步进代码(minute.py)与离线自检(self-test 新增 1 条、pytest 新增 17 条)。
【本篇取数地图】
| 分钟线(5/15/30/60 分,2020 年起) | BaoStock query_history_k_data_plus(frequency=5/15/30/60) | AkShare 腾讯源(更长历史) | 免费源里唯一给分钟线;2020-01-02 起(官方文档写 2020-01-03,实测 01-02 已有数据);逐只拉、无全市场批量;指数分钟线不可依赖(沪市无、深市残缺) |
| 1 分钟线 | ❌ 无免费源 | Tushare(付费,分钟不在积分体系内) | 实测 frequency="1" 报 10004012 请求数据类型不正确 |
| Tick(逐笔) | ❌ 无可靠免费源 | Tushare / 券商 L2(付费) | 全 A 一年约 0.5 TB 量级,散户基本不可行 |
| 全市场批量(日线 / ETF / 复权因子) | BaoStock V0.9.3 按日接口 | — | query_daily_history_k_AStock(date) / query_daily_history_k_ETF(date) / query_daily_adjust_factor(date)——都不含分钟粒度,分钟线仍只能逐只拉 |
| ETF 分钟线 | BaoStock | — | 官方数据范围:ETF 的 5/15/30/60 分钟自 2026-01-05 起(比股票短得多) |
| 量级 / 成本核算 | 本地 Parquet 实测外推 | — | 决策依据,不依赖外部源 |
| 落库格式 | Parquet(snappy) | — | data/minute/{symbol}/{year}.parquet,与 data/daily/ 物理分离 |
单源定案:分钟线固定从 BaoStock 拿;AkShare 腾讯源只作 2020 年之前历史的补位,不与 BaoStock 合并存储(同一类数据永远单源,第二源只做抽样对拍——这是 S3-001 §5.5 的纪律)。
🕐 取数时点(BaoStock 官网原文):日 K 线 17:30 入库、复权因子 18:00、分钟线 20:00、财务次日 1:30。也就是说当天 20:00 之后才能拿到当日分钟数据,别在盘中或刚收盘就跑分钟同步。
一、先算账:值不值得碰,什么时候该碰
先算清“要不要碰”这笔账,再动手。脚本 –size-calc 的真实输出(构造确定样本真实写 Parquet 后按行数外推):
====================================================================
S3-007 量级 & 成本核算 (A股全市场 · 年维度 · Parquet 实测外推)
====================================================================
常量: 标的=5200 年交易日=242 分钟/日=240 Tick/股/日=50,000
——————————————————————–
粒度 年行数 全A年(Parquet) 全A 20年
——————————————————————–
日线 1,258,400 34.6 MB 0.7 GB
1分钟线 302,016,000 7.9 GB 158.1 GB
Tick 62,920,000,000 483.5 GB 9.4 TB
——————————————————————–
各频段年行数(全 A, 基于 240 根/日 = 1 分钟基准):
1min(无免费源) 302,016,000 行 7.91 GB/年
5min 60,403,200 行 1.58 GB/年
15min 20,134,400 行 0.53 GB/年
30min 10,067,200 行 0.26 GB/年
60min 5,033,600 行 0.13 GB/年
翻译成人话:
- 日线全 A 一年 34.6 MB——一张照片。日线库根本不是存储问题(是 S3-002~006 在解决的获取与维护问题);
- 5 分钟线全 A 一年 1.58 GB,20 年约 32 GB——一块移动硬盘装得下。而 BaoStock 只给 2020 年至今这一段,所以真实可行的规模是 5 分钟 × 5 年 × 小池子,量级更小;
- Tick 全 A 一年约 0.5 TB,20 年 9.4 TB——而且这是按“单股日均 5 万笔”的保守下限估的,活跃股实际可达数十万笔,真实体量只会更大。
下面是量级分层图(注意对数尺度,否则 Tick 会把日线压成一条线):
#mermaid-svg-dN9FfOJUQdqsUUmg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-dN9FfOJUQdqsUUmg .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-dN9FfOJUQdqsUUmg .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-dN9FfOJUQdqsUUmg .error-icon{fill:#552222;}#mermaid-svg-dN9FfOJUQdqsUUmg .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-dN9FfOJUQdqsUUmg .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-dN9FfOJUQdqsUUmg .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-dN9FfOJUQdqsUUmg .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-dN9FfOJUQdqsUUmg .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-dN9FfOJUQdqsUUmg .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-dN9FfOJUQdqsUUmg .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-dN9FfOJUQdqsUUmg .marker{fill:#333333;stroke:#333333;}#mermaid-svg-dN9FfOJUQdqsUUmg .marker.cross{stroke:#333333;}#mermaid-svg-dN9FfOJUQdqsUUmg svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-dN9FfOJUQdqsUUmg p{margin:0;}#mermaid-svg-dN9FfOJUQdqsUUmg .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-dN9FfOJUQdqsUUmg .cluster-label text{fill:#333;}#mermaid-svg-dN9FfOJUQdqsUUmg .cluster-label span{color:#333;}#mermaid-svg-dN9FfOJUQdqsUUmg .cluster-label span p{background-color:transparent;}#mermaid-svg-dN9FfOJUQdqsUUmg .label text,#mermaid-svg-dN9FfOJUQdqsUUmg span{fill:#333;color:#333;}#mermaid-svg-dN9FfOJUQdqsUUmg .node rect,#mermaid-svg-dN9FfOJUQdqsUUmg .node circle,#mermaid-svg-dN9FfOJUQdqsUUmg .node ellipse,#mermaid-svg-dN9FfOJUQdqsUUmg .node polygon,#mermaid-svg-dN9FfOJUQdqsUUmg .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-dN9FfOJUQdqsUUmg .rough-node .label text,#mermaid-svg-dN9FfOJUQdqsUUmg .node .label text,#mermaid-svg-dN9FfOJUQdqsUUmg .image-shape .label,#mermaid-svg-dN9FfOJUQdqsUUmg .icon-shape .label{text-anchor:middle;}#mermaid-svg-dN9FfOJUQdqsUUmg .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-dN9FfOJUQdqsUUmg .rough-node .label,#mermaid-svg-dN9FfOJUQdqsUUmg .node .label,#mermaid-svg-dN9FfOJUQdqsUUmg .image-shape .label,#mermaid-svg-dN9FfOJUQdqsUUmg .icon-shape .label{text-align:center;}#mermaid-svg-dN9FfOJUQdqsUUmg .node.clickable{cursor:pointer;}#mermaid-svg-dN9FfOJUQdqsUUmg .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-dN9FfOJUQdqsUUmg .arrowheadPath{fill:#333333;}#mermaid-svg-dN9FfOJUQdqsUUmg .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-dN9FfOJUQdqsUUmg .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-dN9FfOJUQdqsUUmg .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-dN9FfOJUQdqsUUmg .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-dN9FfOJUQdqsUUmg .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-dN9FfOJUQdqsUUmg .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-dN9FfOJUQdqsUUmg .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-dN9FfOJUQdqsUUmg .cluster text{fill:#333;}#mermaid-svg-dN9FfOJUQdqsUUmg .cluster span{color:#333;}#mermaid-svg-dN9FfOJUQdqsUUmg div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-dN9FfOJUQdqsUUmg .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-dN9FfOJUQdqsUUmg rect.text{fill:none;stroke-width:0;}#mermaid-svg-dN9FfOJUQdqsUUmg .icon-shape,#mermaid-svg-dN9FfOJUQdqsUUmg .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-dN9FfOJUQdqsUUmg .icon-shape p,#mermaid-svg-dN9FfOJUQdqsUUmg .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-dN9FfOJUQdqsUUmg .icon-shape .label rect,#mermaid-svg-dN9FfOJUQdqsUUmg .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-dN9FfOJUQdqsUUmg .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-dN9FfOJUQdqsUUmg .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-dN9FfOJUQdqsUUmg :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
约 46x
约 300x
数据粒度分层
日线全A年 34.6 MB
5分钟线全A年 1.58 GB
Tick 逐笔全A年 ~0.5 TB
结论:硬盘买得起,额度和时间买不起、等不起。单只 5 分钟拉一年实测 17~24 秒(本机实测,随网络波动),200 只重放一遍就是约 1 小时;而分钟线没有批量模式(日线有、分钟线没有,见 §2.5),想少花时间只能缩小池子或缩小区间——这才是分钟线的真实约束。
上面算清了成本,接下来是更前置的一个问题:你到底该不该碰分钟线?——先看这张决策树,再对号入座。
#mermaid-svg-S17NQX88A7gWs1on{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-S17NQX88A7gWs1on .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-S17NQX88A7gWs1on .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-S17NQX88A7gWs1on .error-icon{fill:#552222;}#mermaid-svg-S17NQX88A7gWs1on .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-S17NQX88A7gWs1on .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-S17NQX88A7gWs1on .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-S17NQX88A7gWs1on .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-S17NQX88A7gWs1on .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-S17NQX88A7gWs1on .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-S17NQX88A7gWs1on .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-S17NQX88A7gWs1on .marker{fill:#333333;stroke:#333333;}#mermaid-svg-S17NQX88A7gWs1on .marker.cross{stroke:#333333;}#mermaid-svg-S17NQX88A7gWs1on svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-S17NQX88A7gWs1on p{margin:0;}#mermaid-svg-S17NQX88A7gWs1on .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-S17NQX88A7gWs1on .cluster-label text{fill:#333;}#mermaid-svg-S17NQX88A7gWs1on .cluster-label span{color:#333;}#mermaid-svg-S17NQX88A7gWs1on .cluster-label span p{background-color:transparent;}#mermaid-svg-S17NQX88A7gWs1on .label text,#mermaid-svg-S17NQX88A7gWs1on span{fill:#333;color:#333;}#mermaid-svg-S17NQX88A7gWs1on .node rect,#mermaid-svg-S17NQX88A7gWs1on .node circle,#mermaid-svg-S17NQX88A7gWs1on .node ellipse,#mermaid-svg-S17NQX88A7gWs1on .node polygon,#mermaid-svg-S17NQX88A7gWs1on .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-S17NQX88A7gWs1on .rough-node .label text,#mermaid-svg-S17NQX88A7gWs1on .node .label text,#mermaid-svg-S17NQX88A7gWs1on .image-shape .label,#mermaid-svg-S17NQX88A7gWs1on .icon-shape .label{text-anchor:middle;}#mermaid-svg-S17NQX88A7gWs1on .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-S17NQX88A7gWs1on .rough-node .label,#mermaid-svg-S17NQX88A7gWs1on .node .label,#mermaid-svg-S17NQX88A7gWs1on .image-shape .label,#mermaid-svg-S17NQX88A7gWs1on .icon-shape .label{text-align:center;}#mermaid-svg-S17NQX88A7gWs1on .node.clickable{cursor:pointer;}#mermaid-svg-S17NQX88A7gWs1on .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-S17NQX88A7gWs1on .arrowheadPath{fill:#333333;}#mermaid-svg-S17NQX88A7gWs1on .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-S17NQX88A7gWs1on .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-S17NQX88A7gWs1on .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-S17NQX88A7gWs1on .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-S17NQX88A7gWs1on .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-S17NQX88A7gWs1on .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-S17NQX88A7gWs1on .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-S17NQX88A7gWs1on .cluster text{fill:#333;}#mermaid-svg-S17NQX88A7gWs1on .cluster span{color:#333;}#mermaid-svg-S17NQX88A7gWs1on div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-S17NQX88A7gWs1on .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-S17NQX88A7gWs1on rect.text{fill:none;stroke-width:0;}#mermaid-svg-S17NQX88A7gWs1on .icon-shape,#mermaid-svg-S17NQX88A7gWs1on .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-S17NQX88A7gWs1on .icon-shape p,#mermaid-svg-S17NQX88A7gWs1on .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-S17NQX88A7gWs1on .icon-shape .label rect,#mermaid-svg-S17NQX88A7gWs1on .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-S17NQX88A7gWs1on .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-S17NQX88A7gWs1on .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-S17NQX88A7gWs1on :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
不需要
需要
否, 日内信号/动量
是, 做市/冲击成本
合规
合规
策略需要比日线更细的数据吗?
日线库 S3-004~006 足够别碰分钟/Tick
需要 Tick 级逐笔?
小股票池 50~200 只BaoStock 5/15/30/60min 逐只拉独立 data/minute/ + DuckDB
TB 级存储预算付费数据源按 date 切分 + 断点续传
低频离高频认定线差数量级研究用一般无合规风险
按人群给三条建议:
⚠️ 合规提醒(通用表述):程序化 / 高频交易的认定线是“每秒申报 + 撤单 ≥ 300 笔,或单日 ≥ 2 万笔”(《程序化交易管理实施细则》,2025-07-07 施行)。散户低频(每天几笔~几十笔)离认定线差数量级,研究用途取用分钟 / Tick 数据一般无合规风险;若据此做程序化 / 高频交易,须遵守报备与限速义务。网络上流传的“每秒 15 笔 / 撤单率 15% / 订单最小停留 50 微秒”等数字系误传,不要采信。
二、能拿到什么:四条边界(真实调用实测)
这一节的每个结论都来自真实调用,不是文档抄录。探针跑在本机 conda ashare(Python 3.11.15 / baostock 0.9.3),输出原样贴出。
2.1 先看返回长什么样;2.2 ~ 2.5 逐条过四条边界(频率 / 起点 / 指数 / 批量)——其中第 4 条“批量”有个转折(V0.9.3 新增了按日全市场接口),所以在 2.5 单独说清。
2.1 返回值长什么样
=== ① 字段 & 单日根数(5min sh.600519 2024-01-02) ===
fields: ['date', 'time', 'code', 'open', 'high', 'low', 'close', 'volume', 'amount', 'adjustflag'] rows: 48
首: ['2024-01-02', '20240102093500000', 'sh.600519'] 1713.9900
末: ['2024-01-02', '20240102150000000', 'sh.600519'] 1685.0100
三条关键信息:
2.2 频率:只有 5/15/30/60,没有 1 分钟
=== ② frequency 取值 ===
frequency='1' error=10004012 msg='请求数据类型不正确' n=0
frequency='5' error=0 msg='success' n=96
frequency='15' error=0 msg='success' n=32
frequency='30' error=0 msg='success' n=16
frequency='60' error=0 msg='success' n=8
frequency="1" 直接报错 10004012——这是唯一一个会响的坑(其余全是静默)。1 分钟线不在免费能力范围内,minute.py 里 VALID_FREQ = ("5","15","30","60") 直接把非法值拦在构造期。
2.3 时间范围:2020-01-02 起,边界外静默返空
=== ③ 时间起点(股票) ===
2019-12-01~2019-12-31: n=0 首=None
2020-01-02~2020-01-02: n=48 首=2020-01-02
2020-01-02~2020-01-10: n=336 首=2020-01-02
2019 年的请求返回 0 行、error=0;2020-01-02 起有数据。注意超出边界不报错,这就要靠调用方自己裁剪区间——MINUTE_START 常量就是干这个的。
🔴 这里和官方文档差了一天,是实测值更早:官方 home.md 的数据范围写的是“5、15、30、60 分钟 K 线数据,时间范围(近 5 年):2020-01-03 至今”。但 2026-09-29 实测 2020-01-02 单日就有 48 根(元旦假后首个交易日)。代码照官方写 2020-01-03 作默认起点,就会默认漏掉 2020-01-02 这整个交易日。所以 MINUTE_START 取的是实测更早值 2020-01-02。
另外“近 5 年”这个说法本身也要留个心眼:这里的起点是固定的(2020-01-02),不是随今天滚动的窗口——2026-09-29 时点距该起点已经约 6 年 9 个月了。官方那句“近 5 年”是文档写就时的口径。
2.4 指数:官方说“不给”,实测只对了一半
官方 home.md 的数据范围里写得斩钉截铁:“指数数据 —— 日、周、月 K 线已经包含指数(不提供分钟 K 线数据)”。实测下来这句话只对了一半:
=== ④ 指数分钟线: 沪市 vs 深市 (官方文档称'指数不提供分钟K线') ===
[无] sh.000001: err=0 n=0
[无] sh.000300: err=0 n=0
[无] sh.000905: err=0 n=0
[有] sz.399001: err=0 n=48
[有] sz.399006: err=0 n=48
[有] sz.399106: err=0 n=48
沪市指数(sh.000xxx:上证指数、沪深 300、中证 500、上证 50、科创 50……试了 8 个)确实完全拿不到;但深市指数(sz.399xxx)是能返回数据的。两侧的返回都是 error=0——没有报错,只有“有行”和“0 行”的差别。
深市指数这批数据再往下挖,还有两层不完整:
=== ④b 深市指数分钟线: 频率覆盖 ===
sz.399001 5min 2024-03: n=1008
sz.399001 15min 2024-03: n=336
sz.399001 30min 2024-03: n=0
sz.399001 60min 2024-03: n=0
=== ④c 深市指数分钟线: 时间窗口逐月扫描 ===
sz.399001 5min 2023-06-01~2023-06-30: n=0
sz.399001 5min 2023-08-01~2023-08-31: n=816 首=2023-08-09 末=2023-08-31
sz.399001 5min 2023-09-01~2023-09-30: n=960 首=2023-09-01 末=2023-09-28
sz.399001 5min 2024-09-01~2024-09-30: n=528 首=2024-09-02 末=2024-09-18
sz.399001 5min 2024-10-01~2024-10-31: n=0
sz.399001 5min 2025-06-01~2025-06-30: n=0
- 只有 5 分钟和 15 分钟,30 分钟、60 分钟全空——同期个股的 30/60 分钟是正常的(sh.600519 2024-03 分别为 168 根、84 根),所以不是参数写错;
- 时间上只有一段:逐月扫下来,sz.399001 的 5 分钟线集中在 2023-08-09 ~ 2024-09-18,之前(2023-06 及更早)与之后(2024-10 及以后)都是 0 行。距 2026-09-29 已断档一年。
换句话说,深市指数这批数据更像服务端的一段残缺窗口,补不齐历史、也接不上今天。
🔴 本篇的结论是“指数分钟线不可依赖”,不是“指数不给分钟线”(后者是官方文档的写法)。想拿指数分钟级数据:沪市指数直接死心;深市指数也别指望。
指数这一节也暴露了本接口最需要注意的一个特性:“0 行”是个含义过多的信号——“超出起点边界”“非交易日”“指数(含深市那段残缺窗口)”三种情况的返回值完全一样(都是 error=0 + 0 行)。
🔴 所以判断“到底为什么没数据”必须靠区间与标的本身:sym 是不是指数?start 是不是早于 2020-01-02?那天是不是交易日?三个问题在调用前就要自己回答,别指望返回值告诉你。
代码里为此加了 is_index_code()——把指数代码提前认出来并主动告警,省得你把预期内的空表当成“数据缺失”排查半天:
from datafeed import is_index_code
is_index_code("000300.SH") # True —— 沪市指数, 分钟线完全拿不到
is_index_code("399001.SZ") # True —— 深市指数, 只有 5/15 分钟且窗口残缺
is_index_code("000001.SZ") # False —— 平安银行, 是股票
is_index_code("000001") # False —— 裸代码有歧义, 一律不判为指数
⚠️ 注意最后两行:000001 到底是“上证指数”还是“平安银行”,只看裸代码分不出来,必须带交易所后缀。所以 is_index_code 对裸代码一律返回 False——宁可漏判,也不误判。
2.5 “无全市场批量”的准确说法:只对分钟线成立
第 4 条边界需要说得更精确——官方 V0.9.3 新增了按日全市场接口,笼统说“没有批量”容易和它撞车。
官方 home.md 的版本记录里有一条容易漏掉的更新:
V0.9.3 版本 2026/07/10
新增“每日更新”相关的 API,query_daily_history_k_AStock()、query_daily_history_k_ETF()、query_daily_adjust_factor()。
这三个接口的参数只有一个 date(没有 code),意思是“某一日的全市场”。实测确认:
=== ⑦ V0.9.3 按日全市场接口(日线/ETF/复权因子, 均不含分钟粒度) ===
query_daily_history_k_AStock(date=2024-01-02): err=0 fields=18
前2行=[['2024-01-02', 'sh.600000', …], ['2024-01-02', 'sh.600004', …]]
query_daily_history_k_ETF(date=2026-02-02): err=0 fields=18
前2行=[['2026-02-02', 'sh.510010', …], ['2026-02-02', 'sh.510020', …]]
query_daily_adjust_factor(date=2024-01-02): err=0 fields=5
前2行=[['sz.002937', '2024-01-02', '0.957406', '1.695358', '1.695358']]
三个都返回当日全市场数据(首行分别是 sh.600000 / sh.510010 / sz.002937,不是单只),字段数分别是 18 / 18 / 5。
但它们全都是日线粒度,一个字节的分钟数据都不给。 所以准确的说法是:
| 日线 / ETF / 复权因子 | ✅ 有(V0.9.3,2026-07-10 起) | query_daily_history_k_AStock(date) 等,一次调用拿全市场,不用逐只循环 |
| 分钟线(5/15/30/60) | ❌ 没有 | 只能逐只拉——本篇讲的正是这一条 |
这个区分很实际:做全 A 日线,query_daily_history_k_AStock 能把调用次数从“5000+ 次”压到“1 次/天”;做全 A 分钟线,没有这种好事,只能小池子 + 逐只拉。
三、怎么拉:逐只取数与 datetime 合成
取数封装在 src/datafeed/minute.py,核心是 fetcher 工厂——注入 bs_module,所以离线能测、上线能跑:
from datafeed import make_baostock_minute_fetcher
fetcher = make_baostock_minute_fetcher(frequency="5", adjustflag="3")
df = fetcher("600519.SH", "2024-01-01", "2024-06-30")
# df: 8 列 [datetime, symbol, open, high, low, close, volume, amount]
内部有三处关键处理:
def fetcher(symbol, start, end):
rs = bs.query_history_k_data_plus(
_to_bs_code(symbol), # 600519.SH -> sh.600519
BS_MINUTE_FIELDS, # 显式列名, 不用 "date,time,code,open,…" 简写
start_date=str(start), end_date=str(end),
frequency=frequency, adjustflag=adjustflag)
if rs.error_code != "0":
raise MinuteError(...) # 唯一会响的失败: 频率非法等
rows = []
while rs.next():
rows.append(rs.get_row_data())
if not rows:
return pd.DataFrame(columns=CANONICAL_MINUTE) # 静默空 -> 空表, 不抛错
raw = pd.DataFrame(rows, columns=rs.fields) # 铁律: 用 rs.fields 建列
return normalize_minute(raw, symbol)
三条对照着看:
🔴 第 3 条的来历:S3-003 踩过一个坑——BaoStock 新老接口的第 5 列官方拼写不一致(新接口服务端实际返回 adjustFacto,少一个 r),而官方 demo 注释写的是 adjustFactor。照抄注释就会得到一列 NaN。分钟接口的字段名虽然没出过这个问题,但纪律统一:一律 rs.fields。
normalize_minute 负责把 17 位 time 合成 datetime:
t14 = out["time"].astype(str).str.strip().str[:14] # 20240102093500
hhmmss = t14.str[8:10] + ":" + t14.str[10:12] + ":" + t14.str[12:14]
dt = out["date"].astype(str).str.strip() + " " + hhmmss # 2024-01-02 09:35:00
out["datetime"] = pd.to_datetime(dt, format="%Y-%m-%d %H:%M:%S", errors="coerce")
⚠️ 这里也有个坑:别写成 format="%Y-%m-%d %Y%m%d%H%M%S"——Python 的 strptime 不允许 %Y 出现两次(报 redefinition of group name 'Y')。用字符串切片手工插入冒号即可。
四、怎么放:分区键与物理分离
4.1 布局定义
data/minute/{symbol}/{year}.parquet # 例: data/minute/600519.SH/2024.parquet
8 列,与日线同构(首列由 date 换成 datetime):
datetime, symbol, open, high, low, close, volume, amount
4.2 为什么顶层按 symbol 而不是 year
日线(S3-004/005)定的是 data/daily/{year}/{symbol}.parquet——年在前。因为全 A 日线的典型查询是“某一年全市场横截面”。分钟线的查询姿势反过来:你要的是“某只股票某段时间的分钟序列”。分区键顺序必须与查询姿势对齐:
- 顶层 symbol:直接 glob("data/minute/600519.SH/*.parquet") 拿到该股全部年份,不必扫所有年份目录再筛;
- 顶层 year:查一只票要把所有年份目录都开一遍才知道哪些年份有它——这就是 S3-005 讲过的“碎文件陷阱”的镜像版本。
4.3 为什么必须和 data/daily/ 物理分离
data/daily/、data/minute/、data/tick/ 三者平级,谁也不进谁的目录。混存的代价是具体的:DuckDB 扫日线分区时(S3-006 的 scan_daily_files)如果分钟文件混在同一棵目录树下,一次全市场扫描会顺带打开几万个分钟文件,把列存优势吃光。分离之后,两条管道各查各的,互不干扰。
#mermaid-svg-1el6dU04HZNa0uGb{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-1el6dU04HZNa0uGb .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-1el6dU04HZNa0uGb .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-1el6dU04HZNa0uGb .error-icon{fill:#552222;}#mermaid-svg-1el6dU04HZNa0uGb .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-1el6dU04HZNa0uGb .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-1el6dU04HZNa0uGb .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-1el6dU04HZNa0uGb .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-1el6dU04HZNa0uGb .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-1el6dU04HZNa0uGb .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-1el6dU04HZNa0uGb .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-1el6dU04HZNa0uGb .marker{fill:#333333;stroke:#333333;}#mermaid-svg-1el6dU04HZNa0uGb .marker.cross{stroke:#333333;}#mermaid-svg-1el6dU04HZNa0uGb svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-1el6dU04HZNa0uGb p{margin:0;}#mermaid-svg-1el6dU04HZNa0uGb .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-1el6dU04HZNa0uGb .cluster-label text{fill:#333;}#mermaid-svg-1el6dU04HZNa0uGb .cluster-label span{color:#333;}#mermaid-svg-1el6dU04HZNa0uGb .cluster-label span p{background-color:transparent;}#mermaid-svg-1el6dU04HZNa0uGb .label text,#mermaid-svg-1el6dU04HZNa0uGb span{fill:#333;color:#333;}#mermaid-svg-1el6dU04HZNa0uGb .node rect,#mermaid-svg-1el6dU04HZNa0uGb .node circle,#mermaid-svg-1el6dU04HZNa0uGb .node ellipse,#mermaid-svg-1el6dU04HZNa0uGb .node polygon,#mermaid-svg-1el6dU04HZNa0uGb .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-1el6dU04HZNa0uGb .rough-node .label text,#mermaid-svg-1el6dU04HZNa0uGb .node .label text,#mermaid-svg-1el6dU04HZNa0uGb .image-shape .label,#mermaid-svg-1el6dU04HZNa0uGb .icon-shape .label{text-anchor:middle;}#mermaid-svg-1el6dU04HZNa0uGb .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-1el6dU04HZNa0uGb .rough-node .label,#mermaid-svg-1el6dU04HZNa0uGb .node .label,#mermaid-svg-1el6dU04HZNa0uGb .image-shape .label,#mermaid-svg-1el6dU04HZNa0uGb .icon-shape .label{text-align:center;}#mermaid-svg-1el6dU04HZNa0uGb .node.clickable{cursor:pointer;}#mermaid-svg-1el6dU04HZNa0uGb .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-1el6dU04HZNa0uGb .arrowheadPath{fill:#333333;}#mermaid-svg-1el6dU04HZNa0uGb .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-1el6dU04HZNa0uGb .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-1el6dU04HZNa0uGb .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-1el6dU04HZNa0uGb .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-1el6dU04HZNa0uGb .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-1el6dU04HZNa0uGb .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-1el6dU04HZNa0uGb .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-1el6dU04HZNa0uGb .cluster text{fill:#333;}#mermaid-svg-1el6dU04HZNa0uGb .cluster span{color:#333;}#mermaid-svg-1el6dU04HZNa0uGb div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-1el6dU04HZNa0uGb .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-1el6dU04HZNa0uGb rect.text{fill:none;stroke-width:0;}#mermaid-svg-1el6dU04HZNa0uGb .icon-shape,#mermaid-svg-1el6dU04HZNa0uGb .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-1el6dU04HZNa0uGb .icon-shape p,#mermaid-svg-1el6dU04HZNa0uGb .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-1el6dU04HZNa0uGb .icon-shape .label rect,#mermaid-svg-1el6dU04HZNa0uGb .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-1el6dU04HZNa0uGb .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-1el6dU04HZNa0uGb .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-1el6dU04HZNa0uGb :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
Tick (散户一般不碰)
分钟库 (本篇)
行情底座 (S3-004~006)
年在前: 横截面查询
symbol 在前: 单票时间序列
事件流, 按日再切
x
x
data/daily/{year}/{sym}.parquetdata/daily/daily_{year}.parquet
data/minute/{sym}/{year}.parquet
data/tick/{sym}/{date}.parquet
get_daily / scan_daily
query_minute
—
五、落库与查询:三个 API,外加一个 end 语义坑
5.1 落库:按年路由 + 去重 + 原子写
append_minute 把新行按 datetime 的年份拆到各自年份文件,逐年原子合并:
part = new.assign(_yr=new["datetime"].dt.year)
for yr, sub in part.groupby("_yr"):
_merge_atomic(minute_path(norm, int(yr)), sub[CANONICAL_MINUTE])
_merge_atomic 沿用 S3-006 的模式:按 datetime 去重保最新 + tmp → os.replace 原子写。所以同一天重跑、重叠区间补跑都不会翻倍。
真实验证(本机实跑):
拉取 sh.600519 5min 2024-01-01 ~ 2024-03-31 …
fetched=2784 appended=2784 years=[2024] 耗时=12.5s
-> E:\\ashare-quant\\data\\minute\\600519.SH\\2024.parquet
本地库: 2784 行, 最后 bar = 2024-03-29 15:00:00
首行 close=1713.99 末行 close=1702.9
再追加第二季度(验证增量进同一文件、不拆新文件):
拉取 sh.600519 5min 2024-04-01 ~ 2024-06-30 …
fetched=2832 appended=2832 years=[2024] 耗时=3.1s
本地库: 5616 行, 最后 bar = 2024-06-28 15:00:00
首行 close=1713.99 末行 close=1467.39
2784 → 5616 行(+2832),仍在 2024.parquet 一个文件里。
5.2 最后一根 bar:get_last_minute
与 get_last_date 同思路,跨年份文件取全局 max:
last = get_last_minute("600519.SH") # Timestamp('2024-06-28 15:00:00')
这是分钟线增量管道的起点依据(§六)。
5.3 查询:query_minute 与那个 end 语义坑
df = query_minute("600519.SH", start="2024-01-02", end="2024-03-29")
DuckDB 零拷贝直读,Pandas 后端自动回退。这里修掉了本篇的一个真 BUG:最初实现直接 datetime <= pd.Timestamp(end),而 pd.Timestamp("2024-01-03") 是当天 00:00:00——于是“截止到 1 月 3 日”会把 1 月 3 日全天 48 根 bar 全部过滤掉,只剩前两天。这个 bug 是 pytest 的 test_query_range 抓出来的(当时 assert len(df) == 1 得到 0)。
修法是识别“日期型 end”并补到当天最后一秒:
end_ts = pd.Timestamp(end)
if end_ts == end_ts.normalize(): # 说明只给了日期
end_ts = end_ts + pd.Timedelta(days=1) – pd.Timedelta(seconds=1) # -> 23:59:59
🔴 这个坑对所有“日期区间查询”都成立:日线库因为是日频,date 本身就是营业日零点,碰不到;一旦引入日内时间戳,凡按“日期”传参的地方都要问一句“这个 end 包不包含当天”。分钟线把所有这类隐患暴露出来了。
六、日常维护与全链路:和 S3-006 共用一套三件套
分钟线的日常维护不需要重新发明——S3-006 的日线三件套平移即可,差异只有三处:
| 起点 | get_last_date(symbol) | get_last_minute(symbol) |
| 追加 | append_daily → data/daily/ | append_minute → data/minute/{symbol}/{year} |
| checkpoints | 日线 checkpoint | 独立分钟 checkpoint |
#mermaid-svg-bN9uge9bMMsHwT4d{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-bN9uge9bMMsHwT4d .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-bN9uge9bMMsHwT4d .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-bN9uge9bMMsHwT4d .error-icon{fill:#552222;}#mermaid-svg-bN9uge9bMMsHwT4d .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-bN9uge9bMMsHwT4d .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-bN9uge9bMMsHwT4d .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-bN9uge9bMMsHwT4d .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-bN9uge9bMMsHwT4d .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-bN9uge9bMMsHwT4d .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-bN9uge9bMMsHwT4d .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-bN9uge9bMMsHwT4d .marker{fill:#333333;stroke:#333333;}#mermaid-svg-bN9uge9bMMsHwT4d .marker.cross{stroke:#333333;}#mermaid-svg-bN9uge9bMMsHwT4d svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-bN9uge9bMMsHwT4d p{margin:0;}#mermaid-svg-bN9uge9bMMsHwT4d .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-bN9uge9bMMsHwT4d .cluster-label text{fill:#333;}#mermaid-svg-bN9uge9bMMsHwT4d .cluster-label span{color:#333;}#mermaid-svg-bN9uge9bMMsHwT4d .cluster-label span p{background-color:transparent;}#mermaid-svg-bN9uge9bMMsHwT4d .label text,#mermaid-svg-bN9uge9bMMsHwT4d span{fill:#333;color:#333;}#mermaid-svg-bN9uge9bMMsHwT4d .node rect,#mermaid-svg-bN9uge9bMMsHwT4d .node circle,#mermaid-svg-bN9uge9bMMsHwT4d .node ellipse,#mermaid-svg-bN9uge9bMMsHwT4d .node polygon,#mermaid-svg-bN9uge9bMMsHwT4d .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-bN9uge9bMMsHwT4d .rough-node .label text,#mermaid-svg-bN9uge9bMMsHwT4d .node .label text,#mermaid-svg-bN9uge9bMMsHwT4d .image-shape .label,#mermaid-svg-bN9uge9bMMsHwT4d .icon-shape .label{text-anchor:middle;}#mermaid-svg-bN9uge9bMMsHwT4d .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-bN9uge9bMMsHwT4d .rough-node .label,#mermaid-svg-bN9uge9bMMsHwT4d .node .label,#mermaid-svg-bN9uge9bMMsHwT4d .image-shape .label,#mermaid-svg-bN9uge9bMMsHwT4d .icon-shape .label{text-align:center;}#mermaid-svg-bN9uge9bMMsHwT4d .node.clickable{cursor:pointer;}#mermaid-svg-bN9uge9bMMsHwT4d .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-bN9uge9bMMsHwT4d .arrowheadPath{fill:#333333;}#mermaid-svg-bN9uge9bMMsHwT4d .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-bN9uge9bMMsHwT4d .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-bN9uge9bMMsHwT4d .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-bN9uge9bMMsHwT4d .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-bN9uge9bMMsHwT4d .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-bN9uge9bMMsHwT4d .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-bN9uge9bMMsHwT4d .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-bN9uge9bMMsHwT4d .cluster text{fill:#333;}#mermaid-svg-bN9uge9bMMsHwT4d .cluster span{color:#333;}#mermaid-svg-bN9uge9bMMsHwT4d div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-bN9uge9bMMsHwT4d .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-bN9uge9bMMsHwT4d rect.text{fill:none;stroke-width:0;}#mermaid-svg-bN9uge9bMMsHwT4d .icon-shape,#mermaid-svg-bN9uge9bMMsHwT4d .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-bN9uge9bMMsHwT4d .icon-shape p,#mermaid-svg-bN9uge9bMMsHwT4d .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-bN9uge9bMMsHwT4d .icon-shape .label rect,#mermaid-svg-bN9uge9bMMsHwT4d .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-bN9uge9bMMsHwT4d .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-bN9uge9bMMsHwT4d .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-bN9uge9bMMsHwT4d :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
是(三种可能)
否
get_last_minute(sym)跨年份文件取全局 max
start = last + 1 根或 MINUTE_START(2020-01-02)
BaoStock fetcherfrequency / adjustflag=3
返回 0 行?
先排查:指数? 早于 2020-01-02? 非交易日?
append_minute按年路由 + 去重 + 原子写
落 data/minute/{sym}/{year}.parquet
跳过/裁剪区间, 不写盘
为什么各跑各的:日线管道每天 20:00 前跑、分钟管道 20:00 后跑(官网分钟线入库时点),互不影响——这正是“物理分离”带来的工程红利:一套挂了不会污染另一套。
📌 分钟管道的完整增量实现(按标的池、checkpoint 断点续传、限速)本篇不重复贴码——它与 src/datafeed/update.py 的 IncrementalClient 同构,把 append_daily 换成 append_minute、checkpoint 路径换一个即可。避免和 S3-006 重复造轮子。
七、踩坑速查表
| 1 | 无 1 分钟线 | frequency="1" → 10004012 请求数据类型不正确 | VALID_FREQ=("5","15","30","60") 构造期拦截 |
| 2 | 起点被官方文档写晚了一天 | 官方写 2020-01-03,实测 2020-01-02 已有 48 根;照官方写会默认漏一整天 | MINUTE_START="2020-01-02"(取实测值) |
| 3 | 指数分钟线不可依赖 | 沪市指数(sh.000xxx)全 0 行;深市指数(sz.399xxx)有,但只有 5/15 分钟、只覆盖 2023-08-09 ~ 2024-09-18 | 指数不带分钟;is_index_code() 提前识别并告警 |
| 4 | 0 行含义过多 | 超起点 / 非交易日 / 指数(含深市残缺窗口)→ 返回值完全相同 | 调用前自查三件事,不靠返回值判断原因 |
| 5 | 首根是 09:35 | 5 分钟首根时间戳 09:35,不是 09:30 | 记住时间戳 = 区间结束时刻 |
| 6 | time 17 位 | 20240102093500000 | 截前 14 位再插冒号合成 datetime |
| 7 | strptime 重复 %Y | format="%Y-%m-%d %Y%m%d%H%M%S" 直接报正则错 | 用字符串切片,不写重复格式符 |
| 8 | end 日期型漏当天 | datetime <= 00:00:00 把当天 bar 全过滤 | 日期型 end 补到 23:59:59 |
| 9 | 分钟库混进日线 | DuckDB 扫日线顺带开几万分钟文件 | data/minute/ 与 data/daily/ 物理分离 |
| 10 | 20:00 才入库 | 盘中/刚收盘拉不到当日分钟 | 定时任务设在 20:00 之后 |
| 11 | 误以为“分钟线也能全市场批量” | V0.9.3 新增 query_daily_history_k_AStock(date) 等按日接口,但它们只有日线粒度 | 分钟线仍只能逐只拉;别把日线批量接口当成分钟线也能批量 |
| 12 | frequency 与 fields 必须匹配 | frequency="d" 配含 time 的分钟字段 → 10004012 日线指标参数传入错误:time | 分钟线用分钟字段表,日线用日线字段表,别混 |
| 13 | 版本号看错 | baostock.__version__ 返回 00.9.30(前导多一个 0),真实版本是 0.9.3 | 用 importlib.metadata.version("baostock") |
| 14 | 分钟线固化复权价 | 把前/后复权价写进库,多基准混用、无法回溯 | 落库一律 adjustflag="3"(不复权),复权在查询层折算 |
独立测试 45 passed(含 datetime 合成、布局、去重、跨年路由、frequency 校验、桩 bs 建列、静默空、原子写、DuckDB/Pandas 双后端一致、range 语义、指数识别与告警、起点实测值、真实 600519 只读);S3-004/005/006 的 52 passed 回归不受影响——新增分钟模块没有破坏日线契约。
八、小结与下篇预告
小结:这一篇把“日线之外的世界”从“能不能拿到”钉到“怎么存、怎么查”,五件事:
到现在,你的本地库里已经攒齐:S3-002 的日线 raw 管道 + S3-003 的复权因子 + S3-004 的查询层折算 + S3-004~006 的可查询本地库与增量管道 + 本篇的分钟线取值与落库。但还有一块数据的大坑没填——财务数据。
下一篇预告:专栏 S3 的第八篇《财务数据的 PIT 问题:报告期 vs 公告日,一字之差全盘皆输》。我会讲清为什么你回测里用的“最新财报”很可能是未来函数——报告期是 3 月 31 日,但公告日可能拖到 4 月 28 日,你的策略在 4 月 1 日“看到”这份财报,等于提前偷看了 27 天。带着本篇“先把边界钉死再动手”的思路,我们下篇见。
附:可运行脚本(复制即用)
三个文件,全部与 E:\\ashare-quant\\ 里的真实文件逐字节一致,复制保存即可直接使用。
| 1 | src/datafeed/minute.py | 分钟引擎:取数 / 归一 / 落库 / 查询 / 量级核算 / 指数识别 | 必需 |
| 2 | src/datafeed/__init__.py | 包导出(把分钟 API 暴露成 from datafeed import query_minute) | 必需 |
| 3 | src/s3_007_minute_fetch.py | 主脚本 CLI:–self-test / –size-calc / –live-probe / –live | 必需 |
本篇另有一份独立 pytest(src/test_s3_007_minute.py,45 条)留在代码仓库里,用于回归校验(正文里的 45 passed 即出自它)。它不影响读者使用上面的三个文件,故不在此嵌入。
环境准备:
conda activate ashare
pip install baostock -i https://pypi.org/simple # baostock 是 PyPI-only, conda 无此包
pip install pandas pyarrow # 必装
pip install duckdb # 推荐(query_minute 默认引擎)
运行顺序:
cd E:\\ashare-quant
python src/s3_007_minute_fetch.py –self-test # 离线断言 10/10 ✅
python src/s3_007_minute_fetch.py –size-calc # 量级 & 成本核算
python src/s3_007_minute_fetch.py –live-probe # 真实探测(频率/起点/指数边界/批量接口/性能)
python src/s3_007_minute_fetch.py –live 600519.SH –start 2024-01-01 –end 2024-06-30 –freq 5
⚠️ –self-test / –size-calc 全部隔离在临时目录,不读写真实 data/;–live / –live-probe 需网络。文中量级数字基于本机 baostock 0.9.3 / pandas 3.0.5 / pyarrow 24.0.0 / duckdb 1.5.5 实测外推,会随机器与库版本变化,请按你自己的环境重跑校准。
1. src/datafeed/minute.py(新增)
分钟引擎,下面两个文件都依赖它。需要 datafeed 包已存在(S3-004 建的,提供 _config.DATA_DIR / DAILY_DIR / normalize_symbol)。复制到 src/datafeed/ 下。
"""datafeed.minute —— 分钟线获取/落库/查询引擎 (S3-007)。
定位(M2 模块): 日线之外的**细分粒度**数据。免费源里只有 BaoStock 给分钟线,
但它的形态和日线完全不同,必须单独一套路径与布局:
– 只有 5/15/30/60 分钟,**没有 1 分钟**(实测 frequency="1" -> 10004012);
– 只有近 5 年(**官方文档写 2020-01-03, 实测 2020-01-02 已有 48 根**),
边界外**静默返空不报错**;
– **指数分钟线不可依赖**(2026-09-29 实测): 官方文档称"指数不提供分钟K线",
实测**沪市指数(sh.000xxx)完全无数据**; **深市指数(sz.399xxx)虽返回数据,
但只有 5/15 分钟、且只覆盖 2023-08-09 ~ 2024-09-18 一小段**(30/60 分钟为空,
其余年份为空, 距 2026-09-29 已断档) -> 免费方案里指数没有可用的分钟数据;
– **分钟线没有全市场批量**,只能逐只拉(所以只适合小池子)。
注意 V0.9.3(2026-07-10) 起 **日线/ETF/复权因子**另有按日全市场接口
(`query_daily_history_k_AStock` / `_k_ETF` / `query_daily_adjust_factor`),
但**均不含分钟粒度**。
落库布局(与 data/daily/ 物理分离, 绝不同目录):
data/minute/{symbol}/{year}.parquet # 例: data/minute/600519.SH/2024.parquet
为什么 symbol 在前: 查分钟线的姿势是"某只股票某段时间的分钟序列",
分区键顺序必须与查询姿势对齐, 直接 glob 该股目录即可。
列(8 列, 沿用日线"raw + 量额不动"约定):
datetime, symbol, open, high, low, close, volume, amount
铁律(对齐架构 v1.4):
– 落盘只存 **不复权**(adjustflag=3) 分钟价; 复权在查询层按需折算;
– 增量一律原子写(tmp + os.replace) + 按 datetime 去重保最新。
"""
from __future__ import annotations
import warnings
from pathlib import Path
from typing import Callable
import numpy as np
import pandas as pd
from . import _config as cfg
# —- 常量 —————————————————————-
#: BaoStock 支持的分钟频率(实测 "1" 报 10004012, 无 1 分钟线)
VALID_FREQ = ("5", "15", "30", "60")
#: 分钟线可取的最早日期。
#: 🔴 官方文档(home.md 数据范围)写的是"2020-01-03 至今", 但 2026-09-29 实测
#: **2020-01-02 已有 48 根**(元旦假后首个交易日) -> 取实测更早值, 避免默认起点漏一整天。
#: 另注: 官方把这段叫"近 5 年", 但起点是**固定**的, 不是滚动窗口 ——
#: 2026-09-29 时点距该起点已约 6 年 9 个月。
MINUTE_START = "2020-01-02"
#: 指数分钟线的实测边界(2026-09-29 核实; 官方文档称"指数不提供分钟K线"):
#: 沪市指数 sh.000xxx -> **完全无数据**(n=0, error=0 静默空);
#: 深市指数 sz.399xxx -> 有数据, 但**只有 5/15 分钟**、且只覆盖 2023-08-09 ~ 2024-09-18。
#: => 指数分钟线不可依赖; 免费方案里指数没有可用的分钟级数据。
INDEX_MINUTE_SUPPORTED_EXCHANGE = "sz" # 仅深市 399xxx 会返回数据
INDEX_MINUTE_SUPPORTED_FREQ = ("5", "15") # 且只有 5/15 分钟(30/60 为空)
INDEX_MINUTE_WINDOW = ("2023-08-09", "2024-09-18") # 且只覆盖这一小段
#: 分钟线落库 8 列(与日线同构, 首列 datetime 而非 date)
CANONICAL_MINUTE = ["datetime", "symbol", "open", "high", "low",
"close", "volume", "amount"]
#: 分钟线根目录(与 DAILY_DIR 平级, 物理分离)
MINUTE_DIR = cfg.DATA_DIR / "minute"
#: BaoStock 分钟接口返回的字段(实测 rs.fields 顺序)
BS_MINUTE_FIELDS = "date,time,code,open,high,low,close,volume,amount,adjustflag"
# —- 异常 —————————————————————-
class MinuteError(RuntimeError):
"""分钟线获取/落库过程中的通用异常。"""
# —- 代码判别 ————————————————————-
def is_index_code(symbol: str) –> bool:
"""粗判是否指数代码: 沪市 000xxx/950xxx、深市 399xxx。
兼容两种写法 —— 项目规范 ``000300.SH`` 与 BaoStock 原生 ``sh.000300``。
裸代码(无后缀)一律返回 False: 000001 既可能是上证指数(sh.000001),
也可能是平安银行(sz.000001), 无从判别, 宁可漏判不误判。
用途: 指数分钟线不可依赖(见模块 docstring), 取数前先识别, 把"静默 0 行"
翻译成看得懂的告警。
"""
s = str(symbol).strip()
if "." not in s:
return False
a, _, b = s.partition(".")
a, b = a.strip(), b.strip()
if a.lower() in ("sh", "sz", "bj"):
ex, code = a.lower(), b # sh.000300
elif b.lower() in ("sh", "sz", "bj"):
ex, code = b.lower(), a # 000300.SH
else:
return False
if ex == "sz":
return code.startswith("399")
if ex == "sh":
return code.startswith("000") or code.startswith("950")
return False
# —- 路径构造 ————————————————————-
def minute_path(symbol: str, year: int | str) –> Path:
"""分钟线文件路径: data/minute/{symbol}/{year}.parquet(symbol 顶层分区)。"""
return MINUTE_DIR / cfg.normalize_symbol(symbol) / f"{int(year)}.parquet"
def minute_dir_of(symbol: str) –> Path:
"""某标的的分钟线目录: data/minute/{symbol}/。"""
return MINUTE_DIR / cfg.normalize_symbol(symbol)
def _minute_files(symbol: str) –> list[Path]:
"""该标的已落盘的全部分钟文件(按年份排序)。"""
d = minute_dir_of(symbol)
return sorted(d.glob("*.parquet")) if d.exists() else []
# —- 归一化 —————————————————————
def normalize_minute(df: pd.DataFrame, symbol: str) –> pd.DataFrame:
"""把 BaoStock 原始分钟 DataFrame 归一成落库 8 列。
输入需含 date + time 两列(BaoStock 原样), 输出 datetime 合成列。
time 实测格式 = '20240102093500000'(17 位 YYYYMMDDHHMMSSsss)。
"""
out = df.copy()
out["symbol"] = cfg.normalize_symbol(symbol)
if "datetime" not in out.columns:
if "date" in out.columns and "time" in out.columns:
# time 是 17 位数字串: 截前 14 位 = YYYYMMDDHHMMSS; date 形如 2024-01-02
# 拼成 2024-01-02 09:35:00 (用 str 切片而非 strptime, 避免 %Y 重复组名)
t14 = out["time"].astype(str).str.strip().str[:14]
hhmmss = (t14.str[8:10] + ":" + t14.str[10:12] + ":" + t14.str[12:14])
dt = out["date"].astype(str).str.strip() + " " + hhmmss
elif "date" in out.columns:
dt = out["date"].astype(str).str.strip()
else:
raise MinuteError(f"缺少 date/time 列, 无法合成 datetime: {list(df.columns)}")
out["datetime"] = pd.to_datetime(dt, format="%Y-%m-%d %H:%M:%S", errors="coerce")
else:
out["datetime"] = pd.to_datetime(out["datetime"], errors="coerce")
for c in ("open", "high", "low", "close", "volume", "amount"):
out[c] = pd.to_numeric(out.get(c, np.nan), errors="coerce")
return (out[CANONICAL_MINUTE]
.dropna(subset=["datetime"])
.sort_values("datetime")
.reset_index(drop=True))
# —- 原子写 —————————————————————
def _atomic_write(target: Path, df: pd.DataFrame) –> None:
"""原子写: 先写同目录 .tmp, 再 os.replace; 崩溃也不留半截文件。"""
import os
target.parent.mkdir(parents=True, exist_ok=True)
tmp = target.with_suffix(target.suffix + ".tmp")
df.to_parquet(tmp, index=False)
os.replace(tmp, target)
def _merge_atomic(target: Path, new_df: pd.DataFrame) –> int:
"""按 datetime 去重保最新合并进 target, 原子写回; 返回落盘行数。"""
if target.exists():
old = pd.read_parquet(target)
merged = pd.concat([old, new_df], ignore_index=True)
merged = (merged.drop_duplicates(subset=["datetime"], keep="last")
.sort_values("datetime").reset_index(drop=True))
else:
merged = new_df.sort_values("datetime").reset_index(drop=True)
_atomic_write(target, merged)
return len(merged)
# —- 落库 —————————————————————-
def append_minute(symbol: str, df: pd.DataFrame) –> dict:
"""增量追加分钟线到 data/minute/{symbol}/{year}.parquet。
自动把新行按年路由到各自的年份文件, 逐年原子合并去重。
返回 report: {symbol, appended, targets, years}。
"""
norm = cfg.normalize_symbol(symbol)
new = normalize_minute(df, norm)
if new.empty:
return {"symbol": norm, "appended": 0, "targets": [], "years": []}
part = new.assign(_yr=new["datetime"].dt.year)
targets, years = [], []
for yr, sub in part.groupby("_yr"):
p = minute_path(norm, int(yr))
_merge_atomic(p, sub[CANONICAL_MINUTE])
targets.append(str(p))
years.append(int(yr))
return {"symbol": norm, "appended": len(new), "targets": targets, "years": years}
# —- 只读: 最后一根分钟 —————————————————
def get_last_minute(symbol: str) –> "pd.Timestamp | None":
"""返回该标的本地分钟库的最后一根 bar 时间; 无数据返回 None。"""
files = _minute_files(symbol)
if not files:
return None
last = None
for p in files:
try:
mx = pd.read_parquet(p, columns=["datetime"])["datetime"].max()
except Exception:
continue
if last is None or mx > last:
last = mx
return None if last is None else pd.Timestamp(last)
# —- BaoStock 分钟 fetcher ————————————————
def make_baostock_minute_fetcher(frequency: str = "5",
adjustflag: str = "3",
bs_module=None) –> Callable:
"""构造 BaoStock 分钟线 fetcher: (symbol, start, end) -> 归一 8 列 DataFrame。
参数:
frequency: '5' / '15' / '30' / '60'(无 1 分钟)
adjustflag: '3'=不复权(默认, 落库铁律) / '1'=后复权 / '2'=前复权
bs_module: 注入 baostock 模块(测试可传桩), 默认 import baostock
返回的函数已处理 BaoStock 三个静默坑:
① 边界外/非交易日 **静默返 0 行**(不报错) -> 返回空 DataFrame, 由调用方判定;
② 指数无分钟线(同样静默 0 行);
③ 字段一律用 rs.fields 建列, 绝不照抄官方注释。
"""
if frequency not in VALID_FREQ:
raise MinuteError(
f"frequency 必须 ∈ {VALID_FREQ}(BaoStock 无 1 分钟线), 传入 {frequency!r}")
def _to_bs_code(symbol: str) –> str:
"""600519.SH -> sh.600519; 000001.SZ -> sz.000001。"""
norm = cfg.normalize_symbol(symbol)
code, _, ex = norm.partition(".")
return f"{ex.lower()}.{code}"
def fetcher(symbol: str, start: str, end: str) –> pd.DataFrame:
if is_index_code(symbol):
warnings.warn(
f"{symbol} 是指数: 指数分钟线不可依赖 —— 沪市指数完全无数据; "
f"深市指数只有 {'/'.join(INDEX_MINUTE_SUPPORTED_FREQ)} 分钟, 且仅覆盖 "
f"{INDEX_MINUTE_WINDOW[0]} ~ {INDEX_MINUTE_WINDOW[1]}。"
f"拿到空表属预期, 别当“数据缺失/非交易日”去排查。",
stacklevel=2)
bs = bs_module
if bs is None:
import baostock as bs # 局部 import, 未装时可离线用别的 fetcher
rs = bs.query_history_k_data_plus(
_to_bs_code(symbol), BS_MINUTE_FIELDS,
start_date=str(start), end_date=str(end),
frequency=frequency, adjustflag=adjustflag)
if rs.error_code != "0":
raise MinuteError(f"BaoStock 分钟接口错误: {rs.error_code} {rs.error_msg}")
rows = []
while rs.next():
rows.append(rs.get_row_data())
if not rows:
# 静默空: 可能是非交易日 / 超出 5 年边界 / 指数无分钟线
return pd.DataFrame(columns=CANONICAL_MINUTE)
raw = pd.DataFrame(rows, columns=rs.fields) # 铁律: 用 rs.fields, 不照抄注释
return normalize_minute(raw, symbol)
return fetcher
# —- 查询(DuckDB) ———————————————————
def query_minute(symbol: str, start=None, end=None,
backend: str = "auto") –> pd.DataFrame:
"""查询某标的分钟线(本地库), 返回 8 列 DataFrame。
backend: 'auto'(装了 duckdb 就用) / 'duckdb' / 'pandas'
"""
files = _minute_files(symbol)
if not files:
return pd.DataFrame(columns=CANONICAL_MINUTE)
norm = cfg.normalize_symbol(symbol)
use_duck = False
if backend in ("auto", "duckdb"):
try:
import duckdb # noqa: F401
use_duck = True
except ImportError:
if backend == "duckdb":
raise
# end 语义: 若只给到日期(00:00:00), 补到当天 23:59:59, 否则"末 bar"会被排除
end_ts = None
if end is not None:
end_ts = pd.Timestamp(end)
if end_ts == end_ts.normalize():
end_ts = end_ts + pd.Timedelta(days=1) – pd.Timedelta(seconds=1)
start_ts = pd.Timestamp(start) if start is not None else None
if use_duck:
import duckdb
con = duckdb.connect()
paths = [str(p) for p in files]
sql = ("SELECT datetime, symbol, open, high, low, close, volume, amount "
"FROM read_parquet(?)")
params = [paths]
where, cond = [], []
if start_ts is not None:
cond.append("datetime >= ?"); params.append(start_ts)
if end_ts is not None:
cond.append("datetime <= ?"); params.append(end_ts)
if cond:
sql += " WHERE " + " AND ".join(cond)
sql += " ORDER BY datetime"
try:
df = con.execute(sql, params).df()
finally:
con.close()
else:
df = pd.concat([pd.read_parquet(p) for p in files], ignore_index=True)
df = df[df["symbol"] == norm] if "symbol" in df.columns else df
if start_ts is not None:
df = df[df["datetime"] >= start_ts]
if end_ts is not None:
df = df[df["datetime"] <= end_ts]
df = df.sort_values("datetime").reset_index(drop=True)
return df[CANONICAL_MINUTE] if len(df) else pd.DataFrame(columns=CANONICAL_MINUTE)
# —- 量级测算(S3-007 决策依据, 纯计算零依赖) ——————————
def row_counts(universe: int = 5200, trade_days: int = 242,
tick_per_stock_day: int = 50_000) –> dict:
"""纯计算三种粒度的年行数(不落盘, 零依赖)。
日线: universe × trade_days
1 分钟: universe × trade_days × 240
Tick : universe × trade_days × tick_per_stock_day
"""
daily = universe * trade_days
minute1 = daily * 240
tick = daily * tick_per_stock_day
return {"daily": daily, "minute_1m": minute1, "tick": tick,
"universe": universe, "trade_days": trade_days,
"tick_per_stock_day": tick_per_stock_day}
def estimate_annual(sample_rows: dict | None = None) –> dict:
"""用真实 Parquet 测量单文件字节数, 外推全 A 年规模(需 pandas/pyarrow)。
sample_rows: {粒度: 构造行数}, 默认 {daily:242, minute:240, tick:10000}
"""
sample_rows = sample_rows or {"daily": 242, "minute": 240, "tick": 10_000}
import io as _io
def _bytes_per_row(kind: str, n: int) –> tuple[int, int]:
if kind == "daily":
df = pd.DataFrame({
"date": pd.date_range("2024-01-01", periods=n, freq="D"),
"symbol": "600519.SH", "open": 1700.0, "high": 1720.0,
"low": 1680.0, "close": 1710.0,
"volume": 3_000_000.0, "amount": 5_100_000_000.0})
elif kind == "minute":
df = pd.DataFrame({
"datetime": pd.date_range("2024-01-02 09:35", periods=n, freq="5min"),
"symbol": "600519.SH", "open": 1700.0, "high": 1710.0,
"low": 1690.0, "close": 1705.0,
"volume": 30_000.0, "amount": 51_000_000.0})
else: # tick
df = pd.DataFrame({
"datetime": pd.date_range("2024-01-02 09:30", periods=n, freq="s"),
"symbol": "600519.SH", "price": 1700.0, "volume": 100.0,
"amount": 170_000.0, "direction": "B", "order_type": "0"})
buf = _io.BytesIO()
df.to_parquet(buf, index=False)
return buf.getbuffer().nbytes, n
rc = row_counts()
out = {}
for kind, n in sample_rows.items():
nbytes, rows = _bytes_per_row(kind, n)
per_row = nbytes / rows
key = {"daily": "daily", "minute": "minute_1m", "tick": "tick"}[kind]
out[kind] = {
"sample_rows": rows,
"sample_bytes": nbytes,
"bytes_per_row": per_row,
"year_rows": rc[key],
"year_bytes": rc[key] * per_row,
"year_gb": rc[key] * per_row / 1024**3,
"year20_gb": rc[key] * per_row * 20 / 1024**3,
}
return out
2. src/datafeed/__init__.py(改一处)
如果你的包与 S3-004~006 的版本一致,可整体替换;否则只把 from .minute import (…) 那一段、以及 __all__ 里对应的名字加进去即可。
"""datafeed —— 自建本地行情库查询包 (S3-004)。
一行到手:
from datafeed import get_daily
df = get_daily("600519.SH", adjust="hfq") # 任意口径, 一行到手
设计: 落盘只存 raw(OHLC) + 独立复权因子表(S3-003 定案为 BaoStock 除权事件表,
旧逐日 adj_factor 表向后兼容); 复权在查询层实时折算,
绝不固化后复权价(对齐架构文档 v1.4 数据层铁律)。
"""
from .api import (
get_daily,
get_adj_factor,
apply_adjust,
classify_factor,
load_factor,
NoDataError,
MissingAdjFactorError,
)
from .query import ( # noqa: E402
scan_daily,
scan_adj,
query,
benchmark_scan,
connect,
)
from .minute import ( # noqa: E402
MinuteError,
VALID_FREQ,
MINUTE_START,
CANONICAL_MINUTE,
MINUTE_DIR,
INDEX_MINUTE_SUPPORTED_EXCHANGE,
INDEX_MINUTE_SUPPORTED_FREQ,
INDEX_MINUTE_WINDOW,
is_index_code,
minute_path,
minute_dir_of,
normalize_minute,
append_minute,
get_last_minute,
make_baostock_minute_fetcher,
query_minute,
row_counts,
estimate_annual,
)
from .update import ( # noqa: E402
to_tushare_code,
get_last_date,
append_daily,
append_adj,
get_market_dates,
get_market_last_date,
append_market_day,
fetch_incremental_daily,
fetch_incremental_market,
IncrementalClient,
FakeFetcher,
FakeMarketFetcher,
IncrementalError,
LAYOUT_PRIORITY,
MAX_ROWS_PER_CALL,
)
__all__ = [
"get_daily",
"get_adj_factor",
"apply_adjust",
"classify_factor",
"load_factor",
"NoDataError",
"MissingAdjFactorError",
"scan_daily",
"scan_adj",
"query",
"benchmark_scan",
"connect",
"to_tushare_code",
"get_last_date",
"append_daily",
"append_adj",
"get_market_dates",
"get_market_last_date",
"append_market_day",
"fetch_incremental_daily",
"fetch_incremental_market",
"IncrementalClient",
"FakeFetcher",
"FakeMarketFetcher",
"IncrementalError",
"LAYOUT_PRIORITY",
"MAX_ROWS_PER_CALL",
"MinuteError",
"VALID_FREQ",
"MINUTE_START",
"CANONICAL_MINUTE",
"MINUTE_DIR",
"INDEX_MINUTE_SUPPORTED_EXCHANGE",
"INDEX_MINUTE_SUPPORTED_FREQ",
"INDEX_MINUTE_WINDOW",
"is_index_code",
"minute_path",
"minute_dir_of",
"normalize_minute",
"append_minute",
"get_last_minute",
"make_baostock_minute_fetcher",
"query_minute",
"row_counts",
"estimate_annual",
]
3. src/s3_007_minute_fetch.py(新增)
主脚本,复制到 src/ 下。–self-test 离线跑 10 条断言、不碰真实 data/;–live-probe 联网探测四条边界并打印原始输出;–live SYMBOL 真实抓取并落库。
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""s3_007_minute_fetch.py —— 分钟线: 获取、落库与量级核算 (S3-007 / M2)。
运行(conda ashare):
python src/s3_007_minute_fetch.py –self-test # 离线断言(不联网/不污染真实 data/)
python src/s3_007_minute_fetch.py –size-calc # 量级 & 成本核算(纯本地测算)
python src/s3_007_minute_fetch.py –live 600519.SH # 真实调 BaoStock 抓分钟线(需网络)
python src/s3_007_minute_fetch.py –live-probe # 真实探测: 频率/起点/指数边界/批量接口/性能
依赖: pandas, pyarrow(必装); baostock(–live 需网络); duckdb(–size-calc 可选)。
2026-09-29 按官方 home.md + stockKData.md 复核并修正:
– MINUTE_START 由 2020-01-03 改为 2020-01-02(实测 01-02 已有 48 根);
– "指数无分钟线"修正为"指数分钟线不可依赖"(沪市指数完全无数据;
深市指数只有 5/15 分钟、仅覆盖 2023-08-09~2024-09-18);
– 新增 is_index_code() 在取数前识别指数并告警。
"""
from __future__ import annotations
import argparse
import socket
import sys
import tempfile
from pathlib import Path
SRC = Path(__file__).resolve().parent
if str(SRC) not in sys.path:
sys.path.insert(0, str(SRC))
import pandas as pd # noqa: E402
from datafeed import minute as mmod # noqa: E402
from datafeed import _config as cfg # noqa: E402
# —- 离线 self-test ——————————————————–
def self_test() –> int:
print("=" * 68)
print(" S3-007 分钟线 self-test (离线, 无需网络)")
print("=" * 68)
tmp = Path(tempfile.mkdtemp(prefix="s3_007_min_"))
real = mmod.MINUTE_DIR
mmod.MINUTE_DIR = tmp
def _raw(dates, freq_min=5, start_hhmm="0935"):
"""造 BaoStock 原始形态(date + 17 位 time)的行。"""
rows = []
for d in dates:
t = f"{d.replace('-', '')}{start_hhmm}00000"
rows.append({"date": d, "time": t, "code": "sh.600519",
"open": "1", "high": "2", "low": "0.5", "close": "1.5",
"volume": "100", "amount": "150", "adjustflag": "3"})
return pd.DataFrame(rows)
# ① 合成 datetime: 17 位 time -> Timestamp
df = mmod.normalize_minute(_raw(["2024-01-02"]), "600519.SH")
assert len(df) == 1 and df["datetime"].iloc[0] == pd.Timestamp("2024-01-02 09:35:00"), \\
f"datetime 合成错: {df['datetime'].iloc[0]}"
assert list(df.columns) == mmod.CANONICAL_MINUTE, "列不齐 8 列"
print(" ① ✅ time(17位) -> datetime 合成正确, 列对齐 8 列")
# ② append 落到 {symbol}/{year}.parquet, 且与 daily 物理分离
r = mmod.append_minute("600519.SH", _raw(["2024-01-02", "2024-01-03"]))
p = mmod.minute_path("600519.SH", 2024)
assert p.exists(), f"未生成 {p}"
assert p.parent.name == "600519.SH" and p.name == "2024.parquet", "布局应为 symbol/year"
# 物理分离: 真实分钟库根目录 != 日线库根目录, 且互不包含
_m, _d = str(real).lower(), str(cfg.DAILY_DIR).lower()
assert _m != _d and _m not in _d and _d not in _m, \\
f"分钟库与日线库必须物理分离: {_m} vs {_d}"
print(f" ② ✅ 落库 {p.relative_to(tmp)} (symbol 顶层); 分钟根目录与 daily 分离")
# ③ 去重幂等: 重叠追加只加新 datetime
before = len(mmod.query_minute("600519.SH"))
dup = pd.concat([_raw(["2024-01-03"]), _raw(["2024-01-04"])], ignore_index=True)
mmod.append_minute("600519.SH", dup)
after = len(mmod.query_minute("600519.SH"))
assert after – before == 1, f"重叠应只加 1 行, 实际 +{after – before}"
times = mmod.query_minute("600519.SH")["datetime"]
assert times.is_unique, "datetime 有重复, 去重失效"
print(f" ③ ✅ 重叠追加只加 1 新 datetime(去重生效), 无重复")
# ④ 跨年路由: 一次追加跨 2024/2025 -> 两个年份文件
mmod.append_minute("600519.SH", _raw(["2024-12-31", "2025-01-02"]))
assert mmod.minute_path("600519.SH", 2024).exists()
assert mmod.minute_path("600519.SH", 2025).exists(), "跨年未生成 2025 文件"
print(" ④ ✅ 跨年自动路由: 2024 / 2025 各自成文件")
# ⑤ get_last_minute = 全局 max(跨年份文件)
last = mmod.get_last_minute("600519.SH")
assert last == pd.Timestamp("2025-01-02 09:35:00"), f"last 算错: {last}"
print(f" ⑤ ✅ get_last_minute = {last} (跨年份文件取全局 max)")
# ⑥ frequency 校验: 无 1 分钟线, 非法值直接拦下
try:
mmod.make_baostock_minute_fetcher(frequency="1")
raise AssertionError("frequency='1' 应被拒绝")
except mmod.MinuteError:
pass
for f in mmod.VALID_FREQ:
mmod.make_baostock_minute_fetcher(frequency=f)
print(f" ⑥ ✅ frequency 校验: 只接受 {mmod.VALID_FREQ}, '1' 被拒(无 1 分钟线)")
# ⑦ 注入桩 bs: 验证 rs.fields 建列 + 静默空处理
class _FakeRS:
def __init__(self, rows, fields=None):
self.fields = fields or mmod._minute_fields() if hasattr(mmod, "_minute_fields") \\
else (fields or ["date", "time", "code", "open", "high", "low",
"close", "volume", "amount", "adjustflag"])
self._rows = rows
self.error_code, self.error_msg = "0", "success"
self._i = –1
def next(self):
self._i += 1
return self._i < len(self._rows)
def get_row_data(self):
return self._rows[self._i]
class _FakeBS:
def __init__(self, rows):
self.rows = rows
def query_history_k_data_plus(self, code, fields, **kw):
return _FakeRS(self.rows)
rows = [["2024-01-02", "20240102093500000", "sh.600519", "1", "2", "0.5",
"1.5", "100", "150", "3"]]
f_ok = mmod.make_baostock_minute_fetcher(frequency="5", bs_module=_FakeBS(rows))
got = f_ok("600519.SH", "2024-01-02", "2024-01-02")
assert len(got) == 1 and list(got.columns) == mmod.CANONICAL_MINUTE, "桩取数归一失败"
# 静默空: 非交易日/超边界 -> 空 DataFrame 不抛错(指数告警见 ⑨)
f_empty = mmod.make_baostock_minute_fetcher(frequency="5", bs_module=_FakeBS([]))
empty = f_empty("600519.SH", "2019-06-03", "2019-06-03")
assert len(empty) == 0, "静默空应返回空 DataFrame"
print(" ⑦ ✅ 桩 bs: rs.fields 建列归一正确; 静默空 -> 空 DataFrame(不抛错)")
# ⑧ 原子写无 .tmp 残留
assert not list(tmp.rglob("*.tmp")), f"存在 .tmp 残留: {list(tmp.rglob('*.tmp'))}"
print(" ⑧ ✅ 原子写: 无 .tmp 半成品残留")
# ⑨ 官方文档复核修正(2026-09-29): 起点 / 指数边界 固化为离线断言
assert mmod.MINUTE_START == "2020-01-02", \\
f"MINUTE_START 应为实测更早值 2020-01-02(官方文档写 2020-01-03), 实际 {mmod.MINUTE_START}"
# 指数识别: 项目规范 000300.SH 与 BaoStock 原生 sh.000300 都要认
for _c in ("000300.SH", "000001.SH", "399001.SZ", "399006.SZ", "sh.000300", "sz.399001"):
assert mmod.is_index_code(_c) is True, f"{_c} 应识别为指数"
for _c in ("000001.SZ", "600519.SH", "300750.SZ", "sz.000001", "sh.600519"):
assert mmod.is_index_code(_c) is False, f"{_c} 不是指数"
assert mmod.is_index_code("000001") is False, "裸代码歧义(指数/平安银行), 不判为指数"
# 指数取数必须告警(把静默 0 行翻译成看得懂的提示)
import warnings as _w
with _w.catch_warnings(record=True) as _caught:
_w.simplefilter("always")
mmod.make_baostock_minute_fetcher(
frequency="5", bs_module=_FakeBS([]))("000300.SH", "2024-01-02", "2024-01-02")
assert any("指数" in str(x.message) for x in _caught), "指数取数应发出告警"
print(f" ⑨ ✅ 官方复核固化: MINUTE_START={mmod.MINUTE_START}(实测早于官方 01-03); "
f"指数识别+告警正确")
# ⑩ 真实只读: 若仓库有分钟库则只读校验(没有则跳过)
mmod.MINUTE_DIR = real
if mmod._minute_files("600519.SH"):
lm = mmod.get_last_minute("600519.SH")
print(f" ⑩ ✅ 真实只读: get_last_minute('600519.SH') = {lm} (未写盘)")
else:
print(" ⑩ ⏭️ 真实分钟库为空, 跳过只读校验(先跑 –live 建库)")
mmod.MINUTE_DIR = real
print("=" * 68)
print(" 🎉 self-test 全部通过")
print("=" * 68)
return 0
# —- 量级 & 成本核算 ——————————————————-
def size_calc() –> int:
print("=" * 68)
print(" S3-007 量级 & 成本核算 (A股全市场 · 年维度 · Parquet 实测外推)")
print("=" * 68)
rc = mmod.row_counts()
print(f" 常量: 标的={rc['universe']} 年交易日={rc['trade_days']} "
f"分钟/日=240 Tick/股/日={rc['tick_per_stock_day']:,}")
print("-" * 68)
print(f" {'粒度':<12}{'年行数':>18}{'全A年(Parquet)':>20}{'全A 20年':>18}")
print("-" * 68)
est = mmod.estimate_annual()
label = {"daily": "日线", "minute": "1分钟线", "tick": "Tick"}
for k in ("daily", "minute", "tick"):
e = est[k]
yb = e["year_bytes"]
y20 = e["year20_gb"]
yb_s = f"{yb/1024**3:.1f} GB" if yb >= 1024**3 else f"{yb/1024**2:.1f} MB"
y20_s = f"{y20/1024:.1f} TB" if y20 >= 1024 else f"{y20:.1f} GB"
print(f" {label[k]:<12}{e['year_rows']:>18,}{yb_s:>20}{y20_s:>18}")
print("-" * 68)
# 5/15/30/60 分钟线年行数(同一 Bar 数, 频率越高行数越少)
print("\\n 各频段年行数(全 A, 基于 240 根/日 = 1 分钟基准):")
per_day = {"1min(无免费源)": 240, "5min": 48, "15min": 16, "30min": 8, "60min": 4}
bpr = est["minute"]["bytes_per_row"]
for k, n in per_day.items():
rows = rc["universe"] * rc["trade_days"] * n
gb = rows * bpr / 1024**3
print(f" {k:<16} {rows:>15,} 行 {gb:>7.2f} GB/年")
print("\\n 💡 结论: 硬盘不是瓶颈(分钟线一块移动硬盘装 20 年);")
print(" 真正的成本是**获取额度与时间**——BaoStock 无全市场批量, 只能逐只拉。")
return 0
# —- 真实探测 ————————————————————–
def live_probe() –> int:
socket.setdefaulttimeout(60)
import baostock as bs
import time
lg = bs.login()
print(f"[login] {lg.error_code} {lg.error_msg}")
if lg.error_code != "0":
print("登录失败, 结束")
return 1
def _rows(code, start, end, freq, fields=mmod.BS_MINUTE_FIELDS):
rs = bs.query_history_k_data_plus(code, fields, start_date=start, end_date=end,
frequency=freq, adjustflag="3")
rows = []
while rs.next():
rows.append(rs.get_row_data())
return rs, rows
print("\\n=== ① 字段 & 单日根数(5min sh.600519 2024-01-02) ===")
rs, rows = _rows("sh.600519", "2024-01-02", "2024-01-02", "5")
print(" fields:", rs.fields, " rows:", len(rows))
if rows:
print(" 首:", rows[0][:3], rows[0][6])
print(" 末:", rows[–1][:3], rows[–1][6])
print("\\n=== ② frequency 取值 ===")
for f in ["1", "5", "15", "30", "60"]:
rs, rows = _rows("sh.600519", "2024-01-02", "2024-01-03", f, "date,time,close")
print(f" frequency={f!r:4s} error={rs.error_code} msg={rs.error_msg!r} n={len(rows)}")
print("\\n=== ③ 时间起点(股票) ===")
for s, e in [("2019-12-01", "2019-12-31"), ("2020-01-02", "2020-01-02"),
("2020-01-02", "2020-01-10")]:
rs, rows = _rows("sh.600519", s, e, "5", "date,time,close")
print(f" {s}~{e}: n={len(rows)} 首={rows[0][0] if rows else None}")
print("\\n=== ④ 指数分钟线: 沪市 vs 深市 (官方文档称'指数不提供分钟K线') ===")
for c in ["sh.000001", "sh.000300", "sh.000905",
"sz.399001", "sz.399006", "sz.399106"]:
rs, rows = _rows(c, "2024-01-02", "2024-01-02", "5", "date,time,close")
print(f" [{'有' if rows else '无'}] {c}: err={rs.error_code} n={len(rows)}")
print("\\n=== ④b 深市指数分钟线: 频率覆盖 ===")
for f in ["5", "15", "30", "60"]:
rs, rows = _rows("sz.399001", "2024-03-01", "2024-03-31", f, "date,time,close")
print(f" sz.399001 {f:>2}min 2024-03: n={len(rows)}")
print("\\n=== ④c 深市指数分钟线: 时间窗口逐月扫描 ===")
for s, e in [("2023-06-01", "2023-06-30"), ("2023-08-01", "2023-08-31"),
("2023-09-01", "2023-09-30"), ("2024-09-01", "2024-09-30"),
("2024-10-01", "2024-10-31"), ("2025-06-01", "2025-06-30")]:
rs, rows = _rows("sz.399001", s, e, "5", "date,time,close")
tail = f" 首={rows[0][0]} 末={rows[–1][0]}" if rows else ""
print(f" sz.399001 5min {s}~{e}: n={len(rows)}{tail}")
print("\\n=== ⑤ 性能: 600519 2023 全年 5min ===")
t = time.time()
rs, rows = _rows("sh.600519", "2023-01-01", "2023-12-31", "5",
"date,time,code,open,high,low,close,volume,amount")
print(f" n={len(rows)} 耗时={time.time()–t:.2f}s")
print("\\n=== ⑥ 非交易日/周末静默空 ===")
rs, rows = _rows("sh.600519", "2024-01-06", "2024-01-07", "5", "date,time,close")
print(f" 周末: error={rs.error_code} msg={rs.error_msg!r} n={len(rows)}")
print("\\n=== ⑦ V0.9.3 按日全市场接口(日线/ETF/复权因子, 均不含分钟粒度) ===")
for fn, arg in [("query_daily_history_k_AStock", "2024-01-02"),
("query_daily_history_k_ETF", "2026-02-02"),
("query_daily_adjust_factor", "2024-01-02")]:
try:
rs = getattr(bs, fn)(date=arg)
rows = []
i = 0
while rs.next() and i < 2:
rows.append(rs.get_row_data())
i += 1
print(f" {fn}(date={arg}): err={rs.error_code} fields={len(rs.fields)}")
print(f" 前2行={rows}")
except Exception as e:
print(f" {fn}: {type(e).__name__}: {e}")
bs.logout()
print("\\nDONE")
return 0
# —- 真实抓取落库 ———————————————————-
def live(symbol: str, start: str = mmod.MINUTE_START, end: str | None = None,
freq: str = "5") –> int:
socket.setdefaulttimeout(120)
import baostock as bs
import time
end = end or pd.Timestamp.now().normalize().strftime("%Y-%m-%d")
lg = bs.login()
print(f"[login] {lg.error_code} {lg.error_msg}")
if lg.error_code != "0":
return 1
norm = cfg.normalize_symbol(symbol)
code = f"{norm.split('.')[1].lower()}.{norm.split('.')[0]}"
print(f"拉取 {code} {freq}min {start} ~ {end} …")
t = time.time()
rs = bs.query_history_k_data_plus(code, mmod.BS_MINUTE_FIELDS,
start_date=start, end_date=end,
frequency=freq, adjustflag="3")
if rs.error_code != "0":
print(f"接口错误: {rs.error_code} {rs.error_msg}")
bs.logout()
return 1
rows = []
while rs.next():
rows.append(rs.get_row_data())
bs.logout()
dt = time.time() – t
if not rows:
print("返回 0 行(可能: 非交易日/超 5 年边界/指数无分钟线)")
return 1
raw = pd.DataFrame(rows, columns=rs.fields)
rep = mmod.append_minute(norm, raw)
print(f" fetched={len(rows)} appended={rep['appended']} "
f"years={rep['years']} 耗时={dt:.1f}s")
for t_ in rep["targets"]:
print(" ->", t_)
last = mmod.get_last_minute(norm)
df = mmod.query_minute(norm)
print(f" 本地库: {len(df)} 行, 最后 bar = {last}")
if len(df):
print(f" 首行 close={df['close'].iloc[0]} 末行 close={df['close'].iloc[–1]}")
return 0
def main() –> int:
ap = argparse.ArgumentParser(description="S3-007 分钟线 获取/落库/核算")
ap.add_argument("–self-test", action="store_true", help="离线断言")
ap.add_argument("–size-calc", action="store_true", help="量级&成本核算")
ap.add_argument("–live-probe", action="store_true", help="真实探测(需网络)")
ap.add_argument("–live", metavar="SYMBOL", help="真实抓取落库, 如 600519.SH")
ap.add_argument("–start", default=mmod.MINUTE_START)
ap.add_argument("–end", default=None)
ap.add_argument("–freq", default="5", choices=list(mmod.VALID_FREQ))
args = ap.parse_args()
if args.self_test:
return self_test()
if args.size_calc:
return size_calc()
if args.live_probe:
return live_probe()
if args.live:
return live(args.live, args.start, args.end, args.freq)
print("用法: –self-test | –size-calc | –live-probe | –live SYMBOL")
return 0
if __name__ == "__main__":
sys.exit(main())
免责声明
本文仅为量化数据工程的技术分享,所有代码与示例数据均用于演示,不构成任何投资建议。实盘有风险,下单需谨慎;文中涉及的参数、口径、路径、基准数字(标的数 5200、年交易日 242、单只 5 分钟一年 17~24 秒等)均来自本机实测或合理估算,会随市场规模 / 数据源 / 网络环境变化,需根据你自己的环境重新校准。投资盈亏自负。
