Initial commit

This commit is contained in:
Yaser
2026-08-13 19:50:53 +03:30
commit 38084458fe
879 changed files with 95198 additions and 0 deletions

View File

@@ -0,0 +1,50 @@
# encoding: UTF-8
# frozen_string_literal: true
module Matching
# TODO: doc.
class BaseOrder
attr_reader :id, :timestamp, :type, :market, :volume
def initialize(attrs)
@id = attrs[:id]
@timestamp = attrs[:timestamp]
@type = attrs[:type].to_sym
@volume = attrs[:volume].to_d
@market = attrs[:market]
end
def trade_with(_counter_order, _counter_book)
method_not_implemented
end
def fill(_trade_price, _trade_volume, _trade_funds)
method_not_implemented
end
def filled?
method_not_implemented
end
def label
method_not_implemented
end
def valid?(_attrs)
method_not_implemented
end
def attributes
method_not_implemented
end
def bid?
@type == :bid
end
def ask?
@type == :ask
end
end
end

View File

@@ -0,0 +1,47 @@
# encoding: UTF-8
# frozen_string_literal: true
module Matching
# NOTE: Name is TradeStruct for not confusing it with ActiveRecord Trade model.
# TODO: Use TradeStruct instead of trade_price, trade_volume, trade_funds.
TradeStruct = Struct.new(:price, :amount, :total)
ZERO = 0.to_d unless defined?(ZERO)
Error = Class.new(StandardError)
class OrderError < Error
attr_reader :order
def initialize(order, message=nil)
@order = order
super "#{message} (#{order.attributes})"
end
end
class TradeError < Error
attr_reader :trade
def initialize(trade, message=nil)
@trade = trade
super "#{message} (#{trade})"
end
end
# TODO: Use OrderError & TradeError instead of
# NotEnoughVolume, ExceedSumLimit, TradeExecutionError.
class NotEnoughVolume < Error; end
class ExceedSumLimit < Error; end
class MarketOrderbookError < OrderError; end
class TradeExecutionError < Error
attr_accessor :options
def initialize(options = {})
self.options = options
end
end
end

View File

@@ -0,0 +1,212 @@
# 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

View File

@@ -0,0 +1,215 @@
# encoding: UTF-8
# frozen_string_literal: true
require_relative 'constants'
module Matching
class Executor
ExecutorError = Class.new(StandardError)
def initialize(payload)
@payload = payload
@trade_payload = payload[:trade].symbolize_keys if payload[:trade]
end
def process
case @payload[:action]
when 'execute'
execute
when 'cancel'
publish_cancel
else
raise ExecutorError.new("Unknown action: #{@payload[:action]}")
end
end
def publish_cancel
AMQP::Queue.enqueue(:order_processor,
{ action: 'cancel', order: @payload[:order] },
{ persistent: false })
end
def execute
execute!
# TODO: Queue should exist even if none is listening.
rescue TradeExecutionError => e
AMQP::Queue.enqueue(:trade_error, e.options)
[@maker_order, @taker_order].each do |order|
order.with_lock do
next unless order.state == Order::WAIT
AMQP::Queue.enqueue(:matching, action: 'submit', order: order.to_matching_attributes)
end
end
report_exception_to_screen(e)
false
end
def execute!
# NOTE: Run matching engine for disabled markets.
@market = Market.find(@trade_payload[:market_id])
@price = @trade_payload[:strike_price].to_d
@amount = @trade_payload[:amount].to_d
@total = @trade_payload[:total].to_d
create_trade_and_strike_orders
publish_trade
@trade
end
private
def validate!
ask, bid = @maker_order.side == 'sell' ? [@maker_order, @taker_order] : [@taker_order, @maker_order]
raise_error(3001, 'Ask price exceeds strike price.') if ask.ord_type == 'limit' && ask.price > @price
raise_error(3002, 'Bid price is less than strike price.') if bid.ord_type == 'limit' && bid.price < @price
raise_error(3003, "Maker order state isn\'t equal to «wait» (#{@maker_order.state}).") unless @maker_order.state == Order::WAIT
raise_error(3004, "Taker order state isn\'t equal to «wait» (#{@taker_order.state}).") unless @taker_order.state == Order::WAIT
unless @total > ZERO && [@maker_order.volume, @taker_order.volume].min >= @amount
raise_error(3005, 'Not enough funds.')
end
end
def create_trade_and_strike_orders
ActiveRecord::Base.transaction do
Order.lock.where(id: [@trade_payload[:maker_order_id], @trade_payload[:taker_order_id]])
.includes(:ask_currency, :bid_currency)
.to_a
.tap do |orders|
@maker_order = orders.find { |order| order.id == @trade_payload[:maker_order_id] }
@taker_order = orders.find { |order| order.id == @trade_payload[:taker_order_id] }
end
# Check if accounts exists or create them.
@maker_order.member.get_account(@maker_order.income_currency)
@taker_order.member.get_account(@taker_order.income_currency)
validate!
accounts_table = Account
.lock
.select(:id, :member_id, :currency_id, :balance, :locked)
.where(member_id: [@maker_order.member_id, @taker_order .member_id].uniq, currency_id: [@market.base_unit, @market.quote_unit])
.each_with_object({}) { |record, memo| memo["#{record.currency_id}:#{record.member_id}"] = record }
@trade = Trade.new \
maker_order: @maker_order,
maker_id: @maker_order.member_id,
taker_order: @taker_order,
taker_id: @taker_order.member_id,
price: @price,
amount: @amount,
total: @total,
market: @market
strike(@trade, @maker_order, accounts_table["#{@maker_order.outcome_currency.id}:#{@maker_order.member_id}"], accounts_table["#{@maker_order.income_currency.id}:#{@maker_order.member_id}"])
strike(@trade, @taker_order, accounts_table["#{@taker_order.outcome_currency.id}:#{@taker_order.member_id}"], accounts_table["#{@taker_order.income_currency.id}:#{@taker_order.member_id}"])
@trade.record_complete_operations!
([@maker_order, @taker_order] + accounts_table.values).map do |record|
table = record.class.arel_table
statement = Arel::UpdateManager.new
statement.table(table)
statement.where(table[:id].eq(record.id))
updates = record.changed_attributes.map do |(attribute, _)|
if Order === record
value = record.public_send(attribute)
[table[attribute], ::Order::STATES.with_indifferent_access.fetch(value, value)]
else
[table[attribute], record.public_send(attribute)]
end
end
statement.set updates
statement.to_sql
end.join('; ').tap do |sql|
Rails.logger.debug { sql }
client = ActiveRecord::Base.connection.raw_connection
if Rails.configuration.database_adapter.downcase == 'PostgreSQL'.downcase
client.send_query(sql)
client.set_single_row_mode
loop do
(res = client.get_result) or break
res.check
res.each do |_row|
end
end
else
client.query(sql)
while client.next_result
end
end
end
@trade.save(validate: false)
end
end
def publish_trade
AMQP::Queue.publish :trade, @trade.as_json, {
headers: {
type: :local,
market: @market.id,
maker_id: @maker_id,
taker_id: @taker_id
}
}
@trade.trigger_event
[@maker_order, @taker_order].each do |order|
event =
case order.state
when 'cancel' then 'order_canceled'
when 'done' then 'order_completed'
else 'order_updated'
end
order.trigger_event
next unless order.ord_type == 'limit' # Skip market orders.
EventAPI.notify ['market', order.market_id, event].join('.'), \
Serializers::EventAPI.const_get(event.camelize).call(order)
end
end
def raise_error(code, message)
raise TradeExecutionError.new \
maker_order: @maker_order.attributes,
taker_order: @taker_order.attributes,
price: @price,
amount: @amount,
total: @total,
code: code,
message: message
end
def strike(trade, order, outcome_account, income_account)
#amount is crypto and total is price so total is rls or usdt
outcome_value, income_value = OrderAsk === order ? [trade.amount, trade.total] : [trade.total, trade.amount]
fee = income_value * trade.order_fee(order)
real_income_value = income_value - fee
outcome_account.assign_attributes outcome_account.attributes_after_unlock_and_sub_funds!(outcome_value)
income_account.assign_attributes income_account.attributes_after_plus_funds!(real_income_value)
order.volume -= trade.amount
order.locked -= outcome_value
order.funds_received += income_value
order.trades_count += 1
order.updated_at = Time.now
if order.volume.zero?
order.state = Order::DONE
# Unlock not used funds.
unless order.locked.zero?
outcome_account.assign_attributes outcome_account.attributes_after_unlock_funds!(order.locked)
end
elsif order.ord_type == 'market' && order.locked.zero?
# Partially filled market order has run out it's locked funds.
order.state = Order::CANCEL
order.record_cancel_operations!
end
end
end
end

View File

@@ -0,0 +1,70 @@
# encoding: UTF-8
# frozen_string_literal: true
require_relative 'constants'
module Matching
class LimitOrder < BaseOrder
attr_reader :price
def initialize(attrs)
super
@price = attrs[:price].to_d
raise OrderError.new(self, 'Order is not valid') unless valid?(attrs)
end
def trade_with(counter_order, _counter_book)
if counter_order.is_a?(MarketOrder)
raise MarketOrderbookError.new(order, 'market order in orderbook detected')
end
return unless crossed?(counter_order.price)
trade_price = counter_order.price
trade_volume = [volume, counter_order.volume].min
trade_funds = trade_price * trade_volume
[trade_price, trade_volume, trade_funds]
end
def fill(_trade_price, trade_volume, _trade_funds)
raise NotEnoughVolume if trade_volume > @volume
@volume -= trade_volume
end
def filled?
volume <= ZERO
end
def crossed?(price)
if type == :ask
price >= @price # if people offer price higher or equal than ask limit
else
price <= @price # if people offer price lower or equal than bid limit
end
end
def label
"%d/$%s/%s" % [id, price.to_s('F'), volume.to_s('F')]
end
def valid?(_attrs)
type.in?(%i[ask bid]) && \
id.present? && \
timestamp.present? && \
market.present? && \
price > ZERO
end
def attributes
{ id: @id,
timestamp: @timestamp,
type: @type,
volume: @volume,
price: @price,
market: @market,
ord_type: 'limit' }
end
end
end

View File

@@ -0,0 +1,70 @@
# encoding: UTF-8
# frozen_string_literal: true
require_relative 'constants'
module Matching
class MarketOrder < BaseOrder
attr_reader :locked
def initialize(attrs)
super
@locked = attrs[:locked].to_d
raise OrderError.new(self, 'Order is not valid') unless valid?(attrs)
end
def trade_with(counter_order, _counter_book)
if counter_order.is_a?(MarketOrder)
raise MarketOrderbookError.new(order, 'market order in orderbook detected')
end
trade_price = counter_order.price
trade_volume = [volume, counter_order.volume].min
trade_funds = trade_price * trade_volume
# If buy order is out of locked. Which means that order can't fulfill in
# current price point because locked run out.
return if bid? && trade_funds > locked
[trade_price, trade_volume, trade_funds]
end
def fill(_trade_price, trade_volume, trade_funds)
raise NotEnoughVolume if trade_volume > @volume
@volume -= trade_volume
funds = type == :ask ? trade_volume : trade_funds
raise ExceedSumLimit if funds > @locked
@locked -= funds
end
def filled?
volume <= ZERO || locked <= ZERO
end
def label
"%d/%s" % [id, volume.to_s('F')]
end
def valid?(attrs)
type.in?(%i[ask bid]) && \
attrs[:price].blank? && \
id.present? && \
timestamp.present? && \
market.present? && \
locked > ZERO
end
def attributes
{ id: @id,
timestamp: @timestamp,
type: @type,
locked: @locked,
volume: @volume,
market: @market,
ord_type: 'market' }
end
end
end

View File

@@ -0,0 +1,137 @@
# encoding: UTF-8
# frozen_string_literal: true
require_relative 'constants'
module Matching
class OrderBook
attr :side
def initialize(market, side, options={})
@market = market
@side = side.to_sym
@limit_orders = RBTree.new
@market_orders = RBTree.new
@on_change = options[:on_change]
singleton = class<<self;self;end
singleton.send :define_method, :limit_top, self.class.instance_method("#{@side}_limit_top")
end
def notify_change(market, side, price, amount=nil)
return unless @on_change
amount ||= yield
@on_change.call(market, side, price, amount)
rescue StandardError => e
report_exception(e)
end
def best_limit_price
limit_top.try(:price)
end
def top
unless @market_orders.empty?
order = @market_orders.first[1]
raise MarketOrderbookError.new(order, 'market order in orderbook detected')
end
limit_top
end
def fill_top(trade_price, trade_volume, trade_funds)
order = top
raise "No top order in empty book." unless order
order.fill trade_price, trade_volume, trade_funds
if order.filled?
remove order
else
notify_change(@market, @side, trade_price) { @limit_orders[trade_price]&.total }
end
end
def find(order)
case order
when LimitOrder
@limit_orders[order.price].find(order.id)
when MarketOrder
@market_orders[order.id]
end
end
def add(order)
raise OrderError.new(order, 'volume is less than or equal to zero') if order.volume <= ZERO
case order
when LimitOrder
@limit_orders[order.price] ||= PriceLevel.new(order.price)
@limit_orders[order.price].add order
notify_change(@market, @side, order.price) { @limit_orders[order.price]&.total }
when MarketOrder
raise MarketOrderbookError.new(order, 'market order adding to orderbook detected')
else
raise ArgumentError, "Unknown order type"
end
end
def remove(order)
case order
when LimitOrder
updated_order = remove_limit_order(order)
notify_change(@market, @side, order.price) { @limit_orders[order.price]&.total }
return updated_order
when MarketOrder
remove_market_order(order)
else
raise ArgumentError, "Unknown order type"
end
end
def limit_orders
orders = {}
@limit_orders.keys.each {|k| orders[k] = @limit_orders[k].orders }
orders
end
def market_orders
@market_orders.values
end
private
def remove_limit_order(order)
price_level = @limit_orders[order.price]
return unless price_level
order = price_level.find order.id # so we can return fresh order
return unless order
price_level.remove order
@limit_orders.delete(order.price) if price_level.empty?
order
end
def remove_market_order(order)
if order = @market_orders[order.id]
@market_orders.delete order.id
order
end
end
def ask_limit_top # lowest price wins
return if @limit_orders.empty?
price, level = @limit_orders.first
level.top
end
def bid_limit_top # highest price wins
return if @limit_orders.empty?
price, level = @limit_orders.last
level.top
end
end
end

View File

@@ -0,0 +1,34 @@
# encoding: UTF-8
# frozen_string_literal: true
module Matching
class OrderBookManager
attr :ask_orders, :bid_orders
def self.build_order(attrs)
attrs.symbolize_keys!
raise ArgumentError, "Missing ord_type: #{attrs.inspect}" unless attrs[:ord_type].present?
klass = ::Matching.const_get "#{attrs[:ord_type]}_order".camelize
klass.new attrs
end
def initialize(market, options={})
@market = market
@ask_orders = OrderBook.new(market, :ask, options)
@bid_orders = OrderBook.new(market, :bid, options)
end
def get_books(type)
case type
when :ask
[@ask_orders, @bid_orders]
when :bid
[@bid_orders, @ask_orders]
end
end
end
end

View File

@@ -0,0 +1,40 @@
# encoding: UTF-8
# frozen_string_literal: true
module Matching
class PriceLevel
attr :price, :orders
def initialize(price)
@price = price
@orders = []
end
def top
@orders.first
end
def empty?
@orders.empty?
end
def add(order)
unless find(order.id)
@orders << order
end
end
def total
@orders.map(&:volume).sum
end
def remove(order)
@orders.delete_if { |o| o.id == order.id }
end
def find(id)
@orders.find { |o| o.id == id }
end
end
end