Skip to content

3.2 数据获取与清洗

概念详解

数据:量化策略的血液

在量化交易中有一句格言:Garbage In, Garbage Out。无论模型多么精妙,如果输入的数据是脏的,输出就一定是错的。更危险的是,脏数据导致的错误往往是"看起来合理但实际上错误"的,极难被发现。

数据获取与清洗通常占据量化研究者50-70%的工作时间。这不是效率问题,而是数据质量的极端重要性使然。

名词解释:复权

调整股价以反映分红、送股、拆股等事件,保持价格序列连续可比。前复权调整历史价格,后复权调整当前价格。

名词解释:除权除息 (Ex-Right/Ex-Dividend)

股票分红送股后,交易所对股价进行的调整。如果不做复权处理,价格序列会出现"跳跃",看似价格暴跌实际是除权除息所致。

名词解释:前视偏差 (Look-Ahead Bias)

在回测中使用了当时不可获得的信息(如用年报数据在年报发布日之前交易)。这是量化回测中最隐蔽的错误之一。

金融数据的独特挑战

金融数据与一般的数据集有本质的不同:

  1. 时序依赖性:数据点不是独立的,昨天的价格影响今天的决策
  2. 生存偏差:回测往往只使用"现在还活着的股票",忽略了已退市的
  3. 数据时效性:财报数据存在发布延迟,不能假设在报告期结束日就知道数据
  4. 复权复杂性:分红、送股、配股、拆股、并股都会在价格序列上留下"疤痕"
  5. 节假日与停牌:不同市场、不同股票的交易日历不同,处理不当会导致对齐错误
  6. 极端值和错误数据:数据库中的错误(如价格多一个零)比想象中常见得多

主要数据源概览

数据源覆盖范围数据类型费用适用场景
AkShareA股、期货、基金日线、分钟线、基本面免费个人研究、入门学习
Tushare ProA股、指数、宏观全品类免费/付费个人和中小团队
WindA股、港股、全球全品类专业级高额付费专业机构
yfinance全球股票、ETF日线免费海外市场研究
BaostockA股日线、分钟线免费个人研究
JoinQuant/RiceQuantA股日线+基本面免费/付费在线回测+数据一体

Python实战

案例1:清洗日线数据

PYTHON4 行 · 154 B
📄此处有展示代码4 行 · 154 B展开 ▼
python
df['Close'] = df['Close'].ffill()
df['Return'] = df['Close'].pct_change()
abnormal = df[df['Return'].abs() > 0.2]
print(f"异常天数: {len(abnormal)}")
点击展开可浏览运行结果
缺失值(已前向填充): 1 处
异常收益天数: 2
异常日期:
                 Close    Return
2024-03-21  120.278364  0.500000
2024-03-22   80.460457 -0.331048

案例2:从AkShare获取A股数据并完整清洗

PYTHON92 行 · 3.5 KB
📄此处有展示代码92 行 · 3.5 KB展开 ▼
python
import pandas as pd
import numpy as np
import akshare as ak

def fetch_and_clean_a_stock(symbol, start_date, end_date, adjust='qfq'):
    """
    获取并清洗A股日线数据
    
    symbol: 股票代码,如 '600519'
    start_date: 起始日期 '20200101'
    end_date: 截止日期 '20241231'
    adjust: 复权方式 'qfq'(前复权)/'hfq'(后复权)/''(不复权)
    """
    # 获取原始数据
    raw = ak.stock_zh_a_hist(
        symbol=symbol,
        period='daily',
        start_date=start_date,
        end_date=end_date,
        adjust=adjust
    )
    
    # 标准化列名
    raw = raw.rename(columns={
        '日期': 'date', '开盘': 'open', '收盘': 'close',
        '最高': 'high', '最低': 'low', '成交量': 'volume',
        '成交额': 'amount', '振幅': 'amplitude',
        '涨跌幅': 'pct_change', '涨跌额': 'change',
        '换手率': 'turnover'
    })
    
    # 设置日期索引并排序
    raw['date'] = pd.to_datetime(raw['date'])
    raw = raw.set_index('date').sort_index()
    
    # === 数据清洗流水线 ===
    df = raw.copy()
    n_original = len(df)
    
    # 1. 处理重复日期(取最后一条记录)
    df = df[~df.index.duplicated(keep='last')]
    
    # 2. 检测并标记极端价格错误
    # 价格突然翻倍或腰斩(在没有涨跌停限制的情况下)
    price_change = df['close'].pct_change()
    extreme_mask = price_change.abs() > 0.15  # 单日涨跌超15%可能是数据错误
    if extreme_mask.any():
        print(f"检测到 {extreme_mask.sum()} 个极端价格变动日:")
        for date in df.index[extreme_mask]:
            print(f"  {date.date()}: 变动={price_change[date]:.2%}")
    
    # 3. 处理零成交量日(停牌日)
    zero_volume = df['volume'] <= 0
    df.loc[zero_volume, ['open', 'high', 'low']] = df.loc[zero_volume, 'close'].values
    
    # 4. 前向填充(停牌延续最新价)
    # 注意:不建议在金融数据上使用插值,因为价格在停牌期间并没有"中间值"
    df['close'] = df['close'].ffill()
    df['open'] = df['open'].ffill()
    df['high'] = df['high'].ffill()
    df['low'] = df['low'].ffill()
    
    # 5. 衍生计算
    df['return'] = df['close'].pct_change()
    df['log_return'] = np.log(df['close'] / df['close'].shift(1))
    df['range'] = (df['high'] - df['low']) / df['close']  # 日内振幅
    
    # 6. 标记异常收益率日(可能是脏数据或极端事件)
    ret_mean = df['return'].mean()
    ret_std = df['return'].std()
    df['is_abnormal'] = (df['return'] - ret_mean).abs() > 4 * ret_std
    
    # 7. 计算可用交易日数
    trading_days = (~zero_volume).sum()
    
    # === 数据质量报告 ===
    print("=" * 50)
    print(f"数据清洗报告: {symbol}")
    print("=" * 50)
    print(f"原始记录数: {n_original}")
    print(f"清洗后记录数: {len(df)}")
    print(f"重复日期: {n_original - len(df)}")
    print(f"零成交量日(停牌): {zero_volume.sum()} ({zero_volume.sum()/len(df):.1%})")
    print(f"异常收益率日: {df['is_abnormal'].sum()} ({df['is_abnormal'].sum()/len(df):.1%})")
    print(f"日期范围: {df.index.min().date()} ~ {df.index.max().date()}")
    print(f"实际交易日: {trading_days}")
    print(f"\n价格统计:")
    print(f"  开盘: {df['open'].min():.2f} ~ {df['open'].max():.2f}")
    print(f"  收盘: {df['close'].min():.2f} ~ {df['close'].max():.2f}")
    print(f"  日均成交量: {df.loc[~zero_volume, 'volume'].mean():,.0f}")
    
    return df
点击展开可浏览运行结果
📘 本段代码定义了 1 个函数/类:函数 `fetch_and_clean_a_stock`(获取并清洗A股日线数据)。该片段为教学展示(未包含独立运行的输入数据),可在实战练习中结合真实数据调用。

案例3:多股票面板数据构建与前视偏差防护

PYTHON142 行 · 5.1 KB
📄此处有展示代码142 行 · 5.1 KB展开 ▼
python
import pandas as pd
import numpy as np

def build_panel_with_publication_dates(financial_data, report_dates, 
                                        price_data, lookback=252):
    """
    构建避免前视偏差的面板数据集
    
    关键思想:财务数据的实际发布日期(report_dates)通常晚于报告期结束日期。
    回测时必须使用report_dates而非报告期日期来对齐。
    
    financial_data: DataFrame, index=stock_code, columns=财务指标
    report_dates: DataFrame, index=stock_code, values=发布日期
    price_data: DataFrame, index=date, columns=stock_code
    """
    aligned_data = {}
    
    for stock in financial_data.index:
        report_date = pd.Timestamp(report_dates.loc[stock, 'report_date'])
        financials = financial_data.loc[stock]
        
        # 只在发布日期之后才能使用该财务数据
        stock_prices = price_data[stock]
        valid_prices = stock_prices[stock_prices.index >= report_date]
        
        if len(valid_prices) > 0:
            for col in financials.index:
                aligned_data[(stock, col)] = financials[col]
    
    return aligned_data


class DataQualityMonitor:
    """
    持续监控数据质量的工具类,适用于实时或批量数据流
    """
    
    def __init__(self, price_bounds=None, volume_bounds=None):
        self.price_bounds = price_bounds or {}
        self.volume_bounds = volume_bounds or {}
        self.alerts = []
    
    def check_price_continuity(self, prices, max_gap_pct=0.20):
        """
        检查价格连续性:单日变动超过max_gap_pct可能为错误
        """
        returns = prices.pct_change()
        jumps = returns[returns.abs() > max_gap_pct]
        
        if len(jumps) > 0:
            for date, ret in jumps.items():
                self.alerts.append({
                    'type': 'price_jump',
                    'date': date,
                    'value': ret,
                    'severity': 'HIGH' if abs(ret) > 0.50 else 'MEDIUM'
                })
        return jumps
    
    def check_volume_anomaly(self, volumes, zscore_threshold=5):
        """
        成交量异常检测:相对历史均值偏离超过阈值
        """
        vol_mean = volumes.rolling(20).mean()
        vol_std = volumes.rolling(20).std()
        vol_zscore = (volumes - vol_mean) / vol_std
        
        anomalies = vol_zscore[vol_zscore.abs() > zscore_threshold]
        
        for date, z in anomalies.items():
            self.alerts.append({
                'type': 'volume_anomaly',
                'date': date,
                'zscore': z,
                'severity': 'MEDIUM'
            })
        return anomalies
    
    def check_ohlc_logic(self, ohlc_df):
        """
        检查OHLC数据的逻辑一致性
        - Low <= Open, Close, High
        - High >= Open, Close, Low
        - Low <= High
        """
        issues = {}
        
        # Low 不能高于 Open, Close, High
        issues['low_above_open'] = ohlc_df['low'] > ohlc_df['open']
        issues['low_above_close'] = ohlc_df['low'] > ohlc_df['close']
        issues['low_above_high'] = ohlc_df['low'] > ohlc_df['high']
        
        # High 不能低于 Open, Close, Low
        issues['high_below_open'] = ohlc_df['high'] < ohlc_df['open']
        issues['high_below_close'] = ohlc_df['high'] < ohlc_df['close']
        
        total_issues = sum(v.sum() for v in issues.values())
        
        if total_issues > 0:
            for name, mask in issues.items():
                if mask.any():
                    dates = ohlc_df.index[mask]
                    self.alerts.append({
                        'type': 'ohlc_logic',
                        'rule': name,
                        'count': len(dates),
                        'dates': dates[:5].tolist(),  # 只展示前5个
                        'severity': 'HIGH'
                    })
        
        return issues, total_issues
    
    def generate_report(self):
        """生成数据质量报告"""
        df_alerts = pd.DataFrame(self.alerts)
        
        if len(df_alerts) == 0:
            print("✅ 数据质量检查通过,未发现异常")
            return
        
        print("=" * 60)
        print(f"数据质量报告 - {len(df_alerts)} 个警报")
        print("=" * 60)
        
        severity_counts = df_alerts['severity'].value_counts()
        for severity in ['HIGH', 'MEDIUM', 'LOW']:
            if severity in severity_counts:
                print(f"  {severity}: {severity_counts[severity]} 个")
        
        print(f"\n按类型分组:")
        type_counts = df_alerts['type'].value_counts()
        for atype, count in type_counts.items():
            print(f"  {atype}: {count} 个")
        
        # 高严重性警报详情
        high_alerts = df_alerts[df_alerts['severity'] == 'HIGH']
        if len(high_alerts) > 0:
            print(f"\n=== 高严重性警报 ({len(high_alerts)}个) ===")
            for _, alert in high_alerts.head(10).iterrows():
                print(f"  [{alert['severity']}] {alert['type']}: {alert.to_dict()}")
        
        return df_alerts
点击展开可浏览运行结果
📘 本段代码定义了 2 个函数/类:函数 `build_panel_with_publication_dates`(构建避免前视偏差的面板数据集)、类 `DataQualityMonitor`(持续监控数据质量的工具类,适用于实时或批量数据流)。该片段为教学展示(未包含独立运行的输入数据),可在实战练习中结合真实数据调用。

常见误区

误区一:复权数据可以无限信任

事实:复权计算理论上很简单,但实际数据供应商的复权数据常有错误。特别是连续多年的大量分红、送股后,累积的复权因子可能出现严重的精度问题。检查方法:比较前复权价格 × 复权因子 和 不复权价格 的一致性。

误区二:缺失值用均值填充即可

事实:金融数据的缺失机制很重要。是随机缺失(交易暂停)、结构性缺失(退市)、还是数据录入错误?不同的缺失机制需要不同的处理方式,简单均值填充可能在停牌期"发明"出虚假的交易机会。

误区三:数据清洗是一次性的

事实:数据质量问题会随着数据更新不断引入。建立自动化数据质量监控(如DataQualityMonitor类),在每次数据更新后自动检查,是专业量化团队的标准做法。

误区四:财务数据发布即是可知的

事实:A股年报的法定截止日是次年4月30日,但有些公司会提前发布,有些会拖到最后。如果回测中假设所有公司的年报都在12月31日可知(即报告期结束日),这种前视偏差会显著高估基本面策略的表现。必须使用实际发布日期来对齐数据。

实战练习

  1. 数据清洗实战:使用AkShare获取一只A股股票过去五年的日线数据,完成全流程数据清洗。特别检查:(a) 是否存在价格跳跃日,(b) 停牌天数占比,(c) 复权前后价格的一致性。

  2. 异常值检测:对沪深300所有成分股的某一年日收益率,使用箱线图法和Z-score法分别识别异常值,比较两种方法的检测结果差异,讨论哪种方法更适合金融数据。

  3. 前视偏差影响量化:选取一个基本面因子(如PE、PB或ROE),设计两组回测:(a) 使用报告期结束日对齐(存在前视偏差),(b) 使用发布日期对齐(无前视偏差),比较两组结果的差异,量化前视偏差对你的策略表现的影响。

延伸阅读

  • 《Data Wrangling with Python》—— Jacqueline Kazil and Katharine Jarmul
  • 《Python for Finance》—— Yves Hilpisch,第4-6章的金融数据处理
  • 各数据供应商的数据字典:理解每个字段的准确定义和生成逻辑
  • 《Tidy Data》—— Hadley Wickham,整洁数据的概念框架
  • 《Bad Data Handbook》—— Q. Ethan McCallum,数据实际问题的案例集

本章要点

  • 数据清洗占据量化研究时间的50-70%,是回报率最高的投入之一
  • 金融数据的独特挑战:时序依赖、生存偏差、前视偏差、复权和停牌
  • 复权、前视偏差和停牌处理是最容易被忽视但影响最大的三个数据问题
  • 建立自动化的数据质量监控比每次手动检查更高效、更可靠
  • 与脏数据相处是常态,追求的是"足够干净"而非"完全完美"