├── .gitignore ├── LICENSE ├── README.md ├── README_CN.md ├── composer.json ├── config └── mengine.php └── src ├── Core ├── AbstractCommissionPool.php ├── AbstractMengine.php └── Order.php ├── Events ├── DeleteOrderEvent.php ├── DeleteOrderSuccEvent.php ├── MatchEvent.php └── PushQueueEvent.php ├── Exceptions ├── DeleteOrderException.php ├── Exception.php ├── InvalidParamException.php └── MatchException.php ├── Jobs ├── DeleteOrderJob.php └── PushQueueJob.php ├── Listeners ├── DeleteOrderEventListener.php └── PushQueueEventListener.php ├── Mengine.php ├── MengineFacade.php ├── MengineServiceProvider.php └── Services ├── CommissionPoolService.php ├── DepthLinkService.php ├── LinkService.php ├── MengineService.php └── OrderService.php /.gitignore: -------------------------------------------------------------------------------- 1 | composer.lock 2 | vendor 3 | .php_cs.cache 4 | tags 5 | .idea 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 sting 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Laravel Package for Matching Engine 2 | 3 | - [中文文档](README_CN.md) 4 | 5 | ## Quick Start 6 | 7 | - Install: `composer require sting_bo/mengine` 8 | - Copy configuration file: `php artisan vendor:publish` 9 | 10 | 11 | ### Dependencies 12 | * predis 13 | 14 | ### News 15 | 16 | * **[Golang Microservice Matching Engine is Now Available](https://github.com/stingbo/gome)**,feel free to use and raise issues. 17 | 18 | ### Usage Instructions 19 | * For existing systems with data, if using this library, you can write an initialization script to first run the data into the queue. 20 | 21 | * #### Placing an Order #### 22 | 23 | * After placing an order, store it in the database and then instantiate the order object. 24 | 25 | ```php 26 | use StingBo\Mengine\Core\Order; 27 | 28 | $uuid = 3; // User unique identifier 29 | $oid = 4; // Order unique identifier 30 | $symbol = 'abc2usdt'; // Trading pair 31 | $transaction = 'buy'; // Trading direction, buy/sell 32 | $price = 0.4; // Trading price, will be converted to an integer based on the set precision 33 | $volume = 15; // Trading quantity, will be converted to an integer based on the set precision 34 | 35 | $order = new Order($uuid, $oid, $symbol, $transaction, $volume, $price); 36 | ``` 37 | 38 | `Transaction direction`and`precision`can be flexibly set in the configuration file. 39 | ```php 40 | return [ 41 | 'mengine' => [ 42 | // Transaction types, not subject to change. 43 | 'transaction' => [ 44 | 'buy', 45 | 'sell', 46 | ], 47 | 48 | // Default precision, can be changed. 49 | 'accuracy' => 8, 50 | // If the precision for the trading pair is set, use it; otherwise, take the default accuracy. 51 | 'abc2usdt_accuracy' => 6, // Example of a trading pair 52 | 'test2usdt_accuracy' => 7, // Example of a trading pair 53 | 54 | // If strict mode is set to true, it will validate that the decimal places of the transaction volume or price must be less than the configured length, otherwise the order will fail. 55 | // If strict mode is set to false, the data will be truncated to the configured decimal places length. 56 | 'strict_mode' => false, 57 | ], 58 | ]; 59 | ``` 60 | 61 | * Push to the queue, queue tasks need to be manually started. 62 | ```php 63 | use StingBo\Mengine\Services\MengineService; 64 | 65 | $ms = new MengineService(); 66 | $ms->pushQueue($order); 67 | ``` 68 | Start the queue task: 69 | `php artisan queue:work --queue=abc2usdt` 70 | You can also use `horizon` and `supervisor` to assist, making your work more efficient! 71 | 72 | When the queue is consumed, it will enter the matching program. The general steps are as follows: 73 | 1. Get matching delegated orders. 74 | 2. If there are no matching orders, enter the order pool, triggering the order pool change event, see point 5. 75 | 3. If there are matching orders, the program matches and updates the order pool data. 76 | 4. Successful transactions trigger events. Developers should handle orders with transactions in listeners, such as updating database data, WebSocket notifications, etc. 77 | In EventServiceProvider, register listeners for successful matches: 78 | ```php 79 | // Successful match notification, parameters are: current order, matched order, transaction quantity 80 | event(new MatchEvent($order, $match_order, $match_volume)); 81 | 82 | // Register listener 83 | protected $listen = [ 84 | 'StingBo\Mengine\Events\MatchEvent' => [ 85 | 'App\Listeners\YourListener', // Your own listener, should also be implemented asynchronously 86 | ], 87 | ]; 88 | ``` 89 | 5. If only partially filled, the remaining part enters the order pool, triggering the order pool change event, notifying K-line or depth list changes, etc. 90 | Register the listener as follows: 91 | ```php 92 | // Order pool data change event 93 | event(new PushQueueEvent($order)); 94 | 95 | // Register listener 96 | protected $listen = [ 97 | 'StingBo\Mengine\Events\PushQueueEvent' => [ 98 | 'App\Listeners\YourListener', // Your own listener, should also be implemented asynchronously 99 | ], 100 | ]; 101 | ``` 102 | 103 | * #### Canceling an Order #### 104 | The cancellation process should be to first query the database to confirm if it can be canceled, then successfully delete the data from redis, and finally update the database. 105 | ```php 106 | $order = new Order($uuid, $oid, $symbol, $transaction, $volume, $price); 107 | $ms = new MengineService(); 108 | $ms->deleteOrder($order); 109 | ``` 110 | This matching engine does not implement locking mechanisms like databases. To prevent a situation where an order is being matched and a cancellation command is issued, both placing and canceling orders use the same queue to ensure order, and each trading pair has an isolated queue. This ensures efficiency, but developers need to implement asynchronous notification functionality. Register the listener as follows: 111 | ```php 112 | // Successful cancellation notification 113 | event(new DeleteOrderSuccEvent($order)); 114 | 115 | // Register listener 116 | protected $listen = [ 117 | 'StingBo\Mengine\Events\DeleteOrderSuccEvent' => [ 118 | 'App\Listeners\YourListener', // Your own listener, should also be implemented asynchronously 119 | ], 120 | ]; 121 | ``` 122 | 123 | * #### Obtaining Buy/Sell Depth List for a Trading Pair #### 124 | ```php 125 | $symbol = 'abc2cny'; 126 | $transaction = 'buy'; 127 | $ms = new MengineService(); 128 | $ms->getDepth($symbol, $transaction); 129 | ``` 130 | 131 | ### Summary 132 | 133 | Tested on a local, average matching speed for transactions is around 200 per second. Further optimizations for matching speed are planned for the future. 134 | 135 | ![Design of a Matching Engine Based on Redis](https://raw.githubusercontent.com/stingbo/image/master/CryptocurrencyExchange-SimpleMatchingEngineBasedOnRedis.png) 136 | 137 | ## Technical Support 138 | 139 | | contact us | detail | 140 | | :---: | :---: | 141 | | QQ Group | 871358160 | 142 | | Email | sting_bo@163.com | 143 | -------------------------------------------------------------------------------- /README_CN.md: -------------------------------------------------------------------------------- 1 | # Laravel Package for Matching Engine 2 | 3 | ## 快速开始 4 | 5 | - 安装: `composer require sting_bo/mengine` 6 | - 复制配置文件: `php artisan vendor:publish` 7 | 8 | 9 | ### 依赖 10 | * predis 11 | 12 | ### 号外 13 | 14 | * **[已经出Golang微服务撮合啦](https://github.com/stingbo/gome)**,欢迎使用并提issue 15 | 16 | ### 使用说明 17 | * 已有数据的系统如果使用此库,可以自己写一个初始化脚本,先把数据跑入队列 18 | 19 | * #### 用户下单 #### 20 | 21 | * 下单后,先存入数据库,然后才开始下面步骤,实例化单据对象 22 | 23 | ```php 24 | use StingBo\Mengine\Core\Order; 25 | 26 | $uuid = 3; // 用户唯一标识 27 | $oid = 4; // 订单唯一标识 28 | $symbol = 'abc2usdt'; // 交易对 29 | $transaction = 'buy'; // 交易方向,buy/sell 30 | $price = 0.4; // 交易价格,会根据设置精度转化为整数 31 | $volume = 15; // 交易数量,会根据设置精度转化为整数 32 | 33 | $order = new Order($uuid, $oid, $symbol, $transaction, $volume, $price); 34 | ``` 35 | 36 | `交易方向`与`交易精度`可在配置文件灵活设置 37 | ```php 38 | return [ 39 | 'mengine' => [ 40 | // 交易类型,不可更改 41 | 'transaction' => [ 42 | 'buy', 43 | 'sell', 44 | ], 45 | 46 | // 默认精度,可更改 47 | 'accuracy' => 8, 48 | // 设置了交易对精度则使用,没有则取accuracy 49 | 'abc2usdt_accuracy' => 6, // 交易对示例 50 | 'test2usdt_accuracy' => 7, // 交易对示例 51 | 52 | // 如果严格模式为true,会校验交易量或价格的小数位长度必须小于设置的长度,否则会下单失败 53 | // 如果严格模式为false,则会截断数据到设定的小数位长度 54 | 'strict_mode' => false, 55 | ], 56 | ]; 57 | 58 | ``` 59 | 60 | * push到队列,队列任务需要手动开启 61 | ```php 62 | use StingBo\Mengine\Services\MengineService; 63 | 64 | $ms = new MengineService(); 65 | $ms->pushQueue($order); 66 | ``` 67 | 开启队列任务: 68 | `php artisan queue:work --queue=abc2usdt` 69 | 也可以使用`horizon`与`supervisor`来辅助,事半功倍! 70 | 71 | 队列消费时会进入撮合程序,大概的步骤如下: 72 | 1. 获取匹配委托订单 73 | 2. 如果没有匹配的订单,则进入委托池,触发委托池变更事件,详见第5点 74 | 3. 如果有匹配的委托,程序撮合,更新委托池数据 75 | 4. 交易成功会触发事件,开发者要在监听器里处理有交易的委托单,比如更新数据库数据,WebSocket通知等 76 | 在EventServiceProvider里为撮合成功的事件注册监听器: 77 | ```php 78 | // 撮合成功通知,参数分别是:当前订单,被匹配的单据,交易数量 79 | event(new MatchEvent($order, $match_order, $match_volume)); 80 | 81 | // 注册监听器 82 | protected $listen = [ 83 | 'StingBo\Mengine\Events\MatchEvent' => [ 84 | 'App\Listeners\YourListener', // 你自己的监听器,应该也使用异步来实现 85 | ], 86 | ]; 87 | ``` 88 | 5. 如果只是部分成交,则剩余部分进入委托池,触发委托池变更事件,K线或者深度列表变更通知等, 89 | 注册监听器如下: 90 | ```php 91 | // 委托池数据变更事件 92 | event(new PushQueueEvent($order)); 93 | 94 | // 注册监听器 95 | protected $listen = [ 96 | 'StingBo\Mengine\Events\PushQueueEvent' => [ 97 | 'App\Listeners\YourListener', // 你自己的监听器,应该也使用异步来实现 98 | ], 99 | ]; 100 | ``` 101 | 102 | * #### 用户撤单 #### 103 | 撤单流程应该是先查询数据库确认是否可撤销,再从redis里删除数据成功,最后更新回数据库 104 | ```php 105 | $order = new Order($uuid, $oid, $symbol, $transaction, $volume, $price); 106 | $ms = new MengineService(); 107 | $ms->deleteOrder($order); 108 | ``` 109 | 此撮合引擎没有实现像数据库那样的锁机制,为了防止有单子在被撮合时又有撤销的命令出现,所以下单与撤单都走的同一个队列,保证了顺序性,每个交易对是隔离的队列,效率也有一定的保证,但开发需要实现异步通知用户功能,注册监听器如下: 110 | ```php 111 | // 撤单成功通知 112 | event(new DeleteOrderSuccEvent($order)); 113 | 114 | // 注册监听器 115 | protected $listen = [ 116 | 'StingBo\Mengine\Events\DeleteOrderSuccEvent' => [ 117 | 'App\Listeners\YourListener', // 你自己的监听器,应该也使用异步来实现 118 | ], 119 | ]; 120 | ``` 121 | 122 | * #### 获取某个交易对买/卖深度列表 #### 123 | ```php 124 | $symbol = 'abc2cny'; 125 | $transaction = 'buy'; 126 | $ms = new MengineService(); 127 | $ms->getDepth($symbol, $transaction); 128 | ``` 129 | 130 | ### 总结 131 | 132 | 本地垃圾笔记本上测试,交易对撮合速度平均在200笔/s,后续将继续优化撮合速度 133 | 134 | ![基于redis的撮合引擎设计](https://raw.githubusercontent.com/stingbo/image/master/%E6%95%B0%E5%AD%97%E8%B4%A7%E5%B8%81%E4%BA%A4%E6%98%93%E6%89%80-%E5%9F%BA%E4%BA%8Eredis%E7%9A%84%E7%AE%80%E5%8D%95%E6%92%AE%E5%8D%95%E5%BC%95%E6%93%8E.png) 135 | 136 | ## 技术支持 137 | 138 | | 联系方式 | 联系我 | 139 | | :---: | :---: | 140 | | QQ群 | 871358160 | 141 | | 邮箱 | sting_bo@163.com | 142 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sting_bo/mengine", 3 | "description": "Matching Engine For Laravel", 4 | "require": { 5 | "php": "^7.4", 6 | "predis/predis": "^1.1", 7 | "ext-bcmath": "*" 8 | }, 9 | "license": "MIT", 10 | "authors": [ 11 | { 12 | "name": "sb", 13 | "email": "sting_bo@163.com" 14 | } 15 | ], 16 | "autoload": { 17 | "psr-4": { 18 | "StingBo\\Mengine\\": "src/" 19 | } 20 | }, 21 | "extra": { 22 | "laravel": { 23 | "providers": [ 24 | "StingBo\\Mengine\\MengineServiceProvider" 25 | ], 26 | "aliases": { 27 | "Mengine": "StingBo\\Mengine\\MengineFacade" 28 | } 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /config/mengine.php: -------------------------------------------------------------------------------- 1 | [ 5 | // Transaction types, not subject to change. 6 | 'transaction' => [ 7 | 'buy', 8 | 'sell', 9 | ], 10 | 11 | // Default precision, can be changed. 12 | 'accuracy' => 8, 13 | // If the precision for the trading pair is set, use it; otherwise, take the default accuracy. 14 | 'abc2usdt_accuracy' => 6, // Example of a trading pair 15 | 'test2usdt_accuracy' => 7, // Example of a trading pair 16 | 17 | // If strict mode is set to true, it will validate that the decimal places of the transaction volume or price must be less than the configured length, otherwise the order will fail. 18 | // If strict mode is set to false, the data will be truncated to the configured decimal places length. 19 | 'strict_mode' => false, 20 | ], 21 | ]; 22 | -------------------------------------------------------------------------------- /src/Core/AbstractCommissionPool.php: -------------------------------------------------------------------------------- 1 | setSymbol($symbol); 78 | $this->setAccuracy(); 79 | $this->setUuid($uuid); 80 | $this->setOid($oid); 81 | $this->setTransaction($transaction); 82 | $this->setVolume($volume); 83 | $this->setPrice($price); 84 | $this->setOrderHashKey(); 85 | $this->setListZsetKey(); 86 | $this->setDepthHashKey(); 87 | $this->setNode(); 88 | $this->setNodeLink(); 89 | } 90 | 91 | /** 92 | * set uuid. 93 | */ 94 | public function setUuid($uuid): Order 95 | { 96 | if (!$uuid) { 97 | throw new InvalidArgumentException(__METHOD__.' expects argument uuid is not empty.'); 98 | } 99 | 100 | $this->uuid = $uuid; 101 | 102 | return $this; 103 | } 104 | 105 | /** 106 | * set oid. 107 | */ 108 | public function setOid($oid): Order 109 | { 110 | if (!$oid) { 111 | throw new InvalidArgumentException(__METHOD__.' expects argument oid is not empty.'); 112 | } 113 | 114 | $this->oid = $oid; 115 | 116 | return $this; 117 | } 118 | 119 | /** 120 | * set symbol. 121 | */ 122 | public function setSymbol(string $symbol): Order 123 | { 124 | if (!$symbol) { 125 | throw new InvalidArgumentException(__METHOD__.' expects argument symbol is not empty.'); 126 | } 127 | 128 | $this->symbol = $symbol; 129 | 130 | return $this; 131 | } 132 | 133 | /** 134 | * set transaction. 135 | */ 136 | public function setTransaction($transaction): Order 137 | { 138 | if (!in_array($transaction, config('mengine.mengine.transaction'))) { 139 | throw new InvalidArgumentException(__METHOD__.' expects argument transaction to be a valid type of [config.mengine.transaction].'); 140 | } 141 | 142 | $this->transaction = $transaction; 143 | 144 | return $this; 145 | } 146 | 147 | /** 148 | * set volume. 149 | */ 150 | public function setVolume($volume): Order 151 | { 152 | if (floatval($volume) <= 0) { 153 | throw new InvalidArgumentException(__METHOD__.' expects argument volume greater than 0.'); 154 | } 155 | if (config('mengine.mengine.strict_mode')) { // In strict mode, the decimal places cannot exceed the configured length. 156 | $number_string = strval($volume); // Convert the number to a string. 157 | // Use a regular expression to match the decimal part. 158 | if (preg_match('/\.\d{' . $this->accuracy . ',}/', $number_string)) { 159 | throw new InvalidArgumentException(__METHOD__.' decimal places exceed the configured length.'); 160 | } 161 | } 162 | 163 | // Truncate the decimal part. 164 | $volume = number_format($volume, $this->accuracy, '.', ''); 165 | $this->volume = bcmul($volume, bcpow(10, $this->accuracy)); 166 | 167 | return $this; 168 | } 169 | 170 | /** 171 | * set price. 172 | */ 173 | public function setPrice($price): Order 174 | { 175 | if (floatval($price) <= 0) { 176 | throw new InvalidArgumentException(__METHOD__.' expects argument price greater than 0.'); 177 | } 178 | if (config('mengine.mengine.strict_mode')) { 179 | $number_string = strval($price); 180 | if (preg_match('/\.\d{' . $this->accuracy . ',}/', $number_string)) { 181 | throw new InvalidArgumentException(__METHOD__.' decimal places exceed the configured length.'); 182 | } 183 | } 184 | 185 | $price = number_format($price, $this->accuracy, '.', ''); 186 | $this->price = bcmul($price, bcpow(10, $this->accuracy)); 187 | 188 | return $this; 189 | } 190 | 191 | /** 192 | * set accuracy. 193 | */ 194 | public function setAccuracy(): Order 195 | { 196 | $accuracy = config("mengine.mengine.{$this->symbol}_accuracy") ?? config('mengine.mengine.accuracy'); 197 | if (floor(0) !== (floatval($accuracy) - $accuracy)) { 198 | throw new InvalidArgumentException(__METHOD__.' expects argument config.mengine.mengine.accuracy is a positive integer.'); 199 | } 200 | 201 | $this->accuracy = $accuracy; 202 | 203 | return $this; 204 | } 205 | 206 | /** 207 | * 委托标识池field. 208 | */ 209 | public function setOrderHashKey(): Order 210 | { 211 | $this->order_hash_key = $this->symbol.':comparison'; 212 | $this->order_hash_field = $this->symbol.':'.$this->uuid.':'.$this->oid; 213 | 214 | return $this; 215 | } 216 | 217 | /** 218 | * 委托列表. 219 | */ 220 | public function setListZsetKey(): Order 221 | { 222 | $this->order_list_zset_key = $this->symbol.':'.$this->transaction; 223 | 224 | return $this; 225 | } 226 | 227 | /** 228 | * 深度. 229 | */ 230 | public function setDepthHashKey(): Order 231 | { 232 | $this->order_depth_hash_key = $this->symbol.':depth'; 233 | $this->order_depth_hash_field = $this->symbol.':depth:'.$this->price; 234 | 235 | return $this; 236 | } 237 | 238 | /** 239 | * hash模拟node. 240 | */ 241 | public function setNode(): Order 242 | { 243 | $this->node = $this->symbol.':node:'.$this->oid; 244 | 245 | return $this; 246 | } 247 | 248 | /** 249 | * hash模拟Link. 250 | */ 251 | public function setNodeLink(): Order 252 | { 253 | $this->node_link = $this->symbol.':link:'.$this->price; 254 | 255 | return $this; 256 | } 257 | } 258 | -------------------------------------------------------------------------------- /src/Events/DeleteOrderEvent.php: -------------------------------------------------------------------------------- 1 | order = $order; 25 | } 26 | 27 | /** 28 | * Get the channels the event should broadcast on. 29 | * 30 | * @return \Illuminate\Broadcasting\Channel|array 31 | */ 32 | public function broadcastOn() 33 | { 34 | return new PrivateChannel('channel-name'); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Events/DeleteOrderSuccEvent.php: -------------------------------------------------------------------------------- 1 | order = $order; 25 | } 26 | 27 | /** 28 | * Get the channels the event should broadcast on. 29 | * 30 | * @return \Illuminate\Broadcasting\Channel|array 31 | */ 32 | public function broadcastOn() 33 | { 34 | return new PrivateChannel('channel-name'); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Events/MatchEvent.php: -------------------------------------------------------------------------------- 1 | order = $order; 28 | $this->match_order = $match_order; 29 | $this->volume = $volume; 30 | } 31 | 32 | /** 33 | * Get the channels the event should broadcast on. 34 | * 35 | * @return \Illuminate\Broadcasting\Channel|array 36 | */ 37 | public function broadcastOn() 38 | { 39 | return new PrivateChannel('channel-name'); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Events/PushQueueEvent.php: -------------------------------------------------------------------------------- 1 | order = $order; 25 | } 26 | 27 | /** 28 | * Get the channels the event should broadcast on. 29 | * 30 | * @return \Illuminate\Broadcasting\Channel|array 31 | */ 32 | public function broadcastOn() 33 | { 34 | return new PrivateChannel('channel-name'); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Exceptions/DeleteOrderException.php: -------------------------------------------------------------------------------- 1 | order = $order; 28 | } 29 | 30 | /** 31 | * Execute the job. 32 | */ 33 | public function handle(CommissionPoolService $service) 34 | { 35 | $service->deletePoolOrder($this->order); 36 | 37 | return true; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Jobs/PushQueueJob.php: -------------------------------------------------------------------------------- 1 | order = $order; 28 | } 29 | 30 | /** 31 | * Execute the job. 32 | */ 33 | public function handle(CommissionPoolService $service) 34 | { 35 | $service->pushPool($this->order); 36 | 37 | return true; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Listeners/DeleteOrderEventListener.php: -------------------------------------------------------------------------------- 1 | service = $service; 18 | } 19 | 20 | /** 21 | * Handle the event. 22 | * 23 | * @param DeleteOrderEvent $event 24 | * @return bool 25 | */ 26 | public function handle(DeleteOrderEvent $event) 27 | { 28 | $this->service->deletePoolOrder($event->order); 29 | 30 | return true; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Listeners/PushQueueEventListener.php: -------------------------------------------------------------------------------- 1 | service = $service; 18 | } 19 | 20 | /** 21 | * Handle the event. 22 | */ 23 | public function handle(PushQueueEvent $event): bool 24 | { 25 | $this->service->pushPool($event->order); 26 | 27 | return true; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Mengine.php: -------------------------------------------------------------------------------- 1 | app->singleton('mengine', function () { 15 | return $this->app->make('StingBo\Mengine\Services\MengineService'); 16 | }); 17 | } 18 | 19 | /** 20 | * Bootstrap services. 21 | */ 22 | public function boot() 23 | { 24 | $this->publishes([ 25 | __DIR__.'/../config/mengine.php' => config_path('mengine.php'), 26 | ]); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Services/CommissionPoolService.php: -------------------------------------------------------------------------------- 1 | isHashDeleted($order)) { 20 | return false; 21 | } 22 | 23 | $ms_service->deleteHashOrder($order); 24 | $list = $ms_service->getMutexDepth($order->symbol, $order->transaction, $order->price); 25 | if ($list) { 26 | $order = $this->matchUp($order, $list); // 撮合 27 | if (!$order) { 28 | return false; 29 | } 30 | } 31 | 32 | // 深度列表、数量更新、节点更新 33 | $depth_link = new DepthLinkService(); 34 | $depth_link->pushZset($order); 35 | 36 | $depth_link->pushDepthHash($order); 37 | 38 | $depth_link->pushDepthNode($order); 39 | 40 | event(new PushQueueEvent($order)); 41 | 42 | return true; 43 | } 44 | 45 | /** 46 | * 撤单从委托池删除. 47 | */ 48 | public function deletePoolOrder(Order $order): bool 49 | { 50 | $link_service = new LinkService($order->node_link); 51 | $node = $link_service->getNode($order->node); 52 | if (!$node) { 53 | return false; 54 | } 55 | // order里的volume替换为缓存里节点上的数量,防止order里的数量与当初push的不一致或者部分成交 56 | $order->volume = $node->volume; 57 | 58 | // 更新委托量 59 | $depth_link = new DepthLinkService(); 60 | $depth_link->deleteDepthHash($order); 61 | 62 | // 从深度列表里删除 63 | $depth_link->deleteZset($order); 64 | 65 | // 从节点链上删除 66 | $depth_link->deleteDepthNode($order); 67 | 68 | // 撤单成功通知 69 | event(new DeleteOrderSuccEvent($order)); 70 | 71 | return true; 72 | } 73 | 74 | /** 75 | * 撮合. 76 | * 77 | * @param Order $order 下单 78 | * @param array $list 价格匹配部分 79 | * 80 | * @return null|Order 81 | */ 82 | public function matchUp(Order $order, array $list): ?Order 83 | { 84 | // 撮合 85 | foreach ($list as $match_info) { 86 | $link_name = $order->symbol.':link:'.$match_info['price']; 87 | $link_service = new LinkService($link_name); 88 | 89 | $order = $this->matchOrder($order, $link_service); 90 | if ($order->volume <= 0) { 91 | break; 92 | } 93 | } 94 | 95 | if ($order->volume > 0) { 96 | return $order; 97 | } 98 | 99 | return null; 100 | } 101 | 102 | public function matchOrder($order, $link_service) 103 | { 104 | $match_order = $link_service->getFirst(); 105 | if ($match_order) { 106 | $compare_result = bccomp($order->volume, $match_order->volume); 107 | switch ($compare_result) { 108 | case 1: 109 | $match_volume = $match_order->volume; 110 | $order->volume = bcsub($order->volume, $match_order->volume); 111 | $link_service->deleteNode($match_order); 112 | $this->deletePoolMatchOrder($match_order); 113 | 114 | // 撮合成功通知 115 | event(new MatchEvent($order, $match_order, $match_volume)); 116 | 117 | // 递归撮合 118 | $this->matchOrder($order, $link_service); 119 | break; 120 | case 0: 121 | $match_volume = $match_order->volume; 122 | $order->volume = bcsub($order->volume, $match_order->volume); 123 | $link_service->deleteNode($match_order); 124 | $this->deletePoolMatchOrder($match_order); 125 | 126 | // 撮合成功通知 127 | event(new MatchEvent($order, $match_order, $match_volume)); 128 | break; 129 | case -1: 130 | $match_volume = $order->volume; 131 | $match_order->volume = bcsub($match_order->volume, $order->volume); 132 | $order->volume = 0; 133 | $link_service->setNode($match_order->node, $match_order); 134 | 135 | // 委托池更新数量重新设置 136 | $match_order->volume = $match_volume; 137 | $this->deletePoolMatchOrder($match_order); 138 | 139 | // 撮合成功通知 140 | event(new MatchEvent($order, $match_order, $match_volume)); 141 | break; 142 | default: 143 | break; 144 | } 145 | 146 | return $order; 147 | } 148 | 149 | return $order; 150 | } 151 | 152 | /** 153 | * 撮合成交更新委托池. 154 | */ 155 | public function deletePoolMatchOrder($order) 156 | { 157 | $depth_link = new DepthLinkService(); 158 | 159 | // 更新委托量 160 | $depth_link->deleteDepthHash($order); 161 | 162 | // 从深度列表里删除 163 | $depth_link->deleteZset($order); 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /src/Services/DepthLinkService.php: -------------------------------------------------------------------------------- 1 | node_link); 17 | 18 | // 不存在则初始化 19 | $first = $link_service->getFirst(); 20 | if (!$first) { 21 | $link_service->init($order); 22 | 23 | return true; 24 | } 25 | $last = $link_service->getLast(); 26 | if (!$last) { 27 | throw new InvalidArgumentException(__METHOD__.' expects last node is not empty.'); 28 | } 29 | 30 | $link_service->setLast($order); 31 | 32 | return true; 33 | } 34 | 35 | /** 36 | * 从价格点对应的单据里删除. 37 | */ 38 | public function deleteDepthNode(Order $order): bool 39 | { 40 | $link_service = new LinkService($order->node_link); 41 | $order = $link_service->getCurrent($order->node); 42 | if (!$order) { 43 | return false; 44 | } 45 | 46 | $link_service->deleteNode($order); 47 | 48 | return true; 49 | } 50 | 51 | /** 52 | * 放入委托量hash. 53 | */ 54 | public function pushDepthHash(Order $order) 55 | { 56 | Redis::hincrby($order->order_depth_hash_key, $order->order_depth_hash_field, $order->volume); 57 | } 58 | 59 | /** 60 | * 从委托量hash里删除. 61 | */ 62 | public function deleteDepthHash(Order $order) 63 | { 64 | Redis::hincrby($order->order_depth_hash_key, $order->order_depth_hash_field, bcmul(-1, $order->volume)); 65 | } 66 | 67 | /** 68 | * 放入深度池. 69 | */ 70 | public function pushZset(Order $order) 71 | { 72 | Redis::zadd($order->order_list_zset_key, $order->price, $order->price); 73 | } 74 | 75 | /** 76 | * 从深度池删除. 77 | */ 78 | public function deleteZset(Order $order) 79 | { 80 | // 判断对应的委托量,如果没有了则从深度列表里删除 81 | $volume = Redis::hget($order->order_depth_hash_key, $order->order_depth_hash_field); 82 | if ($volume <= 0) { 83 | Redis::zrem($order->order_list_zset_key, $order->price); 84 | } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/Services/LinkService.php: -------------------------------------------------------------------------------- 1 | link = $link_name; 20 | } 21 | 22 | /** 23 | * 第一个价位单初始化节点. 24 | */ 25 | public function init($order) 26 | { 27 | $order->is_first = true; 28 | $order->is_last = true; 29 | 30 | $this->setFirstPointer($order->node); 31 | $this->setLastPointer($order->node); 32 | $this->setNode($order->node, $order); 33 | } 34 | 35 | /** 36 | * 获取价位链上第一个单据. 37 | */ 38 | public function getFirst() 39 | { 40 | $first = $this->getNode('first'); 41 | if (!$first) { 42 | return false; 43 | } 44 | 45 | $node = $this->getNode($first); 46 | if (!$node) { 47 | return false; 48 | } 49 | 50 | return $this->current = $node; 51 | } 52 | 53 | /** 54 | * 设置起始指针. 55 | */ 56 | public function setFirstPointer($node_name) 57 | { 58 | return $this->setNode('first', $node_name); 59 | } 60 | 61 | /** 62 | * 获取价位链上最后一个单据. 63 | */ 64 | public function getLast() 65 | { 66 | $last = $this->getNode('last'); 67 | if (!$last) { 68 | return false; 69 | } 70 | 71 | $node = $this->getNode($last); 72 | if (!$node) { 73 | return false; 74 | } 75 | 76 | return $this->current = $node; 77 | } 78 | 79 | /** 80 | * 设置结束指针. 81 | */ 82 | public function setLastPointer($node_name) 83 | { 84 | return $this->setNode('last', $node_name); 85 | } 86 | 87 | public function getCurrent($field = '') 88 | { 89 | if ($field) { 90 | $node = $this->getNode($field); 91 | if (!$node) { 92 | return false; 93 | } 94 | 95 | return $this->current = $node; 96 | } 97 | 98 | return $this->current ?? false; 99 | } 100 | 101 | /** 102 | * 获取当前单据前一个单据. 103 | */ 104 | public function getPrev() 105 | { 106 | $field = $this->current->prev_node; 107 | if (!$field) { 108 | return false; 109 | } 110 | 111 | $node = $this->getNode($field); 112 | if (!$node) { 113 | return false; 114 | } 115 | 116 | return $this->current = $node; 117 | } 118 | 119 | /** 120 | * 获取当前单据后一个单据. 121 | */ 122 | public function getNext() 123 | { 124 | $field = $this->current->next_node; 125 | if (!$field) { 126 | return false; 127 | } 128 | 129 | $node = $this->getNode($field); 130 | if (!$node) { 131 | return false; 132 | } 133 | 134 | return $this->current = $node; 135 | } 136 | 137 | public function setLast($order) 138 | { 139 | $this->getLast(); 140 | $this->current->is_last = false; 141 | $this->current->next_node = $order->node; 142 | $this->setNode($this->current->node, $this->current); 143 | 144 | $order->prev_node = $this->current->node; 145 | $this->setLastPointer($order->node); 146 | 147 | $order->is_last = true; 148 | $this->setNode($order->node, $order); 149 | } 150 | 151 | /** 152 | * 根据field获取某个节点. 153 | */ 154 | public function getNode($field) 155 | { 156 | $node = Redis::hget($this->link, $field); 157 | 158 | if ($node) { 159 | return unserialize($node); 160 | } 161 | 162 | return null; 163 | } 164 | 165 | /** 166 | * 设置/更新某个field. 167 | */ 168 | public function setNode($field, $order) 169 | { 170 | return Redis::hset($this->link, $field, serialize($order)); 171 | } 172 | 173 | /** 174 | * 删除某个field. 175 | */ 176 | public function deleteNode($order) 177 | { 178 | if ($order->is_first && $order->is_last) { // 只有一个节点则全删除 179 | Redis::hdel($this->link, 'first'); 180 | Redis::hdel($this->link, 'last'); 181 | Redis::hdel($this->link, $order->node); 182 | } elseif ($order->is_first) { // 首节点 183 | $next = $this->getNext(); 184 | if (!$next) { 185 | throw new InvalidParamException(__METHOD__.' expects next node is not empty.'); 186 | } 187 | Redis::hdel($this->link, $order->node); 188 | 189 | $next->is_first = true; 190 | $next->prev_node = null; 191 | $this->setFirstPointer($next->node); 192 | $this->setNode($next->node, $next); 193 | } elseif ($order->is_last) { // 尾结点 194 | $prev = $this->getPrev(); 195 | if (!$prev) { 196 | throw new InvalidParamException(__METHOD__.' expects prev node is not empty.'); 197 | } 198 | Redis::hdel($this->link, $order->node); 199 | 200 | $prev->is_last = true; 201 | $prev->next_node = null; 202 | $this->setLastPointer($prev->node); 203 | $this->setNode($prev->node, $prev); 204 | } else { // 中间结点 205 | $prev = $this->getNode($order->prev_node); 206 | $next = $this->getNode($order->next_node); 207 | if (!$prev || !$next) { 208 | throw new InvalidParamException(__METHOD__.' expects relation node is not empty.'); 209 | } 210 | $prev->next_node = $next->node; 211 | $next->prev_node = $prev->node; 212 | 213 | Redis::hdel($this->link, $order->node); 214 | $this->setNode($next->node, $next); 215 | $this->setNode($prev->node, $prev); 216 | } 217 | } 218 | } 219 | -------------------------------------------------------------------------------- /src/Services/MengineService.php: -------------------------------------------------------------------------------- 1 | pushHash($order); 21 | 22 | // 2. 入委托队列 23 | dispatch((new PushQueueJob($order))->allOnQueue($order->symbol)); 24 | } 25 | 26 | /** 27 | * 放入hash标识池. 28 | */ 29 | public function pushHash(Order $order) 30 | { 31 | Redis::hset($order->order_hash_key, $order->order_hash_field, 1); 32 | } 33 | 34 | /** 35 | * 从hash标识池判断委托是否已经删除. 36 | */ 37 | public function isHashDeleted(Order $order): bool 38 | { 39 | if (Redis::hexists($order->order_hash_key, $order->order_hash_field)) { 40 | return false; 41 | } 42 | 43 | return true; 44 | } 45 | 46 | /** 47 | * 从hash标识池删除. 48 | */ 49 | public function deleteOrder(Order $order) 50 | { 51 | // 第一步,从标识池删除,避免队列有积压时未消费问题 52 | $this->deleteHashOrder($order); 53 | 54 | $link_service = new LinkService($order->node_link); 55 | $node = $link_service->getNode($order->node); 56 | if (!$node) { 57 | throw new DeleteOrderException('order not exist', DeleteOrderException::NOT_EXIST); 58 | } 59 | if ($node->uuid != $order->uuid) { 60 | throw new DeleteOrderException('order message not match', DeleteOrderException::NOT_MATCH); 61 | } 62 | if ($node->symbol != $order->symbol) { 63 | throw new DeleteOrderException('order message not match', DeleteOrderException::NOT_MATCH); 64 | } 65 | if ($node->transaction != $order->transaction) { 66 | throw new DeleteOrderException('order message not match', DeleteOrderException::NOT_MATCH); 67 | } 68 | 69 | // 第二步,从委托池里删除 70 | dispatch((new DeleteOrderJob($order))->allOnQueue($order->symbol)); 71 | } 72 | 73 | /** 74 | * 从标识池删除. 75 | */ 76 | public function deleteHashOrder(Order $order) 77 | { 78 | if (!$this->isHashDeleted($order)) { 79 | Redis::hdel($order->order_hash_key, $order->order_hash_field); 80 | } 81 | } 82 | 83 | /** 84 | * 获取深度列表. 85 | */ 86 | public function getDepth($symbol, $transaction): array 87 | { 88 | $depths = []; 89 | if (config('mengine.mengine.transaction')[0] == $transaction) { 90 | $prices = Redis::zrevrange($symbol.':'.$transaction, 0, -1); 91 | foreach ($prices as $key => $price) { 92 | $volume = Redis::hget($symbol.':depth', $symbol.':depth:'.$price); 93 | $depths[$key] = [ 94 | 'price' => $price, 95 | 'volume' => $volume, 96 | ]; 97 | } 98 | } elseif (config('mengine.mengine.transaction')[1] == $transaction) { 99 | $prices = Redis::zrange($symbol.':'.$transaction, 0, -1); 100 | foreach ($prices as $key => $price) { 101 | $volume = Redis::hget($symbol.':depth', $symbol.':depth:'.$price); 102 | $depths[$key] = [ 103 | 'price' => $price, 104 | 'volume' => $volume, 105 | ]; 106 | } 107 | } 108 | 109 | return $depths; 110 | } 111 | 112 | /** 113 | * 获取反向深度列表. 114 | */ 115 | public function getMutexDepth($symbol, $transaction, $price): array 116 | { 117 | $depths = []; 118 | if (config('mengine.mengine.transaction')[0] == $transaction) { 119 | $transaction = config('mengine.mengine.transaction')[1]; 120 | $prices = Redis::zRangeByScore($symbol.':'.$transaction, '-inf', $price); 121 | foreach ($prices as $key => $price) { 122 | $volume = Redis::hget($symbol.':depth', $symbol.':depth:'.$price); 123 | $depths[$key] = [ 124 | 'price' => $price, 125 | 'volume' => $volume, 126 | ]; 127 | } 128 | } elseif (config('mengine.mengine.transaction')[1] == $transaction) { 129 | $transaction = config('mengine.mengine.transaction')[0]; 130 | $prices = Redis::zRevRangeByScore($symbol.':'.$transaction, '+inf', $price); 131 | foreach ($prices as $key => $price) { 132 | $volume = Redis::hget($symbol.':depth', $symbol.':depth:'.$price); 133 | $depths[$key] = [ 134 | 'price' => $price, 135 | 'volume' => $volume, 136 | ]; 137 | } 138 | } 139 | 140 | return $depths; 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /src/Services/OrderService.php: -------------------------------------------------------------------------------- 1 |