2. 核心数据模型设计:订单簿、持仓、交易对与告警规则
做市系统跑起来,第一件事就是把数据模型定好。我见过不少团队,代码写了大半才发现数据结构设计不合理,回头重构,那叫一个痛苦。今天咱们就把订单簿、持仓、交易对、告警规则这四个核心模型聊透。
2.1 订单簿(OrderBook)数据结构
订单簿说白了就是买卖双方的挂单列表。做市商靠它判断市场深度,决定要不要吃单或者挂单。
核心字段设计:
| 字段名 | 类型 | 说明 |
|---|---|---|
| symbol | str | 交易对,如 "BTCUSDT" |
| bids | list[tuple] | 买单列表,每个元素 (price, quantity) |
| asks | list[tuple] | 卖单列表,每个元素 (price, quantity) |
| timestamp | int | 快照时间戳(毫秒) |
| update_id | int | 更新序号,用于增量合并 |
我个人习惯用 SortedDict 来维护订单簿。为什么?因为买卖盘需要按价格排序,而且插入删除频繁。Python 的 dict 虽然快,但排序麻烦。用 SortedDict 可以 O(log n) 完成插入和查询。
避坑指南: 我曾经在某个项目中直接用 list 存订单簿,每次增量更新都全量排序。结果数据量一大,CPU 直接飙到 100%。后来换成 SortedDict,性能提升了 10 倍不止。
from sortedcontainers import SortedDict
class OrderBook:
def __init__(self, symbol: str):
self.symbol = symbol
self.bids = SortedDict(lambda k: -k) # 价格降序
self.asks = SortedDict() # 价格升序
self.timestamp = 0
self.update_id = 0
def apply_snapshot(self, bids: list, asks: list, ts: int):
"""全量更新订单簿"""
self.bids.clear()
self.asks.clear()
for price, qty in bids:
if float(qty) > 0:
self.bids[float(price)] = float(qty)
for price, qty in asks:
if float(qty) > 0:
self.asks[float(price)] = float(qty)
self.timestamp = ts
def apply_update(self, bids: list, asks: list, update_id: int):
"""增量更新订单簿"""
if update_id <= self.update_id:
return # 丢弃过期更新
for price, qty in bids:
price_f = float(price)
qty_f = float(qty)
if qty_f == 0:
self.bids.pop(price_f, None)
else:
self.bids[price_f] = qty_f
for price, qty in asks:
price_f = float(price)
qty_f = float(qty)
if qty_f == 0:
self.asks.pop(price_f, None)
else:
self.asks[price_f] = qty_f
self.update_id = update_id
你想想看,如果增量更新时不做 update_id 校验,网络延迟导致旧数据覆盖新数据,那订单簿就乱套了。嗯,这里要注意。
2.2 持仓(Position)与风险敞口模型
持仓模型记录你当前持有哪些资产、数量多少、成本价多少。风险敞口则是告诉你,如果市场反向波动,你会亏多少钱。
持仓模型核心字段:
| 字段名 | 类型 | 说明 |
|---|---|---|
| asset | str | 资产名称,如 "BTC" |
| total_qty | float | 总持仓数量 |
| locked_qty | float | 冻结数量(挂单占用) |
| avg_cost | float | 平均成本价 |
| unrealized_pnl | float | 未实现盈亏 |
风险敞口模型:
我个人习惯把风险敞口拆成两部分:
- 方向性敞口: 净持仓数量 × 当前价格。做市商一般希望这个值接近 0。
- 基差敞口: 现货与期货之间的价差风险。这个容易被忽略。
小技巧: 我建议在风险敞口模型里加一个 max_exposure 字段,用来限制单边最大敞口。比如你设定 BTC 敞口不能超过 0.5 个,系统会自动拒绝超过阈值的挂单。
@dataclass
class Position:
asset: str
total_qty: float = 0.0
locked_qty: float = 0.0
avg_cost: float = 0.0
@property
def available_qty(self) -> float:
"""可用数量 = 总持仓 - 冻结数量"""
return self.total_qty - self.locked_qty
@property
def unrealized_pnl(self, current_price: float) -> float:
"""未实现盈亏 = (当前价 - 成本价) × 持仓量"""
return (current_price - self.avg_cost) * self.total_qty
@dataclass
class RiskExposure:
symbol: str
net_position: float # 净持仓(多头 - 空头)
current_price: float
max_exposure: float # 最大允许敞口
var_95: float = 0.0 # 95% VaR 值
@property
def directional_exposure(self) -> float:
"""方向性敞口 = |净持仓| × 当前价"""
return abs(self.net_position) * self.current_price
def is_safe(self) -> bool:
"""检查是否在安全范围内"""
return self.directional_exposure <= self.max_exposure
2.3 交易对(TradingPair)配置模型
交易对配置是系统的「交通规则」。每个交易对都有不同的最小交易量、价格精度、费率等。我建议把这些配置集中管理,不要硬编码。
| 字段名 | 类型 | 说明 |
|---|---|---|
| symbol | str | 交易对名称 |
| base_asset | str | 基础资产,如 "BTC" |
| quote_asset | str | 计价资产,如 "USDT" |
| min_qty | float | 最小交易数量 |
| price_precision | int | 价格小数位数 |
| qty_precision | int | 数量小数位数 |
| taker_fee | float | 吃单费率 |
| maker_fee | float | 挂单费率 |
| spread_bps | float | 目标价差(基点) |
注意: 价格精度和数量精度搞错了,会导致下单被交易所拒绝。我曾经因为 price_precision 少了一位小数,挂单一直失败,排查了半天才发现是精度问题。
@dataclass
class TradingPairConfig:
symbol: str
base_asset: str
quote_asset: str
min_qty: float
price_precision: int
qty_precision: int
taker_fee: float
maker_fee: float
spread_bps: float
def round_price(self, price: float) -> float:
"""按精度舍入价格"""
return round(price, self.price_precision)
def round_qty(self, qty: float) -> float:
"""按精度舍入数量"""
return round(qty, self.qty_precision)
def calculate_spread(self, mid_price: float) -> tuple:
"""计算买卖价差"""
spread = mid_price * self.spread_bps / 10000
bid = self.round_price(mid_price - spread / 2)
ask = self.round_price(mid_price + spread / 2)
return bid, ask
2.4 告警规则(AlertRule)模型设计
告警规则是做市系统的「哨兵」。市场异常、持仓超限、网络断开,都需要及时告警。我习惯把告警规则设计成可配置的,这样运营人员可以随时调整,不用改代码。
告警规则核心字段:
| 字段名 | 类型 | 说明 |
|---|---|---|
| rule_id | str | 规则唯一标识 |
| rule_type | str | 规则类型:price_spike / position_limit / network_down / pnl_loss |
| symbol | str | 关联交易对(可选) |
| condition | dict | 触发条件,如 {"threshold": 0.05, "operator": "gt"} |
| severity | str | 告警级别:info / warning / critical |
| enabled | bool | 是否启用 |
| cooldown_seconds | int | 冷却时间,防止重复告警 |
避坑指南: 我曾经没加 cooldown_seconds,结果网络抖动时,告警短信一分钟发了 200 条,运营同事差点把我拉黑。后来我强制每个规则至少 60 秒冷却时间。
@dataclass
class AlertRule:
rule_id: str
rule_type: str
symbol: Optional[str] = None
condition: dict = field(default_factory=dict)
severity: str = "warning"
enabled: bool = True
cooldown_seconds: int = 60
last_triggered: float = 0.0 # 上次触发时间戳
def should_trigger(self, current_value: float) -> bool:
"""判断是否应该触发告警"""
if not self.enabled:
return False
# 检查冷却时间
if time.time() - self.last_triggered < self.cooldown_seconds:
return False
threshold = self.condition.get("threshold", 0)
operator = self.condition.get("operator", "gt")
if operator == "gt":
return current_value > threshold
elif operator == "lt":
return current_value < threshold
elif operator == "abs_gt":
return abs(current_value) > threshold
return False
def trigger(self):
"""触发告警,更新上次触发时间"""
self.last_triggered = time.time()
# 这里可以调用发送邮件、短信、钉钉等通知
2.5 数据模型关系图
这四个模型不是孤立的。订单簿提供市场数据,持仓模型记录你的状态,交易对配置决定交易参数,告警规则监控一切异常。我画了一张图,帮你理清它们之间的关系。
你看这张图,订单簿和持仓模型是数据源,交易对配置是规则引擎,告警规则是最后的防线。四者配合,才能构建一个健壮的做市系统。
个人建议: 刚开始做的时候,别想着把所有字段都设计完美。先跑通核心流程,再根据实际需求迭代。我见过太多人花两周设计模型,结果上线后发现一半字段用不上。
好了,数据模型就聊到这儿。记住一句话:好的数据模型,能让你的系统跑得又快又稳;差的数据模型,会让你天天加班修 Bug。希望今天的分享对你有帮助。
公众号:蓝海数据掘金营,微信deep3321