Market Service
Торговое ядро и матчинг ордеров
Market Service (В разработке)
Статус: Проектирование (Sprint 4). Вторичный рынок и матчинг.
Market Service обеспечивает вторичное обращение ЦФА на платформе, позволяя пользователям торговать активами между собой.
Основные функции
- Order Management: Создание, отмена и изменение лимитных и рыночных ордеров.
- Matching Engine: Высокопроизводительный движок для сопоставления заявок (FIFO).
- Trade Settlement: Атомарный расчет сделок (DvP — Delivery vs Payment) через взаимодействие с Ledger-DB и CFA-Core.
- Order Book: Поддержание актуального состояния стакана заявок.
Технологический стек
- Фреймворк: NestJS / Node.js
- In-memory Matching: Для обеспечения низкой задержки.
- Kafka: Публикация событий о сделках.
Модель данных (Protobuf)
message Order {
string order_id = 1;
string user_id = 2;
string instrument_id = 3;
OrderSide side = 4; // BUY / SELL
OrderType type = 5; // LIMIT / MARKET
TimeInForce time_in_force = 6; // GTC / IOC / FOK
Decimal price = 7;
Decimal quantity = 8;
OrderStatus status = 9; // OPEN / PARTIALLY_FILLED / FILLED / CANCELLED / REJECTED
}
message Trade {
string trade_id = 1;
string buy_order_id = 2;
string sell_order_id = 3;
string instrument_id = 4;
Decimal price = 5;
Decimal quantity = 6;
Fee fee = 7;
}Бизнес-логика торговли
- Размещение ордера:
- Проверка баланса (Hold в Ledger-DB).
- Публикация в Order Book.
- Matching:
- Поиск встречных заявок по алгоритму FIFO (First In, First Out).
- Формирование сделки (Trade).
- DvP Settlement:
- Атомарное списание/зачисление RUB и ЦФА.
- Начисление комиссий.
Потоки данных
- Пользователь размещает ордер →
OrderPlacedEvent. - Движок находит совпадение →
TradeExecutedEvent. - Ledger-DB блокирует/переводит средства.
- CFA-Core переводит права в блокчейне.
API (gRPC Connect)
MarketService
PlaceOrder
Размещение ордера на покупку или продажу.
message PlaceOrderRequest {
RequestContext context = 1;
Order order = 2;
}
message PlaceOrderResponse {
Order order = 1;
}Пример:
const order = await marketService.placeOrder(
{ requestId, correlationId, idempotencyKey },
{
userId: 'user-123',
instrumentId: 'instrument-456',
side: OrderSide.BUY,
type: OrderType.LIMIT,
price: { amount: '100', currencyCode: 'RUB' },
quantity: 10,
timeInForce: TimeInForce.GTC,
},
);CancelOrder
Отмена ордера.
message CancelOrderRequest {
RequestContext context = 1;
string order_id = 2;
}
message CancelOrderResponse {
Order order = 1;
}GetOrder
Получение информации об ордере.
message GetOrderRequest {
RequestContext context = 1;
string order_id = 2;
}
message GetOrderResponse {
Order order = 1;
}ListOrders
Получение списка ордеров пользователя.
message ListOrdersRequest {
RequestContext context = 1;
string user_id = 2;
string instrument_id = 3;
OrderStatus status = 4;
PaginationRequest pagination = 5;
}
message ListOrdersResponse {
repeated Order orders = 1;
PaginationResponse pagination = 2;
}ListTrades
Получение списка сделок пользователя.
message ListTradesRequest {
RequestContext context = 1;
string user_id = 2;
string instrument_id = 3;
PaginationRequest pagination = 4;
}
message ListTradesResponse {
repeated Trade trades = 1;
PaginationResponse pagination = 2;
}GetOrderBook
Получение стакана ордеров.
message GetOrderBookRequest {
RequestContext context = 1;
string instrument_id = 2;
}
message GetOrderBookResponse {
OrderBookSnapshot snapshot = 1;
}
message OrderBookSnapshot {
string instrument_id = 1;
repeated OrderBookLevel bids = 2;
repeated OrderBookLevel asks = 3;
google.protobuf.Timestamp captured_at = 4;
}
message OrderBookLevel {
Decimal price = 1;
Decimal quantity = 2;
}Matching Engine
Алгоритм
Matching Engine использует алгоритм FIFO (First In, First Out) для сопоставления ордеров.
class MatchingEngine {
private orderBooks: Map<string, OrderBook> = new Map();
placeOrder(order: Order): Trade[] {
const orderBook = this.getOrderBook(order.instrumentId);
const trades: Trade[] = [];
if (order.side === OrderSide.BUY) {
// Ищем встречные ордера на продажу
while (order.quantity > 0 && orderBook.hasAsks()) {
const ask = orderBook.bestAsk();
if (ask.price > order.price) {
break; // Цена слишком высокая
}
const trade = this.executeTrade(order, ask);
trades.push(trade);
if (ask.quantity === 0) {
orderBook.removeAsk(ask);
}
}
} else {
// Ищем встречные ордера на покупку
while (order.quantity > 0 && orderBook.hasBids()) {
const bid = orderBook.bestBid();
if (bid.price < order.price) {
break; // Цена слишком низкая
}
const trade = this.executeTrade(bid, order);
trades.push(trade);
if (bid.quantity === 0) {
orderBook.removeBid(bid);
}
}
}
// Если ордер не полностью исполнен, добавляем в книгу
if (order.quantity > 0) {
orderBook.addOrder(order);
}
return trades;
}
private executeTrade(buyOrder: Order, sellOrder: Order): Trade {
const tradeQuantity = Math.min(buyOrder.quantity, sellOrder.quantity);
const tradePrice = sellOrder.price; // Цена ордера, который был в книге раньше
// Обновляем количества
buyOrder.quantity -= tradeQuantity;
sellOrder.quantity -= tradeQuantity;
// Создаем сделку
const trade = {
tradeId: crypto.randomUUID(),
buyOrderId: buyOrder.orderId,
sellOrderId: sellOrder.orderId,
instrumentId: buyOrder.instrumentId,
price: tradePrice,
quantity: tradeQuantity,
fee: this.calculateFee(tradeQuantity, tradePrice),
executedAt: new Date(),
};
return trade;
}
private calculateFee(quantity: Decimal, price: Decimal): Fee {
const total = quantity * price;
const feeRate = 0.001; // 0.1%
const feeAmount = total * feeRate;
return {
feeType: 'TRADING',
amount: {
amount: feeAmount,
currencyCode: 'RUB',
},
};
}
}DvP (Delivery vs Payment)
Атомарный расчет сделок с гарантией поставки против платежа.
Loading diagram...
Order Book
Структура
class OrderBook {
private bids: PriorityQueue<Order> = new PriorityQueue(
(a, b) => b.price - a.price, // Высокая цена в начале
);
private asks: PriorityQueue<Order> = new PriorityQueue(
(a, b) => a.price - b.price, // Низкая цена в начале
);
addOrder(order: Order): void {
if (order.side === OrderSide.BUY) {
this.bids.push(order);
} else {
this.asks.push(order);
}
}
removeOrder(order: Order): void {
if (order.side === OrderSide.BUY) {
this.bids.remove(order);
} else {
this.asks.remove(order);
}
}
bestBid(): Order | null {
return this.bids.peek();
}
bestAsk(): Order | null {
return this.asks.peek();
}
hasBids(): boolean {
return !this.bids.isEmpty();
}
hasAsks(): boolean {
return !this.asks.isEmpty();
}
getSnapshot(): OrderBookSnapshot {
return {
instrumentId: this.instrumentId,
bids: this.bids.toArray().map((o) => ({
price: o.price,
quantity: o.quantity,
})),
asks: this.asks.toArray().map((o) => ({
price: o.price,
quantity: o.quantity,
})),
capturedAt: new Date(),
};
}
}Хранение в Redis
import { Redis } from 'ioredis';
class OrderBookStorage {
private redis: Redis;
async saveSnapshot(instrumentId: string, snapshot: OrderBookSnapshot): Promise<void> {
const key = `orderbook:${instrumentId}`;
await this.redis.setex(key, 3600, JSON.stringify(snapshot));
}
async getSnapshot(instrumentId: string): Promise<OrderBookSnapshot | null> {
const key = `orderbook:${instrumentId}`;
const data = await this.redis.get(key);
return data ? JSON.parse(data) : null;
}
async publishUpdate(instrumentId: string, snapshot: OrderBookSnapshot): Promise<void> {
await this.redis.publish(`orderbook:${instrumentId}`, JSON.stringify(snapshot));
}
}События Kafka
Исходящие (maniton.market.events.v1)
OrderPlacedEvent
message OrderPlacedEvent {
RequestContext context = 1;
string order_id = 2;
string user_id = 3;
string instrument_id = 4;
OrderSide side = 5;
OrderType type = 6;
Decimal price = 7;
Decimal quantity = 8;
google.protobuf.Timestamp placed_at = 9;
}TradeExecutedEvent
message TradeExecutedEvent {
RequestContext context = 1;
string trade_id = 2;
string buyer_id = 3;
string seller_id = 4;
string instrument_id = 5;
Decimal quantity = 6;
Decimal price = 7;
Fee fee = 8;
google.protobuf.Timestamp executed_at = 9;
}OrderCancelledEvent
message OrderCancelledEvent {
RequestContext context = 1;
string order_id = 2;
string reason = 3;
google.protobuf.Timestamp cancelled_at = 4;
}Входящие
maniton.ledger.events.v1:HoldCreated,HoldReleased,HoldConsumedmaniton.cfa.events.v1:CfaIssued,CfaRedeemed,CfaTransferred
Use Cases
PlaceOrderUseCase
Размещение ордера с проверкой баланса и лимитов.
async execute(request: PlaceOrderRequest): Promise<PlaceOrderResponse> {
const { context, order } = request;
// 1. Проверка KYC
const userProfile = await this.identityClient.getUserProfile(context, order.userId);
if (userProfile.kycStatus < KycStatus.STANDARD) {
throw new Error('KYC level too low for trading');
}
// 2. Проверка лимитов
const limitsCheck = await this.identityClient.checkLimits(
context,
order.userId,
LimitType.TRADING,
{ amount: order.quantity, currencyCode: 'RUB' },
OperationType.TRADING
);
if (!limitsCheck.allowed) {
throw new Error(limitsCheck.reason);
}
// 3. Проверка баланса (для покупки)
if (order.side === OrderSide.BUY) {
const total = order.quantity * order.price;
const balance = await this.ledgerClient.getBalance(
order.userId,
'CFA-RUB'
);
if (balance < total) {
throw new Error('Insufficient balance');
}
}
// 4. Создание холда
if (order.side === OrderSide.BUY) {
const total = order.quantity * order.price;
await this.ledgerClient.createHold({
subaccountId: order.userId,
amount: total,
reason: `Order ${order.orderId}`,
});
} else {
await this.ledgerClient.createHold({
subaccountId: order.userId,
amount: order.quantity,
reason: `Order ${order.orderId}`,
});
}
// 5. Размещение ордера
const trades = this.matchingEngine.placeOrder(order);
// 6. Исполнение сделок
for (const trade of trades) {
await this.executeTrade(trade);
}
// 7. Публикация событий
await this.eventPublisher.publish({
type: 'OrderPlaced',
payload: order,
});
for (const trade of trades) {
await this.eventPublisher.publish({
type: 'TradeExecuted',
payload: trade,
});
}
return { order };
}ExecuteTradeUseCase
Исполнение сделки с DvP.
async executeTrade(trade: Trade): Promise<void> {
const { tradeId, buyOrderId, sellOrderId, instrumentId, quantity, price } = trade;
// 1. Получение ордеров
const buyOrder = await this.orderRepository.findById(buyOrderId);
const sellOrder = await this.orderRepository.findById(sellOrderId);
// 2. Создание операции в Ledger
const operation = await this.ledgerClient.createOperation({
type: OperationType.TRADE_BUY,
userId: buyOrder.userId,
idempotencyKey: tradeId,
postings: [
{
debitSubaccountId: `${buyOrder.userId}:CFA-RUB`,
creditSubaccountId: 'system:reserve:CFA-RUB',
amount: quantity * price,
},
{
debitSubaccountId: 'system:reserve:CFA-EQ',
creditSubaccountId: `${buyOrder.userId}:${instrumentId}`,
amount: quantity,
},
],
});
// 3. Перевод прав в блокчейне
const tx = await this.cfaClient.transferCfa({
context: { requestId, correlationId, idempotencyKey: tradeId },
instrumentId,
fromUserId: sellOrder.userId,
toUserId: buyOrder.userId,
amount: quantity,
tradeId,
});
// 4. Потребление холдов
await this.ledgerClient.consumeHold(buyOrder.holdId);
await this.ledgerClient.consumeHold(sellOrder.holdId);
// 5. Финализация операции
await this.ledgerClient.finalizeOperation(operation.operationId, tx.txHash);
// 6. Начисление комиссии
await this.ledgerClient.createOperation({
type: OperationType.FEE,
userId: buyOrder.userId,
idempotencyKey: `${tradeId}:fee`,
postings: [
{
debitSubaccountId: `${buyOrder.userId}:CFA-RUB`,
creditSubaccountId: 'system:fees:CFA-RUB',
amount: trade.fee.amount,
},
],
});
}Мониторинг
Метрики
import { Counter, Histogram, Gauge } from 'prom-client';
export const ordersPlacedTotal = new Counter({
name: 'orders_placed_total',
help: 'Total number of orders placed',
labelNames: ['instrument_id', 'side'],
});
export const tradesExecutedTotal = new Counter({
name: 'trades_executed_total',
help: 'Total number of trades executed',
labelNames: ['instrument_id'],
});
export const orderBookDepth = new Gauge({
name: 'order_book_depth',
help: 'Order book depth by instrument',
labelNames: ['instrument_id', 'side'],
});
export const tradeExecutionDuration = new Histogram({
name: 'trade_execution_duration_seconds',
help: 'Time spent executing trades',
buckets: [0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5],
});Логи
this.logger.log('Order placed', {
orderId: order.orderId,
userId: order.userId,
instrumentId: order.instrumentId,
side: order.side,
price: order.price,
quantity: order.quantity,
});
this.logger.log('Trade executed', {
tradeId: trade.tradeId,
buyerId: trade.buyerId,
sellerId: trade.sellerId,
instrumentId: trade.instrumentId,
quantity: trade.quantity,
price: trade.price,
fee: trade.fee,
});Troubleshooting
Проблема: Ордер не исполняется
Диагностика:
# Проверка стакана
curl http://market-service:3004/orderbook?instrument_id=instrument-456
# Проверка ордера
curl http://market-service:3004/orders/$ORDER_ID
# Проверка холдов
curl http://ledger-service:3003/holdsРешение:
- Проверьте, что цена соответствует стакану
- Проверьте, что холды созданы
- Проверьте, что баланс достаточен
Проблема: Сделка не проходит
Диагностика:
# Проверка статуса сделки
curl http://market-service:3004/trades/$TRADE_ID
# Проверка операции в Ledger
curl http://ledger-service:3003/operations/$OPERATION_ID
# Проверка транзакцию в блокчейне
curl -X POST http://besu-connector:3004/getTransactionReceipt \
-H "Content-Type: application/json" \
-d '{"tx_hash": "0x..."}'Решение:
- Проверьте, что транзакция прошла в блокчейне
- Проверьте, что холды потреблены
- Ручной разбор если нужно