Skip to content

Commit a18565a

Browse files
authored
Merge pull request #435 from ayshadogo/feat/order-book
Order Book Service (storage, queries, snapshots)
2 parents f900654 + 53e3016 commit a18565a

11 files changed

Lines changed: 865 additions & 7 deletions

.freebuff/project-id

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +0,0 @@
1-
7abb7b97-ecb7-44ec-9f88-a47b534dbacd
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { IsOptional, IsInt, Min, Max } from 'class-validator';
2+
import { Type } from 'class-transformer';
3+
4+
export class OrderBookQueryDto {
5+
/**
6+
* Number of price levels to return per side (bids/asks).
7+
* Defaults to 10, max 100.
8+
*/
9+
@IsOptional()
10+
@Type(() => Number)
11+
@IsInt()
12+
@Min(1)
13+
@Max(100)
14+
depth?: number = 10;
15+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { IsOptional, IsInt, Min, Max, IsEnum } from 'class-validator';
2+
import { Type } from 'class-transformer';
3+
import { OrderStatus } from '../../common/enums/order-type.enum';
4+
5+
export class PaginatedOrdersQueryDto {
6+
/** Page number (1-indexed). */
7+
@IsOptional()
8+
@Type(() => Number)
9+
@IsInt()
10+
@Min(1)
11+
page?: number = 1;
12+
13+
/** Items per page (default 20, max 100). */
14+
@IsOptional()
15+
@Type(() => Number)
16+
@IsInt()
17+
@Min(1)
18+
@Max(100)
19+
limit?: number = 20;
20+
21+
/** Filter by order status. */
22+
@IsOptional()
23+
@IsEnum(OrderStatus)
24+
status?: OrderStatus;
25+
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
// Mock the problematic imports that aren't installed in the test env
2+
jest.mock('@nestjs/websockets', () => ({}));
3+
jest.mock('socket.io', () => ({ Server: jest.fn() }));
4+
jest.mock('uuid', () => ({ v4: jest.fn(() => 'test-uuid') }));
5+
6+
import { Test, TestingModule } from '@nestjs/testing';
7+
import { OrderBookUpdateListener } from './order-book-update.listener';
8+
import { WebSocketService } from '../../websocket/services/websocket.service';
9+
import type { OrderBookResponse } from '../services/order-book.service';
10+
11+
const mockWebSocketService = {
12+
broadcastOrderBookUpdate: jest.fn(),
13+
broadcastOrderUpdate: jest.fn(),
14+
broadcastTradeExecution: jest.fn(),
15+
};
16+
17+
describe('OrderBookUpdateListener', () => {
18+
let listener: OrderBookUpdateListener;
19+
20+
beforeEach(async () => {
21+
jest.clearAllMocks();
22+
23+
const module: TestingModule = await Test.createTestingModule({
24+
providers: [
25+
OrderBookUpdateListener,
26+
{ provide: WebSocketService, useValue: mockWebSocketService },
27+
],
28+
}).compile();
29+
30+
listener = module.get(OrderBookUpdateListener);
31+
});
32+
33+
describe('handleOrderBookUpdate', () => {
34+
it('should broadcast order book update with correct shape', () => {
35+
const response: OrderBookResponse = {
36+
assetId: 1,
37+
pair: 'BTC',
38+
topOfBook: {
39+
bestBid: { price: 99, amount: 5, count: 1 },
40+
bestAsk: { price: 101, amount: 3, count: 1 },
41+
spread: 2,
42+
midPrice: 100,
43+
},
44+
bids: [
45+
{ price: 99, amount: 5, count: 1 },
46+
{ price: 98, amount: 10, count: 2 },
47+
],
48+
asks: [
49+
{ price: 101, amount: 3, count: 1 },
50+
{ price: 102, amount: 8, count: 3 },
51+
],
52+
timestamp: '2026-08-20T12:00:00.000Z',
53+
sequence: 42,
54+
};
55+
56+
listener.handleOrderBookUpdate(response);
57+
58+
expect(mockWebSocketService.broadcastOrderBookUpdate).toHaveBeenCalledWith({
59+
asset: '1',
60+
bids: [
61+
{ price: 99, amount: 5, count: 1 },
62+
{ price: 98, amount: 10, count: 2 },
63+
],
64+
asks: [
65+
{ price: 101, amount: 3, count: 1 },
66+
{ price: 102, amount: 8, count: 3 },
67+
],
68+
timestamp: '2026-08-20T12:00:00.000Z',
69+
sequence: 42,
70+
});
71+
});
72+
73+
it('should handle empty order book', () => {
74+
const response: OrderBookResponse = {
75+
assetId: 2,
76+
pair: 'ETH',
77+
topOfBook: {
78+
bestBid: null,
79+
bestAsk: null,
80+
spread: null,
81+
midPrice: null,
82+
},
83+
bids: [],
84+
asks: [],
85+
timestamp: '2026-08-20T12:00:00.000Z',
86+
sequence: 1,
87+
};
88+
89+
listener.handleOrderBookUpdate(response);
90+
91+
expect(mockWebSocketService.broadcastOrderBookUpdate).toHaveBeenCalledWith({
92+
asset: '2',
93+
bids: [],
94+
asks: [],
95+
timestamp: '2026-08-20T12:00:00.000Z',
96+
sequence: 1,
97+
});
98+
});
99+
});
100+
});
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { Injectable, Logger } from '@nestjs/common';
2+
import { OnEvent } from '@nestjs/event-emitter';
3+
import { WebSocketService } from '../../websocket/services/websocket.service';
4+
import type { OrderBookResponse } from '../services/order-book.service';
5+
6+
/**
7+
* Listens for order book update events emitted by OrderBookService and
8+
* broadcasts them to WebSocket subscribers on the `orderbook:<assetId>`
9+
* channel. This satisfies the acceptance criteria:
10+
* "Order book updates streamed to subscribed clients within 1s of change
11+
* for top-of-book."
12+
*
13+
* The event is emitted synchronously by EventEmitter2 after every order
14+
* mutation (place, cancel, fill), so subscribers see changes nearly
15+
* instantly — well within the 1-second requirement.
16+
*/
17+
@Injectable()
18+
export class OrderBookUpdateListener {
19+
private readonly logger = new Logger(OrderBookUpdateListener.name);
20+
21+
constructor(private readonly webSocketService: WebSocketService) {}
22+
23+
@OnEvent('orderbook.update')
24+
handleOrderBookUpdate(response: OrderBookResponse): void {
25+
const channel = `orderbook:${response.assetId}`;
26+
27+
this.webSocketService.broadcastOrderBookUpdate({
28+
asset: String(response.assetId),
29+
bids: response.topOfBook.bestBid
30+
? [response.topOfBook.bestBid, ...response.bids.slice(1)]
31+
: response.bids,
32+
asks: response.topOfBook.bestAsk
33+
? [response.topOfBook.bestAsk, ...response.asks.slice(1)]
34+
: response.asks,
35+
timestamp: response.timestamp,
36+
sequence: response.sequence,
37+
});
38+
39+
this.logger.debug(
40+
`Broadcast order book update for asset ${response.assetId} to channel ${channel} (seq=${response.sequence})`,
41+
);
42+
}
43+
}

0 commit comments

Comments
 (0)