From c73db26462bcc3b0da8eb37a4c888e63d72dde9a Mon Sep 17 00:00:00 2001 From: Prashant Varma Date: Sun, 20 Oct 2024 13:04:24 +0530 Subject: [PATCH] [feat]: order matching engine --- .gitignore | 3 + docker-compose.yml | 14 +- package-lock.json | 19 +- packages/order-queue/package.json | 1 + .../order-queue/src/classes/RedisManager.ts | 31 ++ packages/order-queue/src/index.ts | 4 +- packages/types/src/index.ts | 166 +++++++- services/engine/logs/server.log | 9 + services/engine/package.json | 9 +- services/engine/src/index.ts | 33 +- services/engine/src/trade/Engine.ts | 402 ++++++++++++++++++ services/engine/src/trade/Orderbook.ts | 222 ++++++++++ 12 files changed, 899 insertions(+), 14 deletions(-) create mode 100644 packages/order-queue/src/classes/RedisManager.ts create mode 100644 services/engine/logs/server.log create mode 100644 services/engine/src/trade/Engine.ts create mode 100644 services/engine/src/trade/Orderbook.ts diff --git a/.gitignore b/.gitignore index ed5a5982..9a54c93f 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,9 @@ coverage # Turbo .turbo +# Snapshot +snapshot.json + # Vercel .vercel diff --git a/docker-compose.yml b/docker-compose.yml index 8fb39713..fe568382 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,8 +1,9 @@ version: "3.8" services: - db: - image: postgres:latest + timescaledb: + image: timescale/timescaledb:latest-pg12 + container_name: timescaledb ports: - 5432:5432 restart: always @@ -11,10 +12,10 @@ services: POSTGRES_PASSWORD: dev POSTGRES_DB: repo volumes: - - db:/var/lib/postgresql/data + - timescale-data:/var/lib/postgresql/data redis: - image: redis:7 + image: redis:latest ports: - 6379:6379 restart: always @@ -22,6 +23,7 @@ services: - cache:/data volumes: - db: + timescale-data: cache: - driver: local \ No newline at end of file + driver: local + \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 6ab09bdc..c32fd5f1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14205,6 +14205,7 @@ "version": "1.0.0", "dependencies": { "@opinix/logger": "*", + "@opinix/types": "*", "dotenv": "^16.4.5", "nodemon": "^3.1.7", "redis": "^4.7.0" @@ -14246,6 +14247,7 @@ "version": "1.0.0", "license": "ISC", "dependencies": { + "@opinix/types": "*", "@repo/db": "*", "@repo/order-queue": "*", "@types/cors": "^2.8.17", @@ -14253,12 +14255,27 @@ "body-parser": "^1.20.3", "cors": "^2.8.5", "dotenv": "^16.4.5", - "express": "^4.21.0" + "express": "^4.21.0", + "uuid": "^10.0.0" }, "devDependencies": { + "@types/uuid": "^10.0.0", "esbuild": "0.24.0" } }, + "services/engine/node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "services/wss": { "name": "@repo/wss", "dependencies": { diff --git a/packages/order-queue/package.json b/packages/order-queue/package.json index af93c22f..c049b372 100644 --- a/packages/order-queue/package.json +++ b/packages/order-queue/package.json @@ -4,6 +4,7 @@ "main": "index.js", "dependencies": { "@opinix/logger": "*", + "@opinix/types": "*", "dotenv": "^16.4.5", "nodemon": "^3.1.7", "redis": "^4.7.0" diff --git a/packages/order-queue/src/classes/RedisManager.ts b/packages/order-queue/src/classes/RedisManager.ts new file mode 100644 index 00000000..8f7ed607 --- /dev/null +++ b/packages/order-queue/src/classes/RedisManager.ts @@ -0,0 +1,31 @@ +import { RedisClientType, createClient } from "redis"; +import { DbMessage, MessageToApi, WsMessage } from "@opinix/types"; + +export class RedisManager { + private client: RedisClientType; + private static instance: RedisManager; + + constructor() { + this.client = createClient(); + this.client.connect(); + } + + public static getInstance() { + if (!this.instance) { + this.instance = new RedisManager(); + } + return this.instance; + } + + public pushMessage(message: DbMessage) { + this.client.lPush("db_processor", JSON.stringify(message)); + } + + public publishMessage(channel: string, message: WsMessage) { + this.client.publish(channel, JSON.stringify(message)); + } + + public sendToApi(clientId: string, message: MessageToApi) { + this.client.publish(clientId, JSON.stringify(message)); + } +} \ No newline at end of file diff --git a/packages/order-queue/src/index.ts b/packages/order-queue/src/index.ts index b62f1f02..950e374b 100644 --- a/packages/order-queue/src/index.ts +++ b/packages/order-queue/src/index.ts @@ -1,10 +1,12 @@ import { addToOrderQueue } from "./queues/orderQueue"; +import { createClient } from "redis"; import orderWorker from "./queues/orderProcessor"; import { logger } from "@opinix/logger"; +import { RedisManager } from "./classes/RedisManager"; const startWorker = async () => { logger.info("WORKER | Starting order worker"); orderWorker; }; startWorker(); -export { addToOrderQueue }; +export { addToOrderQueue, RedisManager, createClient }; diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 00b6ecb0..3a99d15c 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -1,4 +1,3 @@ -import { OrderStatus } from "@prisma/client"; export enum EOrderStatus { PENDING = "PENDING", PLACED = "PLACED", @@ -18,7 +17,7 @@ export type TOrder = { orderBookId: string; price: number; quantity: number; - status: OrderStatus; + status: "PLACED" | "PENDING"; createdAt: Date; }; @@ -45,3 +44,166 @@ export type TEvent = { traders: number; quantity: number; }; + + + +// ** Matching Engine Used Types Here ** + + +export const CREATE_ORDER = "CREATE_ORDER"; +export const CANCEL_ORDER = "CANCEL_ORDER"; +export const TRADE_ADDED = "TRADE_ADDED"; +export const ORDER_UPDATE = "ORDER_UPDATE"; + +export const ON_RAMP = "ON_RAMP"; + +export const GET_DEPTH = "GET_DEPTH"; +export const GET_OPEN_ORDERS = "GET_OPEN_ORDERS"; + + +//TODO: types sharing between the server, wss and the engine? +export type MessageFromApi = { + type: typeof CREATE_ORDER, + data: { + market: string, + price: number, + quantity: number, + side: "yes" | "no", + userId: string + } +} | { + type: typeof CANCEL_ORDER, + data: { + orderId: string, + market: string, + } +} | { + type: typeof ON_RAMP, + data: { + amount: number, + userId: string, + txnId: string + } +} | { + type: typeof GET_DEPTH, + data: { + market: string, + } +} | { + type: typeof GET_OPEN_ORDERS, + data: { + userId: string, + market: string, + } +} + + + +// *** DB Operation Related Types *** + + +export type DbMessage = { + type: typeof TRADE_ADDED, + data: { + id: string, + isBuyerMaker: boolean, + price: number, + quantity: number, + // quoteQuantity: string, + timestamp: number, + market: string + } +} | { + type: typeof ORDER_UPDATE, + data: { + orderId: string, + executedQty: number, + market?: string, + price?: string, + quantity?: string, + side?: "yes" | "no", + } +} + + + +// TYpes for responding the server back + +export interface Order { + price: number; + quantity: number; + orderId: string; + filled: number; + side: "yes" | "no"; + userId: string; +} + + +export type MessageToApi = { + type: "DEPTH", + payload: { + bids: [string, string][], + asks: [string, string][], + } +} | { + type: "ORDER_PLACED", + payload: { + orderId: string, + executedQty: number, + fills: { + price: number, + qty: number, + tradeId: string + }[] + } +} | { + type: "ORDER_CANCELLED", + payload: { + orderId: string, + executedQty: number, + remainingQty: number + } +} | { + type: "OPEN_ORDERS", + payload: Order[] +} + + +// WS Types + +export type TickerUpdateMessage = { + stream: string, + data: { + c?: string, + h?: string, + l?: string, + v?: string, + V?: string, + s?: string, + id: number, + e: "ticker" + } +} + +export type DepthUpdateMessage = { + stream: string, + data: { + b?: [string, string][], + a?: [string, string][], + e: "depth" + } +} + +export type TradeAddedMessage = { + stream: string, + data: { + e: "trade", + t: string, + m: boolean, + p: number, + q: string, + s: string, // symbol + } +} + +export type WsMessage = TickerUpdateMessage | DepthUpdateMessage | TradeAddedMessage; \ No newline at end of file diff --git a/services/engine/logs/server.log b/services/engine/logs/server.log new file mode 100644 index 00000000..313bf065 --- /dev/null +++ b/services/engine/logs/server.log @@ -0,0 +1,9 @@ +[ 2024-10-20 11:49:07 ] - info - WORKER | Starting order worker +[ 2024-10-20 11:49:07 ] - info - SERVER | REDIS: Connected to Redis +[ 2024-10-20 11:49:07 ] - info - SERVER | REDIS: Redis connection is ready to start execution +[ 2024-10-20 11:51:33 ] - info - WORKER | Starting order worker +[ 2024-10-20 11:51:33 ] - info - SERVER | REDIS: Connected to Redis +[ 2024-10-20 11:51:33 ] - info - SERVER | REDIS: Redis connection is ready to start execution +[ 2024-10-20 12:56:25 ] - info - WORKER | Starting order worker +[ 2024-10-20 12:56:25 ] - info - SERVER | REDIS: Connected to Redis +[ 2024-10-20 12:56:25 ] - info - SERVER | REDIS: Redis connection is ready to start execution diff --git a/services/engine/package.json b/services/engine/package.json index 66595a08..c8ee1202 100644 --- a/services/engine/package.json +++ b/services/engine/package.json @@ -13,18 +13,21 @@ "description": "", "dependencies": { "@repo/db": "*", + "@opinix/types": "*", "@repo/order-queue": "*", "@types/cors": "^2.8.17", "@types/express": "^4.17.21", "body-parser": "^1.20.3", "cors": "^2.8.5", "dotenv": "^16.4.5", - "express": "^4.21.0" + "express": "^4.21.0", + "uuid": "^10.0.0" }, "devDependencies": { + "@types/uuid": "^10.0.0", "esbuild": "0.24.0" }, - "exports":{ - ".":"./src/index.ts" + "exports": { + ".": "./src/index.ts" } } diff --git a/services/engine/src/index.ts b/services/engine/src/index.ts index 58927307..66f1c4f2 100644 --- a/services/engine/src/index.ts +++ b/services/engine/src/index.ts @@ -1 +1,32 @@ -export const ORDERBOOK = {}; \ No newline at end of file +/* + TODOS: + 1. Fake Liquidity + 2. setting base balancing + 3. Login go through again + 4. TEST ENGINE ( Latency ) | shift it to RUST or GO if needed. + 5. TYPES + 6. Test Cases +*/ + +import { createClient } from "@repo/order-queue"; +import { Engine } from "./trade/Engine"; + + +async function main() { + const engine = new Engine(); + const redisClient = createClient(); + await redisClient.connect(); + console.log("connected to redis"); + + while (true) { + const response = await redisClient.rPop("messages" as string) + if (!response) { + + } else { + engine.processOrders(JSON.parse(response)); + } + } + +} + +main(); \ No newline at end of file diff --git a/services/engine/src/trade/Engine.ts b/services/engine/src/trade/Engine.ts new file mode 100644 index 00000000..508117e4 --- /dev/null +++ b/services/engine/src/trade/Engine.ts @@ -0,0 +1,402 @@ +import fs from "fs"; +import { v4 as uuidv4 } from "uuid"; +import { CANCEL_ORDER, CREATE_ORDER, GET_DEPTH, GET_OPEN_ORDERS, MessageFromApi, ON_RAMP, ORDER_UPDATE, TRADE_ADDED } from "@opinix/types"; +import { Fill, Order, Orderbook } from "./Orderbook"; +import { RedisManager } from "@repo/order-queue" + +export const EXAMPLE_EVENT = "bitcoin-to-be-priced-at-6811470-usdt-or-more-at-0735-pm" +export const CURRENCY = "INR"; + +interface UserBalance { + [key: string]: { + available: number; + locked: number; + } +} + +export class Engine { + private balances: Map = new Map(); + private orderbooks: Orderbook[] = []; + + constructor() { + let snapshot = null; + try { + if (process.env.WITH_SNAPSHOT) { + snapshot = fs.readFileSync("./snapshot.json"); + } + } catch (error) { + console.log("No snapshot found"); + } + + if (snapshot) { + const parsedSnapShot = JSON.parse(snapshot.toString()); + this.orderbooks = parsedSnapShot.orderbook.map((o: any) => new Orderbook(o.bids, o.asks, o.lastTradeId, o.currentPrice, o.event));; + this.balances = new Map(parsedSnapShot.balance); + } else { + const lastTradeId = uuidv4(); // for now assuming this random id as lastTradeId + this.orderbooks = [new Orderbook([], [], lastTradeId, 0, EXAMPLE_EVENT)] + // this.setBaseBalances(); + } + setInterval(() => { + this.saveSnapshot(); + }, 1000 * 3); + } + saveSnapshot() { + const snapshotSnapshot = { + orderbooks: this.orderbooks.map(o => o.getSnapshot()), + balances: Array.from(this.balances.entries()) + } + fs.writeFileSync("./snapshot.json", JSON.stringify(snapshotSnapshot)); + } + + processOrders({ message, clientId }: { message: MessageFromApi, clientId: string }) { + switch (message.type) { + + case CREATE_ORDER: + try { + const { executedQty, fills, orderId } = this.createOrders("", 1.5, 1, "yes", "jsdbjbvjbjvbdfj") + // publish it to the server via redis + RedisManager.getInstance().sendToApi(clientId, { + type: "ORDER_PLACED", + payload: { + orderId, + executedQty, + fills + } + }); + } catch (error) { + console.log(error); + // publish it to the server via redis + RedisManager.getInstance().sendToApi(clientId, { + type: "ORDER_CANCELLED", + payload: { + orderId: "", + executedQty: 0, + remainingQty: 0 + } + }); + } + break; + + case CANCEL_ORDER: + try { + const orderId = message.data.orderId; + const cancelMarket = message.data.market; + const cancelOrderbook = this.orderbooks.find(o => o.market === cancelMarket); + if (!cancelOrderbook) { + throw new Error("No orderbook found"); + } + + const order = cancelOrderbook.asks.find(o => o.orderId === orderId) || cancelOrderbook.bids.find(o => o.orderId === orderId); + if (!order) { + console.log("No order found"); + throw new Error("No order found"); + } + + if (order.side === "yes") { + const price = cancelOrderbook.cancelBid(order) + const leftQuantity = (order.quantity - order.filled) * order.price; + //@ts-ignore + this.balances.get(order.userId)[CURRENCY].available += leftQuantity; + //@ts-ignore + this.balances.get(order.userId)[CURRENCY].locked -= leftQuantity; + if (price) { + this.sendUpdatedDepthAt(price.toString(), cancelMarket); + } + + } else { + const price = cancelOrderbook.cancelAsk(order) + const leftQuantity = order.quantity - order.filled; + //@ts-ignore + this.balances.get(order.userId)[quoteAsset].available += leftQuantity; + //@ts-ignore + this.balances.get(order.userId)[quoteAsset].locked -= leftQuantity; + if (price) { + this.sendUpdatedDepthAt(price.toString(), cancelMarket); + } + } + // publish it to the server via redis + RedisManager.getInstance().sendToApi(clientId, { + type: "ORDER_CANCELLED", + payload: { + orderId, + executedQty: 0, + remainingQty: 0 + } + }); + } catch (error) { + console.log("Error hwile cancelling order",); + console.log(error); + } + break; + + case GET_OPEN_ORDERS: + try { + const openOrderbook = this.orderbooks.find(o => o.market === message.data.market); + if (!openOrderbook) { + throw new Error("No orderbook found"); + } + const openOrders = openOrderbook.getOpenOrders(message.data.userId); + + RedisManager.getInstance().sendToApi(clientId, { + type: "OPEN_ORDERS", + payload: openOrders + }); + } catch (error) { + console.log(error); + } + break; + + case ON_RAMP: + const userId = message.data.userId; + const amount = Number(message.data.amount); + this.onRamp(userId, amount); + break; + + case GET_DEPTH: + try { + const market = message.data.market; + const orderbook = this.orderbooks.find(o => o.market === market); + if (!orderbook) { + throw new Error("No orderbook found"); + } + RedisManager.getInstance().sendToApi(clientId, { + type: "DEPTH", + payload: orderbook.getMarketDepth() + }); + } catch (e) { + console.log(e); + RedisManager.getInstance().sendToApi(clientId, { + type: "DEPTH", + payload: { + bids: [], + asks: [] + } + }); + } + break; + + } + } + + addOrderbook(orderbook: Orderbook) { + this.orderbooks.push(orderbook); + } + + createOrders(market: string, price: number, quantity: number, side: "yes" | "no", userId: string): { + executedQty: number, + fills: Fill[], + orderId: string + } { + const orderbook = this.orderbooks.find(o => o.market === market) + if (!orderbook) { + throw new Error("No orderbook found"); + } + // Check and Lock funds + this.checkAndLockFunds(side, userId, price, quantity); + + const order: Order = { + price: Number(price), + quantity: Number(quantity), + orderId: uuidv4(), + filled: 0, + side, + userId + } + + const { fills, executedQty } = orderbook.addOrder(order); + this.updateBalance(userId, side, fills); + this.createDbTrades(fills, market, userId); + this.updateDbOrders(order, executedQty, fills, market); + this.publisWsDepthUpdates(fills, price, side, market) + this.publishWsTrades(fills, userId, market); + + return { executedQty, fills, orderId: order.orderId }; + } + + checkAndLockFunds(side: "yes" | "no", userId: string, price: number, quantity: number) { + if (side === "yes") { + if ((this.balances.get(userId)?.[CURRENCY]?.available || 0) < Number(quantity) * Number(price)) { + throw new Error("Insufficient funds"); + } + //@ts-ignore + this.balances.get(userId)[CURRENCY].available = this.balances.get(userId)?.[CURRENCY].available - (Number(quantity) * Number(price)); + + //@ts-ignore + this.balances.get(userId)[CURRENCY].locked = this.balances.get(userId)?.[CURRENCY].locked + (Number(quantity) * Number(price)); + } else { + if ((this.balances.get(userId)?.[CURRENCY]?.available || 0) < Number(quantity)) { + throw new Error("Insufficient funds"); + } + //@ts-ignore + this.balances.get(userId)[CURRENCY].available = this.balances.get(userId)?.[CURRENCY].available - (Number(quantity)); + + //@ts-ignore + this.balances.get(userId)[CURRENCY].locked = this.balances.get(userId)?.[CURRENCY].locked + Number(quantity); + } + } + updateBalance(userId: string, side: "yes" | "no", fills: Fill[]) { + if (side === "yes") { + fills.forEach(fill => { + // Update quote asset balance + //@ts-ignore + this.balances.get(fill.otherUserId)[CURRENCY].available = this.balances.get(fill.otherUserId)?.[CURRENCY].available + (fill.qty * fill.price); + + //@ts-ignore + this.balances.get(userId)[CURRENCY].locked = this.balances.get(userId)?.[CURRENCY].locked - (fill.qty * fill.price); + + // Update base asset balance + + //@ts-ignore + this.balances.get(fill.otherUserId)[CURRENCY].locked = this.balances.get(fill.otherUserId)?.[CURRENCY].locked - fill.qty; + + //@ts-ignore + this.balances.get(userId)[CURRENCY].available = this.balances.get(userId)?.[CURRENCY].available + fill.qty; + + }); + + } else { + fills.forEach(fill => { + // Update quote asset balance + //@ts-ignore + this.balances.get(fill.otherUserId)[CURRENCY].locked = this.balances.get(fill.otherUserId)?.[CURRENCY].locked - (fill.qty * fill.price); + + //@ts-ignore + this.balances.get(userId)[CURRENCY].available = this.balances.get(userId)?.[CURRENCY].available + (fill.qty * fill.price); + + // Update base asset balance + + //@ts-ignore + this.balances.get(fill.otherUserId)[CURRENCY].available = this.balances.get(fill.otherUserId)?.[CURRENCY].available + fill.qty; + + //@ts-ignore + this.balances.get(userId)[CURRENCY].locked = this.balances.get(userId)?.[CURRENCY].locked - (fill.qty); + + }); + } + } + + createDbTrades(fills: Fill[], market: string, userId: string) { + fills.forEach(fill => { + RedisManager.getInstance().pushMessage({ + type: TRADE_ADDED, + data: { + market: market, + id: fill.tradeId.toString(), + isBuyerMaker: fill.otherUserId === userId, // TODO: Is this right? + price: fill.price, + quantity: fill.qty, + timestamp: Date.now() + } + }); + }); + } + + updateDbOrders(order: Order, executedQty: number, fills: Fill[], market: string) { + RedisManager.getInstance().pushMessage({ + type: ORDER_UPDATE, + data: { + orderId: order.orderId, + executedQty: executedQty, + market: market, + price: order.price.toString(), + quantity: order.quantity.toString(), + side: order.side, + } + }); + + fills.forEach(fill => { + RedisManager.getInstance().pushMessage({ + type: ORDER_UPDATE, + data: { + orderId: fill.marketOrderId, + executedQty: fill.qty + } + }); + }); + } + + publisWsDepthUpdates(fills: Fill[], price: number, side: "yes" | "no", market: string) { + const orderbook = this.orderbooks.find(o => o.market === market); + if (!orderbook) { + return; + } + const depth = orderbook.getMarketDepth(); + if (side === "yes") { + const updatedAsks = depth?.asks.filter(x => fills.map(f => f.price)); + const updatedBid = depth?.bids.find(x => x[0] === price.toString()); + console.log("publish ws depth updates") + RedisManager.getInstance().publishMessage(`depth@${market}`, { + stream: `depth@${market}`, + data: { + a: updatedAsks, + b: updatedBid ? [updatedBid] : [], + e: "depth" + } + }); + } + if (side === "no") { + const updatedBids = depth?.bids.filter(x => fills.map(f => f.price)); + const updatedAsk = depth?.asks.find(x => x[0] === price.toString()); + console.log("publish ws depth updates") + RedisManager.getInstance().publishMessage(`depth@${market}`, { + stream: `depth@${market}`, + data: { + a: updatedAsk ? [updatedAsk] : [], + b: updatedBids, + e: "depth" + } + }); + } + } + + publishWsTrades(fills: Fill[], userId: string, market: string) { + fills.forEach(fill => { + RedisManager.getInstance().publishMessage(`trade@${market}`, { + stream: `trade@${market}`, + data: { + e: "trade", + t: fill.tradeId, + m: fill.otherUserId === userId, // CheckCheck + p: fill.price, + q: fill.qty.toString(), + s: market, + } + }); + }); + } + + onRamp(userId: string, amount: number) { + const userBalance = this.balances.get(userId); + if (!userBalance) { + this.balances.set(userId, { + [CURRENCY]: { + available: amount, + locked: 0 + } + }); + } else { + // @ts-ignore + userBalance[CURRENCY].available += amount; + } + } + + sendUpdatedDepthAt(price: string, market: string) { + const orderbook = this.orderbooks.find(o => o.market === market); + if (!orderbook) { + return; + } + const depth = orderbook.getMarketDepth(); + const updatedBids = depth?.bids.filter(b => b[0] === price); + const updatedAsks = depth?.asks.filter(a => a[0] === price); + + RedisManager.getInstance().publishMessage(`depth@${market}`, { + stream: `depth@${market}`, + data: { + a: updatedAsks.length ? updatedAsks : [[price, "0"]], + b: updatedBids.length ? updatedBids : [[price, "0"]], + e: "depth" + } + }); + } +} \ No newline at end of file diff --git a/services/engine/src/trade/Orderbook.ts b/services/engine/src/trade/Orderbook.ts new file mode 100644 index 00000000..d8b369b2 --- /dev/null +++ b/services/engine/src/trade/Orderbook.ts @@ -0,0 +1,222 @@ +import { v4 as uuidv4 } from "uuid"; + +export interface Order { + price: number; + quantity: number; + filled: number; + orderId: string; + side: "yes" | "no"; + userId: string; +} + +export interface Fill { + price: number; + qty: number; + tradeId: string; + otherUserId: string; + marketOrderId: string; // orderId to be matched. +} + +export class Orderbook { + bids: Order[]; + asks: Order[]; + market: string; + lastTradeId: string; + currentPrice:number; + + + constructor(bids: Order[], asks: Order[], lastTradeId: string, currentPrice:number, market: string) { + this.market = market; + this.bids = bids; + this.asks = asks; + this.lastTradeId = lastTradeId; + this.currentPrice = currentPrice || 0; + } + + addOrder(order: Order) { + if (order.side === "yes") { + // matchBid + const { executedQty, fills } = this.matchBid(order); + // fillBid + order.filled = executedQty; + if (executedQty === order.quantity) { + return { + executedQty, + fills, + }; + } + this.bids.push(order); + return { + executedQty, + fills, + }; + } else { + // matchAsk + const { executedQty, fills } = this.matchAsk(order); + // fillAsk + order.filled = executedQty; + if (executedQty === order.quantity) { + return { + executedQty, + fills, + }; + } + this.asks.push(order); + return { + executedQty, + fills, + }; + } + } + + // @ts-ignore Todo; remove ts-ignore + matchBid(order: Order): { fills: Fill[]; executedQty: number } { + const fills: Fill[] = []; + let executedQty = 0; + + // TODO: matching bid with sorted asks array + for (let i = 0; i > this.asks.length; i++) { + if (this.asks[i]?.price! <= order.price && executedQty < order.quantity) { + const filledQty = Math.min( + order.quantity - executedQty, + this.asks[i]?.quantity! + ); + executedQty += filledQty; + // @ts-ignore + this.asks[i].filled += filledQty; + fills.push({ + price: this.asks[i]?.price!, + qty: filledQty, + tradeId: uuidv4(), + otherUserId: this.asks[i]?.userId!, + marketOrderId: this.asks[i]?.orderId!, + }); + } + + // if order left after particially filled + for (let i = 0; i < this.asks.length; i++) { + if (this.asks[i]?.filled === this.asks[i]?.quantity) { + this.asks.splice(i, 1); + i--; + } + } + + return { + fills, + executedQty, + }; + } + } + + // @ts-ignore Todo; remove ts-ignore + matchAsk(order: Order): { fills: Fill[]; executedQty: number } { + const fills: Fill[] = []; + let executedQty = 0; + + for (let i = 0; i < this.bids.length; i++) { + if (this.bids[i]?.price! >= order.price && executedQty < order.quantity) { + const priceRemaining = Math.min( + order.quantity - executedQty, + this.bids[i]?.quantity! + ); + executedQty += priceRemaining; + // @ts-ignore + this.bids[i].filled += priceRemaining; // ERROR SOLVED + fills.push({ + price: this.bids[i]?.price!, + qty: priceRemaining, + tradeId: uuidv4(), + otherUserId: this.bids[i]?.userId!, + marketOrderId: this.bids[i]?.orderId!, + }); + } + for (let i = 0; i < this.bids.length; i++) { + if (this.bids[i]?.filled === this.bids[i]?.quantity) { + this.bids.splice(i, 1); + i--; + } + } + return { + fills, + executedQty, + }; + } + } + + getMarketDepth() { + const bids: [string, string][] = []; + const asks: [string, string][] = []; + + const bidsObj: { [key: string]: number } = {} + const asksObj: { [key: string]: number } = {} + + // bids depth + for (let i = 0; i < this.bids.length; i++) { + const order = this.bids[i]; + const bidsObjPriceKey = order?.price.toString()!; + + if (!bidsObj[bidsObjPriceKey]) { + bidsObj[bidsObjPriceKey] = 0; + } + bidsObj[bidsObjPriceKey] += order?.quantity!; + } + + // asks depth + for (let i = 0; i < this.asks.length; i++) { + const order = this.asks[i]; + const asksObjPriceKey = order?.price.toString()!; + + if (!asksObj[asksObjPriceKey]) { + asksObj[asksObjPriceKey] = 0; + } + asksObj[asksObjPriceKey] += order?.quantity!; + } + + for (const price in bidsObj) { + bids.push([price, bidsObj[price]?.toString()!]); + } + + for (const price in asksObj) { + asks.push([price, asksObj[price]?.toString()!]); + } + + return { + bids, + asks + }; + } + + getOpenOrders(userId: string): Order[] { + const bids = this.bids.filter(b => b.userId === userId); + const asks = this.asks.filter(a => a.userId === userId); + + return [...bids, ...asks]; + } + + cancelBid(order: Order) { + const index = this.bids.findIndex(b => b.orderId === order.orderId); + if (index !== -1) { + const price = this.bids[index]?.price; + this.bids.splice(index, 1); + return price; + } + } + + cancelAsk(order: Order) { + const index = this.asks.findIndex(a => a.orderId === order.orderId); + if (index !== -1) { + const price = this.asks[index]?.price; + this.asks.splice(index, 1); + return price; + } + } + + getSnapshot() { + return { + bids: this.bids, + asks: this.asks, + lastTradeId: this.lastTradeId, + currentPrice: this.currentPrice + } + } +}