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
Check and lock funds
Verify the user has sufficient balance and lock the required funds.
Match against opposite side
Attempt to match the order against existing orders on the opposite side.
Update balances
Transfer funds between matched parties.
Add to order book
If not fully filled, add the remaining quantity to the order book.
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 };
}
How the matching algorithm works
Price check : The bid matches asks with price ≤ bid price
Quantity matching : Take the minimum of remaining bid quantity and ask quantity
Create fill : Record the trade with price, quantity, and user IDs
Update filled amounts : Increment the filled property on both orders
Remove completed orders : Delete fully filled orders from the book
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 };
}
Validate and lock funds
Ensure user has sufficient balance and lock required amount.
Execute matching algorithm
Match against opposite side orders in the order book.
Update user balances
Transfer funds between maker and taker.
Persist to database
Save trade records and order updates.
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.
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.