3、数据解析与清洗:JSON数据解析、数据校验、异常值处理、时间戳对齐
好,咱们进入第三章。说实话,这一章才是真正考验功力的地方。
你想想看,交易所给你的原始数据,就像刚从泥里挖出来的土豆——带着土、带着根、还有虫眼。你不能直接拿去做饭。我见过太多新手,拿到数据就急着画K线,结果图出来全是锯齿,还以为是市场波动大,其实是数据没洗干净。
这一章,咱们就专门干「洗土豆」的活。我会把我在生产环境中踩过的坑,一个一个说给你听。
3.1 JSON数据解析:别被嵌套搞晕
交易所返回的订单簿数据,99%都是JSON格式。但不同交易所的JSON结构,那真是五花八门。
举个例子,币安的深度数据长这样:
{
"lastUpdateId": 1027024,
"bids": [
["0.0024", "10.0"],
["0.0023", "5.0"]
],
"asks": [
["0.0025", "12.0"],
["0.0026", "8.0"]
]
}
看着简单吧?但坑就在那个嵌套数组里。bids和asks都是二维数组,每个元素是[价格, 数量]。我刚开始做的时候,直接用data['bids'][0][0]去取价格,结果某天交易所改了字段顺序,程序直接崩了。
我个人习惯的做法是,先定义一个数据类:
from dataclasses import dataclass
from typing import List, Tuple
@dataclass
class OrderBookLevel:
price: float
quantity: float
is_bid: bool
@dataclass
class OrderBookSnapshot:
update_id: int
bids: List[OrderBookLevel]
asks: List[OrderBookLevel]
def parse_orderbook(raw: dict) -> OrderBookSnapshot:
"""解析原始JSON为结构化数据"""
bids = [
OrderBookLevel(price=float(p), quantity=float(q), is_bid=True)
for p, q in raw['bids']
]
asks = [
OrderBookLevel(price=float(p), quantity=float(q), is_bid=False)
for p, q in raw['asks']
]
return OrderBookSnapshot(
update_id=raw['lastUpdateId'],
bids=bids,
asks=asks
)
这样写的好处是,如果交易所改了字段名,你只需要改一个地方。我在项目中遇到过,某交易所突然把lastUpdateId改成了updateId,幸好我有这层封装,改一行代码就搞定了。
3.2 数据校验:别信交易所的鬼话
你以为交易所给的数据就一定是对的?太天真了。
我曾经在凌晨三点被报警电话吵醒,说系统显示BTC价格跌到0了。我一看,原来是交易所返回了一个空数组。嗯,你没看错,空数组。
我建议至少做这三层校验:
- 结构校验:检查必要的字段是否存在,类型是否正确
- 范围校验:价格不能为负,数量不能为0,时间戳不能是未来
- 逻辑校验:买单价格必须低于卖单价格(否则就是套利机会,但通常不会出现在正常数据里)
代码实现大概是这样的:
def validate_orderbook(data: OrderBookSnapshot) -> bool:
"""校验订单簿数据的合法性"""
# 1. 结构校验
if not data.bids or not data.asks:
return False
# 2. 范围校验
for level in data.bids + data.asks:
if level.price <= 0 or level.quantity <= 0:
return False
# 3. 逻辑校验
best_bid = max(level.price for level in data.bids)
best_ask = min(level.price for level in data.asks)
if best_bid >= best_ask:
return False
return True
3.3 异常值处理:那些离谱的数据点
数据校验通过,不代表数据就干净了。还有一类问题叫「异常值」。
举个例子,某交易所的深度数据里,突然出现一个买单价格是100万,而当前市价才5万。这明显是交易所的撮合引擎抽风了,或者某个交易员手滑输错了。
你怎么处理?
我的做法是引入「价格合理性窗口」:
def filter_outliers(levels: List[OrderBookLevel],
reference_price: float,
window: float = 0.2) -> List[OrderBookLevel]:
"""
过滤异常价格
window: 允许偏离参考价格的比例,0.2表示±20%
"""
lower = reference_price * (1 - window)
upper = reference_price * (1 + window)
return [l for l in levels if lower <= l.price <= upper]
参考价格怎么取?我一般用最近一笔成交价,或者用订单簿的中间价。注意,这个窗口不能设得太小,否则会把正常的深度数据也过滤掉。我个人习惯设20%,在极端行情下可以放宽到50%。
3.4 时间戳对齐:让数据说同一种语言
这是最容易被忽视,但也是最容易出问题的地方。
不同交易所的时间戳格式不一样。有的用毫秒,有的用微秒,有的甚至用字符串。更坑的是,有些交易所的服务器时间和你本地时间有偏差。
你想想看,如果你把不同交易所的数据放在一起分析,时间戳没对齐,那得出的结论全是错的。
我的标准化方案是这样的:
from datetime import datetime, timezone
import pandas as pd
def normalize_timestamp(ts, source: str) -> pd.Timestamp:
"""
将各种格式的时间戳统一为UTC毫秒
source: 'binance', 'okx', 'bybit' 等
"""
if isinstance(ts, (int, float)):
# 判断是秒还是毫秒
if ts > 1e12: # 微秒
ts = ts / 1000
elif ts > 1e9: # 纳秒
ts = ts / 1e6
# 统一转为毫秒
return pd.Timestamp(ts, unit='ms', tz='UTC')
elif isinstance(ts, str):
# 处理ISO格式字符串
return pd.Timestamp(ts).tz_convert('UTC')
else:
raise ValueError(f"不支持的时间戳格式: {type(ts)}")
这里有个细节:我统一用毫秒。为什么?因为大多数交易所的深度更新频率在100ms左右,毫秒精度够用了。用微秒反而会增加存储和计算开销。
我曾经在回测时发现策略收益异常高,查了半天,原来是数据有10秒的缺口,系统把缺口前后的数据当成连续的了,算出来的收益率自然离谱。从那以后,我每次加载数据都会跑一遍连续性检查:
def check_timestamp_continuity(timestamps: List[pd.Timestamp],
max_gap: pd.Timedelta = pd.Timedelta(seconds=1)):
"""检查时间戳是否连续"""
gaps = timestamps[1:] - timestamps[:-1]
large_gaps = gaps[gaps > max_gap]
if len(large_gaps) > 0:
print(f"发现 {len(large_gaps)} 处数据缺口")
for idx, gap in enumerate(large_gaps[:5]): # 只打印前5个
print(f" 缺口 {idx+1}: {gap}")
3.5 本章知识体系
说了这么多,咱们用一张图来总结一下整个数据清洗的流程:
这张图把咱们今天讲的内容串起来了。从原始JSON开始,经过解析、校验、异常值处理、时间戳对齐,最后得到干净可用的数据。每一步都有对应的代码实现和避坑指南。
嗯,数据清洗这部分就讲到这里。记住一句话:脏数据进,脏数据出。你花在清洗上的每一分钟,都会在后面的分析和交易中加倍回报给你。
公众号:蓝海资料掘金营,微信deep3321