Initial commit
This commit is contained in:
50
app/trading/matching/base_order.rb
Normal file
50
app/trading/matching/base_order.rb
Normal 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
|
||||
47
app/trading/matching/constants.rb
Normal file
47
app/trading/matching/constants.rb
Normal 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
|
||||
212
app/trading/matching/engine.rb
Normal file
212
app/trading/matching/engine.rb
Normal 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
|
||||
215
app/trading/matching/executor.rb
Normal file
215
app/trading/matching/executor.rb
Normal 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
|
||||
70
app/trading/matching/limit_order.rb
Normal file
70
app/trading/matching/limit_order.rb
Normal 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
|
||||
70
app/trading/matching/market_order.rb
Normal file
70
app/trading/matching/market_order.rb
Normal 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
|
||||
137
app/trading/matching/order_book.rb
Normal file
137
app/trading/matching/order_book.rb
Normal 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
|
||||
34
app/trading/matching/order_book_manager.rb
Normal file
34
app/trading/matching/order_book_manager.rb
Normal 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
|
||||
40
app/trading/matching/price_level.rb
Normal file
40
app/trading/matching/price_level.rb
Normal 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
|
||||
Reference in New Issue
Block a user