A fast, feature-complete limit order book engine for Node.js, written in TypeScript.
Designed for trading systems, exchanges, and HFT simulations.
โญ Star me on GitHub โ it motivates me a lot!
Why this library? Originally ported from a Go 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.
- Features
- Quick Start
- Requirements
- Installation
- Usage
- Conditional Orders
- Primary Functions
- Understanding Order Results
- Self-Trade Prevention (STP)
- Order Book Options
- Development
- Contributing
- License
- Donation
- Standard price-time priority matching
- Market, limit, and post-only limit orders
- Conditional orders: Stop Limit, Stop Market, and OCO (One-Cancels-the-Other)
- Time-in-force: GTC (Good-Til-Cancelled), FOK (Fill-Or-Kill), IOC (Immediate-Or-Cancel)
- Self-Trade Prevention (STP) with 4 modes (NONE, EXPIRE_MAKER, EXPIRE_TAKER, EXPIRE_BOTH)
- Order cancellation
- Order price and/or size modification
- Snapshot and journaling for order book state persistence and recovery
- High throughput โ benchmarked at 300k+ trades per second
- Full TypeScript support with dual ESM/CJS exports
import { OrderBook, Side } from 'nodejs-order-book'
const ob = new OrderBook()
// Place a sell limit order
ob.limit({ side: Side.SELL, id: 'order-1', size: 55, price: 100 })
// Place a buy market order
const result = ob.market({ side: Side.BUY, size: 10 })
console.log(result.done) // Filled orders
console.log(result.partial) // Partial fill, if any- Node.js 18+ (ES2022 target)
- npm, yarn, or pnpm
Install with npm:
npm install nodejs-order-book
Install with yarn:
yarn add nodejs-order-book
Install with pnpm:
pnpm add nodejs-order-book
The package supports both ESM and CommonJS:
// ESM (recommended)
import { OrderBook, Side, OrderType, SelfTradePreventionMode } from 'nodejs-order-book'
// CommonJS
const { OrderBook, Side, OrderType, SelfTradePreventionMode } = require('nodejs-order-book')To start using the order book you need to import OrderBook and create a new instance:
import { OrderBook } from 'nodejs-order-book'
const ob = new OrderBook()Then you'll be able to use the following primary functions:
ob.createOrder({
type: 'limit' | 'market',
side: 'buy' | 'sell',
size: number,
price?: number,
id?: string,
postOnly?: boolean,
timeInForce?: 'GTC' | 'FOK' | 'IOC'
})
ob.limit({
id: string,
side: 'buy' | 'sell',
size: number,
price: number,
postOnly?: boolean,
timeInForce?: 'GTC' | 'FOK' | 'IOC'
})
ob.market({ side: 'buy' | 'sell', size: number })
ob.modify(orderID: string, {
side: 'buy' | 'sell',
size: number,
price: number
})
ob.cancel(orderID: string)Stop Market, Stop Limit and OCO orders are supported.
import { OrderBook } from 'nodejs-order-book'
const ob = new OrderBook()
ob.createOrder({
type: 'stop_limit' | 'stop_market' | 'oco',
side: 'buy' | 'sell',
size: number,
price?: number,
id?: string,
stopPrice?: number,
timeInForce?: 'GTC' | 'FOK' | 'IOC',
stopLimitTimeInForce?: 'GTC' | 'FOK' | 'IOC'
})
ob.stopLimit({
id: string,
side: 'buy' | 'sell',
size: number,
price: number,
stopPrice: number,
timeInForce?: 'GTC' | 'FOK' | 'IOC'
})
ob.stopMarket({
side: 'buy' | 'sell',
size: number,
stopPrice: number
})
ob.oco({
id: string,
side: 'buy' | 'sell',
size: number,
price: number,
stopPrice: number,
stopLimitPrice: number,
timeInForce?: 'GTC' | 'FOK' | 'IOC',
stopLimitTimeInForce?: 'GTC' | 'FOK' | 'IOC'
})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.
A unified entry point that accepts a type field to dispatch to the correct handler:
// Limit order
ob.createOrder({
type: 'limit',
side: 'buy' | 'sell',
size: number,
price: number,
id: string,
postOnly?: boolean,
timeInForce?: 'GTC' | 'FOK' | 'IOC'
})
// Market order
ob.createOrder({
type: 'market',
side: 'buy' | 'sell',
size: number
})
// Stop limit order
ob.createOrder({
type: 'stop_limit',
side: 'buy' | 'sell',
size: number,
price: number,
id: string,
stopPrice: number,
timeInForce?: 'GTC' | 'FOK' | 'IOC'
})
// Stop market order
ob.createOrder({
type: 'stop_market',
side: 'buy' | 'sell',
size: number,
stopPrice: number
})
// OCO order
ob.createOrder({
type: 'oco',
side: 'buy' | 'sell',
size: number,
stopPrice: number,
stopLimitPrice: number,
timeInForce?: 'GTC' | 'FOK' | 'IOC',
stopLimitTimeInForce?: 'GTC' | 'FOK' | 'IOC'
})Create a limit order. See {@link LimitOrderOptions} for details.
/**
* @param options.side - `sell` or `buy`
* @param options.id - Unique order ID
* @param options.size - How much of currency you want to trade in units of base currency
* @param options.price - The price at which the order is to be fulfilled, in units of the quote currency
* @param options.postOnly - When `true` the order is rejected if it immediately matches as a taker. Default is `false`
* @param options.timeInForce - GTC, FOK, or IOC. Default is GTC
* @returns An object with the result of the processed order or an error. See {@link IProcessOrder}
*/
ob.limit({
side: 'buy' | 'sell',
id: string,
size: number,
price: number,
postOnly?: boolean,
timeInForce?: 'GTC' | 'FOK' | 'IOC'
})For example:
ob.limit({ side: "sell", id: "uniqueID", size: 55, price: 100 })
asks: 110 -> 5 110 -> 5
100 -> 1 100 -> 56
-------------- -> --------------
bids: 90 -> 5 90 -> 5
80 -> 1 80 -> 1
done - null
partial - nullob.limit({ side: "buy", id: "uniqueID", size: 7, price: 120 })
asks: 110 -> 5
100 -> 1
-------------- -> --------------
bids: 90 -> 5 120 -> 1
80 -> 1 90 -> 5
80 -> 1
done - 2 (or more orders)
partial - uniqueID orderob.limit({ side: "buy", id: "uniqueID", size: 3, price: 120 })
asks: 110 -> 5
100 -> 1 110 -> 3
-------------- -> --------------
bids: 90 -> 5 90 -> 5
80 -> 1 80 -> 1
done - 1 order with 100 price, (may be also few orders with 110 price) + uniqueID order
partial - 1 order with price 110Create a market order. See {@link MarketOrderOptions} for details.
/**
* @param options.side - `sell` or `buy`
* @param options.size - How much of currency you want to trade in units of base currency
* @returns An object with the result of the processed order or an error. See {@link IProcessOrder}
*/
ob.market({ side: 'buy' | 'sell', size: number })For example:
ob.market({ side: 'sell', size: 6 })
asks: 110 -> 5 110 -> 5
100 -> 1 100 -> 1
-------------- -> --------------
bids: 90 -> 5 80 -> 1
80 -> 2
done - 2 (or more orders)
partial - 1 order with price 80
quantityLeft - 0ob.market({ side: 'buy', size: 10 })
asks: 110 -> 5
100 -> 1
-------------- -> --------------
bids: 90 -> 5 90 -> 5
80 -> 1 80 -> 1
done - 2 (or more orders)
partial - null
quantityLeft - 4Create a stop limit order. See {@link StopLimitOrderOptions} for details.
/**
* @param options.side - `sell` or `buy`
* @param options.id - Unique order ID
* @param options.size - How much of currency you want to trade in units of base currency
* @param options.price - The price at which the order is to be fulfilled, in units of the quote currency
* @param options.stopPrice - The price at which the order is triggered
* @param options.timeInForce - GTC, FOK, or IOC. Default is GTC
* @returns An object with the result of the processed order or an error. See {@link IProcessOrder}
*/
ob.stopLimit({
side: 'buy' | 'sell',
id: string,
size: number,
price: number,
stopPrice: number,
timeInForce?: 'GTC' | 'FOK' | 'IOC'
})Create a stop market order. See {@link StopMarketOrderOptions} for details.
/**
* @param options.side - `sell` or `buy`
* @param options.size - How much of currency you want to trade in units of base currency
* @param options.stopPrice - The price at which the order is triggered
* @returns An object with the result of the processed order or an error. See {@link IProcessOrder}
*/
ob.stopMarket({
side: 'buy' | 'sell',
size: number,
stopPrice: number
})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.
For BUY orders: stopPrice must be above the current price, price below.
For SELL orders: stopPrice must be below the current price, price above.
See {@link OCOOrderOptions} for details.
/**
* @param options.side - `sell` or `buy`
* @param options.id - Unique order ID
* @param options.size - How much of currency you want to trade in units of base currency
* @param options.price - The limit order price, in units of the quote currency
* @param options.stopPrice - The stop trigger price
* @param options.stopLimitPrice - The stop_limit order price, in units of the quote currency
* @param options.timeInForce - Time-in-force of the limit order. GTC, FOK, IOC. Default is GTC
* @param options.stopLimitTimeInForce - Time-in-force of the stop_limit order. GTC, FOK, IOC. Default is GTC
* @returns An object with the result of the processed order or an error. See {@link IProcessOrder}
*/
ob.oco({
side: 'buy' | 'sell',
id: string,
size: number,
price: number,
stopPrice: number,
stopLimitPrice: number,
timeInForce?: 'GTC' | 'FOK' | 'IOC',
stopLimitTimeInForce?: 'GTC' | 'FOK' | 'IOC'
})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.
/**
* @param orderID - The ID of the order to modify
* @param orderUpdate - An object with `{size, price}`. Only provided fields are updated
* @returns An object with the result or an error
*/
ob.modify(orderID: string, { size: number, price: number })For example:
ob.limit({ side: "sell", id: "uniqueID", size: 55, price: 100 })
asks: 110 -> 5 110 -> 5
100 -> 1 100 -> 56
-------------- -> --------------
bids: 90 -> 5 90 -> 5
80 -> 1 80 -> 1
// Modify the size from 55 to 65
ob.modify("uniqueID", { size: 65 })
asks: 110 -> 5 110 -> 5
100 -> 56 100 -> 66
-------------- -> --------------
bids: 90 -> 5 90 -> 5
80 -> 1 80 -> 1
// Modify the price from 100 to 110
ob.modify("uniqueID", { price: 110 })
asks: 110 -> 5 110 -> 70
100 -> 66 100 -> 1
-------------- -> --------------
bids: 90 -> 5 90 -> 5
80 -> 1 80 -> 1Remove an existing order by ID from the order book.
/**
* @param orderID - The ID of the order to remove
* @returns The removed order if found, or `undefined`
*/
ob.cancel(orderID: string)For example:
ob.cancel("myUniqueID-Sell-1-with-100")
asks: 110 -> 5
100 -> 1 110 -> 5
-------------- -> --------------
bids: 90 -> 5 90 -> 5
80 -> 1 80 -> 1When creating an order, the library returns an IProcessOrder object:
interface IProcessOrder {
done: IOrder[]; // Fully consumed orders
activated: IStopOrder[]; // Triggered stop orders (stop limit, stop market, OCO)
partial: ILimitOrder | null; // Partially consumed limit order (if any)
quantityLeft: number; // Unfilled quantity of the taker order
partialQuantityProcessed: number; // Quantity consumed from the order in 'partial'
err: OrderBookError | null;
log?: JournalLog; // Journal entry (only when enableJournaling is true)
stpExpired?: IOrder[]; // Orders expired due to Self-Trade Prevention
}The taker order does NOT always appear in the result arrays.
| Order Type | Fill Status | Taker in done[] |
Taker in partial |
quantityLeft |
|---|---|---|---|---|
| LIMIT | Fully filled | โ YES | โ NO | 0 |
| LIMIT | Partially filled | โ NO | โ YES | > 0 |
| MARKET | Fully or partially filled | โ NO | โ NO | >= 0 |
Key facts:
- Market orders never appear in
done[]orpartial- only the matched maker orders appear - Limit orders fully filled: Taker appears in
done[]alongside matched makers - Limit orders partially filled: Taker appears in
partial, matched makers appear indone[] quantityLeft: Always represents unfilled quantity of the taker, regardless of where it appears
Note on
activated[]: When a stop limit, stop market, or OCO order is triggered, the triggered order(s) appear in theactivatedarray. These are orders that were resting in the stop book and have now been activated for matching.Note on
stpExpired[]: When Self-Trade Prevention is configured and triggered, expired orders are listed instpExpired. See Self-Trade Prevention (STP) for details.
This represents how much of the order in partial was processed, not how much is left.
- If
partialcontains the taker (partially filled limit order): represents amount of taker that was filled - If
partialcontains a maker (partially consumed resting order): represents amount of maker that was consumed
Example 1 - Taker in partial:
// 10-unit buy order, only 5 available
{
done: [{ id: 'maker-1', size: 5 }], // Fully consumed maker
partial: { id: 'taker', size: 5 }, // Taker (5 still unfilled)
quantityLeft: 5, // 5 units of taker unfilled
partialQuantityProcessed: 5 // 5 units of taker were filled
}Example 2 - Maker in partial:
// 8-unit buy order, 20 available from one maker
{
done: [{ id: 'taker', size: 8 }], // Fully filled taker
partial: { id: 'maker-1', size: 12 }, // Maker: 12 still unfilled (20 - 8)
quantityLeft: 0, // Taker fully filled
partialQuantityProcessed: 8 // 8 units of maker were consumed
}
partial.sizealways represents the remaining quantity of that order, not what was consumed. In this example, the maker started with size 20, had 8 consumed, sopartial.sizeis 12 (what's left on the book).
// Market order for 10 units (10 available)
book.createOrder({ type: 'market', id: 'buy-1', size: 10, side: 'buy' })
// Result:
{
done: [{ id: 'sell-1', side: 'sell', size: 10 }], // Matched maker only
partial: null, // Taker NOT here
quantityLeft: 0, // Fully filled
partialQuantityProcessed: 0
}// Limit order for 10 units (10 available)
book.createOrder({ type: 'limit', id: 'buy-1', price: 100, size: 10, side: 'buy' })
// Result:
{
done: [
{ id: 'sell-1', side: 'sell', size: 10 }, // Matched maker
{ id: 'buy-1', side: 'buy', size: 10 } // Taker โ
],
partial: null,
quantityLeft: 0,
partialQuantityProcessed: 0
}// Limit order for 10 units (only 5 available)
book.createOrder({ type: 'limit', id: 'buy-1', price: 100, size: 10, side: 'buy' })
// Result:
{
done: [{ id: 'sell-1', side: 'sell', size: 5 }], // Fully consumed maker
partial: { id: 'buy-1', side: 'buy', size: 5 }, // Taker โ
(5 unfilled)
quantityLeft: 5, // 5 units unfilled
partialQuantityProcessed: 5 // 5 units filled
}Inspired by Binance's Self-Trade Prevention โ prevents orders from the same account from matching against each other.
Each order can carry an accountId and a stpMode. When a taker order enters the book and would match against a maker order with the same accountId, the STP mode of the taker order determines what happens:
| Mode | Effect |
|---|---|
NONE |
No prevention โ orders match normally |
EXPIRE_MAKER |
The resting maker order(s) expire; the taker order continues |
EXPIRE_TAKER |
The taker order is rejected; the resting maker order(s) stay on the book |
EXPIRE_BOTH |
Both the taker and the matching maker order(s) expire |
The STP mode of the taker order always takes precedence โ the mode stored on a resting maker order is ignored for STP purposes.
Add accountId and stpMode to any order:
import { OrderBook, SelfTradePreventionMode, Side } from 'nodejs-order-book'
const ob = new OrderBook()
// Place a resting limit order from account "alice"
ob.limit({
side: Side.BUY,
id: 'maker-order',
size: 5,
price: 100,
accountId: 'alice',
})
// Taker from the same account with STP enabled
const result = ob.limit({
side: Side.SELL,
id: 'taker-order',
size: 3,
price: 90,
accountId: 'alice',
stpMode: SelfTradePreventionMode.EXPIRE_MAKER,
})
// Check which orders expired due to STP
console.log(result.stpExpired) // [{ id: 'maker-order', ... }]When STP is triggered, the response (IProcessOrder) includes:
| Field | Type | Description |
|---|---|---|
stpExpired |
IOrder[] | undefined |
Orders removed from the book due to STP |
err |
OrderBookError | null |
Error with code: 1202 and message: "Self-trade prevention triggered" for EXPIRE_TAKER / EXPIRE_BOTH |
STP rejections return error code 1202:
import { ErrorCodes } from 'nodejs-order-book'
assert.equal(result.err?.code, ErrorCodes.STP_TRIGGERED)
// โ 1202
assert.equal(result.err?.message, 'Self-trade prevention triggered')Maker BUY @ 100 qty: 5 account: "alice"
Maker BUY @ 90 qty: 5 account: "alice"
Taker SELL @ 90 qty: 3 account: "alice" mode: EXPIRE_MAKER
The two resting buy orders share the same account as the taker. With EXPIRE_MAKER, they are removed from the book and reported in stpExpired[]. The taker order (size 3) is placed on the book as a new maker.
stpExpired โ [maker-buy-100, maker-buy-90]
err โ null
Maker BUY @ 100 qty: 5 account: "alice"
Taker SELL @ 90 qty: 3 account: "alice" mode: EXPIRE_TAKER
The taker order is rejected immediately. The resting maker order remains untouched on the book.
stpExpired โ undefined
err โ { code: 1202, message: "Self-trade prevention triggered" }
Maker BUY @ 100 qty: 5 account: "alice"
Taker SELL @ 90 qty: 3 account: "alice" mode: EXPIRE_BOTH
The maker is removed from the book and the taker is rejected. Both sides expire.
stpExpired โ [maker-buy-100]
err โ { code: 1202, message: "Self-trade prevention triggered" }
Maker BUY @ 100 qty: 5 account: "alice"
Taker SELL @ 90 qty: 3 account: "bob" mode: EXPIRE_MAKER
The accounts differ, so STP does not trigger. The orders match normally.
done โ [filled trade summary]
stpExpired โ undefined
Maker BUY @ 100 qty: 5 account: "alice"
Taker SELL @ 90 qty: 3 account: "alice" mode: NONE
Even though both orders are from the same account, NONE mode allows the match.
done โ [filled trade summary]
stpExpired โ undefined
Maker BUY @ 100 qty: 5 account: "alice"
Taker SELL (market) qty: 3 account: "alice" mode: EXPIRE_MAKER
The resting maker is expired via STP. The market order has no remaining liquidity, so it also expires.
stpExpired โ [maker-buy-100]
err โ null
Maker "alice" BUY @ 100 qty: 5
Maker "bob" BUY @ 100 qty: 5
Taker "alice" SELL @ 90 qty: 8 mode: EXPIRE_MAKER
At price level 100, alice's maker is expired (stpExpired), while bob's maker matches normally (done). The remaining taker quantity (3) rests on the book.
stpExpired โ [maker-alice-100]
done โ [maker-bob-100]
Stop orders preserve the stpMode they were created with. When a stop order is triggered and becomes a taker, its STP mode is applied at match time.
ob.createOrder({
type: OrderType.STOP_LIMIT,
side: Side.BUY,
size: 3,
price: 110,
stopPrice: 108,
accountId: 'alice',
stpMode: SelfTradePreventionMode.EXPIRE_MAKER,
})- STP is evaluated using the taker order's mode, regardless of what mode the resting maker orders carry.
- If no
accountIdis specified on either side, STP is not triggered (backward compatible). - If no
stpModeis specified, it defaults toNONE(no prevention). - Stop market and stop limit orders preserve the
stpModeand apply it when triggered. - Modify operations reset
stpModetoNONE.
The order book can be initialized with the following options by passing them to the constructor:
A snapshot represents the state of the order book at a specific point in time. It includes:
asks: List of ask orders, each with apriceand a list of associatedorders.bids: List of bid orders, each with apriceand a list of associatedorders.stopBook: An object withbidsandasksproperties related to everyStopOrderin the order book.ts: A Unix timestamp of when the snapshot was taken.lastOp: The ID of the last operation included in the snapshot.
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.
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.
const ob = new OrderBook({ enableJournaling: true })
// After every order, save the log to the database
const order = ob.limit({ side: "sell", id: "uniqueID", size: 55, price: 100 })
await saveLog(order.log)
// ... after some time, take a snapshot and save it
const snapshot = ob.snapshot()
await saveSnapshot(JSON.stringify(snapshot))
// Safe to remove logs before the snapshot's lastOp
await removePreviousLogs(snapshot.lastOp)
// On server restart, restore from snapshot + logs
const logs = await getLogs()
const snapshot = await getSnapshot()
const ob = new OrderBook({
snapshot: JSON.parse(snapshot),
journal: logs,
enableJournaling: true,
})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.
const logs = await getLogs()
const ob = new OrderBook({ journal: logs, enableJournalLog: true })Combining snapshots with journaling gives you full state persistence and auditability.
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.
const ob = new OrderBook({ enableJournaling: true }) // false by default
// After every operation, save the log
const order = ob.limit({ side: "sell", id: "uniqueID", size: 55, price: 100 })
await saveLog(order.log)- Node.js 18+
- npm (or yarn / pnpm)
# Install dependencies
npm install
# Build all distributions (CJS, ESM, types)
npm run build
# Run tests
npm run test
# Run tests with coverage
npm run test:cov
# Run benchmarks (build first)
npm run bench| Command | Description |
|---|---|
npm run build |
Build CJS, ESM, and type declarations |
npm run test |
Run unit tests |
npm run test:dev |
Run tests in watch mode |
npm run test:cov |
Run tests with lcov coverage report |
npm run bench |
Run performance benchmarks |
npm run lint |
Check code style with Biome |
npm run lint:fix |
Auto-fix lint issues |
npm run clean |
Clean build output |
npm run package |
Build and pack for local testing |
Contributions are welcome! Please read the Contributing Guidelines before getting started.
- Fork the repository
- Create your feature branch (
git checkout -b my-feature) - Commit your changes (
git commit -m 'feat: add my feature') - Push to the branch (
git push origin my-feature) - Open a Pull Request
Please also refer to the Code of Conduct and Security Policy.
Copyright Andrea Fassina, Licensed under MIT.
If this project saves you time or helps your business, consider buying me a coffee.
- USDT (TRC20):
TXArNxsq2Ee8Jvsk45PudVio52Joiq1yEe - BTC:
1GYDVSAQNgG7MFhV5bk15XJy3qoE4NFenp - BTC (BEP20):
0xf673ee099be8129ec05e2f549d96ebea24ac5d97 - ETH (ERC20):
0xf673ee099be8129ec05e2f549d96ebea24ac5d97 - BNB (BEP20):
0xf673ee099be8129ec05e2f549d96ebea24ac5d97