跳到主要内容

数据清洗:缺失值、复权与异常值

直觉

「垃圾进,垃圾出。」策略再精巧,喂进去脏数据也白搭。数据清洗主要对付三类问题:缺失值(停牌、漏数)、复权(除息除权造成的价格跳变)、异常值(明显错误或极端值)。

1. 缺失值

常见做法:

  • df.dropna():直接删掉含缺失的行(数据多时);
  • df.ffill():用前一个有效值填充(停牌最常用——价格不变);
  • df.interpolate():线性插值(适合平滑序列)。
「人话」解释:为什么停牌常用 ffill?

停牌期间没有任何成交,价格「定格」在最后一次交易水平,最合理的假设就是价格不变——也就是用前值填充(forward fill)。 注意:ffill 可能掩盖真实风险(一只停牌很久的股票,ffill 后看起来风平浪静)。

2. 复权(价格调整)

公司分红、拆股会让价格「凭空」跳变,干扰收益计算。复权就是把这些跳变回填,得到一条连续的「真实持有收益」曲线:

  • 前复权:把历史价格按当前调整(向后看齐);
  • 后复权:把未来价格按起点调整。

我们的 spy_daily.csv 里:close 是未复权收盘,adj_close前复权收盘(按 2%/年股息回加)。算「总收益」要用 adj_close

3. 异常值(Winsorize / 截尾)

把超出分位数范围的极端值「拉回」边界,避免单个离群点主导统计:

xiclip(xi, q1%, q99%)x_i \leftarrow \text{clip}(x_i,\ q_{1\%},\ q_{99\%})

可运行案例:缺失填充、复权对比、异常截尾

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

df = pd.read_csv('/data/spy_daily.csv', parse_dates=['date']).set_index('date')

# —— 1) 缺失值演示:人为挖几个洞再 ffill ——
close = df['close'].copy()
close.iloc[[100, 101, 500, 501, 502]] = np.nan
print("挖洞后缺失数:", int(close.isna().sum()))
close_ff = close.ffill()
print("ffill 后缺失数:", int(close_ff.isna().sum()))

# —— 2) 复权:价格收益 vs 总收益 ——
adj = df['adj_close']
price_ret = np.log(df['close'] / df['close'].shift(1)).sum()
total_ret = np.log(adj / adj.shift(1)).sum()
print(f"\n未复权 累计对数收益: {price_ret:.4f}")
print(f"前复权 累计对数收益: {total_ret:.4f}   ← 含分红, 应更高")
print(f"复权带来的差额      : {total_ret - price_ret:.4f}")

# —— 3) Winsorize 日收益尾部 ——
r = np.log(adj / adj.shift(1)).dropna()
lo, hi = r.quantile([0.01, 0.99])
r_win = r.clip(lo, hi)
print(f"\n原始最大/最小日收益: {r.min()*100:.2f}% / {r.max()*100:.2f}%")
print(f"截尾后(1%~99%):      {r_win.min()*100:.2f}% / {r_win.max()*100:.2f}%")

plt.figure(figsize=(8, 3.4))
df[['close','adj_close']].plot(ax=plt.gca())
plt.title("close(未复权) vs adj_close(前复权)"); plt.xlabel(""); plt.ylabel("价格")
plt.tight_layout(); plt.show()

小结

  • 缺失:停牌常用 ffill;缺失多时可直接 dropna
  • 复权:算总收益要用复权价(adj_close),否则会漏掉分红;
  • 异常值:用分位数截尾(Winsorize)防离群点主导。