统一数据模型设计:标准化的订单、行情与账户结构
做过多交易所对接的人都知道,最头疼的事不是写代码,而是处理字段差异。我刚开始做这个的时候,被坑过好几次——同一个字段,在币安叫「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 一定要定义为「真正可以下单的余额」,而不是交易所返回的原始值。适配器里要做转换。
统一数据模型的核心逻辑
说了这么多,我们来画张图,看看整体架构是什么样的。
这张图其实就说明了一切。策略层只跟统一模型打交道,适配器负责「翻译」。你想想看,如果哪天要接入一个新交易所,你只需要写一个适配器,把交易所的字段映射到统一模型上。策略代码一行都不用改。
适配器实现示例
光说不练假把式。我们来看看适配器怎么写。以币安的订单结构为例:
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 兜底,避免程序崩溃