第二章:库存数据模型
做市商的库存管理,说白了就是跟数据打交道。你想想看,每秒成百上千笔订单进来,每笔都影响你的库存状态。没有一套靠谱的数据模型,你根本不知道自己在裸泳。
我个人习惯把库存数据模型拆成三块来设计:静态结构、存储方案、实时流处理。这三块缺一不可,就像三条腿的凳子,少一条就坐不稳。
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了。
我目前在用的方案是三层流处理架构:
- 数据接入层:用Kafka接收交易所的成交、订单、资金流水
- 流计算层:用Flink做实时聚合,维护内存中的库存状态机
- 数据输出层:同时写入数据库(持久化)和Redis(低延迟查询)
这里我画了一张架构图,你看一眼就明白了:
这张图的核心逻辑是:数据从交易所进来,经过消息队列缓冲,流计算引擎维护一个内存中的库存状态机,然后同时写入数据库和缓存。为什么搞这么复杂?因为数据库写入慢,但需要持久化;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消费积压,原因是库存状态机里有个死循环——处理一笔成交事件时,又触发了另一笔事件的生成,导致无限递归。后来我加了一个「事件去重」的逻辑,每个事件只处理一次,才彻底解决。记住:状态机里不要产生新的事件,只做状态转换。
好了,库存数据模型这块就聊这么多。数据模型是地基,地基不稳,上面的策略再牛也白搭。下一节我们聊聊库存风险指标的计算,那才是真正考验做市商水平的地方。