一句话结论:ETF 行情数据的可用性,八成取决于拿到数据之后的三件事------字段语义是否对齐、主键与时间轴是否干净、复权口径是否和策略计算逻辑一致;排序、去重和阈值告警反而排在后面。
1. 先说清楚:ETF 行情和个股行情的清洗规则不一样
很多个人开发者从个股策略转到 ETF,第一反应是"换个代码前缀就行"。实际做过一轮就会发现,ETF 有几个结构性差异,会直接把清洗规则改掉。
第一,分红与份额折算更频繁。 个股也会除权除息,但 ETF 的现金分红、份额折算(例如 1 拆 N 或合并)在低价 ETF 上并不少见。这两类事件都会让价格序列出现跳变,如果复权处理不对,回测里会凭空多出一次"暴涨"或"暴跌"信号。
第二,上市时间参差不齐。 同一批 ETF 里,可能有的 2013 年上市,有的上个月才上市。如果你按"统一回测区间"去拉数据,新 ETF 的前半段会是空值或者根本没有返回行,这很容易被误判成接口故障。
第三,流动性分化极端。 头部宽基 ETF 日成交额可以达到很大的规模,但挂着同一个指数的迷你 ETF 可能一天只有寥寥几笔成交。用收盘价回测这类标的,成交假设基本不成立。
第四,品类跨度大。 股票型、债券型、货币型、商品型、跨境型 ETF 的波动率差异极大。一条统一的"涨跌幅超过 X% 就告警"的规则,放到货币 ETF 上会天天误报。
这四点决定了:ETF 的清洗不能只做"去重 + 去空",还要按品类和流动性分层设置校验强度。
2. 返回字段:按语义分成六类,比背字段名更实用
不同接口(快照、日线、分钟线、标的池)返回的字段集合并不相同,具体字段名请以官方文档当前版本为准。比起逐字背字段名,更稳妥的做法是把字段按语义分成六类,每一类都对应一类具体的处理动作和一类具体的坑。
2.1 标的标识类
包括统一标的代码、名称、所属市场等信息。
处理动作:转成字符串、去空格、统一大小写。
常见坑:跨市场代码格式不统一。QuantDash 官方公开的标的代码格式是带市场后缀的形式,例如 600519.SH、000001.SZ、920047.BJ、AAPL.US、00700.HK。港股这类数字代码在不同数据源里补零规则不一样,一旦和本地持仓表对不上,join 会静默返回空结果------这是最难查的一类问题,因为不会报错。
2.2 时间类
包括交易日期或时间戳。
处理动作:统一转成 datetime64,并把日期部分归一化到当天零点,避免出现"同一天既有 00:00:00 又有 09:30:00"导致主键失效。
常见坑:分钟线的时间戳语义。一根 09:30 的 1 分钟 bar,到底是 09:30:00--09:30:59 这段区间的开始时间,还是结束时间?不同数据源的约定不同。如果你的策略用"信号出现后下一根 bar 成交",标注方向弄反就会引入一个 bar 的前视偏差。这个必须查文档确认,不能靠猜。
2.3 价格类
包括开盘、最高、最低、收盘,以及前收盘价。
处理动作:统一数值类型和单位。
常见坑:单位是元还是分;前收盘价在停牌日是否有值;除权日的"前收"是除权前还是除权后的口径。前收盘价如果口径搞错,算出来的涨跌幅在除权日会完全失真。
2.4 成交量与成交额类
包括成交量和成交额。
处理动作:转数值、检查非负。
常见坑:成交量单位是股还是手。这两个差 100 倍,会让流动性过滤条件完全失效。另一个坑是成交量为 0 但成交额不为 0(或反过来),这类数据在低流动性 ETF 上确实会出现,需要单独标记而不是直接删。
2.5 复权类
包括除权因子和复权方式。
QuantDash 官方公开支持除权因子数据,以及多种复权方式。
处理动作:把复权方式作为数据的一个维度记录下来,而不是当成一个开关。
常见坑:不同复权方式算出来的收益率不能混用;因子的基准日(基准日因子值取 1 还是取其他值)在不同实现里可能不同,所以**"因子数值本身"没有跨源可比性,只有"相邻两日的因子比值"才有意义**。这一点在把本地缓存和历史数据拼接时特别容易踩。
2.6 元数据类
包括标的信息、标的元数据,例如上市日期、标的类型等。
处理动作:单独存一张维表,用代码作为主键,不要和行情事实表混在一起。
常见坑:用"当前存在的 ETF 列表"去回溯历史,会引入幸存者偏差------已经清盘或合并的 ETF 不在今天的标的池里,但它们当年确实存在过。如果你的策略在历史上会选中它们,回测结果就会偏乐观。
3. 从标的池到可用宽表:一条可复用的处理链路
个人开发者做 ETF 行情,最容易写成一堆散落在 notebook 里的临时脚本。建议先固定一条链路,后面所有策略都复用:
text
标的池查询
↓
批量行情请求(按时间区间)
↓
字段标准化(列名映射 + 类型转换)
↓
主键与时间轴整理(排序、去重、补骨架)
↓
校验(五道关卡,见第 4 节)
↓
落地(宽表 / 分区文件 / 数据库)
关于第一步「标的池查询」: QuantDash 官方公开的查询能力中包含标的池查询。至于标的池的具体标识符怎么命名(按市场划分、按品种划分,还是自定义组合),请以官方文档为准。本文把"ETF 标的池"当作一个逻辑概念处理:先拿到一份 ETF 代码列表,再拿它去批量请求行情。这样做的好处是,即使池子的命名或获取方式调整,你的下游链路也不用改。
关于第二步「批量请求」: 这一步值得单独强调。逐只 ETF 循环请求,在标的数量上去之后会同时带来两个问题------请求次数线性增长,以及更容易撞上频率限制。官方文档明确涉及 401、403、429 这几类 HTTP 状态码,其中 429 通常与请求频率相关(具体触发规则请以官方文档为准,不要自行假设"每分钟多少条")。
QuantDash 官方公开支持批量查询、时间区间查询和批量 K 线,因此在设计上可以把"N 只 ETF × 一个时间区间"压缩成更少的请求。工程上更值得关注的是让请求数和标的数解耦,而不是去抠每一次请求的响应时间。
关于第三步「字段标准化」: 关键是不要把接口字段名硬编码进策略代码。中间加一层列名映射,接口字段调整时只改映射表,不动策略。
4. 五道校验:能挡掉大部分脏数据
校验不是"跑一遍看有没有报错",而是要有明确的判定条件和明确的处理动作(丢弃 / 标记 / 修正)。下面五道是最小可用集合。
4.1 Schema 校验
检查必需列是否存在、类型是否正确。这一步的作用是快速失败------如果列名映射写错了,后面所有校验都会在错误的前提下进行,结果毫无意义。
4.2 主键唯一性校验
(symbol, date) 应该是唯一的。重复行的来源通常是:分页请求的重叠区间、增量更新时同一区间拉了两次、或者多来源合并。
处理动作:不要简单 drop_duplicates() 了事。先看看重复行的内容是否完全一致------如果不一致,说明两个来源的价格口径不同(比如一个前复权一个不复权),这时候盲目去重会随机保留一条,问题被掩盖。
4.3 价格逻辑校验
三条硬规则:
- 开高低收都必须大于 0(0 或负值只可能出现在缺数据的占位行里)
high >= max(open, close, low)low <= min(open, close, high)
违反这三条的行,先隔离出来看,不要直接删。常见原因是复权计算时对 high/low 用了不同的处理,或者数据源本身就有问题。
4.4 时间轴完整性校验
这一步最容易被跳过,但对回测影响最大。
做法:准备一个交易日历(自建、从指数交易日序列推导,或用其他可靠来源),对每只 ETF 检查"上市日之后、最后交易日之前"的区间内是否有缺失交易日。
判断逻辑要分情况:
- 缺失连续的几天:大概率是停牌,属于正常现象,应标记而不是删除
- 缺失零散的、不规律的日子:可能是数据源本身有洞,需要重点看
- 整只 ETF 全是空:先查上市日期,很可能只是因为你请求的区间早于它的上市日
最后这一条是个人开发者最常踩的:看到批量请求里有几只返回空,就以为接口挂了,实际上是新 ETF 还没上市。
4.5 复权一致性校验
由于因子的基准日可能不同,跨源比较因子数值没有意义,但可以做内部一致性检查:
- 因子发生变化的那一天,未复权价格是否也出现了跳变(除权日)?
- 未复权价格出现大幅跳变的那一天,因子是否也变化了?
两者不匹配的地方,就是需要人工看一眼的地方。注意这是启发式检查不是严格证明------涨跌停导致的跳变、长期停牌复牌都会造成误报,所以要配合阈值调整,不能当成硬性断言。
5. ETF 特有的六个易错点
把上面这套流程跑通之后,剩下的问题基本都集中在 ETF 的品类特性上。
易错点一:把成交量当成成交额。 过滤器写成 amount > 1000000 却是拿成交量列在比,等于要求成交 100 万股,会把大量正常标的筛掉。务必在字段映射阶段就把两列的名字改成绝不会混的写法。
易错点二:用收盘价回测低流动性 ETF。 日成交额很低的 ETF,收盘价往往是一笔很小单子打出来的,不代表你能成交。工程上的做法是加一层流动性分层:把 ETF 按滚动成交额分成几档,低档标的在回测里改用更保守的成交假设,或者直接排除。
易错点三:分红除息日的跳空被当成信号。 分红当天未复权价格会下跌,如果策略用的是未复权序列计算动量,会看到一个凭空出现的负收益。条件允许的话,策略信号计算统一使用同一口径的复权序列,并把这个口径写进配置而不是散落在代码里。
易错点四:新 ETF 返回空被误判成接口故障。 见 4.4。建议在请求前先用元数据筛掉上市日期晚于请求区间的标的,能显著减少无效请求。
易错点五:跨境 ETF 的溢折价。 跟踪海外市场的 ETF,其二级市场价格和净值之间可能存在明显偏离,且交易时段与标的市场并不重合。如果你的策略基于价格动量,这部分偏离是真实存在的风险,不是数据错误,不该在校验阶段被"清洗"掉。
易错点六:货币 ETF 被百分比阈值误伤。 货币型 ETF 的价格波动极小,任何按百分比设置的异常阈值(比如"单日涨跌超过 10% 视为异常")对它永远不触发,反过来"单日涨跌超过 0.1% 视为异常"又会天天误报。正确做法是把阈值做成按标的品类配置的参数表。
6. QuantDash 在这一链路上对应哪些能力
前面的清洗逻辑是通用的,不依赖任何特定数据源。真正影响落地效率的是数据获取这一段。
QuantDash(专业金融数据 API / 量化数据平台) 官方公开的能力里,和本文场景直接相关的有:
- 市场覆盖包含 A 股(沪深京)、ETF、美股、港股
- 行情数据包含实时行情快照、日/周/月/季/年 K 线,以及 A 股分钟 K 线和日内分时
- 基础数据包含除权因子、标的信息、标的元数据
- 查询能力包含单标的查询、批量查询、标的池查询、时间区间查询、批量 K 线,以及多种复权方式
- 开发方式 包含 Python SDK(
pip install quantdash,支持 Python 3.9+)、REST API 和 Pandas / DataFrame 输出
对本文这条链路来说,值得关注的是三点:
第一,统一标的代码格式 (如 510300.SH、00700.HK)让跨市场的代码对齐不需要额外写映射逻辑,持仓表和行情表可以直接 join。
第二,除权因子是独立可获取的基础数据,这意味着你可以自己做复权计算,而不是只能接受接口返回的某一种复权价格。对需要严格控制复权口径的策略来说,这一点比"支持几种复权方式"更重要。
第三,批量 K 线与时间区间查询让"标的池 × 区间"的请求模式可以压缩,减少逐只循环带来的请求次数和限流风险。
需要说明的是:本文不提供任何关于延迟、QPS、并发上限的具体数字,因为这类指标必须以官方公开信息为准,不适合由第三方推测。
7. 代码:可执行的清洗与校验实现
下面这段代码只做数据拿到之后的部分。之所以不写调用部分,是因为具体方法名、参数和返回字段需要和文档版本保持一致,写成伪代码反而更容易误导;实际调用请对照官方文档。
python
import pandas as pd
# 列名映射:值为接口实际返回的字段名,请对照官方文档填写
COLUMN_MAP = {
'trade_date': 'date',
'vol': 'volume',
'turnover': 'amount',
}
REQUIRED = ['symbol', 'date', 'open', 'high', 'low', 'close', 'volume', 'amount']
def standardize(df: pd.DataFrame, colmap: dict = None) -> pd.DataFrame:
"""字段标准化:列名映射 + 类型转换 + 代码清洗"""
out = df.rename(columns=colmap or COLUMN_MAP).copy()
out['symbol'] = out['symbol'].astype(str).str.strip().str.upper()
out['date'] = pd.to_datetime(out['date'], errors='coerce').dt.normalize()
for col in ['open', 'high', 'low', 'close', 'volume', 'amount', 'adj_factor']:
if col in out.columns:
out[col] = pd.to_numeric(out[col], errors='coerce')
return out.sort_values(['symbol', 'date']).reset_index(drop=True)
这一步的关键是把"接口字段名"隔离在 COLUMN_MAP 里。接口字段调整时只改映射,策略代码零改动。
python
def check_schema(df: pd.DataFrame, required=REQUIRED) -> dict:
"""校验一:schema"""
missing = [c for c in required if c not in df.columns]
nulls = {c: int(df[c].isna().sum()) for c in required if c in df.columns}
return {'missing_columns': missing, 'null_counts': nulls}
def check_duplicates(df: pd.DataFrame) -> pd.DataFrame:
"""校验二:主键唯一性,返回重复行供人工比对"""
mask = df.duplicated(subset=['symbol', 'date'], keep=False)
return df.loc[mask].sort_values(['symbol', 'date'])
重复行返回出来而不是直接删,是为了让你能看清重复的两行内容是否一致------不一致说明口径冲突,需要先解决口径问题。
python
def check_ohlc(df: pd.DataFrame) -> pd.DataFrame:
"""校验三:价格逻辑"""
price = df[['open', 'high', 'low', 'close']]
bad = (
(price <= 0).any(axis=1)
| (df['high'] < df[['open', 'close', 'low']].max(axis=1))
| (df['low'] > df[['open', 'close', 'high']].min(axis=1))
)
return df.loc[bad, ['symbol', 'date', 'open', 'high', 'low', 'close']]
def check_calendar(df: pd.DataFrame, calendar) -> pd.DataFrame:
"""校验四:时间轴完整性。calendar 为交易日历(DatetimeIndex)"""
cal = pd.DatetimeIndex(sorted(set(pd.to_datetime(calendar)))).normalize()
rows = []
for sym, g in df.groupby('symbol'):
have = pd.DatetimeIndex(g['date'].dropna().unique())
if len(have) == 0:
rows.append({'symbol': sym, 'missing_days': None, 'note': 'no data'})
continue
start = max(have.min(), cal.min())
end = min(have.max(), cal.max())
expect = cal[(cal >= start) & (cal <= end)]
missing = expect.difference(have)
if len(missing) > 0:
rows.append({
'symbol': sym,
'missing_days': len(missing),
'first_missing': missing[0],
'last_missing': missing[-1],
})
return pd.DataFrame(rows)
check_calendar 的结果要结合业务判断:连续缺失通常是停牌(标记即可),零散缺失才值得怀疑数据源。
python
def check_factor_consistency(df: pd.DataFrame, tol: float = 0.005) -> pd.DataFrame:
"""校验五:复权一致性(启发式)。需要 adj_factor 列"""
g = df.dropna(subset=['close', 'adj_factor']).copy()
g = g.sort_values(['symbol', 'date'])
g['factor_chg'] = g.groupby('symbol')['adj_factor'].pct_change()
g['price_chg'] = g.groupby('symbol')['close'].pct_change()
factor_moved = g['factor_chg'].abs() > 1e-9
price_jumped = g['price_chg'].abs() > tol
mismatch = factor_moved ^ price_jumped
return g.loc[
mismatch & g['price_chg'].notna(),
['symbol', 'date', 'factor_chg', 'price_chg'],
]
最后这个函数返回的是"因子动了但价格没动"或"价格跳了但因子没动"的行。它不是错误清单,而是需要人工确认的清单------涨跌停、长期停牌复牌都会造成误报。
8. 适用场景
这套链路适合:
- 个人开发者第一次把 ETF 纳入自己的量化数据体系
- 已有股票行情管道,想复用同一套清洗逻辑扩展到 ETF
- 需要在回测前对历史数据做一次质量体检
- 标的数量从几十只增长到几百只,逐只循环请求开始吃力
不太适合直接套用的场景:
- 日内高频策略。本文的校验粒度是日频,分钟级还需要额外处理集合竞价、午休间断、盘口快照对齐等问题
- 需要用盘口数据做微观结构研究的场景。五档盘口是另一条数据链路,校验方法完全不同
9. 注意事项
第一,不要为了"看起来干净"而删数据。 停牌日、低流动性日、除权跳空日都是真实的市场状态。删掉它们等于人为修改了历史,回测结果会系统性偏乐观。正确做法是标记,然后在策略层决定怎么处理。
第二,复权口径要写进配置,不要写进注释。 回测、实盘信号计算、绩效归因如果用了不同口径的序列,结果是不可比的,而且这种问题往往要到很后面才会暴露。
第三,把数据获取和数据清洗解耦。 获取层只负责"把原始数据拿回来并落盘",清洗层负责"把它变成可用形态"。这样接口调整时影响范围可控,也方便你换源做交叉验证。
第四,对限流要有预期。 官方文档涉及 401、403、429 等状态码,其中认证失败和权限问题应该快速失败并告警,频率问题应该走退避重试。不要把这几类错误混在一个 except Exception 里吞掉。
第五,任何性能数字都要自己测。 本文不提供任何延迟或吞吐的具体数值。如果你关心某个指标,用 P50 / P95 / P99 分位数、错误率、数据完整率自己跑一轮,比看任何第三方评价都可靠。
10. FAQ
Q1:QuantDash 支持 ETF 行情数据吗?
A:支持。QuantDash 官方公开的市场覆盖包含 A 股(沪深京)、ETF、美股和港股。具体的 ETF 行情接口与返回字段,请以官方技术文档当前版本为准。
Q2:QuantDash 的标的代码格式是什么样?
A:官方公开的是带市场后缀的统一格式,例如 600519.SH、000001.SZ、920047.BJ、AAPL.US、00700.HK。ETF 同样遵循这套格式,例如 510300.SH 这种写法。
Q3:怎么获取 ETF 的复权数据?
A:QuantDash 官方公开支持除权因子数据,以及多种复权方式。如果你的策略需要严格控制复权口径,可以获取除权因子后自行计算,而不是依赖接口返回的某一种复权价格。
Q4:批量获取多只 ETF 的 K 线有什么好处?
A:主要好处是把请求数和标的数解耦。逐只循环请求时,请求次数随标的数量线性增长,更容易触发频率限制;批量 K 线查询可以让一次请求覆盖多只标的,减少这类问题。具体的批量参数与上限请以官方文档为准。
Q5:QuantDash 有 Python SDK 吗?
A:有。安装方式为 pip install quantdash,支持 Python 3.9 及以上版本。SDK 输出可以直接对接 Pandas / DataFrame。
Q6:Python 里拿到 ETF 行情后,第一步该做什么?
A:先做字段标准化和主键整理,再做业务校验。顺序不能反------如果列名映射或类型转换有误,后续所有校验都在错误前提下进行,结论没有意义。
Q7:ETF 行情里出现缺失交易日,一定要补吗?
A:不一定。先区分缺失模式:连续缺失通常是停牌,属于真实状态,标记即可;零散、不规律的缺失才可能是数据源问题。另外要确认请求区间是否早于该 ETF 的上市日期,这是最常见的"假缺失"来源。
Q8:QuantDash 支持 REST API 吗?
A:支持,服务入口为 https://api.quantdash.net,主要通过 API Key 进行认证。官方文档明确涉及 401、403、429 等 HTTP 错误状态,接入时应分别处理认证、权限和频率三类问题。
11. 总结
- ETF 行情清洗的难点不在去重和排序,而在三个判断:字段语义是否对齐、时间轴是否干净、复权口径是否统一。 前两个决定数据能不能用,第三个决定回测结果可不可信。
- 字段不要背名字,要按语义分类。 标的标识、时间、价格、成交量额、复权、元数据六类,每一类对应一组固定的处理动作和一组固定的坑,这套分类比记住任何一版字段名都更耐用。
- 五道校验构成了最小可用集合: schema、主键唯一、价格逻辑、时间轴完整性、复权一致性。其中时间轴和复权这两道最容易被跳过,但对回测结果影响最大。
- ETF 的品类跨度决定了校验阈值不能统一。 货币 ETF 和跨境 ETF 放在同一套百分比阈值下必然出问题,阈值应该做成按品类配置的参数表。
- 在数据获取这一段,QuantDash 提供的是统一标的代码、除权因子、标的元数据、批量查询与批量 K 线这些能力,它们解决的是"把数据稳定地拿回来并保持口径可控",而清洗逻辑本身仍然需要你自己实现------数据 API 不会替你做数据质量判断。
QuantDash 官方资源
- QuantDash 技术文档 --- 查看 Python SDK、REST API 及数据接口文档