债券数据清洗与预处理:缺失值处理、异常值检测与修正、数据类型转换、日期时间数据处理

做债券量化分析,最怕什么?不是策略不赚钱,而是数据本身有问题。我见过太多人花大把时间调模型,结果发现收益率曲线算出来是错的——原因就是原始数据里藏着几个缺失值或者异常点。说白了,数据清洗这一步要是没做好,后面所有分析都是空中楼阁。

今天咱们就聊聊债券数据清洗的几个核心环节。我个人习惯把这块分成四块:缺失值怎么补、异常值怎么抓、数据类型怎么转、日期时间怎么处理。嗯,一个一个来。

核心观点:数据清洗不是体力活,而是技术活。好的清洗策略能提升模型表现,差的策略可能直接毁掉你的回测结果。

缺失值处理:别急着删,先想想为什么缺失

债券数据里缺失值太常见了。比如某只信用债某天没有成交,那收益率就是空的。再比如节假日,银行间市场休市,数据自然断档。遇到这种情况,我建议先问自己三个问题:

  • 这个缺失是随机的,还是系统性的?
  • 缺失比例有多大?超过30%就要警惕了
  • 后续模型对数据完整性要求有多高?

我在项目中遇到过最典型的情况:国债收益率曲线在2008年金融危机期间出现大量缺失。为什么?因为市场流动性枯竭,很多期限根本没有报价。这时候如果用简单的均值填充,那分析结果就完全失真了。

常用的缺失值处理方法,我整理了一张表:

方法 适用场景 注意事项
向前填充(ffill) 时间序列数据,缺失值较少 假设数据变化缓慢,适合日频数据
向后填充(bfill) 数据开头有缺失 慎用,容易引入未来信息
线性插值 收益率曲线等连续数据 对非线性趋势效果一般
模型预测填充 缺失比例较高时 计算量大,但精度高
直接删除 缺失比例极小(<1%) 简单粗暴,但可能丢失信息

我的小技巧:处理债券收益率缺失时,我习惯先用同期限国债收益率做回归填充。因为信用债和国债之间往往存在稳定的利差关系,比单纯插值靠谱得多。

# 向前填充示例
import pandas as pd

df['yield'].fillna(method='ffill', inplace=True)

# 线性插值
df['yield'] = df['yield'].interpolate(method='linear')

# 模型填充(简单版)
from sklearn.linear_model import LinearRegression
mask = df['yield'].isna()
X_train = df.loc[~mask, ['treasury_yield', 'rating_score']]
y_train = df.loc[~mask, 'yield']
model = LinearRegression().fit(X_train, y_train)
df.loc[mask, 'yield'] = model.predict(df.loc[mask, ['treasury_yield', 'rating_score']])

异常值检测与修正:别让一个错误数据毁了整个模型

债券数据里的异常值,有时候是录入错误,有时候是市场极端波动。比如某只债的收益率突然从3%跳到15%,第二天又回到3.1%。你想想看,这合理吗?大概率是数据源搞错了。

我曾经在回测中发现一个策略收益异常高,兴奋了半天。后来一查,原来是某天的价格数据多了一个零。嗯,从那以后我养成了一个习惯:任何异常收益都要先怀疑数据,再怀疑策略。

常用的异常值检测方法:

  • Z-score方法:假设数据服从正态分布,超过3个标准差就算异常。适合收益率变化率这类数据。
  • IQR方法:用四分位距判断,不受极端值影响。适合信用利差这类偏态分布的数据。
  • 移动窗口法:用过去N天的均值和标准差判断当前值是否异常。适合时间序列数据。

注意:债券市场在重大事件(比如降息、违约)发生时,收益率出现大幅波动是正常的。这时候不要机械地删除异常值,要结合事件日期来判断。我曾经因为误删了2015年股灾期间的债券数据,导致回测结果严重偏离实际情况。

# Z-score异常值检测
from scipy import stats
z_scores = np.abs(stats.zscore(df['yield_change']))
df['is_outlier'] = z_scores > 3

# IQR方法
Q1 = df['credit_spread'].quantile(0.25)
Q3 = df['credit_spread'].quantile(0.75)
IQR = Q3 - Q1
lower_bound = Q1 - 1.5 * IQR
upper_bound = Q3 + 1.5 * IQR
df['is_outlier'] = (df['credit_spread'] < lower_bound) | (df['credit_spread'] > upper_bound)

# 修正异常值:用中位数替换
median_val = df.loc[~df['is_outlier'], 'credit_spread'].median()
df.loc[df['is_outlier'], 'credit_spread'] = median_val

数据类型转换:别让字符串坑了你

这个坑我踩过不止一次。从Wind或者Bloomberg导出来的数据,收益率看起来是数字,实际上存的是字符串。你一做计算,直接报错。或者债券代码明明是字符串,pandas自动给你转成了整数,前面的0全丢了。

为什么会这样?因为数据源为了保留格式(比如保留两位小数),把数值存成了文本。你想想看,如果不对数据类型做检查,后面所有计算都是白费功夫。

我建议拿到数据后,第一件事就是检查dtypes:

# 检查数据类型
print(df.dtypes)

# 常见转换
df['yield'] = pd.to_numeric(df['yield'], errors='coerce')  # 强制转数值,错误变NaN
df['bond_code'] = df['bond_code'].astype(str)  # 债券代码转字符串
df['volume'] = df['volume'].astype(float)  # 成交量转浮点数

# 处理百分比字符串
df['yield_pct'] = df['yield_pct'].str.replace('%', '').astype(float) / 100

避坑指南:我曾经在处理中债估值数据时,发现某些债券的到期收益率列里混入了"---"这样的占位符。pd.to_numeric加上errors='coerce'参数,会自动把这些转成NaN,然后你再统一处理缺失值,完美。

日期时间数据处理:债券分析的时间维度

债券分析对时间的要求特别高。为什么?因为债券的定价、久期计算、收益率曲线构建,全都依赖精确的日期。比如你算应计利息,差一天都不行。

我见过最离谱的情况:有人把2023-01-01和2023/01/01混在一起用,pandas解析出来全是NaT。还有人在计算持有期收益率时,直接用日期相减,结果忘了考虑节假日和周末。

常用的日期处理技巧:

  • 统一日期格式:用pd.to_datetime()统一转换,指定format参数提高效率
  • 交易日历:债券市场有自己的交易日历,别用自然日
  • 日期差计算:用np.busday_count()计算实际交易日天数
# 统一日期格式
df['trade_date'] = pd.to_datetime(df['trade_date'], format='%Y-%m-%d')

# 设置日期为索引
df.set_index('trade_date', inplace=True)

# 计算两个日期之间的交易日数
import numpy as np
start_date = np.datetime64('2023-01-01')
end_date = np.datetime64('2023-12-31')
trading_days = np.busday_count(start_date, end_date, weekmask='1111100')  # 周一到周五

# 生成交易日序列
trading_calendar = pd.bdate_range(start='2023-01-01', end='2023-12-31', freq='B')

# 重采样到月度频率
monthly_data = df.resample('M').last()  # 取月末数据

实战经验:我在做国债期货基差分析时,发现用自然日计算持有期会引入很大误差。后来改用中金所的交易日历,用np.busday_count()精确计算交易日数,回测结果才变得合理。记住:债券市场只看交易日,不看周末和节假日。

知识体系总览

说了这么多,咱们用一张图来总结一下债券数据清洗与预处理的整体流程:

债券数据清洗与预处理流程 原始债券数据 缺失值处理 填充/插值/删除 异常值检测 Z-score/IQR/移动窗口 数据类型转换 数值/字符串/百分比 日期时间处理 交易日历/重采样 清洗后数据 量化分析模型输入

这张图把整个流程串起来了。从原始数据出发,经过缺失值处理、异常值检测、数据类型转换、日期时间处理这四个环节,最终得到干净可用的数据,才能输入到量化模型中去。

最后说一句:数据清洗没有银弹。不同的债券品种、不同的数据源、不同的分析目标,处理方式都不一样。我的建议是:多动手试,多对比结果,慢慢形成自己的清洗框架。


公众号:蓝海资料掘金营,微信deep3321