设计撮合引擎


在证券交易系统中,撮合引擎是实现买卖盘成交的关键组件。我们先分析撮合引擎的工作原理,然后设计并实现一个最简化的撮合引擎。

在证券市场中,撮合交易是一种微观价格发现模型,它允许买卖双方各自提交买卖订单并报价,按价格优先,时间优先的顺序,凡买单价格大于等于卖单价格时,双方即达成价格协商并成交。在A股身经百战的老股民对此规则应该非常熟悉,这里不再详述。

我们将讨论如何从技术上来实现它。对于撮合引擎来说,它必须维护两个买卖盘列表,一个买盘,一个卖盘,买盘按价格从高到低排序,确保报价最高的订单排在最前面;卖盘则相反,按照价格从低到高排序,确保报价最低的卖单排在最前面。

下图是一个实际的买卖盘:

orderbook

对于买盘来说,上图的订单排序为2086.50,2086.09,2086.06,20860,2085.97,……

对于卖盘来说,上图的订单排序为2086.55,2086.75,2086.77,2086.90,2086.99,……

不可能出现买1价格大于等于卖1价格的情况,因为这意味着应该成交的买卖订单没有成交却在订单簿上等待成交。

对于多个价格相同的订单,例如2086.55,很可能张三卖出1,李四卖出3,累计数量是4。当一个新的买单价格≥2086.55时,到底优先和张三的卖单成交还是优先和李四的卖单成交呢?这要看张三和李四的订单时间谁更靠前。

我们在订单上虽然保存了创建时间,但排序时,是根据定序ID即sequenceId来排序,以确保全局唯一。时间本身实际上是订单的一个普通属性,仅展示给用户,不参与业务排序。

下一步是实现订单簿OrderBook的表示。一个直观的想法是使用List<Order>,并对订单进行排序。但是,在证券交易中,使用List会导致两个致命问题:

  • 插入新的订单时,必须从头扫描List<Order>,以便在合适的地方插入Order,平均耗时O(N);
  • 取消订单时,也必须从头扫描List<Order>,平均耗时O(N)。

更好的方法是使用红黑树,它是一种自平衡的二叉排序树,插入和删除的效率都是O(logN),对应的Java类是TreeMap

所以我们定义OrderBook的结构就是一个TreeMap<OrderKey, OrderEntity>,它的排序根据OrderKey决定。由业务规则可知,负责排序的OrderKey只需要sequenceIdprice即可:

  1. // 以record实现的OrderKey:
  2. public record OrderKey(long sequenceId, BigDecimal price) {
  3. }

因此,OrderBook的核心数据结构就可以表示如下:

  1. public class OrderBook {
  2. public final Direction direction; // 方向
  3. public final TreeMap<OrderKey, Order> book; // 排序树
  4. public OrderBook(Direction direction) {
  5. this.direction = direction;
  6. this.book = new TreeMap<>(???);
  7. }
  8. }

有的童鞋注意到TreeMap的排序要求实现Comparable接口或者提供一个Comparator。我们之所以没有在OrderKey上实现Comparable接口是因为买卖盘排序的价格规则不同,因此,编写两个Comparator分别用于排序买盘和卖盘:

  1. private static final Comparator<OrderKey> SORT_SELL = new Comparator<>() {
  2. public int compare(OrderKey o1, OrderKey o2) {
  3. // 价格低在前:
  4. int cmp = o1.price().compareTo(o2.price());
  5. // 时间早在前:
  6. return cmp == 0 ? Long.compare(o1.sequenceId(), o2.sequenceId()) : cmp;
  7. }
  8. };
  9. private static final Comparator<OrderKey> SORT_BUY = new Comparator<>() {
  10. public int compare(OrderKey o1, OrderKey o2) {
  11. // 价格高在前:
  12. int cmp = o2.price().compareTo(o1.price());
  13. // 时间早在前:
  14. return cmp == 0 ? Long.compare(o1.sequenceId(), o2.sequenceId()) : cmp;
  15. }
  16. };

这样,OrderBookTreeMap排序就由Direction指定:

  1. public OrderBook(Direction direction) {
  2. this.direction = direction;
  3. this.book = new TreeMap<>(direction == Direction.BUY ? SORT_BUY : SORT_SELL);
  4. }

这里友情提示Java的BigDecimal比较大小的大坑:比较两个BigDecimal是否值相等,一定要用compareTo(),不要用equals(),因为1.21.20因为scale不同导致equals()返回false

在Java中比较两个BigDecimal的值只能使用compareTo(),不能使用equals()!

再给OrderBook添加插入、删除和查找首元素方法:

  1. public OrderEntity getFirst() {
  2. return this.book.isEmpty() ? null : this.book.firstEntry().getValue();
  3. }
  4. public boolean remove(OrderEntity order) {
  5. return this.book.remove(new OrderKey(order.sequenceId, order.price)) != null;
  6. }
  7. public boolean add(OrderEntity order) {
  8. return this.book.put(new OrderKey(order.sequenceId, order.price), order) == null;
  9. }

现在,有了买卖盘,我们就可以编写撮合引擎了。定义MatchEngine核心数据结构如下:

  1. public class MatchEngine {
  2. public final OrderBook buyBook = new OrderBook(Direction.BUY);
  3. public final OrderBook sellBook = new OrderBook(Direction.SELL);
  4. public BigDecimal marketPrice = BigDecimal.ZERO; // 最新市场价
  5. private long sequenceId; // 上次处理的Sequence ID
  6. }

一个完整的撮合引擎包含一个买盘、一个卖盘和一个最新成交价(初始值为0)。撮合引擎的输入是一个OrderEntity实例,每处理一个订单,就输出撮合结果MatchResult,核心处理方法定义如下:

  1. public MatchResult processOrder(long sequenceId, OrderEntity order) {
  2. ...
  3. }

下面我们讨论如何处理一个具体的订单。对于撮合交易来说,如果新订单是一个买单,则首先尝试在卖盘中匹配价格合适的卖单,如果匹配成功则成交。一个大的买单可能会匹配多个较小的卖单。当买单被完全匹配后,说明此买单已完全成交,处理结束,否则,如果存在未成交的买单,则将其放入买盘。处理卖单的逻辑是类似的。

我们把已经挂在买卖盘的订单称为挂单(Maker),当前正在处理的订单称为吃单(Taker),一个Taker订单如果未完全成交则转为Maker挂在买卖盘,因此,处理当前Taker订单的逻辑如下:

  1. public MatchResult processOrder(long sequenceId, OrderEntity order) {
  2. switch (order.direction) {
  3. case BUY:
  4. // 买单与sellBook匹配,最后放入buyBook:
  5. return processOrder(order, this.sellBook, this.buyBook);
  6. case SELL:
  7. // 卖单与buyBook匹配,最后放入sellBook:
  8. return processOrder(order, this.buyBook, this.sellBook);
  9. default:
  10. throw new IllegalArgumentException("Invalid direction.");
  11. }
  12. }
  13. MatchResult processOrder(long sequenceId, OrderEntity takerOrder, OrderBook makerBook, OrderBook anotherBook) {
  14. ...
  15. }

根据价格匹配,直到成交双方有一方完全成交或成交条件不满足时结束处理,我们直接给出processOrder()的业务逻辑代码:

  1. MatchResult processOrder(long sequenceId, OrderEntity takerOrder, OrderBook makerBook, OrderBook anotherBook) {
  2. this.sequenceId = sequenceId;
  3. long ts = takerOrder.createdAt;
  4. MatchResult matchResult = new MatchResult(takerOrder);
  5. BigDecimal takerUnfilledQuantity = takerOrder.quantity;
  6. for (;;) {
  7. OrderEntity makerOrder = makerBook.getFirst();
  8. if (makerOrder == null) {
  9. // 对手盘不存在:
  10. break;
  11. }
  12. if (takerOrder.direction == Direction.BUY && takerOrder.price.compareTo(makerOrder.price) < 0) {
  13. // 买入订单价格比卖盘第一档价格低:
  14. break;
  15. } else if (takerOrder.direction == Direction.SELL && takerOrder.price.compareTo(makerOrder.price) > 0) {
  16. // 卖出订单价格比买盘第一档价格高:
  17. break;
  18. }
  19. // 以Maker价格成交:
  20. this.marketPrice = makerOrder.price;
  21. // 待成交数量为两者较小值:
  22. BigDecimal matchedQuantity = takerUnfilledQuantity.min(makerOrder.unfilledQuantity);
  23. // 成交记录:
  24. matchResult.add(makerOrder.price, matchedQuantity, makerOrder);
  25. // 更新成交后的订单数量:
  26. takerUnfilledQuantity = takerUnfilledQuantity.subtract(matchedQuantity);
  27. BigDecimal makerUnfilledQuantity = makerOrder.unfilledQuantity.subtract(matchedQuantity);
  28. // 对手盘完全成交后,从订单簿中删除:
  29. if (makerUnfilledQuantity.signum() == 0) {
  30. makerOrder.updateOrder(makerUnfilledQuantity, OrderStatus.FULLY_FILLED, ts);
  31. makerBook.remove(makerOrder);
  32. } else {
  33. // 对手盘部分成交:
  34. makerOrder.updateOrder(makerUnfilledQuantity, OrderStatus.PARTIAL_FILLED, ts);
  35. }
  36. // Taker订单完全成交后,退出循环:
  37. if (takerUnfilledQuantity.signum() == 0) {
  38. takerOrder.updateOrder(takerUnfilledQuantity, OrderStatus.FULLY_FILLED, ts);
  39. break;
  40. }
  41. }
  42. // Taker订单未完全成交时,放入订单簿:
  43. if (takerUnfilledQuantity.signum() > 0) {
  44. takerOrder.updateOrder(takerUnfilledQuantity,
  45. takerUnfilledQuantity.compareTo(takerOrder.quantity) == 0 ? OrderStatus.PENDING
  46. : OrderStatus.PARTIAL_FILLED,
  47. ts);
  48. anotherBook.add(takerOrder);
  49. }
  50. return matchResult;
  51. }

可见,撮合匹配的业务逻辑是相对简单的。撮合结果记录在MatchResult中,它可以用一个Taker订单和一系列撮合匹配记录表示:

  1. public class MatchResult {
  2. public final Order takerOrder;
  3. public final List<MatchDetailRecord> MatchDetails = new ArrayList<>();
  4. // 构造方法略
  5. }

每一笔撮合记录则由成交双方、成交价格与数量表示:

  1. public record MatchDetailRecord(
  2. BigDecimal price,
  3. BigDecimal quantity,
  4. OrderEntity takerOrder,
  5. OrderEntity makerOrder) {
  6. }

撮合引擎返回的MatchResult包含了本次处理的完整结果,下一步需要把MatchResult发送给清算系统,对交易双方进行清算即完成了整个交易的处理。

我们可以编写一个简单的测试来验证撮合引擎工作是否正常。假设如下的订单依次输入到撮合引擎:

  1. // 方向 价格 数量
  2. buy 2082.34 1
  3. sell 2087.6 2
  4. buy 2087.8 1
  5. buy 2085.01 5
  6. sell 2088.02 3
  7. sell 2087.60 6
  8. buy 2081.11 7
  9. buy 2086.0 3
  10. buy 2088.33 1
  11. sell 2086.54 2
  12. sell 2086.55 5
  13. buy 2086.55 3

经过撮合后最终买卖盘及市场价如下:

  1. 2088.02 3
  2. 2087.60 6
  3. 2086.55 4
  4. ---------
  5. 2086.55
  6. ---------
  7. 2086.00 3
  8. 2085.01 5
  9. 2082.34 1
  10. 2081.11 7

如果我们仔细观察整个系统的输入和输出,输入实际上是一系列按时间排序后的订单(实际排序按sequenceId),输出是一系列MatchResult,内部状态的变化就是买卖盘以及市场价的变化。如果两个初始状态相同的MatchEngine,输入的订单序列是完全相同的,则我们得到的MatchResult输出序列以及最终的内部状态也是完全相同的。

下面是问题解答。

如何实现多个交易对?

一个撮合引擎只能处理一个交易对,如果要实现多个交易对,则需要构造一个“多撮合实例”的引擎:

  1. class MatchEngineGroup {
  2. Map<Long, MatchEngine> engines = new HashMap<>();
  3. public MatchResult processOrder(long sequenceId, OrderEntity order) {
  4. // 获得订单的交易对ID:
  5. Long symbolId = order.symbolId;
  6. // 查找交易对所对应的引擎实例:
  7. MatchEngine engine = engines.get(symbolId);
  8. if (engine == null) {
  9. // 该交易对的第一个订单:
  10. engine = new MatchEngine();
  11. engines.put(symbolId, engine);
  12. }
  13. // 由该实例处理订单:
  14. return engine.processOrder(sequenceId, order);
  15. }
  16. }

需要给订单增加symbolId属性以标识该订单是哪个交易对。

参考源码

可以从GitHubGitee下载源码。

GitHubmichaelliaowarpexchange/

▸ build)

▸ sql)

▤ schema.sql)

▤ docker-compose.yml)

▤ pom.xml)

▸ common)

▸ src/main)

▸ java/com/itranswarp/exchange)

▸ bean)

▤ OrderBookBean.java)

▤ OrderBookItemBean.java)

▸ enums)

▤ AssetEnum.java)

▤ Direction.java)

▤ OrderStatus.java)

▸ model)

▸ support)

▤ EntitySupport.java)

▸ trade)

▤ OrderEntity.java)

▸ support)

▤ LoggerSupport.java)

▸ util)

▤ ByteUtil.java)

▤ JsonUtil.java)

▸ resources)

▤ logback-spring.xml)

▤ pom.xml)

▸ config)

▸ src/main)

▸ java/com/itranswarp/exchange/config)

▤ ConfigApplication.java)

▸ resources)

▤ application.yml)

▤ pom.xml)

▸ config-repo)

▤ application-default.yml)

▤ application-test.yml)

▤ application.yml)

▤ push.yml)

▤ quotation.yml)

▤ trading-api.yml)

▤ trading-engine.yml)

▤ trading-sequencer.yml)

▤ ui-default.yml)

▤ ui.yml)

▸ parent)

▤ pom.xml)

▸ push)

▸ src/main)

▸ java/com/itranswarp/exchange/push)

▤ PushApplication.java)

▸ resources)

▤ application.yml)

▤ pom.xml)

▸ quotation)

▸ src/main)

▸ java/com/itranswarp/exchange)

▤ QuotationApplication.java)

▸ resources)

▤ application.yml)

▤ pom.xml)

▸ trading-api)

▸ src/main)

▸ java/com/itranswarp/exchange)

▤ TradingApiApplication.java)

▸ resources)

▤ application.yml)

▤ pom.xml)

▸ trading-engine)

▸ src)

▸ main)

▸ java/com/itranswarp/exchange)

▸ assets)

▤ Asset.java)

▤ AssetService.java)

▤ Transfer.java)

▸ match)

▤ MatchDetailRecord.java)

▤ MatchEngine.java)

▤ MatchResult.java)

▤ OrderBook.java)

▤ OrderKey.java)

▸ order)

▤ OrderService.java)

▤ TradingEngineApplication.java)

▸ resources)

▤ application.yml)

▸ test/java/com/itranswarp/exchange)

▸ assets)

▤ AssetServiceTest.java)

▸ match)

▤ MatchEngineTest.java)

▤ pom.xml)

▸ trading-sequencer)

▸ src/main)

▸ java/com/itranswarp/exchange)

▤ TradingSequencerApplication.java)

▸ resources)

▤ application.yml)

▤ pom.xml)

▸ ui)

▸ src/main)

▸ java/com/itranswarp/exchange)

▤ UIApplication.java)

▸ resources)

▤ application.yml)

▤ pom.xml)

▤ .gitignore)

▤ LICENSE)

▤ README.md)

小结

本文讨论并实现了一个可工作的撮合引擎核心。实现撮合引擎的关键在于将业务模型转换为高效的数据结构。只要保证核心数据结构的简单和高效,撮合引擎的业务逻辑编写是非常容易的。

读后有收获可以支付宝请作者喝咖啡:

设计撮合引擎 - 图2