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

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

View 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

View 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

View 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

View 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

View 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

View 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