☰
比特币资金流数据清洗实战:从coinglass抓取到特征表落地
2026/10/23 6:47:01 网站建设 项目流程

1. 项目定位:这个数据清洗模块解决了什么事

先说一下我在实际工作里看到的真实场景。很多做结构化数据研究的人,专门负责把市场里的交易类原始数据接回来,然后落库、对齐、算特征。而「比特币数据清洗模块 抓取coinglass原始资金流的数据清洗」这个标题,第一眼看像是个常规爬虫任务,但真正接手后才发现,核心难点根本不在“能不能抓到数据”,而在“抓回来之后怎么让数据变得能直接用”。

标题拆开看其实分了三层:第一层是“coinglass原始资金流”,这是数据源和内容;第二层是“抓取”,这是采集动作;第三层是“数据清洗”,这才是模块的主体和灵魂。一个合格的清洗模块,不是只写两个函数把空值填掉就完事,它要处理的是源数据从接口到本地、从毫秒时间戳到业务字段、从原始报文到标准表格之间的一整条链路。

这个模块最适合谁?两类人。一类是正在自建行情数据管道的个人开发者,另一类是小团队里的数据工程或量化研究人员。你如果只是偶尔手搓一个脚本,跑完一次就不管了,那不需要模块化;但如果你要每天定时抓取多个合约的资金费率、持仓量、多空比,还要把它和交易K线、成交记录做关联分析,那么清洗模块就是整条流水线的地基。地基没打牢,后面所有的特征工程、样本统计都会变成空中楼阁。

1.1 原始资金流为什么不能直接用

很多人第一次从coinglass拉数据,看到返回的原始JSON会觉得:“这不是挺整齐的吗?有timestamp,有数值,展开成表格就能用了。”这种想法我最初也有过,直到拿清洗后的数据和交易所自己的接口做交叉验证,才发现问题一大堆。

首先是时区问题。coinglass返回的多是UTC毫秒时间戳,但本地数据库如果默认用东八区存储,就会产生8小时偏移。开头一两天不觉得,等你要把资金费率和某根K线关联时,你会发现费率永远对不上K线收盘时刻。其次是重复数据。抓取任务为了稳定一般会设计重试机制,一旦网络抖动导致重复请求,同一时刻的记录会被写入两次;如果不去重,后面做滚动统计时样本数会被莫名放大,并且是那种毫无规律的放大,非常难排查。

真正让我意识到必须做清洗的,是字段口径。同样一个字段,在不同接口版本里,“资金费率”可能返回的是小数0.0001,也可能返回的是百分比数值0.01;持仓量有时是以计价货币计价的美元,有时又是以基础货币计价的比特币数量。这些差异如果不统一,下游特征表里的数值就会完全失真。更麻烦的是缺失窗口和异常值,某个时段接口没有返回数据,或者返回了一个比其他时间点大两个数量级的离群值,如果直接丢进模型,轻则特征噪声变大,重则把整个统计区间带偏。

1.2 清洗模块的功能边界

在我实际交付的模块里,它的职责被严格定义为四件事:第一是解析和标准化,把所有原始字段统一成同一套单位、同一套时区、同一套命名;第二是去重和排序,保证同一业务时间只保留一条有效记录;第三是缺失和异常处理,用规则和统计方法识别断档、离群点,并决定是填充还是打标记;第四是对齐和落库,把清洗后的明细数据输出成可以直接被下游读取的格式。

模块不应该承担的事我也划得很清楚:它不做行情预测,不做交易决策,也不临时去计算任何复杂的因子。为什么要把边界收窄?因为一旦清洗模块被塞入太多业务逻辑,它就变成了一锅粥——昨天加一个均线特征,今天加一个百分位统计,明天又要处理某个特定合约的规则,代码会越来越难维护。数据清洗管线的目标永远是“让下游拿到一张可信表”,而不是“让下游少写几行代码”。

2. 抓取前的准备:理解coinglass资金流数据的字段与口径

先聊数据源本身。coinglass这个平台提供的主要是合约市场的衍生数据,包括资金费率、持仓量、多空账户比、爆仓订单、大户资金流向等。标题里写的“资金流”其实是一个宽泛说法,在具体实现时通常要抓的是资金费率、持仓量和多空比这几个核心字段。它们各自的业务含义和清洗重点不太一样,动手之前务必先列清楚。

2.1 核心字段与业务含义

我整理了一张常用字段表,这就是我们清洗模块的输入标准:

字段名业务含义清洗重点
timestamp指标对应的时间点统一为UTC时间戳,必要时换算到交易时区
funding_rate当期资金费率统一为小数格式,识别异常值
next_funding_rate下一期预测费率类型转换,缺失标记
open_interest合约持仓量统一计价单位,处理跳变
long_short_ratio多空账户数量比或仓位比确认口径,避免混合使用
basis基差可能有正负值,无需绝对值处理
symbol交易对统一大小写,统一命名规范

其中资金费率是这类数据里最重要的一个字段。通俗解释一下:在合约市场里,多空双方会定期交换一笔资金费用,费率由市场溢价决定,目的是让合约价格锚定现货价格。资金费率为正说明多头付钱给空头,为负则反过来。它不是指导买卖的信号,而是一个反映市场情绪和持仓成本的重要观测变量。我们在清洗时只把它当成一个数值序列来处理,不参与任何方向性判断。

持仓量和多空比也是老面孔。持仓量反映当前未平仓合约的总规模,多空比则体现了账户层面的分歧程度。这些字段采集过来以后,最常和自身的历史分位数、价格收益率放在一起做统计分析。如果你抓到的原始数据单位不一致,比如这个字段用的是美元计价,那个字段用的是币本位计价,那么算出来的“持仓量环比变化”就会变成彻头彻尾的错误数据。

2.2 抓取策略与原始数据落地

在抓取之前,先把接口文档认真读一遍,看清楚字段定义、单位说明、时间粒度和限频要求。我的习惯是先在文档页面上跑通一次最小请求,把返回的JSON整体存下来,再决定清洗方案。千万不要一上来就写全量抓取脚本,否则很容易出现“把整个返回数据塞进DataFrame发现结构不对”的尴尬。

抓取上三个关键点:一是按时间窗口切分,如果接口支持历史区间,就别一次拉全量,最好按小时或者按天分片拉,这样单次失败时只需要补一小段;二是控制请求频率,正规接口都有限频要求,我会把请求间隔设成接口限制的1.5倍到2倍,宁可慢一点也不要触发封禁;三是原始数据必须完整落地,抓到的原文JSON要原封不动存一份,清洗逻辑写错了还能重新清洗,如果直接清洗后覆写原始数据,那就没有后悔药了。

落地格式我一般用按日期分文件的JSON或parquet,文件名里带上数据日期和抓取批次,比如funding_20250601_001.json。为什么保留批次号?因为同一天可能因为补数任务抓多次,有批次号就能清楚区分哪一批是重试的、哪一批是原始的。数据清洗有个基础原则:原始数据永远是只读的,清洗模块只是从它派生新表,不能回写。

3. 清洗流程设计与实现细节

这一章是整个模块的核心。我按照实际清洗的先后顺序来拆解,每一步都很简单,但组合起来就是一条可以定时跑、可以回溯的干净管线。

3.1 时间与去重的统一

时间戳统一是第一件事。coinglass的原始时间戳大多数是毫秒级,用Python处理时我会直接转成带时区的UTC时间:

import pandas as pd df["ts"] = pd.to_datetime(df["ts"], unit="ms", utc=True) df["datetime_cn"] = df["ts"].dt.tz_convert("Asia/Shanghai")

这里有个容易踩坑的细节:to_datetime默认如果传入的是毫秒时间戳,unit="ms"一定要写对。有人传的是秒级时间戳却用毫秒解析,结果时间直接变成1970年附近,整张表的时间轴全部失效。最好是先打印一条原始记录做人工确认,确认接口文档里写的是毫秒还是秒。

去重的策略也不只是“有重复就删”。我统计过实际数据里的重复情况,最常见的场景是补数任务,同一个时间点因为重试被写入了两条记录,其中一条可能在字段上有细微差异。简单“保留第一条”很可能留下错误版本,“保留最后一条”也不一定对。我的做法是按业务主键,也就是“symbol + timestamp”做去重,然后按抓取批次优先保留最新批次的数据:

# 先按批次排序,批次号大的视为更新 df = df.sort_values(["symbol", "ts", "batch_id"]) df = df.drop_duplicates(subset=["symbol", "ts"], keep="last")

这样设计的前提是抓取模块每批都有批次号。如果原始数据没有这个字段,也可以用新增本地的抓取时间字段,以最后一次抓取为准。去重时必须知道自己在“留最新”,否则手动删重复很容易丢掉修复后的数据。

3.2 数值规范化与单位处理

时间清理好后,下一步是让每个字段变成可计算的数值类型。

for col in ["funding_rate", "open_interest", "long_short_ratio", "basis"]: df[col] = pd.to_numeric(df[col], errors="coerce")

errors="coerce"的含义是遇到非数字字符串时变成 NaN,这样能很直观地暴露脏数据。清洗初期我建议保留NaN,不要立刻填充,先把那些“本该是数字但不是数字”的记录打印出来分析原因——是接口字段取值不固定,还是转换时把字符串原样带过来了,或者是单位变成"1.2万"这种带单位后缀的格式。

单位统一是重头戏。资金费率我统一转成小数形式。比如接口返回0.0001,就直接用;如果返回0.01%,那就需要除以100得到0.0001。判断方法很简单:看文档里的说明,再看一段历史范围的最大值。常规季度合约资金费率的绝对值偶尔会到0.01以上,但很少会出现150这种数字,看到超过常规量级的数据,先怀疑单位,再怀疑数据错误。

持仓量的单位统一可以用一个辅助函数:

def normalize_open_interest(row, price_series): # 如果原始字段以币计价,统一乘价格转为美元计价 if row.get("oi_currency") == "BTC": return row["open_interest"] * price_series.loc[row["symbol"], row["ts"]] return row["open_interest"]

这里的price_series是清洗好的K线价格序列,不建议直接用“当天均价”这种粗粒度数据,因为持仓量是随最新成交变化的,必须用对应时间点的最新价格来换算,尤其是波动较大的时间段,用日线均价换算出来的误差会非常大。

3.3 缺失窗口与异常值判断

缺失值的处理是整个清洗模块里最需要拿捏的地方。资金费率不是每秒更新的数据,它一般有固定的结算周期,比如每8小时一次,或者每1小时一次,所以它的缺失主要指某个完整周期没有任何记录。

我的处理原则有三个层次。第一个层次是短缺失,连续缺口不超过两个周期,可以用相邻周期的均值插值或者前向填充。第二个层次是中长缺失,通过时间索引重采样后,先向左填充,再向右填充,但填充距离如果超过设定阈值就放弃填充。第三个层次是彻底不处理,让缺口保留NaN,并在表里单独加一个is_missing标记。为什么不全填充?因为资金费率的中长期缺失往往意味着平台维护或数据源本身停更,这时候用人工插值造出来的数据会掩盖真实市场状态,下游统计很可能被误导。

异常值检测也要结合业务背景,不能只套统计公式。我用的是“滚动窗口z-score + 业务阈值”双重规则:

# 滚动统计 df["funding_rate_mean"] = df["funding_rate"].rolling(24, min_periods=12).mean() df["funding_rate_std"] = df["funding_rate"].rolling(24, min_periods=12).std() df["z_score"] = (df["funding_rate"] - df["funding_rate_mean"]) / df["funding_rate_std"] # 规则1:z-score超过5且绝对值超过业务阈值 # 规则2:数值变化率超过历史分位数的99% df["is_outlier"] = (df["z_score"].abs() > 5) & (df["funding_rate"].abs() > 0.01)

注意z-score在一个小窗口内可能失真,比如某段时间费率一直在0附近波动,标准差很小,一个正常的小幅波动就可能被误判为异常。所以业务阈值一定要同时添加,过滤掉那些“因窗口太窄而放大噪声”的误判情况。

持仓量的异常就更直观了。正常合约的持仓量不会在几秒钟内大幅跳变。如果一条记录的持仓量和上一条相比变化超过30%,并且下一条又回到原来的区间,这种“尖峰”多半是数据错误,我会单独标记而不是直接删除,因为它对后续统计的影响取决于具体场景。标记字段比硬删更有用:下游分析时可以选择过滤掉,也可以选择保留后建模。

4. 清洗管线实操:从原始JSON到可分析特征表

等前面这些规则都定了,就可以把它们串成一条正式的清洗管线。这一章我会直接展示核心代码和输出表结构。代码会刻意保持简单,方便你迁移到自己的工程里。

4.1 主清洗函数实现

import json import pandas as pd import numpy as np from pathlib import Path class CoinglassCleaner: def __init__(self, symbol="BTC"): self.symbol = symbol.upper() def load_raw(self, file_path: str) -> pd.DataFrame: with open(file_path, "r", encoding="utf-8") as f: raw = json.load(f) # 不同接口返回结构不同,这里假设数据在 records 字段下 records = raw.get("records", raw.get("data", [])) return pd.DataFrame(records) def clean(self, file_path: str, batch_id: int) -> pd.DataFrame: df = self.load_raw(file_path) if df.empty: return df # 1. 统一symbol df["symbol"] = self.symbol # 2. 时间转换与去重 df["ts"] = pd.to_datetime(df["ts"], unit="ms", utc=True) df["batch_id"] = batch_id df = df.sort_values(["symbol", "ts", "batch_id"]) df = df.drop_duplicates(subset=["symbol", "ts"], keep="last") # 3. 数值类型强制转换 num_cols = ["funding_rate", "open_interest", "long_short_ratio", "basis"] for col in num_cols: if col in df.columns: df[col] = pd.to_numeric(df[col], errors="coerce") # 4. 资金费率单位统一,这里假设原始已转为小数 df["funding_rate_clean"] = df["funding_rate"] / 100.0 # 5. 缺失标记 missing_cols = [col for col in num_cols if col in df.columns] for col in missing_cols: df[f"{col}_is_missing"] = df[col].isna().astype(int) # 6. 时间聚合到小时 df["ts_hour"] = df["ts"].dt.floor("1h") return df def aggregate_hourly(self, df: pd.DataFrame) -> pd.DataFrame: group_cols = ["symbol", "ts_hour"] agg_df = df.groupby(group_cols).agg( funding_rate_last=("funding_rate_clean", "last"), funding_rate_min=("funding_rate_clean", "min"), funding_rate_max=("funding_rate_clean", "max"), open_interest_last=("open_interest", "last"), long_short_ratio_last=("long_short_ratio", "last"), missing_count=("funding_rate_clean", lambda s: s.isna().sum()), ).reset_index() return agg_df

这个主函数的思路很简单:load原始数据,统一时间,去重,转类型,再聚合。实际生产里还可以加日志输出、参数校验和数据量监控,我在交付版本里会加一个简单的处理记录,比如:

log_record = { "file": file_path, "source_rows": len(df_raw), "clean_rows": len(df_clean), "missing_ratio": float(df_clean["funding_rate_clean"].isna().mean()), "updated_at": pd.Timestamp.utcnow().isoformat() }

别小看这个处理记录,它能帮你在数据异常时快速定位“是抓取问题还是清洗问题”。

4.2 聚合对齐与输出表设计

清洗后的数据一般会输出成两种表:明细表和小时聚合表。

明细表保留原始时间粒度的每一行干净数据,是回溯排查的依据。小时聚合表则是把原始时间粒度统一到小时级别,方便和分钟级K线表关联。为什么聚合到小时?资金费率这类指标本身更新频率低,按分钟重采样意义不大,反而会制造大量重复值;推到小时级别既能保留日内变化,又不会让表体积膨胀太快。

cleaner = CoinglassCleaner(symbol="BTC") df_clean = cleaner.clean("./raw/funding_20250601_001.json", batch_id=1) df_hourly = cleaner.aggregate_hourly(df_clean) df_hourly.to_parquet("./clean/funding_btc_hourly.parquet")

输出表里我建议专门加一列period_end_time,它表示这笔数据对应的时间窗口的结束时刻。比如费率数据的原始时间戳指向结算时刻,我们把它归到上一个完整小时窗口,并用ts_hour表示窗口起点,用period_end_time表示窗口终点。这样之后在关联K线计算“当期收益率”时,能精确拿到同一时间窗口内的价格变化,不用再靠偏移拼接。

4.3 调度与日志设计

清洗模块一旦稳定,就要考虑怎么跑起来。我的做法是写一个shell脚本,每天定时执行,分三步:先抓取原始数据,再清洗落库,最后做数据质量报告。

python fetch_funding.py --date "$DATE" python clean_funding.py --file raw/funding_${DATE}_001.json python check_quality.py --table clean/funding_btc_hourly.parquet

第三步的check_quality.py是在数据落库后自动检查,包括记录数、缺失率、最新时间戳有没有过期、重复比例等指标,如果有问题就发送告警消息。这一步很容易被人忽略,但它其实是清洗模块的“质检员”,没有它你根本无法知道昨天的数据到底有没有成功清洗干净。

调度起来之后,还需注意一个常见问题:补数任务。某天抓取失败或清洗失败,你会想在第二天把前一天的缺口补上。这时批次号就派上了大用场,补数任务用更大的batch_id跑一遍,清洗逻辑会自动把旧批次数据覆盖为最新批次,保证数据表始终收敛到唯一状态。

5. 踩坑记录与排查速查表

5.1 常见问题整理

我把这几年在这个领域实际遇到的问题按频率排了个序,做成速查表,供大家参考:

问题现象可能原因解决方案
时间整体偏移8小时UTC与东八区转换遗漏统一用带时区时间,存储前明确约定时区
同一时间点多条记录抓取重试机制导致重复按业务主键去重,保留最新批次
资金费率出现超大数值原始字段是百分比而非小数按字段文档统一除以100,并加业务阈值校验
持仓量短时剧烈跳变数据单位切换或口径变更检查是否混合了USDT本位与币本位数据
某段时间数据完全缺失平台维护或源数据停更保留NaN,不强行填充,加缺失标记
数值列出现字符串接口偶尔返回空字符串或用"-"占位用pd.to_numeric强转,空字符串自动变NaN
清洗结果和交易所数据对不上新旧字段定义不一致在清洗模块里记录字段版本,定期做交叉核对

5.2 一次凌晨任务失败的完整复盘

说一个我印象很深的实际案例。某次定时任务在凌晨运行时,抓取阶段正常,清洗阶段也正常,但第二天早上看数据质量报告,发现某个合约的持仓量在某一整段区间内全部成了NaN。排查代码发现,清洗函数里用了pd.to_numeric(errors="coerce"),再往下游追,才发现是上游接口临时改了返回格式,把原来的数字字段变成了JSON嵌套对象,比如"open_interest": {"value": 12345, "unit": "USD"}这种结构。pd.to_numeric处理不了字典,自然全部转成了NaN。

这一步暴露了两个问题:一是清洗函数默认了原始字段一定是简单标量,没有做结构化字段的解析;二是数据质量报告虽然报警了,但报警信息只显示了“缺失率过高”,没有报出“具体字段名”和“异常示例值”,排查效率很低。后来我把清洗函数改成了递归解析模式,从原始JSON里提取字段时先判断类型,是字典就提取value字段,是字符串就尝试数值转换,同时在质量报告里输出最近几条异常样本。就这样,同类问题再次发生时,我只需要看报告就能定位原因,不再需要反复翻原始日志。

还有一个经验很值得分享:原始数据里如果出现了“你怎么看都觉得不对”的值,不要急着在清洗模块里塞特殊规则绕过它。正确做法是先在代码里打一个is_suspicious标记,然后花时间搞懂为什么不对。很多看似异常的数据点,背后其实是数据源对某个短暂市场状态的真实记录,比如极端波动时费率短时拉高、持仓量短时回落,这些反而是有研究价值的样本。清洗模块要做的不是把“不同寻常”抹掉,而是把它们识别出来交给下游决定怎么用。

6. 写在最后:给自建数据模块者的几点建议

前前后后写过好几版数据清洗模块,每次重构都会发现一些可以做得更稳的地方。这里想留下来的建议有三条。

第一,原始数据的保存比清洗代码更重要。清洗逻辑写错了可以立刻改,但原始数据如果删了或者覆盖了,就再也找不回来了。建议所有的原始JSON都按日期和批次归档,离线保存至少三个月以上。我见过太多项目因为“省存储空间”只保留清洗结果,后来要验证一个异常值是不是源数据问题,结果无从查起,最后只能靠猜。

第二,清洗规则要从数据本身长出来,不能拍脑袋。拿到一批数据之后,先跑一段完整描述性统计:看看时间范围、缺失率、每个字段的取值范围、是否有重复记录、单位是否统一。这个探索性分析过程会告诉你大多数规则应该怎么定。我最初写的清洗模块里有一堆自以为是的异常值阈值,后来正是靠这段探索性分析发现了很多阈值在真实数据里根本站不住脚。

第三,模块一定要做版本管理。不只是代码版本,还包括清洗规则的版本和数据字段的版本。同一张表,上个月和这个月如果清洗规则变了,下游分析时就必须知道哪些字段经过了不同处理,否则很容易把不同口径的数据混在一起用。简单做法是给输出表加一个schema_version字段,每次调整核心逻辑就递进一个版本号。至少可以保证以后追溯问题的时候,能快速锁定“数据是什么时候开始变的”。

一个数据清洗模块做得好不好,不是看它用了多少华丽方法,而是看它能不能让别人在三个月后仍然放心地使用这张表。对我来说,做到这一点,模块就算真正交付成功了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询