class Gekko::Book

An order book consisting of a bid side and an ask side

Attributes

asks[RW]
base_precision[RW]
bids[RW]
multiplier[RW]
pair[RW]
received[RW]
tape[RW]

Public Class Methods

from_hash(hsh) click to toggle source

Loads the book from a hash

@param hsh [Hash] A Book hash @return [Gekko::Book] The loaded book instance

# File lib/gekko/book.rb, line 293
def self.from_hash(hsh)
  h = symbolize_keys(hsh)

  book = Book.new(hsh[:pair], {
    bids: BookSide.from_hash(:bid, h[:bids]),
    asks: BookSide.from_hash(:ask, h[:asks])
  })

  [:bids, :asks].each { |s| book.send(s).each { |ord| book.received[ord.id.to_s] = ord } }
  book.tape = Tape.from_hash(h[:tape]) if h[:tape]

  book
end
new(pair, opts = {}) click to toggle source
# File lib/gekko/book.rb, line 19
def initialize(pair, opts = {})
  self.pair           = opts[:pair] || pair
  self.bids           = opts[:bids] || BookSide.new(:bid)
  self.asks           = opts[:asks] || BookSide.new(:ask)
  self.tape           = opts[:tape] || Tape.new({ logger: opts[:logger] })
  self.base_precision = opts[:base_precision] || 8
  self.multiplier     = BigDecimal(10 ** base_precision)
  self.received       = opts[:received] || {}

  @triggered_stops = []
end

Public Instance Methods

ask() click to toggle source

Returns the current best ask price or nil if there are currently no asks

# File lib/gekko/book.rb, line 222
def ask
  asks.top
end
bid() click to toggle source

Returns the current best bid price or nil if there are currently no bids

# File lib/gekko/book.rb, line 230
def bid
  bids.top
end
cancel(order_id) click to toggle source

Cancels an order given an ID

@param order_id [UUID] The ID of the order to cancel

# File lib/gekko/book.rb, line 189
def cancel(order_id)
  prev_bid = bid
  prev_ask = ask

  order = received[order_id.to_s]

  if order
    s = order.bid? ? bids : asks
    dels = s.delete(order) || s.stops.delete(order)
    dels && tape << order.message(:done, reason: :canceled)

    tick! if (prev_bid != bid) || (prev_ask != ask)
  end
end
execute_trade(maker, taker) click to toggle source

Executes a trade between two orders

@param maker [Gekko::LimitOrder] The order in the book providing liquidity @param taker [Gekko::Order] The order being executed

# File lib/gekko/book.rb, line 140
def execute_trade(maker, taker)
  trade_price     = maker.price
  max_quote_size  = nil

  # Rounding direction depends on the takers direction
  rounding = (taker.bid? ? :floor : :ceil)

  if taker.is_a?(MarketOrder)
    max_size_with_quote_margin = taker.remaining_quote_margin &&
      (taker.remaining_quote_margin * multiplier / trade_price).send(rounding)
  end

  base_size = [
    maker.remaining,
    taker.remaining,
    max_size_with_quote_margin
  ].compact.min

  if taker.is_a?(LimitOrder)
    quote_size = (base_size * trade_price) / multiplier

  elsif taker.is_a?(MarketOrder)
    if base_size == max_size_with_quote_margin
      taker.max_precision = true
    end

    quote_size = [(trade_price * base_size / multiplier).round, taker.remaining_quote_margin].compact.min
    taker.remaining_quote_margin -= quote_size if taker.quote_margin
  end

  tape << {
    type:       :execution,
    price:      trade_price,
    base_size:  base_size,
    quote_size: quote_size,
    maker_id:   maker.id.to_s,
    taker_id:   taker.id.to_s,
    tick:       taker.bid? ? :up : :down
  }

  taker.remaining  -= base_size if taker.remaining
  maker.remaining  -= base_size
end
receive_order(order, stop_order_execution = false) click to toggle source

Receives an order and executes it

@param order [Order] The order to execute @param execute_triggered_stops [Boolean] Whether to also execute the STOP orders triggered

by the current received order executions
# File lib/gekko/book.rb, line 38
def receive_order(order, stop_order_execution = false)
  raise "Order must be a Gekko::LimitOrder or a Gekko::MarketOrder."      unless [LimitOrder, MarketOrder].include?(order.class)
  raise "Can't receive a new STOP before a first trade has taken place."  if order.stop? && ticker[:last].nil?

  # We need to initialize the stop_price for trailing stops if necessary
  if order.stop? && !order.stop_price
    if order.stop_percent
      order.stop_price = ticker[:last] + (ticker[:last] * order.stop_percent / Gekko::Order::TRL_STOP_PCT_MULTIPLIER * (order.bid? ? 1 : -1)).round
    elsif order.stop_offset
      order.stop_price = ticker[:last] + order.stop_offset * (order.bid? ? 1 : -1)
    end
  end

  # The side of the received order
  current_side = (order.ask? ? asks : bids)

  # The side from which we'll pop orders
  opposite_side = order.bid? ? asks : bids

  if received.has_key?(order.id.to_s) && !stop_order_execution
    tape << order.message(:reject, reason: :duplicate_id)

  else
    self.received[order.id.to_s] = order

    if order.expired?
      tape << order.message(:reject, reason: :expired)

    elsif order.stop? && !order.should_trigger?(tape.last_trade_price)
      current_side.stops << order # Add the STOP to the list of currently active STOPs

    elsif order.post_only && order.crosses?(opposite_side.first)
      tape << order.message(:reject, reason: :would_execute)

    else
      old_ticker = ticker
      tape << order.message(:received)

      order_side    = order.bid? ? bids : asks
      next_match    = opposite_side.first
      prev_match_id = nil

      while !order.done? && order.crosses?(next_match)
        # If we match against the same order twice in a row, something went seriously
        # wrong, we'd rather noisily die at this point.
        raise 'Infinite matching loop detected !!' if (prev_match_id == next_match.id)
        prev_match_id = next_match.id

        if next_match.expired?
          tape << opposite_side.shift.message(:done, reason: :expired)
          next_match = opposite_side.first

        elsif order.uid == next_match.uid
          # Same user/account associated to order, we cancel the next match
          tape << opposite_side.shift.message(:done, reason: :canceled)
          next_match = opposite_side.first

        else
          execute_trade(next_match, order)

          if next_match.filled?
            tape << opposite_side.shift.message(:done, reason: :filled)
            next_match = opposite_side.first
          end
        end
      end

      if order.filled?
        tape << order.message(:done, reason: :filled)
      elsif order.fill_or_kill?
        tape << order.message(:done, reason: :killed)
      else
        order_side.insert_order(order)
        tape << order.message(:open)
      end

      current_side.stops.each do |stop|
        if stop.should_trigger?(tape.last_trade_price)
          @triggered_stops << current_side.stops.delete(stop)
        end
      end

      opposite_side.stops.each { |s| s.update_trailing_stop(tape.last_trade_price) }

      if !stop_order_execution
        # We only want to execute triggered stops at the top level as to correctly order them
        while t = @triggered_stops.shift
          receive_order(t, true)
        end

        tick! unless (ticker == old_ticker)
      end
    end
  end
end
remove_expired!() click to toggle source

Removes all expired orders from the book

# File lib/gekko/book.rb, line 207
def remove_expired!
  prev_bid = bid
  prev_ask = ask

  [bids, asks].each do |bs|
    bs.remove_expired! { |tape_msg| tape << tape_msg }
  end

  tick! if (prev_bid != bid) || (prev_ask != ask)
end
spread() click to toggle source

Returns the current spread if at least a bid and an ask are present, returns nil otherwise

# File lib/gekko/book.rb, line 238
def spread
  ask && bid && (ask - bid)
end
tick!() click to toggle source

Emits a ticker on the tape

# File lib/gekko/book.rb, line 245
def tick!
  tape << { type: :ticker }.merge(ticker)
end
ticker() click to toggle source

Returns the current ticker

@return [Hash] The current ticker

# File lib/gekko/book.rb, line 254
def ticker
  v24h = tape.volume_24h
  {
    last:       tape.last_trade_price,
    bid:        bid,
    ask:        ask,
    high_24h:   tape.high_24h,
    low_24h:    tape.low_24h,
    spread:     spread,
    volume_24h: v24h,

    # We'd like to return +nil+, not +false+ when we don't have any volume
    vwap_24h:   ((v24h > 0) && (tape.quote_volume_24h * multiplier / v24h).to_i) || nil
  }
end
to_hash() click to toggle source

Returns a Hash representation of this Book instance

@return [Hash] The serializable representation

# File lib/gekko/book.rb, line 275
def to_hash
  {
    time:             Time.now.to_f,
    bids:             bids.to_hash,
    asks:             asks.to_hash,
    pair:             pair,
    tape:             tape.to_hash,
    received:         received,
    base_precision:   base_precision
  }
end