4. 数据清洗与标准化:去重、乱序处理、时间戳对齐、缺失值填充,以及统一的数据模型定义

数据清洗,说白了就是给行情数据「洗澡」。

你想想看,交易所推送过来的原始数据,那叫一个乱。重复的、乱序的、缺胳膊少腿的,什么都有。我刚开始做实时行情系统那会儿,就吃过这个亏——数据没洗干净就直接喂给策略引擎,结果回测曲线漂亮得不行,实盘一跑就崩。嗯,从那以后,我把数据清洗列为了最高优先级。

4.1 去重:别让同一笔成交算两次

为什么会有重复数据?原因很多。比如网络重传、交易所网关的冗余推送、或者我们自己的采集节点做了主备切换。我个人习惯的做法是:在数据入口处就干掉重复,不要等到后面再处理。

核心思路:用「交易所代码 + 产品代码 + 时间戳 + 序列号」作为唯一键。

来看一段我常用的去重逻辑(伪代码):

// 基于布隆过滤器的去重,适合高吞吐场景
class Deduplicator {
    BloomFilter<String> filter = new BloomFilter<>(1000000, 0.001);
    
    boolean isDuplicate(TickData tick) {
        String key = tick.exchange + ":" + tick.symbol 
                   + ":" + tick.timestamp + ":" + tick.seqNo;
        if (filter.mightContain(key)) {
            return true;  // 大概率重复
        }
        filter.put(key);
        return false;
    }
}

避坑指南:我曾经用HashMap做去重,结果内存爆了。布隆过滤器虽然有小概率误判,但胜在省内存。如果你对精度要求极高,可以用Redis的Set结构,配合过期时间自动清理。

4.2 乱序处理:时间旅行者必须被纠正

行情数据乱序,是分布式系统的常态。比如上海和深圳的行情,经过不同的网络路径到达你的服务器,时间戳晚的反而先到。这很常见。

我处理乱序的思路很简单:给数据一个「等待窗口」

// 乱序处理:等待窗口机制
class OutOfOrderHandler {
    long windowMs = 50;  // 等待50毫秒
    PriorityQueue<TickData> buffer = new PriorityQueue<>(
        (a, b) -> Long.compare(a.timestamp, b.timestamp)
    );
    
    void handle(TickData tick) {
        buffer.offer(tick);
        // 清理窗口外的数据
        while (!buffer.isEmpty() && 
               buffer.peek().timestamp < System.currentTimeMillis() - windowMs) {
            emit(buffer.poll());  // 按时间顺序输出
        }
    }
}

注意:窗口大小不是固定的。我建议根据网络延迟的P99值来动态调整。比如你的网络P99延迟是30ms,那窗口设到50ms就够用了。设太大,实时性会受影响;设太小,乱序数据还是漏过去了。

4.3 时间戳对齐:让不同数据源说同一种「时间语言」

不同交易所的时间戳格式五花八门。有的用Unix毫秒,有的用纳秒,还有的用字符串格式的"2024-01-15 09:30:00.123"。更头疼的是,有些交易所的时钟本身就不准。

我的做法是:统一转为纳秒级时间戳,并做时钟同步校正

数据源 原始格式 标准化后
上海交易所 毫秒时间戳 纳秒时间戳
深圳交易所 字符串格式 纳秒时间戳
期货CTP 微秒时间戳 纳秒时间戳
// 时间戳对齐工具
class TimestampNormalizer {
    long normalize(String rawTimestamp, String source) {
        switch (source) {
            case "SH":
                return Long.parseLong(rawTimestamp) * 1_000_000L;
            case "SZ":
                return parseStringTimestamp(rawTimestamp);  // 字符串转纳秒
            case "CTP":
                return Long.parseLong(rawTimestamp) * 1_000L;
            default:
                throw new IllegalArgumentException("未知数据源");
        }
    }
}

一个小技巧:我习惯在标准化后的时间戳里保留原始时间戳的副本。万一后面排查问题,还能追溯回去。这个习惯救过我不少次。

4.4 缺失值填充:别让空数据搞崩你的策略

行情数据缺失,最常见的是某只股票突然停牌,或者网络闪断导致几笔数据没收到。直接跳过?不行。策略引擎需要连续的数据流。

我常用的填充策略有三种:

  • 前值填充:用上一笔数据填充。适合价格变化不大的场景。
  • 线性插值:根据前后数据计算中间值。适合成交量等连续指标。
  • 标记填充:填充一个特殊值(如-1),让下游自己决定怎么处理。
// 缺失值填充器
class MissingValueFiller {
    TickData lastValid = null;
    
    TickData fill(TickData current) {
        if (current.price == null) {
            if (lastValid != null) {
                current.price = lastValid.price;  // 前值填充
                current.volume = 0;               // 成交量填0
            } else {
                current.price = 0.0;  // 没有历史数据,填0
            }
        }
        lastValid = current;
        return current;
    }
}

我曾经踩过的坑:用前值填充时,如果连续缺失超过5笔,价格可能已经严重偏离真实值。建议设置一个「最大连续填充次数」,超过这个次数就标记为异常数据,而不是继续填充。

4.5 统一数据模型:给所有数据一个「家」

数据清洗的最终目的,是让所有数据都遵循同一个数据模型。我习惯用Protocol Buffers来定义,因为它序列化快、跨语言兼容好。

// 统一行情数据模型(Protobuf定义)
message UnifiedTick {
    string exchange = 1;      // 交易所代码
    string symbol = 2;        // 产品代码
    int64 timestamp_ns = 3;   // 标准化后的纳秒时间戳
    double price = 4;         // 最新成交价
    int64 volume = 5;         // 成交量
    double bid_price = 6;     // 买一价
    double ask_price = 7;     // 卖一价
    int32 bid_volume = 8;     // 买一量
    int32 ask_volume = 9;     // 卖一量
    int64 seq_no = 10;        // 交易所序列号
    int32 quality = 11;       // 数据质量标记:0=正常,1=填充,2=异常
}

为什么这么设计?quality字段是我后来加的。有了它,下游策略可以知道哪些数据是「原装」的,哪些是「加工」过的。这个设计思路,我建议你也用上。

4.6 整体流程:一张图看懂

下面这张图,是我做数据清洗的标准流程。每个环节都经过实战检验。

数据清洗与标准化流程 原始行情数据 去重 乱序处理 时间戳对齐 缺失值填充 各环节说明 • 去重:布隆过滤器 + 唯一键 • 乱序:等待窗口 + 优先级队列 • 对齐:统一纳秒时间戳 • 填充:前值/线性/标记 • 输出:统一数据模型 关键指标: • 去重率 > 99.99% • 乱序纠正延迟 < 100ms • 时间戳精度 ±1μs • 缺失填充率 < 0.1% 输出格式: UnifiedTick (Protobuf) 包含 quality 标记字段

数据清洗这件事,看起来琐碎,但它是整个实时行情系统的基石。我见过太多团队在算法和策略上花大功夫,结果数据源没洗干净,一切白搭。记住一句话:垃圾进,垃圾出。把数据清洗做好,后面的工作才能事半功倍。

最后分享一个经验:数据清洗的代码,一定要写单元测试。我每次改清洗逻辑,都会用历史数据跑一遍回归测试。别嫌麻烦,这个习惯能帮你省下无数排查问题的时间。


公众号:蓝海数据掘金营,微信deep3321