第二章:库存数据模型

做市商的库存管理,说白了就是跟数据打交道。你想想看,每秒成百上千笔订单进来,每笔都影响你的库存状态。没有一套靠谱的数据模型,你根本不知道自己在裸泳。

我个人习惯把库存数据模型拆成三块来设计:静态结构存储方案实时流处理。这三块缺一不可,就像三条腿的凳子,少一条就坐不稳。

2.1 库存数据结构设计

先聊聊数据结构。我在项目中遇到过最坑的事,就是一开始把库存数据设计得太简单——就一个「当前持仓数量」字段。结果呢?盘中对账永远对不平,因为不知道哪些是已成交未结算的,哪些是挂单冻结的。

所以,一个合格的库存数据结构,至少得包含这几个维度:

  • 资产标识:交易对、币种、合约代码
  • 数量分层:可用数量、冻结数量、在途数量、总持仓
  • 成本信息:平均持仓成本、已实现盈亏、未实现盈亏
  • 时间戳:最后更新时间、持仓开始时间
  • 仓位属性:做市方向(多头/空头)、策略ID、风控组ID

核心公式:

总持仓 = 可用数量 + 冻结数量 + 在途数量

净敞口 = 多头总持仓 - 空头总持仓

嗯,这里要注意「在途数量」这个概念。它指的是已经成交但还没结算到账的部分。比如你在币安做市,成交了一笔买单,但资金还在结算中,这时候它既不是可用也不是冻结,得单独拎出来。

我建议用这样的结构体来表示一个库存快照:

{
  "asset": "BTC/USDT",
  "timestamp": 1704067200000,
  "position": {
    "long": {
      "total": 12.5,
      "available": 8.2,
      "frozen": 3.0,
      "in_transit": 1.3,
      "avg_cost": 42500.00
    },
    "short": {
      "total": 5.0,
      "available": 5.0,
      "frozen": 0.0,
      "in_transit": 0.0,
      "avg_cost": 42600.00
    }
  },
  "pnl": {
    "realized": 1250.00,
    "unrealized": -320.00
  },
  "strategy_id": "mm_btc_001",
  "risk_group": "rg_btc_low"
}

为什么要把多头和空头分开?因为做市商经常同时持有多空仓位,比如在永续合约上做基差交易。你想想看,如果只记一个净持仓,你根本不知道自己的做市策略到底在哪个方向暴露了风险。

2.2 数据库表设计

数据库表设计这块,我踩过的坑比赚的钱还多。一开始我用的是单表存储,每次查询都全表扫描,延迟高得吓人。后来改成分库分表 + 时序存储的方案,才算真正解决问题。

我一般设计三张核心表:

表1:库存快照表(snapshot)

这张表存的是每个时间点的完整库存状态。说白了就是上面那个JSON结构拍平了存进去。

CREATE TABLE inventory_snapshot (
  id BIGINT AUTO_INCREMENT PRIMARY KEY,
  asset VARCHAR(20) NOT NULL,
  strategy_id VARCHAR(50) NOT NULL,
  snapshot_time DATETIME(3) NOT NULL,
  
  -- 多头数据
  long_total DECIMAL(20,8) DEFAULT 0,
  long_available DECIMAL(20,8) DEFAULT 0,
  long_frozen DECIMAL(20,8) DEFAULT 0,
  long_in_transit DECIMAL(20,8) DEFAULT 0,
  long_avg_cost DECIMAL(20,4) DEFAULT 0,
  
  -- 空头数据
  short_total DECIMAL(20,8) DEFAULT 0,
  short_available DECIMAL(20,8) DEFAULT 0,
  short_frozen DECIMAL(20,8) DEFAULT 0,
  short_in_transit DECIMAL(20,8) DEFAULT 0,
  short_avg_cost DECIMAL(20,4) DEFAULT 0,
  
  -- 盈亏数据
  realized_pnl DECIMAL(20,4) DEFAULT 0,
  unrealized_pnl DECIMAL(20,4) DEFAULT 0,
  
  -- 风控数据
  risk_group VARCHAR(20),
  net_exposure DECIMAL(20,8) GENERATED ALWAYS AS (long_total - short_total) STORED,
  
  INDEX idx_asset_time (asset, snapshot_time),
  INDEX idx_strategy_time (strategy_id, snapshot_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

我的经验: snapshot_time 一定要用 DATETIME(3),精确到毫秒。我曾经用秒级精度,结果同一秒内出现多条记录,对账时根本分不清先后顺序。

表2:库存变更流水表(ledger)

这张表记录每一次库存变动。为什么要有这张表?因为光有快照你没法回溯——比如突然发现库存对不上,你得知道是哪笔交易导致的。

CREATE TABLE inventory_ledger (
  id BIGINT AUTO_INCREMENT PRIMARY KEY,
  asset VARCHAR(20) NOT NULL,
  strategy_id VARCHAR(50) NOT NULL,
  event_time DATETIME(3) NOT NULL,
  event_type ENUM('trade', 'fee', 'transfer', 'settlement', 'adjustment') NOT NULL,
  
  -- 变更详情
  change_type ENUM('increase', 'decrease') NOT NULL,
  change_amount DECIMAL(20,8) NOT NULL,
  before_balance DECIMAL(20,8) NOT NULL,
  after_balance DECIMAL(20,8) NOT NULL,
  
  -- 关联信息
  trade_id VARCHAR(64),
  order_id VARCHAR(64),
  operator VARCHAR(32) DEFAULT 'system',
  remark TEXT,
  
  INDEX idx_asset_event (asset, event_time),
  INDEX idx_trade_id (trade_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

避坑指南: 我曾经在流水表里只记了变动金额,没记变动前后的余额。结果有一次数据出问题,想回滚都找不到基准点。记住:流水表一定要记录 before 和 after,这是审计的命根子。

表3:库存聚合视图(materialized view)

实时查询的时候,你不可能每次都去扫快照表。我建议建一个物化视图,按分钟或秒聚合,专门给风控和交易系统用。

-- 伪代码:每5秒刷新一次
CREATE MATERIALIZED VIEW inventory_agg AS
SELECT 
  asset,
  strategy_id,
  window_start,
  window_end,
  SUM(long_total) as total_long,
  SUM(short_total) as total_short,
  AVG(long_avg_cost) as avg_long_cost,
  AVG(short_avg_cost) as avg_short_cost,
  SUM(realized_pnl) as total_realized_pnl
FROM inventory_snapshot
GROUP BY asset, strategy_id, 
  TUMBLE(snapshot_time, INTERVAL '5' SECOND);

2.3 实时数据流处理

数据库存的是历史,但做市商要的是实时。你想想看,如果库存数据延迟3秒,你的报价可能已经亏了10个bps了。

我目前在用的方案是三层流处理架构

  1. 数据接入层:用Kafka接收交易所的成交、订单、资金流水
  2. 流计算层:用Flink做实时聚合,维护内存中的库存状态机
  3. 数据输出层:同时写入数据库(持久化)和Redis(低延迟查询)

这里我画了一张架构图,你看一眼就明白了:

库存实时数据流处理架构 数据源 交易所成交/订单/资金流水 消息队列 Kafka / Pulsar 流计算引擎 Flink / Spark Streaming 内存库存状态机 持久化存储 MySQL / ClickHouse 缓存层 Redis / Aerospike 实时推送 WebSocket / gRPC 延迟目标:端到端 < 50ms | 吞吐量:10万+ TPS

这张图的核心逻辑是:数据从交易所进来,经过消息队列缓冲,流计算引擎维护一个内存中的库存状态机,然后同时写入数据库和缓存。为什么搞这么复杂?因为数据库写入慢,但需要持久化;Redis查询快,但可能丢数据。两者互补,各取所长。

在Flink里,我维护库存状态机的核心逻辑是这样的:

// 伪代码:Flink 库存状态机
public class InventoryStateMachine {
  
  // 状态:当前库存快照
  private ValueState<InventorySnapshot> currentState;
  
  public void processEvent(InventoryEvent event) {
    InventorySnapshot snapshot = currentState.value();
    
    switch (event.getType()) {
      case TRADE_BUY:
        // 增加多头持仓,减少可用资金
        snapshot.longTotal += event.amount;
        snapshot.longAvailable += event.amount;
        snapshot.longAvgCost = recalcAvgCost(snapshot, event);
        break;
        
      case TRADE_SELL:
        // 减少多头持仓
        snapshot.longAvailable -= event.amount;
        snapshot.longTotal -= event.amount;
        break;
        
      case ORDER_FROZEN:
        // 挂单冻结
        snapshot.longAvailable -= event.amount;
        snapshot.longFrozen += event.amount;
        break;
        
      case ORDER_CANCELED:
        // 撤单解冻
        snapshot.longFrozen -= event.amount;
        snapshot.longAvailable += event.amount;
        break;
    }
    
    // 更新状态
    currentState.update(snapshot);
    
    // 输出到下游
    output.collect(snapshot);
  }
}

我的经验: 状态机里一定要处理「乱序事件」。比如撤单事件比成交事件先到,你怎么办?我一般用事件时间(event time)而不是处理时间(processing time),配合watermark机制来处理乱序。这个坑我踩了整整两周才爬出来。

最后说一句,实时流处理最怕的就是数据倾斜。比如某个热门币种的交易量是其他币种的100倍,如果所有数据都往同一个分区跑,你的Flink任务迟早要挂。我建议按交易对 + 策略ID做keyBy,把数据打散到不同的并行度上。

避坑指南: 我曾经在生产环境遇到过Kafka消费积压,原因是库存状态机里有个死循环——处理一笔成交事件时,又触发了另一笔事件的生成,导致无限递归。后来我加了一个「事件去重」的逻辑,每个事件只处理一次,才彻底解决。记住:状态机里不要产生新的事件,只做状态转换。

好了,库存数据模型这块就聊这么多。数据模型是地基,地基不稳,上面的策略再牛也白搭。下一节我们聊聊库存风险指标的计算,那才是真正考验做市商水平的地方。


专注资料整理