depth: Add methods to derive liquidity allowances on orderbooks by volume and slippage (#962)

* depth: methods to derive liquidity impact details

* depth: fix comments on link list methods

* depth: fix whoopsie

* Update exchanges/orderbook/linked_list_test.go

Co-authored-by: Scott <gloriousCode@users.noreply.github.com>

* glorious: nits

* orderbook: standardise methods to GCT math package

* averagePrice: implementation (WIP)

* ll: hmmmmmm

* continued

* orderbook: reworked functions

* WIP

* orderbook: add tests link up with RPC

* orderbook: refined calculations, add tests (WIP)

* orderbook: add tests finalise/verify remove state until next PR if needed

* rpcserver/orderbook: remove redundant type and change wording

* linter: fix

* Update exchanges/orderbook/linked_list.go

Co-authored-by: Scott <gloriousCode@users.noreply.github.com>

* gctcli: noobed it up

* orderbook: work work work (yesterday)

* depth: WIP and testing

* orderbook: improve calculations for bids traversal

* orderbook: adjust tests

* orderbook: linters/nits

* orderbook: fix error returns and add asset to whalebomb

* orderbook: drop error when full book is potentially consumed

* Update cmd/gctcli/orderbook.go

Co-authored-by: Scott <gloriousCode@users.noreply.github.com>

* orderbook: Add tests and nits

* glorious: nits

* backtester: handle new errors

* grpc: linter

* orderbook: remove pesky t.Log()s

* glorious: nits (yesterday)

* depth/gctcli: Add in purchase requirements into orderbook movement, will also standardize in next commit after tests are fixed.

* orderbook: standardize and overhaul

* orderbook: update comments, update tests

* rpcserver: add average ordercost and amounts

* depth: add spread and imbalance methods

* linter: fix

* glorious: nits

* orderbook: don't purge price, rn test.

* glorious: codes nits

* linter: fix

* Update exchanges/orderbook/linked_list.go

Co-authored-by: Adrian Gallagher <adrian.gallagher@thrasher.io>

* Update exchanges/orderbook/linked_list.go

Co-authored-by: Adrian Gallagher <adrian.gallagher@thrasher.io>

* Update exchanges/orderbook/linked_list.go

Co-authored-by: Adrian Gallagher <adrian.gallagher@thrasher.io>

* Update exchanges/orderbook/linked_list.go

Co-authored-by: Adrian Gallagher <adrian.gallagher@thrasher.io>

* Update exchanges/orderbook/linked_list.go

Co-authored-by: Adrian Gallagher <adrian.gallagher@thrasher.io>

* thrasher: nits

Co-authored-by: Scott <gloriousCode@users.noreply.github.com>
Co-authored-by: Ryan O'Hara-Reid <ryan.oharareid@thrasher.io>
Co-authored-by: Adrian Gallagher <adrian.gallagher@thrasher.io>
This commit is contained in:
Ryan O'Hara-Reid
2022-10-14 16:43:37 +11:00
committed by GitHub
parent 74d0cc323d
commit 9acbdbf203
21 changed files with 9150 additions and 3037 deletions

View File

@@ -3,10 +3,16 @@ package orderbook
import (
"errors"
"fmt"
"sort"
math "github.com/thrasher-corp/gocryptotrader/common/math"
"github.com/thrasher-corp/gocryptotrader/log"
"github.com/thrasher-corp/gocryptotrader/currency"
)
const fullLiquidityUsageWarning = "[WARNING]: Full liquidity exhausted."
var (
errPriceTargetInvalid = errors.New("price target is invalid")
errCannotShiftPrice = errors.New("cannot shift price")
)
// WhaleBombResult returns the whale bomb result
@@ -15,214 +21,240 @@ type WhaleBombResult struct {
MinimumPrice float64
MaximumPrice float64
PercentageGainOrLoss float64
Orders orderSummary
Orders Items
Status string
}
// WhaleBomb finds the amount required to target a price
func (b *Base) WhaleBomb(priceTarget float64, buy bool) (*WhaleBombResult, error) {
if priceTarget < 0 {
return nil, errors.New("price target is invalid")
return nil, errPriceTargetInvalid
}
if buy {
a, orders := b.findAmount(priceTarget, true)
min, max := orders.MinimumPrice(false), orders.MaximumPrice(true)
var err error
if max < priceTarget {
err = errors.New("unable to hit price target due to insufficient orderbook items")
}
status := fmt.Sprintf("Buying %.2f %v worth of %v will send the price from %v to %v [%.2f%%] and take %v orders.",
a, b.Pair.Quote.String(), b.Pair.Base.String(), min, max,
math.CalculatePercentageGainOrLoss(max, min), len(orders))
return &WhaleBombResult{
Amount: a,
Orders: orders,
MinimumPrice: min,
MaximumPrice: max,
Status: status,
}, err
action, err := b.findAmount(priceTarget, buy)
if err != nil {
return nil, err
}
a, orders := b.findAmount(priceTarget, false)
min, max := orders.MinimumPrice(false), orders.MaximumPrice(true)
var err error
if min > priceTarget {
err = errors.New("unable to hit price target due to insufficient orderbook items")
var warning string
if action.FullLiquidityUsed {
warning = fullLiquidityUsageWarning
}
status := fmt.Sprintf("Selling %.2f %v worth of %v will send the price from %v to %v [%.2f%%] and take %v orders.",
a, b.Pair.Base.String(), b.Pair.Quote.String(), max, min,
math.CalculatePercentageGainOrLoss(min, max), len(orders))
var status string
var percent, min, max, amount float64
if buy {
min = action.ReferencePrice
max = action.TranchePositionPrice
amount = action.QuoteAmount
percent = math.CalculatePercentageGainOrLoss(action.TranchePositionPrice, action.ReferencePrice)
status = fmt.Sprintf("Buying using %.2f %s worth of %s will send the price from %v to %v [%.2f%%] and impact %d price tranche(s). %s",
amount, b.Pair.Quote, b.Pair.Base, min, max,
percent, len(action.Tranches), warning)
} else {
min = action.TranchePositionPrice
max = action.ReferencePrice
amount = action.BaseAmount
percent = math.CalculatePercentageGainOrLoss(action.TranchePositionPrice, action.ReferencePrice)
status = fmt.Sprintf("Selling using %.2f %s worth of %s will send the price from %v to %v [%.2f%%] and impact %d price tranche(s). %s",
amount, b.Pair.Base, b.Pair.Quote, max, min,
percent, len(action.Tranches), warning)
}
return &WhaleBombResult{
Amount: a,
Orders: orders,
MinimumPrice: min,
MaximumPrice: max,
Status: status,
Amount: amount,
Orders: action.Tranches,
MinimumPrice: min,
MaximumPrice: max,
Status: status,
PercentageGainOrLoss: percent,
}, err
}
// OrderSimulationResult returns the order simulation result
type OrderSimulationResult WhaleBombResult
// SimulateOrder simulates an order
func (b *Base) SimulateOrder(amount float64, buy bool) *OrderSimulationResult {
func (b *Base) SimulateOrder(amount float64, buy bool) (*WhaleBombResult, error) {
var direction string
var action *DeploymentAction
var soldAmount, boughtAmount, minimumPrice, maximumPrice float64
var sold, bought currency.Code
var err error
if buy {
orders, amt := b.buy(amount)
min, max := orders.MinimumPrice(false), orders.MaximumPrice(true)
pct := math.CalculatePercentageGainOrLoss(max, min)
status := fmt.Sprintf("Buying %.2f %v worth of %v will send the price from %v to %v [%.2f%%] and take %v orders.",
amount, b.Pair.Quote.String(), b.Pair.Base.String(), min, max,
pct, len(orders))
return &OrderSimulationResult{
Orders: orders,
Amount: amt,
MinimumPrice: min,
MaximumPrice: max,
PercentageGainOrLoss: pct,
Status: status,
direction = "Buying"
action, err = b.buy(amount)
if err != nil {
return nil, err
}
soldAmount = action.QuoteAmount
boughtAmount = action.BaseAmount
maximumPrice = action.TranchePositionPrice
minimumPrice = action.ReferencePrice
sold = b.Pair.Quote
bought = b.Pair.Base
} else {
direction = "Selling"
action, err = b.sell(amount)
if err != nil {
return nil, err
}
soldAmount = action.BaseAmount
boughtAmount = action.QuoteAmount
minimumPrice = action.TranchePositionPrice
maximumPrice = action.ReferencePrice
sold = b.Pair.Base
bought = b.Pair.Quote
}
orders, amt := b.sell(amount)
min, max := orders.MinimumPrice(false), orders.MaximumPrice(true)
pct := math.CalculatePercentageGainOrLoss(min, max)
status := fmt.Sprintf("Selling %f %v worth of %v will send the price from %v to %v [%.2f%%] and take %v orders.",
amount, b.Pair.Base.String(), b.Pair.Quote.String(), max, min,
pct, len(orders))
return &OrderSimulationResult{
Orders: orders,
Amount: amt,
MinimumPrice: min,
MaximumPrice: max,
var warning string
if action.FullLiquidityUsed {
warning = fullLiquidityUsageWarning
}
pct := math.CalculatePercentageGainOrLoss(action.TranchePositionPrice, action.ReferencePrice)
status := fmt.Sprintf("%s using %f %v worth of %v will send the price from %v to %v [%.2f%%] and impact %v price tranche(s). %s",
direction, soldAmount, sold, bought, action.ReferencePrice,
action.TranchePositionPrice, pct, len(action.Tranches), warning)
return &WhaleBombResult{
Orders: action.Tranches,
Amount: boughtAmount,
MinimumPrice: minimumPrice,
MaximumPrice: maximumPrice,
PercentageGainOrLoss: pct,
Status: status,
}
}, nil
}
type orderSummary []Item
func (o orderSummary) Print() {
for x := range o {
log.Debugf(log.OrderBook, "Order: Price: %f Amount: %f", o[x].Price, o[x].Amount)
}
}
func (o orderSummary) MinimumPrice(reverse bool) float64 {
if len(o) != 0 {
sortOrdersByPrice(&o, reverse)
return o[0].Price
}
return 0
}
func (o orderSummary) MaximumPrice(reverse bool) float64 {
if len(o) != 0 {
sortOrdersByPrice(&o, reverse)
return o[0].Price
}
return 0
}
// ByPrice used for sorting orders by order date
type ByPrice orderSummary
func (b ByPrice) Len() int { return len(b) }
func (b ByPrice) Less(i, j int) bool { return b[i].Price < b[j].Price }
func (b ByPrice) Swap(i, j int) { b[i], b[j] = b[j], b[i] }
// sortOrdersByPrice the caller function to sort orders
func sortOrdersByPrice(o *orderSummary, reverse bool) {
if reverse {
sort.Sort(sort.Reverse(ByPrice(*o)))
} else {
sort.Sort(ByPrice(*o))
}
}
func (b *Base) findAmount(price float64, buy bool) (float64, orderSummary) {
orders := make(orderSummary, 0)
var amt float64
func (b *Base) findAmount(priceTarget float64, buy bool) (*DeploymentAction, error) {
action := DeploymentAction{}
if buy {
if len(b.Asks) == 0 {
return nil, errNoLiquidity
}
action.ReferencePrice = b.Asks[0].Price
if action.ReferencePrice > priceTarget {
return nil, fmt.Errorf("%w to %f as it's below ascending ask prices starting at %f",
errCannotShiftPrice, priceTarget, action.ReferencePrice)
}
for x := range b.Asks {
if b.Asks[x].Price >= price {
amt += b.Asks[x].Price * b.Asks[x].Amount
orders = append(orders, Item{
Price: b.Asks[x].Price,
Amount: b.Asks[x].Amount,
})
return amt, orders
if b.Asks[x].Price >= priceTarget {
action.TranchePositionPrice = b.Asks[x].Price
return &action, nil
}
orders = append(orders, Item{
Price: b.Asks[x].Price,
Amount: b.Asks[x].Amount,
})
amt += b.Asks[x].Price * b.Asks[x].Amount
action.Tranches = append(action.Tranches, b.Asks[x])
action.QuoteAmount += b.Asks[x].Price * b.Asks[x].Amount
action.BaseAmount += b.Asks[x].Amount
}
return amt, orders
action.TranchePositionPrice = b.Asks[len(b.Asks)-1].Price
action.FullLiquidityUsed = true
return &action, nil
}
for x := range b.Bids {
if b.Bids[x].Price <= price {
amt += b.Bids[x].Amount
orders = append(orders, Item{
Price: b.Bids[x].Price,
Amount: b.Bids[x].Amount,
})
break
}
orders = append(orders, Item{
Price: b.Bids[x].Price,
Amount: b.Bids[x].Amount,
})
amt += b.Bids[x].Amount
if len(b.Bids) == 0 {
return nil, errNoLiquidity
}
return amt, orders
action.ReferencePrice = b.Bids[0].Price
if action.ReferencePrice < priceTarget {
return nil, fmt.Errorf("%w to %f as it's above descending bid prices starting at %f",
errCannotShiftPrice, priceTarget, action.ReferencePrice)
}
for x := range b.Bids {
if b.Bids[x].Price <= priceTarget {
action.TranchePositionPrice = b.Bids[x].Price
return &action, nil
}
action.Tranches = append(action.Tranches, b.Bids[x])
action.QuoteAmount += b.Bids[x].Price * b.Bids[x].Amount
action.BaseAmount += b.Bids[x].Amount
}
action.TranchePositionPrice = b.Bids[len(b.Bids)-1].Price
action.FullLiquidityUsed = true
return &action, nil
}
func (b *Base) buy(amount float64) (orders orderSummary, baseAmount float64) {
var processedAmt float64
// DeploymentAction defines deployment information on a liquidity side.
type DeploymentAction struct {
ReferencePrice float64
TranchePositionPrice float64
BaseAmount float64
QuoteAmount float64
Tranches Items
FullLiquidityUsed bool
}
func (b *Base) buy(quote float64) (*DeploymentAction, error) {
if quote <= 0 {
return nil, errQuoteAmountInvalid
}
if len(b.Asks) == 0 {
return nil, errNoLiquidity
}
action := &DeploymentAction{ReferencePrice: b.Asks[0].Price}
for x := range b.Asks {
subtotal := b.Asks[x].Price * b.Asks[x].Amount
if processedAmt+subtotal >= amount {
diff := amount - processedAmt
subAmt := diff / b.Asks[x].Price
orders = append(orders, Item{
action.TranchePositionPrice = b.Asks[x].Price
trancheValue := b.Asks[x].Price * b.Asks[x].Amount
action.QuoteAmount += trancheValue
remaining := quote - trancheValue
if remaining <= 0 {
if remaining == 0 {
if len(b.Asks)-1 > x {
action.TranchePositionPrice = b.Asks[x+1].Price
} else {
action.FullLiquidityUsed = true
}
}
subAmount := quote / b.Asks[x].Price
action.Tranches = append(action.Tranches, Item{
Price: b.Asks[x].Price,
Amount: subAmt,
Amount: subAmount,
})
baseAmount += subAmt
break
action.BaseAmount += subAmount
return action, nil
}
processedAmt += subtotal
baseAmount += b.Asks[x].Amount
orders = append(orders, Item{
Price: b.Asks[x].Price,
Amount: b.Asks[x].Amount,
})
if len(b.Asks)-1 <= x {
action.FullLiquidityUsed = true
}
quote = remaining
action.BaseAmount += b.Asks[x].Amount
action.Tranches = append(action.Tranches, b.Asks[x])
}
return
return action, nil
}
func (b *Base) sell(amount float64) (orders orderSummary, quoteAmount float64) {
var processedAmt float64
for x := range b.Bids {
if processedAmt+b.Bids[x].Amount >= amount {
diff := amount - processedAmt
orders = append(orders, Item{
Price: b.Bids[x].Price,
Amount: diff,
})
quoteAmount += diff * b.Bids[x].Price
break
}
processedAmt += b.Bids[x].Amount
quoteAmount += b.Bids[x].Amount * b.Bids[x].Price
orders = append(orders, Item{
Price: b.Bids[x].Price,
Amount: b.Bids[x].Amount,
})
func (b *Base) sell(base float64) (*DeploymentAction, error) {
if base <= 0 {
return nil, errBaseAmountInvalid
}
return
if len(b.Bids) == 0 {
return nil, errNoLiquidity
}
action := &DeploymentAction{ReferencePrice: b.Bids[0].Price}
for x := range b.Bids {
action.TranchePositionPrice = b.Bids[x].Price
remaining := base - b.Bids[x].Amount
if remaining <= 0 {
if remaining == 0 {
if len(b.Bids)-1 > x {
action.TranchePositionPrice = b.Bids[x+1].Price
} else {
action.FullLiquidityUsed = true
}
}
action.Tranches = append(action.Tranches, Item{
Price: b.Bids[x].Price,
Amount: base,
})
action.BaseAmount += base
action.QuoteAmount += base * b.Bids[x].Price
return action, nil
}
if len(b.Bids)-1 <= x {
action.FullLiquidityUsed = true
}
base = remaining
action.BaseAmount += b.Bids[x].Amount
action.QuoteAmount += b.Bids[x].Amount * b.Bids[x].Price
action.Tranches = append(action.Tranches, b.Bids[x])
}
return action, nil
}
// GetAveragePrice finds the average buy or sell price of a specified amount.
@@ -254,10 +286,9 @@ func (elem Items) FindNominalAmount(amount float64) (aggNominalAmount, remaining
aggNominalAmount += elem[x].Price * remainingAmount
remainingAmount = 0
break
} else {
aggNominalAmount += elem[x].Price * elem[x].Amount
remainingAmount -= elem[x].Amount
}
aggNominalAmount += elem[x].Price * elem[x].Amount
remainingAmount -= elem[x].Amount
}
return aggNominalAmount, remainingAmount
}

View File

@@ -3,6 +3,7 @@ package orderbook
import (
"errors"
"math"
"strings"
"testing"
"github.com/thrasher-corp/gocryptotrader/currency"
@@ -27,69 +28,475 @@ func TestWhaleBomb(t *testing.T) {
t.Parallel()
b := testSetup()
// invalid price amount
_, err := b.WhaleBomb(-1, true)
if err == nil {
t.Error("unexpected result")
if !errors.Is(err, errPriceTargetInvalid) {
t.Fatalf("received: '%v' but expected: '%v'", err, errPriceTargetInvalid)
}
// valid
_, err = b.WhaleBomb(7001, true)
result, err := b.WhaleBomb(7001, true) // <- This price should not be wiped out on the book.
if !errors.Is(err, nil) {
t.Fatalf("received: '%v' but expected: '%v'", err, nil)
}
if result.Amount != 7000 {
t.Fatalf("received: '%v' but expected: '%v'", result.Amount, 7000)
}
if result.MaximumPrice != 7001 {
t.Fatalf("received: '%v' but expected: '%v'", result.MaximumPrice, 7001)
}
if result.MinimumPrice != 7000 {
t.Fatalf("received: '%v' but expected: '%v'", result.MinimumPrice, 7000)
}
if result.PercentageGainOrLoss != 0.014285714285714287 {
t.Fatalf("received: '%v' but expected: '%v'", result.PercentageGainOrLoss, 0.014285714285714287)
}
result, err = b.WhaleBomb(7000.5, true) // <- Slot between prices will lift to next ask tranche
if !errors.Is(err, nil) {
t.Errorf("received '%v', expected '%v'", err, nil)
}
// invalid
_, err = b.WhaleBomb(7002, true)
if err == nil {
t.Error("unexpected result")
if result.Amount != 7000 {
t.Fatalf("received: '%v' but expected: '%v'", result.Amount, 7000)
}
// valid
_, err = b.WhaleBomb(6998, false)
if result.MaximumPrice != 7001 {
t.Fatalf("received: '%v' but expected: '%v'", result.MaximumPrice, 7001)
}
if result.MinimumPrice != 7000 {
t.Fatalf("received: '%v' but expected: '%v'", result.MinimumPrice, 7000)
}
if result.PercentageGainOrLoss != 0.014285714285714287 {
t.Fatalf("received: '%v' but expected: '%v'", result.PercentageGainOrLoss, 0.014285714285714287)
}
result, err = b.WhaleBomb(7002, true) // <- exceed available quotations
if !errors.Is(err, nil) {
t.Fatalf("received: '%v' but expected: '%v'", err, nil)
}
if !strings.Contains(result.Status, fullLiquidityUsageWarning) {
t.Fatal("expected status to contain liquidity warning")
}
result, err = b.WhaleBomb(7000, true) // <- Book should not move
if !errors.Is(err, nil) {
t.Fatalf("received: '%v' but expected: '%v'", err, nil)
}
if result.Amount != 0 {
t.Fatalf("received: '%v' but expected: '%v'", result.Amount, 0)
}
if result.MaximumPrice != 7000 {
t.Fatalf("received: '%v' but expected: '%v'", result.MaximumPrice, 7000)
}
if result.MinimumPrice != 7000 {
t.Fatalf("received: '%v' but expected: '%v'", result.MinimumPrice, 7000)
}
if result.PercentageGainOrLoss != 0 {
t.Fatalf("received: '%v' but expected: '%v'", result.PercentageGainOrLoss, 0)
}
_, err = b.WhaleBomb(6000, true)
if !errors.Is(err, errCannotShiftPrice) {
t.Fatalf("received: '%v' but expected: '%v'", err, errCannotShiftPrice)
}
_, err = b.WhaleBomb(-1, false)
if !errors.Is(err, errPriceTargetInvalid) {
t.Fatalf("received: '%v' but expected: '%v'", err, errPriceTargetInvalid)
}
result, err = b.WhaleBomb(6998, false) // <- This price should not be wiped out on the book.
if !errors.Is(err, nil) {
t.Fatalf("received: '%v' but expected: '%v'", err, nil)
}
if result.Amount != 1 {
t.Fatalf("received: '%v' but expected: '%v'", result.Amount, 1)
}
if result.MaximumPrice != 6999 {
t.Fatalf("received: '%v' but expected: '%v'", result.MaximumPrice, 6999)
}
if result.MinimumPrice != 6998 {
t.Fatalf("received: '%v' but expected: '%v'", result.MinimumPrice, 6998)
}
if result.PercentageGainOrLoss != -0.014287755393627661 {
t.Fatalf("received: '%v' but expected: '%v'", result.PercentageGainOrLoss, -0.014287755393627661)
}
result, err = b.WhaleBomb(6998.5, false) // <- Slot between prices will drop to next bid tranche
if !errors.Is(err, nil) {
t.Errorf("received '%v', expected '%v'", err, nil)
}
// invalid
_, err = b.WhaleBomb(6997, false)
if err == nil {
t.Error("unexpected result")
if result.Amount != 1 {
t.Fatalf("received: '%v' but expected: '%v'", result.Amount, 1)
}
if result.MaximumPrice != 6999 {
t.Fatalf("received: '%v' but expected: '%v'", result.MaximumPrice, 6999)
}
if result.MinimumPrice != 6998 {
t.Fatalf("received: '%v' but expected: '%v'", result.MinimumPrice, 6998)
}
if result.PercentageGainOrLoss != -0.014287755393627661 {
t.Fatalf("received: '%v' but expected: '%v'", result.PercentageGainOrLoss, -0.014287755393627661)
}
result, err = b.WhaleBomb(6997, false) // <- exceed available quotations
if !errors.Is(err, nil) {
t.Fatalf("received: '%v' but expected: '%v'", err, nil)
}
if !strings.Contains(result.Status, fullLiquidityUsageWarning) {
t.Fatal("expected status to contain liquidity warning")
}
result, err = b.WhaleBomb(6999, false) // <- Book should not move
if !errors.Is(err, nil) {
t.Fatalf("received: '%v' but expected: '%v'", err, nil)
}
if result.Amount != 0 {
t.Fatalf("received: '%v' but expected: '%v'", result.Amount, 0)
}
if result.MaximumPrice != 6999 {
t.Fatalf("received: '%v' but expected: '%v'", result.MaximumPrice, 6999)
}
if result.MinimumPrice != 6999 {
t.Fatalf("received: '%v' but expected: '%v'", result.MinimumPrice, 6999)
}
if result.PercentageGainOrLoss != 0 {
t.Fatalf("received: '%v' but expected: '%v'", result.PercentageGainOrLoss, 0)
}
_, err = b.WhaleBomb(7500, false)
if !errors.Is(err, errCannotShiftPrice) {
t.Fatalf("received: '%v' but expected: '%v'", err, errCannotShiftPrice)
}
}
func TestSimulateOrder(t *testing.T) {
t.Parallel()
b := testSetup()
b.SimulateOrder(8000, true)
b.SimulateOrder(1.5, false)
}
func TestOrderSummary(t *testing.T) {
var o orderSummary
if p := o.MaximumPrice(false); p != 0 {
t.Error("unexpected result")
}
if p := o.MinimumPrice(false); p != 0 {
t.Error("unexpected result")
// Invalid
_, err := b.SimulateOrder(-8000, true)
if !errors.Is(err, errQuoteAmountInvalid) {
t.Fatalf("received: '%v' but expected: '%v'", err, errQuoteAmountInvalid)
}
o = orderSummary{
{Price: 1337, Amount: 1},
{Price: 9001, Amount: 1},
}
if p := o.MaximumPrice(false); p != 1337 {
t.Error("unexpected result")
}
if p := o.MaximumPrice(true); p != 9001 {
t.Error("unexpected result")
}
if p := o.MinimumPrice(false); p != 1337 {
t.Error("unexpected result")
}
if p := o.MinimumPrice(true); p != 9001 {
t.Error("unexpected result")
_, err = (&Base{}).SimulateOrder(1337, true)
if !errors.Is(err, errNoLiquidity) {
t.Fatalf("received: '%v' but expected: '%v'", err, errNoLiquidity)
}
o.Print()
// Full liquidity used
result, err := b.SimulateOrder(21002, true)
if !errors.Is(err, nil) {
t.Fatalf("received: '%v' but expected: '%v'", err, nil)
}
if result.Amount != 3 {
t.Fatalf("received: '%v' but expected: '%v'", result.Amount, 3)
}
if result.MinimumPrice != 7000 {
t.Fatalf("received: '%v' but expected: '%v'", result.MinimumPrice, 7000)
}
if result.MaximumPrice != 7001 {
t.Fatalf("received: '%v' but expected: '%v'", result.MaximumPrice, 7001)
}
if !strings.Contains(result.Status, fullLiquidityUsageWarning) {
t.Fatalf("received: '%v' but expected string to contain: '%v'", result.Status, fullLiquidityUsageWarning)
}
if len(result.Orders) != 2 {
t.Fatalf("received: '%v' but expected: '%v'", len(result.Orders), 2)
}
// Exceed full liquidity used
result, err = b.SimulateOrder(21003, true)
if !errors.Is(err, nil) {
t.Fatalf("received: '%v' but expected: '%v'", err, nil)
}
if result.Amount != 3 {
t.Fatalf("received: '%v' but expected: '%v'", result.Amount, 3)
}
if result.MinimumPrice != 7000 {
t.Fatalf("received: '%v' but expected: '%v'", result.MinimumPrice, 7000)
}
if result.MaximumPrice != 7001 {
t.Fatalf("received: '%v' but expected: '%v'", result.MaximumPrice, 7001)
}
if !strings.Contains(result.Status, fullLiquidityUsageWarning) {
t.Fatalf("received: '%v' but expected string to contain: '%v'", result.Status, fullLiquidityUsageWarning)
}
if len(result.Orders) != 2 {
t.Fatalf("received: '%v' but expected: '%v'", len(result.Orders), 2)
}
// First tranche
result, err = b.SimulateOrder(7000, true)
if !errors.Is(err, nil) {
t.Fatalf("received: '%v' but expected: '%v'", err, nil)
}
if result.Amount != 1 {
t.Fatalf("received: '%v' but expected: '%v'", result.Amount, 1)
}
if result.MinimumPrice != 7000 {
t.Fatalf("received: '%v' but expected: '%v'", result.MinimumPrice, 7000)
}
if result.MaximumPrice != 7001 { // A full tranche is wiped out and this one should be preserved.
t.Fatalf("received: '%v' but expected: '%v'", result.MaximumPrice, 7001)
}
if strings.Contains(result.Status, fullLiquidityUsageWarning) {
t.Fatalf("received: '%v' but expected string to contain: '%v'", result.Status, "NO WARNING")
}
if len(result.Orders) != 1 {
t.Fatalf("received: '%v' but expected: '%v'", len(result.Orders), 1)
}
// Half of first tranch
result, err = b.SimulateOrder(3500, true)
if !errors.Is(err, nil) {
t.Fatalf("received: '%v' but expected: '%v'", err, nil)
}
if result.Amount != .5 {
t.Fatalf("received: '%v' but expected: '%v'", result.Amount, .5)
}
if result.MinimumPrice != 7000 {
t.Fatalf("received: '%v' but expected: '%v'", result.MinimumPrice, 7000)
}
if result.MaximumPrice != 7000 {
t.Fatalf("received: '%v' but expected: '%v'", result.MaximumPrice, 7000)
}
if strings.Contains(result.Status, fullLiquidityUsageWarning) {
t.Fatalf("received: '%v' but expected string to contain: '%v'", result.Status, "NO WARNING")
}
if len(result.Orders) != 1 {
t.Fatalf("received: '%v' but expected: '%v'", len(result.Orders), 1)
}
if result.Orders[0].Amount != 0.5 {
t.Fatalf("received: '%v' but expected: '%v'", result.Orders[0].Amount, 0.5)
}
// Half of second tranche
result, err = b.SimulateOrder(14001, true)
if !errors.Is(err, nil) {
t.Fatalf("received: '%v' but expected: '%v'", err, nil)
}
if result.Amount != 2 {
t.Fatalf("received: '%v' but expected: '%v'", result.Amount, 2)
}
if result.MinimumPrice != 7000 {
t.Fatalf("received: '%v' but expected: '%v'", result.MinimumPrice, 7000)
}
if result.MaximumPrice != 7001 {
t.Fatalf("received: '%v' but expected: '%v'", result.MaximumPrice, 7001)
}
if strings.Contains(result.Status, fullLiquidityUsageWarning) {
t.Fatalf("received: '%v' but expected string to contain: '%v'", result.Status, "NO WARNING")
}
if len(result.Orders) != 2 {
t.Fatalf("received: '%v' but expected: '%v'", len(result.Orders), 1)
}
if result.Orders[1].Amount != 1 {
t.Fatalf("received: '%v' but expected: '%v'", result.Orders[1].Amount, 1)
}
// Hitting bids
// Invalid
_, err = (&Base{}).SimulateOrder(-1, false)
if !errors.Is(err, errBaseAmountInvalid) {
t.Fatalf("received: '%v' but expected: '%v'", err, errBaseAmountInvalid)
}
_, err = (&Base{}).SimulateOrder(2, false)
if !errors.Is(err, errNoLiquidity) {
t.Fatalf("received: '%v' but expected: '%v'", err, errNoLiquidity)
}
// Full liquidity used
result, err = b.SimulateOrder(3, false)
if !errors.Is(err, nil) {
t.Fatalf("received: '%v' but expected: '%v'", err, nil)
}
if result.Amount != 20995 {
t.Fatalf("received: '%v' but expected: '%v'", result.Amount, 20995)
}
if result.MaximumPrice != 6999 {
t.Fatalf("received: '%v' but expected: '%v'", result.MaximumPrice, 6999)
}
if result.MinimumPrice != 6998 {
t.Fatalf("received: '%v' but expected: '%v'", result.MinimumPrice, 6998)
}
if !strings.Contains(result.Status, fullLiquidityUsageWarning) {
t.Fatalf("received: '%v' but expected string to contain: '%v'", result.Status, fullLiquidityUsageWarning)
}
if len(result.Orders) != 2 {
t.Fatalf("received: '%v' but expected: '%v'", len(result.Orders), 2)
}
// Exceed full liquidity used
result, err = b.SimulateOrder(3.1, false)
if !errors.Is(err, nil) {
t.Fatalf("received: '%v' but expected: '%v'", err, nil)
}
if result.Amount != 20995 {
t.Fatalf("received: '%v' but expected: '%v'", result.Amount, 20995)
}
if result.MaximumPrice != 6999 {
t.Fatalf("received: '%v' but expected: '%v'", result.MaximumPrice, 6999)
}
if result.MinimumPrice != 6998 {
t.Fatalf("received: '%v' but expected: '%v'", result.MinimumPrice, 6998)
}
if !strings.Contains(result.Status, fullLiquidityUsageWarning) {
t.Fatalf("received: '%v' but expected string to contain: '%v'", result.Status, fullLiquidityUsageWarning)
}
if len(result.Orders) != 2 {
t.Fatalf("received: '%v' but expected: '%v'", len(result.Orders), 2)
}
// First tranche
result, err = b.SimulateOrder(1, false)
if !errors.Is(err, nil) {
t.Fatalf("received: '%v' but expected: '%v'", err, nil)
}
if result.Amount != 6999 {
t.Fatalf("received: '%v' but expected: '%v'", result.Amount, 6999)
}
if result.MaximumPrice != 6999 {
t.Fatalf("received: '%v' but expected: '%v'", result.MaximumPrice, 6999)
}
if result.MinimumPrice != 6998 { // A full tranche is wiped out and this one should be preserved.
t.Fatalf("received: '%v' but expected: '%v'", result.MinimumPrice, 6998)
}
if strings.Contains(result.Status, fullLiquidityUsageWarning) {
t.Fatalf("received: '%v' but expected string to contain: '%v'", result.Status, "NO WARNING")
}
if len(result.Orders) != 1 {
t.Fatalf("received: '%v' but expected: '%v'", len(result.Orders), 1)
}
// Half of first tranch
result, err = b.SimulateOrder(.5, false)
if !errors.Is(err, nil) {
t.Fatalf("received: '%v' but expected: '%v'", err, nil)
}
if result.Amount != 3499.5 {
t.Fatalf("received: '%v' but expected: '%v'", result.Amount, 3499.5)
}
if result.MinimumPrice != 6999 {
t.Fatalf("received: '%v' but expected: '%v'", result.MinimumPrice, 6999)
}
if result.MaximumPrice != 6999 {
t.Fatalf("received: '%v' but expected: '%v'", result.MaximumPrice, 6999)
}
if strings.Contains(result.Status, fullLiquidityUsageWarning) {
t.Fatalf("received: '%v' but expected string to contain: '%v'", result.Status, "NO WARNING")
}
if len(result.Orders) != 1 {
t.Fatalf("received: '%v' but expected: '%v'", len(result.Orders), 1)
}
if result.Orders[0].Amount != 0.5 {
t.Fatalf("received: '%v' but expected: '%v'", result.Orders[0].Amount, 0.5)
}
// Half of second tranche
result, err = b.SimulateOrder(2, false)
if !errors.Is(err, nil) {
t.Fatalf("received: '%v' but expected: '%v'", err, nil)
}
if result.Amount != 13997 {
t.Fatalf("received: '%v' but expected: '%v'", result.Amount, 13997)
}
if result.MaximumPrice != 6999 {
t.Fatalf("received: '%v' but expected: '%v'", result.MaximumPrice, 6999)
}
if result.MinimumPrice != 6998 {
t.Fatalf("received: '%v' but expected: '%v'", result.MinimumPrice, 6998)
}
if strings.Contains(result.Status, fullLiquidityUsageWarning) {
t.Fatalf("received: '%v' but expected string to contain: '%v'", result.Status, "NO WARNING")
}
if len(result.Orders) != 2 {
t.Fatalf("received: '%v' but expected: '%v'", len(result.Orders), 2)
}
if result.Orders[1].Amount != 1 {
t.Fatalf("received: '%v' but expected: '%v'", result.Orders[1].Amount, 1)
}
}
func TestGetAveragePrice(t *testing.T) {

View File

@@ -64,26 +64,6 @@ func (d *Depth) Publish() {
}
}
// GetAskLength returns length of asks
func (d *Depth) GetAskLength() (int, error) {
d.m.Lock()
defer d.m.Unlock()
if d.validationError != nil {
return 0, d.validationError
}
return d.asks.length, nil
}
// GetBidLength returns length of bids
func (d *Depth) GetBidLength() (int, error) {
d.m.Lock()
defer d.m.Unlock()
if d.validationError != nil {
return 0, d.validationError
}
return d.bids.length, nil
}
// Retrieve returns the orderbook base a copy of the underlying linked list
// spread
func (d *Depth) Retrieve() (*Base, error) {
@@ -106,30 +86,6 @@ func (d *Depth) Retrieve() (*Base, error) {
}, nil
}
// TotalBidAmounts returns the total amount of bids and the total orderbook
// bids value
func (d *Depth) TotalBidAmounts() (liquidity, value float64, err error) {
d.m.Lock()
defer d.m.Unlock()
if d.validationError != nil {
return 0, 0, d.validationError
}
liquidity, value = d.bids.amount()
return liquidity, value, nil
}
// TotalAskAmounts returns the total amount of asks and the total orderbook
// asks value
func (d *Depth) TotalAskAmounts() (liquidity, value float64, err error) {
d.m.Lock()
defer d.m.Unlock()
if d.validationError != nil {
return 0, 0, d.validationError
}
liquidity, value = d.asks.amount()
return liquidity, value, nil
}
// LoadSnapshot flushes the bids and asks with a snapshot
func (d *Depth) LoadSnapshot(bids, asks []Item, lastUpdateID int64, lastUpdated time.Time, updateByREST bool) {
d.m.Lock()
@@ -323,6 +279,50 @@ func (d *Depth) IsFundingRate() bool {
return d.isFundingRate
}
// GetAskLength returns length of asks
func (d *Depth) GetAskLength() (int, error) {
d.m.Lock()
defer d.m.Unlock()
if d.validationError != nil {
return 0, d.validationError
}
return d.asks.length, nil
}
// GetBidLength returns length of bids
func (d *Depth) GetBidLength() (int, error) {
d.m.Lock()
defer d.m.Unlock()
if d.validationError != nil {
return 0, d.validationError
}
return d.bids.length, nil
}
// TotalBidAmounts returns the total amount of bids and the total orderbook
// bids value
func (d *Depth) TotalBidAmounts() (liquidity, value float64, err error) {
d.m.Lock()
defer d.m.Unlock()
if d.validationError != nil {
return 0, 0, d.validationError
}
liquidity, value = d.bids.amount()
return liquidity, value, nil
}
// TotalAskAmounts returns the total amount of asks and the total orderbook
// asks value
func (d *Depth) TotalAskAmounts() (liquidity, value float64, err error) {
d.m.Lock()
defer d.m.Unlock()
if d.validationError != nil {
return 0, 0, d.validationError
}
liquidity, value = d.asks.amount()
return liquidity, value, nil
}
// updateAndAlert updates the last updated ID and when it was updated to the
// recent update. Then alerts all pending routines. NOTE: This requires locking.
func (d *Depth) updateAndAlert(update *Update) {
@@ -330,3 +330,382 @@ func (d *Depth) updateAndAlert(update *Update) {
d.lastUpdated = update.UpdateTime
d.Alert()
}
// HitTheBidsByNominalSlippage hits the bids by the required nominal slippage
// percentage, calculated from the reference price and returns orderbook
// movement details for the bid side.
func (d *Depth) HitTheBidsByNominalSlippage(maxSlippage, refPrice float64) (*Movement, error) {
d.m.Lock()
defer d.m.Unlock()
if d.validationError != nil {
return nil, d.validationError
}
return d.bids.hitBidsByNominalSlippage(maxSlippage, refPrice)
}
// HitTheBidsByNominalSlippageFromMid hits the bids by the required nominal
// slippage percentage, calculated from the mid price and returns orderbook
// movement details for the bid side.
func (d *Depth) HitTheBidsByNominalSlippageFromMid(maxSlippage float64) (*Movement, error) {
d.m.Lock()
defer d.m.Unlock()
if d.validationError != nil {
return nil, d.validationError
}
mid, err := d.getMidPriceNoLock()
if err != nil {
return nil, err
}
return d.bids.hitBidsByNominalSlippage(maxSlippage, mid)
}
// HitTheBidsByNominalSlippageFromBest hits the bids by the required nominal
// slippage percentage, calculated from the best bid price and returns orderbook
// movement details for the bid side.
func (d *Depth) HitTheBidsByNominalSlippageFromBest(maxSlippage float64) (*Movement, error) {
d.m.Lock()
defer d.m.Unlock()
if d.validationError != nil {
return nil, d.validationError
}
head, err := d.bids.getHeadPriceNoLock()
if err != nil {
return nil, err
}
return d.bids.hitBidsByNominalSlippage(maxSlippage, head)
}
// LiftTheAsksByNominalSlippage lifts the asks by the required nominal slippage
// percentage, calculated from the reference price and returns orderbook
// movement details for the ask side.
func (d *Depth) LiftTheAsksByNominalSlippage(maxSlippage, refPrice float64) (*Movement, error) {
d.m.Lock()
defer d.m.Unlock()
if d.validationError != nil {
return nil, d.validationError
}
return d.asks.liftAsksByNominalSlippage(maxSlippage, refPrice)
}
// LiftTheAsksByNominalSlippageFromMid lifts the asks by the required nominal
// slippage percentage, calculated from the mid price and returns orderbook
// movement details for the ask side.
func (d *Depth) LiftTheAsksByNominalSlippageFromMid(maxSlippage float64) (*Movement, error) {
d.m.Lock()
defer d.m.Unlock()
if d.validationError != nil {
return nil, d.validationError
}
mid, err := d.getMidPriceNoLock()
if err != nil {
return nil, err
}
return d.asks.liftAsksByNominalSlippage(maxSlippage, mid)
}
// LiftTheAsksByNominalSlippageFromBest lifts the asks by the required nominal
// slippage percentage, calculated from the best ask price and returns orderbook
// movement details for the ask side.
func (d *Depth) LiftTheAsksByNominalSlippageFromBest(maxSlippage float64) (*Movement, error) {
d.m.Lock()
defer d.m.Unlock()
if d.validationError != nil {
return nil, d.validationError
}
head, err := d.asks.getHeadPriceNoLock()
if err != nil {
return nil, err
}
return d.asks.liftAsksByNominalSlippage(maxSlippage, head)
}
// HitTheBidsByImpactSlippage hits the bids by the required impact slippage
// percentage, calculated from the reference price and returns orderbook
// movement details for the bid side.
func (d *Depth) HitTheBidsByImpactSlippage(maxSlippage, refPrice float64) (*Movement, error) {
d.m.Lock()
defer d.m.Unlock()
if d.validationError != nil {
return nil, d.validationError
}
return d.bids.hitBidsByImpactSlippage(maxSlippage, refPrice)
}
// HitTheBidsByImpactSlippageFromMid hits the bids by the required impact
// slippage percentage, calculated from the mid price and returns orderbook
// movement details for the bid side.
func (d *Depth) HitTheBidsByImpactSlippageFromMid(maxSlippage float64) (*Movement, error) {
d.m.Lock()
defer d.m.Unlock()
if d.validationError != nil {
return nil, d.validationError
}
mid, err := d.getMidPriceNoLock()
if err != nil {
return nil, err
}
return d.bids.hitBidsByImpactSlippage(maxSlippage, mid)
}
// HitTheBidsByImpactSlippageFromBest hits the bids by the required impact
// slippage percentage, calculated from the best bid price and returns orderbook
// movement details for the bid side.
func (d *Depth) HitTheBidsByImpactSlippageFromBest(maxSlippage float64) (*Movement, error) {
d.m.Lock()
defer d.m.Unlock()
if d.validationError != nil {
return nil, d.validationError
}
head, err := d.bids.getHeadPriceNoLock()
if err != nil {
return nil, err
}
return d.bids.hitBidsByImpactSlippage(maxSlippage, head)
}
// LiftTheAsksByImpactSlippage lifts the asks by the required impact slippage
// percentage, calculated from the reference price and returns orderbook
// movement details for the ask side.
func (d *Depth) LiftTheAsksByImpactSlippage(maxSlippage, refPrice float64) (*Movement, error) {
d.m.Lock()
defer d.m.Unlock()
if d.validationError != nil {
return nil, d.validationError
}
return d.asks.liftAsksByImpactSlippage(maxSlippage, refPrice)
}
// LiftTheAsksByImpactSlippageFromMid lifts the asks by the required impact
// slippage percentage, calculated from the mid price and returns orderbook
// movement details for the ask side.
func (d *Depth) LiftTheAsksByImpactSlippageFromMid(maxSlippage float64) (*Movement, error) {
d.m.Lock()
defer d.m.Unlock()
if d.validationError != nil {
return nil, d.validationError
}
mid, err := d.getMidPriceNoLock()
if err != nil {
return nil, err
}
return d.asks.liftAsksByImpactSlippage(maxSlippage, mid)
}
// LiftTheAsksByImpactSlippageFromBest lifts the asks by the required impact
// slippage percentage, calculated from the best ask price and returns orderbook
// movement details for the ask side.
func (d *Depth) LiftTheAsksByImpactSlippageFromBest(maxSlippage float64) (*Movement, error) {
d.m.Lock()
defer d.m.Unlock()
if d.validationError != nil {
return nil, d.validationError
}
head, err := d.asks.getHeadPriceNoLock()
if err != nil {
return nil, err
}
return d.asks.liftAsksByImpactSlippage(maxSlippage, head)
}
// HitTheBids derives full orderbook slippage information from reference price
// using an amount. Purchase refers to how much quote currency is desired else
// the amount would refer to base currency deployed to orderbook bid side.
func (d *Depth) HitTheBids(amount, refPrice float64, purchase bool) (*Movement, error) {
d.m.Lock()
defer d.m.Unlock()
if d.validationError != nil {
return nil, d.validationError
}
if purchase {
return d.bids.getMovementByQuotation(amount, refPrice, false)
}
return d.bids.getMovementByBase(amount, refPrice, false)
}
// HitTheBidsFromMid derives full orderbook slippage information from mid price
// using an amount. Purchase refers to how much quote currency is desired else
// the amount would refer to base currency deployed to orderbook bid side.
func (d *Depth) HitTheBidsFromMid(amount float64, purchase bool) (*Movement, error) {
d.m.Lock()
defer d.m.Unlock()
if d.validationError != nil {
return nil, d.validationError
}
mid, err := d.getMidPriceNoLock()
if err != nil {
return nil, err
}
if purchase {
return d.bids.getMovementByQuotation(amount, mid, false)
}
return d.bids.getMovementByBase(amount, mid, false)
}
// HitTheBidsFromBest derives full orderbook slippage information from best bid
// price using an amount. Purchase refers to how much quote currency is desired
// else the amount would refer to base currency deployed to orderbook bid side.
func (d *Depth) HitTheBidsFromBest(amount float64, purchase bool) (*Movement, error) {
d.m.Lock()
defer d.m.Unlock()
if d.validationError != nil {
return nil, d.validationError
}
head, err := d.bids.getHeadPriceNoLock()
if err != nil {
return nil, err
}
if purchase {
return d.bids.getMovementByQuotation(amount, head, false)
}
return d.bids.getMovementByBase(amount, head, false)
}
// LiftTheAsks derives full orderbook slippage information from reference price
// using an amount. Purchase refers to how much base currency is desired else
// the amount would refer to quote currency deployed to orderbook ask side.
func (d *Depth) LiftTheAsks(amount, refPrice float64, purchase bool) (*Movement, error) {
d.m.Lock()
defer d.m.Unlock()
if d.validationError != nil {
return nil, d.validationError
}
if purchase {
return d.asks.getMovementByBase(amount, refPrice, true)
}
return d.asks.getMovementByQuotation(amount, refPrice, true)
}
// LiftTheAsksFromMid derives full orderbook slippage information from mid price
// using an amount. Purchase refers to how much base currency is desired else
// the amount would refer to quote currency deployed to orderbook ask side.
func (d *Depth) LiftTheAsksFromMid(amount float64, purchase bool) (*Movement, error) {
d.m.Lock()
defer d.m.Unlock()
if d.validationError != nil {
return nil, d.validationError
}
mid, err := d.getMidPriceNoLock()
if err != nil {
return nil, err
}
if purchase {
return d.asks.getMovementByBase(amount, mid, true)
}
return d.asks.getMovementByQuotation(amount, mid, true)
}
// LiftTheAsksFromBest derives full orderbook slippage information from best ask
// price using an amount. Purchase refers to how much base currency is desired
// else the amount would refer to quote currency deployed to orderbook ask side.
func (d *Depth) LiftTheAsksFromBest(amount float64, purchase bool) (*Movement, error) {
d.m.Lock()
defer d.m.Unlock()
if d.validationError != nil {
return nil, d.validationError
}
head, err := d.asks.getHeadPriceNoLock()
if err != nil {
return nil, err
}
if purchase {
return d.asks.getMovementByBase(amount, head, true)
}
return d.asks.getMovementByQuotation(amount, head, true)
}
// GetMidPrice returns the mid price between the ask and bid spread
func (d *Depth) GetMidPrice() (float64, error) {
d.m.Lock()
defer d.m.Unlock()
if d.validationError != nil {
return 0, d.validationError
}
return d.getMidPriceNoLock()
}
// getMidPriceNoLock is an unprotected helper that gets mid price
func (d *Depth) getMidPriceNoLock() (float64, error) {
bidHead, err := d.bids.getHeadPriceNoLock()
if err != nil {
return 0, err
}
askHead, err := d.asks.getHeadPriceNoLock()
if err != nil {
return 0, err
}
return (bidHead + askHead) / 2, nil
}
// GetBestBid returns the best bid price
func (d *Depth) GetBestBid() (float64, error) {
d.m.Lock()
defer d.m.Unlock()
if d.validationError != nil {
return 0, d.validationError
}
return d.bids.getHeadPriceNoLock()
}
// GetBestAsk returns the best ask price
func (d *Depth) GetBestAsk() (float64, error) {
d.m.Lock()
defer d.m.Unlock()
if d.validationError != nil {
return 0, d.validationError
}
return d.asks.getHeadPriceNoLock()
}
// GetSpreadAmount returns the spread as a quotation amount
func (d *Depth) GetSpreadAmount() (float64, error) {
d.m.Lock()
defer d.m.Unlock()
if d.validationError != nil {
return 0, d.validationError
}
askHead, err := d.asks.getHeadPriceNoLock()
if err != nil {
return 0, err
}
bidHead, err := d.bids.getHeadPriceNoLock()
if err != nil {
return 0, err
}
return askHead - bidHead, nil
}
// GetSpreadPercentage returns the spread as a percentage
func (d *Depth) GetSpreadPercentage() (float64, error) {
d.m.Lock()
defer d.m.Unlock()
if d.validationError != nil {
return 0, d.validationError
}
askHead, err := d.asks.getHeadPriceNoLock()
if err != nil {
return 0, err
}
bidHead, err := d.bids.getHeadPriceNoLock()
if err != nil {
return 0, err
}
return (askHead - bidHead) / askHead * 100, nil
}
// GetImbalance returns top orderbook imbalance
func (d *Depth) GetImbalance() (float64, error) {
d.m.Lock()
defer d.m.Unlock()
if d.validationError != nil {
return 0, d.validationError
}
askVolume, err := d.asks.getHeadVolumeNoLock()
if err != nil {
return 0, err
}
bidVolume, err := d.bids.getHeadVolumeNoLock()
if err != nil {
return 0, err
}
return (bidVolume - askVolume) / (bidVolume + askVolume), nil
}

File diff suppressed because it is too large Load Diff

View File

@@ -3,11 +3,28 @@ package orderbook
import (
"errors"
"fmt"
"github.com/thrasher-corp/gocryptotrader/common/math"
)
var errIDCannotBeMatched = errors.New("cannot match ID on linked list")
var errCollisionDetected = errors.New("cannot insert update collision detected")
var errAmountCannotBeLessOrEqualToZero = errors.New("amount cannot be less or equal to zero")
// FullLiquidityExhaustedPercentage defines when a book has been completely
// wiped out of potential liquidity.
const FullLiquidityExhaustedPercentage = -100
var (
errIDCannotBeMatched = errors.New("cannot match ID on linked list")
errCollisionDetected = errors.New("cannot insert update, collision detected")
errAmountCannotBeLessOrEqualToZero = errors.New("amount cannot be less than or equal to zero")
errInvalidNominalSlippage = errors.New("invalid slippage amount, its value must be greater than or equal to zero")
errInvalidImpactSlippage = errors.New("invalid slippage amount, its value must be greater than zero")
errInvalidSlippageCannotExceed100 = errors.New("invalid slippage amount, its value cannot exceed 100%")
errBaseAmountInvalid = errors.New("invalid base amount")
errInvalidReferencePrice = errors.New("invalid reference price")
errQuoteAmountInvalid = errors.New("quote amount invalid")
errInvalidCost = errors.New("invalid cost amount")
errInvalidAmount = errors.New("invalid amount")
errInvalidHeadPrice = errors.New("invalid head price")
)
// linkedList defines a linked list for a depth level, reutilisation of nodes
// to and from a stack.
@@ -340,6 +357,115 @@ func (ll *linkedList) insertUpdates(updts Items, stack *stack, comp comparison)
return nil
}
// getHeadPriceNoLock gets best/head price
func (ll *linkedList) getHeadPriceNoLock() (float64, error) {
if ll.head == nil {
return 0, errNoLiquidity
}
return ll.head.Value.Price, nil
}
// getHeadVolumeNoLock gets best/head volume
func (ll *linkedList) getHeadVolumeNoLock() (float64, error) {
if ll.head == nil {
return 0, errNoLiquidity
}
return ll.head.Value.Amount, nil
}
// getMovementByQuotation traverses through orderbook liquidity using quotation
// currency as a limiter and returns orderbook movement details. Swap boolean
// allows the swap of sold and purchased to reduce code so it doesn't need to be
// specific to bid or ask.
func (ll *linkedList) getMovementByQuotation(quote, refPrice float64, swap bool) (*Movement, error) {
if quote <= 0 {
return nil, errQuoteAmountInvalid
}
if refPrice <= 0 {
return nil, errInvalidReferencePrice
}
head, err := ll.getHeadPriceNoLock()
if err != nil {
return nil, err
}
m := Movement{StartPrice: refPrice}
for tip := ll.head; tip != nil; tip = tip.Next {
trancheValue := tip.Value.Amount * tip.Value.Price
leftover := quote - trancheValue
if leftover < 0 {
m.Purchased += quote
m.Sold += quote / trancheValue * tip.Value.Amount
// This tranche is not consumed so the book shifts to this price.
m.EndPrice = tip.Value.Price
quote = 0
break
}
// Full tranche consumed
m.Purchased += tip.Value.Price * tip.Value.Amount
m.Sold += tip.Value.Amount
quote = leftover
if leftover == 0 {
// Price no longer exists on the book so use next full price tranche
// to calculate book impact. If available.
if tip.Next != nil {
m.EndPrice = tip.Next.Value.Price
} else {
m.FullBookSideConsumed = true
}
}
}
return m.finalizeFields(m.Purchased, m.Sold, head, quote, swap)
}
// getMovementByBase traverses through orderbook liquidity using base currency
// as a limiter and returns orderbook movement details. Swap boolean allows the
// swap of sold and purchased to reduce code so it doesn't need to be specific
// to bid or ask.
func (ll *linkedList) getMovementByBase(base, refPrice float64, swap bool) (*Movement, error) {
if base <= 0 {
return nil, errBaseAmountInvalid
}
if refPrice <= 0 {
return nil, errInvalidReferencePrice
}
head, err := ll.getHeadPriceNoLock()
if err != nil {
return nil, err
}
m := Movement{StartPrice: refPrice}
for tip := ll.head; tip != nil; tip = tip.Next {
leftover := base - tip.Value.Amount
if leftover < 0 {
m.Purchased += tip.Value.Price * base
m.Sold += base
// This tranche is not consumed so the book shifts to this price.
m.EndPrice = tip.Value.Price
base = 0
break
}
// Full tranche consumed
m.Purchased += tip.Value.Price * tip.Value.Amount
m.Sold += tip.Value.Amount
base = leftover
if leftover == 0 {
// Price no longer exists on the book so use next full price tranche
// to calculate book impact.
if tip.Next != nil {
m.EndPrice = tip.Next.Value.Price
} else {
m.FullBookSideConsumed = true
}
}
}
return m.finalizeFields(m.Purchased, m.Sold, head, base, swap)
}
// bids embed a linked list to attach methods for bid depth specific
// functionality
type bids struct {
@@ -367,6 +493,115 @@ func (ll *bids) insertUpdates(updts Items, stack *stack) error {
return ll.linkedList.insertUpdates(updts, stack, bidCompare)
}
// hitBidsByNominalSlippage hits the bids by the required nominal slippage
// percentage, calculated from the reference price and returns orderbook
// movement details.
func (ll *bids) hitBidsByNominalSlippage(slippage, refPrice float64) (*Movement, error) {
if slippage < 0 {
return nil, errInvalidNominalSlippage
}
if slippage > 100 {
return nil, errInvalidSlippageCannotExceed100
}
if refPrice <= 0 {
return nil, errInvalidReferencePrice
}
if ll.head == nil {
return nil, errNoLiquidity
}
nominal := &Movement{StartPrice: refPrice, EndPrice: refPrice}
var cumulativeValue, cumulativeAmounts float64
for tip := ll.head; tip != nil; tip = tip.Next {
totalTrancheValue := tip.Value.Price * tip.Value.Amount
currentFullValue := totalTrancheValue + cumulativeValue
currentTotalAmounts := cumulativeAmounts + tip.Value.Amount
nominal.AverageOrderCost = currentFullValue / currentTotalAmounts
percent := math.CalculatePercentageGainOrLoss(nominal.AverageOrderCost, refPrice)
if percent != 0 {
percent *= -1
}
if slippage < percent {
targetCost := (1 - slippage/100) * refPrice
if targetCost == refPrice {
nominal.AverageOrderCost = cumulativeValue / cumulativeAmounts
// Rounding issue on requested nominal percentage
return nominal, nil
}
comparative := targetCost * cumulativeAmounts
comparativeDiff := comparative - cumulativeValue
trancheTargetPriceDiff := tip.Value.Price - targetCost
trancheAmountExpectation := comparativeDiff / trancheTargetPriceDiff
nominal.NominalPercentage = slippage
nominal.Sold = cumulativeAmounts + trancheAmountExpectation
nominal.Purchased += trancheAmountExpectation * tip.Value.Price
nominal.AverageOrderCost = nominal.Purchased / nominal.Sold
nominal.EndPrice = tip.Value.Price
return nominal, nil
}
nominal.EndPrice = tip.Value.Price
cumulativeValue = currentFullValue
nominal.NominalPercentage = percent
nominal.Sold += tip.Value.Amount
nominal.Purchased += totalTrancheValue
cumulativeAmounts = currentTotalAmounts
if slippage == percent {
nominal.FullBookSideConsumed = tip.Next == nil
return nominal, nil
}
}
nominal.FullBookSideConsumed = true
return nominal, nil
}
// hitBidsByImpactSlippage hits the bids by the required impact slippage
// percentage, calculated from the reference price and returns orderbook
// movement details.
func (ll *bids) hitBidsByImpactSlippage(slippage, refPrice float64) (*Movement, error) {
if slippage <= 0 {
return nil, errInvalidImpactSlippage
}
if slippage > 100 {
return nil, errInvalidSlippageCannotExceed100
}
if refPrice <= 0 {
return nil, errInvalidReferencePrice
}
if ll.head == nil {
return nil, errNoLiquidity
}
impact := &Movement{StartPrice: refPrice, EndPrice: refPrice}
for tip := ll.head; tip != nil; tip = tip.Next {
percent := math.CalculatePercentageGainOrLoss(tip.Value.Price, refPrice)
if percent != 0 {
percent *= -1
}
impact.EndPrice = tip.Value.Price
impact.ImpactPercentage = percent
if slippage <= percent {
// Don't include this tranche amount as this consumes the tranche
// book price, thus obtaining a higher percentage impact.
return impact, nil
}
impact.Sold += tip.Value.Amount
impact.Purchased += tip.Value.Amount * tip.Value.Price
impact.AverageOrderCost = impact.Purchased / impact.Sold
}
impact.FullBookSideConsumed = true
impact.ImpactPercentage = FullLiquidityExhaustedPercentage
return impact, nil
}
// asks embed a linked list to attach methods for ask depth specific
// functionality
type asks struct {
@@ -394,6 +629,100 @@ func (ll *asks) insertUpdates(updts Items, stack *stack) error {
return ll.linkedList.insertUpdates(updts, stack, askCompare)
}
// liftAsksByNominalSlippage lifts the asks by the required nominal slippage
// percentage, calculated from the reference price and returns orderbook
// movement details.
func (ll *asks) liftAsksByNominalSlippage(slippage, refPrice float64) (*Movement, error) {
if slippage < 0 {
return nil, errInvalidNominalSlippage
}
if refPrice <= 0 {
return nil, errInvalidReferencePrice
}
if ll.head == nil {
return nil, errNoLiquidity
}
nominal := &Movement{StartPrice: refPrice, EndPrice: refPrice}
var cumulativeAmounts float64
for tip := ll.head; tip != nil; tip = tip.Next {
totalTrancheValue := tip.Value.Price * tip.Value.Amount
currentValue := totalTrancheValue + nominal.Sold
currentAmounts := cumulativeAmounts + tip.Value.Amount
nominal.AverageOrderCost = currentValue / currentAmounts
percent := math.CalculatePercentageGainOrLoss(nominal.AverageOrderCost, refPrice)
if slippage < percent {
targetCost := (1 + slippage/100) * refPrice
if targetCost == refPrice {
nominal.AverageOrderCost = nominal.Sold / nominal.Purchased
// Rounding issue on requested nominal percentage
return nominal, nil
}
comparative := targetCost * cumulativeAmounts
comparativeDiff := comparative - nominal.Sold
trancheTargetPriceDiff := tip.Value.Price - targetCost
trancheAmountExpectation := comparativeDiff / trancheTargetPriceDiff
nominal.NominalPercentage = slippage
nominal.Sold += trancheAmountExpectation * tip.Value.Price
nominal.Purchased += trancheAmountExpectation
nominal.AverageOrderCost = nominal.Sold / nominal.Purchased
nominal.EndPrice = tip.Value.Price
return nominal, nil
}
nominal.EndPrice = tip.Value.Price
nominal.Sold = currentValue
nominal.Purchased += tip.Value.Amount
nominal.NominalPercentage = percent
if slippage == percent {
return nominal, nil
}
cumulativeAmounts = currentAmounts
}
nominal.FullBookSideConsumed = true
return nominal, nil
}
// liftAsksByImpactSlippage lifts the asks by the required impact slippage
// percentage, calculated from the reference price and returns orderbook
// movement details.
func (ll *asks) liftAsksByImpactSlippage(slippage, refPrice float64) (*Movement, error) {
if slippage <= 0 {
return nil, errInvalidImpactSlippage
}
if refPrice <= 0 {
return nil, errInvalidReferencePrice
}
if ll.head == nil {
return nil, errNoLiquidity
}
impact := &Movement{StartPrice: refPrice, EndPrice: refPrice}
for tip := ll.head; tip != nil; tip = tip.Next {
percent := math.CalculatePercentageGainOrLoss(tip.Value.Price, refPrice)
impact.ImpactPercentage = percent
impact.EndPrice = tip.Value.Price
if slippage <= percent {
// Don't include this tranche amount as this consumes the tranche
// book price, thus obtaining a higher percentage impact.
return impact, nil
}
impact.Sold += tip.Value.Amount * tip.Value.Price
impact.Purchased += tip.Value.Amount
impact.AverageOrderCost = impact.Sold / impact.Purchased
}
impact.FullBookSideConsumed = true
impact.ImpactPercentage = FullLiquidityExhaustedPercentage
return impact, nil
}
// move moves a node from a point in a node chain to another node position,
// this left justified towards head as element zero is the top of the depth
// side. (can inline)
@@ -526,3 +855,67 @@ func shiftBookmark(tip *Node, bookmark, head **Node, updt Item) bool {
(*bookmark).Next = nil
return true
}
// finalizeFields sets average order costing, percentages, slippage cost and
// preserves existing fields.
func (m *Movement) finalizeFields(cost, amount, headPrice, leftover float64, swap bool) (*Movement, error) {
if cost <= 0 {
return nil, errInvalidCost
}
if amount <= 0 {
return nil, errInvalidAmount
}
if headPrice <= 0 {
return nil, errInvalidHeadPrice
}
if m.StartPrice != m.EndPrice {
// Average order cost defines the actual cost price as capital is
// deployed through the orderbook liquidity.
m.AverageOrderCost = cost / amount
} else {
// Edge case rounding issue for float64 with small numbers.
m.AverageOrderCost = m.StartPrice
}
// Nominal percentage is the difference from the reference price to average
// order cost.
m.NominalPercentage = math.CalculatePercentageGainOrLoss(m.AverageOrderCost, m.StartPrice)
if m.NominalPercentage < 0 {
m.NominalPercentage *= -1
}
if !m.FullBookSideConsumed && leftover == 0 {
// Impact percentage is how much the orderbook slips from the reference
// price to the remaining tranche price.
m.ImpactPercentage = math.CalculatePercentageGainOrLoss(m.EndPrice, m.StartPrice)
if m.ImpactPercentage < 0 {
m.ImpactPercentage *= -1
}
} else {
// Full liquidity exhausted by request amount
m.ImpactPercentage = FullLiquidityExhaustedPercentage
m.FullBookSideConsumed = true
}
// Slippage cost is the difference in quotation terms between the actual
// cost and the amounts at head price e.g.
// Let P(n)=Price A(n)=Amount and iterate through a descending bid order example;
// Cost: $270 (P1:100 x A1:1 + P2:90 x A2:1 + P3:80 x A3:1)
// No slippage cost: $300 (P1:100 x A1:1 + P1:100 x A2:1 + P1:100 x A3:1)
// $300 - $270 = $30 of slippage.
m.SlippageCost = cost - (headPrice * amount)
if m.SlippageCost < 0 {
m.SlippageCost *= -1
}
// Swap saves on code duplication for difference in ask or bid amounts.
if swap {
m.Sold, m.Purchased = m.Purchased, m.Sold
}
return m, nil
}

View File

@@ -30,6 +30,29 @@ var ask = Items{
Item{Price: 1356, Amount: 1},
}
var bid = Items{
Item{Price: 1336, Amount: 1},
Item{Price: 1335, Amount: 1},
Item{Price: 1334, Amount: 1},
Item{Price: 1333, Amount: 1},
Item{Price: 1332, Amount: 1},
Item{Price: 1331, Amount: 1},
Item{Price: 1330, Amount: 1},
Item{Price: 1329, Amount: 1},
Item{Price: 1328, Amount: 1},
Item{Price: 1327, Amount: 1},
Item{Price: 1326, Amount: 1},
Item{Price: 1325, Amount: 1},
Item{Price: 1324, Amount: 1},
Item{Price: 1323, Amount: 1},
Item{Price: 1322, Amount: 1},
Item{Price: 1321, Amount: 1},
Item{Price: 1320, Amount: 1},
Item{Price: 1319, Amount: 1},
Item{Price: 1318, Amount: 1},
Item{Price: 1317, Amount: 1},
}
// Display displays depth content for tests
func (ll *linkedList) display() {
for tip := ll.head; tip != nil; tip = tip.Next {
@@ -1447,3 +1470,674 @@ func TestShiftBookmark(t *testing.T) {
t.Fatal("unexpected pointer variable")
}
}
func TestGetMovementByBaseAmount(t *testing.T) {
t.Parallel()
cases := []struct {
Name string
BaseAmount float64
ReferencePrice float64
BidLiquidity Items
ExpectedNominal float64
ExpectedImpact float64
ExpectedCost float64
ExpectedError error
}{
{
Name: "no amount",
ExpectedError: errBaseAmountInvalid,
},
{
Name: "no reference price",
BaseAmount: 1,
ExpectedError: errInvalidReferencePrice,
},
{
Name: "not enough liquidity to service quote amount",
BaseAmount: 1,
ReferencePrice: 1000,
ExpectedError: errNoLiquidity,
},
{
Name: "thrasher test",
BidLiquidity: Items{{Price: 10000, Amount: 2}, {Price: 9900, Amount: 7}, {Price: 9800, Amount: 3}},
BaseAmount: 10,
ReferencePrice: 10000,
ExpectedNominal: 0.8999999999999999,
ExpectedImpact: 2,
ExpectedCost: 900,
},
{
Name: "consume first tranche",
BidLiquidity: Items{{Price: 10000, Amount: 2}, {Price: 9900, Amount: 7}, {Price: 9800, Amount: 3}},
BaseAmount: 2,
ReferencePrice: 10000,
ExpectedNominal: 0,
ExpectedImpact: 1,
ExpectedCost: 0,
},
{
Name: "consume most of first tranche",
BidLiquidity: Items{{Price: 10000, Amount: 2}, {Price: 9900, Amount: 7}, {Price: 9800, Amount: 3}},
BaseAmount: 1.5,
ReferencePrice: 10000,
ExpectedNominal: 0,
ExpectedImpact: 0,
ExpectedCost: 0,
},
{
Name: "consume full liquidity",
BidLiquidity: Items{{Price: 10000, Amount: 2}, {Price: 9900, Amount: 7}, {Price: 9800, Amount: 3}},
BaseAmount: 12,
ReferencePrice: 10000,
ExpectedNominal: 1.0833333333333395,
ExpectedImpact: FullLiquidityExhaustedPercentage,
ExpectedCost: 1300,
},
}
for x := range cases {
tt := cases[x]
t.Run(tt.Name, func(t *testing.T) {
t.Parallel()
depth := NewDepth(id)
depth.LoadSnapshot(tt.BidLiquidity, nil, 0, time.Time{}, true)
movement, err := depth.bids.getMovementByBase(tt.BaseAmount, tt.ReferencePrice, false)
if !errors.Is(err, tt.ExpectedError) {
t.Fatalf("received: '%v' but expected: '%v'", err, tt.ExpectedError)
}
if movement == nil {
return
}
if movement.NominalPercentage != tt.ExpectedNominal {
t.Fatalf("nominal received: '%v' but expected: '%v'",
movement.NominalPercentage, tt.ExpectedNominal)
}
if movement.ImpactPercentage != tt.ExpectedImpact {
t.Fatalf("impact received: '%v' but expected: '%v'",
movement.ImpactPercentage, tt.ExpectedImpact)
}
if movement.SlippageCost != tt.ExpectedCost {
t.Fatalf("cost received: '%v' but expected: '%v'",
movement.SlippageCost, tt.ExpectedCost)
}
})
}
}
func TestGetBaseAmountFromNominalSlippage(t *testing.T) {
t.Parallel()
cases := []struct {
Name string
NominalSlippage float64
ReferencePrice float64
BidLiquidity Items
ExpectedShift *Movement
ExpectedError error
}{
{
Name: "invalid slippage",
NominalSlippage: -1,
ExpectedError: errInvalidNominalSlippage,
},
{
Name: "invalid slippage - larger than 100%",
NominalSlippage: 101,
ExpectedError: errInvalidSlippageCannotExceed100,
},
{
Name: "no reference price",
NominalSlippage: 1,
ExpectedError: errInvalidReferencePrice,
},
{
Name: "no liquidity to service quote amount",
NominalSlippage: 1,
ReferencePrice: 1000,
ExpectedError: errNoLiquidity,
},
{
Name: "thrasher test",
BidLiquidity: Items{{Price: 10000, Amount: 2}, {Price: 9900, Amount: 7}, {Price: 9800, Amount: 3}},
NominalSlippage: 1,
ReferencePrice: 10000,
ExpectedShift: &Movement{
Sold: 11,
Purchased: 108900,
AverageOrderCost: 9900,
NominalPercentage: 1,
StartPrice: 10000,
EndPrice: 9800,
},
},
{
Name: "consume first tranche - take one amount out of second",
BidLiquidity: Items{{Price: 10000, Amount: 2}, {Price: 9900, Amount: 7}, {Price: 9800, Amount: 3}},
NominalSlippage: 0.33333333333334,
ReferencePrice: 10000,
ExpectedShift: &Movement{
Sold: 3.0000000000000275, // <- expected rounding issue
Purchased: 29900.00000000027,
AverageOrderCost: 9966.666666666664,
NominalPercentage: 0.33333333333334,
StartPrice: 10000,
EndPrice: 9900,
},
},
{
Name: "consume full liquidity",
BidLiquidity: Items{{Price: 10000, Amount: 2}, {Price: 9900, Amount: 7}, {Price: 9800, Amount: 3}},
NominalSlippage: 10,
ReferencePrice: 10000,
ExpectedShift: &Movement{
Sold: 12,
Purchased: 118700,
AverageOrderCost: 9891.666666666666,
NominalPercentage: 1.0833333333333395,
StartPrice: 10000,
EndPrice: 9800,
FullBookSideConsumed: true,
},
},
{
Name: "scotts lovely slippery slippage requirements",
BidLiquidity: Items{{Price: 10000, Amount: 2}, {Price: 9900, Amount: 7}, {Price: 9800, Amount: 3}},
NominalSlippage: 0.00000000000000000000000000000000000000000000000000000000000000000000000000000000001,
ReferencePrice: 10000,
ExpectedShift: &Movement{
Sold: 2,
Purchased: 20000,
AverageOrderCost: 10000,
StartPrice: 10000,
EndPrice: 10000,
},
},
}
for x := range cases {
tt := cases[x]
t.Run(tt.Name, func(t *testing.T) {
t.Parallel()
depth := NewDepth(id)
depth.LoadSnapshot(tt.BidLiquidity, nil, 0, time.Time{}, true)
base, err := depth.bids.hitBidsByNominalSlippage(tt.NominalSlippage, tt.ReferencePrice)
if !errors.Is(err, tt.ExpectedError) {
t.Fatalf("%s received: '%v' but expected: '%v'",
tt.Name, err, tt.ExpectedError)
}
if !base.IsEqual(tt.ExpectedShift) {
t.Fatalf("%s quote received: '%+v' but expected: '%+v'",
tt.Name, base, tt.ExpectedShift)
}
})
}
}
// IsEqual is a tester function for comparison.
func (m *Movement) IsEqual(that *Movement) bool {
if m == nil || that == nil {
return m == nil && that == nil
}
return m.FullBookSideConsumed == that.FullBookSideConsumed &&
m.Sold == that.Sold &&
m.Purchased == that.Purchased &&
m.NominalPercentage == that.NominalPercentage &&
m.ImpactPercentage == that.ImpactPercentage &&
m.EndPrice == that.EndPrice &&
m.StartPrice == that.StartPrice &&
m.AverageOrderCost == that.AverageOrderCost
}
func TestGetBaseAmountFromImpact(t *testing.T) {
t.Parallel()
cases := []struct {
Name string
ImpactSlippage float64
ReferencePrice float64
BidLiquidity Items
ExpectedShift *Movement
ExpectedError error
}{
{
Name: "invalid slippage",
ExpectedError: errInvalidImpactSlippage,
},
{
Name: "invalid slippage - exceed 100%",
ImpactSlippage: 101,
ExpectedError: errInvalidSlippageCannotExceed100,
},
{
Name: "no reference price",
ImpactSlippage: 1,
ExpectedError: errInvalidReferencePrice,
},
{
Name: "no liquidity",
ImpactSlippage: 1,
ReferencePrice: 10000,
ExpectedError: errNoLiquidity,
},
{
Name: "thrasher test",
BidLiquidity: Items{{Price: 10000, Amount: 2}, {Price: 9900, Amount: 7}, {Price: 9800, Amount: 3}},
ImpactSlippage: 1,
ReferencePrice: 10000,
ExpectedShift: &Movement{
Sold: 2,
Purchased: 20000,
ImpactPercentage: 1,
AverageOrderCost: 10000,
StartPrice: 10000,
EndPrice: 9900,
},
},
{
Name: "consume first tranche and second tranche",
BidLiquidity: Items{{Price: 10000, Amount: 2}, {Price: 9900, Amount: 7}, {Price: 9800, Amount: 3}},
ImpactSlippage: 2,
ReferencePrice: 10000,
ExpectedShift: &Movement{
Sold: 9,
Purchased: 89300,
AverageOrderCost: 9922.222222222223,
ImpactPercentage: 2,
StartPrice: 10000,
EndPrice: 9800,
},
},
{
Name: "consume full liquidity",
BidLiquidity: Items{{Price: 10000, Amount: 2}, {Price: 9900, Amount: 7}, {Price: 9800, Amount: 3}},
ImpactSlippage: 10,
ReferencePrice: 10000,
ExpectedShift: &Movement{
Sold: 12,
Purchased: 118700,
ImpactPercentage: FullLiquidityExhaustedPercentage,
AverageOrderCost: 9891.666666666666,
StartPrice: 10000,
EndPrice: 9800,
FullBookSideConsumed: true,
},
},
}
for x := range cases {
tt := cases[x]
t.Run(tt.Name, func(t *testing.T) {
t.Parallel()
depth := NewDepth(id)
depth.LoadSnapshot(tt.BidLiquidity, nil, 0, time.Time{}, true)
base, err := depth.bids.hitBidsByImpactSlippage(tt.ImpactSlippage, tt.ReferencePrice)
if !errors.Is(err, tt.ExpectedError) {
t.Fatalf("%s received: '%v' but expected: '%v'", tt.Name, err, tt.ExpectedError)
}
if !base.IsEqual(tt.ExpectedShift) {
t.Fatalf("%s quote received: '%+v' but expected: '%+v'",
tt.Name, base, tt.ExpectedShift)
}
})
}
}
func TestGetMovementByQuoteAmount(t *testing.T) {
t.Parallel()
cases := []struct {
Name string
QuoteAmount float64
ReferencePrice float64
AskLiquidity Items
ExpectedNominal float64
ExpectedImpact float64
ExpectedCost float64
ExpectedError error
}{
{
Name: "no amount",
ExpectedError: errQuoteAmountInvalid,
},
{
Name: "no reference price",
QuoteAmount: 1,
ExpectedError: errInvalidReferencePrice,
},
{
Name: "not enough liquidity to service quote amount",
QuoteAmount: 1,
ReferencePrice: 1000,
ExpectedError: errNoLiquidity,
},
{
Name: "thrasher test",
AskLiquidity: Items{{Price: 10000, Amount: 2}, {Price: 10100, Amount: 7}, {Price: 10200, Amount: 3}},
QuoteAmount: 100900,
ReferencePrice: 10000,
ExpectedNominal: 0.8999999999999999,
ExpectedImpact: 2,
ExpectedCost: 900,
},
{
Name: "consume first tranche",
AskLiquidity: Items{{Price: 10000, Amount: 2}, {Price: 10100, Amount: 7}, {Price: 10200, Amount: 3}},
QuoteAmount: 20000,
ReferencePrice: 10000,
ExpectedNominal: 0,
ExpectedImpact: 1,
ExpectedCost: 0,
},
{
Name: "consume most of first tranche",
AskLiquidity: Items{{Price: 10000, Amount: 2}, {Price: 10100, Amount: 7}, {Price: 10200, Amount: 3}},
QuoteAmount: 15000,
ReferencePrice: 10000,
ExpectedNominal: 0,
ExpectedImpact: 0,
ExpectedCost: 0,
},
{
Name: "consume full liquidity",
AskLiquidity: Items{{Price: 10000, Amount: 2}, {Price: 10100, Amount: 7}, {Price: 10200, Amount: 3}},
QuoteAmount: 121300,
ReferencePrice: 10000,
ExpectedNominal: 1.0833333333333395,
ExpectedImpact: FullLiquidityExhaustedPercentage,
ExpectedCost: 1300,
},
}
for x := range cases {
tt := cases[x]
t.Run(tt.Name, func(t *testing.T) {
t.Parallel()
depth := NewDepth(id)
depth.LoadSnapshot(nil, tt.AskLiquidity, 0, time.Time{}, true)
movement, err := depth.asks.getMovementByQuotation(tt.QuoteAmount, tt.ReferencePrice, false)
if !errors.Is(err, tt.ExpectedError) {
t.Fatalf("received: '%v' but expected: '%v'", err, tt.ExpectedError)
}
if movement == nil {
return
}
if movement.NominalPercentage != tt.ExpectedNominal {
t.Fatalf("nominal received: '%v' but expected: '%v'",
movement.NominalPercentage, tt.ExpectedNominal)
}
if movement.ImpactPercentage != tt.ExpectedImpact {
t.Fatalf("impact received: '%v' but expected: '%v'",
movement.ImpactPercentage, tt.ExpectedImpact)
}
if movement.SlippageCost != tt.ExpectedCost {
t.Fatalf("cost received: '%v' but expected: '%v'",
movement.SlippageCost, tt.ExpectedCost)
}
})
}
}
func TestGetQuoteAmountFromNominalSlippage(t *testing.T) {
t.Parallel()
cases := []struct {
Name string
NominalSlippage float64
ReferencePrice float64
AskLiquidity Items
ExpectedShift *Movement
ExpectedError error
}{
{
Name: "invalid slippage",
NominalSlippage: -1,
ExpectedError: errInvalidNominalSlippage,
},
{
Name: "no reference price",
NominalSlippage: 1,
ExpectedError: errInvalidReferencePrice,
},
{
Name: "no liquidity",
NominalSlippage: 1,
ReferencePrice: 10000,
ExpectedError: errNoLiquidity,
},
{
Name: "consume first tranche - one amount on second tranche",
AskLiquidity: Items{{Price: 10000, Amount: 2}, {Price: 10100, Amount: 7}, {Price: 10200, Amount: 3}},
NominalSlippage: 0.33333333333334,
ReferencePrice: 10000,
ExpectedShift: &Movement{
Sold: 30100.000000000276, // <- expected rounding issue
Purchased: 3.0000000000000275,
AverageOrderCost: 10033.333333333333333333333333333,
NominalPercentage: 0.33333333333334,
StartPrice: 10000,
EndPrice: 10100,
},
},
{
Name: "last tranche total agg meeting 1 percent nominally",
AskLiquidity: Items{{Price: 10000, Amount: 2}, {Price: 10100, Amount: 7}, {Price: 10200, Amount: 3}},
NominalSlippage: 1,
ReferencePrice: 10000,
ExpectedShift: &Movement{
Sold: 111100,
Purchased: 11,
AverageOrderCost: 10100,
NominalPercentage: 1,
StartPrice: 10000,
EndPrice: 10200,
},
},
{
Name: "take full second tranche",
AskLiquidity: Items{{Price: 10000, Amount: 2}, {Price: 10100, Amount: 7}, {Price: 10200, Amount: 3}},
NominalSlippage: 0.7777777777777738,
ReferencePrice: 10000,
ExpectedShift: &Movement{
Sold: 90700,
Purchased: 9,
AverageOrderCost: 10077.777777777777,
NominalPercentage: 0.7777777777777738,
StartPrice: 10000,
EndPrice: 10100,
},
},
{
Name: "consume full liquidity",
AskLiquidity: Items{{Price: 10000, Amount: 2}, {Price: 10100, Amount: 7}, {Price: 10200, Amount: 3}},
NominalSlippage: 10,
ReferencePrice: 10000,
ExpectedShift: &Movement{
Sold: 121300,
Purchased: 12,
AverageOrderCost: 10108.333333333334,
NominalPercentage: 1.0833333333333395,
StartPrice: 10000,
EndPrice: 10200,
FullBookSideConsumed: true,
},
},
{
Name: "scotts lovely slippery slippage requirements",
AskLiquidity: Items{{Price: 10000, Amount: 2}, {Price: 10100, Amount: 7}, {Price: 10200, Amount: 3}},
NominalSlippage: 0.00000000000000000000000000000000000000000000000000000000000000000000000000000000001,
ReferencePrice: 10000,
ExpectedShift: &Movement{
Sold: 20000,
Purchased: 2,
AverageOrderCost: 10000,
StartPrice: 10000,
EndPrice: 10000,
},
},
}
for x := range cases {
tt := cases[x]
t.Run(tt.Name, func(t *testing.T) {
t.Parallel()
depth := NewDepth(id)
depth.LoadSnapshot(nil, tt.AskLiquidity, 0, time.Time{}, true)
quote, err := depth.asks.liftAsksByNominalSlippage(tt.NominalSlippage, tt.ReferencePrice)
if !errors.Is(err, tt.ExpectedError) {
t.Fatalf("%s received: '%v' but expected: '%v'", tt.Name, err, tt.ExpectedError)
}
if !quote.IsEqual(tt.ExpectedShift) {
t.Fatalf("%s quote received: '%+v' but expected: '%+v'",
tt.Name, quote, tt.ExpectedShift)
}
})
}
}
func TestGetQuoteAmountFromImpact(t *testing.T) {
t.Parallel()
cases := []struct {
Name string
ImpactSlippage float64
ReferencePrice float64
AskLiquidity Items
ExpectedShift *Movement
ExpectedError error
}{
{
Name: "invalid slippage",
ImpactSlippage: -1,
ExpectedError: errInvalidImpactSlippage,
},
{
Name: "no reference price",
ImpactSlippage: 1,
ExpectedError: errInvalidReferencePrice,
},
{
Name: "no liquidity",
ImpactSlippage: 1,
ReferencePrice: 1000,
ExpectedError: errNoLiquidity,
},
{
Name: "thrasher test",
AskLiquidity: Items{{Price: 10000, Amount: 2}, {Price: 10100, Amount: 7}, {Price: 10200, Amount: 3}},
ImpactSlippage: 1,
ReferencePrice: 10000,
ExpectedShift: &Movement{
Sold: 20000,
Purchased: 2,
AverageOrderCost: 10000,
ImpactPercentage: 1,
StartPrice: 10000,
EndPrice: 10100,
},
},
{
Name: "consume first tranche and second tranche",
AskLiquidity: Items{{Price: 10000, Amount: 2}, {Price: 10100, Amount: 7}, {Price: 10200, Amount: 3}},
ImpactSlippage: 2,
ReferencePrice: 10000,
ExpectedShift: &Movement{
Sold: 90700,
Purchased: 9,
AverageOrderCost: 10077.777777777777777777777777778,
ImpactPercentage: 2,
StartPrice: 10000,
EndPrice: 10200,
},
},
{
Name: "consume full liquidity",
AskLiquidity: Items{{Price: 10000, Amount: 2}, {Price: 10100, Amount: 7}, {Price: 10200, Amount: 3}},
ImpactSlippage: 10,
ReferencePrice: 10000,
ExpectedShift: &Movement{
Sold: 121300,
Purchased: 12,
AverageOrderCost: 10108.333333333333333333333333333,
ImpactPercentage: FullLiquidityExhaustedPercentage,
StartPrice: 10000,
EndPrice: 10200,
FullBookSideConsumed: true,
},
},
}
for x := range cases {
tt := cases[x]
t.Run(tt.Name, func(t *testing.T) {
t.Parallel()
depth := NewDepth(id)
depth.LoadSnapshot(nil, tt.AskLiquidity, 0, time.Time{}, true)
quote, err := depth.asks.liftAsksByImpactSlippage(tt.ImpactSlippage, tt.ReferencePrice)
if !errors.Is(err, tt.ExpectedError) {
t.Fatalf("received: '%v' but expected: '%v'", err, tt.ExpectedError)
}
if !quote.IsEqual(tt.ExpectedShift) {
t.Fatalf("%s quote received: '%+v' but expected: '%+v'",
tt.Name, quote, tt.ExpectedShift)
}
})
}
}
func TestGetHeadPrice(t *testing.T) {
t.Parallel()
depth := NewDepth(id)
if _, err := depth.bids.getHeadPriceNoLock(); !errors.Is(err, errNoLiquidity) {
t.Fatalf("received: '%v' but expected: '%v'", err, errNoLiquidity)
}
if _, err := depth.asks.getHeadPriceNoLock(); !errors.Is(err, errNoLiquidity) {
t.Fatalf("received: '%v' but expected: '%v'", err, errNoLiquidity)
}
depth.LoadSnapshot(bid, ask, 0, time.Time{}, true)
val, err := depth.bids.getHeadPriceNoLock()
if !errors.Is(err, nil) {
t.Fatalf("received: '%v' but expected: '%v'", err, nil)
}
if val != 1336 {
t.Fatal("unexpected value")
}
val, err = depth.asks.getHeadPriceNoLock()
if !errors.Is(err, nil) {
t.Fatalf("received: '%v' but expected: '%v'", err, nil)
}
if val != 1337 {
t.Fatal("unexpected value", val)
}
}
func TestFinalizeFields(t *testing.T) {
m := &Movement{}
_, err := m.finalizeFields(0, 0, 0, 0, false)
if !errors.Is(err, errInvalidCost) {
t.Fatalf("received: '%v' but expected: '%v'", err, errInvalidCost)
}
_, err = m.finalizeFields(1, 0, 0, 0, false)
if !errors.Is(err, errInvalidAmount) {
t.Fatalf("received: '%v' but expected: '%v'", err, errInvalidAmount)
}
_, err = m.finalizeFields(1, 1, 0, 0, false)
if !errors.Is(err, errInvalidHeadPrice) {
t.Fatalf("received: '%v' but expected: '%v'", err, errInvalidHeadPrice)
}
// Test slippage as per https://en.wikipedia.org/wiki/Slippage_(finance)
mov, err := m.finalizeFields(20000*151.11585, 20000, 151.08, 0, false)
if !errors.Is(err, nil) {
t.Fatalf("received: '%v' but expected: '%v'", err, nil)
}
if mov.SlippageCost != 716.9999999995343 {
t.Fatalf("received: '%v' but expected: '%v'", mov.SlippageCost, 716.9999999995343)
}
}

View File

@@ -148,3 +148,40 @@ type Update struct {
// only exchange utilising this field.
MaxDepth int
}
// Movement defines orderbook traversal details from either hitting the bids or
// lifting the asks.
type Movement struct {
// NominalPercentage (real-world) defines how far in percentage terms is
// your average order price away from the reference price.
NominalPercentage float64
// ImpactPercentage defines how far the price has moved on the order book
// from the reference price.
ImpactPercentage float64
// SlippageCost is the cost of the slippage. This is priced in quotation.
SlippageCost float64
// StartPrice defines the reference price or the head of the orderbook side.
StartPrice float64
// EndPrice defines where the price has ended on the orderbook side.
EndPrice float64
// Sold defines the amount of currency sold.
Sold float64
// Purchases defines the amount of currency purchased.
Purchased float64
// AverageOrderCost defines the average order cost of position as it slips
// through the orderbook tranches.
AverageOrderCost float64
// FullBookSideConsumed defines if the orderbook liquidty has been consumed
// by the requested amount. This might not represent the actual book on the
// exchange as they might restrict the amount of information being passed
// back from either a REST request or websocket stream.
FullBookSideConsumed bool
}
// SideAmounts define the amounts total for the tranches, total value in
// quotation and the cumulative base amounts.
type SideAmounts struct {
Tranches int64
QuoteValue float64
BaseAmount float64
}

View File

@@ -12,14 +12,18 @@ import (
func TestSimulate(t *testing.T) {
b := bitstamp.Bitstamp{}
b.SetDefaults()
b.Verbose = false
o, err := b.FetchOrderbook(context.Background(),
currency.NewPair(currency.BTC, currency.USD), asset.Spot)
if err != nil {
t.Error(err)
}
r := o.SimulateOrder(10000000, true)
t.Log(r.Status)
r = o.SimulateOrder(2171, false)
t.Log(r.Status)
_, err = o.SimulateOrder(10000000, true)
if err != nil {
t.Fatal(err)
}
_, err = o.SimulateOrder(2171, false)
if err != nil {
t.Fatal(err)
}
}