设计提案

我会如何设计一套自研 B-Book 交易平台的架构

作者:曾祈荣 · · 阅读约 10 分钟

提出这个需求的,通常是不想再租用别人系统的创始人或券商经营者。他们为 MetaTrader 5 或某个白标方案付费,却改不了它的行为,于是想拥有自己的执行核心:也就是给客户交易报价、成交、算保证金和平仓的那部分代码。有时候,提问的是他们刚招来的工程师。

这篇文章讲我会怎样设计这个核心,以及按什么顺序去构建。目的是让你读完之后知道难点在哪里,这样在为一份方案付钱之前,你自己就能评判它。

B-Book 对软件意味着什么

在 B-Book 模式下,券商是每一笔客户交易的对手方,并在内部成交。出了问题,没有交易所可以怪。客户看到的价格、成交、占用的保证金、账本分录,全都出自你的代码,其中任何一处出错,损失的都是你或客户的真金白银。

公开的教程大多讲的是交易所撮合引擎:一个订单簿,订单之间按价格优先、时间优先的规则相互撮合。B-Book 核心是另一个问题。成交是报价驱动的,对着的是你从报价提供方拿到并加点之后的价格,而真正费力的工作在每个账户上:保证金、敞口和强平。

系统组件

Price feedsdata or liquidity providersQuote servicenormalise · mark up · stalenessClient appsWebTrader · client portalOrder gatewayauth · validationidempotency keysExecution coreone writer per accountorder state machineBack officegroups · spreads · leverageJournal and ledgerappend-only · PostgreSQLRiskmargin · stop-out · exposurequotesevery transitionreadsstreaming
价格从行情源适配器出发,经过报价服务进入引擎;引擎负责订单、持仓、账本和风控,外围是客户端 API、实时推送和后台。
  • 行情源适配器:每个报价提供方一个,各自把供应商的协议转换成同一种内部 tick。
  • 报价服务:把 tick 统一成标准格式,按各个组别的设置加点,并在某个交易品种的行情源没有动静时把它标记为过期。
  • 订单网关:身份认证、参数校验和幂等键,都在请求到达核心之前完成。
  • 执行核心:决定一笔订单是否成交,以及按哪一条报价成交。
  • 持仓与账本:未平仓的持仓,加上一份只追加的记录,记下每一笔成交、隔夜利息、手续费和余额变动。
  • 风控:保证金、强平,以及券商自身在每个品种上的净敞口。
  • 客户端 API 与实时推送:WebTrader 和客户门户调用和订阅的就是它们。
  • 后台:账户、组别、出入金、报表,以及带审计记录的人工更正。

八个方框不等于八个服务。第一天,我只会把故障方式不同、或者发布节奏不同的部分拆开。

报价部分(适配器和报价服务)与引擎分开运行,因为供应商的连接会断,一个出问题的适配器不能把交易拖垮。客户端 API、实时推送和订单网关组成面向互联网的一层,随在线用户数扩容。后台每周都在改,绝不应该和引擎一起部署。

执行核心、持仓、账本写入和风控留在同一个部署单元里。它们共用同一份账户状态,每一刻都必须一致。在它们之间隔一层网络,一次函数调用就变成了分布式事务,而且恰恰发生在你最承受不起的那条路径上。

五条设计规则,以及每一条要防止的故障

1. 每个账户只有一个写入者

一个账户的每一次状态变化,都走同一条有序队列:成交、保证金检查、隔夜利息、入金、强平。否则,保证金检查可能基于一笔已被并发成交占用的资金而通过,或者强平和客户自己的平仓单同时去平同一个持仓。我在 MT5 上遇到过这个问题代价较小的版本:在一个非线程安全的 SDK 上并发调用,让一个账户短暂地读到了另一个账户的数字,直到所有调用都被串行化才解决。

2. 价格是事件,过期报价绝不成交

每条报价都带有来源、序号和时间戳,每笔成交都记录它用的是哪一条报价。如果某个品种最新的报价已经超过该品种允许的时限,核心就拒绝订单并给出原因。这能防止最糟糕的一类纠纷:客户按一个市场一分钟前就已离开的价格成交,而记录里没有任何东西能解释原因。

3. 订单是带幂等键的状态机

订单在一组有名字的状态之间流转(已接收、已受理、已成交、已拒绝、已取消),而且只能沿着允许的路径转换。每个请求都带一个由客户端生成的键,存储时加唯一约束,这样超时后的重试会返回原来的结果,而不是再开一个仓位。这和支付 webhook 只处理一次是同一套做法。

4. 金额用定点数,计算只有一份实现

价格、交易量和余额都是按声明好的精度存储的整数,绝不用浮点数。盈亏、净值和保证金各自只有一个函数,引擎、API 和报表都调用它。有两份实现就会逐渐走样,然后客户屏幕上的数字和后台报表就对不上了。在那个自营交易平台上,正是把失败判定的计算挪进一个共享服务之后,才结束了因隔夜利息被重复计算而造成的误判失败。

5. 强平是纯函数,用回放来测试

给定持仓、价格和组别的规则,强平决策每次都必须得出同样的结果:不读时钟,出现并列时也不随意取舍。这样我就可以录制 tick 序列,在引擎里回放,并断言哪个持仓在哪一条报价上被平掉。做不到这一点,你就无法向客户或监管机构解释,一个账户为什么是在那个位置被平仓的。

规则 4 和规则 5 合在一起,大致是下面这个样子。比较时用交叉相乘代替除法,所以舍入永远不会改变决策结果。

// Money is a whole number of minor units of the account currency (cents for USD).
type Money int64

type Position struct {
	ID       int64
	Floating Money // unrealised P/L at the current quote, swap and commission included
	Margin   Money // margin held for this position
}

type Decision struct {
	Equity, Margin Money
	CloseID        int64 // 0 means close nothing
}

// atOrBelow compares equity/margin with a level in basis points (5000 = 50%)
// by cross-multiplying, so no division or rounding can move the decision.
func atOrBelow(equity, margin Money, levelBps int64) bool {
	return int64(equity)*10000 <= int64(margin)*levelBps
}

// Evaluate is pure: the same inputs always give the same decision.
// Positions arrive sorted by ID, so ties break the same way on every replay.
func Evaluate(balance, credit Money, positions []Position, stopOutBps int64) Decision {
	d := Decision{Equity: balance + credit}
	var worst *Position
	for i := range positions {
		p := &positions[i]
		d.Equity += p.Floating
		d.Margin += p.Margin
		if worst == nil || p.Floating < worst.Floating {
			worst = p
		}
	}
	if d.Margin == 0 {
		return d // no margin in use: the level is undefined, nothing to close
	}
	if atOrBelow(d.Equity, d.Margin, stopOutBps) {
		d.CloseID = worst.ID // close the largest loser, then evaluate again
	}
	return d
}
go示意用的 Go 代码,不是生产代码:一个定点数的强平决策。这里展示的策略(先平掉浮动盈亏最差的持仓,然后重新评估)是一种常见的选择,应该放在组别设置里。

会出什么问题,设计上怎么应对

写引擎的第一行代码之前,我会先写出这张表。之后每一行都会变成一个测试。

场景会发生什么设计上如何应对
行情源停滞某个品种不再更新,而客户还在继续下单。报价一旦超过时限,报价服务就把这个品种标记为过期。该品种上的订单会被拒绝并给出原因,不会有任何持仓按过期价格被平掉,同时触发告警。
异常 tick 尖刺某个报价提供方发来一个远离市场的价格。按这个价格成交或强平都是错的。每个 tick 都要和上一个正常价格以及其他来源比对。超出该品种允许区间的 tick 先扣住,等后续 tick 确认之后再放行。
成交过程中进程崩溃引擎在决定成交之后、通知客户之前挂掉。一笔成交是一条日志(journal)记录(订单状态、持仓、账本分录),在发出任何确认之前写入。重启之后,它要么完整存在,要么完全不存在。
订单重复提交双击或者网络重试,把同一笔订单发了两次。幂等键相同,答案就相同。第二个请求返回第一笔订单的结果,不会开出任何新仓位。
周末后开盘跳空周末之后的第一条报价与周五收盘价相差很远。止损价被直接跳过,有些账户一开盘就已低于强平水平。止损和强平按开盘后第一条有效报价成交,而不是按设定的价位成交。回放测试集里包含跳空序列,负余额按一份书面政策处理。
数据库故障切换主库消失。一笔正在进行的提交,可能已经落库,也可能没有。引擎停止接单,而不是跑到自己的 journal 前面去。journal 采用同步复制,所以已确认的成交不会丢。随后引擎重新加载、对账,再恢复接单。
在第一笔成交之前就要考虑到的六种故障。

恢复:以 journal 为准

为了速度,引擎把账户状态放在内存里,但内存只是 journal 的一份缓存。每一条被接受的指令及其产生的事件,都会在发出任何确认之前追加到一份持久、有序的日志里,定期快照则让重启时间有上限。启动时,引擎先加载最新的快照,再回放快照之后的 journal,然后才开始接单。

接下来,对账任务做的是审计员会做的事:根据账本分录重新计算每个账户的持仓和余额,再和引擎里的状态比较。只要有差异,就停止该账户的交易,并通知人来处理。

Redis 或任何缓存,用来存会话、做限流、把报价分发到浏览器,都没有问题。但它绝不能成为资金数据的事实来源。按它常见的持久化和复制配置,崩溃或故障切换时可能丢失刚刚确认过的写入,而在这里,丢一次写入就是丢一笔成交或一笔入金。

为 A-Book、混合模式和第二个品牌留出余地

核心应该通过一个执行场所(execution venue)接口来成交,内部账簿是它的第一个、也是唯一一个实现。以后要把一部分订单流路由给外部流动性提供商(A-Book,或者按组别、品种或客户拆分的混合模式),只需要增加一个执行场所和一条路由规则,而不用重写成交路径。外部执行场所会带来部分成交、拒单,以及需要对账的第二套持仓,所以在内部账簿得到验证之前,我不会去做它们。

多品牌和白标支持,第一天做很便宜,以后再做就很贵。从第一次数据库迁移开始,租户和品牌就要进入表结构,进入每一个唯一键,进入每一个缓存键和消息流键。交易条件属于组别,而不属于代码:杠杆、点差、隔夜利息和强平水平都是按组别设置的,每一次修改都要留下审计记录。

第一个付费冲刺应该证明什么

我不会一上来就做完整的系统。第一个技术冲刺应该在一个很小的范围内证明核心是正确的,顺序如下:

  1. 01报价进得来:一个行情源适配器加报价服务,带加点和过期检测。
  2. 02带幂等键的市价单,按当前报价成交。
  3. 03持仓和盈亏,每一笔成交都写入只追加的账本。
  4. 04保证金和强平,在录制的 tick 序列上通过回放测试,序列里要包含一次跳空和一个异常 tick。
  5. 05一层很薄的 API 和推送流,供 WebTrader 调用:报价、下单、持仓、账户概览。

挂单、隔夜利息与展期、A-Book 路由、第二个品牌、完整的后台、手机 App 和真实支付,都不应该放进这个冲刺。这些每一项都是实打实的工作,但没有一项能告诉你核心是否可靠。

关于编程语言

在初创公司的规模下,用 Go 或 Java 这类带垃圾回收的语言来起步做 B-Book 核心,是合理的选择。在这里,正确性和回放测试比纳秒级的性能更重要,一笔成交错了,比慢了代价更大。只有当延迟本身成了产品,我才会重新考虑这个选择。

接入真实客户之前

回放测试全部通过,只是证据的起点,而不是终点。在接触真实客户资金之前,平台还需要:由没有参与编写的人做独立测试,对 API、后台和基础设施做安全评审,并为部署、行情源中断、故障切换和人工更正准备好运维手册。

它还需要取得法律和监管方面的确认。作为对手方直接与自己的客户交易(dealing as principal),是受监管的业务。能不能做、在哪里做、凭什么牌照、适用哪些客户资金规则,都是业务方和它的律师要回答的问题。我是工程师,不是律师,也不是合规官:我会把这些环节排进计划,并把系统设计得能够满足这些控制要求,但我不能代替它们。

这类工作我承接,归在交易平台与券商系统开发这项服务下:一次架构评审,一个用来验证的首期冲刺,或者你已在运行的引擎周边的系统。如果你正在权衡要不要拥有自己的执行核心,告诉我你现在进展到哪一步。

相关服务交易平台与券商系统开发了解更多

更多笔记