单元 02:获取并检查行情数据
前两个单元使用的 demo_etf_daily.csv 已经整齐、正确。但现实中的数据通常不会主动告诉你:这里多了一行,那里少了一天,某个价格无法解析,证券代码还藏着一个空格。
本单元从一份刻意带有格式问题的原始行情出发,完成一次可重复的数据清洗和质量审计。我们只自动修复能够确定答案的问题;需要猜测价格或市场状态的问题,一律报告并停止。
本单元目标
完成本单元后,你将能够:
- 区分原始数据、清洗动作和质量检查;
- 定义行情表的必要字段、主键、类型和排序要求;
- 检查缺失值、重复记录、非法数值和 OHLC 关系;
- 使用明确的交易日历判断缺失交易日;
- 区分完全重复与相互冲突的重复记录;
- 解释为什么有些问题可以自动修复,有些必须停止处理;
- 生成清洗后的行情、机器可读的质量报告和人工检查图。
最终产物由下面的命令生成:
uv run python examples/unit02/check_data_quality.py前置条件
- 已完成单元 01;
- 能解释
symbol、date和 OHLCV; - 已执行
uv sync --locked; - 后续命令均在项目根目录运行。
场景:下载成功不等于数据可用
下载成功不等于数据可用
数据接口返回 200 OK,只能说明这次网络请求成功。它不能证明:
- 字段齐全且含义没有变化;
- 日期和数字被正确解析;
- 每个标的每天只有一条记录;
- 交易日没有漏掉;
- 价格和成交量符合基本业务关系;
- 复权、货币和时区与你的研究假设一致。
所以数据进入研究流程后,不应该直接传给策略。更稳妥的流程是:
数据源
↓
原始数据:保留收到的内容
↓
确定性清洗:格式统一、完全重复、排序
↓
质量审计:完整性、唯一性、有效性、交易日覆盖
↓
人工复核:图形、来源与异常解释
↓
研究数据清洗与审计的边界
清洗负责执行已经确定的规则,审计负责暴露仍未解决的问题。不能为了让检查变成绿色而随意改数据。
认识原始样例
本单元使用 demo_etf_daily_raw.csv。它由项目根据前一单元的正确样例构造,包含 11 行记录和三类问题:
- 1 行
symbol带有首尾空白; - 1 行与另一行的全部字段完全相同;
- 日期没有按顺序排列。
价格和成交量没有被篡改,10 个预期交易日也都存在。完整说明见待清洗样例数据说明。
为什么不把所有问题自动“修好”
很多金融数据错误没有唯一答案。如果收盘价缺失,我们不知道正确价格;如果同一天出现两个不同收盘价,也不知道该信哪一个。自动填一个数字只会把可见的缺失变成更难发现的错误。
先定义什么叫“正确”
没有数据契约(Data Contract),就没有可执行的质量检查。数据契约是对表结构和业务规则的明确约定。
本单元对 DEMO_ETF 日线作出以下约定:
| 维度 | 规则 |
|---|---|
| 必要字段 | symbol、date、open、high、low、close、volume |
| 主键 | symbol + date,组合后必须唯一 |
| 日期 | 使用 YYYY-MM-DD,每个标的内部升序 |
| 价格 | OHLC 均为 CNY/份,必须大于 0 |
| 成交量 | 单位为份,必须大于或等于 0 |
| 完整性 | 必要字段不能缺失 |
| OHLC | high 不低于开收盘,low 不高于开收盘,且 high ≥ low |
| 交易日 | 与 demo_market_calendar.csv 完全一致 |
| 复权 | 合成数据,没有复权含义 |
主键(Primary Key)是能唯一标识一条记录的一组字段。只用 date 不能支持多标的,只用 symbol 不能支持多天;二者组合才表示“某个标的在某个交易日的日线”。
这些规则只适用于当前数据契约。例如,成交量为 0 在某些数据中可能合法,价格为 0 通常不合法;分钟行情的主键还需要时间戳。把规则写进代码前,必须先确认资产类型和数据供应商的定义。
解析日期和数值
原始 CSV 中的日期和数字最初只是文本表示。程序需要把它们转换为适合比较和计算的类型:
prices["date"] = pd.to_datetime(
prices["date"], format="%Y-%m-%d", errors="coerce"
)
for column in ["open", "high", "low", "close", "volume"]:
prices[column] = pd.to_numeric(prices[column], errors="coerce")format="%Y-%m-%d" 明确要求年—月—日格式,避免 01/02/2024 究竟是 1 月 2 日还是 2 月 1 日的歧义。
errors="coerce" 会把无法解析的日期变成 NaT,把无法解析的数字变成 NaN。这不是修复,而是把隐蔽的坏文本转换成可以统一检查的缺失值。pandas 官方文档也说明了 to_datetime(..., errors="coerce") 会将不可解析日期转换为 NaT,参见 pandas.to_datetime。
NaN(Not a Number)常用于表示缺失数值;NaT(Not a Time)是日期时间类型中的缺失值;None、空字符串和特殊文本也可能在读取时形成缺失值。
研究代码不应该只检查某一种缺失表示。pandas 的 isna() 可以统一识别常见缺失值。
只执行确定性清洗
打开 examples/unit02/check_data_quality.py,函数 clean_unambiguous_issues 只做三件事。
去除证券代码首尾空白
prices["symbol"] = prices["symbol"].astype("string").str.strip()" DEMO_ETF " 和 "DEMO_ETF" 显然想表达同一个代码。删除首尾空白不会猜测市场数据。
注意,不要顺手把所有代码转成整数。证券代码可能包含字母,也可能有有意义的前导零。
删除完全相同的重复行
prices = prices.drop_duplicates()只有全部字段都相同时,删除多余副本才不改变信息。pandas 的 duplicated 和 drop_duplicates 可以标记或删除重复行,参见 DataFrame API。
下面两行则是冲突重复,不能保留任意一条冒充正确答案:
DEMO_ETF,2024-01-05,...,close=102.40
DEMO_ETF,2024-01-05,...,close=999.00它们拥有相同主键,但价格不同。程序会报告 duplicate_keys 并停止,需要回到数据源、成交所公告或其他可信来源核实。
按标的和日期排序
prices = prices.sort_values(["symbol", "date"], kind="stable")时间序列计算依赖先后顺序。数据没有升序时,后续的“上一日收盘价”“移动平均线”和收益率都会把错误的两天连在一起。
稳定排序(Stable Sort)会保留排序键相同时记录的原始相对次序。但稳定排序并不能解决冲突重复,所以排序后仍要检查主键唯一性。
执行质量审计
清洗之后,audit_prices 从七个方面检查数据。
1. 结构完整
必要列必须存在,表不能是空的。列名缺失时程序立即返回,因为后续业务检查已经失去基础。
2. 必要值完整
逐列统计 NaN 和 NaT。报告不仅写“存在缺失”,还写明哪个字段缺多少个,方便回到原始记录定位。
3. 主键唯一
程序使用 symbol + date 检查冲突重复,并通过 keep=False 标记同组的所有记录,而不是只标记第二条。
4. 时间有序
每个标的内部日期必须从早到晚。全表排序并不等同于只看 date;多标的数据必须先按标的分组。
5. 数值范围合理
OHLC 必须大于 0,成交量不能小于 0。这里检查的是业务上不可能或不符合契约的值,不是判断价格“太高”或“太低”。
6. OHLC 关系成立
沿用单元 01 的规则:
high ≥ max(open, close)
low ≤ min(open, close)
high ≥ low7. 交易日覆盖完整
程序将行情日期与 demo_market_calendar.csv 比较:
- 日历有、行情没有:
missing_sessions; - 行情有、日历没有:
unexpected_sessions。
不能用“周一到周五”代替交易日历。真实市场存在节假日、临时休市和历史规则变化;跨境 ETF 的申购赎回还可能受到境外市场休市影响。
停牌(Trading Suspension)也需要结合数据契约判断。有的供应商在停牌日保留一行并把成交量记为 0,有的供应商不返回该行。缺少记录可能是数据丢失,也可能是市场状态,检查器只能报告差异,不能凭空决定如何填补。
运行完整流程
执行:
uv run python examples/unit02/check_data_quality.py预期输出:
原始记录: 11 行
去除代码首尾空白: 1 行
删除完全重复记录: 1 行
日期重新排序: 是
清洗后记录: 10 行
数据质量检查: 通过(0 个问题)
清洗数据: data/processed/demo_etf_daily.csv
质量报告: outputs/unit02/data_quality_report.json
检查图表: outputs/unit02/cleaned_prices.png查看质量报告:
uv run python -m json.tool --no-ensure-ascii outputs/unit02/data_quality_report.json核心结果应为:
{
"status": "passed",
"raw_rows": 11,
"clean_rows": 10,
"quality_issues": []
}实际报告还保留每项清洗动作及受影响行数。报告让程序和读者都能回答:输入多少行、改了什么、输出多少行、还有哪些未解决问题。
人工检查图表
打开 outputs/unit02/cleaned_prices.png。上图是收盘价,下图是成交量。
重点观察:
- 日期是否按时间向右推进;
- 是否出现突然接近 0 或远离相邻值的价格;
- 成交量是否出现数量级突变;
- 图中空档能否由交易日历解释;
- 走势是否与单元 01 的正确样例一致。
人工读图可以快速发现数量级错误、复权跳变和异常尖峰,但它不能证明数据正确。10 行数据可以逐点查看,100 万行数据不行;自动规则负责一致执行,图形负责提供另一种观察角度。
异常值(Outlier)也不等于错误值。一次真实的价格暴跌可能是最重要的信息,如果因为“看起来不正常”就删除,会把风险从历史中抹掉。正确流程是先标记、核实来源和市场事件,再决定是否修正。
接入网络数据时,还要记录什么?
核心练习不依赖联网接口,但真实项目获取数据时还应记录:
- 数据供应商和接口名称;
- 请求的标的、市场、字段、频率和日期范围;
- 请求时间、时区和接口版本;
- 复权参数、货币和单位;
- 分页、限频和失败重试结果;
- 数据授权及允许的使用范围。
第一次收到的数据应原样保留。接口以后返回不同结果时,原始响应可以帮助判断是供应商修订历史数据、参数变化,还是清洗代码发生变化。这种从结果追溯到来源和处理步骤的能力称为数据血缘(Data Lineage / Provenance)。
联网下载应该只是“替换输入”的步骤,后面的清洗、质量审计和报告流程仍然适用。不要把下载和清洗塞进一个无法观察中间结果的函数。
不要自动做这些事
不要把缺失价格填成 0
价格 0 会制造一次接近 -100% 的虚假下跌,并污染后续收益率、波动率和回撤。
不要默认用前一日收盘价填补
向前填充会假装资产价格没有变化。停牌场景中这有时可能符合某种估值约定,但必须由明确规则支持,不能作为通用清洗动作。
不要遇到重复就保留第一条
完全重复可以去重,冲突重复必须核实。如果供应商后返回的是修订值,保留第一条反而可能保留旧数据。
不要把工作日都补成交易日
市场日历不是普通工作日日历。错误补行会创造不存在的交易机会。
不要看到尖峰就删除
尖峰可能来自拆分、分红、货币单位、录入错误,也可能是真实行情。先解释原因,再选择处理方式。
不要只检查最终行数
输入 11 行、输出 10 行符合本次预期,但“恰好 10 行”不能证明内容正确。还要检查主键、字段、数值、日历和来源。
验证结果
再次运行程序:
uv run python examples/unit02/check_data_quality.py完成标准:
- 原始 11 行按明确规则整理为 10 行;
- 质量报告的
status为passed,quality_issues为空; - 清洗结果按
symbol + date升序,主键唯一; - 10 个预期交易日全部存在;
- 清洗结果与前一单元的正确固定样例逐字段一致;
- 你能够解释为什么程序拒绝自动填补缺失价格和冲突重复。
小结
本单元建立了第一条可以信任的数据入口:
原始 CSV + 明确交易日历
↓
解析类型与确定性清洗
↓
结构 / 缺失 / 重复 / 数值 / OHLC / 日历
↓
机器报告 + 人工读图
↓
可用于研究的日线现在我们有了一份口径明确、顺序正确、通过基础审计的价格序列,也为接下来计算收益率与净值准备好了可靠输入。
练习
基础练习:阅读质量报告
运行主程序后打开 JSON 报告,逐项回答:
- 原始数据为什么有 11 行?
- 哪三项清洗动作被执行?
- 为什么清洗结果是 10 行?
quality_issues为空能证明什么,不能证明什么?
进阶练习:制造可检测的问题
复制原始 CSV 到临时位置,分别进行以下修改,每次只改一项:
- 删除 2024-01-10;
- 把某天成交量改为
-1; - 把某天最高价改为低于收盘价;
- 复制某天记录,但给它一个不同的收盘价。
让 run_pipeline 读取临时文件,观察质量报告中的问题代码。不要修改固定教学样例。
延伸练习:设计真实数据契约
选择一个你未来想研究的真实标的,不需要下载数据。先写出:
- 市场、证券代码、资产类型和货币;
- 数据频率、时区和交易日历来源;
- 主键、必要字段和单位;
- 复权口径;
- 停牌日应该有记录还是没有记录;
- 哪些问题可以自动修复,哪些必须人工核实。
如果其中任何一项无法确定,记录为待调查问题,而不是自行假设。