持仓限额、保证金、fat-finger 边界和 kill switch,都必须在订单离开网关之前对每一笔给出答复。本文讲这些检查如何住在订单路径内部而不是旁边:哪些状态放在内存里、哪些做增量重算、重启之后会发生什么。先从约束讲起。
一笔订单到达网关。在它发往交易场所之前,必须有什么来判定这个账户是否被允许发出它。这个判定对每一笔订单都要跑,包括绝大多数完全正常的订单,所以它的成本由全部正常流量承担,而不只由被拒的那些承担。
这是首先要点明的约束。放进订单路径的风控检查,是对下单征收的一道税。工程上的问题不是如何让检查变聪明,而是如何让它小到交易者感觉不到,同时诚实到该拒的订单照样拒。
热路径只回答一个问题:以我们当前对这个账户的了解,这笔订单现在可以发出去吗。回答这个问题的检查留在里面。回答另一个问题的检查搬到外面。
其余一切都跑在路径旁边,用同一份状态,不扣住订单。它为热路径所执行的限额提供依据,但不横在交易者与交易场所之间。
分界线是一个问题,而不是一个类别。在路径内:这笔订单可以发出去吗。在路径旁:限额应该是多少。任何回答第二个问题、却仍然扣住订单的东西,都是设计错误,无论这项检查多重要。
交易前检查所需的状态——当前持仓、在途订单、已用与可用保证金、限额配置——就住在做出判定的那个进程的内存里。不是在数据库前面的缓存里,也不是在一次网络调用的后面。就在进程里。
原因不只是速度,尽管一次查询比在本地数组里查一下要贵好几个数量级。原因是正确性。数据库保存的是写入那一刻的持仓。风控检查需要的持仓,要把刚刚发出、尚未成交、尚未确认、也尚未落盘的订单算进去。如果你从存储里读,你比对的就是一段已经被自己的订单流甩在身后的过去。
落到实处,这会像塑造任何低延迟组件那样塑造这个进程:
数据库是持仓被记录的地方,不是持仓被知晓的地方。
完整重算账户的敞口和保证金,要遍历每一个持仓和每一笔在途订单。这个成本随账本规模增长,也就意味着交易最多的客户反而要承受更慢的风控检查。所以热路径不做重算,它只应用增量。
账户上带着一组滚动聚合值——按合约和按分组的净敞口与总敞口、已用保证金、在途名义金额。一笔来单对这些聚合值产生一个小的改动,改动后的值与限额比对,订单据此被接受或拒绝。工作量与订单成正比,而不是与组合成正比。
完整重算依然存在——按计划执行、在保证金参数变更时执行,以及作为对增量结果的周期性自检。它跑在路径之外、跑在副本上,其结果要么被换入,要么被作为差异上报。悄悄偏离真实状态的增量状态,比根本不做检查更糟,所以这项比对不是可选项。
在我们搭建的风控路径上实测,交易前检查本身在 <1 ms 内完成。这个数字覆盖的是基于内存状态的判定,而不是订单从客户端到交易场所再返回的完整行程。
kill switch 恰恰是在事情已经出错时才会用到。这就排除了把它建在可能本身正出问题的那套机制之上。它是一条独立的路径,有自己的规则。
拦住新订单是容易的那一半。更难的一半,是开关对已经挂在交易场所的订单做什么:在发单路径被禁用的同时,仍要能撤回报价、撤销在途订单。那条撤单路径值得单独测试,因为用到它的是最糟的一天,而不是平常的一天。
内存状态是持久记录的派生视图。这正是重启得以幸存的原因。每一个改变风控状态的事件——接受一笔订单、释放一笔预留、应用一次成交、变更一项限额、启用开关——都在被下游处理之前,追加写入本机的日志。
于是恢复时间取决于日志长度和 drop copy 是否可用,而不取决于账本规模;而每一个未知项的失败模式都一样:拒绝让这个账户交易。
这些实打实的局限值得直说,因为它们决定这套架构究竟合不合适:
amBrain 为经纪商和自营交易公司搭建这类交易前风控路径,热路径用 Rust 编写。团队自 2019 年起在亚美尼亚埃里温做交易基础设施。
我们的工程团队专注于FinTech方向的解决方案。聊聊怎么把你的项目做出来。