统一数据模型设计:标准化的订单、行情与账户结构

做过多交易所对接的人都知道,最头疼的事不是写代码,而是处理字段差异。我刚开始做这个的时候,被坑过好几次——同一个字段,在币安叫「symbol」,在OKX叫「instId」,在火币又叫「contract_code」。你想想看,如果每个交易所都写一套独立的数据结构,那代码维护起来得多痛苦?

所以,统一数据模型就派上用场了。说白了,就是定义一套「中间语言」,让所有交易所的数据都翻译成这套语言。这样,你的策略层、风控层、订单管理模块,都只跟这套模型打交道。

为什么需要统一数据模型?

我见过不少团队,一开始图省事,直接拿交易所的原始JSON来用。结果呢?三个月后,代码里到处都是 if-else 判断,每个新交易所接入都要改一堆逻辑。嗯,这其实是个坑。

统一数据模型的好处很明显:

  • 解耦:策略逻辑与交易所实现完全分离
  • 可扩展:新增交易所只需写一个适配器,核心代码不用动
  • 可测试:可以用模拟数据测试策略,不用真的下单
  • 可维护:字段含义清晰,新人上手快

核心原则:统一模型只保留「最小公共集合」,交易所特有的字段放在扩展字段里。别想着把所有字段都塞进去,那反而失去了统一的意义。

订单结构设计

订单结构是交易系统的核心。我设计过三版才稳定下来,第一版太复杂,第二版又漏了关键字段。最终版本长这样:

@dataclass
class Order:
    # 核心标识
    order_id: str           # 交易所返回的订单ID
    client_order_id: str    # 客户端自定义ID,用于幂等
    symbol: str             # 统一交易对,如 "BTC-USDT"
    exchange: str           # 交易所名称,如 "binance"
    
    # 订单参数
    side: OrderSide         # BUY / SELL
    order_type: OrderType   # LIMIT / MARKET / STOP_LIMIT
    price: Decimal          # 价格,市价单可为None
    quantity: Decimal       # 数量
    quote_quantity: Decimal # 市价单的报价金额(可选)
    
    # 状态信息
    status: OrderStatus     # NEW / PARTIALLY_FILLED / FILLED / CANCELED / REJECTED
    filled_quantity: Decimal
    filled_amount: Decimal
    avg_price: Decimal
    fee: Decimal
    fee_asset: str
    
    # 时间戳
    created_at: int         # 毫秒时间戳
    updated_at: int
    
    # 扩展字段
    raw_data: dict          # 原始交易所数据,用于调试

这里有个细节:client_order_id 是我特别强调的。为什么?因为网络波动时,你发了一个订单,没收到响应,重发就可能重复下单。有了客户端ID,交易所可以帮你做幂等判断。我在对接某二线交易所时,就因为没这个字段,重复下了好几笔单,亏了不少手续费。

个人经验Decimal 类型一定要用,别用 float。浮点数精度问题在金融场景下会要命。我曾经因为 float 精度问题,导致一笔订单价格差了0.0001,结果被交易所拒单了。

行情结构设计

行情数据的特点是量大、更新快。所以设计时要兼顾完整性和性能。我个人习惯把行情分成两类:快照型和增量型。

@dataclass
class Ticker:
    symbol: str
    exchange: str
    bid: Decimal
    ask: Decimal
    last: Decimal
    volume_24h: Decimal
    high_24h: Decimal
    low_24h: Decimal
    timestamp: int

@dataclass
class OrderBook:
    symbol: str
    exchange: str
    bids: List[PriceLevel]   # [(price, quantity), ...]
    asks: List[PriceLevel]
    timestamp: int
    is_snapshot: bool        # True=全量, False=增量

@dataclass
class Trade:
    trade_id: str
    symbol: str
    exchange: str
    price: Decimal
    quantity: Decimal
    side: OrderSide
    timestamp: int

这里要注意的是 OrderBook 的 is_snapshot 字段。为什么要有它?因为 WebSocket 推送的通常是增量数据,你需要先拿到全量快照,再叠加增量。如果不区分,很容易把数据搞乱。我见过有人直接把增量当全量用,结果盘口数据全是错的。

账户结构设计

账户结构相对简单,但坑也不少。不同交易所对「可用余额」和「冻结余额」的定义不太一样。有的把挂单冻结也算在可用里,有的不算。

@dataclass
class AccountBalance:
    exchange: str
    assets: Dict[str, AssetBalance]
    timestamp: int

@dataclass
class AssetBalance:
    asset: str              # "BTC", "USDT"
    free: Decimal           # 可用余额
    locked: Decimal         # 冻结余额(挂单占用)
    total: Decimal          # total = free + locked
    btc_value: Decimal      # 折算成BTC的价值(可选)

我建议加上 btc_value 字段。为什么?因为做风控时,你需要知道总资产价值。如果每个币种单独看,很难判断整体风险。当然,这个值需要定期更新,不能太老。

避坑指南:我曾经遇到一个交易所,它的「可用余额」包含了未成交的挂单冻结。也就是说,你挂了100个USDT的买单,可用余额里还显示这100USDT。这跟主流交易所的规则完全不同。所以,统一模型里的 free 一定要定义为「真正可以下单的余额」,而不是交易所返回的原始值。适配器里要做转换。

统一数据模型的核心逻辑

说了这么多,我们来画张图,看看整体架构是什么样的。

统一数据模型架构图 策略层 / 风控层 / 订单管理 统一数据模型 Order | Ticker | OrderBook | AccountBalance 交易所适配器(Adapter) 币安 API OKX API 火币 API 数据流向:策略层 → 统一模型 → 适配器 → 交易所API

这张图其实就说明了一切。策略层只跟统一模型打交道,适配器负责「翻译」。你想想看,如果哪天要接入一个新交易所,你只需要写一个适配器,把交易所的字段映射到统一模型上。策略代码一行都不用改。

适配器实现示例

光说不练假把式。我们来看看适配器怎么写。以币安的订单结构为例:

class BinanceOrderAdapter:
    """币安订单适配器"""
    
    @staticmethod
    def to_unified(raw: dict) -> Order:
        """将币安原始订单转换为统一模型"""
        return Order(
            order_id=raw['orderId'],
            client_order_id=raw.get('clientOrderId', ''),
            symbol=raw['symbol'].replace('USDT', '-USDT'),
            exchange='binance',
            side=OrderSide.BUY if raw['side'] == 'BUY' else OrderSide.SELL,
            order_type=BinanceOrderAdapter._parse_type(raw['type']),
            price=Decimal(str(raw['price'])),
            quantity=Decimal(str(raw['origQty'])),
            quote_quantity=Decimal(str(raw.get('cummulativeQuoteQty', '0'))),
            status=BinanceOrderAdapter._parse_status(raw['status']),
            filled_quantity=Decimal(str(raw['executedQty'])),
            filled_amount=Decimal(str(raw['cummulativeQuoteQty'])),
            avg_price=Decimal(str(raw.get('avgPrice', '0'))),
            fee=Decimal(str(raw.get('commission', '0'))),
            fee_asset=raw.get('commissionAsset', ''),
            created_at=raw['time'],
            updated_at=raw['updateTime'],
            raw_data=raw
        )
    
    @staticmethod
    def _parse_type(raw_type: str) -> OrderType:
        mapping = {
            'LIMIT': OrderType.LIMIT,
            'MARKET': OrderType.MARKET,
            'STOP_LOSS_LIMIT': OrderType.STOP_LIMIT,
        }
        return mapping.get(raw_type, OrderType.UNKNOWN)
    
    @staticmethod
    def _parse_status(raw_status: str) -> OrderStatus:
        mapping = {
            'NEW': OrderStatus.NEW,
            'PARTIALLY_FILLED': OrderStatus.PARTIALLY_FILLED,
            'FILLED': OrderStatus.FILLED,
            'CANCELED': OrderStatus.CANCELED,
            'REJECTED': OrderStatus.REJECTED,
        }
        return mapping.get(raw_status, OrderStatus.UNKNOWN)

你看,核心逻辑就是做字段映射。这里有个小技巧:Decimal(str(...)) 这种写法。为什么不用 Decimal(raw['price'])?因为有些交易所返回的是字符串,有些是浮点数。用 str() 转一下,可以保证 Decimal 的精度不受影响。

个人习惯:我会在适配器里加一个 validate() 方法,检查必填字段是否存在。比如 orderId 如果为空,直接抛异常,而不是等到后面用的时候才发现。早发现早处理,这是我在项目里学到的教训。

枚举类型定义

统一模型里用到的枚举,我建议单独定义,方便复用:

from enum import Enum

class OrderSide(Enum):
    BUY = "BUY"
    SELL = "SELL"

class OrderType(Enum):
    LIMIT = "LIMIT"
    MARKET = "MARKET"
    STOP_LIMIT = "STOP_LIMIT"
    STOP_MARKET = "STOP_MARKET"
    UNKNOWN = "UNKNOWN"

class OrderStatus(Enum):
    NEW = "NEW"
    PARTIALLY_FILLED = "PARTIALLY_FILLED"
    FILLED = "FILLED"
    CANCELED = "CANCELED"
    REJECTED = "REJECTED"
    EXPIRED = "EXPIRED"
    UNKNOWN = "UNKNOWN"

这里我特意加了 UNKNOWN 状态。为什么?因为总会有一些交易所的状态是你没见过的。与其让程序崩溃,不如优雅地标记为未知,然后记录日志。我遇到过某交易所返回了一个「PENDING_CANCEL」状态,主流交易所都没有这个,用 UNKNOWN 兜底就对了。

总结一下

统一数据模型的设计,说白了就是「约定大于配置」。你定好一套标准,所有模块都按这个标准来。刚开始可能觉得多写了一些代码,但长期来看,维护成本会大幅降低。

我个人建议,在项目初期就把统一模型定义好,哪怕只支持两三个交易所。因为后面加交易所的时候,你会发现这个决策有多明智。嗯,这就是我踩过坑之后得出的结论。

核心要点回顾

  • 订单结构:包含核心标识、参数、状态、时间戳、扩展字段
  • 行情结构:区分快照和增量,注意数据一致性
  • 账户结构:明确「可用余额」的定义,避免歧义
  • 适配器:负责字段映射,做数据校验
  • 枚举类型:用 UNKNOWN 兜底,避免程序崩溃

专注资料整理