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 CHANGED
@@ -10,56 +10,75 @@
10
10
  # Node.js Order Book
11
11
 
12
12
  <p align="center">
13
- Ultra-fast Node.js Order Book written in TypeScript </br> for high-frequency trading (HFT) :rocket::rocket: </br></br>
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
- - [About Primary Functions](#about-primary-functions)
24
- - [Create order `createOrder()`](#create-order)
25
- - [Create Limit order `limit()`](#create-limit-order)
26
- - [Create Market order `market()`](#create-market-order)
27
- - [Create Stop Limit order `stopLimit()`](#create-stop-limit-order)
28
- - [Create Stop Market order `stopMarket()`](#create-stop-market-order)
29
- - [Create OCO (One-Cancels-the-Other) order `oco()`](#create-oco-one-cancels-the-other-order)
30
- - [Modify an existing order `modifiy()`](#modify-an-existing-order)
31
- - [Cancel order `cancel()`](#cancel-order)
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
- - Supports both market and limit orders
51
- - Supports `post-only` limit order <img src="https://img.shields.io/badge/New-green" alt="New">
52
- - Supports conditional orders [**Stop Limit, Stop Market and OCO**](#conditional-orders) <img src="https://img.shields.io/badge/New-green" alt="New">
53
- - Supports time in force GTC, FOK and IOC <img src="https://img.shields.io/badge/New-green" alt="New">
54
- - Support [Self-Trade Prevention (STP)](#self-trade-prevention-stp)
55
- - Supports order cancelling
56
- - Supports order price and/or size updating <img src="https://img.shields.io/badge/New-green" alt="New">
57
- - Snapshot and journaling functionalities for restoring the order book during server startup <img src="https://img.shields.io/badge/New-green" alt="New">
58
- - **High performance (above 300k trades per second)**
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
- **Machine:** ASUS ExpertBook, 11th Gen Intel(R) Core(TM) i7-1165G7, 2.80Ghz, 16GB RAM, Node.js v18.4.0.
78
+ ## Requirements
61
79
 
62
- <img src="https://user-images.githubusercontent.com/1219087/181792292-8619ee25-bf75-4871-a06c-bd6c82157f33.png" alt="nodejs-order-book-benchmark" title="nodejs-order-book benchmark" />
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
- To start using order book you need to import `OrderBook` and create new instance:
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 next primary functions:
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
- Currently `Stop Market`, `Stop Limit` and `OCO` orders are supported.
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
- ## About primary functions
203
+ ## Primary Functions
172
204
 
173
- To add an order to the order book you can call the general `createOrder()` function or calling the underlying `limit()`, `market()`, `stopLimit()`, `stopMarket()` or `oco()` functions
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
- ### Create Order
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
- // Create limit order
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
- // Create market order
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
- // Create stop limit order
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
- // Create stop market order
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
- // Create OCO order
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
- ### Create Limit Order
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 fullfilled, in units of the quote currency
238
- * @param options.postOnly - When `true` the order will be rejected if immediately matches and trades as a taker. Default is `false`
239
- * @param options.timeInForce - Time-in-force type supported are: GTC, FOK, IOC. Default is GTC
240
- * @returns An object with the result of the processed order or an error. See {@link IProcessOrder} for the returned data structure
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
- ### Create Market Order
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. See {@link IProcessOrder} for the returned data structure
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
- ### Create Stop Limit Order
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 fullfilled, in units of the quote currency
349
- * @param options.stopPrice - The price at which the order will be triggered.
350
- * @param options.timeInForce - Time-in-force type supported are: GTC, FOK, IOC. Default is GTC
351
- * @returns An object with the result of the processed order or an error. See {@link IProcessOrder} for the returned data structure
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
- ### Create Stop Market Order
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 will be triggered.
373
- * @returns An object with the result of the processed order or an error. See {@link IProcessOrder} for the returned data structure
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
- ### Create OCO (One-Cancels-the-Other) Order
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 price of the `limit` order at which the order is to be fullfilled, in units of the quote currency
402
- * @param options.stopPrice - The price at which the `stop_limit` order will be triggered.
403
- * @param options.stopLimitPrice - The price of the `stop_limit` order at which the order is to be fullfilled, in units of the quote currency.
404
- * @param options.timeInForce - Time-in-force of the `limit` order. Type supported are: GTC, FOK, IOC. Default is GTC
405
- * @param options.stopLimitTimeInForce - Time-in-force of the `stop_limit` order. Type supported are: GTC, FOK, IOC. Default is GTC
406
- * @returns An object with the result of the processed order or an error. See {@link IProcessOrder} for the returned data structure
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
- ### Modify an existing order
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
- * Modify an existing order with given ID. When an order is modified by price or quantity,
425
- * it will be deemed as a new entry. Under the price-time-priority algorithm, orders are
426
- * prioritized according to their order price and order time. Hence, the latest orders
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
- ### Cancel Order
487
+ ### cancel()
488
+
489
+ Remove an existing order by ID from the order book.
468
490
 
469
491
  ```ts
470
492
  /**
471
- * Remove an existing order with given ID from the order book
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 that were removed from the book due to STP |
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 orderbook can be initialized with the following options by passing them to the constructor:
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
- - `asks`: List of ask orders, each with a `price` and a list of associated `orders`.
695
- - `bids`: List of bid orders, each with a `price` and a list of associated `orders`.
696
- - `stopBook`: an object with `bids` and `asks` properties related to every `StopOrder` in the orderbook.
697
- - `ts`: A timestamp indicating when the snapshot was taken, in Unix timestamp format.
698
- - `lastOp`: The id of the last operation included in the snapshot
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 orderbook can restore from a snapshot before processing any journal logs, ensuring consistency and accuracy.
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 of the order book returns an object containing an `array` of `bids` and `asks`, which in turn are arrays of order objects. If the snapshot is saved to the database as a `string`, make sure to pass the snapshot in its original format when initializing the order book. For example, you can achieve this by using `JSON.parse` to convert the string back into its original object form.
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
- // after every order save the log to the database
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 of the order book and save it on the database
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
- // If you want you can safely remove all logs preceding the `lastOp` id of the snapshot, and continue to save each subsequent log to the database
852
+ // Safe to remove logs before the snapshot's lastOp
718
853
  await removePreviousLogs(snapshot.lastOp)
719
854
 
720
- // On server restart get the snapshot and logs from the database and initialize the order book
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({ snapshot: JSON.parse(snapshot), journal: log, enableJournaling: true })
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
- By combining snapshots with journaling, you can effectively restore and audit the state of the order book.
874
+
875
+ Combining snapshots with journaling gives you full state persistence and auditability.
736
876
 
737
877
  ### Enable Journaling
738
- `enabledJournaling` is a configuration setting that determines whether journaling is enabled or disabled. When enabled, the property `log` will be added to the body of the response for each operation. The logs must be saved to the database and can then be used when a new instance of the order book is instantiated.
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
- // after every order save the log to the database
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
- ### Build
891
+ ### Prerequisites
750
892
 
751
- Build production (distribution) files in your dist folder:
893
+ - Node.js 18+
894
+ - npm (or yarn / pnpm)
752
895
 
753
- ```
754
- npm run build
755
- ```
896
+ ### Setup
756
897
 
757
- ### Testing
898
+ ```bash
899
+ # Install dependencies
900
+ npm install
758
901
 
759
- To run all the unit-test
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 testing coverage
768
-
769
- ```
908
+ # Run tests with coverage
770
909
  npm run test:cov
771
- ```
772
910
 
773
- ### Benchmarking
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
- ## Contributing
915
+ ### Commands
782
916
 
783
- I would greatly appreciate any contributions to make this project better. Please make sure to follow the below guidelines before getting your hands dirty.
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
- 1. Fork the repository
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
- ## Donation
931
+ Contributions are welcome! Please read the [Contributing Guidelines](CONTRIBUTING.md) before getting started.
792
932
 
793
- If this project help you reduce time to develop, you can give me a cup of coffee 🍵 :)
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
- - USDT (TRC20): `TXArNxsq2Ee8Jvsk45PudVio52Joiq1yEe`
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`
@@ -560,7 +560,8 @@ class OrderBook {
560
560
  iter = this.bids.maxPriceQueue;
561
561
  }
562
562
  if (timeInForce === types_1.TimeInForce.FOK) {
563
- const fillable = this.canFillOrder(sideToProcess, side, size, price);
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
- canFillOrder = (orderSide, side, size, price) => {
881
- return side === types_1.Side.BUY
882
- ? this.buyOrderCanBeFilled(orderSide, size, price)
883
- : this.sellOrderCanBeFilled(orderSide, size, price);
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
- buyOrderCanBeFilled = (orderSide, size, price) => {
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
- let cumulativeSize = 0;
890
- // biome-ignore lint/suspicious/useIterableCallbackReturn: the forEach of the priceTree must return true to break the loop
891
- orderSide.priceTree().forEach((_, level) => {
892
- if (price >= level.price() && cumulativeSize < size) {
893
- cumulativeSize += level.volume();
894
- }
895
- else {
896
- return true; // break the loop
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
- return cumulativeSize >= size;
900
- };
901
- sellOrderCanBeFilled = (orderSide, size, price) => {
902
- if (orderSide.volume() < size) {
903
- return false;
904
- }
905
- let cumulativeSize = 0;
906
- // biome-ignore lint/suspicious/useIterableCallbackReturn: the forEach of the priceTree must return true to break the loop
907
- orderSide.priceTree().forEach((_, level) => {
908
- if (price <= level.price() && cumulativeSize < size) {
909
- cumulativeSize += level.volume();
910
- }
911
- else {
912
- return true; // break the loop
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 cumulativeSize >= size;
970
+ }
971
+ return false;
916
972
  };
917
973
  validateMarketOrder = (order) => {
918
974
  const response = this.getProcessOrderResponse(order.size);
@@ -557,7 +557,8 @@ export class OrderBook {
557
557
  iter = this.bids.maxPriceQueue;
558
558
  }
559
559
  if (timeInForce === TimeInForce.FOK) {
560
- const fillable = this.canFillOrder(sideToProcess, side, size, price);
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
- canFillOrder = (orderSide, side, size, price) => {
878
- return side === Side.BUY
879
- ? this.buyOrderCanBeFilled(orderSide, size, price)
880
- : this.sellOrderCanBeFilled(orderSide, size, price);
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
- buyOrderCanBeFilled = (orderSide, size, price) => {
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
- let cumulativeSize = 0;
887
- // biome-ignore lint/suspicious/useIterableCallbackReturn: the forEach of the priceTree must return true to break the loop
888
- orderSide.priceTree().forEach((_, level) => {
889
- if (price >= level.price() && cumulativeSize < size) {
890
- cumulativeSize += level.volume();
891
- }
892
- else {
893
- return true; // break the loop
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
- return cumulativeSize >= size;
897
- };
898
- sellOrderCanBeFilled = (orderSide, size, price) => {
899
- if (orderSide.volume() < size) {
900
- return false;
901
- }
902
- let cumulativeSize = 0;
903
- // biome-ignore lint/suspicious/useIterableCallbackReturn: the forEach of the priceTree must return true to break the loop
904
- orderSide.priceTree().forEach((_, level) => {
905
- if (price <= level.price() && cumulativeSize < size) {
906
- cumulativeSize += level.volume();
907
- }
908
- else {
909
- return true; // break the loop
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 cumulativeSize >= size;
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodejs-order-book",
3
- "version": "10.1.1",
3
+ "version": "10.2.0",
4
4
  "description": "Node.js Lmit Order Book for high-frequency trading (HFT).",
5
5
  "author": "Andrea Fassina <fasenderos@gmail.com>",
6
6
  "license": "MIT",