From 489e2ebade84b8607d7e8a73368d5b0fe94810b5 Mon Sep 17 00:00:00 2001 From: Ryan O'Hara-Reid Date: Fri, 25 Mar 2022 11:38:56 +1100 Subject: [PATCH] orderbook: export NewDepth(), add methods to orderbook.Unsafe type (#907) * orderbook: export NewDepth function, return unsafe pointer * orderbook: Add unsafe methods and liquidity checks * Update exchanges/orderbook/unsafe.go Co-authored-by: Scott * orderbook: addr nits * orderbook: update comments * Update exchanges/orderbook/unsafe.go Co-authored-by: Adrian Gallagher * Update exchanges/orderbook/unsafe.go Co-authored-by: Adrian Gallagher Co-authored-by: Scott Co-authored-by: Adrian Gallagher --- exchanges/orderbook/depth.go | 2 +- exchanges/orderbook/depth_test.go | 22 +-- exchanges/orderbook/orderbook.go | 4 +- exchanges/orderbook/unsafe.go | 108 ++++++++++++++- exchanges/orderbook/unsafe_test.go | 211 ++++++++++++++++++++++++++++- 5 files changed, 330 insertions(+), 17 deletions(-) diff --git a/exchanges/orderbook/depth.go b/exchanges/orderbook/depth.go index fc1265a1..5acfcb7a 100644 --- a/exchanges/orderbook/depth.go +++ b/exchanges/orderbook/depth.go @@ -28,7 +28,7 @@ type Depth struct { } // NewDepth returns a new depth item -func newDepth(id uuid.UUID) *Depth { +func NewDepth(id uuid.UUID) *Depth { return &Depth{ stack: newStack(), id: id, diff --git a/exchanges/orderbook/depth_test.go b/exchanges/orderbook/depth_test.go index 1c2e8371..6650929a 100644 --- a/exchanges/orderbook/depth_test.go +++ b/exchanges/orderbook/depth_test.go @@ -14,7 +14,7 @@ import ( var id, _ = uuid.NewV4() func TestGetLength(t *testing.T) { - d := newDepth(id) + d := NewDepth(id) if d.GetAskLength() != 0 { t.Errorf("expected len %v, but received %v", 0, d.GetAskLength()) } @@ -25,7 +25,7 @@ func TestGetLength(t *testing.T) { t.Errorf("expected len %v, but received %v", 1, d.GetAskLength()) } - d = newDepth(id) + d = NewDepth(id) if d.GetBidLength() != 0 { t.Errorf("expected len %v, but received %v", 0, d.GetBidLength()) } @@ -38,7 +38,7 @@ func TestGetLength(t *testing.T) { } func TestRetrieve(t *testing.T) { - d := newDepth(id) + d := NewDepth(id) d.asks.load([]Item{{Price: 1337}}, d.stack) d.bids.load([]Item{{Price: 1337}}, d.stack) d.options = options{ @@ -75,7 +75,7 @@ func TestRetrieve(t *testing.T) { } func TestTotalAmounts(t *testing.T) { - d := newDepth(id) + d := NewDepth(id) liquidity, value := d.TotalBidAmounts() if liquidity != 0 || value != 0 { @@ -118,7 +118,7 @@ func TestTotalAmounts(t *testing.T) { } func TestLoadSnapshot(t *testing.T) { - d := newDepth(id) + d := NewDepth(id) d.LoadSnapshot(Items{{Price: 1337, Amount: 1}}, Items{{Price: 1337, Amount: 10}}, 0, time.Time{}, false) if d.Retrieve().Asks[0].Price != 1337 || d.Retrieve().Bids[0].Price != 1337 { t.Fatal("not set") @@ -126,7 +126,7 @@ func TestLoadSnapshot(t *testing.T) { } func TestFlush(t *testing.T) { - d := newDepth(id) + d := NewDepth(id) d.LoadSnapshot(Items{{Price: 1337, Amount: 1}}, Items{{Price: 1337, Amount: 10}}, 0, time.Time{}, false) d.Flush() if len(d.Retrieve().Asks) != 0 || len(d.Retrieve().Bids) != 0 { @@ -140,7 +140,7 @@ func TestFlush(t *testing.T) { } func TestUpdateBidAskByPrice(t *testing.T) { - d := newDepth(id) + d := NewDepth(id) d.LoadSnapshot(Items{{Price: 1337, Amount: 1, ID: 1}}, Items{{Price: 1337, Amount: 10, ID: 2}}, 0, time.Time{}, false) // empty @@ -157,7 +157,7 @@ func TestUpdateBidAskByPrice(t *testing.T) { } func TestDeleteBidAskByID(t *testing.T) { - d := newDepth(id) + d := NewDepth(id) d.LoadSnapshot(Items{{Price: 1337, Amount: 1, ID: 1}}, Items{{Price: 1337, Amount: 10, ID: 2}}, 0, time.Time{}, false) err := d.DeleteBidAskByID(Items{{Price: 1337, Amount: 2, ID: 1}}, Items{{Price: 1337, Amount: 2, ID: 2}}, false, 0, time.Time{}) if err != nil { @@ -184,7 +184,7 @@ func TestDeleteBidAskByID(t *testing.T) { } func TestUpdateBidAskByID(t *testing.T) { - d := newDepth(id) + d := NewDepth(id) d.LoadSnapshot(Items{{Price: 1337, Amount: 1, ID: 1}}, Items{{Price: 1337, Amount: 10, ID: 2}}, 0, time.Time{}, false) err := d.UpdateBidAskByID(Items{{Price: 1337, Amount: 2, ID: 1}}, Items{{Price: 1337, Amount: 2, ID: 2}}, 0, time.Time{}) if err != nil { @@ -207,7 +207,7 @@ func TestUpdateBidAskByID(t *testing.T) { } func TestInsertBidAskByID(t *testing.T) { - d := newDepth(id) + d := NewDepth(id) d.LoadSnapshot(Items{{Price: 1337, Amount: 1, ID: 1}}, Items{{Price: 1337, Amount: 10, ID: 2}}, 0, time.Time{}, false) err := d.InsertBidAskByID(Items{{Price: 1338, Amount: 2, ID: 3}}, Items{{Price: 1336, Amount: 2, ID: 4}}, 0, time.Time{}) if err != nil { @@ -219,7 +219,7 @@ func TestInsertBidAskByID(t *testing.T) { } func TestUpdateInsertByID(t *testing.T) { - d := newDepth(id) + d := NewDepth(id) d.LoadSnapshot(Items{{Price: 1337, Amount: 1, ID: 1}}, Items{{Price: 1337, Amount: 10, ID: 2}}, 0, time.Time{}, false) err := d.UpdateInsertByID(Items{{Price: 1338, Amount: 0, ID: 3}}, Items{{Price: 1336, Amount: 2, ID: 4}}, 0, time.Time{}) diff --git a/exchanges/orderbook/orderbook.go b/exchanges/orderbook/orderbook.go index cc68e2d7..79eac8f7 100644 --- a/exchanges/orderbook/orderbook.go +++ b/exchanges/orderbook/orderbook.go @@ -75,7 +75,7 @@ func (s *Service) Update(b *Base) error { book, ok := m3[b.Pair.Quote.Item] if !ok { - book = newDepth(m1.ID) + book = NewDepth(m1.ID) book.AssignOptions(b) m3[b.Pair.Quote.Item] = book } @@ -122,7 +122,7 @@ func (s *Service) DeployDepth(exchange string, p currency.Pair, a asset.Item) (* } book, ok := m3[p.Quote.Item] if !ok { - book = newDepth(m1.ID) + book = NewDepth(m1.ID) book.exchange = exchange book.pair = p book.asset = a diff --git a/exchanges/orderbook/unsafe.go b/exchanges/orderbook/unsafe.go index 1b2850cb..0b0a4c3a 100644 --- a/exchanges/orderbook/unsafe.go +++ b/exchanges/orderbook/unsafe.go @@ -1,12 +1,16 @@ package orderbook import ( + "errors" + "fmt" "sync" "time" "github.com/thrasher-corp/gocryptotrader/exchanges/alert" ) +var errNoLiquidity = errors.New("no liquidity") + // Unsafe is an exported linked list reference to the current bid/ask heads and // a reference to the underlying depth mutex. This allows for the exposure of // the internal list to an external strategy or subsystem. The bid and ask @@ -52,8 +56,8 @@ func (src *Unsafe) UnlockWith(dst sync.Locker) { } // GetUnsafe returns an unsafe orderbook with pointers to the linked list heads. -func (d *Depth) GetUnsafe() Unsafe { - return Unsafe{ +func (d *Depth) GetUnsafe() *Unsafe { + return &Unsafe{ BidHead: &d.bids.linkedList.head, AskHead: &d.asks.linkedList.head, m: &d.m, @@ -62,3 +66,103 @@ func (d *Depth) GetUnsafe() Unsafe { LastUpdated: &d.options.lastUpdated, } } + +// CheckBidLiquidity determines if the liquidity is sufficient for usage +func (src *Unsafe) CheckBidLiquidity() error { + _, err := src.GetBidLiquidity() + return err +} + +// CheckAskLiquidity determines if the liquidity is sufficient for usage +func (src *Unsafe) CheckAskLiquidity() error { + _, err := src.GetAskLiquidity() + return err +} + +// GetBestBid returns the top bid price +func (src *Unsafe) GetBestBid() (float64, error) { + bid, err := src.GetBidLiquidity() + if err != nil { + return 0, fmt.Errorf("get orderbook best bid price %w", err) + } + return bid.Value.Price, nil +} + +// GetBestAsk returns the top ask price +func (src *Unsafe) GetBestAsk() (float64, error) { + ask, err := src.GetAskLiquidity() + if err != nil { + return 0, fmt.Errorf("get orderbook best ask price %w", err) + } + return ask.Value.Price, nil +} + +// GetBidLiquidity gets the head node for the bid liquidity +func (src *Unsafe) GetBidLiquidity() (*Node, error) { + n := *src.BidHead + if n == nil { + return nil, fmt.Errorf("bid %w", errNoLiquidity) + } + return n, nil +} + +// GetAskLiquidity gets the head node for the ask liquidity +func (src *Unsafe) GetAskLiquidity() (*Node, error) { + n := *src.AskHead + if n == nil { + return nil, fmt.Errorf("ask %w", errNoLiquidity) + } + return n, nil +} + +// GetLiquidity checks and returns nodes to the top bids and asks +func (src *Unsafe) GetLiquidity() (ask, bid *Node, err error) { + bid, err = src.GetBidLiquidity() + if err != nil { + return nil, nil, err + } + ask, err = src.GetAskLiquidity() + if err != nil { + return nil, nil, err + } + return ask, bid, nil +} + +// GetMidPrice returns the average between the top bid and top ask. +func (src *Unsafe) GetMidPrice() (float64, error) { + ask, bid, err := src.GetLiquidity() + if err != nil { + return 0, fmt.Errorf("get orderbook mid price %w", err) + } + return (bid.Value.Price + ask.Value.Price) / 2, nil +} + +// GetSpread returns the spread between the top bid and top asks. +func (src *Unsafe) GetSpread() (float64, error) { + ask, bid, err := src.GetLiquidity() + if err != nil { + return 0, fmt.Errorf("get orderbook price spread %w", err) + } + return ask.Value.Price - bid.Value.Price, nil +} + +// GetImbalance returns difference between the top bid and top ask amounts +// divided by its sum. +func (src *Unsafe) GetImbalance() (float64, error) { + ask, bid, err := src.GetLiquidity() + if err != nil { + return 0, fmt.Errorf("get orderbook imbalance %w", err) + } + top := bid.Value.Amount - ask.Value.Amount + bottom := bid.Value.Amount + ask.Value.Amount + if bottom == 0 { + return 0, errNoLiquidity + } + return top / bottom, nil +} + +// IsStreaming returns if the orderbook is updated by a streaming protocol and +// is most likely more up to date than that of a REST protocol update. +func (src *Unsafe) IsStreaming() bool { + return !*src.UpdatedViaREST +} diff --git a/exchanges/orderbook/unsafe_test.go b/exchanges/orderbook/unsafe_test.go index 54cefeb4..7a43ebef 100644 --- a/exchanges/orderbook/unsafe_test.go +++ b/exchanges/orderbook/unsafe_test.go @@ -1,7 +1,9 @@ package orderbook import ( + "errors" "testing" + "time" "github.com/gofrs/uuid" ) @@ -14,7 +16,8 @@ func (e *externalBook) Lock() {} func (e *externalBook) Unlock() {} func TestUnsafe(t *testing.T) { - d := newDepth(unsafeID) + t.Parallel() + d := NewDepth(unsafeID) ob := d.GetUnsafe() if ob.AskHead == nil || ob.BidHead == nil || ob.m == nil { t.Fatal("these items should not be nil") @@ -26,3 +29,209 @@ func TestUnsafe(t *testing.T) { ob.LockWith(ob2) ob.UnlockWith(ob2) } + +func TestGetLiquidity(t *testing.T) { + t.Parallel() + d := NewDepth(unsafeID) + unsafe := d.GetUnsafe() + _, _, err := unsafe.GetLiquidity() + if !errors.Is(err, errNoLiquidity) { + t.Fatalf("received: '%v' but expected: '%v'", err, errNoLiquidity) + } + + d.LoadSnapshot([]Item{{Price: 2}}, nil, 0, time.Time{}, false) + _, _, err = unsafe.GetLiquidity() + if !errors.Is(err, errNoLiquidity) { + t.Fatalf("received: '%v' but expected: '%v'", err, errNoLiquidity) + } + + d.LoadSnapshot([]Item{{Price: 2}}, []Item{{Price: 2}}, 0, time.Time{}, false) + aN, bN, err := unsafe.GetLiquidity() + if !errors.Is(err, nil) { + t.Fatalf("received: '%v' but expected: '%v'", err, nil) + } + + if aN == nil { + t.Fatal("unexpected value") + } + + if bN == nil { + t.Fatal("unexpected value") + } +} + +func TestCheckBidLiquidity(t *testing.T) { + t.Parallel() + d := NewDepth(unsafeID) + unsafe := d.GetUnsafe() + err := unsafe.CheckBidLiquidity() + if !errors.Is(err, errNoLiquidity) { + t.Fatalf("received: '%v' but expected: '%v'", err, errNoLiquidity) + } + + d.LoadSnapshot([]Item{{Price: 2}}, nil, 0, time.Time{}, false) + err = unsafe.CheckBidLiquidity() + if !errors.Is(err, nil) { + t.Fatalf("received: '%v' but expected: '%v'", err, nil) + } +} + +func TestCheckAskLiquidity(t *testing.T) { + t.Parallel() + d := NewDepth(unsafeID) + unsafe := d.GetUnsafe() + err := unsafe.CheckAskLiquidity() + if !errors.Is(err, errNoLiquidity) { + t.Fatalf("received: '%v' but expected: '%v'", err, errNoLiquidity) + } + + d.LoadSnapshot(nil, []Item{{Price: 2}}, 0, time.Time{}, false) + err = unsafe.CheckAskLiquidity() + if !errors.Is(err, nil) { + t.Fatalf("received: '%v' but expected: '%v'", err, nil) + } +} + +func TestGetBestBid(t *testing.T) { + t.Parallel() + d := NewDepth(unsafeID) + unsafe := d.GetUnsafe() + if _, err := unsafe.GetBestBid(); !errors.Is(err, errNoLiquidity) { + t.Fatalf("received: '%v' but expected: '%v'", err, errNoLiquidity) + } + + d.LoadSnapshot([]Item{{Price: 2}}, nil, 0, time.Time{}, false) + bestBid, err := unsafe.GetBestBid() + if !errors.Is(err, nil) { + t.Fatalf("received: '%v' but expected: '%v'", err, nil) + } + + if bestBid != 2 { + t.Fatal("unexpected value") + } +} + +func TestGetBestAsk(t *testing.T) { + t.Parallel() + d := NewDepth(unsafeID) + unsafe := d.GetUnsafe() + if _, err := unsafe.GetBestAsk(); !errors.Is(err, errNoLiquidity) { + t.Fatalf("received: '%v' but expected: '%v'", err, errNoLiquidity) + } + + d.LoadSnapshot(nil, []Item{{Price: 2}}, 0, time.Time{}, false) + bestAsk, err := unsafe.GetBestAsk() + if !errors.Is(err, nil) { + t.Fatalf("received: '%v' but expected: '%v'", err, nil) + } + + if bestAsk != 2 { + t.Fatal("unexpected value") + } +} + +func TestGetMidPrice(t *testing.T) { + t.Parallel() + d := NewDepth(unsafeID) + unsafe := d.GetUnsafe() + if _, err := unsafe.GetMidPrice(); !errors.Is(err, errNoLiquidity) { + t.Fatalf("received: '%v' but expected: '%v'", err, errNoLiquidity) + } + + d.LoadSnapshot([]Item{{Price: 1}}, []Item{{Price: 2}}, 0, time.Time{}, false) + mid, err := unsafe.GetMidPrice() + if !errors.Is(err, nil) { + t.Fatalf("received: '%v' but expected: '%v'", err, nil) + } + + if mid != 1.5 { + t.Fatal("unexpected value") + } +} + +func TestGetSpread(t *testing.T) { + t.Parallel() + d := NewDepth(unsafeID) + unsafe := d.GetUnsafe() + if _, err := unsafe.GetSpread(); !errors.Is(err, errNoLiquidity) { + t.Fatalf("received: '%v' but expected: '%v'", err, errNoLiquidity) + } + + d.LoadSnapshot([]Item{{Price: 1}}, []Item{{Price: 2}}, 0, time.Time{}, false) + spread, err := unsafe.GetSpread() + if !errors.Is(err, nil) { + t.Fatalf("received: '%v' but expected: '%v'", err, nil) + } + + if spread != 1 { + t.Fatal("unexpected value") + } +} + +func TestGetImbalance(t *testing.T) { + t.Parallel() + d := NewDepth(unsafeID) + unsafe := d.GetUnsafe() + _, err := unsafe.GetImbalance() + if !errors.Is(err, errNoLiquidity) { + t.Fatalf("received: '%v' but expected: '%v'", err, errNoLiquidity) + } + + // unlikely event zero amounts + d.LoadSnapshot([]Item{{Price: 1, Amount: 0}}, []Item{{Price: 2, Amount: 0}}, 0, time.Time{}, false) + _, err = unsafe.GetImbalance() + if !errors.Is(err, errNoLiquidity) { + t.Fatalf("received: '%v' but expected: '%v'", err, errNoLiquidity) + } + + // balance skewed to asks + d.LoadSnapshot([]Item{{Price: 1, Amount: 1}}, []Item{{Price: 2, Amount: 1000}}, 0, time.Time{}, false) + imbalance, err := unsafe.GetImbalance() + if !errors.Is(err, nil) { + t.Fatalf("received: '%v' but expected: '%v'", err, nil) + } + + if imbalance != -0.998001998001998 { + t.Fatal("unexpected value") + } + + // balance skewed to bids + d.LoadSnapshot([]Item{{Price: 1, Amount: 1000}}, []Item{{Price: 2, Amount: 1}}, 0, time.Time{}, false) + imbalance, err = unsafe.GetImbalance() + if !errors.Is(err, nil) { + t.Fatalf("received: '%v' but expected: '%v'", err, nil) + } + + if imbalance != 0.998001998001998 { + t.Fatal("unexpected value") + } + + // in balance + d.LoadSnapshot([]Item{{Price: 1, Amount: 1}}, []Item{{Price: 2, Amount: 1}}, 0, time.Time{}, false) + imbalance, err = unsafe.GetImbalance() + if !errors.Is(err, nil) { + t.Fatalf("received: '%v' but expected: '%v'", err, nil) + } + + if imbalance != 0 { + t.Fatal("unexpected value") + } +} + +func TestIsStreaming(t *testing.T) { + d := NewDepth(unsafeID) + unsafe := d.GetUnsafe() + if !unsafe.IsStreaming() { + t.Fatalf("received: '%v' but expected: '%v'", unsafe.IsStreaming(), true) + } + + d.LoadSnapshot([]Item{{Price: 1, Amount: 1}}, []Item{{Price: 2, Amount: 1}}, 0, time.Time{}, true) + if unsafe.IsStreaming() { + t.Fatalf("received: '%v' but expected: '%v'", unsafe.IsStreaming(), false) + } + + d.LoadSnapshot([]Item{{Price: 1, Amount: 1}}, []Item{{Price: 2, Amount: 1}}, 0, time.Time{}, false) + if !unsafe.IsStreaming() { + t.Fatalf("received: '%v' but expected: '%v'", unsafe.IsStreaming(), true) + } +}