Манитон Docs

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;
}

Бизнес-логика торговли

  1. Размещение ордера:
    • Проверка баланса (Hold в Ledger-DB).
    • Публикация в Order Book.
  2. Matching:
    • Поиск встречных заявок по алгоритму FIFO (First In, First Out).
    • Формирование сделки (Trade).
  3. DvP Settlement:
    • Атомарное списание/зачисление RUB и ЦФА.
    • Начисление комиссий.

Потоки данных

  1. Пользователь размещает ордер → OrderPlacedEvent.
  2. Движок находит совпадение → TradeExecutedEvent.
  3. Ledger-DB блокирует/переводит средства.
  4. 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, HoldConsumed
  • maniton.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

Решение:

  1. Проверьте, что цена соответствует стакану
  2. Проверьте, что холды созданы
  3. Проверьте, что баланс достаточен

Проблема: Сделка не проходит

Диагностика:

# Проверка статуса сделки
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..."}'

Решение:

  1. Проверьте, что транзакция прошла в блокчейне
  2. Проверьте, что холды потреблены
  3. Ручной разбор если нужно

Дополнительные ресурсы

On this page