Files
Dena/app/models/market.rb
2026-08-13 19:50:53 +03:30

236 lines
7.9 KiB
Ruby

# encoding: UTF-8
# frozen_string_literal: true
# People exchange commodities in markets. Each market focuses on certain
# commodity pair `{A, B}`. By convention, we call people exchange A for B
# *sellers* who submit *ask* orders, and people exchange B for A *buyers*
# who submit *bid* orders.
#
# ID of market is always in the form "#{B}#{A}". For example, in 'btcusd'
# market, the commodity pair is `{btc, usd}`. Sellers sell out _btc_ for
# _usd_, buyers buy in _btc_ with _usd_. _btc_ is the `base_unit`, while
# _usd_ is the `quote_unit`.
#
# Given market BTCUSD.
# Ask/Base currency/unit = BTC.
# Bid/Quote currency/unit = USD.
class Market < ApplicationRecord
# == Constants ============================================================
# Since we use decimal with 16 digits fractional part for storing numbers in DB
# sum of multipliers fractional parts must not be greater then 16.
# In the worst situation we have 3 multipliers (price * amount * fee).
# For fee we define static precision - 6. See TradingFee::FEE_PRECISION.
# So 10 left for amount and price precision.
DB_DECIMAL_PRECISION = 16
FUNDS_PRECISION = 10
TOP_POSITION = 1
AVERAGE_NUMBER = 5
STATES = %w[enabled disabled hidden locked sale presale].freeze
# enabled - user can view and trade.
# disabled - none can trade, user can't view.
# hidden - user can't view but can trade.
# locked - user can view but can't trade.
# sale - user can't view but can trade with market orders.
# presale - user can't view and trade. Admin can trade.
# == Attributes ===========================================================
attr_readonly :base_unit, :quote_unit
# base_currency & quote_currency is preferred names instead of legacy
# base_unit & quote_unit.
# For avoiding DB migration and config we use alias as temporary solution.
alias_attribute :base_currency, :base_unit
alias_attribute :quote_currency, :quote_unit
# == Extensions ===========================================================
serialize :data, JSON unless ::Rails.configuration.database_support_json
include Helpers::ReorderPosition
# == Relationships ========================================================
has_one :base, class_name: 'Currency', foreign_key: :id, primary_key: :base_unit
has_one :quote, class_name: 'Currency', foreign_key: :id, primary_key: :quote_unit
belongs_to :engine, required: true
has_many :trading_fees, dependent: :delete_all
# == Validations ==========================================================
validate do
if quote_currency == base_currency
errors.add(:quote_currency, 'duplicates base currency')
end
end
validate on: :create do
if ENV['MAX_MARKETS'].present? && Market.count >= ENV['MAX_MARKETS'].to_i
errors.add(:max, 'Market limit has been reached')
end
if Market.where(base_currency: quote_currency, quote_currency: base_currency).present? ||
Market.where(base_currency: base_currency, quote_currency: quote_currency).present?
errors.add(:base, "#{base_currency.upcase}, #{quote_currency.upcase} market already exists")
end
end
validates :id, uniqueness: { case_sensitive: false }, presence: true
validates :base_currency, :quote_currency, presence: true
validates :min_price, :max_price, precision: { less_than_or_eq_to: ->(m) { m.price_precision } }
validates :min_amount, precision: { less_than_or_eq_to: ->(m) { m.amount_precision } }
validates :position,
presence: true,
numericality: { greater_than_or_equal_to: TOP_POSITION, only_integer: true }
validates :amount_precision,
:price_precision,
numericality: { greater_than_or_equal_to: 0, only_integer: true }
validates :price_precision,
numericality: {
less_than_or_equal_to: ->(_m) { FUNDS_PRECISION }
}
validates :amount_precision,
numericality: {
less_than_or_equal_to: ->(m) { FUNDS_PRECISION - m.price_precision }
}
validates :base_currency, :quote_currency, inclusion: { in: -> (_) { Currency.codes } }
validates :min_price,
presence: true,
numericality: { greater_than_or_equal_to: ->(market) { market.min_price_by_precision } }
validates :max_price,
numericality: { allow_blank: true, greater_than_or_equal_to: ->(market) { market.min_price }},
if: ->(market) { !market.max_price.zero? }
validates :min_amount,
presence: true,
numericality: { greater_than_or_equal_to: ->(market) { market.min_amount_by_precision } }
validates :state, inclusion: { in: STATES }
# == Scopes ===============================================================
scope :ordered, -> { order(position: :asc) }
scope :active, -> { where(state: %i[enabled hidden]) }
scope :enabled, -> { where(state: :enabled) }
# == Callbacks ============================================================
after_initialize :initialize_defaults, if: :new_record?
before_validation(on: :create) { self.id = "#{base_currency}#{quote_currency}" }
before_validation(on: :create) { self.position = Market.count + 1 unless position.present? }
after_commit { AMQP::Queue.enqueue(:matching, action: 'new', market: id) }
after_commit :wipe_cache
after_create { insert_position(self) }
before_update { update_position(self) if position_changed? }
# == Instance Methods =====================================================
def price_now
last_trades = Trade.public_from_influx(self.id, AVERAGE_NUMBER)
prices = last_trades.inject(0) { |sum,x| sum + x[:price] }
prices / AVERAGE_NUMBER
end
def initialize_defaults
self.data = {} if data.blank?
end
def wipe_cache
Rails.cache.delete_matched("markets*")
end
def name
"#{base_currency}/#{quote_currency}".upcase
end
def underscore_name
"#{base_currency.upcase}_#{quote_currency.upcase}"
end
alias to_s name
def round_amount(d)
d.round(amount_precision, BigDecimal::ROUND_DOWN)
end
def round_price(d)
d.round(price_precision, BigDecimal::ROUND_DOWN)
end
def unit_info
{ name: name, base_unit: base_currency, quote_unit: quote_currency }
end
# min_amount_by_precision - is the smallest positive number which could be
# rounded to value greater then 0 with precision defined by
# Market #amount_precision. So min_amount_by_precision is the smallest amount
# of order/trade for current market.
# E.g.
# market.amount_precision => 4
# min_amount_by_precision => 0.0001
#
# market.amount_precision => 2
# min_amount_by_precision => 0.01
#
def min_amount_by_precision
0.1.to_d**amount_precision
end
# See #min_amount_by_precision.
def min_price_by_precision
0.1.to_d**price_precision
end
def engine_name=(engine_name)
self.engine = Engine.find_by(name: engine_name)
end
end
# == Schema Information
# Schema version: 20200909083000
#
# Table name: markets
#
# id :string(20) not null, primary key
# base_unit :string(10) not null
# quote_unit :string(10) not null
# engine_id :bigint not null
# amount_precision :integer default(4), not null
# price_precision :integer default(4), not null
# min_price :decimal(32, 16) default(0.0), not null
# max_price :decimal(32, 16) default(0.0), not null
# min_amount :decimal(32, 16) default(0.0), not null
# position :integer not null
# data :json
# state :string(32) default("enabled"), not null
# created_at :datetime not null
# updated_at :datetime not null
#
# Indexes
#
# index_markets_on_base_unit (base_unit)
# index_markets_on_base_unit_and_quote_unit (base_unit,quote_unit) UNIQUE
# index_markets_on_engine_id (engine_id)
# index_markets_on_position (position)
# index_markets_on_quote_unit (quote_unit)
#