Initial commit
This commit is contained in:
415
app/models/order.rb
Normal file
415
app/models/order.rb
Normal file
@@ -0,0 +1,415 @@
|
||||
# encoding: UTF-8
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'csv'
|
||||
|
||||
class Order < ApplicationRecord
|
||||
|
||||
belongs_to :market, required: true
|
||||
belongs_to :member, required: true
|
||||
attribute :uuid, :uuid if Rails.configuration.database_adapter.downcase != 'PostgreSQL'.downcase
|
||||
|
||||
# Error is raised in case market doesn't have enough volume to fulfill the Order.
|
||||
InsufficientMarketLiquidity = Class.new(StandardError)
|
||||
|
||||
extend Enumerize
|
||||
STATES = { pending: 0, wait: 100, done: 200, cancel: -100, reject: -200 }.freeze
|
||||
enumerize :state, in: STATES, scope: true
|
||||
|
||||
TYPES = %w[market limit].freeze
|
||||
|
||||
THIRD_PARTY_ORDER_ACTION_TYPE = {
|
||||
'submit_single' => 0,
|
||||
'cancel_single' => 3,
|
||||
'cancel_bulk' => 4
|
||||
}.freeze
|
||||
|
||||
belongs_to :ask_currency, class_name: 'Currency', foreign_key: :ask
|
||||
belongs_to :bid_currency, class_name: 'Currency', foreign_key: :bid
|
||||
after_commit :trigger_event
|
||||
|
||||
validates :ord_type, :volume, :origin_volume, :locked, :origin_locked, presence: true
|
||||
validates :price, numericality: { greater_than: 0 }, if: ->(order) { order.ord_type == 'limit' }
|
||||
|
||||
validates :origin_volume,
|
||||
numericality: { greater_than: 0, greater_than_or_equal_to: ->(order){ order.market.min_amount } },
|
||||
on: :create
|
||||
|
||||
validates :origin_volume, precision: { less_than_or_eq_to: ->(o) { o.market.amount_precision } },
|
||||
if: ->(o) { o.origin_volume.present? }, on: :create
|
||||
|
||||
validate :market_order_validations, if: ->(order) { order.ord_type == 'market' }
|
||||
|
||||
validates :price, presence: true, if: :is_limit_order?
|
||||
|
||||
validates :price, precision: { less_than_or_eq_to: ->(o) { o.market.price_precision } },
|
||||
if: ->(o) { o.price.present? }, on: :create
|
||||
|
||||
validates :price,
|
||||
numericality: { less_than_or_equal_to: ->(order){ order.market.max_price }},
|
||||
if: ->(order) { order.is_limit_order? && order.market.max_price.nonzero? },
|
||||
on: :create
|
||||
|
||||
validates :price,
|
||||
numericality: { greater_than_or_equal_to: ->(order){ order.market.min_price }},
|
||||
if: :is_limit_order?, on: :create
|
||||
|
||||
attr_readonly :member_id,
|
||||
:bid,
|
||||
:ask,
|
||||
:market_id,
|
||||
:ord_type,
|
||||
:origin_volume,
|
||||
:origin_locked,
|
||||
:created_at
|
||||
|
||||
PENDING = 'pending'
|
||||
WAIT = 'wait'
|
||||
DONE = 'done'
|
||||
CANCEL = 'cancel'
|
||||
REJECT = 'reject'
|
||||
|
||||
scope :done, -> { with_state(:done) }
|
||||
scope :active, -> { with_state(:wait) }
|
||||
scope :with_market, ->(market) { where(market_id: market) }
|
||||
|
||||
# Custom ransackers.
|
||||
|
||||
ransacker :state, formatter: proc { |v| STATES[v.to_sym] } do |parent|
|
||||
parent.table[:state]
|
||||
end
|
||||
|
||||
# Single Order can produce multiple Trades with different fee types (maker and taker).
|
||||
# Since we can't predict fee types on order creation step and
|
||||
# Market fees configuration can change we need to store fees on Order creation.
|
||||
after_validation(on: :create, if: ->(o) { o.errors.blank? }) do
|
||||
member.group_update(self)
|
||||
trading_fee = TradingFee.for(group: member.group, market_id: market_id)
|
||||
self.maker_fee = trading_fee.maker
|
||||
self.taker_fee = trading_fee.taker
|
||||
end
|
||||
|
||||
before_create do
|
||||
self.uuid = UUID.generate if uuid.blank?
|
||||
end
|
||||
|
||||
after_commit on: :create do
|
||||
next unless ord_type == 'limit'
|
||||
EventAPI.notify ['market', market_id, 'order_created'].join('.'), \
|
||||
Serializers::EventAPI::OrderCreated.call(self)
|
||||
end
|
||||
|
||||
after_commit on: :update do
|
||||
next unless ord_type == 'limit'
|
||||
|
||||
event = case state
|
||||
when 'cancel' then 'order_canceled'
|
||||
when 'done' then 'order_completed'
|
||||
else 'order_updated'
|
||||
end
|
||||
|
||||
Serializers::EventAPI.const_get(event.camelize).call(self).tap do |payload|
|
||||
EventAPI.notify ['market', market_id, event].join('.'), payload
|
||||
end
|
||||
end
|
||||
|
||||
class << self
|
||||
def submit(id)
|
||||
ActiveRecord::Base.transaction do
|
||||
order = lock.find_by_id!(id)
|
||||
return unless order.state == ::Order::PENDING
|
||||
|
||||
order.hold_account!.lock_funds!(order.locked)
|
||||
order.record_submit_operations!
|
||||
order.update!(state: ::Order::WAIT)
|
||||
|
||||
AMQP::Queue.enqueue(:matching, action: 'submit', order: order.to_matching_attributes)
|
||||
end
|
||||
rescue => e
|
||||
order = find_by_id!(id)
|
||||
order.update!(state: ::Order::REJECT) if order
|
||||
|
||||
raise e
|
||||
end
|
||||
|
||||
def cancel(id)
|
||||
order = lock.find_by_id!(id)
|
||||
market_engine = order.market.engine
|
||||
return unless order.state == ::Order::WAIT
|
||||
|
||||
return order.trigger_third_party_cancellation unless market_engine.peatio_engine?
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
order.hold_account!.unlock_funds!(order.locked)
|
||||
order.record_cancel_operations!
|
||||
|
||||
order.update!(state: ::Order::CANCEL)
|
||||
end
|
||||
end
|
||||
|
||||
def trigger_bulk_cancel_third_party(engine_driver, filters = {})
|
||||
AMQP::Queue.publish(engine_driver,
|
||||
data: filters,
|
||||
type: THIRD_PARTY_ORDER_ACTION_TYPE['cancel_bulk'])
|
||||
end
|
||||
|
||||
def to_csv
|
||||
attributes = %w[id market_id ord_type side price volume origin_volume avg_price trades_count state created_at updated_at]
|
||||
|
||||
CSV.generate(headers: true) do |csv|
|
||||
csv << attributes
|
||||
|
||||
all.each do |order|
|
||||
data = attributes[0...-2].map { |attr| order.send(attr) }
|
||||
data += attributes[-2..-1].map { |attr| order.send(attr).iso8601 }
|
||||
csv << data
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def submit_order
|
||||
return unless new_record?
|
||||
|
||||
self.locked = self.origin_locked = if ord_type == 'market' && side == 'buy'
|
||||
[compute_locked * OrderBid::LOCKING_BUFFER_FACTOR, member_balance].min
|
||||
else
|
||||
compute_locked
|
||||
end
|
||||
|
||||
raise ::Account::AccountError unless member_balance >= locked
|
||||
|
||||
return trigger_third_party_creation unless market.engine.peatio_engine?
|
||||
|
||||
save!
|
||||
AMQP::Queue.enqueue(:order_processor,
|
||||
{ action: 'submit', order: attributes },
|
||||
{ persistent: false })
|
||||
end
|
||||
|
||||
def trigger_third_party_creation
|
||||
return unless new_record?
|
||||
|
||||
self.uuid ||= UUID.generate
|
||||
self.created_at ||= Time.now
|
||||
|
||||
AMQP::Queue.publish(market.engine.driver, data: as_json_for_third_party, type: THIRD_PARTY_ORDER_ACTION_TYPE['submit_single'])
|
||||
end
|
||||
|
||||
def trigger_cancellation
|
||||
market.engine.peatio_engine? ? trigger_internal_cancellation : trigger_third_party_cancellation
|
||||
end
|
||||
|
||||
def trigger_internal_cancellation
|
||||
AMQP::Queue.enqueue(:matching, action: 'cancel', order: to_matching_attributes)
|
||||
end
|
||||
|
||||
def trigger_third_party_cancellation
|
||||
AMQP::Queue.publish(market.engine.driver,
|
||||
data: as_json_for_third_party,
|
||||
type: THIRD_PARTY_ORDER_ACTION_TYPE['cancel_single'])
|
||||
end
|
||||
|
||||
def trades
|
||||
Trade.where('maker_order_id = ? OR taker_order_id = ?', id, id)
|
||||
end
|
||||
|
||||
def funds_used
|
||||
origin_locked - locked
|
||||
end
|
||||
|
||||
def trigger_event
|
||||
# skip market type orders, they should not appear on trading-ui
|
||||
return unless ord_type == 'limit' || state == 'done'
|
||||
|
||||
::AMQP::Queue.enqueue_event('private', member&.uid, 'order', for_notify)
|
||||
end
|
||||
|
||||
def side
|
||||
self.class.name.underscore[-3, 3] == 'ask' ? 'sell' : 'buy'
|
||||
end
|
||||
|
||||
# @deprecated Please use {#side} instead
|
||||
def kind
|
||||
self.class.name.underscore[-3, 3]
|
||||
end
|
||||
|
||||
# @deprecated Please use {#created_at} instead
|
||||
def at
|
||||
created_at.to_i
|
||||
end
|
||||
|
||||
def for_notify
|
||||
{
|
||||
id: id,
|
||||
market: market_id,
|
||||
kind: kind,
|
||||
side: side,
|
||||
ord_type: ord_type,
|
||||
price: price&.to_s('F'),
|
||||
avg_price: avg_price&.to_s('F'),
|
||||
state: state,
|
||||
origin_volume: origin_volume.to_s('F'),
|
||||
remaining_volume: volume.to_s('F'),
|
||||
executed_volume: (origin_volume - volume).to_s('F'),
|
||||
at: at,
|
||||
created_at: created_at.to_i,
|
||||
updated_at: updated_at.to_i,
|
||||
trades_count: trades_count
|
||||
}
|
||||
end
|
||||
|
||||
def to_matching_attributes
|
||||
{ id: id,
|
||||
market: market_id,
|
||||
type: type[-3, 3].downcase.to_sym,
|
||||
ord_type: ord_type,
|
||||
volume: volume,
|
||||
price: price,
|
||||
locked: locked,
|
||||
timestamp: created_at.to_i }
|
||||
end
|
||||
|
||||
def as_json_for_events_processor
|
||||
{ id: id,
|
||||
member_id: member_id,
|
||||
member_uid: member.uid,
|
||||
ask: ask,
|
||||
bid: bid,
|
||||
type: type,
|
||||
ord_type: ord_type,
|
||||
price: price,
|
||||
volume: volume,
|
||||
origin_volume: origin_volume,
|
||||
market_id: market_id,
|
||||
maker_fee: maker_fee,
|
||||
taker_fee: taker_fee,
|
||||
locked: locked,
|
||||
state: read_attribute_before_type_cast(:state) }
|
||||
end
|
||||
|
||||
def as_json_for_third_party
|
||||
{
|
||||
uuid: uuid,
|
||||
market_id: market_id,
|
||||
member_uid: member.uid,
|
||||
origin_volume: origin_volume,
|
||||
volume: volume,
|
||||
price: price,
|
||||
side: type,
|
||||
type: ord_type,
|
||||
created_at: created_at.to_i
|
||||
}
|
||||
end
|
||||
|
||||
# @deprecated
|
||||
def round_amount_and_price
|
||||
self.price = market.round_price(price.to_d) if price
|
||||
|
||||
if volume
|
||||
self.volume = market.round_amount(volume.to_d)
|
||||
self.origin_volume = origin_volume.present? ? market.round_amount(origin_volume.to_d) : volume
|
||||
end
|
||||
end
|
||||
|
||||
def record_submit_operations!
|
||||
transaction do
|
||||
# Debit main fiat/crypto Liability account.
|
||||
# Credit locked fiat/crypto Liability account.
|
||||
Operations::Liability.transfer!(
|
||||
amount: locked,
|
||||
currency: currency,
|
||||
reference: self,
|
||||
from_kind: :main,
|
||||
to_kind: :locked,
|
||||
member_id: member_id
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def record_cancel_operations!
|
||||
transaction do
|
||||
# Debit locked fiat/crypto Liability account.
|
||||
# Credit main fiat/crypto Liability account.
|
||||
Operations::Liability.transfer!(
|
||||
amount: locked,
|
||||
currency: currency,
|
||||
reference: self,
|
||||
from_kind: :locked,
|
||||
to_kind: :main,
|
||||
member_id: member_id
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def is_limit_order?
|
||||
ord_type == 'limit'
|
||||
end
|
||||
|
||||
def member_balance
|
||||
member.get_account(currency).balance
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def market_order_validations
|
||||
errors.add(:price, 'must not be present') if price.present?
|
||||
end
|
||||
|
||||
FUSE = '0.9'.to_d
|
||||
def estimate_required_funds(price_levels)
|
||||
required_funds = Account::ZERO
|
||||
expected_volume = volume
|
||||
|
||||
until expected_volume.zero? || price_levels.empty?
|
||||
level_price, level_volume = price_levels.shift
|
||||
|
||||
v = [expected_volume, level_volume].min
|
||||
required_funds += yield level_price, v
|
||||
expected_volume -= v
|
||||
end
|
||||
|
||||
raise InsufficientMarketLiquidity if expected_volume.nonzero?
|
||||
|
||||
required_funds
|
||||
end
|
||||
end
|
||||
|
||||
# == Schema Information
|
||||
# Schema version: 20201125134745
|
||||
#
|
||||
# Table name: orders
|
||||
#
|
||||
# id :integer not null, primary key
|
||||
# uuid :binary(16) not null
|
||||
# remote_id :string(255)
|
||||
# bid :string(10) not null
|
||||
# ask :string(10) not null
|
||||
# market_id :string(20) not null
|
||||
# price :decimal(32, 16)
|
||||
# volume :decimal(32, 16) not null
|
||||
# origin_volume :decimal(32, 16) not null
|
||||
# maker_fee :decimal(17, 16) default(0.0), not null
|
||||
# taker_fee :decimal(17, 16) default(0.0), not null
|
||||
# state :integer not null
|
||||
# type :string(8) not null
|
||||
# member_id :integer not null
|
||||
# ord_type :string(30) not null
|
||||
# locked :decimal(32, 16) default(0.0), not null
|
||||
# origin_locked :decimal(32, 16) default(0.0), not null
|
||||
# funds_received :decimal(32, 16) default(0.0)
|
||||
# trades_count :integer default(0), not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_orders_on_member_id (member_id)
|
||||
# index_orders_on_state (state)
|
||||
# index_orders_on_type_and_market_id (type,market_id)
|
||||
# index_orders_on_type_and_member_id (type,member_id)
|
||||
# index_orders_on_type_and_state_and_market_id (type,state,market_id)
|
||||
# index_orders_on_type_and_state_and_member_id (type,state,member_id)
|
||||
# index_orders_on_updated_at (updated_at)
|
||||
# index_orders_on_uuid (uuid) UNIQUE
|
||||
#
|
||||
Reference in New Issue
Block a user