Skip to content

Commit e2f1601

Browse files
authored
Merge pull request #436 from sadeeq6400/swap-matching
Feat: Implement Swap Matching Engine
2 parents 6f44af2 + 2366aa7 commit e2f1601

17 files changed

Lines changed: 698 additions & 2 deletions

src/app.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ import { WalletLedger } from './wallet/entities/wallet-ledger.entity';
113113
import { LedgerEntry } from './wallet/entities/ledger-entry.entity';
114114
import { WithdrawalRequest } from './wallet/entities/withdrawal-request.entity';
115115
import { FiatPaymentIntent } from './wallet/entities/fiat-payment-intent.entity';
116+
import { TradingModule } from './trading/trading.module';
116117

117118
@Module({
118119
imports: [
@@ -267,6 +268,7 @@ import { FiatPaymentIntent } from './wallet/entities/fiat-payment-intent.entity'
267268
EscrowSettlementModule,
268269
// ── Wallet & Payments Integration ──
269270
WalletModule,
271+
TradingModule,
270272

271273
// ── Error Handling ──
272274
ErrorModule,
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { Test, TestingModule } from '@nestjs/testing';
2+
import { TradingController } from './trading.controller';
3+
import { TradingService } from '../services/trading.service';
4+
import { CreateOrderDto } from '../dto/create-order.dto';
5+
import { OrderSide, OrderType, OrderStatus } from '../enums/order.enum';
6+
import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard';
7+
8+
describe('TradingController', () => {
9+
let controller: TradingController;
10+
let service: TradingService;
11+
12+
beforeEach(async () => {
13+
const mockTradingService = {
14+
createOrder: jest.fn().mockImplementation((userId, dto) =>
15+
Promise.resolve({
16+
id: 'order-123',
17+
userId,
18+
...dto,
19+
status: OrderStatus.OPEN,
20+
}),
21+
),
22+
};
23+
24+
const module: TestingModule = await Test.createTestingModule({
25+
controllers: [TradingController],
26+
providers: [{ provide: TradingService, useValue: mockTradingService }],
27+
})
28+
.overrideGuard(JwtAuthGuard)
29+
.useValue({ canActivate: () => true })
30+
.compile();
31+
32+
controller = module.get<TradingController>(TradingController);
33+
service = module.get<TradingService>(TradingService);
34+
});
35+
36+
it('should create order for authenticated user', async () => {
37+
const req = { user: { userId: 'user-456' } };
38+
const dto: CreateOrderDto = {
39+
assetPair: 'SOL/USDC',
40+
side: OrderSide.BUY,
41+
type: OrderType.LIMIT,
42+
price: 150,
43+
quantity: 5,
44+
};
45+
46+
const result = await controller.createOrder(req, dto);
47+
expect(result).toBeDefined();
48+
expect(service.createOrder).toHaveBeenCalledWith('user-456', dto);
49+
});
50+
});
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { Controller, Post, Body, UseGuards, Req } from '@nestjs/common';
2+
import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard';
3+
import { TradingService } from '../services/trading.service';
4+
import { CreateOrderDto } from '../dto/create-order.dto';
5+
6+
@Controller('trading')
7+
export class TradingController {
8+
constructor(private readonly tradingService: TradingService) {}
9+
10+
@Post('orders')
11+
@UseGuards(JwtAuthGuard)
12+
createOrder(@Req() req, @Body() createOrderDto: CreateOrderDto) {
13+
const userId = req.user?.userId || req.user?.id || req.user?.sub;
14+
return this.tradingService.createOrder(userId, createOrderDto);
15+
}
16+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { IsEnum, IsNotEmpty, IsNumber, IsString } from 'class-validator';
2+
import { OrderType, OrderSide } from '../enums/order.enum';
3+
4+
export class CreateOrderDto {
5+
@IsNotEmpty()
6+
@IsString()
7+
assetPair: string;
8+
9+
@IsNotEmpty()
10+
@IsEnum(OrderSide)
11+
side: OrderSide;
12+
13+
@IsNotEmpty()
14+
@IsEnum(OrderType)
15+
type: OrderType;
16+
17+
@IsNotEmpty()
18+
@IsNumber()
19+
price: number;
20+
21+
@IsNotEmpty()
22+
@IsNumber()
23+
quantity: number;
24+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import {
2+
Entity,
3+
PrimaryGeneratedColumn,
4+
Column,
5+
CreateDateColumn,
6+
UpdateDateColumn,
7+
} from 'typeorm';
8+
import { OrderType, OrderSide, OrderStatus } from '../enums/order.enum';
9+
10+
@Entity('orders')
11+
export class Order {
12+
@PrimaryGeneratedColumn('uuid')
13+
id: string;
14+
15+
@Column()
16+
userId: string;
17+
18+
@Column()
19+
assetPair: string;
20+
21+
@Column({ type: 'enum', enum: OrderSide })
22+
side: OrderSide;
23+
24+
@Column({ type: 'enum', enum: OrderType })
25+
type: OrderType;
26+
27+
@Column({ type: 'decimal', precision: 18, scale: 8 })
28+
price: number;
29+
30+
@Column({ type: 'decimal', precision: 18, scale: 8 })
31+
quantity: number;
32+
33+
@Column({ type: 'decimal', precision: 18, scale: 8, default: 0 })
34+
filledQuantity: number;
35+
36+
@Column({ type: 'enum', enum: OrderStatus, default: OrderStatus.OPEN })
37+
status: OrderStatus;
38+
39+
@CreateDateColumn()
40+
createdAt: Date;
41+
42+
@UpdateDateColumn()
43+
updatedAt: Date;
44+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import {
2+
Entity,
3+
PrimaryGeneratedColumn,
4+
Column,
5+
CreateDateColumn,
6+
} from 'typeorm';
7+
8+
@Entity('trades')
9+
export class Trade {
10+
@PrimaryGeneratedColumn('uuid')
11+
id: string;
12+
13+
@Column()
14+
assetPair: string;
15+
16+
@Column()
17+
takerOrderId: string;
18+
19+
@Column()
20+
makerOrderId: string;
21+
22+
@Column({ type: 'decimal', precision: 18, scale: 8 })
23+
price: number;
24+
25+
@Column({ type: 'decimal', precision: 18, scale: 8 })
26+
quantity: number;
27+
28+
@CreateDateColumn()
29+
timestamp: Date;
30+
}

src/trading/enums/order.enum.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
export enum OrderType {
2+
LIMIT = 'LIMIT',
3+
MARKET = 'MARKET',
4+
}
5+
6+
export enum OrderSide {
7+
BUY = 'BUY',
8+
SELL = 'SELL',
9+
}
10+
11+
export enum OrderStatus {
12+
OPEN = 'OPEN',
13+
PARTIALLY_FILLED = 'PARTIALLY_FILLED',
14+
FILLED = 'FILLED',
15+
CANCELLED = 'CANCELLED',
16+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { MatchingEngine } from './matching-engine.service';
2+
import { Order } from '../entities/order.entity';
3+
import { OrderSide, OrderStatus, OrderType } from '../enums/order.enum';
4+
5+
describe('MatchingEngine', () => {
6+
let matchingEngine: MatchingEngine;
7+
8+
beforeEach(() => {
9+
matchingEngine = new MatchingEngine();
10+
});
11+
12+
it('should match buy and sell orders when prices overlap', () => {
13+
const buyOrder = new Order();
14+
buyOrder.id = 'buy-1';
15+
buyOrder.userId = 'user-1';
16+
buyOrder.assetPair = 'ETH/USDT';
17+
buyOrder.side = OrderSide.BUY;
18+
buyOrder.type = OrderType.LIMIT;
19+
buyOrder.price = 2000;
20+
buyOrder.quantity = 2;
21+
buyOrder.filledQuantity = 0;
22+
buyOrder.status = OrderStatus.OPEN;
23+
buyOrder.createdAt = new Date('2026-01-01T00:00:00Z');
24+
25+
const sellOrder = new Order();
26+
sellOrder.id = 'sell-1';
27+
sellOrder.userId = 'user-2';
28+
sellOrder.assetPair = 'ETH/USDT';
29+
sellOrder.side = OrderSide.SELL;
30+
sellOrder.type = OrderType.LIMIT;
31+
sellOrder.price = 1900;
32+
sellOrder.quantity = 1;
33+
sellOrder.filledQuantity = 0;
34+
sellOrder.status = OrderStatus.OPEN;
35+
sellOrder.createdAt = new Date('2026-01-01T00:00:01Z');
36+
37+
// Add buy order first (no match yet)
38+
const trades1 = matchingEngine.addOrder(buyOrder);
39+
expect(trades1.length).toBe(0);
40+
41+
// Add sell order (should match)
42+
const trades2 = matchingEngine.addOrder(sellOrder);
43+
expect(trades2.length).toBe(1);
44+
expect(trades2[0].assetPair).toBe('ETH/USDT');
45+
expect(trades2[0].quantity).toBe(1);
46+
expect(trades2[0].price).toBe(1900);
47+
expect(trades2[0].takerOrderId).toBe('buy-1');
48+
expect(trades2[0].makerOrderId).toBe('sell-1');
49+
50+
expect(sellOrder.status).toBe(OrderStatus.FILLED);
51+
expect(buyOrder.status).toBe(OrderStatus.PARTIALLY_FILLED);
52+
expect(buyOrder.filledQuantity).toBe(1);
53+
});
54+
});
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { Injectable } from '@nestjs/common';
2+
import { Order } from '../entities/order.entity';
3+
import { OrderBook } from './order-book.service';
4+
import { Trade } from '../entities/trade.entity';
5+
import { OrderStatus, OrderSide } from '../enums/order.enum';
6+
7+
@Injectable()
8+
export class MatchingEngine {
9+
private readonly orderBooks: Map<string, OrderBook> = new Map();
10+
11+
addOrder(order: Order): Trade[] {
12+
const orderBook = this.getOrderBook(order.assetPair);
13+
orderBook.addOrder(order);
14+
return this.matchOrders(order.assetPair);
15+
}
16+
17+
private getOrderBook(assetPair: string): OrderBook {
18+
let book = this.orderBooks.get(assetPair);
19+
if (!book) {
20+
book = new OrderBook();
21+
this.orderBooks.set(assetPair, book);
22+
}
23+
return book;
24+
}
25+
26+
private matchOrders(assetPair: string): Trade[] {
27+
const trades: Trade[] = [];
28+
const orderBook = this.getOrderBook(assetPair);
29+
const bids = orderBook.getBids();
30+
const asks = orderBook.getAsks();
31+
32+
while (
33+
bids.length > 0 &&
34+
asks.length > 0 &&
35+
bids[0].price >= asks[0].price
36+
) {
37+
const bestBid = bids[0];
38+
const bestAsk = asks[0];
39+
const tradeQuantity = Math.min(
40+
bestBid.quantity - bestBid.filledQuantity,
41+
bestAsk.quantity - bestAsk.filledQuantity,
42+
);
43+
44+
const trade = new Trade();
45+
trade.assetPair = assetPair;
46+
trade.price = bestAsk.price;
47+
trade.quantity = tradeQuantity;
48+
trade.takerOrderId = bestBid.id;
49+
trade.makerOrderId = bestAsk.id;
50+
trade.timestamp = new Date();
51+
trades.push(trade);
52+
53+
bestBid.filledQuantity += tradeQuantity;
54+
bestAsk.filledQuantity += tradeQuantity;
55+
56+
if (bestBid.filledQuantity === bestBid.quantity) {
57+
bestBid.status = OrderStatus.FILLED;
58+
bids.shift();
59+
} else {
60+
bestBid.status = OrderStatus.PARTIALLY_FILLED;
61+
}
62+
63+
if (bestAsk.filledQuantity === bestAsk.quantity) {
64+
bestAsk.status = OrderStatus.FILLED;
65+
asks.shift();
66+
} else {
67+
bestAsk.status = OrderStatus.PARTIALLY_FILLED;
68+
}
69+
}
70+
return trades;
71+
}
72+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { OrderBook } from './order-book.service';
2+
import { Order } from '../entities/order.entity';
3+
import { OrderSide, OrderType, OrderStatus } from '../enums/order.enum';
4+
5+
describe('OrderBook', () => {
6+
let orderBook: OrderBook;
7+
8+
beforeEach(() => {
9+
orderBook = new OrderBook();
10+
});
11+
12+
it('should sort buy orders with highest price first (bids)', () => {
13+
const order1 = new Order();
14+
order1.id = '1';
15+
order1.side = OrderSide.BUY;
16+
order1.price = 100;
17+
order1.createdAt = new Date('2026-01-01T00:00:00Z');
18+
19+
const order2 = new Order();
20+
order2.id = '2';
21+
order2.side = OrderSide.BUY;
22+
order2.price = 150;
23+
order2.createdAt = new Date('2026-01-01T00:00:01Z');
24+
25+
orderBook.addOrder(order1);
26+
orderBook.addOrder(order2);
27+
28+
const bids = orderBook.getBids();
29+
expect(bids.length).toBe(2);
30+
expect(bids[0].price).toBe(150);
31+
expect(bids[1].price).toBe(100);
32+
});
33+
34+
it('should sort sell orders with lowest price first (asks)', () => {
35+
const order1 = new Order();
36+
order1.id = '1';
37+
order1.side = OrderSide.SELL;
38+
order1.price = 200;
39+
order1.createdAt = new Date('2026-01-01T00:00:00Z');
40+
41+
const order2 = new Order();
42+
order2.id = '2';
43+
order2.side = OrderSide.SELL;
44+
order2.price = 180;
45+
order2.createdAt = new Date('2026-01-01T00:00:01Z');
46+
47+
orderBook.addOrder(order1);
48+
orderBook.addOrder(order2);
49+
50+
const asks = orderBook.getAsks();
51+
expect(asks.length).toBe(2);
52+
expect(asks[0].price).toBe(180);
53+
expect(asks[1].price).toBe(200);
54+
});
55+
});

0 commit comments

Comments
 (0)