Initial commit

This commit is contained in:
Yaser
2026-08-13 19:50:53 +03:30
commit 38084458fe
879 changed files with 95198 additions and 0 deletions

View File

@@ -0,0 +1,761 @@
# encoding: UTF-8
# frozen_string_literal: true
describe Matching::Engine do
Order.define_method(:to_matching_mock) do
if ord_type.to_sym == :limit
Matching.mock_limit_order(to_matching_attributes)
else
Matching.mock_market_order(to_matching_attributes)
end
end
let(:market) { Market.find('btcusd') }
let(:price) { 10.to_d }
let(:volume) { 5.to_d }
let(:ask) { Matching.mock_limit_order(type: :ask, price: price, volume: volume) }
let(:bid) { Matching.mock_limit_order(type: :bid, price: price, volume: volume) }
let(:orderbook) { Matching::OrderBookManager.new('btcusd', broadcast: false) }
subject { Matching::Engine.new(market, mode: :run) }
before { subject.stubs(:orderbook).returns(orderbook) }
context 'submit market order 2' do
subject { Matching::Engine.new(market, mode: :dryrun) }
context 'sell market order 1' do
# We have the next state of bid(buy) order book.
# | price | volume |
# | 0.86 | 0.9817 |
#
# Ask market order for 0.918 BTC was created.
# We expect order to be fully executed.
let!(:bid1_in_db) do
create(:order_bid,
:btcusd,
ord_type: :limit,
locked: 0.844262.to_d,
price: 0.86.to_d,
volume: 0.9817.to_d)
end
let!(:ask1_in_db) do
create(:order_ask,
:btcusd,
ord_type: :market,
locked: 0.918.to_d,
price: nil,
volume: 0.918.to_d)
end
let(:expected_messages) do
[
[
:trade_executor,
{
:action => "execute",
:trade => {
:market_id=>"btcusd",
:maker_order_id=>bid1_in_db.id,
:taker_order_id=>ask1_in_db.id,
:strike_price=>0.86.to_d,
:amount=>0.918.to_d,
:total=>0.78948.to_d
}
},
{ persistent: false }
]
]
end
it 'publish trade' do
subject.submit bid1_in_db.to_matching_mock
subject.submit ask1_in_db.to_matching_mock
expect(subject.queue).to eq expected_messages
end
end
context 'sell market order 2' do
# We have the next state of bid(buy) order book.
# | price | volume |
# | 8.6 | 0.9817 |
#
# Ask market order for 0.918 BTC was created.
# We expect order to be fully executed.
let!(:bid1_in_db) do
create(:order_bid,
:btcusd,
ord_type: :limit,
locked: 8.44262.to_d,
price: 8.6.to_d,
volume: 0.9817.to_d)
end
let!(:ask1_in_db) do
create(:order_ask,
:btcusd,
ord_type: :market,
locked: 0.918.to_d,
price: nil,
volume: 0.918.to_d)
end
let(:expected_messages) do
[
[
:trade_executor,
{
:action => "execute",
:trade => {
:market_id=>"btcusd",
:taker_order_id=>ask1_in_db.id,
:maker_order_id=>bid1_in_db.id,
:strike_price=>8.6.to_d,
:amount=>0.918.to_d,
:total=>7.8948.to_d
}
},
{ persistent: false }
]
]
end
it 'publish trade' do
subject.submit bid1_in_db.to_matching_mock
subject.submit ask1_in_db.to_matching_mock
expect(subject.queue).to eq expected_messages
end
end
context 'market order out of locked 1' do
# We have the next state of ask(sell) order book.
# | price | volume |
# | 0.8006| 0.9817 |
#
# Bid market order for 0.8395 BTC was created with 0.6716 USD locked
# (estimated average price is 0.8). But orderbook state changed and order doesn't
# have enough locked to match with first in orderbook.
# We expect order to be cancelled.
let!(:ask1_in_db) do
create(:order_ask,
:btcusd,
price: 80.06.to_d,
volume: 0.9817.to_d)
end
let!(:bid1_in_db) do
create(:order_bid,
:btcusd,
ord_type: :market,
locked: 67.16.to_d,
price: nil,
volume: 0.8395.to_d)
end
let(:expected_messages) do
[
[
:trade_executor,
{
:action=>"cancel",
:order=>
{:id=>bid1_in_db.id,
:timestamp=>bid1_in_db.created_at.to_i,
:type=>:bid,
:locked=>67.16.to_d,
:volume=>0.8395.to_d,
:market=>"btcusd",
:ord_type=>"market"}
},
{:persistent=>false}
]
]
end
it 'publish cancel order' do
subject.submit ask1_in_db.to_matching_mock
subject.submit bid1_in_db.to_matching_mock
expect(subject.queue).to eq expected_messages
end
end
context 'market order out of locked 2' do
# We have the next state of ask(sell) order book.
# | price | volume |
# | 0.8006| 0.0111 |
# | 1.4117| 0.9346 |
#
# Bid market order for 0.8395 BTC was created with 0.47237199 USD locked
# (estimated average price is 0.562682).
# But orderbook state changed and order doesn't have enough locked to
# fulfill. We expect order to be partially filled and then cancelled.
# For full order execution we need 1.181463512078
# (0.0111 * 0.8006 + 0.83061334 * 1.4117). Which is less then locked.
let!(:ask1_in_db) do
create(:order_ask,
:btcusd,
price: 80.06.to_d,
volume: 0.0111.to_d)
end
let!(:ask2_in_db) do
create(:order_ask,
:btcusd,
price: 141.17.to_d,
volume: 0.9346.to_d)
end
let!(:bid1_in_db) do
create(:order_bid,
:btcusd,
ord_type: :market,
locked: 47.237199.to_d,
price: nil,
volume: 0.8395.to_d)
end
let(:expected_messages) do
[
[
:trade_executor,
{
:action => "execute",
:trade => {
:market_id=>"btcusd",
:maker_order_id=>ask1_in_db.id,
:taker_order_id=>bid1_in_db.id,
:strike_price=>80.06.to_d,
:amount=>0.0111.to_d,
:total=>0.888666.to_d
}
},
{ persistent: false }
],
[
:trade_executor,
{
:action=>"cancel",
:order=>
{:id=>bid1_in_db.id,
:timestamp=>bid1_in_db.created_at.to_i,
:type=>:bid,
:locked=>46.348533.to_d,
:volume=>0.8284.to_d,
:market=>"btcusd",
:ord_type=>"market"}
},
{:persistent=>false}
]
]
end
it 'publish single trade and cancel order' do
subject.submit ask1_in_db.to_matching_mock
subject.submit ask2_in_db.to_matching_mock
subject.submit bid1_in_db.to_matching_mock
expect(subject.queue).to eq expected_messages
end
end
context 'market order out of locked 3' do
# We have the next state of ask(sell) order book.
# | price | volume |
# | 3000.0| 0.0009 |
# | 3001.0| 0.0011 |
# | 3010.0| 0.1000 |
#
# Bid market order for 0.01 BTC was created with 30.03 USD locked
# (estimated average price is 3003).
# But orderbook state changed and order doesn't have enough locked to
# fulfill. We expect order to create two trades and then cancel.
# For full order execution we need 30.0811
# (3000 * 0.0009 + 3001 * 0.0011 + 3010 * 0.008).
# Which is less then locked.
let!(:ask1_in_db) do
create(:order_ask,
:btcusd,
price: 3000.to_d,
volume: 0.0009.to_d)
end
let!(:ask2_in_db) do
create(:order_ask,
:btcusd,
price: 3001.to_d,
volume: 0.0011.to_d)
end
let!(:ask3_in_db) do
create(:order_ask,
:btcusd,
price: 3010.to_d,
volume: 0.1000.to_d)
end
let!(:bid1_in_db) do
create(:order_bid,
:btcusd,
ord_type: :market,
locked: 30.03.to_d,
price: nil,
volume: 0.01.to_d)
end
let(:expected_messages) do
[
[
:trade_executor,
{ :action => "execute",
:trade => {
:market_id=>"btcusd",
:maker_order_id=>ask1_in_db.id,
:taker_order_id=>bid1_in_db.id,
:strike_price=>0.3e4,
:amount=>0.9e-3,
:total=>0.27e1
}},
{ :persistent=>false }
],
[
:trade_executor,
{ :action => "execute",
:trade => {
:market_id=>"btcusd",
:maker_order_id=>ask2_in_db.id,
:taker_order_id=>bid1_in_db.id,
:strike_price=>0.3001e4,
:amount=>0.11e-2,
:total=>0.33011e1
}},
{ :persistent=>false }
],
[
:trade_executor,
{ :action=>"cancel",
:order=> {
:id=>bid1_in_db.id,
:timestamp=>bid1_in_db.created_at.to_i,
:type=>:bid,
:locked=>0.240289e2,
:volume=>0.8e-2,
:market=>"btcusd",
:ord_type=>"market"
}},
{ :persistent=>false }
]
]
end
it 'publish single two trades and cancel order' do
subject.submit ask1_in_db.to_matching_mock
subject.submit ask2_in_db.to_matching_mock
subject.submit bid1_in_db.to_matching_mock
expect(subject.queue).to eq expected_messages
end
end
context 'market doesn\'t have enough funds 1' do
# We have the next state of ask(sell) order book.
# | price | volume |
# | 3000.0| 0.0009 |
#
# Bid market order for 0.001 BTC was created with 30.03 USD locked
# (estimated average price is 3003).
# But orderbook state changed and market doesn't have enough volume to
# fulfill. We expect order to match with the first opposite order and then
# be cancelled.
let!(:ask1_in_db) do
create(:order_ask,
:btcusd,
price: 3000.to_d,
volume: 0.0009.to_d)
end
let!(:bid1_in_db) do
create(:order_bid,
:btcusd,
ord_type: :market,
locked: 30.03.to_d,
price: nil,
volume: 0.01.to_d)
end
let(:expected_messages) do
[
[
:trade_executor,
{ :action => "execute",
:trade => {
:market_id=>"btcusd",
:maker_order_id=>ask1_in_db.id,
:taker_order_id=>bid1_in_db.id,
:strike_price=>0.3e4,
:amount=>0.9e-3,
:total=>0.27e1
}},
{ :persistent=>false }
],
[
:trade_executor,
{
:action=>"cancel",
:order=> {
:id=>bid1_in_db.id,
:timestamp=>bid1_in_db.created_at.to_i,
:type=>:bid,
:locked=>0.2733e2,
:volume=>0.91e-2,
:market=>"btcusd",
:ord_type=>"market"
}
},
{ :persistent=>false }
]
]
end
it 'publish single trade and cancel order' do
subject.submit ask1_in_db.to_matching_mock
subject.submit bid1_in_db.to_matching_mock
expect(subject.queue).to eq expected_messages
end
end
context 'market doesn\'t have enough funds 2' do
# We have the next state of ask(sell) order book.
# | price | volume |
# | 3000.0| 0.0009 |
#
# 1. Bid market order for 0.00045 BTC was created with 1.35 USD locked
# (estimated average price is 3000).
# 2. Bid market order for 0.0009 BTC was created with 2.7 USD locked
# (estimated average price is 3000).
# Firs order match fully. Second order match partially and cancel.
let!(:ask1_in_db) do
create(:order_ask,
:btcusd,
price: 3000.to_d,
volume: 0.0009.to_d)
end
let!(:bid1_in_db) do
create(:order_bid,
:btcusd,
ord_type: :market,
locked: 1.35.to_d,
price: nil,
volume: 0.00045.to_d)
end
let!(:bid2_in_db) do
create(:order_bid,
:btcusd,
ord_type: :market,
locked: 2.7.to_d,
price: nil,
volume: 0.0009.to_d)
end
let(:expected_messages) do
[
[
:trade_executor,
{ :action => "execute",
:trade => {
:market_id => "btcusd",
:maker_order_id => ask1_in_db.id,
:taker_order_id => bid1_in_db.id,
:strike_price => 0.3e4.to_d,
:amount => 0.45e-3.to_d,
:total => 0.135e1.to_d
}},
{ :persistent => false }
],
[
:trade_executor,
{
:action => "execute",
:trade => {
:market_id => "btcusd",
:maker_order_id => ask1_in_db.id,
:taker_order_id => bid2_in_db.id,
:strike_price => 0.3e4.to_d,
:amount => 0.45e-3.to_d,
:total => 0.135e1.to_d
}
},
{ :persistent => false }
],
[
:trade_executor,
{
:action => "cancel",
:order => {
:id => bid2_in_db.id,
:timestamp => bid2_in_db.created_at.to_i,
:type => :bid,
:locked => 0.135e1.to_d,
:volume => 0.45e-3.to_d,
:market => "btcusd",
:ord_type => "market"
}
},
{ :persistent => false }
]
]
end
it 'publish single two trades and cancel order' do
subject.submit ask1_in_db.to_matching_mock
subject.submit bid1_in_db.to_matching_mock
subject.submit bid2_in_db.to_matching_mock
expect(subject.queue).to eq expected_messages
end
end
end
context 'submit market order' do
let!(:bid) { Matching.mock_limit_order(type: :bid, price: '0.1'.to_d, volume: '0.1'.to_d) }
let!(:ask1) { Matching.mock_limit_order(type: :ask, price: '1.0'.to_d, volume: '1.0'.to_d) }
let!(:ask2) { Matching.mock_limit_order(type: :ask, price: '2.0'.to_d, volume: '1.0'.to_d) }
let!(:ask3) { Matching.mock_limit_order(type: :ask, price: '3.0'.to_d, volume: '1.0'.to_d) }
it 'should fill the market order completely' do
mo = Matching.mock_market_order(type: :bid, locked: '6.0'.to_d, volume: '2.4'.to_d)
AMQP::Queue.expects(:enqueue).with(:trade_executor, { action: "execute", trade: { market_id: market.id, maker_order_id: ask1.id, taker_order_id: mo.id, strike_price: ask1.price, amount: ask1.volume, total: '1.0'.to_d } }, anything)
AMQP::Queue.expects(:enqueue).with(:trade_executor, { action: "execute", trade: { market_id: market.id, maker_order_id: ask2.id, taker_order_id: mo.id, strike_price: ask2.price, amount: ask2.volume, total: '2.0'.to_d } }, anything)
AMQP::Queue.expects(:enqueue).with(:trade_executor, { action: "execute", trade: { market_id: market.id, maker_order_id: ask3.id, taker_order_id: mo.id, strike_price: ask3.price, amount: '0.4'.to_d, total: '1.2'.to_d } }, anything)
subject.submit bid
subject.submit ask1
subject.submit ask2
subject.submit ask3
subject.submit mo
expect(subject.ask_orders.limit_orders.size).to eq 1
expect(subject.ask_orders.limit_orders.values.first).to eq [ask3]
expect(ask3.volume).to eq '0.6'.to_d
expect(subject.bid_orders.market_orders).to be_empty
end
it 'should fill the market order partially and cancel it' do
mo = Matching.mock_market_order(type: :bid, locked: '6.0'.to_d, volume: '2.4'.to_d)
AMQP::Queue.expects(:enqueue).with(:trade_executor, { action: "execute", trade: { market_id: market.id, maker_order_id: ask1.id, taker_order_id: mo.id, strike_price: ask1.price, amount: ask1.volume, total: '1.0'.to_d } }, anything)
AMQP::Queue.expects(:enqueue).with(:trade_executor, { action: "execute", trade: { market_id: market.id, maker_order_id: ask2.id, taker_order_id: mo.id, strike_price: ask2.price, amount: ask2.volume, total: '2.0'.to_d } }, anything)
AMQP::Queue.expects(:enqueue).with(:trade_executor, has_entries(action: 'cancel', order: has_entry(id: mo.id)), anything)
subject.submit bid
subject.submit ask1
subject.submit ask2
subject.submit mo
expect(subject.ask_orders.limit_orders).to be_empty
expect(subject.bid_orders.market_orders).to be_empty
end
end
context 'submit limit order' do
context 'fully match incoming order' do
it 'should execute trade' do
AMQP::Queue.expects(:enqueue)
.with(:trade_executor, { action: "execute", trade: { market_id: market.id, maker_order_id: ask.id, taker_order_id: bid.id, strike_price: price, amount: volume, total: '50.0'.to_d } }, anything)
subject.submit(ask)
subject.submit(bid)
expect(subject.ask_orders.limit_orders).to be_empty
expect(subject.bid_orders.limit_orders).to be_empty
end
end
context 'partial match incoming order' do
let(:ask) { Matching.mock_limit_order(type: :ask, price: price, volume: 3.to_d) }
it 'should execute trade' do
AMQP::Queue.expects(:enqueue)
.with(:trade_executor, { action: "execute", trade: { market_id: market.id, maker_order_id: ask.id, taker_order_id: bid.id, strike_price: price, amount: 3.to_d, total: '30.0'.to_d } }, anything)
subject.submit(ask)
subject.submit(bid)
expect(subject.ask_orders.limit_orders).to be_empty
expect(subject.bid_orders.limit_orders).not_to be_empty
AMQP::Queue.expects(:enqueue)
.with(:trade_executor, { action: 'cancel', order: bid.attributes }, anything)
subject.cancel(bid)
expect(subject.bid_orders.limit_orders).to be_empty
end
end
context 'match order with many counter orders' do
let(:bid) { Matching.mock_limit_order(type: :bid, price: price, volume: 10.to_d) }
let(:asks) do
[nil, nil, nil].map do
Matching.mock_limit_order(type: :ask, price: price, volume: 3.to_d)
end
end
it 'should execute trade' do
AMQP::Queue.expects(:enqueue).times(asks.size)
asks.each { |ask| subject.submit(ask) }
subject.submit(bid)
expect(subject.ask_orders.limit_orders).to be_empty
expect(subject.bid_orders.limit_orders).not_to be_empty
end
end
context 'fully match order after some cancellatons' do
let(:bid) { Matching.mock_limit_order(type: :bid, price: price, volume: 10.to_d) }
let(:low_ask) { Matching.mock_limit_order(type: :ask, price: price - 1, volume: 3.to_d) }
let(:high_ask) { Matching.mock_limit_order(type: :ask, price: price, volume: 3.to_d) }
it 'should match bid with high ask' do
subject.submit(low_ask) # low ask enters first
subject.submit(high_ask)
subject.cancel(low_ask) # but it's canceled
AMQP::Queue.expects(:enqueue)
.with(:trade_executor, { action: "execute", trade: { market_id: market.id, maker_order_id: high_ask.id, taker_order_id: bid.id, strike_price: high_ask.price, amount: high_ask.volume, total: '30.0'.to_d } }, anything)
subject.submit(bid)
expect(subject.ask_orders.limit_orders).to be_empty
expect(subject.bid_orders.limit_orders).not_to be_empty
end
end
end
context '#cancel' do
it 'should cancel order' do
subject.submit(ask)
subject.cancel(ask)
expect(subject.ask_orders.limit_orders).to be_empty
subject.submit(bid)
subject.cancel(bid)
expect(subject.bid_orders.limit_orders).to be_empty
end
end
context 'dryrun' do
subject { Matching::Engine.new(market, mode: :dryrun) }
it 'should not publish matched trades' do
AMQP::Queue.expects(:enqueue).never
subject.submit(ask)
subject.submit(bid)
expect(subject.ask_orders.limit_orders).to be_empty
expect(subject.bid_orders.limit_orders).to be_empty
expect(subject.queue.size).to eq 1
expect(subject.queue.first).to eq [:trade_executor, { action: "execute", trade: { market_id: market.id, maker_order_id: ask.id, taker_order_id: bid.id, strike_price: price, amount: volume, total: '50.0'.to_d } }, { persistent: false }]
end
end
context 'publish_increment' do
before(:each) { subject.initializing = false }
it 'should publish increment of orderbook' do
::AMQP::Queue.expects(:enqueue_event).with("public", market.id, "ob-inc", { "asks" => ["10.0", "5.0"], "sequence" => 2, })
::AMQP::Queue.expects(:enqueue_event).with("public", market.id, "ob-inc", { "bids" => ["10.0", "5.0"], "sequence" => 3, })
subject.publish_increment(market.id, :ask, ask.price, ask.volume)
subject.publish_increment(market.id, :bid, bid.price, bid.volume)
end
end
context 'publish_snapshot' do
let(:ask1) { Matching.mock_limit_order(type: :ask, price: "14".to_d, volume: "1.0".to_d) }
let(:ask2) { Matching.mock_limit_order(type: :ask, price: "12".to_d, volume: "1.0".to_d) }
let(:bid1) { Matching.mock_limit_order(type: :bid, price: "11".to_d, volume: "2.0".to_d) }
let(:bid2) { Matching.mock_limit_order(type: :bid, price: "10".to_d, volume: "2.0".to_d) }
it 'should publish snapshot of orderbook' do
subject.submit(ask1)
subject.submit(ask2)
subject.submit(bid1)
subject.submit(bid2)
::AMQP::Queue.expects(:enqueue_event).with("public", market.id, "ob-snap", {
"asks" => [["12.0", "1.0"], ["14.0", "1.0"]],
"bids" => [["11.0", "2.0"], ["10.0", "2.0"]],
"sequence" => 1,
})
subject.publish_snapshot
end
context 'periodic publish snapshot time' do
before(:each) { subject.initializing = false }
it 'should publish snapshot of orderbook and set increment_count to 1' do
subject.snapshot_time = Time.now - 80.second
subject.submit(ask1)
subject.submit(bid1)
::AMQP::Queue.expects(:enqueue_event).with("public", market.id, "ob-snap", {
"asks" => [["14.0", "1.0"]],
"bids" => [["11.0", "2.0"]],
"sequence" => 1,
})
::AMQP::Queue.expects(:enqueue_event).with("public", market.id, "ob-inc", { "asks" => ["11.0", "2.0"], "sequence" => 2 })
subject.publish_increment(market.id, :ask, bid1.price, bid1.volume)
expect(subject.increment_count).to eq(1)
end
it 'should publish snapshot of orderbook (snapshot_time >= 1m and increment count < 20)' do
subject.snapshot_time = Time.now - 80.second
subject.submit(ask1)
subject.submit(bid1)
::AMQP::Queue.expects(:enqueue_event).with("public", market.id, "ob-snap", {
"asks" => [["14.0", "1.0"]],
"bids" => [["11.0", "2.0"]],
"sequence" => 1,
})
::AMQP::Queue.expects(:enqueue_event).with("public", market.id, "ob-inc", { "asks" => ["11.0", "2.0"], "sequence" => 2 })
subject.publish_increment(market.id, :ask, bid1.price, bid1.volume)
end
it 'should publish snapshot of orderbook (snapshot_time > 10s and increment count => 20)' do
subject.snapshot_time = Time.now - 11.second
subject.increment_count = 20
subject.submit(ask1)
subject.submit(bid1)
::AMQP::Queue.expects(:enqueue_event).with("public", market.id, "ob-snap", {
"asks" => [["14.0", "1.0"]],
"bids" => [["11.0", "2.0"]],
"sequence" => 1
})
::AMQP::Queue.expects(:enqueue_event).with("public", market.id, "ob-inc", { "asks" => ["11.0", "2.0"], "sequence" => 2 })
subject.publish_increment(market.id, :ask, bid1.price, bid1.volume)
end
it 'shouldnt publish snapshot of orderbook (snapshot_time <= 1m and increment count < 20)' do
subject.snapshot_time = Time.now
subject.submit(ask1)
subject.submit(bid1)
::AMQP::Queue.expects(:enqueue_event).with("public", market.id, "ob-snap", {
"asks" => [["14.0", "1.0"]],
"bids" => [["11.0", "2.0"]],
"sequence" => 1
}).never
::AMQP::Queue.expects(:enqueue_event).with("public", market.id, "ob-inc", { "asks" => ["11.0", "2.0"], "sequence" => 2 })
subject.publish_increment(market.id, :ask, bid1.price, bid1.volume)
end
it 'shouldnt publish snapshot of orderbook (snapshot_time < 10s and increment count => 20)' do
subject.snapshot_time = Time.now - 1.second
subject.increment_count = 20
subject.submit(ask1)
subject.submit(bid1)
::AMQP::Queue.expects(:enqueue_event).with("public", market.id, "ob-snap", {
"asks" => [["14.0", "1.0"]],
"bids" => [["11.0", "2.0"]],
}).never
::AMQP::Queue.expects(:enqueue_event).with("public", market.id, "ob-inc", { "asks" => ["11.0", "2.0"], "sequence" => 2 })
subject.publish_increment(market.id, :ask, bid1.price, bid1.volume)
end
end
end
end