From 509b00bb149b33d23a223334e61c19cd18039a8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20K=C3=A4chele?= Date: Wed, 8 Nov 2023 20:33:49 +0100 Subject: [PATCH] Move order book to Rex::Book namespace --- lib/rex.rb | 10 ++-- lib/rex/book/limit.rb | 81 +++++++++++++++++++++++++ lib/rex/book/matcher.rb | 41 +++++++++++++ lib/rex/book/order.rb | 33 ++++++++++ lib/rex/book/order_book.rb | 96 ++++++++++++++++++++++++++++++ lib/rex/book/trade.rb | 21 +++++++ lib/rex/limit.rb | 79 ------------------------ lib/rex/matcher.rb | 39 ------------ lib/rex/order.rb | 31 ---------- lib/rex/order_book.rb | 94 ----------------------------- lib/rex/trade.rb | 19 ------ spec/{ => book}/limit_spec.rb | 2 +- spec/{ => book}/matcher_spec.rb | 6 +- spec/{ => book}/order_book_spec.rb | 6 +- spec/{ => book}/order_spec.rb | 2 +- spec/factories/limits.rb | 2 +- spec/factories/orders.rb | 2 +- 17 files changed, 287 insertions(+), 277 deletions(-) create mode 100644 lib/rex/book/limit.rb create mode 100644 lib/rex/book/matcher.rb create mode 100644 lib/rex/book/order.rb create mode 100644 lib/rex/book/order_book.rb create mode 100644 lib/rex/book/trade.rb delete mode 100644 lib/rex/limit.rb delete mode 100644 lib/rex/matcher.rb delete mode 100644 lib/rex/order.rb delete mode 100644 lib/rex/order_book.rb delete mode 100644 lib/rex/trade.rb rename spec/{ => book}/limit_spec.rb (99%) rename spec/{ => book}/matcher_spec.rb (95%) rename spec/{ => book}/order_book_spec.rb (97%) rename spec/{ => book}/order_spec.rb (93%) diff --git a/lib/rex.rb b/lib/rex.rb index 3460e1c..2c84c02 100644 --- a/lib/rex.rb +++ b/lib/rex.rb @@ -1,11 +1,11 @@ # frozen_string_literal: true require_relative "rex/version" -require_relative "rex/limit" -require_relative "rex/order" -require_relative "rex/trade" -require_relative "rex/order_book" -require_relative "rex/matcher" +require_relative "rex/book/limit" +require_relative "rex/book/order" +require_relative "rex/book/trade" +require_relative "rex/book/order_book" +require_relative "rex/book/matcher" module Rex class Error < StandardError; end diff --git a/lib/rex/book/limit.rb b/lib/rex/book/limit.rb new file mode 100644 index 0000000..ad4d048 --- /dev/null +++ b/lib/rex/book/limit.rb @@ -0,0 +1,81 @@ +module Rex + module Book + class Limit + attr_reader( + :first_order, + :last_order, + :price + ) + def initialize(price) + @price = price + @first_order = nil + @last_order = nil + @order_count = 0 + end + + def peek_first_order + @first_order + end + + def count + @order_count + end + + def pop_first_order + return nil if empty? + + order = @first_order + @first_order = @first_order.next_order + @first_order&.previous_order = nil + + if @first_order.nil? + @last_order = nil + end + + order.next_order = nil + order.previous_order = nil + + @order_count -= 1 + order + end + + def add_order(order) + if empty? + @first_order = order + else + @last_order.next_order = order + order.previous_order = @last_order + end + @last_order = order + @order_count += 1 + end + + # Assumption when calling: the order is part of the limit + def remove_order(order) + if @first_order == order + @first_order = order.next_order + end + + if @last_order == order + @last_order = order.previous_order + end + + previous_order = order.previous_order + next_order = order.next_order + + previous_order&.next_order = next_order + next_order&.previous_order = previous_order + + order.previous_order = nil + order.next_order = nil + @order_count -= 1 + + order + end + + def empty? + @order_count == 0 + end + end + end +end diff --git a/lib/rex/book/matcher.rb b/lib/rex/book/matcher.rb new file mode 100644 index 0000000..bbd9c07 --- /dev/null +++ b/lib/rex/book/matcher.rb @@ -0,0 +1,41 @@ +module Rex + module Book + class Matcher + def match(order_book) + trades = [] + highest_buy_order = order_book.highest_buy_order + lowest_sell_order = order_book.lowest_sell_order + return trades if highest_buy_order.nil? || lowest_sell_order.nil? + + while highest_buy_order.price >= lowest_sell_order.price + max_quantity = min(highest_buy_order.remaining_quantity, lowest_sell_order.remaining_quantity) + trade = Trade.new( + id: order_book.next_trade_id, + buy_order: highest_buy_order, + sell_order: lowest_sell_order, + quantity: max_quantity, + price: lowest_sell_order.price + ) + + order_book.process_trade(trade) + trades.push(trade) + + # Go for the next run + highest_buy_order = order_book.highest_buy_order + lowest_sell_order = order_book.lowest_sell_order + + return trades if highest_buy_order.nil? || lowest_sell_order.nil? + end + + trades + end + + private + + def min(a, b) + return a if a < b + b + end + end + end +end diff --git a/lib/rex/book/order.rb b/lib/rex/book/order.rb new file mode 100644 index 0000000..bc6d274 --- /dev/null +++ b/lib/rex/book/order.rb @@ -0,0 +1,33 @@ +module Rex + module Book + class Order + attr_accessor( + :id, + :is_buy, + :price, + :user_id, + :quantity, + :previous_order, + :next_order, + :remaining_quantity + ) + + def initialize(attrs = {}) + @id = attrs[:id] + + @user_id = attrs[:user_id] + @price = attrs[:price] + @is_buy = attrs[:is_buy] + @quantity = attrs[:quantity] + @remaining_quantity = attrs[:quantity] + + @next_order = nil + @previous_order = nil + end + + def filled? + remaining_quantity == 0 + end + end + end +end diff --git a/lib/rex/book/order_book.rb b/lib/rex/book/order_book.rb new file mode 100644 index 0000000..6760c01 --- /dev/null +++ b/lib/rex/book/order_book.rb @@ -0,0 +1,96 @@ +require "rbtree" + +module Rex + module Book + class OrderBook + def initialize(matcher: Matcher.new) + @matcher = matcher + @sell_side = RBTree.new + @buy_side = RBTree.new + @order_ids = {} # order_id => order + @current_trade_id = 0 + @current_order_id = 0 + end + + def add_order(order) + side = side_for_order(order) + order.id = next_order_id + + side[order.price] ||= Limit.new(order.price) + side[order.price].add_order(order) + + order_ids[order.id] = order + end + + def add_and_match_order(order) + add_order(order) + @matcher.match(self) + end + + def remove_order(order_id) + order = order_ids[order_id] + return nil if order.nil? + + side = side_for_order(order) + limit = side[order.price] + limit.remove_order(order) + + if limit.empty? + side.delete(limit.price) + end + + order_ids.delete(order.id) + end + + alias_method :cancel_order, :remove_order + + def process_trade(trade) + trade.buy_order.remaining_quantity -= trade.quantity + trade.sell_order.remaining_quantity -= trade.quantity + + remove_order(trade.buy_order.id) if trade.buy_order.filled? + remove_order(trade.sell_order.id) if trade.sell_order.filled? + end + + def highest_buy_order + buy_side.last&.[](1)&.peek_first_order + end + + def lowest_sell_order + sell_side.first&.[](1)&.peek_first_order + end + + def best_buy_price + buy_side.last&.[](0) + end + + def best_sell_price + sell_side.first&.[](0) + end + + def next_trade_id + @current_trade_id += 1 + end + + private + + attr_reader( + :order_ids, + :buy_side, + :sell_side + ) + + def next_order_id + @current_order_id += 1 + end + + def side_for_order(order) + if order.is_buy + buy_side + else + sell_side + end + end + end + end +end diff --git a/lib/rex/book/trade.rb b/lib/rex/book/trade.rb new file mode 100644 index 0000000..a83fe89 --- /dev/null +++ b/lib/rex/book/trade.rb @@ -0,0 +1,21 @@ +module Rex + module Book + class Trade + attr_accessor( + :id, + :buy_order, + :sell_order, + :quantity, + :price + ) + + def initialize(attributes = {}) + @id = attributes[:id] + @buy_order = attributes[:buy_order] + @sell_order = attributes[:sell_order] + @quantity = attributes[:quantity] + @price = attributes[:price] + end + end + end +end diff --git a/lib/rex/limit.rb b/lib/rex/limit.rb deleted file mode 100644 index e34bbd1..0000000 --- a/lib/rex/limit.rb +++ /dev/null @@ -1,79 +0,0 @@ -module Rex - class Limit - attr_reader( - :first_order, - :last_order, - :price - ) - def initialize(price) - @price = price - @first_order = nil - @last_order = nil - @order_count = 0 - end - - def peek_first_order - @first_order - end - - def count - @order_count - end - - def pop_first_order - return nil if empty? - - order = @first_order - @first_order = @first_order.next_order - @first_order&.previous_order = nil - - if @first_order.nil? - @last_order = nil - end - - order.next_order = nil - order.previous_order = nil - - @order_count -= 1 - order - end - - def add_order(order) - if empty? - @first_order = order - else - @last_order.next_order = order - order.previous_order = @last_order - end - @last_order = order - @order_count += 1 - end - - # Assumption when calling: the order is part of the limit - def remove_order(order) - if @first_order == order - @first_order = order.next_order - end - - if @last_order == order - @last_order = order.previous_order - end - - previous_order = order.previous_order - next_order = order.next_order - - previous_order&.next_order = next_order - next_order&.previous_order = previous_order - - order.previous_order = nil - order.next_order = nil - @order_count -= 1 - - order - end - - def empty? - @order_count == 0 - end - end -end diff --git a/lib/rex/matcher.rb b/lib/rex/matcher.rb deleted file mode 100644 index 5c91e7f..0000000 --- a/lib/rex/matcher.rb +++ /dev/null @@ -1,39 +0,0 @@ -module Rex - class Matcher - def match(order_book) - trades = [] - highest_buy_order = order_book.highest_buy_order - lowest_sell_order = order_book.lowest_sell_order - return trades if highest_buy_order.nil? || lowest_sell_order.nil? - - while highest_buy_order.price >= lowest_sell_order.price - max_quantity = min(highest_buy_order.remaining_quantity, lowest_sell_order.remaining_quantity) - trade = Trade.new( - id: order_book.next_trade_id, - buy_order: highest_buy_order, - sell_order: lowest_sell_order, - quantity: max_quantity, - price: lowest_sell_order.price - ) - - order_book.process_trade(trade) - trades.push(trade) - - # Go for the next run - highest_buy_order = order_book.highest_buy_order - lowest_sell_order = order_book.lowest_sell_order - - return trades if highest_buy_order.nil? || lowest_sell_order.nil? - end - - trades - end - - private - - def min(a, b) - return a if a < b - b - end - end -end diff --git a/lib/rex/order.rb b/lib/rex/order.rb deleted file mode 100644 index e791302..0000000 --- a/lib/rex/order.rb +++ /dev/null @@ -1,31 +0,0 @@ -module Rex - class Order - attr_accessor( - :id, - :is_buy, - :price, - :user_id, - :quantity, - :previous_order, - :next_order, - :remaining_quantity - ) - - def initialize(attrs = {}) - @id = attrs[:id] - - @user_id = attrs[:user_id] - @price = attrs[:price] - @is_buy = attrs[:is_buy] - @quantity = attrs[:quantity] - @remaining_quantity = attrs[:quantity] - - @next_order = nil - @previous_order = nil - end - - def filled? - remaining_quantity == 0 - end - end -end diff --git a/lib/rex/order_book.rb b/lib/rex/order_book.rb deleted file mode 100644 index e009ca6..0000000 --- a/lib/rex/order_book.rb +++ /dev/null @@ -1,94 +0,0 @@ -require "rbtree" - -module Rex - class OrderBook - def initialize(matcher: Matcher.new) - @matcher = matcher - @sell_side = RBTree.new - @buy_side = RBTree.new - @order_ids = {} # order_id => order - @current_trade_id = 0 - @current_order_id = 0 - end - - def add_order(order) - side = side_for_order(order) - order.id = next_order_id - - side[order.price] ||= Limit.new(order.price) - side[order.price].add_order(order) - - order_ids[order.id] = order - end - - def add_and_match_order(order) - add_order(order) - @matcher.match(self) - end - - def remove_order(order_id) - order = order_ids[order_id] - return nil if order.nil? - - side = side_for_order(order) - limit = side[order.price] - limit.remove_order(order) - - if limit.empty? - side.delete(limit.price) - end - - order_ids.delete(order.id) - end - - alias_method :cancel_order, :remove_order - - def process_trade(trade) - trade.buy_order.remaining_quantity -= trade.quantity - trade.sell_order.remaining_quantity -= trade.quantity - - remove_order(trade.buy_order.id) if trade.buy_order.filled? - remove_order(trade.sell_order.id) if trade.sell_order.filled? - end - - def highest_buy_order - buy_side.last&.[](1)&.peek_first_order - end - - def lowest_sell_order - sell_side.first&.[](1)&.peek_first_order - end - - def best_buy_price - buy_side.last&.[](0) - end - - def best_sell_price - sell_side.first&.[](0) - end - - def next_trade_id - @current_trade_id += 1 - end - - private - - attr_reader( - :order_ids, - :buy_side, - :sell_side - ) - - def next_order_id - @current_order_id += 1 - end - - def side_for_order(order) - if order.is_buy - buy_side - else - sell_side - end - end - end -end diff --git a/lib/rex/trade.rb b/lib/rex/trade.rb deleted file mode 100644 index f552175..0000000 --- a/lib/rex/trade.rb +++ /dev/null @@ -1,19 +0,0 @@ -module Rex - class Trade - attr_accessor( - :id, - :buy_order, - :sell_order, - :quantity, - :price - ) - - def initialize(attributes = {}) - @id = attributes[:id] - @buy_order = attributes[:buy_order] - @sell_order = attributes[:sell_order] - @quantity = attributes[:quantity] - @price = attributes[:price] - end - end -end diff --git a/spec/limit_spec.rb b/spec/book/limit_spec.rb similarity index 99% rename from spec/limit_spec.rb rename to spec/book/limit_spec.rb index b96aadc..7cd9914 100644 --- a/spec/limit_spec.rb +++ b/spec/book/limit_spec.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -RSpec.describe Rex::Limit do +RSpec.describe Rex::Book::Limit do let(:instance) { described_class.new(100) } let(:order_a) { build(:order) } let(:order_b) { build(:order) } diff --git a/spec/matcher_spec.rb b/spec/book/matcher_spec.rb similarity index 95% rename from spec/matcher_spec.rb rename to spec/book/matcher_spec.rb index 9b8cd11..ade6797 100644 --- a/spec/matcher_spec.rb +++ b/spec/book/matcher_spec.rb @@ -1,10 +1,10 @@ # frozen_string_literal: true -RSpec.describe Rex::Matcher do +RSpec.describe Rex::Book::Matcher do let(:instance) { described_class.new } describe "#match" do - let(:order_book) { Rex::OrderBook.new } + let(:order_book) { Rex::Book::OrderBook.new } let(:buy_order) { build(:order, price: 100, is_buy: true, quantity: 100, remaining_quantity: 100) } let(:cheaper_sell_order) { build(:order, price: 99, is_buy: false, quantity: 50, remaining_quantity: 50) } let(:pricier_sell_order) { build(:order, price: 100, is_buy: false, quantity: 70, remaining_quantity: 70) } @@ -45,7 +45,7 @@ RSpec.describe Rex::Matcher do context "when order book is empty" do it "returns an empty list" do - expect(instance.match(Rex::OrderBook.new)).to eq([]) + expect(instance.match(Rex::Book::OrderBook.new)).to eq([]) end end diff --git a/spec/order_book_spec.rb b/spec/book/order_book_spec.rb similarity index 97% rename from spec/order_book_spec.rb rename to spec/book/order_book_spec.rb index 36e976b..b008bd7 100644 --- a/spec/order_book_spec.rb +++ b/spec/book/order_book_spec.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -RSpec.describe Rex::OrderBook do +RSpec.describe Rex::Book::OrderBook do let(:instance) { described_class.new } describe "#add_order" do @@ -57,7 +57,7 @@ RSpec.describe Rex::OrderBook do describe "add_and_match_order" do context "when matcher is given" do - let(:matcher) { instance_double(Rex::Matcher) } + let(:matcher) { instance_double(Rex::Book::Matcher) } let(:instance) { described_class.new(matcher: matcher) } let(:order) { build(:order, is_buy: true, price: 100) } @@ -93,7 +93,7 @@ RSpec.describe Rex::OrderBook do end end - describe "#lowest_sell_Order" do + describe "#lowest_sell_order" do context "when there is nothing in the book" do it "returns nil " do expect(instance.highest_buy_order).to eq(nil) diff --git a/spec/order_spec.rb b/spec/book/order_spec.rb similarity index 93% rename from spec/order_spec.rb rename to spec/book/order_spec.rb index c5c2277..0117acd 100644 --- a/spec/order_spec.rb +++ b/spec/book/order_spec.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -RSpec.describe Rex::Order do +RSpec.describe Rex::Book::Order do describe "#filled?" do let(:order) do instance = build(:order) diff --git a/spec/factories/limits.rb b/spec/factories/limits.rb index 75d8c73..45af17a 100644 --- a/spec/factories/limits.rb +++ b/spec/factories/limits.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true FactoryBot.define do - factory :limit, class: Rex::Limit do + factory :limit, class: Rex::Book::Limit do transient do price { 100 } end diff --git a/spec/factories/orders.rb b/spec/factories/orders.rb index 3f38a46..37fe7da 100644 --- a/spec/factories/orders.rb +++ b/spec/factories/orders.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true FactoryBot.define do - factory :order, class: Rex::Order do + factory :order, class: Rex::Book::Order do sequence(:id) { |i| i } sequence(:user_id) { |i| i } sequence(:is_buy) { |i| (i % 2).zero? }