nodejs-order-book 10.1.1 → 10.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +309 -159
- package/dist/cjs/orderbook.js +90 -34
- package/dist/esm/orderbook.js +90 -34
- package/dist/types/orderbook.d.ts +28 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -10,56 +10,75 @@
|
|
|
10
10
|
# Node.js Order Book
|
|
11
11
|
|
|
12
12
|
<p align="center">
|
|
13
|
-
|
|
13
|
+
A fast, feature-complete limit order book engine for Node.js, written in TypeScript. </br>
|
|
14
|
+
Designed for trading systems, exchanges, and HFT simulations. </br></br>
|
|
14
15
|
:star: Star me on GitHub — it motivates me a lot!
|
|
15
16
|
</p>
|
|
16
17
|
|
|
18
|
+
**Why this library?** Originally ported from a [Go orderbook](https://github.com/i25959341/orderbook), this engine has been extended with conditional orders, Self-Trade Prevention (STP), snapshot/journaling for crash recovery, and full TypeScript support — while maintaining high throughput.
|
|
19
|
+
|
|
17
20
|
## Table of Contents
|
|
18
21
|
|
|
19
22
|
- [Features](#features)
|
|
23
|
+
- [Quick Start](#quick-start)
|
|
24
|
+
- [Requirements](#requirements)
|
|
20
25
|
- [Installation](#installation)
|
|
21
26
|
- [Usage](#usage)
|
|
22
27
|
- [Conditional Orders](#conditional-orders)
|
|
23
|
-
- [
|
|
24
|
-
- [
|
|
25
|
-
- [
|
|
26
|
-
- [
|
|
27
|
-
- [
|
|
28
|
-
- [
|
|
29
|
-
- [
|
|
30
|
-
- [
|
|
31
|
-
- [
|
|
28
|
+
- [Primary Functions](#primary-functions)
|
|
29
|
+
- [createOrder()](#createorder)
|
|
30
|
+
- [limit()](#limit)
|
|
31
|
+
- [market()](#market)
|
|
32
|
+
- [stopLimit()](#stoplimit)
|
|
33
|
+
- [stopMarket()](#stopmarket)
|
|
34
|
+
- [oco()](#oco)
|
|
35
|
+
- [modify()](#modify)
|
|
36
|
+
- [cancel()](#cancel)
|
|
37
|
+
- [Understanding Order Results](#understanding-order-results)
|
|
32
38
|
- [Self-Trade Prevention (STP)](#self-trade-prevention-stp)
|
|
33
39
|
- [Order Book Options](#order-book-options)
|
|
34
40
|
- [Snapshot](#snapshot)
|
|
35
41
|
- [Journal Logs](#journal-logs)
|
|
36
42
|
- [Enable Journaling](#enable-journaling)
|
|
37
43
|
- [Development](#development)
|
|
38
|
-
- [Build](#build)
|
|
39
|
-
- [Testing](#testing)
|
|
40
|
-
- [Coverage](#coverage)
|
|
41
|
-
- [Benchmarking](#benchmarking)
|
|
42
44
|
- [Contributing](#contributing)
|
|
43
|
-
- [Donation](#donation)
|
|
44
45
|
- [License](#license)
|
|
46
|
+
- [Donation](#donation)
|
|
45
47
|
|
|
46
48
|
## Features
|
|
47
|
-
> Initially ported from [Go orderbook](https://github.com/i25959341/orderbook), this order book has been enhanced with new features
|
|
48
49
|
|
|
49
|
-
- Standard price-time priority
|
|
50
|
-
-
|
|
51
|
-
-
|
|
52
|
-
-
|
|
53
|
-
-
|
|
54
|
-
-
|
|
55
|
-
-
|
|
56
|
-
-
|
|
57
|
-
-
|
|
58
|
-
-
|
|
50
|
+
- Standard price-time priority matching
|
|
51
|
+
- Market, limit, and post-only limit orders
|
|
52
|
+
- Conditional orders: Stop Limit, Stop Market, and OCO (One-Cancels-the-Other)
|
|
53
|
+
- Time-in-force: GTC (Good-Til-Cancelled), FOK (Fill-Or-Kill), IOC (Immediate-Or-Cancel)
|
|
54
|
+
- Self-Trade Prevention (STP) with 4 modes (NONE, EXPIRE_MAKER, EXPIRE_TAKER, EXPIRE_BOTH)
|
|
55
|
+
- Order cancellation
|
|
56
|
+
- Order price and/or size modification
|
|
57
|
+
- Snapshot and journaling for order book state persistence and recovery
|
|
58
|
+
- **High throughput** — benchmarked at 300k+ trades per second
|
|
59
|
+
- Full TypeScript support with dual ESM/CJS exports
|
|
60
|
+
|
|
61
|
+
## Quick Start
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
import { OrderBook, Side } from 'nodejs-order-book'
|
|
65
|
+
|
|
66
|
+
const ob = new OrderBook()
|
|
67
|
+
|
|
68
|
+
// Place a sell limit order
|
|
69
|
+
ob.limit({ side: Side.SELL, id: 'order-1', size: 55, price: 100 })
|
|
70
|
+
|
|
71
|
+
// Place a buy market order
|
|
72
|
+
const result = ob.market({ side: Side.BUY, size: 10 })
|
|
73
|
+
|
|
74
|
+
console.log(result.done) // Filled orders
|
|
75
|
+
console.log(result.partial) // Partial fill, if any
|
|
76
|
+
```
|
|
59
77
|
|
|
60
|
-
|
|
78
|
+
## Requirements
|
|
61
79
|
|
|
62
|
-
|
|
80
|
+
- **Node.js** 18+ (ES2022 target)
|
|
81
|
+
- **npm**, **yarn**, or **pnpm**
|
|
63
82
|
|
|
64
83
|
## Installation
|
|
65
84
|
|
|
@@ -83,7 +102,17 @@ pnpm add nodejs-order-book
|
|
|
83
102
|
|
|
84
103
|
## Usage
|
|
85
104
|
|
|
86
|
-
|
|
105
|
+
The package supports both **ESM** and **CommonJS**:
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
// ESM (recommended)
|
|
109
|
+
import { OrderBook, Side, OrderType, SelfTradePreventionMode } from 'nodejs-order-book'
|
|
110
|
+
|
|
111
|
+
// CommonJS
|
|
112
|
+
const { OrderBook, Side, OrderType, SelfTradePreventionMode } = require('nodejs-order-book')
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
To start using the order book you need to import `OrderBook` and create a new instance:
|
|
87
116
|
|
|
88
117
|
```ts
|
|
89
118
|
import { OrderBook } from 'nodejs-order-book'
|
|
@@ -91,7 +120,7 @@ import { OrderBook } from 'nodejs-order-book'
|
|
|
91
120
|
const ob = new OrderBook()
|
|
92
121
|
```
|
|
93
122
|
|
|
94
|
-
Then you'll be able to use
|
|
123
|
+
Then you'll be able to use the following primary functions:
|
|
95
124
|
|
|
96
125
|
```ts
|
|
97
126
|
ob.createOrder({
|
|
@@ -115,7 +144,7 @@ ob.limit({
|
|
|
115
144
|
|
|
116
145
|
ob.market({ side: 'buy' | 'sell', size: number })
|
|
117
146
|
|
|
118
|
-
ob.modify(orderID: string, {
|
|
147
|
+
ob.modify(orderID: string, {
|
|
119
148
|
side: 'buy' | 'sell',
|
|
120
149
|
size: number,
|
|
121
150
|
price: number
|
|
@@ -123,14 +152,17 @@ ob.modify(orderID: string, {
|
|
|
123
152
|
|
|
124
153
|
ob.cancel(orderID: string)
|
|
125
154
|
```
|
|
155
|
+
|
|
126
156
|
### Conditional Orders
|
|
127
|
-
|
|
157
|
+
|
|
158
|
+
`Stop Market`, `Stop Limit` and `OCO` orders are supported.
|
|
159
|
+
|
|
128
160
|
```ts
|
|
129
161
|
import { OrderBook } from 'nodejs-order-book'
|
|
130
162
|
|
|
131
163
|
const ob = new OrderBook()
|
|
132
164
|
|
|
133
|
-
ob.createOrder({
|
|
165
|
+
ob.createOrder({
|
|
134
166
|
type: 'stop_limit' | 'stop_market' | 'oco',
|
|
135
167
|
side: 'buy' | 'sell',
|
|
136
168
|
size: number,
|
|
@@ -168,14 +200,16 @@ ob.oco({
|
|
|
168
200
|
})
|
|
169
201
|
```
|
|
170
202
|
|
|
171
|
-
##
|
|
203
|
+
## Primary Functions
|
|
172
204
|
|
|
173
|
-
To add an order to the order book you can call the general `createOrder()` function or
|
|
205
|
+
To add an order to the order book you can call the general `createOrder()` function or use the underlying `limit()`, `market()`, `stopLimit()`, `stopMarket()` or `oco()` directly.
|
|
174
206
|
|
|
175
|
-
###
|
|
207
|
+
### createOrder()
|
|
208
|
+
|
|
209
|
+
A unified entry point that accepts a `type` field to dispatch to the correct handler:
|
|
176
210
|
|
|
177
211
|
```ts
|
|
178
|
-
//
|
|
212
|
+
// Limit order
|
|
179
213
|
ob.createOrder({
|
|
180
214
|
type: 'limit',
|
|
181
215
|
side: 'buy' | 'sell',
|
|
@@ -186,14 +220,14 @@ ob.createOrder({
|
|
|
186
220
|
timeInForce?: 'GTC' | 'FOK' | 'IOC'
|
|
187
221
|
})
|
|
188
222
|
|
|
189
|
-
//
|
|
223
|
+
// Market order
|
|
190
224
|
ob.createOrder({
|
|
191
225
|
type: 'market',
|
|
192
226
|
side: 'buy' | 'sell',
|
|
193
227
|
size: number
|
|
194
228
|
})
|
|
195
229
|
|
|
196
|
-
//
|
|
230
|
+
// Stop limit order
|
|
197
231
|
ob.createOrder({
|
|
198
232
|
type: 'stop_limit',
|
|
199
233
|
side: 'buy' | 'sell',
|
|
@@ -204,7 +238,7 @@ ob.createOrder({
|
|
|
204
238
|
timeInForce?: 'GTC' | 'FOK' | 'IOC'
|
|
205
239
|
})
|
|
206
240
|
|
|
207
|
-
//
|
|
241
|
+
// Stop market order
|
|
208
242
|
ob.createOrder({
|
|
209
243
|
type: 'stop_market',
|
|
210
244
|
side: 'buy' | 'sell',
|
|
@@ -212,7 +246,7 @@ ob.createOrder({
|
|
|
212
246
|
stopPrice: number
|
|
213
247
|
})
|
|
214
248
|
|
|
215
|
-
//
|
|
249
|
+
// OCO order
|
|
216
250
|
ob.createOrder({
|
|
217
251
|
type: 'oco',
|
|
218
252
|
side: 'buy' | 'sell',
|
|
@@ -224,20 +258,19 @@ ob.createOrder({
|
|
|
224
258
|
})
|
|
225
259
|
```
|
|
226
260
|
|
|
227
|
-
###
|
|
261
|
+
### limit()
|
|
262
|
+
|
|
263
|
+
Create a limit order.
|
|
228
264
|
|
|
229
265
|
```ts
|
|
230
266
|
/**
|
|
231
|
-
* Create a limit order. See {@link LimitOrderOptions} for details.
|
|
232
|
-
*
|
|
233
|
-
* @param options
|
|
234
267
|
* @param options.side - `sell` or `buy`
|
|
235
268
|
* @param options.id - Unique order ID
|
|
236
269
|
* @param options.size - How much of currency you want to trade in units of base currency
|
|
237
|
-
* @param options.price - The price at which the order is to be
|
|
238
|
-
* @param options.postOnly - When `true` the order
|
|
239
|
-
* @param options.timeInForce -
|
|
240
|
-
* @returns An object with the result of the processed order or an error.
|
|
270
|
+
* @param options.price - The price at which the order is to be fulfilled, in units of the quote currency
|
|
271
|
+
* @param options.postOnly - When `true` the order is rejected if it immediately matches as a taker. Default is `false`
|
|
272
|
+
* @param options.timeInForce - GTC, FOK, or IOC. Default is GTC
|
|
273
|
+
* @returns An object with the result of the processed order or an error.
|
|
241
274
|
*/
|
|
242
275
|
ob.limit({
|
|
243
276
|
side: 'buy' | 'sell',
|
|
@@ -291,16 +324,15 @@ done - 1 order with 100 price, (may be also few orders with 110 price) + uniq
|
|
|
291
324
|
partial - 1 order with price 110
|
|
292
325
|
```
|
|
293
326
|
|
|
294
|
-
###
|
|
327
|
+
### market()
|
|
328
|
+
|
|
329
|
+
Create a market order.
|
|
295
330
|
|
|
296
331
|
```ts
|
|
297
332
|
/**
|
|
298
|
-
* Create a market order. See {@link MarketOrderOptions} for details.
|
|
299
|
-
*
|
|
300
|
-
* @param options
|
|
301
333
|
* @param options.side - `sell` or `buy`
|
|
302
334
|
* @param options.size - How much of currency you want to trade in units of base currency
|
|
303
|
-
* @returns An object with the result of the processed order or an error.
|
|
335
|
+
* @returns An object with the result of the processed order or an error.
|
|
304
336
|
*/
|
|
305
337
|
ob.market({ side: 'buy' | 'sell', size: number })
|
|
306
338
|
```
|
|
@@ -335,20 +367,19 @@ partial - null
|
|
|
335
367
|
quantityLeft - 4
|
|
336
368
|
```
|
|
337
369
|
|
|
338
|
-
###
|
|
370
|
+
### stopLimit()
|
|
371
|
+
|
|
372
|
+
Create a stop limit order.
|
|
339
373
|
|
|
340
374
|
```ts
|
|
341
375
|
/**
|
|
342
|
-
* Create a stop limit order. See {@link StopLimitOrderOptions} for details.
|
|
343
|
-
*
|
|
344
|
-
* @param options
|
|
345
376
|
* @param options.side - `sell` or `buy`
|
|
346
377
|
* @param options.id - Unique order ID
|
|
347
378
|
* @param options.size - How much of currency you want to trade in units of base currency
|
|
348
|
-
* @param options.price - The price at which the order is to be
|
|
349
|
-
* @param options.stopPrice - The price at which the order
|
|
350
|
-
* @param options.timeInForce -
|
|
351
|
-
* @returns An object with the result of the processed order or an error.
|
|
379
|
+
* @param options.price - The price at which the order is to be fulfilled, in units of the quote currency
|
|
380
|
+
* @param options.stopPrice - The price at which the order is triggered
|
|
381
|
+
* @param options.timeInForce - GTC, FOK, or IOC. Default is GTC
|
|
382
|
+
* @returns An object with the result of the processed order or an error.
|
|
352
383
|
*/
|
|
353
384
|
ob.stopLimit({
|
|
354
385
|
side: 'buy' | 'sell',
|
|
@@ -360,17 +391,16 @@ ob.stopLimit({
|
|
|
360
391
|
})
|
|
361
392
|
```
|
|
362
393
|
|
|
363
|
-
###
|
|
394
|
+
### stopMarket()
|
|
395
|
+
|
|
396
|
+
Create a stop market order.
|
|
364
397
|
|
|
365
398
|
```ts
|
|
366
399
|
/**
|
|
367
|
-
* Create a stop market order. See {@link StopMarketOrderOptions} for details.
|
|
368
|
-
*
|
|
369
|
-
* @param options
|
|
370
400
|
* @param options.side - `sell` or `buy`
|
|
371
401
|
* @param options.size - How much of currency you want to trade in units of base currency
|
|
372
|
-
* @param options.stopPrice - The price at which the order
|
|
373
|
-
* @returns An object with the result of the processed order or an error.
|
|
402
|
+
* @param options.stopPrice - The price at which the order is triggered
|
|
403
|
+
* @returns An object with the result of the processed order or an error.
|
|
374
404
|
*/
|
|
375
405
|
ob.stopMarket({
|
|
376
406
|
side: 'buy' | 'sell',
|
|
@@ -379,31 +409,24 @@ ob.stopMarket({
|
|
|
379
409
|
})
|
|
380
410
|
```
|
|
381
411
|
|
|
382
|
-
###
|
|
412
|
+
### oco()
|
|
413
|
+
|
|
414
|
+
Create an OCO (One-Cancels-the-Other) order. An OCO combines a `stop_limit` and a `limit` order: when one is triggered or filled, the other is automatically canceled. Both orders share the same `side` and `size`. If you cancel one, the entire OCO pair is canceled.
|
|
415
|
+
|
|
416
|
+
For BUY orders: `stopPrice` must be above the current price, `price` below.
|
|
417
|
+
For SELL orders: `stopPrice` must be below the current price, `price` above.
|
|
383
418
|
|
|
384
419
|
```ts
|
|
385
420
|
/**
|
|
386
|
-
* Create an OCO (One-Cancels-the-Other) order.
|
|
387
|
-
* OCO order combines a `stop_limit` order and a `limit` order, where if stop price
|
|
388
|
-
* is triggered or limit order is fully or partially fulfilled, the other is canceled.
|
|
389
|
-
* Both orders have the same `side` and `size`. If you cancel one of the orders, the
|
|
390
|
-
* entire OCO order pair will be canceled.
|
|
391
|
-
*
|
|
392
|
-
* For BUY orders the `stopPrice` must be above the current price and the `price` below the current price
|
|
393
|
-
* For SELL orders the `stopPrice` must be below the current price and the `price` above the current price
|
|
394
|
-
*
|
|
395
|
-
* See {@link OCOOrderOptions} for details.
|
|
396
|
-
*
|
|
397
|
-
* @param options
|
|
398
421
|
* @param options.side - `sell` or `buy`
|
|
399
422
|
* @param options.id - Unique order ID
|
|
400
423
|
* @param options.size - How much of currency you want to trade in units of base currency
|
|
401
|
-
* @param options.price - The
|
|
402
|
-
* @param options.stopPrice - The
|
|
403
|
-
* @param options.stopLimitPrice - The
|
|
404
|
-
* @param options.timeInForce - Time-in-force of the
|
|
405
|
-
* @param options.stopLimitTimeInForce - Time-in-force of the
|
|
406
|
-
* @returns An object with the result of the processed order or an error.
|
|
424
|
+
* @param options.price - The limit order price, in units of the quote currency
|
|
425
|
+
* @param options.stopPrice - The stop trigger price
|
|
426
|
+
* @param options.stopLimitPrice - The stop_limit order price, in units of the quote currency
|
|
427
|
+
* @param options.timeInForce - Time-in-force of the limit order. GTC, FOK, IOC. Default is GTC
|
|
428
|
+
* @param options.stopLimitTimeInForce - Time-in-force of the stop_limit order. GTC, FOK, IOC. Default is GTC
|
|
429
|
+
* @returns An object with the result of the processed order or an error.
|
|
407
430
|
*/
|
|
408
431
|
ob.oco({
|
|
409
432
|
side: 'buy' | 'sell',
|
|
@@ -417,18 +440,15 @@ ob.oco({
|
|
|
417
440
|
})
|
|
418
441
|
```
|
|
419
442
|
|
|
420
|
-
###
|
|
443
|
+
### modify()
|
|
444
|
+
|
|
445
|
+
Modify an existing order by ID. When an order is modified (price or quantity), it is treated as a new entry: under price-time-priority, it moves to the back of the matching queue.
|
|
421
446
|
|
|
422
447
|
```ts
|
|
423
448
|
/**
|
|
424
|
-
*
|
|
425
|
-
*
|
|
426
|
-
*
|
|
427
|
-
* will be placed at the back of the matching order queue.
|
|
428
|
-
*
|
|
429
|
-
* @param orderID - The ID of the order to be modified
|
|
430
|
-
* @param orderUpdate - An object with the modified size and/or price of an order. The shape of the object is `{size, price}`.
|
|
431
|
-
* @returns An object with the result of the processed order or an error
|
|
449
|
+
* @param orderID - The ID of the order to modify
|
|
450
|
+
* @param orderUpdate - An object with `{size, price}`. Only provided fields are updated
|
|
451
|
+
* @returns An object with the result or an error
|
|
432
452
|
*/
|
|
433
453
|
ob.modify(orderID: string, { size: number, price: number })
|
|
434
454
|
```
|
|
@@ -464,14 +484,14 @@ bids: 90 -> 5 90 -> 5
|
|
|
464
484
|
80 -> 1 80 -> 1
|
|
465
485
|
```
|
|
466
486
|
|
|
467
|
-
###
|
|
487
|
+
### cancel()
|
|
488
|
+
|
|
489
|
+
Remove an existing order by ID from the order book.
|
|
468
490
|
|
|
469
491
|
```ts
|
|
470
492
|
/**
|
|
471
|
-
*
|
|
472
|
-
*
|
|
473
|
-
* @param orderID - The ID of the order to be removed
|
|
474
|
-
* @returns The removed order if exists or `undefined`
|
|
493
|
+
* @param orderID - The ID of the order to remove
|
|
494
|
+
* @returns The removed order if found, or `undefined`
|
|
475
495
|
*/
|
|
476
496
|
ob.cancel(orderID: string)
|
|
477
497
|
```
|
|
@@ -488,6 +508,122 @@ bids: 90 -> 5 90 -> 5
|
|
|
488
508
|
80 -> 1 80 -> 1
|
|
489
509
|
```
|
|
490
510
|
|
|
511
|
+
## Understanding Order Results
|
|
512
|
+
|
|
513
|
+
When creating an order, the library returns an `IProcessOrder` object:
|
|
514
|
+
|
|
515
|
+
```ts
|
|
516
|
+
interface IProcessOrder {
|
|
517
|
+
done: IOrder[]; // Fully consumed orders
|
|
518
|
+
activated: IStopOrder[]; // Triggered stop orders (stop limit, stop market, OCO)
|
|
519
|
+
partial: ILimitOrder | null; // Partially consumed limit order (if any)
|
|
520
|
+
quantityLeft: number; // Unfilled quantity of the taker order
|
|
521
|
+
partialQuantityProcessed: number; // Quantity consumed from the order in 'partial'
|
|
522
|
+
err: OrderBookError | null;
|
|
523
|
+
log?: JournalLog; // Journal entry (only when enableJournaling is true)
|
|
524
|
+
stpExpired?: IOrder[]; // Orders expired due to Self-Trade Prevention
|
|
525
|
+
}
|
|
526
|
+
```
|
|
527
|
+
|
|
528
|
+
### When Does the Taker Appear in Results?
|
|
529
|
+
|
|
530
|
+
**The taker order does NOT always appear in the result arrays.**
|
|
531
|
+
|
|
532
|
+
| Order Type | Fill Status | Taker in `done[]` | Taker in `partial` | `quantityLeft` |
|
|
533
|
+
|------------|-------------|-------------------|--------------------|----------------|
|
|
534
|
+
| LIMIT | Fully filled | ✅ YES | ❌ NO | `0` |
|
|
535
|
+
| LIMIT | Partially filled | ❌ NO | ✅ YES | `> 0` |
|
|
536
|
+
| MARKET | Fully or partially filled | ❌ NO | ❌ NO | `>= 0` |
|
|
537
|
+
|
|
538
|
+
**Key facts:**
|
|
539
|
+
- **Market orders never appear in `done[]` or `partial`** - only the matched maker orders appear
|
|
540
|
+
- **Limit orders fully filled**: Taker appears in `done[]` alongside matched makers
|
|
541
|
+
- **Limit orders partially filled**: Taker appears in `partial`, matched makers appear in `done[]`
|
|
542
|
+
- **`quantityLeft`**: Always represents unfilled quantity of the taker, regardless of where it appears
|
|
543
|
+
|
|
544
|
+
> **Note on `activated[]`**: When a stop limit, stop market, or OCO order is triggered, the triggered order(s) appear in the `activated` array. These are orders that were resting in the stop book and have now been activated for matching.
|
|
545
|
+
>
|
|
546
|
+
> **Note on `stpExpired[]`**: When Self-Trade Prevention is configured and triggered, expired orders are listed in `stpExpired`. See [Self-Trade Prevention (STP)](#self-trade-prevention-stp) for details.
|
|
547
|
+
|
|
548
|
+
### What is `partialQuantityProcessed`?
|
|
549
|
+
|
|
550
|
+
This represents **how much of the order in `partial` was processed**, not how much is left.
|
|
551
|
+
|
|
552
|
+
- If `partial` contains the **taker** (partially filled limit order): represents amount of taker that was filled
|
|
553
|
+
- If `partial` contains a **maker** (partially consumed resting order): represents amount of maker that was consumed
|
|
554
|
+
|
|
555
|
+
**Example 1 - Taker in partial:**
|
|
556
|
+
```ts
|
|
557
|
+
// 10-unit buy order, only 5 available
|
|
558
|
+
{
|
|
559
|
+
done: [{ id: 'maker-1', size: 5 }], // Fully consumed maker
|
|
560
|
+
partial: { id: 'taker', size: 5 }, // Taker (5 still unfilled)
|
|
561
|
+
quantityLeft: 5, // 5 units of taker unfilled
|
|
562
|
+
partialQuantityProcessed: 5 // 5 units of taker were filled
|
|
563
|
+
}
|
|
564
|
+
```
|
|
565
|
+
|
|
566
|
+
**Example 2 - Maker in partial:**
|
|
567
|
+
```ts
|
|
568
|
+
// 8-unit buy order, 20 available from one maker
|
|
569
|
+
{
|
|
570
|
+
done: [{ id: 'taker', size: 8 }], // Fully filled taker
|
|
571
|
+
partial: { id: 'maker-1', size: 12 }, // Maker: 12 still unfilled (20 - 8)
|
|
572
|
+
quantityLeft: 0, // Taker fully filled
|
|
573
|
+
partialQuantityProcessed: 8 // 8 units of maker were consumed
|
|
574
|
+
}
|
|
575
|
+
```
|
|
576
|
+
|
|
577
|
+
> `partial.size` always represents the **remaining** quantity of that order, not what was consumed. In this example, the maker started with size 20, had 8 consumed, so `partial.size` is 12 (what's left on the book).
|
|
578
|
+
|
|
579
|
+
### Example: Market Order (Fully Filled)
|
|
580
|
+
|
|
581
|
+
```ts
|
|
582
|
+
// Market order for 10 units (10 available)
|
|
583
|
+
book.createOrder({ type: 'market', id: 'buy-1', size: 10, side: 'buy' })
|
|
584
|
+
|
|
585
|
+
// Result:
|
|
586
|
+
{
|
|
587
|
+
done: [{ id: 'sell-1', side: 'sell', size: 10 }], // Matched maker only
|
|
588
|
+
partial: null, // Taker NOT here
|
|
589
|
+
quantityLeft: 0, // Fully filled
|
|
590
|
+
partialQuantityProcessed: 0
|
|
591
|
+
}
|
|
592
|
+
```
|
|
593
|
+
|
|
594
|
+
### Example: Limit Order (Fully Filled)
|
|
595
|
+
|
|
596
|
+
```ts
|
|
597
|
+
// Limit order for 10 units (10 available)
|
|
598
|
+
book.createOrder({ type: 'limit', id: 'buy-1', price: 100, size: 10, side: 'buy' })
|
|
599
|
+
|
|
600
|
+
// Result:
|
|
601
|
+
{
|
|
602
|
+
done: [
|
|
603
|
+
{ id: 'sell-1', side: 'sell', size: 10 }, // Matched maker
|
|
604
|
+
{ id: 'buy-1', side: 'buy', size: 10 } // Taker ✅
|
|
605
|
+
],
|
|
606
|
+
partial: null,
|
|
607
|
+
quantityLeft: 0,
|
|
608
|
+
partialQuantityProcessed: 0
|
|
609
|
+
}
|
|
610
|
+
```
|
|
611
|
+
|
|
612
|
+
### Example: Limit Order (Partially Filled)
|
|
613
|
+
|
|
614
|
+
```ts
|
|
615
|
+
// Limit order for 10 units (only 5 available)
|
|
616
|
+
book.createOrder({ type: 'limit', id: 'buy-1', price: 100, size: 10, side: 'buy' })
|
|
617
|
+
|
|
618
|
+
// Result:
|
|
619
|
+
{
|
|
620
|
+
done: [{ id: 'sell-1', side: 'sell', size: 5 }], // Fully consumed maker
|
|
621
|
+
partial: { id: 'buy-1', side: 'buy', size: 5 }, // Taker ✅ (5 unfilled)
|
|
622
|
+
quantityLeft: 5, // 5 units unfilled
|
|
623
|
+
partialQuantityProcessed: 5 // 5 units filled
|
|
624
|
+
}
|
|
625
|
+
```
|
|
626
|
+
|
|
491
627
|
## Self-Trade Prevention (STP)
|
|
492
628
|
|
|
493
629
|
> Inspired by [Binance's Self-Trade Prevention](https://developers.binance.com/docs/derivatives/usds-margined-futures/faq/stp-faq) — prevents orders from the same account from matching against each other.
|
|
@@ -543,7 +679,7 @@ When STP is triggered, the response (`IProcessOrder`) includes:
|
|
|
543
679
|
|
|
544
680
|
| Field | Type | Description |
|
|
545
681
|
|-------|------|-------------|
|
|
546
|
-
| `stpExpired` | `IOrder[] \| undefined` | Orders
|
|
682
|
+
| `stpExpired` | `IOrder[] \| undefined` | Orders removed from the book due to STP |
|
|
547
683
|
| `err` | `OrderBookError \| null` | Error with `code: 1202` and `message: "Self-trade prevention triggered"` for `EXPIRE_TAKER` / `EXPIRE_BOTH` |
|
|
548
684
|
|
|
549
685
|
### Error code
|
|
@@ -686,118 +822,132 @@ ob.createOrder({
|
|
|
686
822
|
|
|
687
823
|
## Order Book Options
|
|
688
824
|
|
|
689
|
-
The
|
|
825
|
+
The order book can be initialized with the following options by passing them to the constructor:
|
|
690
826
|
|
|
691
827
|
### Snapshot
|
|
692
|
-
A `snapshot` represents the state of the order book at a specific point in time. It includes the following properties:
|
|
693
828
|
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
829
|
+
A `snapshot` represents the state of the order book at a specific point in time. It includes:
|
|
830
|
+
|
|
831
|
+
- `asks`: List of ask orders, each with a `price` and a list of associated `orders`.
|
|
832
|
+
- `bids`: List of bid orders, each with a `price` and a list of associated `orders`.
|
|
833
|
+
- `stopBook`: An object with `bids` and `asks` properties related to every `StopOrder` in the order book.
|
|
834
|
+
- `ts`: A Unix timestamp of when the snapshot was taken.
|
|
835
|
+
- `lastOp`: The ID of the last operation included in the snapshot.
|
|
699
836
|
|
|
700
|
-
Snapshots are crucial for restoring the order book to a previous state. The
|
|
701
|
-
After taking the snapshot, you can safely remove all logs preceding the `lastOp` id.
|
|
837
|
+
Snapshots are crucial for restoring the order book to a previous state. The order book can restore from a snapshot before processing any journal logs, ensuring consistency and accuracy. After taking a snapshot, you can safely remove all logs preceding the `lastOp` id.
|
|
702
838
|
|
|
703
|
-
**Note**: The snapshot
|
|
839
|
+
**Note**: The snapshot returns an object containing arrays of `bids` and `asks`. If the snapshot is saved to the database as a string, use `JSON.parse` to restore it when initializing the order book.
|
|
704
840
|
|
|
705
841
|
```ts
|
|
706
|
-
const ob = new OrderBook({ enableJournaling: true})
|
|
842
|
+
const ob = new OrderBook({ enableJournaling: true })
|
|
707
843
|
|
|
708
|
-
//
|
|
844
|
+
// After every order, save the log to the database
|
|
709
845
|
const order = ob.limit({ side: "sell", id: "uniqueID", size: 55, price: 100 })
|
|
710
846
|
await saveLog(order.log)
|
|
711
847
|
|
|
712
|
-
// ... after some time take a snapshot
|
|
713
|
-
|
|
848
|
+
// ... after some time, take a snapshot and save it
|
|
714
849
|
const snapshot = ob.snapshot()
|
|
715
850
|
await saveSnapshot(JSON.stringify(snapshot))
|
|
716
851
|
|
|
717
|
-
//
|
|
852
|
+
// Safe to remove logs before the snapshot's lastOp
|
|
718
853
|
await removePreviousLogs(snapshot.lastOp)
|
|
719
854
|
|
|
720
|
-
// On server restart
|
|
855
|
+
// On server restart, restore from snapshot + logs
|
|
721
856
|
const logs = await getLogs()
|
|
722
857
|
const snapshot = await getSnapshot()
|
|
723
858
|
|
|
724
|
-
const ob = new OrderBook({
|
|
859
|
+
const ob = new OrderBook({
|
|
860
|
+
snapshot: JSON.parse(snapshot),
|
|
861
|
+
journal: logs,
|
|
862
|
+
enableJournaling: true,
|
|
863
|
+
})
|
|
725
864
|
```
|
|
726
865
|
|
|
727
866
|
### Journal Logs
|
|
728
|
-
The `journal` option expects an array of journal logs that you can get by setting `enableJournaling` to true. When the journal is provided, the order book will replay all the operations, bringing the order book to the same state as the last log.
|
|
729
|
-
```ts
|
|
730
|
-
// Assuming 'logs' is an array of log entries retrieved from the database
|
|
731
867
|
|
|
868
|
+
The `journal` option accepts an array of journal logs (obtained by setting `enableJournaling` to `true`). When provided, the order book replays all operations, restoring its state to match the last log.
|
|
869
|
+
|
|
870
|
+
```ts
|
|
732
871
|
const logs = await getLogs()
|
|
733
872
|
const ob = new OrderBook({ journal: logs, enableJournalLog: true })
|
|
734
873
|
```
|
|
735
|
-
|
|
874
|
+
|
|
875
|
+
Combining snapshots with journaling gives you full state persistence and auditability.
|
|
736
876
|
|
|
737
877
|
### Enable Journaling
|
|
738
|
-
|
|
878
|
+
|
|
879
|
+
When `enableJournaling` is `true`, the property `log` is attached to every operation response. These logs should be persisted and can be used to restore the order book on restart.
|
|
880
|
+
|
|
739
881
|
```ts
|
|
740
882
|
const ob = new OrderBook({ enableJournaling: true }) // false by default
|
|
741
883
|
|
|
742
|
-
//
|
|
884
|
+
// After every operation, save the log
|
|
743
885
|
const order = ob.limit({ side: "sell", id: "uniqueID", size: 55, price: 100 })
|
|
744
886
|
await saveLog(order.log)
|
|
745
887
|
```
|
|
746
888
|
|
|
747
889
|
## Development
|
|
748
890
|
|
|
749
|
-
###
|
|
891
|
+
### Prerequisites
|
|
750
892
|
|
|
751
|
-
|
|
893
|
+
- Node.js 18+
|
|
894
|
+
- npm (or yarn / pnpm)
|
|
752
895
|
|
|
753
|
-
|
|
754
|
-
npm run build
|
|
755
|
-
```
|
|
896
|
+
### Setup
|
|
756
897
|
|
|
757
|
-
|
|
898
|
+
```bash
|
|
899
|
+
# Install dependencies
|
|
900
|
+
npm install
|
|
758
901
|
|
|
759
|
-
|
|
902
|
+
# Build all distributions (CJS, ESM, types)
|
|
903
|
+
npm run build
|
|
760
904
|
|
|
761
|
-
|
|
905
|
+
# Run tests
|
|
762
906
|
npm run test
|
|
763
|
-
```
|
|
764
|
-
|
|
765
|
-
### Coverage
|
|
766
907
|
|
|
767
|
-
Run
|
|
768
|
-
|
|
769
|
-
```
|
|
908
|
+
# Run tests with coverage
|
|
770
909
|
npm run test:cov
|
|
771
|
-
```
|
|
772
910
|
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
Before running benchmark, make sure to have built the source code with `npm run build` first
|
|
776
|
-
|
|
777
|
-
```
|
|
911
|
+
# Run benchmarks (build first)
|
|
778
912
|
npm run bench
|
|
779
913
|
```
|
|
780
914
|
|
|
781
|
-
|
|
915
|
+
### Commands
|
|
782
916
|
|
|
783
|
-
|
|
917
|
+
| Command | Description |
|
|
918
|
+
|---------|-------------|
|
|
919
|
+
| `npm run build` | Build CJS, ESM, and type declarations |
|
|
920
|
+
| `npm run test` | Run unit tests |
|
|
921
|
+
| `npm run test:dev` | Run tests in watch mode |
|
|
922
|
+
| `npm run test:cov` | Run tests with lcov coverage report |
|
|
923
|
+
| `npm run bench` | Run performance benchmarks |
|
|
924
|
+
| `npm run lint` | Check code style with Biome |
|
|
925
|
+
| `npm run lint:fix` | Auto-fix lint issues |
|
|
926
|
+
| `npm run clean` | Clean build output |
|
|
927
|
+
| `npm run package` | Build and pack for local testing |
|
|
784
928
|
|
|
785
|
-
|
|
786
|
-
2. Create your branch (git checkout -b my-branch)
|
|
787
|
-
3. Commit any changes to your branch
|
|
788
|
-
4. Push your changes to your remote branch
|
|
789
|
-
5. Open a pull request
|
|
929
|
+
## Contributing
|
|
790
930
|
|
|
791
|
-
|
|
931
|
+
Contributions are welcome! Please read the [Contributing Guidelines](CONTRIBUTING.md) before getting started.
|
|
792
932
|
|
|
793
|
-
|
|
933
|
+
1. Fork the repository
|
|
934
|
+
2. Create your feature branch (`git checkout -b my-feature`)
|
|
935
|
+
3. Commit your changes (`git commit -m 'feat: add my feature'`)
|
|
936
|
+
4. Push to the branch (`git push origin my-feature`)
|
|
937
|
+
5. Open a Pull Request
|
|
794
938
|
|
|
795
|
-
|
|
796
|
-
- BTC: `1GYDVSAQNgG7MFhV5bk15XJy3qoE4NFenp`
|
|
797
|
-
- BTC (BEP20): `0xf673ee099be8129ec05e2f549d96ebea24ac5d97`
|
|
798
|
-
- ETH (ERC20): `0xf673ee099be8129ec05e2f549d96ebea24ac5d97`
|
|
799
|
-
- BNB (BEP20): `0xf673ee099be8129ec05e2f549d96ebea24ac5d97`
|
|
939
|
+
Please also refer to the [Code of Conduct](CODE_OF_CONDUCT.md) and [Security Policy](SECURITY.md).
|
|
800
940
|
|
|
801
941
|
## License
|
|
802
942
|
|
|
803
943
|
Copyright [Andrea Fassina](https://github.com/fasenderos), Licensed under [MIT](LICENSE).
|
|
944
|
+
|
|
945
|
+
## Donation
|
|
946
|
+
|
|
947
|
+
If this project saves you time or helps your business, consider buying me a coffee.
|
|
948
|
+
|
|
949
|
+
- **USDT (TRC20):** `TXArNxsq2Ee8Jvsk45PudVio52Joiq1yEe`
|
|
950
|
+
- **BTC:** `1GYDVSAQNgG7MFhV5bk15XJy3qoE4NFenp`
|
|
951
|
+
- **BTC (BEP20):** `0xf673ee099be8129ec05e2f549d96ebea24ac5d97`
|
|
952
|
+
- **ETH (ERC20):** `0xf673ee099be8129ec05e2f549d96ebea24ac5d97`
|
|
953
|
+
- **BNB (BEP20):** `0xf673ee099be8129ec05e2f549d96ebea24ac5d97`
|
package/dist/cjs/orderbook.js
CHANGED
|
@@ -560,7 +560,8 @@ class OrderBook {
|
|
|
560
560
|
iter = this.bids.maxPriceQueue;
|
|
561
561
|
}
|
|
562
562
|
if (timeInForce === types_1.TimeInForce.FOK) {
|
|
563
|
-
|
|
563
|
+
// FOK is atomic, so this check is STP-aware.
|
|
564
|
+
const fillable = this.canFillOrder(sideToProcess, (levelPrice) => comparator(price, levelPrice), size, takerAccountId, stpMode);
|
|
564
565
|
if (!fillable) {
|
|
565
566
|
response.err = (0, errors_1.CustomError)(errors_1.ERROR.LIMIT_ORDER_FOK_NOT_FILLABLE);
|
|
566
567
|
return;
|
|
@@ -828,13 +829,18 @@ class OrderBook {
|
|
|
828
829
|
continue;
|
|
829
830
|
}
|
|
830
831
|
case types_1.SelfTradePreventionMode.EXPIRE_TAKER: {
|
|
831
|
-
// Taker expires immediately, nothing matches
|
|
832
|
+
// Taker expires immediately, nothing matches.
|
|
833
|
+
// response.quantityLeft is intentionally left untouched: it already
|
|
834
|
+
// holds the unfilled quantity, which is lower than the level-entry
|
|
835
|
+
// `quantityToTrade` when STP triggers after a partial fill inside
|
|
836
|
+
// this price level. Resetting it would resurrect filled quantity.
|
|
832
837
|
response.err = (0, errors_1.CustomError)(errors_1.ERROR.STP_TRIGGERED);
|
|
833
|
-
response.quantityLeft = quantityToTrade;
|
|
834
838
|
return response;
|
|
835
839
|
}
|
|
836
840
|
case types_1.SelfTradePreventionMode.EXPIRE_BOTH: {
|
|
837
|
-
// Remove maker from book AND expire taker
|
|
841
|
+
// Remove maker from book AND expire taker.
|
|
842
|
+
// As in EXPIRE_TAKER, response.quantityLeft must keep the running
|
|
843
|
+
// unfilled quantity rather than the level-entry quantityToTrade.
|
|
838
844
|
const removedOrder = this._cancelOrder(headOrder.id, true);
|
|
839
845
|
if (removedOrder?.order !== undefined) {
|
|
840
846
|
if (response.stpExpired === undefined) {
|
|
@@ -843,7 +849,6 @@ class OrderBook {
|
|
|
843
849
|
response.stpExpired.push(removedOrder.order);
|
|
844
850
|
}
|
|
845
851
|
response.err = (0, errors_1.CustomError)(errors_1.ERROR.STP_TRIGGERED);
|
|
846
|
-
response.quantityLeft = quantityToTrade;
|
|
847
852
|
return response;
|
|
848
853
|
}
|
|
849
854
|
}
|
|
@@ -877,42 +882,93 @@ class OrderBook {
|
|
|
877
882
|
}
|
|
878
883
|
return response;
|
|
879
884
|
};
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
885
|
+
/**
|
|
886
|
+
* Tells whether a self-trade check is required for this order, which is also the
|
|
887
|
+
* point past which level volumes stop being enough and orders must be walked
|
|
888
|
+
* individually.
|
|
889
|
+
*
|
|
890
|
+
* The conditions mirror the guard in `processQueue`, and the two must stay
|
|
891
|
+
* equivalent: if this reports a check where the matcher would not, a FOK order
|
|
892
|
+
* gets rejected as not fillable while the same order would otherwise have
|
|
893
|
+
* matched.
|
|
894
|
+
*/
|
|
895
|
+
shouldCheckSelfTrade = (takerAccountId, stpMode) => {
|
|
896
|
+
return (Boolean(takerAccountId) &&
|
|
897
|
+
stpMode != null &&
|
|
898
|
+
stpMode !== types_1.SelfTradePreventionMode.NONE);
|
|
884
899
|
};
|
|
885
|
-
|
|
900
|
+
/**
|
|
901
|
+
* Decides whether a FOK order can be filled in full, before the book is touched.
|
|
902
|
+
*
|
|
903
|
+
* Liquidity is walked in the same order the matcher consumes it, and a maker
|
|
904
|
+
* belonging to the taker's own account is not tradeable when STP is active.
|
|
905
|
+
* The three modes differ in how they stop the walk:
|
|
906
|
+
* EXPIRE_MAKER expires the maker and matching continues past it, while
|
|
907
|
+
* EXPIRE_TAKER and EXPIRE_BOTH stop the taker there, leaving no liquidity
|
|
908
|
+
* beyond that maker reachable.
|
|
909
|
+
*
|
|
910
|
+
* Only the STP-active path has to inspect individual orders. Without STP there
|
|
911
|
+
* is nothing to rule out, so level volumes are exact and the walk stays one
|
|
912
|
+
* step per price level.
|
|
913
|
+
*
|
|
914
|
+
* @param isLevelReachable - tells whether a level is within the taker's price.
|
|
915
|
+
* @returns true when `size` can be filled in full.
|
|
916
|
+
*/
|
|
917
|
+
canFillOrder = (orderSide, isLevelReachable, size, takerAccountId, stpMode) => {
|
|
918
|
+
// Necessary condition, so it is safe to reject on regardless of STP.
|
|
886
919
|
if (orderSide.volume() < size) {
|
|
887
920
|
return false;
|
|
888
921
|
}
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
cumulativeSize
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
922
|
+
if (!this.shouldCheckSelfTrade(takerAccountId, stpMode)) {
|
|
923
|
+
let cumulativeSize = 0;
|
|
924
|
+
// biome-ignore lint/suspicious/useIterableCallbackReturn: the forEach of the priceTree must return true to break the loop
|
|
925
|
+
orderSide.priceTree().forEach((levelPrice, level) => {
|
|
926
|
+
if (isLevelReachable(levelPrice) && cumulativeSize < size) {
|
|
927
|
+
cumulativeSize += level.volume();
|
|
928
|
+
}
|
|
929
|
+
else {
|
|
930
|
+
return true; // break the loop
|
|
931
|
+
}
|
|
932
|
+
});
|
|
933
|
+
return cumulativeSize >= size;
|
|
934
|
+
}
|
|
935
|
+
// A same-account maker is not tradeable, so the walk has to inspect orders
|
|
936
|
+
// rather than just level volumes. The price tree is ordered best-price-first
|
|
937
|
+
// on both sides, which is the order the matcher consumes levels in. Snapshot
|
|
938
|
+
// the reachable levels rather than polling for the next one: this walk removes
|
|
939
|
+
// nothing, so polling would keep returning the level it is already on.
|
|
940
|
+
const levels = [];
|
|
941
|
+
orderSide.priceTree().forEach((levelPrice, level) => {
|
|
942
|
+
if (isLevelReachable(levelPrice)) {
|
|
943
|
+
levels.push(level);
|
|
897
944
|
}
|
|
898
945
|
});
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
946
|
+
let quantityLeft = size;
|
|
947
|
+
for (const level of levels) {
|
|
948
|
+
for (const order of level.toArray()) {
|
|
949
|
+
// `shouldCheckSelfTrade` above already guarantees takerAccountId is a
|
|
950
|
+
// non-empty string, so this cannot match an order that has no
|
|
951
|
+
// accountId, nor a second order with an empty one.
|
|
952
|
+
if (order.accountId === takerAccountId) {
|
|
953
|
+
if (stpMode !== types_1.SelfTradePreventionMode.EXPIRE_MAKER) {
|
|
954
|
+
// EXPIRE_TAKER / EXPIRE_BOTH stop the taker here, so no
|
|
955
|
+
// liquidity beyond this maker is reachable. STP is guaranteed
|
|
956
|
+
// active in this branch, so every other mode aborts.
|
|
957
|
+
return false;
|
|
958
|
+
}
|
|
959
|
+
// EXPIRE_MAKER expires the maker, so it offers no tradeable size
|
|
960
|
+
// and the walk continues past it.
|
|
961
|
+
continue;
|
|
962
|
+
}
|
|
963
|
+
if (quantityLeft <= order.size) {
|
|
964
|
+
// The taker completes here, so an STP maker further down the queue
|
|
965
|
+
// is never reached, exactly as in `processQueue`.
|
|
966
|
+
return true;
|
|
967
|
+
}
|
|
968
|
+
quantityLeft -= order.size;
|
|
913
969
|
}
|
|
914
|
-
}
|
|
915
|
-
return
|
|
970
|
+
}
|
|
971
|
+
return false;
|
|
916
972
|
};
|
|
917
973
|
validateMarketOrder = (order) => {
|
|
918
974
|
const response = this.getProcessOrderResponse(order.size);
|
package/dist/esm/orderbook.js
CHANGED
|
@@ -557,7 +557,8 @@ export class OrderBook {
|
|
|
557
557
|
iter = this.bids.maxPriceQueue;
|
|
558
558
|
}
|
|
559
559
|
if (timeInForce === TimeInForce.FOK) {
|
|
560
|
-
|
|
560
|
+
// FOK is atomic, so this check is STP-aware.
|
|
561
|
+
const fillable = this.canFillOrder(sideToProcess, (levelPrice) => comparator(price, levelPrice), size, takerAccountId, stpMode);
|
|
561
562
|
if (!fillable) {
|
|
562
563
|
response.err = CustomError(ERROR.LIMIT_ORDER_FOK_NOT_FILLABLE);
|
|
563
564
|
return;
|
|
@@ -825,13 +826,18 @@ export class OrderBook {
|
|
|
825
826
|
continue;
|
|
826
827
|
}
|
|
827
828
|
case SelfTradePreventionMode.EXPIRE_TAKER: {
|
|
828
|
-
// Taker expires immediately, nothing matches
|
|
829
|
+
// Taker expires immediately, nothing matches.
|
|
830
|
+
// response.quantityLeft is intentionally left untouched: it already
|
|
831
|
+
// holds the unfilled quantity, which is lower than the level-entry
|
|
832
|
+
// `quantityToTrade` when STP triggers after a partial fill inside
|
|
833
|
+
// this price level. Resetting it would resurrect filled quantity.
|
|
829
834
|
response.err = CustomError(ERROR.STP_TRIGGERED);
|
|
830
|
-
response.quantityLeft = quantityToTrade;
|
|
831
835
|
return response;
|
|
832
836
|
}
|
|
833
837
|
case SelfTradePreventionMode.EXPIRE_BOTH: {
|
|
834
|
-
// Remove maker from book AND expire taker
|
|
838
|
+
// Remove maker from book AND expire taker.
|
|
839
|
+
// As in EXPIRE_TAKER, response.quantityLeft must keep the running
|
|
840
|
+
// unfilled quantity rather than the level-entry quantityToTrade.
|
|
835
841
|
const removedOrder = this._cancelOrder(headOrder.id, true);
|
|
836
842
|
if (removedOrder?.order !== undefined) {
|
|
837
843
|
if (response.stpExpired === undefined) {
|
|
@@ -840,7 +846,6 @@ export class OrderBook {
|
|
|
840
846
|
response.stpExpired.push(removedOrder.order);
|
|
841
847
|
}
|
|
842
848
|
response.err = CustomError(ERROR.STP_TRIGGERED);
|
|
843
|
-
response.quantityLeft = quantityToTrade;
|
|
844
849
|
return response;
|
|
845
850
|
}
|
|
846
851
|
}
|
|
@@ -874,42 +879,93 @@ export class OrderBook {
|
|
|
874
879
|
}
|
|
875
880
|
return response;
|
|
876
881
|
};
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
882
|
+
/**
|
|
883
|
+
* Tells whether a self-trade check is required for this order, which is also the
|
|
884
|
+
* point past which level volumes stop being enough and orders must be walked
|
|
885
|
+
* individually.
|
|
886
|
+
*
|
|
887
|
+
* The conditions mirror the guard in `processQueue`, and the two must stay
|
|
888
|
+
* equivalent: if this reports a check where the matcher would not, a FOK order
|
|
889
|
+
* gets rejected as not fillable while the same order would otherwise have
|
|
890
|
+
* matched.
|
|
891
|
+
*/
|
|
892
|
+
shouldCheckSelfTrade = (takerAccountId, stpMode) => {
|
|
893
|
+
return (Boolean(takerAccountId) &&
|
|
894
|
+
stpMode != null &&
|
|
895
|
+
stpMode !== SelfTradePreventionMode.NONE);
|
|
881
896
|
};
|
|
882
|
-
|
|
897
|
+
/**
|
|
898
|
+
* Decides whether a FOK order can be filled in full, before the book is touched.
|
|
899
|
+
*
|
|
900
|
+
* Liquidity is walked in the same order the matcher consumes it, and a maker
|
|
901
|
+
* belonging to the taker's own account is not tradeable when STP is active.
|
|
902
|
+
* The three modes differ in how they stop the walk:
|
|
903
|
+
* EXPIRE_MAKER expires the maker and matching continues past it, while
|
|
904
|
+
* EXPIRE_TAKER and EXPIRE_BOTH stop the taker there, leaving no liquidity
|
|
905
|
+
* beyond that maker reachable.
|
|
906
|
+
*
|
|
907
|
+
* Only the STP-active path has to inspect individual orders. Without STP there
|
|
908
|
+
* is nothing to rule out, so level volumes are exact and the walk stays one
|
|
909
|
+
* step per price level.
|
|
910
|
+
*
|
|
911
|
+
* @param isLevelReachable - tells whether a level is within the taker's price.
|
|
912
|
+
* @returns true when `size` can be filled in full.
|
|
913
|
+
*/
|
|
914
|
+
canFillOrder = (orderSide, isLevelReachable, size, takerAccountId, stpMode) => {
|
|
915
|
+
// Necessary condition, so it is safe to reject on regardless of STP.
|
|
883
916
|
if (orderSide.volume() < size) {
|
|
884
917
|
return false;
|
|
885
918
|
}
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
cumulativeSize
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
919
|
+
if (!this.shouldCheckSelfTrade(takerAccountId, stpMode)) {
|
|
920
|
+
let cumulativeSize = 0;
|
|
921
|
+
// biome-ignore lint/suspicious/useIterableCallbackReturn: the forEach of the priceTree must return true to break the loop
|
|
922
|
+
orderSide.priceTree().forEach((levelPrice, level) => {
|
|
923
|
+
if (isLevelReachable(levelPrice) && cumulativeSize < size) {
|
|
924
|
+
cumulativeSize += level.volume();
|
|
925
|
+
}
|
|
926
|
+
else {
|
|
927
|
+
return true; // break the loop
|
|
928
|
+
}
|
|
929
|
+
});
|
|
930
|
+
return cumulativeSize >= size;
|
|
931
|
+
}
|
|
932
|
+
// A same-account maker is not tradeable, so the walk has to inspect orders
|
|
933
|
+
// rather than just level volumes. The price tree is ordered best-price-first
|
|
934
|
+
// on both sides, which is the order the matcher consumes levels in. Snapshot
|
|
935
|
+
// the reachable levels rather than polling for the next one: this walk removes
|
|
936
|
+
// nothing, so polling would keep returning the level it is already on.
|
|
937
|
+
const levels = [];
|
|
938
|
+
orderSide.priceTree().forEach((levelPrice, level) => {
|
|
939
|
+
if (isLevelReachable(levelPrice)) {
|
|
940
|
+
levels.push(level);
|
|
894
941
|
}
|
|
895
942
|
});
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
943
|
+
let quantityLeft = size;
|
|
944
|
+
for (const level of levels) {
|
|
945
|
+
for (const order of level.toArray()) {
|
|
946
|
+
// `shouldCheckSelfTrade` above already guarantees takerAccountId is a
|
|
947
|
+
// non-empty string, so this cannot match an order that has no
|
|
948
|
+
// accountId, nor a second order with an empty one.
|
|
949
|
+
if (order.accountId === takerAccountId) {
|
|
950
|
+
if (stpMode !== SelfTradePreventionMode.EXPIRE_MAKER) {
|
|
951
|
+
// EXPIRE_TAKER / EXPIRE_BOTH stop the taker here, so no
|
|
952
|
+
// liquidity beyond this maker is reachable. STP is guaranteed
|
|
953
|
+
// active in this branch, so every other mode aborts.
|
|
954
|
+
return false;
|
|
955
|
+
}
|
|
956
|
+
// EXPIRE_MAKER expires the maker, so it offers no tradeable size
|
|
957
|
+
// and the walk continues past it.
|
|
958
|
+
continue;
|
|
959
|
+
}
|
|
960
|
+
if (quantityLeft <= order.size) {
|
|
961
|
+
// The taker completes here, so an STP maker further down the queue
|
|
962
|
+
// is never reached, exactly as in `processQueue`.
|
|
963
|
+
return true;
|
|
964
|
+
}
|
|
965
|
+
quantityLeft -= order.size;
|
|
910
966
|
}
|
|
911
|
-
}
|
|
912
|
-
return
|
|
967
|
+
}
|
|
968
|
+
return false;
|
|
913
969
|
};
|
|
914
970
|
validateMarketOrder = (order) => {
|
|
915
971
|
const response = this.getProcessOrderResponse(order.size);
|
|
@@ -163,9 +163,35 @@ export declare class OrderBook {
|
|
|
163
163
|
private readonly greaterThanOrEqual;
|
|
164
164
|
private readonly lowerThanOrEqual;
|
|
165
165
|
private readonly processQueue;
|
|
166
|
+
/**
|
|
167
|
+
* Tells whether a self-trade check is required for this order, which is also the
|
|
168
|
+
* point past which level volumes stop being enough and orders must be walked
|
|
169
|
+
* individually.
|
|
170
|
+
*
|
|
171
|
+
* The conditions mirror the guard in `processQueue`, and the two must stay
|
|
172
|
+
* equivalent: if this reports a check where the matcher would not, a FOK order
|
|
173
|
+
* gets rejected as not fillable while the same order would otherwise have
|
|
174
|
+
* matched.
|
|
175
|
+
*/
|
|
176
|
+
private readonly shouldCheckSelfTrade;
|
|
177
|
+
/**
|
|
178
|
+
* Decides whether a FOK order can be filled in full, before the book is touched.
|
|
179
|
+
*
|
|
180
|
+
* Liquidity is walked in the same order the matcher consumes it, and a maker
|
|
181
|
+
* belonging to the taker's own account is not tradeable when STP is active.
|
|
182
|
+
* The three modes differ in how they stop the walk:
|
|
183
|
+
* EXPIRE_MAKER expires the maker and matching continues past it, while
|
|
184
|
+
* EXPIRE_TAKER and EXPIRE_BOTH stop the taker there, leaving no liquidity
|
|
185
|
+
* beyond that maker reachable.
|
|
186
|
+
*
|
|
187
|
+
* Only the STP-active path has to inspect individual orders. Without STP there
|
|
188
|
+
* is nothing to rule out, so level volumes are exact and the walk stays one
|
|
189
|
+
* step per price level.
|
|
190
|
+
*
|
|
191
|
+
* @param isLevelReachable - tells whether a level is within the taker's price.
|
|
192
|
+
* @returns true when `size` can be filled in full.
|
|
193
|
+
*/
|
|
166
194
|
private readonly canFillOrder;
|
|
167
|
-
private readonly buyOrderCanBeFilled;
|
|
168
|
-
private readonly sellOrderCanBeFilled;
|
|
169
195
|
private readonly validateMarketOrder;
|
|
170
196
|
private readonly validateLimitOrder;
|
|
171
197
|
}
|