Initial commit
This commit is contained in:
107
spec/trading/accounting_spec.rb
Normal file
107
spec/trading/accounting_spec.rb
Normal file
@@ -0,0 +1,107 @@
|
||||
describe 'Accounting' do
|
||||
let(:seller) { create(:member, :level_3, :barong) }
|
||||
let(:buyer) { create(:member, :level_3, :barong) }
|
||||
|
||||
let :order_ask do
|
||||
create :order_ask, \
|
||||
bid: :usd,
|
||||
ask: :btc,
|
||||
market: Market.find(:btcusd),
|
||||
state: :wait,
|
||||
ord_type: :limit,
|
||||
price: '1'.to_d,
|
||||
volume: '10000.0',
|
||||
origin_volume: '10000.0',
|
||||
locked: '10000',
|
||||
origin_locked: '10000',
|
||||
member: seller
|
||||
end
|
||||
|
||||
let :order_bid do
|
||||
create :order_bid, \
|
||||
bid: :usd,
|
||||
ask: :btc,
|
||||
market: Market.find(:btcusd),
|
||||
state: :wait,
|
||||
ord_type: :limit,
|
||||
price: '1.2'.to_d,
|
||||
volume: '10000',
|
||||
origin_volume: '10000',
|
||||
locked: '12000',
|
||||
origin_locked: '12000',
|
||||
member: buyer
|
||||
end
|
||||
|
||||
let(:deposit_btc) { create(:deposit_btc, member: seller, amount: order_ask.locked, currency_id: :btc) }
|
||||
|
||||
let(:deposit_usd) { create(:deposit_usd, member: buyer, amount: order_bid.locked, currency_id: :usd) }
|
||||
|
||||
let :executor do
|
||||
ask = Matching::LimitOrder.new(order_ask.to_matching_attributes)
|
||||
bid = Matching::LimitOrder.new(order_bid.to_matching_attributes)
|
||||
Matching::Executor.new \
|
||||
action: 'execute',
|
||||
trade: {
|
||||
market_id: :btcusd,
|
||||
maker_order_id: ask.id,
|
||||
taker_order_id: bid.id,
|
||||
strike_price: '1.2',
|
||||
amount: '10000',
|
||||
total: '12000'
|
||||
}
|
||||
end
|
||||
|
||||
let(:asset_balance) { Operations::Asset.balance }
|
||||
let(:liability_balance) { Operations::Liability.balance }
|
||||
let(:revenue_balance) { Operations::Revenue.balance }
|
||||
let(:expense_balance) { Operations::Expense.balance }
|
||||
|
||||
before do
|
||||
deposit_btc.accept!
|
||||
deposit_btc.process!
|
||||
deposit_btc.dispatch!
|
||||
deposit_usd.accept!
|
||||
|
||||
order_bid.hold_account!.lock_funds!(order_bid.locked)
|
||||
order_bid.record_submit_operations!
|
||||
|
||||
order_ask.hold_account!.lock_funds!(order_ask.locked)
|
||||
order_ask.record_submit_operations!
|
||||
|
||||
executor.execute!
|
||||
end
|
||||
|
||||
it 'assert that asset - liabilities = revenue - expense' do
|
||||
expect(asset_balance.merge(liability_balance){ |k, a, b| a - b}).to eq (revenue_balance.merge(expense_balance){ |k, a, b| a - b})
|
||||
end
|
||||
|
||||
it 'assert the balance is 15.0 / 18.0 $' do
|
||||
balance = asset_balance.merge(liability_balance){ |k, a, b| a - b}
|
||||
|
||||
expect(balance.fetch(:btc)).to eq '15.0'.to_d
|
||||
expect(balance.fetch(:usd)).to eq '18.0'.to_d
|
||||
end
|
||||
|
||||
context 'withdraws' do
|
||||
let(:btc_withdraw) { create(:btc_withdraw, sum: 1000.to_d, member: buyer ) }
|
||||
|
||||
before do
|
||||
btc_withdraw.accept!
|
||||
btc_withdraw.update(txid: 'a1a43ab7166f81059449f80a35abdc6febe62fe1f75a0cdb25d49ebae3fc10d9')
|
||||
btc_withdraw.process!
|
||||
btc_withdraw.dispatch!
|
||||
btc_withdraw.success!
|
||||
end
|
||||
|
||||
it 'after btc withdraw, assert that asset - liabilities = revenue - expense' do
|
||||
expect(asset_balance.merge(liability_balance){ |k, a, b| a - b}).to eq (revenue_balance.merge(expense_balance){ |k, a, b| a - b})
|
||||
end
|
||||
|
||||
it 'after btc withdraw (fee: 0.01) assert the balance is 15.01 / 18.0 $' do
|
||||
balance = asset_balance.merge(liability_balance){ |k, a, b| a - b}
|
||||
|
||||
expect(balance.fetch(:btc)).to eq '15.01'.to_d
|
||||
expect(balance.fetch(:usd)).to eq '18.0'.to_d
|
||||
end
|
||||
end
|
||||
end
|
||||
761
spec/trading/matching/engine_spec.rb
Normal file
761
spec/trading/matching/engine_spec.rb
Normal 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
|
||||
234
spec/trading/matching/executor_spec.rb
Normal file
234
spec/trading/matching/executor_spec.rb
Normal file
@@ -0,0 +1,234 @@
|
||||
# encoding: UTF-8
|
||||
# frozen_string_literal: true
|
||||
|
||||
describe Matching::Executor do
|
||||
let(:alice) { who_is_billionaire }
|
||||
let(:bob) { who_is_billionaire }
|
||||
let(:market) { Market.find('btcusd') }
|
||||
let(:price) { 10.to_d }
|
||||
let(:volume) { 5.to_d }
|
||||
|
||||
subject do
|
||||
Matching::Executor.new(
|
||||
action: 'execute',
|
||||
trade: {
|
||||
market_id: market.id,
|
||||
maker_order_id: ask.id,
|
||||
taker_order_id: bid.id,
|
||||
strike_price: price.to_s('F'),
|
||||
amount: volume.to_s('F'),
|
||||
total: (price * volume).to_s('F')
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
context 'invalid volume' do
|
||||
let(:ask) { ::Matching::LimitOrder.new create(:order_ask, :btcusd, price: price, volume: volume, member: alice).to_matching_attributes }
|
||||
let(:bid) { ::Matching::LimitOrder.new create(:order_bid, :btcusd, price: price, volume: 3.to_d, member: bob).to_matching_attributes }
|
||||
|
||||
it 'should raise error' do
|
||||
expect { subject.execute! }.to raise_error(Matching::TradeExecutionError)
|
||||
end
|
||||
end
|
||||
|
||||
context 'invalid bid price' do
|
||||
let(:ask) { ::Matching::LimitOrder.new create(:order_ask, :btcusd, price: price, volume: volume, member: alice).to_matching_attributes }
|
||||
let(:bid) { ::Matching::LimitOrder.new create(:order_bid, :btcusd, price: price - 1, volume: volume, member: bob).to_matching_attributes }
|
||||
|
||||
it 'should raise error' do
|
||||
expect { subject.execute! }.to raise_error(Matching::TradeExecutionError)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
context 'invalid ask price' do
|
||||
let(:ask) { ::Matching::LimitOrder.new create(:order_ask, :btcusd, price: price + 1, volume: volume, member: alice).to_matching_attributes }
|
||||
let(:bid) { ::Matching::LimitOrder.new create(:order_bid, :btcusd, price: price, volume: volume, member: bob).to_matching_attributes }
|
||||
|
||||
it 'should raise error' do
|
||||
expect { subject.execute! }.to raise_error(Matching::TradeExecutionError)
|
||||
end
|
||||
end
|
||||
|
||||
context 'full execution' do
|
||||
let(:ask) { ::Matching::LimitOrder.new create(:order_ask, :btcusd, price: price, volume: volume, member: alice).to_matching_attributes }
|
||||
let(:bid) { ::Matching::LimitOrder.new create(:order_bid, :btcusd, price: price, volume: volume, member: bob).to_matching_attributes }
|
||||
|
||||
it 'should create trade' do
|
||||
expect do
|
||||
trade = subject.execute!
|
||||
|
||||
expect(trade.price).to eq price
|
||||
expect(trade.amount).to eq volume
|
||||
expect(trade.maker_order_id).to eq ask.id
|
||||
expect(trade.taker_order_id).to eq bid.id
|
||||
end.to change(Trade, :count).by(1)
|
||||
end
|
||||
|
||||
it 'should set trade used funds' do
|
||||
trade = subject.execute!
|
||||
expect(trade.total).to eq price * volume
|
||||
end
|
||||
|
||||
it 'should increase order\'s trades count' do
|
||||
subject.execute!
|
||||
expect(Order.find(ask.id).trades_count).to eq 1
|
||||
expect(Order.find(bid.id).trades_count).to eq 1
|
||||
end
|
||||
|
||||
it 'should mark both orders as done' do
|
||||
subject.execute!
|
||||
expect(Order.find(ask.id).state).to eq Order::DONE
|
||||
expect(Order.find(bid.id).state).to eq Order::DONE
|
||||
end
|
||||
|
||||
it 'should publish trade through amqp' do
|
||||
AMQP::Queue.expects(:publish)
|
||||
subject.execute!
|
||||
end
|
||||
end
|
||||
|
||||
context 'partial ask execution' do
|
||||
let(:ask) { create(:order_ask, :btcusd, price: price, volume: 7.to_d, member: alice) }
|
||||
let(:bid) { create(:order_bid, :btcusd, price: price, volume: 5.to_d, member: bob) }
|
||||
|
||||
it 'should set bid to done only' do
|
||||
subject.execute!
|
||||
|
||||
expect(ask.reload.state).not_to eq Order::DONE
|
||||
expect(bid.reload.state).to eq Order::DONE
|
||||
end
|
||||
end
|
||||
|
||||
context 'partial bid execution' do
|
||||
let(:ask) { create(:order_ask, :btcusd, price: price, volume: 5.to_d, member: alice) }
|
||||
let(:bid) { create(:order_bid, :btcusd, price: price, volume: 7.to_d, member: bob) }
|
||||
|
||||
it 'should set ask to done only' do
|
||||
subject.execute!
|
||||
|
||||
expect(ask.reload.state).to eq Order::DONE
|
||||
expect(bid.reload.state).not_to eq Order::DONE
|
||||
end
|
||||
end
|
||||
|
||||
context 'partially filled market order whose locked fund run out' do
|
||||
let(:ask) { create(:order_ask, :btcusd, price: '2.0'.to_d, volume: '3.0'.to_d, member: alice) }
|
||||
let(:bid) { create(:order_bid, :btcusd, price: nil, ord_type: 'market', volume: '2.0'.to_d, locked: '3.0'.to_d, member: bob) }
|
||||
|
||||
it 'should cancel the market order' do
|
||||
executor = Matching::Executor.new(
|
||||
action: 'execute',
|
||||
trade: {
|
||||
market_id: market.id,
|
||||
maker_order_id: ask.id,
|
||||
taker_order_id: bid.id,
|
||||
strike_price: '2.0'.to_d,
|
||||
amount: '1.5'.to_d,
|
||||
total: '3.0'.to_d
|
||||
}
|
||||
)
|
||||
executor.execute!
|
||||
|
||||
expect(bid.reload.state).to eq Order::CANCEL
|
||||
end
|
||||
end
|
||||
|
||||
context 'unlock not used funds' do
|
||||
let(:ask) { create(:order_ask, :btcusd, price: price - 1, volume: 7.to_d, member: alice) }
|
||||
let(:bid) { create(:order_bid, :btcusd, price: price, volume: volume, member: bob) }
|
||||
|
||||
subject do
|
||||
Matching::Executor.new(
|
||||
action: 'execute',
|
||||
trade: {
|
||||
market_id: market.id,
|
||||
maker_order_id: ask.id,
|
||||
taker_order_id: bid.id,
|
||||
strike_price: price - 1, # so bid order only used (price-1)*volume
|
||||
amount: volume.to_s('F'),
|
||||
total: ((price - 1) * volume).to_s('F')
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
it 'should unlock funds not used by bid order' do
|
||||
locked_before = bid.hold_account.reload.locked
|
||||
|
||||
subject.execute!
|
||||
locked_after = bid.hold_account.reload.locked
|
||||
|
||||
expect(locked_after).to eq locked_before - (price * volume)
|
||||
end
|
||||
|
||||
it 'should save unused amount in order locked attribute' do
|
||||
subject.execute!
|
||||
expect(bid.reload.locked).to eq price * volume - (price - 1) * volume
|
||||
end
|
||||
end
|
||||
|
||||
context 'maker/taker fee' do
|
||||
# Create two limit orders:
|
||||
# Ask: member_id: alice, market: btcusd, price: 10, volume: 5 - Maker;
|
||||
# Bid: member_id: bob, market: btcusd, price: 10, volume: 5 - Taker;
|
||||
|
||||
|
||||
context 'maker_fee: 1%, taker_fee: 2%' do
|
||||
# Maker_fee: 1%;
|
||||
# Taker_fee: 2%;
|
||||
# Result after exeuction:
|
||||
# Alice get 49.5 usd;
|
||||
# Bob get 4.9 btc;
|
||||
let(:ask) { create(:order_ask, :btcusd, price: price, volume: volume, member: alice) }
|
||||
let(:bid) { create(:order_bid, :btcusd, price: price, volume: volume, member: bob) }
|
||||
|
||||
before do
|
||||
TradingFee.last.update!(maker: 0.01, taker: 0.02)
|
||||
subject.execute!
|
||||
end
|
||||
|
||||
it do
|
||||
expect(ask.member.balance_for(currency: bid.currency, kind: :main)).to eq(49.5)
|
||||
expect(bid.member.balance_for(currency: ask.currency, kind: :main)).to eq(4.9)
|
||||
expect(Operations::Revenue.find_by(currency: bid.currency, member: ask.member).credit).to eq(0.5)
|
||||
expect(Operations::Revenue.find_by(currency: ask.currency, member: bid.member).credit).to eq(0.1)
|
||||
end
|
||||
end
|
||||
|
||||
context 'maker_fee: 0%, taker_fee: 2%' do
|
||||
# Maker_fee: 0%;
|
||||
# Taker_fee: 2%;
|
||||
# Result after exeuction:
|
||||
# Alice get 50.0 usd;
|
||||
# Bob get 4.9 btc;
|
||||
let(:ask) { create(:order_ask, :btcusd, price: price, volume: volume, member: alice) }
|
||||
let(:bid) { create(:order_bid, :btcusd, price: price, volume: volume, member: bob) }
|
||||
|
||||
before do
|
||||
TradingFee.last.update!(maker: 0.0, taker: 0.02)
|
||||
subject.execute!
|
||||
end
|
||||
|
||||
it do
|
||||
expect(ask.member.balance_for(currency: bid.currency, kind: :main)).to eq(50)
|
||||
expect(bid.member.balance_for(currency: ask.currency, kind: :main)).to eq(4.9)
|
||||
expect(Operations::Revenue.find_by(currency: bid.currency, member: ask.member).blank?).to eq(true)
|
||||
expect(Operations::Revenue.find_by(currency: ask.currency, member: bid.member).credit).to eq(0.1)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'execution fail' do
|
||||
let(:ask) { ::Matching::LimitOrder.new create(:order_ask, :btcusd, price: price, volume: volume, member: alice).to_matching_attributes }
|
||||
let(:bid) { ::Matching::LimitOrder.new create(:order_bid, :btcusd, price: price, volume: volume, member: bob).to_matching_attributes }
|
||||
|
||||
it 'should not create trade' do
|
||||
# set locked funds to 0 so strike will fail
|
||||
alice.get_account(:btc).update_attributes(locked: ::Trade::ZERO)
|
||||
|
||||
expect do
|
||||
expect { subject.execute! }.to raise_error(Account::AccountError)
|
||||
end.not_to change(Trade, :count)
|
||||
end
|
||||
end
|
||||
end
|
||||
32
spec/trading/matching/limit_order_spec.rb
Normal file
32
spec/trading/matching/limit_order_spec.rb
Normal file
@@ -0,0 +1,32 @@
|
||||
# encoding: UTF-8
|
||||
# frozen_string_literal: true
|
||||
|
||||
describe Matching::LimitOrder do
|
||||
context 'initialize' do
|
||||
it 'should throw invalid order error for empty attributes' do
|
||||
expect do
|
||||
Matching::LimitOrder.new(type: '', price: '', volume: '')
|
||||
end.to raise_error(Matching::OrderError)
|
||||
end
|
||||
|
||||
it 'should initialize market' do
|
||||
expect(Matching.mock_limit_order(type: :bid).market).to eq 'btcusd'
|
||||
end
|
||||
end
|
||||
|
||||
context 'crossed?' do
|
||||
it 'should cross at lower or equal price for bid order' do
|
||||
order = Matching.mock_limit_order(type: :bid, price: '10.0'.to_d)
|
||||
expect(order.crossed?('9.0'.to_d)).to be true
|
||||
expect(order.crossed?('10.0'.to_d)).to be true
|
||||
expect(order.crossed?('11.0'.to_d)).to be false
|
||||
end
|
||||
|
||||
it 'should cross at higher or equal price for ask order' do
|
||||
order = Matching.mock_limit_order(type: :ask, price: '10.0'.to_d)
|
||||
expect(order.crossed?('9.0'.to_d)).to be false
|
||||
expect(order.crossed?('10.0'.to_d)).to be true
|
||||
expect(order.crossed?('11.0'.to_d)).to be true
|
||||
end
|
||||
end
|
||||
end
|
||||
32
spec/trading/matching/market_order_spec.rb
Normal file
32
spec/trading/matching/market_order_spec.rb
Normal file
@@ -0,0 +1,32 @@
|
||||
# encoding: UTF-8
|
||||
# frozen_string_literal: true
|
||||
|
||||
describe Matching::MarketOrder do
|
||||
context 'initialize' do
|
||||
it 'should not allow price attribute' do
|
||||
expect { Matching.mock_market_order(type: :ask, price: '1.0'.to_d) }.to raise_error(Matching::OrderError)
|
||||
end
|
||||
|
||||
it 'should only accept positive sum limit' do
|
||||
expect { Matching.mock_market_order(type: :bid, locked: '0.0'.to_d) }.to raise_error(Matching::OrderError)
|
||||
end
|
||||
end
|
||||
|
||||
context '#fill' do
|
||||
subject { Matching.mock_market_order(type: :bid, locked: '10.0'.to_d, volume: '2.0'.to_d) }
|
||||
|
||||
it 'should raise not enough volume error' do
|
||||
expect { subject.fill('1.0'.to_d, '3.0'.to_d, '3.0'.to_d) }.to raise_error(Matching::NotEnoughVolume)
|
||||
end
|
||||
|
||||
it 'should raise sum limit reached error' do
|
||||
expect { subject.fill('11.0'.to_d, '1.0'.to_d, '11.0'.to_d) }.to raise_error(Matching::ExceedSumLimit)
|
||||
end
|
||||
|
||||
it 'should also decrease volume and sum limit' do
|
||||
subject.fill '6.0'.to_d, '1.0'.to_d, '6.0'.to_d
|
||||
expect(subject.volume).to eq '1.0'.to_d
|
||||
expect(subject.locked).to eq '4.0'.to_d
|
||||
end
|
||||
end
|
||||
end
|
||||
11
spec/trading/matching/order_book_manager_spec.rb
Normal file
11
spec/trading/matching/order_book_manager_spec.rb
Normal file
@@ -0,0 +1,11 @@
|
||||
# encoding: UTF-8
|
||||
# frozen_string_literal: true
|
||||
|
||||
describe Matching::OrderBookManager do
|
||||
context '.build_order' do
|
||||
it 'should build limit order' do
|
||||
order = ::Matching::OrderBookManager.build_order id: 1, market: 'btcusd', ord_type: 'limit', type: 'ask', price: '1.0', volume: '1.0', timestamp: 12_345
|
||||
expect(order).to be_instance_of(::Matching::LimitOrder)
|
||||
end
|
||||
end
|
||||
end
|
||||
243
spec/trading/matching/order_book_spec.rb
Normal file
243
spec/trading/matching/order_book_spec.rb
Normal file
@@ -0,0 +1,243 @@
|
||||
# encoding: UTF-8
|
||||
# frozen_string_literal: true
|
||||
|
||||
describe Matching::OrderBook do
|
||||
context '#find' do
|
||||
subject { Matching::OrderBook.new('btcusd', :ask) }
|
||||
|
||||
it 'should find specific order' do
|
||||
o1 = Matching.mock_limit_order(type: :ask, price: '1.0'.to_d)
|
||||
o2 = Matching.mock_limit_order(type: :ask, price: '1.0'.to_d)
|
||||
subject.add o1
|
||||
subject.add o2
|
||||
|
||||
expect(subject.find(o1.dup).object_id).to eq o1.object_id
|
||||
expect(subject.find(o2.dup).object_id).to eq o2.object_id
|
||||
end
|
||||
end
|
||||
|
||||
context '#add' do
|
||||
subject { Matching::OrderBook.new('btcusd', :ask) }
|
||||
|
||||
it 'should reject invalid order whose volume is zero' do
|
||||
expect do
|
||||
subject.add Matching.mock_limit_order(type: :ask, volume: '0.0'.to_d)
|
||||
end.to raise_error(Matching::OrderError)
|
||||
end
|
||||
|
||||
it 'should add market order' do
|
||||
subject.add Matching.mock_limit_order(type: :ask)
|
||||
|
||||
o1 = Matching.mock_market_order(type: :ask)
|
||||
expect{ subject.add o1 }.to raise_error(Matching::MarketOrderbookError)
|
||||
end
|
||||
|
||||
it 'should create price level for order with new price' do
|
||||
order = Matching.mock_limit_order(type: :ask)
|
||||
subject.add order
|
||||
expect(subject.limit_orders.keys.first).to eq order.price
|
||||
expect(subject.limit_orders.values.first).to eq [order]
|
||||
end
|
||||
|
||||
it 'should add order with same price to same price level' do
|
||||
o1 = Matching.mock_limit_order(type: :ask)
|
||||
o2 = Matching.mock_limit_order(type: :ask, price: o1.price)
|
||||
subject.add o1
|
||||
subject.add o2
|
||||
|
||||
expect(subject.limit_orders.keys.size).to eq 1
|
||||
expect(subject.limit_orders.values.first).to eq [o1, o2]
|
||||
end
|
||||
|
||||
it 'should not broadcast add event' do
|
||||
order = Matching.mock_limit_order(type: :ask)
|
||||
|
||||
AMQP::Queue.expects(:enqueue).with(:slave_book, { action: 'add', order: order.attributes }, persistent: false).never
|
||||
Matching::OrderBook.new('btcusd', :ask, broadcast: false).add order
|
||||
end
|
||||
end
|
||||
|
||||
context '#remove' do
|
||||
subject { Matching::OrderBook.new('btcusd', :ask) }
|
||||
|
||||
it 'should remove market order' do
|
||||
subject.define_singleton_method(:legacy_add) do |order|
|
||||
raise Matching::InvalidOrderError, "volume is zero" if order.volume <= 0.to_d
|
||||
|
||||
case order
|
||||
when Matching::LimitOrder
|
||||
@limit_orders[order.price] ||= PriceLevel.new(order.price)
|
||||
@limit_orders[order.price].add order
|
||||
when Matching::MarketOrder
|
||||
@market_orders[order.id] = order
|
||||
else
|
||||
raise ArgumentError, "Unknown order type"
|
||||
end
|
||||
end
|
||||
|
||||
subject.add Matching.mock_limit_order(type: :ask)
|
||||
order = Matching.mock_market_order(type: :ask)
|
||||
subject.legacy_add order
|
||||
subject.remove order
|
||||
expect(subject.market_orders).to be_empty
|
||||
end
|
||||
|
||||
it 'should remove limit order' do
|
||||
o1 = Matching.mock_limit_order(type: :ask, price: '1.0'.to_d)
|
||||
o2 = Matching.mock_limit_order(type: :ask, price: '1.0'.to_d)
|
||||
subject.add o1
|
||||
subject.add o2
|
||||
subject.remove o1.dup # dup so it's not the same object, but has same id
|
||||
|
||||
expect(subject.limit_orders.values.first.size).to eq 1
|
||||
end
|
||||
|
||||
it 'should remove price level if its only limit order removed' do
|
||||
order = Matching.mock_limit_order(type: :ask)
|
||||
subject.add order
|
||||
subject.remove order.dup
|
||||
expect(subject.limit_orders).to be_empty
|
||||
end
|
||||
|
||||
it 'should return nil if order is not found' do
|
||||
order = Matching.mock_limit_order(type: :ask)
|
||||
expect(subject.remove(order)).to be_nil
|
||||
end
|
||||
|
||||
it 'should return order in book' do
|
||||
o1 = Matching.mock_limit_order(type: :ask, price: '1.0'.to_d)
|
||||
o2 = o1.dup
|
||||
o1.instance_variable_set(:@volume, '12345'.to_d)
|
||||
subject.add o1
|
||||
o = subject.remove o2
|
||||
expect(o.volume).to eq '12345'.to_d
|
||||
end
|
||||
end
|
||||
|
||||
context '#best_limit_price' do
|
||||
it 'should return highest bid price' do
|
||||
book = Matching::OrderBook.new('btcusd', :bid)
|
||||
o1 = Matching.mock_limit_order(type: :bid, price: '1.0'.to_d)
|
||||
o2 = Matching.mock_limit_order(type: :bid, price: '2.0'.to_d)
|
||||
book.add o1
|
||||
book.add o2
|
||||
|
||||
expect(book.best_limit_price).to eq o2.price
|
||||
end
|
||||
|
||||
it 'should return lowest ask price' do
|
||||
book = Matching::OrderBook.new('btcusd', :ask)
|
||||
o1 = Matching.mock_limit_order(type: :ask, price: '1.0'.to_d)
|
||||
o2 = Matching.mock_limit_order(type: :ask, price: '2.0'.to_d)
|
||||
book.add o1
|
||||
book.add o2
|
||||
|
||||
expect(book.best_limit_price).to eq o1.price
|
||||
end
|
||||
|
||||
it 'should return nil if there\'s no limit order' do
|
||||
book = Matching::OrderBook.new('btcusd', :ask)
|
||||
expect(book.best_limit_price).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context '#top' do
|
||||
it 'should return nil for empty book' do
|
||||
book = Matching::OrderBook.new('btcusd', :ask)
|
||||
expect(book.top).to be_nil
|
||||
end
|
||||
|
||||
it 'should find ask order with lowest price' do
|
||||
book = Matching::OrderBook.new('btcusd', :ask)
|
||||
o1 = Matching.mock_limit_order(type: :ask, price: '1.0'.to_d)
|
||||
o2 = Matching.mock_limit_order(type: :ask, price: '2.0'.to_d)
|
||||
book.add o1
|
||||
book.add o2
|
||||
|
||||
expect(book.top).to eq o1
|
||||
end
|
||||
|
||||
it 'should find bid order with highest price' do
|
||||
book = Matching::OrderBook.new('btcusd', :bid)
|
||||
o1 = Matching.mock_limit_order(type: :bid, price: '1.0'.to_d)
|
||||
o2 = Matching.mock_limit_order(type: :bid, price: '2.0'.to_d)
|
||||
book.add o1
|
||||
book.add o2
|
||||
|
||||
expect(book.top).to eq o2
|
||||
end
|
||||
|
||||
it 'should favor earlier order if orders have same price' do
|
||||
book = Matching::OrderBook.new('btcusd', :ask)
|
||||
o1 = Matching.mock_limit_order(type: :ask, price: '1.0'.to_d)
|
||||
o2 = Matching.mock_limit_order(type: :ask, price: '1.0'.to_d)
|
||||
book.add o1
|
||||
book.add o2
|
||||
|
||||
expect(book.top).to eq o1
|
||||
end
|
||||
end
|
||||
|
||||
context '#fill_top' do
|
||||
subject { Matching::OrderBook.new('btcusd', :ask) }
|
||||
|
||||
it 'should raise error if there is no top order' do
|
||||
expect do
|
||||
subject.fill_top('1.0'.to_d, '1.0'.to_d, '1.0'.to_d)
|
||||
end.to raise_error(RuntimeError, 'No top order in empty book.')
|
||||
end
|
||||
|
||||
it 'should remove the price level if top order is the only order in level' do
|
||||
subject.add Matching.mock_limit_order(type: :ask, volume: '1.0'.to_d)
|
||||
subject.fill_top '1.0'.to_d, '1.0'.to_d, '1.0'.to_d
|
||||
expect(subject.limit_orders).to be_empty
|
||||
end
|
||||
|
||||
it 'should remove order from level' do
|
||||
subject.add Matching.mock_limit_order(type: :ask, volume: '1.0'.to_d)
|
||||
subject.add Matching.mock_limit_order(type: :ask, volume: '1.0'.to_d)
|
||||
subject.fill_top '1.0'.to_d, '1.0'.to_d, '1.0'.to_d
|
||||
expect(subject.limit_orders.values.first.size).to eq 1
|
||||
end
|
||||
|
||||
it 'should fill top order with volume' do
|
||||
subject.add Matching.mock_limit_order(type: :ask, volume: '2.0'.to_d)
|
||||
subject.fill_top '1.0'.to_d, '0.5'.to_d, '0.5'.to_d
|
||||
expect(subject.top.volume).to eq '1.5'.to_d
|
||||
end
|
||||
end
|
||||
|
||||
context 'on_change callback provided' do
|
||||
let(:callback) { Object.new }
|
||||
subject { Matching::OrderBook.new('btcusd', :ask, on_change: callback) }
|
||||
|
||||
it 'should notify add limit order' do
|
||||
order = Matching.mock_limit_order(type: :ask)
|
||||
callback.expects(:call).with('btcusd', :ask, order.price, order.volume)
|
||||
subject.add order
|
||||
end
|
||||
|
||||
it 'should notify remove limit order' do
|
||||
o1 = Matching.mock_limit_order(type: :ask, price: '1.0'.to_d)
|
||||
o2 = Matching.mock_limit_order(type: :ask, price: '1.0'.to_d)
|
||||
|
||||
callback.expects(:call).with('btcusd', :ask, '1.0'.to_d, o1.volume)
|
||||
callback.expects(:call).with('btcusd', :ask, '1.0'.to_d, o2.volume + o1.volume)
|
||||
callback.expects(:call).with('btcusd', :ask, '1.0'.to_d, o2.volume)
|
||||
|
||||
subject.add o1
|
||||
subject.add o2
|
||||
subject.remove o1.dup # dup so it's not the same object, but has same id
|
||||
|
||||
expect(subject.limit_orders.values.first.size).to eq 1
|
||||
end
|
||||
|
||||
it 'should notify fill top order with volume' do
|
||||
o1 = Matching.mock_limit_order(type: :ask, volume: '2.0'.to_d)
|
||||
callback.expects(:call).with('btcusd', :ask, o1.price, '2.0'.to_d)
|
||||
callback.expects(:call).with('btcusd', :ask, o1.price, '1.5'.to_d)
|
||||
subject.add(o1)
|
||||
subject.fill_top(o1.price, '0.5'.to_d, '0.5'.to_d)
|
||||
end
|
||||
end
|
||||
end
|
||||
25
spec/trading/matching/price_level_spec.rb
Normal file
25
spec/trading/matching/price_level_spec.rb
Normal file
@@ -0,0 +1,25 @@
|
||||
# encoding: UTF-8
|
||||
# frozen_string_literal: true
|
||||
|
||||
describe Matching::PriceLevel do
|
||||
subject { Matching::PriceLevel.new('1.0'.to_d) }
|
||||
let(:o1) { Matching.mock_limit_order(type: :ask) }
|
||||
let(:o2) { Matching.mock_limit_order(type: :ask) }
|
||||
let(:o3) { Matching.mock_limit_order(type: :ask) }
|
||||
|
||||
before do
|
||||
subject.add o1
|
||||
subject.add o2
|
||||
subject.add o3
|
||||
end
|
||||
|
||||
it 'should remove order' do
|
||||
subject.remove o2
|
||||
expect(subject.orders).to eq [o1, o3]
|
||||
end
|
||||
|
||||
it 'should find order by id' do
|
||||
expect(subject.find(o1.id)).to eq o1
|
||||
expect(subject.find(o2.id)).to eq o2
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user