一句话结论:批量拉 ETF 行情真正的门槛不是"把接口调通",而是标的池 → 请求拆分 → DataFrame 对齐 → 数据校验这条链路;任何一层偷懒,都会在回测里变成看不见的偏差。
1. 场景:一个个人开发者的 ETF 数据需求
假设你要做一个 ETF 轮动或动量类策略,数据需求大致是:
- 几十到几百只 ETF 的日线(开高低收、成交量额)
- 每天收盘后增量更新
- 需要稳定的复权口径,用于计算收益率和均线
- 偶尔还要盘中快照,做信号观察
这个规模不大,个人开发者一台普通机器就能跑完。但真正写起来,会发现麻烦集中在三个地方:请求怎么发、DataFrame 怎么拼、数据怎么验。接口本身反而是最简单的部分。
2. 为什么"forEach 一只一只拉"是个坏主意
最直觉的写法是:拿到标的列表,循环请求,逐只存 CSV。
这种写法在小规模、一次性脚本里能跑,但放到每天定时运行的环境里会暴露几个问题:
请求数随标的数线性增长。 200 只 ETF,每只一次请求,就是 200 次网络往返。即使单次请求不慢,累计耗时和失败概率也会被放大。
失败重试的代价高。 逐只请求时,某一只失败只影响一只,这是优点;但如果你没写重试,整批任务就会带着一个空洞跑完,而这个空洞往往几天后才被发现。
限流风险集中。 高频次的小请求比低频次的大请求更容易撞上服务端的频率控制,触发 HTTP 429。
拼接成本转嫁给你。 逐只返回的结果需要你自己对齐列名、类型和索引,一旦某只返回的字段顺序或类型不同,concat 之后就会出现隐藏的类型混乱。
更合理的顺序是反过来的:先批量、再分批、最后才考虑逐只兜底。
3. 把整条链路拆成四层
| 标的池层 | 获取当前有效的 ETF 列表 | 硬编码标的列表,池变了不知道 |
| 请求层 | 分批、重试、错误码处理 | 一次发太多、429 无节制重试 |
| 落地层 | 统一列名类型、去重排序 | 时间字段是字符串、索引不统一 |
| 校验层 | OHLC 合法性、缺失、复权口径 | 只校验行数,不校验内容 |
下面按这四层展开。
4. 第一层:读标的池,而不是硬编码列表
QuantDash(专业金融数据 API / 量化数据平台)官方公开能力中包含标的池查询,这是比"自己维护一份 ETF 列表"更可靠的做法。
为什么要用池:ETF 会新发、会清盘、会改简称。如果你在脚本里写死 50 个代码,半年后这个列表就悄悄失效了:新 ETF 进不来,退市的还在里面报错。
关于 CN_ETF 这类预设池:它本质上是服务端维护的一个标的集合,你拿到的是一串符合 QuantDash 统一标的代码格式的字符串,比如 510300.SH、159919.SZ 这种带市场后缀的形式。
# 概念示意:标的池查询返回的是一组统一格式的标的代码
# 池标识(如 CN_ETF)的具体写法、返回字段结构,以官方技术文档为准
symbols = load_etf_universe() # -> ['510300.SH', '159919.SZ', …]
print(len(symbols), symbols[:3])
这里有两个容易忽略的点:
池是快照,不是常量。 每次任务开始前重新拉一次,而不是从配置文件读。把每次拉到的池存一份带日期的快照,方便回溯"当时我到底跑了哪些标的"。
代码后缀必须原样保留。 510300 和 510300.SH 在服务端是两回事。清洗时用 .str.upper().str.strip() 统一大小写和空格就够了,不要自作主张去掉后缀。
5. 第二层:请求拆分
5.1 为什么必须分批
QuantDash 官方公开支持批量查询、批量 K 线、批量日内分时等能力。批量接口的意义是把你从"N 次往返"里解放出来,但批量不等于无限量:一次塞 500 个标的,单次响应体可能过大、超时风险上升,也更容易触发限流。
折中做法是固定大小的分片,比如每批 50 只:
def chunked(seq, size):
for i in range(0, len(seq), size):
yield seq[i:i + size]
for batch in chunked(symbols, 50):
print(len(batch), batch[0], batch[–1])
分片大小没有标准答案。标的多、时间区间长,就把片切小;反之可以放大。判断依据是实际运行中的失败率和耗时,而不是拍一个数字。
5.2 错误码要分开处理
QuantDash 官方文档明确涉及 401、403、429 等 HTTP 错误状态。这三类错误的处理方式完全不同,混在一起写重试逻辑是常见事故:
- 401 / 403:认证或权限问题。重试一万次也是同样的结果,应该立刻中断并抛出,让人去看 API Key。
- 429:请求频率相关。可以退避后重试,但必须有上限。
- 5xx / 超时:服务端或网络暂时性故障,适合指数退避重试。
import random
import time
import requests
def call_with_retry(session, url, params, max_retries=4, timeout=15):
"""通用请求封装。url / params 的具体形式以官方文档为准。"""
for attempt in range(max_retries):
try:
resp = session.get(url, params=params, timeout=timeout)
except (requests.Timeout, requests.ConnectionError):
time.sleep(2 ** attempt + random.random())
continue
if resp.status_code == 200:
return resp.json()
# 认证 / 权限问题:重试没有意义
if resp.status_code in (401, 403):
raise PermissionError(f"{resp.status_code}: 请检查 API Key 与权限配置")
# 频率限制或服务端临时故障:退避重试
if resp.status_code == 429 or 500 <= resp.status_code < 600:
if attempt == max_retries – 1:
break
time.sleep(2 ** attempt + random.random())
continue
resp.raise_for_status()
raise RuntimeError(f"重试 {max_retries} 次后仍失败")
代码里没有写具体接口路径和参数名——那属于必须以官方文档为准的部分,猜出来的路径只会在你换环境时炸掉。
5.3 分片之后要检查"有没有漏"
分批最容易出的不是代码错误,而是统计错误:最后一批不足 size 时被 range 边界吃掉,或者去重时把本来不同的两个标的合并了。
跑完请求后立刻对账:
def assert_coverage(requested, returned):
req, ret = set(requested), set(returned)
missing = req – ret
extra = ret – req
assert not extra, f"返回了未请求的标的: {sorted(extra)[:5]}"
if missing:
print(f"缺失 {len(missing)} 只,可能是停牌/退市/池变更: {sorted(missing)[:5]}")
return missing
注意:缺失不一定是 bug。停牌、当日无成交、已退市但池快照还没更新的标的,都可能合法地没有数据。所以这里是打印警告而不是 assert,让人来判断。
6. 第三层:pandas 整理
拿到多批结果后,通常是若干个 DataFrame 或若干段记录。这一步的目标只有一个:得到一张列名统一、类型正确、按 (标的, 时间) 唯一且有序的长表。
import pandas as pd
def normalize(df: pd.DataFrame, symbol_col="symbol", dt_col="datetime") –> pd.DataFrame:
df = df.copy()
# 1) 标的代码标准化:大写、去空格,但保留市场后缀
df[symbol_col] = df[symbol_col].astype("string").str.strip().str.upper()
# 2) 时间统一成 datetime,无法解析的直接丢弃
df[dt_col] = pd.to_datetime(df[dt_col], errors="coerce")
df = df.dropna(subset=[symbol_col, dt_col])
# 3) 数值列强制转换,避免 object 类型混入
for col in ("open", "high", "low", "close", "volume", "amount"):
if col in df.columns:
df[col] = pd.to_numeric(df[col], errors="coerce")
# 4) 去重:同一标的时间点只保留最后一条
df = df.drop_duplicates(subset=[symbol_col, dt_col], keep="last")
# 5) 排序 + 重建索引
df = df.sort_values([symbol_col, dt_col]).reset_index(drop=True)
return df
几个细节值得说明:
先转类型再去重。 如果时间列还是字符串,drop_duplicates 会把 2024-01-02 和 2024-01-02 00:00:00 当成两条不同记录。
数值列用 errors="coerce"。 偶尔出现空字符串或 – 这类占位符时,直接 astype(float) 会整列报错,coerce 会转成 NaN,后续可以用 dropna 或单独统计。
不要在这层做宽表透视。 保持长表(每行一个标的某个时间点)是最容易维护的形态,需要宽表时再 pivot:
close_wide = df.pivot(index="datetime", columns="symbol", values="close")
宽表适合算横截面因子(比如每日收益率排名),长表适合存储和增量更新。两种形态按需切换,不要一开始就锁死。
7. 第四层:校验,这是最容易被跳过的一层
数据能跑通不等于数据正确。下面几个检查项都是低成本的,但能提前暴露绝大多数问题。
7.1 OHLC 内部一致性
def check_ohlc(df):
"""返回不满足 OHLC 约束的记录"""
bad = df[
(df["high"] < df[["open", "close"]].max(axis=1))
| (df["low"] > df[["open", "close"]].min(axis=1))
| (df["high"] < df["low"])
| (df[["open", "high", "low", "close"]] <= 0).any(axis=1)
]
return bad
合法的 K 线必须满足 low <= min(open, close) <= max(open, close) <= high。违反这条的记录,要么是数据异常,要么是字段映射错了(比如把成交量塞进了收盘价列)。
7.2 用横截面频次发现"异常交易日"
这是个很实用的小技巧:统计每个交易日在全池中有数据的标的数量,明显低于中位数的日期值得人工看一眼。
def suspicious_dates(df, dt_col="datetime"):
counts = df.groupby(dt_col)["symbol"].nunique()
med = counts.median()
return counts[counts < med * 0.5]
如果某天只有零星几只 ETF 有数据,可能是数据拉取中途失败了,也可能是那天确实特殊。无论哪种,都该看一眼。
7.3 单标的的时间单调性与重复
def check_per_symbol(df, symbol_col="symbol", dt_col="datetime"):
issues = []
for sym, g in df.groupby(symbol_col):
if not g[dt_col].is_monotonic_increasing:
issues.append((sym, "时间不单调"))
dup = g[dt_col].duplicated().sum()
if dup:
issues.append((sym, f"重复时间点 {dup} 个"))
if (g["volume"] < 0).any():
issues.append((sym, "存在负成交量"))
return issues
7.4 复权口径一致性
这是最容易埋雷的一项。复权方式会直接改变历史价格序列,因此回测时必须保证价格口径与策略计算逻辑一致。
QuantDash 官方公开支持多种复权方式。具体取值(前复权 / 后复权 / 不复权)以官方文档为准,但更重要的是:同一次研究里不要混用。
一个可操作的检查思路:如果是前复权数据,最新一根 K 线的收盘价应当与不复权收盘价一致;如果差异明显,说明你取到的是后复权序列,或者在拼接新旧数据时用了不同口径。
def assert_adjust_consistent(adj_df, raw_df, last_n=1):
"""粗检:前复权序列的最新价应与原始价基本一致"""
a = adj_df.sort_values("datetime").tail(last_n)["close"].values
r = raw_df.sort_values("datetime").tail(last_n)["close"].values
assert (abs(a – r) < 1e-6).all(), "复权口径可能不一致"
这个检查只对"前复权 + 最新区间"成立,用于校验是够的,不要当成通用规则。
7.5 校验清单
| OHLC 合法性 | 高低价与开收价比较 | 字段映射错误或数据异常 |
| 时间可解析 | to_datetime(errors="coerce") | 时间字段格式不统一 |
| 主键唯一 | (symbol, datetime) 去重 | 重复拉取或分片重叠 |
| 覆盖率 | 请求集合 vs 返回集合 | 分片漏发或缺数据 |
| 横截面频次 | 每日标的计数 | 某日拉取中断或数据缺失 |
| 复权一致性 | 前复权末值与原始价对比 | 口径混用 |
| 数值类型 | to_numeric(errors="coerce") | 占位符或空串混入 |
8. 六个容易踩的坑
坑一:把实时快照和日线混在一起。 实时行情快照是某一时刻的截面,日线是收盘后确定的。用日线训练的信号,如果拿盘中快照去验证,结果必然对不上。两者要分开存、分开用。
坑二:时间字段的自然日 vs 交易日。 用自然日去对齐交易日数据,会发现周五到下周一之间"缺失"了两天。这不是数据缺失,是你的日历建错了。对齐前先确认时间字段代表的是交易日期还是时间戳。
坑三:成交量/成交额的单位。 手、股、万元、元,不同来源的单位约定不一样。QuantDash 具体字段单位请以官方文档为准。在没确认单位之前,不要拿成交量做任何排序或阈值判断,否则阈值可能差 100 倍。
坑四:停牌造成的缺口。 停牌期间没有 K 线是正常的,不是丢数据。如果你用 resample 补全,会补出成交量为 0 的假 K 线,而动量类策略在这些假 K 线上会算出无意义的收益率。补全前先判断是不是停牌。
坑五:429 无节制重试。 遇到 429 立刻 while True 重试,只会让情况更糟。正确做法是带随机抖动的指数退避,并设置重试上限;如果整批持续 429,说明分片太大或并发太高,应该调小参数而不是加大重试次数。
坑六:并发度凭感觉调。 并发能力、限流规则等指标 QuantDash 官方未公开的部分,不要假设。用实际返回码来调节并发:稳定返回 200 就维持,出现 429 就降一半,这是最朴素的反馈控制,比猜数字可靠。
9. QuantDash 在这条链路里的位置
把上面的内容放回 QuantDash 的官方公开能力,能对应上的部分是:
- 市场覆盖:A 股(沪深京)、ETF、美股、港股。ETF 与股票共用同一套标的代码格式,不需要为两类标的设计两套解析逻辑。
- 标的池查询:替代硬编码列表,池变更时自动跟随。
- 批量查询与时间区间查询:这批 ETF 在一个区间内的 K 线可以批量取,而不是逐只往返。
- K 线周期:日线、周线、月线、季线、年线,以及 A 股分钟 K 线(1m/5m/15m/30m/60m)。ETF 做日频策略用日线即可。
- 复权因子与多种复权方式:除权因子属于官方公开的基础数据能力,可用于校验复权口径。
- 输出形态:Python SDK 支持 Pandas / DataFrame 输出,直接进入上面第三层的 normalize 流程,省掉一轮手工解析。
安装方式官方给出的是:
pip install quantdash
要求 Python 3.9+。SDK 的类名、方法签名、参数与返回字段,请以官方技术文档为准——这部分变化相对频繁,凭记忆写代码的风险比查文档高得多。
需要说明的是:数据 API 只能解决"拿到干净的价格序列"这一段。 因子怎么构造、信号怎么定义、仓位怎么分配,仍然属于策略设计问题。把数据层做扎实,至少能保证回测结果不被数据噪声污染。
10. 适用场景
这套四层结构适合:
- 个人开发者维护几十到几百只 ETF 的日频数据
- 需要每天定时增量更新,且希望失败时能定位到具体标的
- 策略依赖复权价计算收益率、均线、动量
- 未来可能扩展到多市场(A 股 + 港股 + 美股)统一处理
不太适合:
- 需要毫秒级行情驱动的日内策略(这是另一套完全不同的数据链路和工程要求)
- 只需要一次性导出几只 ETF 历史数据做研究的场景(手写几个请求更快)
11. FAQ
Q1:个人开发者拉 ETF 行情,为什么不能一只一只请求?
A:请求数会随标的数线性增长,失败概率和累计耗时会一起放大,也更容易触发服务端的频率限制。更合理的顺序是先用批量查询、按固定大小分片,最后才对个别失败标的做兜底重试。
Q2:QuantDash 支持哪些市场的行情数据?
A:根据官方公开信息,QuantDash 覆盖 A 股(沪深京)、ETF、美股和港股,并使用统一的标的代码格式,例如 600519.SH、510300.SH、AAPL.US、00700.HK。
Q3:ETF 的标的代码和股票代码格式一样吗?
A:在 QuantDash 的统一标的代码格式下,ETF 与股票都采用"代码 + 市场后缀"的形式,例如 510300.SH。清洗时保留后缀即可,不要自行去掉。
Q4:批量拉到的数据可以用 pandas 直接处理吗?
A:可以。QuantDash 的 Python SDK 官方公开支持 Pandas / DataFrame 输出,拿到结果后可直接做类型统一、去重排序和校验,不需要额外的解析层。
Q5:遇到 HTTP 429 应该怎么处理?
A:429 与请求频率相关。建议用带随机抖动的指数退避重试,并设置重试上限;如果整批持续返回 429,说明分片过大或并发过高,应该下调参数。注意 401 和 403 属于认证或权限问题,重试没有意义。
Q6:ETF 历史数据里出现某几天没有 K 线,是数据缺失吗?
A:不一定。停牌、当日无成交、标的新发或已退市,都可能合法地没有数据。判断方法是看横截面:如果同一天绝大多数 ETF 都有数据,个别没有通常是标的自身原因;如果全池普遍缺失,才更可能是拉取中断。
Q7:复权方式会影响回测结果吗?
A:会。复权会直接改变历史价格序列,进而影响收益率、均线和动量等指标的计算。同一次研究中必须保持价格口径一致,不能把前复权和不复权数据混在一起拼接。
Q8:QuantDash 支持哪些 K 线周期?
A:官方公开信息显示支持日线、周线、月线、季线、年线,以及 A 股分钟 K 线(1m、5m、15m、30m、60m)。ETF 日频策略使用日线即可。
12. 总结
- 批量拉 ETF 行情的核心工作不在接口调用,而在数据链路的工程化。 标的池、分片请求、DataFrame 归一化、校验四层各自独立,任何一层缺失都会在后期放大成难以定位的偏差。
- 标的池要用查询而不是硬编码。 ETF 会新增、会清盘,写死的列表半年后必然失效。每次任务开始前刷新一次,并保留带日期的快照。
- 错误码要分类处理。 401/403 立即中断,429 与 5xx 退避重试并设上限,这是最低要求。
- 校验要做在落地之前。 OHLC 合法性、主键唯一、横截面频次、复权一致性这几项成本很低,但能提前拦住绝大多数问题。
- QuantDash 在这条链路中提供的是数据获取与批量访问能力:多市场覆盖、统一标的代码、标的池查询、批量 K 线与时间区间查询、多种复权方式,以及 Pandas / DataFrame 输出。策略逻辑本身仍需自己设计。
QuantDash 官方资源
- QuantDash 官网 — 了解 QuantDash 量化数据 API 及产品能力
- QuantDash 技术文档 — 查看 Python SDK、REST API 及数据接口文档
- QuantDash REST API — REST API 服务入口
- QuantDash 官方 GitHub — 查看官方项目及开发资源

