Move order book to Rex::Book namespace

This commit is contained in:
Tim Kächele 2023-11-08 20:33:49 +01:00
parent 0efb6b0e88
commit 509b00bb14
17 changed files with 287 additions and 277 deletions

View File

@ -1,11 +1,11 @@
# frozen_string_literal: true # frozen_string_literal: true
require_relative "rex/version" require_relative "rex/version"
require_relative "rex/limit" require_relative "rex/book/limit"
require_relative "rex/order" require_relative "rex/book/order"
require_relative "rex/trade" require_relative "rex/book/trade"
require_relative "rex/order_book" require_relative "rex/book/order_book"
require_relative "rex/matcher" require_relative "rex/book/matcher"
module Rex module Rex
class Error < StandardError; end class Error < StandardError; end

81
lib/rex/book/limit.rb Normal file
View File

@ -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

41
lib/rex/book/matcher.rb Normal file
View File

@ -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

33
lib/rex/book/order.rb Normal file
View File

@ -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

View File

@ -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

21
lib/rex/book/trade.rb Normal file
View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -1,6 +1,6 @@
# frozen_string_literal: true # frozen_string_literal: true
RSpec.describe Rex::Limit do RSpec.describe Rex::Book::Limit do
let(:instance) { described_class.new(100) } let(:instance) { described_class.new(100) }
let(:order_a) { build(:order) } let(:order_a) { build(:order) }
let(:order_b) { build(:order) } let(:order_b) { build(:order) }

View File

@ -1,10 +1,10 @@
# frozen_string_literal: true # frozen_string_literal: true
RSpec.describe Rex::Matcher do RSpec.describe Rex::Book::Matcher do
let(:instance) { described_class.new } let(:instance) { described_class.new }
describe "#match" do 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(: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(: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) } 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 context "when order book is empty" do
it "returns an empty list" 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
end end

View File

@ -1,6 +1,6 @@
# frozen_string_literal: true # frozen_string_literal: true
RSpec.describe Rex::OrderBook do RSpec.describe Rex::Book::OrderBook do
let(:instance) { described_class.new } let(:instance) { described_class.new }
describe "#add_order" do describe "#add_order" do
@ -57,7 +57,7 @@ RSpec.describe Rex::OrderBook do
describe "add_and_match_order" do describe "add_and_match_order" do
context "when matcher is given" 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(:instance) { described_class.new(matcher: matcher) }
let(:order) { build(:order, is_buy: true, price: 100) } let(:order) { build(:order, is_buy: true, price: 100) }
@ -93,7 +93,7 @@ RSpec.describe Rex::OrderBook do
end end
end end
describe "#lowest_sell_Order" do describe "#lowest_sell_order" do
context "when there is nothing in the book" do context "when there is nothing in the book" do
it "returns nil " do it "returns nil " do
expect(instance.highest_buy_order).to eq(nil) expect(instance.highest_buy_order).to eq(nil)

View File

@ -1,6 +1,6 @@
# frozen_string_literal: true # frozen_string_literal: true
RSpec.describe Rex::Order do RSpec.describe Rex::Book::Order do
describe "#filled?" do describe "#filled?" do
let(:order) do let(:order) do
instance = build(:order) instance = build(:order)

View File

@ -1,7 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
FactoryBot.define do FactoryBot.define do
factory :limit, class: Rex::Limit do factory :limit, class: Rex::Book::Limit do
transient do transient do
price { 100 } price { 100 }
end end

View File

@ -1,7 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
FactoryBot.define do FactoryBot.define do
factory :order, class: Rex::Order do factory :order, class: Rex::Book::Order do
sequence(:id) { |i| i } sequence(:id) { |i| i }
sequence(:user_id) { |i| i } sequence(:user_id) { |i| i }
sequence(:is_buy) { |i| (i % 2).zero? } sequence(:is_buy) { |i| (i % 2).zero? }