diff --git a/README.md b/README.md index 46e7fab..e5f3bab 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ Ultra-fast matching engine written in TypeScript - Standard price-time priority - Supports both market and limit orders -- Supports conditional orders (Stop Market and Stop Limit) +- Supports conditional orders (Stop Market, Stop Limit and OCO) - Supports time in force GTC, FOK and IOC - Supports order cancelling - Supports order price and/or size updating @@ -57,7 +57,7 @@ const lob = new OrderBook() Then you'll be able to use next primary functions: ```js -lob.createOrder({ type: 'limit' | 'market' | 'stop_limit' | 'stop_market', side: 'buy' | 'sell', size: number, price?: number, id?: string, stopPrice?: number, timeInForce?: 'GTC' | 'FOK' | 'IOC' }) +lob.createOrder({ type: 'limit' | 'market' | 'stop_limit' | 'stop_market' | 'oco', side: 'buy' | 'sell', size: number, price?: number, id?: string, stopPrice?: number, timeInForce?: 'GTC' | 'FOK' | 'IOC' }) lob.limit({ id: string, side: 'buy' | 'sell', size: number, price: number, timeInForce?: 'GTC' | 'FOK' | 'IOC' }) @@ -65,6 +65,8 @@ lob.market({ side: 'buy' | 'sell', size: number }) lob.stopLimit({ id: string, side: 'buy' | 'sell', size: number, price: number, stopPrice: number, timeInForce?: 'GTC' | 'FOK' | 'IOC' }) +lob.oco({ id: string, side: 'buy' | 'sell', size: number, price: number, stopPrice: number, stopLimitPrice: number, timeInForce?: 'GTC' | 'FOK' | 'IOC', stopLimitTimeInForce?: 'GTC' | 'FOK' | 'IOC' }) + lob.stopMarket({ side: 'buy' | 'sell', size: number, stopPrice: number }) lob.modify(orderID: string, { side: 'buy' | 'sell', size: number, price: number }) @@ -79,17 +81,20 @@ To add an order to the order book you can call the general `createOrder()` funct ### Create Order ```js -// Create a limit order +// Create limit order createOrder({ type: 'limit', side: 'buy' | 'sell', size: number, price: number, id: string, timeInForce?: 'GTC' | 'FOK' | 'IOC' }) -// Create a market order +// Create market order createOrder({ type: 'market', side: 'buy' | 'sell', size: number }) -// Create a stop limit order +// Create stop limit order createOrder({ type: 'stop_limit', side: 'buy' | 'sell', size: number, price: number, id: string, stopPrice: number, timeInForce?: 'GTC' | 'FOK' | 'IOC' }) -// Create a stop market order +// Create stop market order createOrder({ type: 'stop_market', side: 'buy' | 'sell', size: number, stopPrice: number }) + +// Create OCO order +createOrder({ type: 'oco', side: 'buy' | 'sell', size: number, stopPrice: number, stopLimitPrice: number, timeInForce?: 'GTC' | 'FOK' | 'IOC', stopLimitTimeInForce?: 'GTC' | 'FOK' | 'IOC' }) ``` ### Create Limit Order @@ -228,6 +233,35 @@ stopLimit({ side: 'buy' | 'sell', id: string, size: number, price: number, stopP stopMarket({ side: 'buy' | 'sell', size: number, stopPrice: number }) ``` +### Create OCO (One-Cancels-the-Other) Order + +```js +/** + * Create an OCO (One-Cancels-the-Other) order. + * OCO order combines a `stop_limit` order and a `limit` order, where if stop price + * is triggered or limit order is fully or partially fulfilled, the other is canceled. + * Both orders have the same `side` and `size`. If you cancel one of the orders, the + * entire OCO order pair will be canceled. + * + * For BUY orders the `stopPrice` must be above the current price and the `price` below the current price + * For SELL orders the `stopPrice` must be below the current price and the `price` above the current price + * + * See {@link OCOOrderOptions} for details. + * + * @param options + * @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 of the `limit` order at which the order is to be fullfilled, in units of the quote currency + * @param options.stopPrice - The price at which the `stop_limit` order will be triggered. + * @param options.stopLimitPrice - The price of the `stop_limit` order at which the order is to be fullfilled, in units of the quote currency. + * @param options.timeInForce - Time-in-force of the `limit` order. Type supported are: GTC, FOK, IOC. Default is GTC + * @param options.stopLimitTimeInForce - Time-in-force of the `stop_limit` order. Type supported are: GTC, FOK, IOC. Default is GTC + * @returns An object with the result of the processed order or an error. See {@link IProcessOrder} for the returned data structure + */ +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 ```js diff --git a/src/errors.ts b/src/errors.ts index 7f75d61..036b60b 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -1,13 +1,13 @@ export enum ERROR { Default = 'Something wrong', ErrInsufficientQuantity = 'orderbook: insufficient quantity to calculate price', + ErrInvalidConditionalOrder = 'orderbook: Stop-Limit Order (BUY: marketPrice < stopPrice <= price, SELL: marketPrice > stopPrice >= price). Stop-Market Order (BUY: marketPrice < stopPrice, SELL: marketPrice > stopPrice). OCO order (BUY: price < marketPrice < stopPrice, SELL: price > marketPrice > stopPrice)', ErrInvalidOrderType = "orderbook: supported order type are 'limit' and 'market'", ErrInvalidPrice = 'orderbook: invalid order price', ErrInvalidPriceLevel = 'orderbook: invalid order price level', ErrInvalidPriceOrQuantity = 'orderbook: invalid order price or quantity', ErrInvalidQuantity = 'orderbook: invalid order quantity', ErrInvalidSide = "orderbook: given neither 'bid' nor 'ask'", - ErrInvalidStopPrice = 'orderbook: Invalid Stop Price. For Stop-Limit Order (BUY: marketPrice < stopPrice <= price, SELL: marketPrice > stopPrice >= price). For Stop-Market Order (BUY: marketPrice < stopPrice, SELL: marketPrice > stopPrice)', ErrInvalidTimeInForce = "orderbook: supported time in force are 'GTC', 'IOC' and 'FOK'", ErrLimitFOKNotFillable = 'orderbook: limit FOK order not fillable', ErrOrderExists = 'orderbook: order already exists', @@ -33,8 +33,8 @@ export const CustomError = (error?: ERROR | string): Error => { return new Error(ERROR.ErrOrderNotFound) case ERROR.ErrInvalidSide: return new Error(ERROR.ErrInvalidSide) - case ERROR.ErrInvalidStopPrice: - return new Error(ERROR.ErrInvalidStopPrice) + case ERROR.ErrInvalidConditionalOrder: + return new Error(ERROR.ErrInvalidConditionalOrder) case ERROR.ErrInvalidOrderType: return new Error(ERROR.ErrInvalidOrderType) case ERROR.ErrInvalidTimeInForce: diff --git a/src/order.ts b/src/order.ts index 4b25097..5a9c2bb 100644 --- a/src/order.ts +++ b/src/order.ts @@ -75,12 +75,15 @@ export class LimitOrder extends BaseOrder { private _price: number private readonly _timeInForce: TimeInForce private readonly _isMaker: boolean + // Refers to the linked Stop Limit order stopPrice + private readonly _ocoStopPrice?: number constructor (options: InternalLimitOrderOptions) { super(options) this._type = options.type this._price = options.price this._timeInForce = options.timeInForce this._isMaker = options.isMaker + this._ocoStopPrice = options.ocoStopPrice } // Getter for order type @@ -108,6 +111,10 @@ export class LimitOrder extends BaseOrder { return this._isMaker } + get ocoStopPrice (): number | undefined { + return this._ocoStopPrice + } + toString = (): string => `${this._id}: type: ${this.type} @@ -170,7 +177,7 @@ export class StopMarketOrder extends BaseOrder { side: this._side, size: this._size, origSize: this._origSize, - stopPrice: this.stopPrice, + stopPrice: this._stopPrice, time: this._time }) } @@ -181,6 +188,8 @@ export class StopLimitOrder extends BaseOrder { private readonly _stopPrice: number private readonly _timeInForce: TimeInForce private readonly _isMaker: boolean + // It's true when there is a linked Limit Order + private readonly _isOCO: boolean constructor (options: InternalStopLimitOrderOptions) { super(options) this._type = options.type @@ -188,6 +197,7 @@ export class StopLimitOrder extends BaseOrder { this._stopPrice = options.stopPrice this._timeInForce = options.timeInForce this._isMaker = options.isMaker + this._isOCO = options.isOCO ?? false } // Getter for order type @@ -220,6 +230,11 @@ export class StopLimitOrder extends BaseOrder { return this._isMaker } + // Getter for order isOCO + get isOCO (): boolean { + return this._isOCO + } + toString = (): string => `${this._id}: type: ${this.type} @@ -241,8 +256,8 @@ export class StopLimitOrder extends BaseOrder { size: this._size, origSize: this._origSize, price: this._price, - stopPrice: this.stopPrice, - timeInForce: this.timeInForce, + stopPrice: this._stopPrice, + timeInForce: this._timeInForce, time: this._time, isMaker: this._isMaker }) diff --git a/src/orderbook.ts b/src/orderbook.ts index 5f8d6bd..d9d32e9 100644 --- a/src/orderbook.ts +++ b/src/orderbook.ts @@ -24,7 +24,9 @@ import { MarketOrderOptions, StopMarketOrderOptions, LimitOrderOptions, - StopLimitOrderOptions + StopLimitOrderOptions, + OCOOrderOptions, + StopOrder } from './types' const validTimeInForce = Object.values(TimeInForce) @@ -86,8 +88,10 @@ export class OrderBook { * @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 fullfilled, in units of the quote currency. Param only for limit order * @param options.orderID - Unique order ID. Param only for limit order - * @param options.timeInForce - Time-in-force supported are: `GTC` (default), `FOK`, `IOC`. Param only for limit order * @param options.stopPrice - The price at which the order will be triggered. Used with `stop_limit` and `stop_market` order. + * @param options.stopLimitPrice - The price at which the order will be triggered. Used with `stop_limit` and `stop_market` order. + * @param options.timeInForce - Time-in-force supported are: `GTC` (default), `FOK`, `IOC`. Param only for limit order + * @param options.stopLimitTimeInForce - Time-in-force supported are: `GTC` (default), `FOK`, `IOC`. Param only for limit order * @returns An object with the result of the processed order or an error. See {@link IProcessOrder} for the returned data structure */ public createOrder (options: CreateOrderOptions): IProcessOrder @@ -104,6 +108,8 @@ export class OrderBook { * @param orderID - Unique order ID. Param only for limit order * @param timeInForce - Time-in-force supported are: `GTC` (default), `FOK`, `IOC`. Param only for limit order * @param stopPrice - The price at which the order will be triggered. Used with `stop_limit` and `stop_market` order. + * @param stopLimitPrice - The price at which the order will be triggered. Used with `stop_limit` and `stop_market` order. + * @param stopLimitTimeInForce - Time-in-force supported are: `GTC` (default), `FOK`, `IOC`. Param only for limit order * @returns An object with the result of the processed order or an error. See {@link IProcessOrder} for the returned data structure */ public createOrder ( @@ -115,7 +121,9 @@ export class OrderBook { price?: number, orderID?: string, timeInForce?: TimeInForce, - stopPrice?: number + stopPrice?: number, + stopLimitPrice?: number, + stopLimitTimeInForce?: TimeInForce ): IProcessOrder public createOrder ( @@ -125,7 +133,9 @@ export class OrderBook { price?: number, orderID?: string, timeInForce = TimeInForce.GTC, - stopPrice?: number + stopPrice?: number, + stopLimitPrice?: number, + stopLimitTimeInForce = TimeInForce.GTC ): IProcessOrder { let options: CreateOrderOptions // We don't want to test the deprecated signature. @@ -144,7 +154,10 @@ export class OrderBook { id: orderID, timeInForce, // @ts-expect-error - stopPrice + stopPrice, + // @ts-expect-error + stopLimitPrice, + stopLimitTimeInForce } /* c8 ignore stop */ } else if (typeof typeOrOptions === 'object') { @@ -164,6 +177,8 @@ export class OrderBook { return this.stopMarket(options) case OrderType.STOP_LIMIT: return this.stopLimit(options) + case OrderType.OCO: + return this.oco(options) default: return { done: [], @@ -277,9 +292,9 @@ export class OrderBook { price !== undefined ) { return this._limit({ + id: orderID, side: sideOrOptions, size, - id: orderID, price, timeInForce }) @@ -309,6 +324,33 @@ export class OrderBook { return this._stopLimit(options) } + /** + * Create an OCO (One-Cancels-the-Other) order. + * OCO order combines a `stop_limit` order and a `limit` order, where if stop price + * is triggered or limit order is fully or partially fulfilled, the other is canceled. + * Both orders have the same `side` and `size`. If you cancel one of the orders, the + * entire OCO order pair will be canceled. + * + * For BUY orders the `stopPrice` must be above the current price and the `price` below the current price + * For SELL orders the `stopPrice` must be below the current price and the `price` above the current price + * + * See {@link OCOOrderOptions} for details. + * + * @param options + * @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 of the `limit` order at which the order is to be fullfilled, in units of the quote currency + * @param options.stopPrice - The price at which the `stop_limit` order will be triggered. + * @param options.stopLimitPrice - The price of the `stop_limit` order at which the order is to be fullfilled, in units of the quote currency. + * @param options.timeInForce - Time-in-force of the `limit` order. Type supported are: GTC, FOK, IOC. Default is GTC + * @param options.stopLimitTimeInForce - Time-in-force of the `stop_limit` order. Type supported are: GTC, FOK, IOC. Default is GTC + * @returns An object with the result of the processed order or an error. See {@link IProcessOrder} for the returned data structure + */ + public oco = (options: OCOOrderOptions): IProcessOrder => { + return this._oco(options) + } + /** * Modify an existing order with given ID. When an order is modified by price or quantity, * it will be deemed as a new entry. Under the price-time-priority algorithm, orders are @@ -475,15 +517,12 @@ export class OrderBook { let quantityToTrade = options.size let iter let sideToProcess: OrderSide - let oppositeSide: Side if (options.side === Side.BUY) { iter = this.asks.minPriceQueue sideToProcess = this.asks - oppositeSide = Side.SELL } else { iter = this.bids.maxPriceQueue sideToProcess = this.bids - oppositeSide = Side.BUY } const priceBefore = this._marketPrice while (quantityToTrade > 0 && sideToProcess.len() > 0) { @@ -497,7 +536,9 @@ export class OrderBook { quantityToTrade = quantityLeft } response.quantityLeft = quantityToTrade - this.executeConditionalOrder(oppositeSide, priceBefore, response) + + this.executeConditionalOrder(options.side, priceBefore, response) + if (this.enableJournaling) { response.log = { opId: ++this._lastOp, @@ -510,7 +551,7 @@ export class OrderBook { } private readonly _limit = ( - options: LimitOrderOptions, + options: LimitOrderOptions & { ocoStopPrice?: number }, incomingResponse?: IProcessOrder ): IProcessOrder => { const response = incomingResponse ?? this.validateLimitOrder(options) @@ -521,7 +562,8 @@ export class OrderBook { options.id, options.size, options.price, - options.timeInForce ?? TimeInForce.GTC + options.timeInForce ?? TimeInForce.GTC, + options.ocoStopPrice ) if (this.enableJournaling && order != null) { response.log = { @@ -566,6 +608,46 @@ export class OrderBook { return this._stopOrder(stopLimit, response) } + private readonly _oco = (options: OCOOrderOptions): IProcessOrder => { + const response = this.validateLimitOrder(options) + /* c8 ignore next already validated with limit test */ + if (response.err != null) return response + if (this.validateOCOOrder(options)) { + // We use the same ID for Stop Limit and Limit Order, since + // we check only on limit order for duplicated ids + this._limit( + { + id: options.id, + side: options.side, + size: options.size, + price: options.price, + timeInForce: options.timeInForce, + ocoStopPrice: options.stopPrice + }, + response + ) + /* c8 ignore next already validated with limit test */ + if (response.err != null) return response + + const stopLimit = OrderFactory.createOrder({ + type: OrderType.STOP_LIMIT, + id: options.id, + side: options.side, + size: options.size, + price: options.stopLimitPrice, + stopPrice: options.stopPrice, + isMaker: true, + timeInForce: options.stopLimitTimeInForce ?? TimeInForce.GTC, + isOCO: true + }) + this.stopBook.add(stopLimit) + response.done.push(stopLimit) + } else { + response.err = CustomError(ERROR.ErrInvalidConditionalOrder) + } + return response + } + private readonly _stopOrder = ( stopOrder: StopMarketOrder | StopLimitOrder, response: IProcessOrder @@ -574,7 +656,7 @@ export class OrderBook { this.stopBook.add(stopOrder) response.done.push(stopOrder) } else { - response.err = CustomError(ERROR.ErrInvalidStopPrice) + response.err = CustomError(ERROR.ErrInvalidConditionalOrder) } return response } @@ -596,9 +678,15 @@ export class OrderBook { } } + /** + * Remove an existing order with given ID from the order book + * @param orderID The id of the order to be deleted + * @param internalDeletion Set to true when the delete comes from internal operations + * @returns The removed order if exists or `undefined` + */ private readonly _cancelOrder = ( orderID: string, - skipOpInc: boolean = false + internalDeletion: boolean = false ): ICancelOrder | undefined => { const order = this.orders[orderID] if (order === undefined) return @@ -608,9 +696,15 @@ export class OrderBook { const response: ICancelOrder = { order: side.remove(order) } + + // Delete OCO Order only when the delete request comes from user + if (!internalDeletion && order.ocoStopPrice !== undefined) { + response.stopOrder = this.stopBook.remove(order.side, orderID, order.ocoStopPrice) + } + if (this.enableJournaling) { response.log = { - opId: skipOpInc ? this._lastOp : ++this._lastOp, + opId: internalDeletion ? this._lastOp : ++this._lastOp, ts: Date.now(), op: 'd', o: { orderID } @@ -636,26 +730,24 @@ export class OrderBook { orderID: string, size: number, price: number, - timeInForce: TimeInForce + timeInForce: TimeInForce, + ocoStopPrice?: number ): LimitOrder | undefined => { let quantityToTrade = size let sideToProcess: OrderSide let sideToAdd: OrderSide let comparator let iter - let oppositeSide: Side if (side === Side.BUY) { sideToAdd = this.bids sideToProcess = this.asks comparator = this.greaterThanOrEqual iter = this.asks.minPriceQueue - oppositeSide = Side.SELL } else { sideToAdd = this.asks sideToProcess = this.bids comparator = this.lowerThanOrEqual iter = this.bids.maxPriceQueue - oppositeSide = Side.BUY } if (timeInForce === TimeInForce.FOK) { @@ -665,7 +757,6 @@ export class OrderBook { return } } - let bestPrice = iter() const priceBefore = this._marketPrice while ( @@ -684,7 +775,7 @@ export class OrderBook { bestPrice = iter() } - this.executeConditionalOrder(oppositeSide, priceBefore, response) + this.executeConditionalOrder(side, priceBefore, response) let order: LimitOrder if (quantityToTrade > 0) { @@ -696,7 +787,8 @@ export class OrderBook { price, time: Date.now(), timeInForce, - isMaker: true + isMaker: true, + ...(ocoStopPrice !== undefined ? { ocoStopPrice } : {}) }) if (response.done.length > 0) { response.partialQuantityProcessed = size - quantityToTrade @@ -738,44 +830,54 @@ export class OrderBook { } private readonly executeConditionalOrder = ( - oppositeSide: Side, + side: Side, priceBefore: number, response: IProcessOrder ): void => { const pendingOrders = this.stopBook.getConditionalOrders( - oppositeSide, + side, priceBefore, this._marketPrice ) if (pendingOrders.length > 0) { + const toBeExecuted: StopOrder[] = [] + // Before get all orders to be executed and clean up the stop queue + // in order to avoid that an executed limit/market order run against + // the same stop order queue pendingOrders.forEach((queue) => { while (queue.len() > 0) { const headOrder = queue.removeFromHead() - if (headOrder !== undefined) { - if (headOrder.type === OrderType.STOP_MARKET) { - this._market( - { - id: headOrder.id, - side: headOrder.side, - size: headOrder.size - }, - response - ) - } else { - this._limit( - { - id: headOrder.id, - side: headOrder.side, - size: headOrder.size, - price: headOrder.price, - timeInForce: headOrder.timeInForce - }, - response - ) - } - response.activated.push(headOrder) + if (headOrder !== undefined) toBeExecuted.push(headOrder) + } + // Queue is empty now so remove the priceLevel + this.stopBook.removePriceLevel(side, queue.price) + }) + toBeExecuted.forEach((stopOrder) => { + if (stopOrder.type === OrderType.STOP_MARKET) { + this._market( + { + id: stopOrder.id, + side: stopOrder.side, + size: stopOrder.size + }, + response + ) + } else { + if (stopOrder.isOCO) { + this._cancelOrder(stopOrder.id, true) } + this._limit( + { + id: stopOrder.id, + side: stopOrder.side, + size: stopOrder.size, + price: stopOrder.price, + timeInForce: stopOrder.timeInForce + }, + response + ) } + response.activated.push(stopOrder) }) } } @@ -821,6 +923,30 @@ export class OrderBook { } } + /** + * OCO Order: + * Buy: price < marketPrice < stopPrice + * Sell: price > marketPrice > stopPrice + */ + private readonly validateOCOOrder = (options: OCOOrderOptions): boolean => { + let response = false + if ( + options.side === Side.BUY && + options.price < this._marketPrice && + this._marketPrice < options.stopPrice + ) { + response = true + } + if ( + options.side === Side.SELL && + options.price > this._marketPrice && + this._marketPrice > options.stopPrice + ) { + response = true + } + return response + } + private readonly greaterThanOrEqual = (a: number, b: number): boolean => { return a >= b } @@ -869,6 +995,14 @@ export class OrderBook { response.done.push(canceledOrder.order) } } + // Remove linked OCO Stop Order if any + if (headOrder.ocoStopPrice !== undefined) { + this.stopBook.remove( + headOrder.side, + headOrder.id, + headOrder.ocoStopPrice + ) + } this._marketPrice = headOrder.price } } diff --git a/src/stopbook.ts b/src/stopbook.ts index ef01b16..893cdc0 100644 --- a/src/stopbook.ts +++ b/src/stopbook.ts @@ -17,13 +17,27 @@ export class StopBook { stopSide.append(order) } + remove = ( + side: Side, + id: string, + stopPrice: number + ): StopOrder | undefined => { + const stopSide = side === Side.BUY ? this.bids : this.asks + return stopSide.remove(id, stopPrice) + } + + removePriceLevel = (side: Side, priceLevel: number): void => { + const stopSide = side === Side.BUY ? this.bids : this.asks + return stopSide.removePriceLevel(priceLevel) + } + getConditionalOrders = ( - oppositeSide: Side, - upperBound: number, - lowerBound: number + side: Side, + priceBefore: number, + marketPrice: number ): StopQueue[] => { - const stopSide = oppositeSide === Side.BUY ? this.asks : this.bids - return stopSide.between(upperBound, lowerBound) + const stopSide = side === Side.BUY ? this.bids : this.asks + return stopSide.between(priceBefore, marketPrice) } /** @@ -38,6 +52,7 @@ export class StopBook { let response = false const { type, side, stopPrice } = order if (type === OrderType.STOP_LIMIT) { + // Buy: marketPrice < stopPrice <= price if ( side === Side.BUY && marketPrice < stopPrice && @@ -45,6 +60,7 @@ export class StopBook { ) { response = true } + // Sell: marketPrice > stopPrice >= price if ( side === Side.SELL && marketPrice > stopPrice && @@ -53,7 +69,9 @@ export class StopBook { response = true } } else { + // Buy: marketPrice < stopPrice if (side === Side.BUY && marketPrice < stopPrice) response = true + // Sell: marketPrice > stopPrice if (side === Side.SELL && marketPrice > stopPrice) response = true } return response diff --git a/src/stopqueue.ts b/src/stopqueue.ts index bfc7220..23c64d7 100644 --- a/src/stopqueue.ts +++ b/src/stopqueue.ts @@ -11,6 +11,10 @@ export class StopQueue { this._orders = new Denque() } + get price (): number { + return this._price + } + // returns the number of orders in queue len = (): number => { return this._orders.length @@ -18,7 +22,11 @@ export class StopQueue { // remove order from head of queue removeFromHead = (): StopOrder | undefined => { - return this._orders.shift() + // We can't use the shift method here because we need + // to update index in the map, so we use the remove(id) function + const order = this._orders.peekFront() + if (order === undefined) return + return this.remove(order.id) } // adds order to tail of the queue @@ -27,4 +35,21 @@ export class StopQueue { this._ordersMap[order.id] = this._orders.length - 1 return order } + + // removes order from the queue + remove = (id: string): StopOrder | undefined => { + const deletedOrderIndex = this._ordersMap[id] + if (deletedOrderIndex === undefined) return + + const deletedOrder = this._orders.removeOne(deletedOrderIndex) + // eslint-disable-next-line + delete this._ordersMap[id] + // Update all orders indexes where index is greater than the deleted one + for (const orderId in this._ordersMap) { + if (this._ordersMap[orderId] > deletedOrderIndex) { + this._ordersMap[orderId] -= 1 + } + } + return deletedOrder + } } diff --git a/src/stopside.ts b/src/stopside.ts index 66cc8c7..8676fcb 100644 --- a/src/stopside.ts +++ b/src/stopside.ts @@ -2,10 +2,12 @@ import createRBTree from 'functional-red-black-tree' import { Side } from './side' import { StopQueue } from './stopqueue' import { StopOrder } from './types' +import { CustomError, ERROR } from './errors' export class StopSide { private _priceTree: createRBTree.Tree private _prices: { [key: string]: StopQueue } = {} + private readonly _side: Side constructor (side: Side) { const compare = @@ -13,6 +15,7 @@ export class StopSide { ? (a: number, b: number) => a - b : (a: number, b: number) => b - a this._priceTree = createRBTree(compare) + this._side = side } // appends order to definite price level @@ -27,15 +30,52 @@ export class StopSide { return this._prices[strPrice].append(order) } - // Get orders queue between upper and lower price levels - between = (upperBound: number, lowerBound: number): StopQueue[] => { + // removes order from definite price level + remove = (id: string, stopPrice: number): StopOrder | undefined => { + const strPrice = stopPrice.toString() + if (this._prices[strPrice] === undefined) { + throw CustomError(ERROR.ErrInvalidPriceLevel) + } + const deletedOrder = this._prices[strPrice].remove(id) + if (this._prices[strPrice].len() === 0) { + /* eslint-disable @typescript-eslint/no-dynamic-delete */ + delete this._prices[strPrice] + this._priceTree = this._priceTree.remove(stopPrice) + } + return deletedOrder + } + + removePriceLevel = (priceLevel: number): void => { + /* eslint-disable @typescript-eslint/no-dynamic-delete */ + delete this._prices[priceLevel.toString()] + this._priceTree = this._priceTree.remove(priceLevel) + } + + // Get orders queue between two price levels + between = (priceBefore: number, marketPrice: number): StopQueue[] => { const queues: StopQueue[] = [] + let lowerBound = priceBefore + let upperBound = marketPrice + const highest = Math.max(priceBefore, marketPrice) + const lowest = Math.min(priceBefore, marketPrice) + if (this._side === Side.BUY) { + lowerBound = highest + upperBound = lowest - 1 + } else { + lowerBound = lowest + upperBound = highest + 1 + } this._priceTree.forEach( - (_, queue) => { - queues.push(queue) + (price, queue) => { + if ( + (this._side === Side.BUY && price >= lowest) || + (this._side === Side.SELL && price <= highest) + ) { + queues.push(queue) + } }, lowerBound, // Inclusive - upperBound + 1 // Exclusive (so we add +1) + upperBound // Exclusive (so we add +-1 depending on the side) ) return queues } diff --git a/src/types.ts b/src/types.ts index e63c6dd..7f80b1b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -4,6 +4,7 @@ import type { Side } from './side' export enum OrderType { LIMIT = 'limit', MARKET = 'market', + OCO = 'oco', STOP_LIMIT = 'stop_limit', STOP_MARKET = 'stop_market', } @@ -52,11 +53,12 @@ interface ILimitOrderOptions extends InternalBaseOrderOptions { } export interface InternalLimitOrderOptions extends ILimitOrderOptions { type: OrderType.LIMIT + ocoStopPrice?: number } /** * Specific options for a stop market order. -*/ + */ export interface StopMarketOrderOptions extends MarketOrderOptions { stopPrice: number } @@ -74,6 +76,16 @@ export interface StopLimitOrderOptions extends LimitOrderOptions { export interface InternalStopLimitOrderOptions extends ILimitOrderOptions { type: OrderType.STOP_LIMIT stopPrice: number + isOCO?: boolean +} + +/** + * Specific options for oco order. + */ +export interface OCOOrderOptions extends StopLimitOrderOptions { + stopPrice: number + stopLimitPrice: number + stopLimitTimeInForce?: TimeInForce } /** @@ -141,6 +153,10 @@ export type OrderOptions = | InternalStopLimitOrderOptions | InternalStopMarketOrderOptions +export type StopOrderOptions = + | StopMarketOrderOptions + | StopLimitOrderOptions + | OCOOrderOptions /** * Represents the result of processing an order. */ @@ -188,6 +204,7 @@ export interface CancelOrderOptions { */ export interface ICancelOrder { order: LimitOrder + stopOrder?: StopOrder /** Optional log related to the order cancellation. */ log?: CancelOrderJournalLog } @@ -258,10 +275,11 @@ export type JournalLog = | CancelOrderJournalLog export type CreateOrderOptions = - | { type: OrderType.MARKET } & MarketOrderOptions - | { type: OrderType.LIMIT } & LimitOrderOptions - | { type: OrderType.STOP_MARKET } & StopMarketOrderOptions - | { type: OrderType.STOP_LIMIT } & StopLimitOrderOptions + | ({ type: OrderType.MARKET } & MarketOrderOptions) + | ({ type: OrderType.LIMIT } & LimitOrderOptions) + | ({ type: OrderType.STOP_MARKET } & StopMarketOrderOptions) + | ({ type: OrderType.STOP_LIMIT } & StopLimitOrderOptions) + | ({ type: OrderType.OCO } & OCOOrderOptions) /** * Options for configuring the order book. diff --git a/test/order.test.ts b/test/order.test.ts index ab8c5e1..04f7a05 100644 --- a/test/order.test.ts +++ b/test/order.test.ts @@ -18,41 +18,44 @@ void test('it should create LimitOrder', ({ equal, same, end }) => { const price = 100 const time = Date.now() const timeInForce = TimeInForce.IOC - const order = OrderFactory.createOrder({ - id, - type, - side, - size, - price, - time, - timeInForce, - isMaker: false - }) - equal(order instanceof LimitOrder, true) - equal(order.id, id) - equal(order.type, type) - equal(order.side, side) - equal(order.size, size) - equal(order.origSize, size) - equal(order.price, price) - equal(order.time, time) - equal(order.timeInForce, timeInForce) - equal(order.isMaker, false) - same(order.toObject(), { - id, - type, - side, - size, - origSize: size, - price, - time, - timeInForce, - isMaker: order.isMaker - }) - equal( - order.toString(), - `${id}: + { + const order = OrderFactory.createOrder({ + id, + type, + side, + size, + price, + time, + timeInForce, + isMaker: false + }) + + equal(order instanceof LimitOrder, true) + equal(order.id, id) + equal(order.type, type) + equal(order.side, side) + equal(order.size, size) + equal(order.origSize, size) + equal(order.price, price) + equal(order.time, time) + equal(order.timeInForce, timeInForce) + equal(order.isMaker, false) + equal(order.ocoStopPrice, undefined) + same(order.toObject(), { + id, + type, + side, + size, + origSize: size, + price, + time, + timeInForce, + isMaker: order.isMaker + }) + equal( + order.toString(), + `${id}: type: ${type} side: ${side} size: ${size} @@ -61,10 +64,49 @@ void test('it should create LimitOrder', ({ equal, same, end }) => { time: ${time} timeInForce: ${timeInForce} isMaker: ${false as unknown as string}` - ) - equal( - order.toJSON(), - JSON.stringify({ + ) + equal( + order.toJSON(), + JSON.stringify({ + id, + type, + side, + size, + origSize: size, + price, + time, + timeInForce, + isMaker: false + }) + ) + } + + { + // Limit Order with ocoStopPrice + const ocoStopPrice = 10 + const order = OrderFactory.createOrder({ + id, + type, + side, + size, + price, + time, + timeInForce, + isMaker: false, + ocoStopPrice + }) + equal(order instanceof LimitOrder, true) + equal(order.id, id) + equal(order.type, type) + equal(order.side, side) + equal(order.size, size) + equal(order.origSize, size) + equal(order.price, price) + equal(order.time, time) + equal(order.timeInForce, timeInForce) + equal(order.isMaker, false) + equal(order.ocoStopPrice, ocoStopPrice) + same(order.toObject(), { id, type, side, @@ -73,9 +115,35 @@ void test('it should create LimitOrder', ({ equal, same, end }) => { price, time, timeInForce, - isMaker: false + isMaker: order.isMaker }) - ) + equal( + order.toString(), + `${id}: + type: ${type} + side: ${side} + size: ${size} + origSize: ${size} + price: ${price} + time: ${time} + timeInForce: ${timeInForce} + isMaker: ${false as unknown as string}` + ) + equal( + order.toJSON(), + JSON.stringify({ + id, + type, + side, + size, + origSize: size, + price, + time, + timeInForce, + isMaker: false + }) + ) + } end() }) @@ -146,44 +214,46 @@ void test('it should create StopLimitOrder', ({ equal, same, end }) => { const stopPrice = 4 const time = Date.now() const timeInForce = TimeInForce.IOC - const order = OrderFactory.createOrder({ - id, - type, - side, - size, - price, - time, - stopPrice, - timeInForce, - isMaker: true - }) + { + const order = OrderFactory.createOrder({ + id, + type, + side, + size, + price, + time, + stopPrice, + timeInForce, + isMaker: true + }) - equal(order instanceof StopLimitOrder, true) - equal(order.id, id) - equal(order.type, type) - equal(order.side, side) - equal(order.size, size) - equal(order.price, price) - equal(order.origSize, size) - equal(order.stopPrice, stopPrice) - equal(order.timeInForce, timeInForce) - equal(order.isMaker, true) - equal(order.time, time) - same(order.toObject(), { - id, - type, - side, - size, - origSize: size, - price, - stopPrice, - timeInForce, - time, - isMaker: true - }) - equal( - order.toString(), - `${id}: + equal(order instanceof StopLimitOrder, true) + equal(order.id, id) + equal(order.type, type) + equal(order.side, side) + equal(order.size, size) + equal(order.price, price) + equal(order.origSize, size) + equal(order.stopPrice, stopPrice) + equal(order.timeInForce, timeInForce) + equal(order.isMaker, true) + equal(order.time, time) + equal(order.isOCO, false) + same(order.toObject(), { + id, + type, + side, + size, + origSize: size, + price, + stopPrice, + timeInForce, + time, + isMaker: true + }) + equal( + order.toString(), + `${id}: type: ${type} side: ${side} size: ${size} @@ -193,10 +263,56 @@ void test('it should create StopLimitOrder', ({ equal, same, end }) => { timeInForce: ${timeInForce} time: ${time} isMaker: ${true as unknown as string}` - ) - equal( - order.toJSON(), - JSON.stringify({ + ) + equal( + order.toJSON(), + JSON.stringify({ + id, + type, + side, + size, + origSize: size, + price, + stopPrice, + timeInForce, + time, + isMaker: true + }) + ) + // Price setter + const newPrice = 120 + order.price = newPrice + equal(order.price, newPrice) + } + + { + // Stop Limit Order created by OCO order + const order = OrderFactory.createOrder({ + id, + type, + side, + size, + price, + time, + stopPrice, + timeInForce, + isMaker: true, + isOCO: true + }) + + equal(order instanceof StopLimitOrder, true) + equal(order.id, id) + equal(order.type, type) + equal(order.side, side) + equal(order.size, size) + equal(order.price, price) + equal(order.origSize, size) + equal(order.stopPrice, stopPrice) + equal(order.timeInForce, timeInForce) + equal(order.isMaker, true) + equal(order.time, time) + equal(order.isOCO, true) + same(order.toObject(), { id, type, side, @@ -208,11 +324,39 @@ void test('it should create StopLimitOrder', ({ equal, same, end }) => { time, isMaker: true }) - ) - // Price setter - const newPrice = 120 - order.price = newPrice - equal(order.price, newPrice) + equal( + order.toString(), + `${id}: + type: ${type} + side: ${side} + size: ${size} + origSize: ${size} + price: ${price} + stopPrice: ${stopPrice} + timeInForce: ${timeInForce} + time: ${time} + isMaker: ${true as unknown as string}` + ) + equal( + order.toJSON(), + JSON.stringify({ + id, + type, + side, + size, + origSize: size, + price, + stopPrice, + timeInForce, + time, + isMaker: true + }) + ) + // Price setter + const newPrice = 120 + order.price = newPrice + equal(order.price, newPrice) + } end() }) @@ -230,12 +374,12 @@ void test('it should create order without passing a date or id', ({ teardown(() => (Date.now = now)) // @ts-expect-error cannot assign because is readonly // eslint-disable-next-line - teardown(() => (randomUUID = originalRandomUUID)) + teardown(() => (randomUUID = originalRandomUUID)); Date.now = (...m) => fakeTimestamp // @ts-expect-error cannot assign because is readonly // eslint-disable-next-line - randomUUID = () => fakeId + randomUUID = () => fakeId; const type = OrderType.STOP_MARKET const side = Side.BUY diff --git a/test/orderbook.test.ts b/test/orderbook.test.ts index 51420c1..1fe2da4 100644 --- a/test/orderbook.test.ts +++ b/test/orderbook.test.ts @@ -2,7 +2,12 @@ import { test } from 'tap' import { Side } from '../src/side' import { OrderBook } from '../src/orderbook' import { ERROR } from '../src/errors' -import { JournalLog, OrderType, TimeInForce } from '../src/types' +import { + IProcessOrder, + JournalLog, + OrderType, + TimeInForce +} from '../src/types' import { OrderQueue } from '../src/orderqueue' import { LimitOrder, StopLimitOrder, StopMarketOrder } from '../src/order' @@ -366,13 +371,13 @@ void test('test stop_market order', ({ equal, end }) => { size: 1, stopPrice: ob.marketPrice - 10 }) // Below market price - equal(wrongStopPrice.err?.message, ERROR.ErrInvalidStopPrice) + equal(wrongStopPrice.err?.message, ERROR.ErrInvalidConditionalOrder) const wrongStopPrice2 = ob.stopMarket({ side: Side.BUY, size: 1, stopPrice: ob.marketPrice }) // Same as market price - equal(wrongStopPrice2.err?.message, ERROR.ErrInvalidStopPrice) + equal(wrongStopPrice2.err?.message, ERROR.ErrInvalidConditionalOrder) const wrongOtherOrderOption1 = ob.stopMarket({ // @ts-expect-error invalid side side: 'wrong-side', @@ -415,13 +420,13 @@ void test('test stop_market order', ({ equal, end }) => { size: 1, stopPrice: ob.marketPrice + 10 }) // Above market price - equal(wrongStopPrice.err?.message, ERROR.ErrInvalidStopPrice) + equal(wrongStopPrice.err?.message, ERROR.ErrInvalidConditionalOrder) const wrongStopPrice2 = ob.stopMarket({ side: Side.SELL, size: 1, stopPrice: ob.marketPrice }) // Same as market price - equal(wrongStopPrice2.err?.message, ERROR.ErrInvalidStopPrice) + equal(wrongStopPrice2.err?.message, ERROR.ErrInvalidConditionalOrder) // Add a stop market SELL order const beforeMarketPrice = ob.marketPrice @@ -489,7 +494,7 @@ void test('test stop_limit order', ({ equal, end }) => { stopPrice: ob.marketPrice - 10, // Below market price price: ob.marketPrice }) - equal(wrongStopPrice.err?.message, ERROR.ErrInvalidStopPrice) + equal(wrongStopPrice.err?.message, ERROR.ErrInvalidConditionalOrder) const wrongStopPrice2 = ob.stopLimit({ id: 'fake-id', side: Side.BUY, @@ -497,7 +502,7 @@ void test('test stop_limit order', ({ equal, end }) => { stopPrice: ob.marketPrice, price: ob.marketPrice }) // Same as market price - equal(wrongStopPrice2.err?.message, ERROR.ErrInvalidStopPrice) + equal(wrongStopPrice2.err?.message, ERROR.ErrInvalidConditionalOrder) const wrongOtherOrderOption1 = ob.stopLimit({ // @ts-expect-error invalid side side: 'wrong-side', @@ -569,7 +574,7 @@ void test('test stop_limit order', ({ equal, end }) => { stopPrice: ob.marketPrice + 10, // Above market price price: ob.marketPrice }) - equal(wrongStopPrice.err?.message, ERROR.ErrInvalidStopPrice) + equal(wrongStopPrice.err?.message, ERROR.ErrInvalidConditionalOrder) const wrongStopPrice2 = ob.stopLimit({ id: 'fake-id', side: Side.SELL, @@ -577,7 +582,7 @@ void test('test stop_limit order', ({ equal, end }) => { stopPrice: ob.marketPrice, price: ob.marketPrice }) // Same as market price - equal(wrongStopPrice2.err?.message, ERROR.ErrInvalidStopPrice) + equal(wrongStopPrice2.err?.message, ERROR.ErrInvalidConditionalOrder) // Add a stop limit BUY order const beforeMarketPrice = ob.marketPrice @@ -631,6 +636,193 @@ void test('test stop_limit order', ({ equal, end }) => { end() }) +/** + * OCO Order: + * Buy: price < marketPrice < stopPrice + * Sell: price > marketPrice > stopPrice + */ +void test('test oco order', ({ equal, end }) => { + const ob = new OrderBook() + + addDepth(ob, '', 2) + // We need to create at least on maket order in order to set + // the market price + ob.market({ side: Side.BUY, size: 3, id: 'che-cazz' }) + equal(ob.marketPrice, 110) + + const validate = ( + orderId: string, + side: Side, + price: number, + stopPrice: number, + stopLimitPrice: number, + expect: boolean | ERROR | ((response: IProcessOrder) => void) + ): void => { + const order = ob.oco({ + id: orderId, + side, + size: 1, + price, + stopPrice, + stopLimitPrice, + stopLimitTimeInForce: TimeInForce.GTC + }) + if (typeof expect === 'function') { + expect(order) + } else { + const toValidate = + typeof expect === 'boolean' ? order : order.err?.message + equal(toValidate, expect) + } + } + + // Test OCO Buy + // wrong stopPrice + validate( + 'fake-id', + Side.BUY, + ob.marketPrice - 10, + ob.marketPrice - 10, + ob.marketPrice, + ERROR.ErrInvalidConditionalOrder + ) + // wrong price + validate( + 'fake-id', + Side.BUY, + ob.marketPrice + 10, + ob.marketPrice + 10, + ob.marketPrice, + ERROR.ErrInvalidConditionalOrder + ) + + // Here marketPrice is 110, lowest sell is 110 and highest buy is 90 + // valid OCO with limit to 100 and stopLimit to 120 + validate('oco-buy-1', Side.BUY, 100, 120, 121, (response) => { + const order = response.done[0] as StopLimitOrder + equal(order instanceof StopLimitOrder, true) + equal(order.stopPrice === 120, true) + equal(order.price === 121, true) + equal(order.isOCO, true) + // The limit oco must be the only one inserted in the price level 100 + // @ts-expect-error bids is private + equal(ob.bids.maxPriceQueue()?.price(), 100) + // @ts-expect-error bids is private + equal(ob.bids.maxPriceQueue()?.tail()?.id, 'oco-buy-1') + }) + + // Here marketPrice is 110, lowest sell is 110 and highest buy is 90 + // valid OCO with limit to 100 and stopLimit to 120 + validate('oco-buy-2', Side.BUY, 100, 120, 121, (response) => { + const order = response.done[0] as StopLimitOrder + equal(order instanceof StopLimitOrder, true) + equal(order.stopPrice === 120, true) + equal(order.price === 121, true) + equal(order.isOCO, true) + // The limit oco must be the only one inserted in the price level 100 + // @ts-expect-error bids is private + equal(ob.bids.maxPriceQueue()?.price(), 100) + // @ts-expect-error bids is private + equal(ob.bids.maxPriceQueue()?.tail()?.id, 'oco-buy-2') + }) + + // Test OCO Sell + // wrong stopPrice + validate( + 'fake-id', + Side.SELL, + ob.marketPrice + 10, + ob.marketPrice + 10, + ob.marketPrice, + ERROR.ErrInvalidConditionalOrder + ) + // wrong price + validate( + 'fake-id', + Side.SELL, + ob.marketPrice - 10, + ob.marketPrice - 10, + ob.marketPrice, + ERROR.ErrInvalidConditionalOrder + ) + + // Here marketPrice is 110, lowest sell is 110 and highest buy is 100 + // valid OCO with limit to 120 and stopLimit to 100 + validate('oco-sell-1', Side.SELL, 120, 100, 99, (response) => { + const order = response.done[0] as StopLimitOrder + equal(order instanceof StopLimitOrder, true) + equal(order.stopPrice === 100, true) + equal(order.price === 99, true) + equal(order.isOCO, true) + // The limit oco must be in the tail of the price level 120 + // @ts-expect-error bids is private + equal(ob.asks._prices[120].tail()?.id === 'oco-sell-1', true) + }) + + // Here marketPrice is 110, lowest sell is 110 and highest buy is 90 + // valid OCO with limit to 120 and stopLimit to 100 + validate('oco-sell-2', Side.SELL, 120, 100, 99, (response) => { + const order = response.done[0] as StopLimitOrder + equal(order instanceof StopLimitOrder, true) + equal(order.stopPrice === 100, true) + equal(order.price === 99, true) + equal(order.isOCO, true) + // The limit oco must be in the tail of the price level 120 + // @ts-expect-error bids is private + equal(ob.asks._prices[120].tail()?.id === 'oco-sell-2', true) + }) + + // Removing the limit order should remove also the stop limit + const response = ob.cancel('oco-sell-2') + equal(response?.order.id, 'oco-sell-2') + equal(response?.stopOrder?.id, 'oco-sell-2') + + // Recreate the same OCO with the createOrder method + { + const response = ob.createOrder({ + id: 'oco-sell-2', + type: OrderType.OCO, + size: 1, + side: Side.SELL, + price: 120, + stopPrice: 100, + stopLimitPrice: 99 + }) + const order = response.done[0] as StopLimitOrder + equal(order instanceof StopLimitOrder, true) + equal(order.stopPrice === 100, true) + equal(order.price === 99, true) + equal(order.isOCO, true) + // The limit oco must be in the tail of the price level 120 + // @ts-expect-error bids is private + equal(ob.asks._prices[120].tail()?.id === 'oco-sell-2', true) + } + + { + const response = ob.market({ side: Side.SELL, size: 1 }) + // market order match against the limit order oco-buy-1 and activate the two stop limit + // orders of the oco sell. + equal(response.done[0]?.id === 'oco-buy-1', true) + equal(response.activated[0]?.id === 'oco-sell-1', true) + equal(response.activated[1]?.id === 'oco-sell-2', true) + + // The first stop limit oco-sell-1 match against the limit oco-buy-2 + equal(response.done[1]?.id === 'oco-buy-2', true) + equal(response.done[2]?.id === 'oco-sell-1', true) + + // While the second stop limit oco-sell-2 go to the order book + equal(response.partial?._id === 'oco-sell-2', true) + equal(response.partialQuantityProcessed, 0) + + // Both the side of the stop book must be empty + // @ts-expect-error stopBook is private + equal(ob.stopBook.asks._priceTree.length, 0) + // @ts-expect-error stopBook is private + equal(ob.stopBook.bids._priceTree.length, 0) + } + end() +}) + void test('test modify', ({ equal, end }) => { const ob = new OrderBook() diff --git a/test/orderside.test.ts b/test/orderside.test.ts index 0299023..5217095 100644 --- a/test/orderside.test.ts +++ b/test/orderside.test.ts @@ -139,6 +139,15 @@ void test('it should append/update/remove orders from queue on BUY side', ({ equal(updateOrder1.price, 25) equal(os.toString(), '\n25 -> 10\n20 -> 5') + // @ts-expect-error _priceTree is private property + os._priceTree.values.reduce((previousPrice, curr) => { + // BUY side are in descending order bigger to lower + // @ts-expect-error _price is private property + const currPrice = curr._price + equal(currPrice < previousPrice, true) + return currPrice + }, Infinity) + // Remove the updated order os.remove(updatedOrder) @@ -287,6 +296,15 @@ void test('it should append/update/remove orders from queue on SELL side', ({ equal(updateOrder1.price, 25) equal(os.toString(), '\n25 -> 10\n20 -> 5') + // @ts-expect-error _priceTree is private property + os._priceTree.values.reduce((previousPrice, curr) => { + // SELL side are in ascending order lower to bigger + // @ts-expect-error _price is private property + const currPrice = curr._price + equal(currPrice > previousPrice, true) + return currPrice + }, 0) + // Remove the updated order os.remove(updatedOrder) diff --git a/test/stopbook.test.ts b/test/stopbook.test.ts new file mode 100644 index 0000000..1880826 --- /dev/null +++ b/test/stopbook.test.ts @@ -0,0 +1,149 @@ +import { test } from 'tap' +import { OrderFactory } from '../src/order' +import { OrderType, StopOrder, TimeInForce } from '../src/types' +import { Side } from '../src/side' +import { StopBook } from '../src/stopbook' + +void test('it should add/remove/get order to stop book', ({ equal, same, end }) => { + const ob = new StopBook() + // @ts-expect-error asks is private + equal(ob.asks._priceTree.length, 0) + // @ts-expect-error bids is private + equal(ob.bids._priceTree.length, 0) + + const addOrder = (side: Side, orderId: string, stopPrice: number): void => { + const order = OrderFactory.createOrder({ + id: orderId, + type: OrderType.STOP_LIMIT, + side, + size: 5, + price: stopPrice, + stopPrice, + isMaker: true, + timeInForce: TimeInForce.GTC + }) + ob.add(order) + } + + // Start with SELL side + addOrder(Side.SELL, 'sell-1', 110) + // @ts-expect-error asks is private + equal(ob.asks._priceTree.length, 1) + // @ts-expect-error bids is private + equal(ob.bids._priceTree.length, 0) + + addOrder(Side.SELL, 'sell-2', 110) // Same price as before + addOrder(Side.SELL, 'sell-3', 120) + addOrder(Side.SELL, 'sell-4', 130) + addOrder(Side.SELL, 'sell-5', 140) + + // @ts-expect-error asks is private + equal(ob.asks._priceTree.length, 4) + // @ts-expect-error bids is private + equal(ob.bids._priceTree.length, 0) + + // Test BUY side + addOrder(Side.BUY, 'buy-1', 100) + // @ts-expect-error asks is private + equal(ob.asks._priceTree.length, 4) + // @ts-expect-error bids is private + equal(ob.bids._priceTree.length, 1) + + addOrder(Side.BUY, 'buy-2', 100) // Same price as before + addOrder(Side.BUY, 'buy-3', 90) + addOrder(Side.BUY, 'buy-4', 80) + addOrder(Side.BUY, 'buy-5', 70) + + // @ts-expect-error asks is private + equal(ob.asks._priceTree.length, 4) + // @ts-expect-error bids is private + equal(ob.bids._priceTree.length, 4) + + { // Before removing orders, test getConditionalOrders + const response = ob.getConditionalOrders(Side.SELL, 110, 130) + let totalOrder = 0 + response.forEach((stopQueue) => { + totalOrder += stopQueue.len() + // @ts-expect-error _price is private + equal(stopQueue._price >= 110 && stopQueue._price <= 130, true) + }) + equal(totalOrder, 4) + } + + { // Before removing orders, test getConditionalOrders + const response = ob.getConditionalOrders(Side.BUY, 70, 130) + let totalOrder = 0 + response.forEach((stopQueue) => { + totalOrder += stopQueue.len() + // @ts-expect-error _price is private + equal(stopQueue._price >= 70 && stopQueue._price <= 100, true) + }) + equal(totalOrder, 5) + } + + same(ob.remove(Side.SELL, 'sell-3', 120)?.id, 'sell-3') + // @ts-expect-error asks is private + equal(ob.asks._priceTree.length, 3) + + // Lenght non changed because there were two orders at price level 100 + same(ob.remove(Side.BUY, 'buy-2', 100)?.id, 'buy-2') + // @ts-expect-error asks is private + equal(ob.bids._priceTree.length, 4) + + // Try to remove non existing order + equal(ob.remove(Side.SELL, 'fake-id', 130), undefined) + + end() +}) + +void test('it should validate conditional order', ({ equal, end }) => { + const ob = new StopBook() + + const validate = ( + orderType: OrderType.STOP_LIMIT | OrderType.STOP_MARKET, + side: Side, + price: number | null = null, + stopPrice: number, + expect: boolean, + marketPrice: number + ): void => { + // @ts-expect-error price is available only for STOP_LIMIT + const order = OrderFactory.createOrder({ + id: 'foo', + type: orderType, + side, + size: 5, + ...(price !== null ? { price } : {}), + stopPrice, + isMaker: true, + timeInForce: TimeInForce.GTC + }) as StopOrder + equal(ob.validConditionalOrder(marketPrice, order), expect) + } + + // Stop LIMIT BUY + validate(OrderType.STOP_LIMIT, Side.BUY, 100, 90, true, 80) + validate(OrderType.STOP_LIMIT, Side.BUY, 100, 90, false, 90) + validate(OrderType.STOP_LIMIT, Side.BUY, 100, 90, false, 110) + validate(OrderType.STOP_LIMIT, Side.BUY, 90, 90, true, 80) + validate(OrderType.STOP_LIMIT, Side.BUY, 90, 90, true, 80) + validate(OrderType.STOP_LIMIT, Side.BUY, 90, 100, false, 80) + + // Stop LIMIT SELL + validate(OrderType.STOP_LIMIT, Side.SELL, 90, 100, true, 110) + validate(OrderType.STOP_LIMIT, Side.SELL, 90, 100, false, 100) + validate(OrderType.STOP_LIMIT, Side.SELL, 90, 90, true, 110) + validate(OrderType.STOP_LIMIT, Side.SELL, 90, 80, false, 110) + + // Stop MARKET BUY + validate(OrderType.STOP_MARKET, Side.BUY, null, 90, true, 80) + validate(OrderType.STOP_MARKET, Side.BUY, null, 90, false, 90) + validate(OrderType.STOP_MARKET, Side.BUY, null, 90, false, 110) + + // Stop MARKET SELL + validate(OrderType.STOP_MARKET, Side.SELL, null, 90, true, 100) + validate(OrderType.STOP_MARKET, Side.SELL, null, 90, false, 90) + validate(OrderType.STOP_MARKET, Side.SELL, null, 90, false, 80) + + end() +}) diff --git a/test/stopqueue.test.ts b/test/stopqueue.test.ts new file mode 100644 index 0000000..e60666b --- /dev/null +++ b/test/stopqueue.test.ts @@ -0,0 +1,88 @@ +import { test } from 'tap' +import { OrderFactory, StopLimitOrder } from '../src/order' +import { Side } from '../src/side' +import { StopQueue } from '../src/stopqueue' +import { OrderType, TimeInForce } from '../src/types' + +void test('it should append/remove orders from queue', ({ + equal, + same, + end +}) => { + const price = 100 + const stopPrice = 90 + const oq = new StopQueue(price) + // Test edge case where head is undefined (queue is empty) + equal(oq.removeFromHead(), undefined) + + const order1 = OrderFactory.createOrder({ + type: OrderType.STOP_LIMIT, + id: 'order1', + side: Side.SELL, + size: 5, + price, + stopPrice, + timeInForce: TimeInForce.GTC, + isMaker: true + }) + const order2 = OrderFactory.createOrder({ + type: OrderType.STOP_LIMIT, + id: 'order2', + side: Side.SELL, + size: 5, + price, + stopPrice, + timeInForce: TimeInForce.GTC, + isMaker: true + }) + + const head = oq.append(order1) + const tail = oq.append(order2) + + equal(head instanceof StopLimitOrder, true) + equal(tail instanceof StopLimitOrder, true) + same(head, order1) + same(tail, order2) + equal(oq.len(), 2) + + const order3 = OrderFactory.createOrder({ + type: OrderType.STOP_LIMIT, + id: 'order3', + side: Side.SELL, + size: 10, + price, + stopPrice, + timeInForce: TimeInForce.GTC, + isMaker: true + }) + oq.append(order3) + equal(oq.len(), 3) + + const order4 = OrderFactory.createOrder({ + type: OrderType.STOP_LIMIT, + id: 'order4', + side: Side.SELL, + size: 10, + price, + stopPrice, + timeInForce: TimeInForce.GTC, + isMaker: true + }) + oq.append(order4) + equal(oq.len(), 4) + + same(oq.removeFromHead(), order1) + same(oq.remove(order4.id), order4) + equal(oq.len(), 2) + + same(oq.removeFromHead(), order2) + equal(oq.len(), 1) + + equal(oq.remove('fake-id'), undefined) + equal(oq.len(), 1) + + same(oq.remove(order3.id), order3) + equal(oq.len(), 0) + + end() +}) diff --git a/test/stopside.test.ts b/test/stopside.test.ts new file mode 100644 index 0000000..f8a5fa2 --- /dev/null +++ b/test/stopside.test.ts @@ -0,0 +1,333 @@ +import { test } from 'tap' +import { OrderFactory } from '../src/order' +import { Side } from '../src/side' +import { StopSide } from '../src/stopside' +import { OrderType, TimeInForce } from '../src/types' +import { ERROR } from '../src/errors' + +void test('it should append/remove orders from queue on BUY side', ({ + equal, + end +}) => { + const os = new StopSide(Side.BUY) + // @ts-expect-error _prices is private + equal(Object.keys(os._prices).length, 0) + // @ts-expect-error _priceTree is private + equal(os._priceTree.length, 0) + { + const order = OrderFactory.createOrder({ + type: OrderType.STOP_LIMIT, + id: 'order1', + side: Side.BUY, + size: 5, + price: 10, + timeInForce: TimeInForce.GTC, + isMaker: true, + stopPrice: 10 + }) + os.append(order) + // @ts-expect-error _prices is private + equal(Object.keys(os._prices).length, 1) + // @ts-expect-error _priceTree is private + equal(os._priceTree.length, 1) + } + + { + const order = OrderFactory.createOrder({ + type: OrderType.STOP_LIMIT, + id: 'order2', + side: Side.BUY, + size: 5, + price: 10, + timeInForce: TimeInForce.GTC, + isMaker: true, + stopPrice: 10 // same stopPrice as before, so same price level + }) + os.append(order) + // @ts-expect-error _prices is private + equal(Object.keys(os._prices).length, 1) + // @ts-expect-error _priceTree is private + equal(os._priceTree.length, 1) + } + + { + const order = OrderFactory.createOrder({ + type: OrderType.STOP_MARKET, + side: Side.BUY, + size: 5, + stopPrice: 20, + timeInForce: TimeInForce.GTC, + isMaker: true + }) + + os.append(order) + // @ts-expect-error _prices is private + equal(Object.keys(os._prices).length, 2) + // @ts-expect-error _priceTree is private + equal(os._priceTree.length, 2) + } + + // @ts-expect-error _priceTree is private property + os._priceTree.values.reduce((previousPrice, curr) => { + // BUY side are in descending order bigger to lower + // @ts-expect-error _price is private property + const currPrice = curr._price + equal(currPrice < previousPrice, true) + return currPrice + }, Infinity) + + { + // Remove the first order + const response = os.remove('order1', 10) + + // @ts-expect-error _prices is private + equal(Object.keys(os._prices).length, 2) + // @ts-expect-error _priceTree is private + equal(os._priceTree.length, 2) + equal(response?.id, 'order1') + } + + { + // Try to remove the same order already deleted + const response = os.remove('order1', 10) + // @ts-expect-error _prices is private + equal(Object.keys(os._prices).length, 2) + // @ts-expect-error _priceTree is private + equal(os._priceTree.length, 2) + equal(response, undefined) + } + + { + // Remove the second order order, so the price level is empty + const response = os.remove('order2', 10) + + // @ts-expect-error _prices is private + equal(Object.keys(os._prices).length, 1) + // @ts-expect-error _priceTree is private + equal(os._priceTree.length, 1) + equal(response?.id, 'order2') + } + + // Test for error when price level not exists + try { + // order1 has been replaced whit updateOrder, so trying to update order1 will throw an error of type ErrInvalidPriceLevel + os.remove('some-id', 100) + } catch (error) { + if (error instanceof Error) { + // TypeScript knows err is Error + equal(error?.message, ERROR.ErrInvalidPriceLevel) + } + } + + end() +}) + +void test('it should append/remove orders from queue on SELL side', ({ + equal, + end +}) => { + const os = new StopSide(Side.SELL) + // @ts-expect-error _prices is private + equal(Object.keys(os._prices).length, 0) + // @ts-expect-error _priceTree is private + equal(os._priceTree.length, 0) + { + const order = OrderFactory.createOrder({ + type: OrderType.STOP_LIMIT, + id: 'order1', + side: Side.SELL, + size: 5, + price: 10, + timeInForce: TimeInForce.GTC, + isMaker: true, + stopPrice: 10 + }) + os.append(order) + // @ts-expect-error _prices is private + equal(Object.keys(os._prices).length, 1) + // @ts-expect-error _priceTree is private + equal(os._priceTree.length, 1) + } + + { + const order = OrderFactory.createOrder({ + type: OrderType.STOP_LIMIT, + id: 'order2', + side: Side.SELL, + size: 5, + price: 10, + timeInForce: TimeInForce.GTC, + isMaker: true, + stopPrice: 10 // same stopPrice as before, so same price level + }) + os.append(order) + // @ts-expect-error _prices is private + equal(Object.keys(os._prices).length, 1) + // @ts-expect-error _priceTree is private + equal(os._priceTree.length, 1) + } + + { + const order = OrderFactory.createOrder({ + type: OrderType.STOP_MARKET, + side: Side.SELL, + size: 5, + stopPrice: 20, + timeInForce: TimeInForce.GTC, + isMaker: true + }) + + os.append(order) + // @ts-expect-error _prices is private + equal(Object.keys(os._prices).length, 2) + // @ts-expect-error _priceTree is private + equal(os._priceTree.length, 2) + } + + // @ts-expect-error _priceTree is private property + os._priceTree.values.reduce((previousPrice, curr) => { + // SELL side are in ascending order lower to bigger + // @ts-expect-error _price is private property + const currPrice = curr._price + equal(currPrice > previousPrice, true) + return currPrice + }, 0) + + { + // Remove the first order + const response = os.remove('order1', 10) + + // @ts-expect-error _prices is private + equal(Object.keys(os._prices).length, 2) + // @ts-expect-error _priceTree is private + equal(os._priceTree.length, 2) + equal(response?.id, 'order1') + } + + { + // Try to remove the same order already deleted + const response = os.remove('order1', 10) + // @ts-expect-error _prices is private + equal(Object.keys(os._prices).length, 2) + // @ts-expect-error _priceTree is private + equal(os._priceTree.length, 2) + equal(response, undefined) + } + + { + // Remove the second order order, so the price level is empty + const response = os.remove('order2', 10) + + // @ts-expect-error _prices is private + equal(Object.keys(os._prices).length, 1) + // @ts-expect-error _priceTree is private + equal(os._priceTree.length, 1) + equal(response?.id, 'order2') + } + + // Test for error when price level not exists + try { + // order1 has been replaced whit updateOrder, so trying to update order1 will throw an error of type ErrInvalidPriceLevel + os.remove('some-id', 100) + } catch (error) { + if (error instanceof Error) { + // TypeScript knows err is Error + equal(error?.message, ERROR.ErrInvalidPriceLevel) + } + } + + end() +}) + +void test('it should find all queue between upper and lower bound', ({ + equal, + end +}) => { + const appenOrder = (orderId: string, stopPrice: number, side, os: StopSide): void => { + const order = OrderFactory.createOrder({ + type: OrderType.STOP_LIMIT, + id: orderId, + side, + size: 5, + price: 10, + timeInForce: TimeInForce.GTC, + isMaker: true, + stopPrice + }) + os.append(order) + } + + { + const side = Side.BUY + const os = new StopSide(side) + appenOrder('order1', 10, side, os) + appenOrder('order1-1', 19.5, side, os) + appenOrder('order2', 20, side, os) + appenOrder('order2-1', 20, side, os) + appenOrder('order2-3', 20, side, os) + appenOrder('order3', 30, side, os) + appenOrder('order4', 40, side, os) + appenOrder('order4-1', 40, side, os) + appenOrder('order4-2', 40.5, side, os) + appenOrder('order5', 50, side, os) + + { + const response = os.between(40, 20) + + response.forEach((queue) => { + // @ts-expect-error _price is private + equal(queue._price <= 40, true) + // @ts-expect-error _price is private + equal(queue._price >= 20, true) + }) + } + + { + const response = os.between(20, 40) + response.forEach((queue) => { + // @ts-expect-error _price is private + equal(queue._price <= 40, true) + // @ts-expect-error _price is private + equal(queue._price >= 20, true) + }) + } + } + + { + const side = Side.SELL + const os = new StopSide(side) + appenOrder('order1', 10, side, os) + appenOrder('order1-1', 19.5, side, os) + appenOrder('order2', 20, side, os) + appenOrder('order2-1', 20, side, os) + appenOrder('order2-3', 20, side, os) + appenOrder('order3', 30, side, os) + appenOrder('order4', 40, side, os) + appenOrder('order4-1', 40, side, os) + appenOrder('order4-2', 40.5, side, os) + appenOrder('order5', 50, side, os) + + { + const response = os.between(40, 20) + + response.forEach((queue) => { + // @ts-expect-error _price is private + equal(queue._price <= 40, true) + // @ts-expect-error _price is private + equal(queue._price >= 20, true) + }) + } + + { + const response = os.between(20, 40) + response.forEach((queue) => { + // @ts-expect-error _price is private + equal(queue._price <= 40, true) + // @ts-expect-error _price is private + equal(queue._price >= 20, true) + }) + } + } + + end() +})