Skip to main content

Matching engine overview

The matching engine is the core of Opinix Trade. It maintains order books for each trading event and executes trades using a price-time priority algorithm.
The engine runs in a separate worker process to keep expensive matching logic isolated from the API server.

Order book structure

Each event has an order book with two sides: bids (yes) and asks (no).

Order interface

// From services/engine/src/trade/Orderbook.ts:3
export interface Order {
  price: number;          // Price per unit (0-10 range)
  quantity: number;       // Number of units
  filled: number;         // How much has been executed
  orderId: string;        // Unique identifier
  side: "yes" | "no";    // Bid or ask
  userId: string;         // User who placed the order
}

Fill interface

When orders match, fills are created:
// From services/engine/src/trade/Orderbook.ts:12
export interface Fill {
  price: number;          // Execution price
  qty: number;            // Quantity filled
  tradeId: string;        // Unique trade ID
  otherUserId: string;    // Counterparty user ID
  marketOrderId: string;  // ID of the matched order
}

Matching algorithm

The engine uses a simple but effective matching algorithm based on price-time priority.

Adding an order

1

Check and lock funds

Verify the user has sufficient balance and lock the required funds.
2

Match against opposite side

Attempt to match the order against existing orders on the opposite side.
3

Update balances

Transfer funds between matched parties.
4

Add to order book

If not fully filled, add the remaining quantity to the order book.
5

Publish updates

Send updates to the database and WebSocket server.

Matching a bid (yes order)

When a user places a “yes” order, it’s matched against existing “no” orders:
// From services/engine/src/trade/Orderbook.ts:73
matchBid(order: Order): { fills: Fill[]; executedQty: number } {
  const fills: Fill[] = [];
  let executedQty = 0;

  // Iterate through asks (no orders) sorted by price
  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;
      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!,
      });
    }
  }
  
  // Remove fully filled orders
  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 };
}
  1. Price check: The bid matches asks with price ≤ bid price
  2. Quantity matching: Take the minimum of remaining bid quantity and ask quantity
  3. Create fill: Record the trade with price, quantity, and user IDs
  4. Update filled amounts: Increment the filled property on both orders
  5. Remove completed orders: Delete fully filled orders from the book
  6. Repeat: Continue until the bid is fully filled or no more matches exist

Matching an ask (no order)

The ask matching logic is symmetrical:
// From services/engine/src/trade/Orderbook.ts:112
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;
      this.bids[i].filled += priceRemaining;
      
      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 };
}

Fund management

The engine manages user balances in memory for fast execution.

Balance structure

// From services/engine/src/trade/Engine.ts:19
interface UserBalance {
  available: number;  // Funds available for trading
  locked: number;     // Funds locked in open orders
}

Checking and locking funds

Before executing an order, funds are locked:
// From services/engine/src/trade/Engine.ts:245
checkAndLockFunds(
  side: "yes" | "no",
  userId: string,
  price: number,
  quantity: number
) {
  if (side === "yes") {
    // For yes orders, lock price * quantity in INR
    if ((this.balances.get(userId)?.available || 0) < quantity * price) {
      throw new Error("Insufficient balance");
    }
    this.balances.get(userId).available -= quantity * price;
    this.balances.get(userId).locked += quantity * price;
  } else {
    // For no orders, lock quantity in contracts
    if ((this.balances.get(userId)?.locked || 0) < Number(quantity)) {
      throw new Error("Insufficient funds");
    }
    this.balances.get(userId).available -= Number(quantity);
    this.balances.get(userId).locked += Number(quantity);
  }
}
If a user doesn’t have sufficient balance, the order is rejected immediately before any matching occurs.

Updating balances after fills

When orders match, balances are updated:
// From services/engine/src/trade/Engine.ts:271
updateBalance(userId: string, side: "yes" | "no", fills: Fill[]) {
  if (side === "yes") {
    fills.forEach((fill) => {
      const makerBalance = this.balances.get(fill.otherUserId);
      const takerBalance = this.balances.get(userId);
      
      if (makerBalance && takerBalance) {
        // Maker receives INR
        makerBalance.available += fill.qty * fill.price;
        
        // Taker's locked INR is released
        takerBalance.locked -= fill.qty * fill.price;
        
        // Maker's locked contracts are released
        makerBalance.locked -= fill.qty;
        
        // Taker receives contracts
        takerBalance.available += fill.qty;
      }
    });
  } else {
    // Symmetric logic for no orders
    fills.forEach((fill) => {
      const takerBalance = this.balances.get(fill.otherUserId);
      const makerBalance = this.balances.get(userId);
      
      if (takerBalance && makerBalance) {
        takerBalance.locked -= fill.qty * fill.price;
        makerBalance.available += fill.qty * fill.price;
        takerBalance.available += fill.qty;
        makerBalance.locked -= fill.qty;
      }
    });
  }
}

Order book depth

The engine calculates market depth by aggregating orders at each price level:
// From services/engine/src/trade/Orderbook.ts:146
getMarketDepth() {
  const bids: [string, string][] = [];
  const asks: [string, string][] = [];

  const bidsObj: { [key: string]: number } = {}
  const asksObj: { [key: string]: number } = {}

  // Aggregate bids by price
  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!;
  }

  // Aggregate asks by price
  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!;
  }

  // Convert to arrays
  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 };
}
Market depth shows the total quantity available at each price level, giving traders insight into liquidity.

Complete order flow

Here’s the complete flow when creating an order:
// From services/engine/src/trade/Engine.ts:205
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");
  }
  
  // 1. 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,
  };
  
  // 2. Match the order
  const { fills, executedQty } = orderbook.addOrder(order);
  
  // 3. Update balances
  this.updateBalance(userId, side, fills);

  // 4. Create database trades
  this.createDbTrades(fills, market, userId);

  // 5. Update database orders
  this.updateDbOrders(order, executedQty, fills, market);

  // 6. Publish WebSocket depth updates
  this.publisWsDepthUpdates(fills, price, side, market);

  // 7. Publish WebSocket trade updates
  this.publishWsTrades(fills, userId, market);

  return { executedQty, fills, orderId: order.orderId };
}
1

Validate and lock funds

Ensure user has sufficient balance and lock required amount.
2

Execute matching algorithm

Match against opposite side orders in the order book.
3

Update user balances

Transfer funds between maker and taker.
4

Persist to database

Save trade records and order updates.
5

Broadcast updates

Publish depth and trade updates via WebSocket.

Order cancellation

Users can cancel open orders:
// From services/engine/src/trade/Engine.ts:103
case CANCEL_ORDER:
  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) {
    throw new Error("No order found");
  }
  
  if (order.side === "yes") {
    const price = cancelOrderbook.cancelBid(order);
    const leftQuantity = (order.quantity - order.filled) * order.price;
    
    // Unlock funds
    this.balances.get(order.userId).available += leftQuantity;
    this.balances.get(order.userId).locked -= leftQuantity;
    
    if (price) {
      this.sendUpdatedDepthAt(price.toString(), cancelMarket);
    }
  }
When an order is cancelled, locked funds are returned to the user’s available balance.

Performance considerations

In-memory execution

  • All order books and balances are kept in memory
  • Matching happens in microseconds
  • No database queries during order execution

Snapshot recovery

// From services/engine/src/trade/Engine.ts:52
saveSnapshot() {
  const snapshotSnapshot = {
    orderbooks: this.orderbooks.map((o) => o.getSnapshot()),
    balances: Array.from(this.balances.entries()),
  };
  fs.writeFileSync("./snapshot.json", JSON.stringify(snapshotSnapshot));
}
Snapshots are saved every 3 seconds. In the event of a crash, up to 3 seconds of data could be lost. For production, consider using Redis persistence or append-only logs.