# encoding: UTF-8 # frozen_string_literal: true require_relative 'constants' module Matching class Engine ORDER_SUBMIT_MAX_ATTEMPTS = 3 MIN_INCREMENT_COUNT_TO_SNAPSHOT = 20 MIN_PERIOD_TO_SNAPSHOT = 10.second MAX_PERIOD_TO_SNAPSHOT = 60.second attr :orderbook, :mode, :queue attr_accessor :initializing, :snapshot_time, :increment_count, :sequence_number delegate :ask_orders, :bid_orders, to: :orderbook def initialize(market, options={}) @market = market @orderbook = OrderBookManager.new(market.id, on_change: method(:publish_increment)) @initializing = true @sequence_number = 1 @increment_count = 0 @snapshot_time = Time.now # Engine is able to run in different mode: # dryrun: do the match, do not publish the trades # run: do the match, publish the trades (default) shift_gears(options[:mode] || :run) end def shift_gears(mode) case mode when :dryrun @queue = [] class <<@queue def enqueue(*args) push args end end when :run @queue = AMQP::Queue else raise "Unrecognized mode: #{mode}" end @mode = mode end def submit(order) attempt ||= 1 match(order) rescue StandardError => e ::Rails.logger.error "Failed to submit order #{order.label}. Attempt #{attempt}." report_exception(e) if attempt < ORDER_SUBMIT_MAX_ATTEMPTS ::Rails.logger.error "Retrying to submit order #{order.label}." attempt += 1 retry else ::Rails.logger.error "Cancelling order #{order.label}." publish_cancel(order) end end def match(order) book, opposite_book = orderbook.get_books(order.type) loop do # If order is fulfilled we stop matching. break if order.filled? # If opposite orderbook is empty: # - add order to orderbook in case of limit order; # - publish message with cancel action to order processor in case of market order. if opposite_book.top.blank? order.is_a?(LimitOrder) ? book.add(order) : publish_cancel(order) break end opposite_order = opposite_book.top trade = order.trade_with(opposite_order, opposite_book) # If order doesn't match with best order opposite order: # - add order to orderbook in case of limit order; # - publish message with cancel action to order processor in case of market order. if trade.blank? order.is_a?(LimitOrder) ? book.add(order) : publish_cancel(order) break end price, amount, total = trade validate_trade!(price, amount, total) order.fill(price, amount, total) opposite_book.fill_top(price, amount, total) # Publish message to trade_executor with matched trade. publish(order, opposite_order, trade) # NOTE: Legacy peatio was designed in the way that there is orderbook # for both limit and market orders. # We are using MarketOrderbookError for averting this behaviour. # Market order is either match directly or it's cancelled. # This error and it's usage should be dropped with market type # orderbook removing. rescue MarketOrderbookError => e report_exception(e) cancel(e.order) end end def cancel(order) book, _counter_book = orderbook.get_books(order.type) book.remove(order) publish_cancel(order) rescue => e ::Rails.logger.error { "Failed to cancel order #{order.label}." } report_exception(e) end def limit_orders { ask: ask_orders.limit_orders, bid: bid_orders.limit_orders } end def market_orders { ask: ask_orders.market_orders, bid: bid_orders.market_orders } end def publish_snapshot @snapshot_time = Time.now ::AMQP::Queue.enqueue_event("public", @market.id, "ob-snap", { "asks" => ask_orders.limit_orders.map{|k,v| [k.to_s, v.map(&:volume).sum.to_s]}[0..300], "bids" => bid_orders.limit_orders.map{|k,v| [k.to_s, v.map(&:volume).sum.to_s]}.reverse[0..300], "sequence" => @sequence_number, }) end def publish_increment(market, side, price, amount) return if @initializing # Publish snapshot if: # increment_count < 20 and last snapshot time > 1 min # increment_count > 20 and last snapshot time > 10 second if @increment_count < MIN_INCREMENT_COUNT_TO_SNAPSHOT && @snapshot_time <= Time.now - MAX_PERIOD_TO_SNAPSHOT publish_snapshot @increment_count = 0 elsif @increment_count >= MIN_INCREMENT_COUNT_TO_SNAPSHOT && @snapshot_time < Time.now - MIN_PERIOD_TO_SNAPSHOT publish_snapshot @increment_count = 0 end @increment_count += 1 @sequence_number += 1 ::AMQP::Queue.enqueue_event("public", market, "ob-inc", { "#{side}s" => [price.to_s, amount.to_s], "sequence" => @sequence_number, }) end private def publish(order, counter_order, trade) maker_order, taker_order = order.id < counter_order.id ? [order, counter_order] : [counter_order, order] # Rounding is forbidden in this step because it can cause difference # between amount/total in DB and orderbook. price = trade[0] amount = trade[1] total = trade[2] ::Rails.logger.info { "[#{@market.id}] new trade - maker_order: #{maker_order.label} taker_order: #{taker_order.label} price: #{price} amount: #{amount} total: #{total}" } @queue.enqueue(:trade_executor, { action: 'execute', trade: { market_id: @market.id, maker_order_id: maker_order.id, taker_order_id: taker_order.id, strike_price: price, amount: amount, total: total } }, { persistent: false }) end def publish_cancel(order) @queue.enqueue(:trade_executor, { action: 'cancel', order: order.attributes }, { persistent: false }) end def validate_trade!(price, amount, total) message = if [price, amount, total].any? { |d| d == ZERO } 'price, amount or total is equal to 0.' elsif price * amount != total 'price * amount != total' elsif round(price * amount) != round(total) 'round(price * amount) != round(total)' end return if message.blank? TradeStruct.new(price, amount, total).tap do |t| raise TradeError.new(t, message) end end def round(d) d.round(Market::DB_DECIMAL_PRECISION, BigDecimal::ROUND_DOWN) end end end