diff --git a/model/number.go b/model/number.go index bd8728932..b18e81ba4 100644 --- a/model/number.go +++ b/model/number.go @@ -1,6 +1,7 @@ package model import ( + "fmt" "log" "math" "strconv" @@ -27,6 +28,25 @@ func (n Number) AsString() string { return strconv.FormatFloat(n.AsFloat(), 'f', int(n.Precision()), 64) } +// AsRatio returns an integer numerator and denominator +func (n Number) AsRatio() (int32, int32, error) { + denominator := int32(math.Pow(10, float64(n.Precision()))) + + // add an adjustment because the computed value should not have any digits beyond the decimal + // and we want to roll over values that are not computed correctly rather than using the expensive math/big library + adjustment := 0.1 + if n.AsFloat() < 0 { + adjustment = -0.1 + } + numerator := int32(n.AsFloat()*float64(denominator) + adjustment) + + if float64(numerator)/float64(denominator) != n.AsFloat() { + return 0, 0, fmt.Errorf("invalid conversion to a ratio probably caused by an overflow, float input: %f, numerator: %d, denominator: %d", n.AsFloat(), numerator, denominator) + } + + return numerator, denominator, nil +} + // String is the Stringer interface impl. func (n Number) String() string { return n.AsString() diff --git a/model/number_test.go b/model/number_test.go new file mode 100644 index 000000000..d2d5589e3 --- /dev/null +++ b/model/number_test.go @@ -0,0 +1,71 @@ +package model + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestAsRatio(t *testing.T) { + testCases := []struct { + n *Number + wantN int32 + wantD int32 + }{ + { + n: NumberFromFloat(0.251523, 6), + wantN: 251523, + wantD: 1000000, + }, { + n: NumberFromFloat(-0.251523, 6), + wantN: -251523, + wantD: 1000000, + }, { + n: NumberFromFloat(0.251841, 6), + wantN: 251841, + wantD: 1000000, + }, { + n: NumberFromFloat(-0.251841, 6), + wantN: -251841, + wantD: 1000000, + }, + } + + for _, kase := range testCases { + t.Run(kase.n.AsString(), func(t *testing.T) { + num, den, e := kase.n.AsRatio() + if !assert.NoError(t, e) { + return + } + + if !assert.Equal(t, kase.wantN, num) { + return + } + assert.Equal(t, kase.wantD, den) + }) + } +} + +func TestAsRatio_Error(t *testing.T) { + testCases := []struct { + n *Number + }{ + { + n: NumberFromFloat(1.0, 10), + }, { + n: NumberFromFloat(2.5, 9), + }, { + n: NumberFromFloat(-2.5, 9), + }, + } + + for _, kase := range testCases { + t.Run(kase.n.AsString(), func(t *testing.T) { + num, den, e := kase.n.AsRatio() + if !assert.Error(t, e, fmt.Sprintf("got back num=%d, den=%d", num, den)) { + return + } + }) + } +} diff --git a/plugins/sellSideStrategy.go b/plugins/sellSideStrategy.go index 584bbb258..6f346b58c 100644 --- a/plugins/sellSideStrategy.go +++ b/plugins/sellSideStrategy.go @@ -62,29 +62,66 @@ func makeSellSideStrategy( // PruneExistingOffers impl func (s *sellSideStrategy) PruneExistingOffers(offers []horizon.Offer) ([]build.TransactionMutator, []horizon.Offer) { + // figure out which offers we want to prune + shouldPrune := computeOffersToPrune(offers, s.currentLevels) + pruneOps := []build.TransactionMutator{} - for i := 0; i < len(offers); i++ { - curAmount := utils.AmountStringAsFloat(offers[i].Amount) - curPrice := utils.GetPrice(offers[i]) + updatedOffers := []horizon.Offer{} + for i, offer := range offers { + isPruning := shouldPrune[i] + if isPruning { + pOp := s.sdex.DeleteOffer(offer) + pruneOps = append(pruneOps, &pOp) + } else { + updatedOffers = append(updatedOffers, offer) + } + + curAmount := utils.AmountStringAsFloat(offer.Amount) + curPrice := utils.GetPrice(offer) if s.divideAmountByPrice { curAmount = curAmount * curPrice curPrice = 1 / curPrice } - // base and quote here refers to the bot's base and quote, not the base and quote of the sellSideStrategy - isPruning := i >= len(s.currentLevels) log.Printf("offer | %s | level=%d | curPriceQuote=%.7f | curAmtBase=%.7f | pruning=%v\n", s.action, i+1, curPrice, curAmount, isPruning) + } + return pruneOps, updatedOffers +} - if isPruning { - pOp := s.sdex.DeleteOffer(offers[i]) - pruneOps = append(pruneOps, &pOp) - } +// computeOffersToPrune returns a list of bools representing whether we should prune the offer at that position or not +func computeOffersToPrune(offers []horizon.Offer, levels []api.Level) []bool { + numToPrune := len(offers) - len(levels) + if numToPrune <= 0 { + return make([]bool, len(offers)) } - if len(offers) > len(s.currentLevels) { - offers = offers[:len(s.currentLevels)] + offerIdx := 0 + levelIdx := 0 + shouldPrune := make([]bool, len(offers)) + for numToPrune > 0 { + if offerIdx == len(offers) || levelIdx == len(levels) { + // prune remaining offers (from the back as a convention) + for i := 0; i < numToPrune; i++ { + shouldPrune[len(offers)-1-i] = true + } + return shouldPrune + } + + offerPrice := float64(offers[offerIdx].PriceR.N) / float64(offers[offerIdx].PriceR.D) + levelPrice := levels[levelIdx].Price.AsFloat() + if offerPrice < levelPrice { + shouldPrune[offerIdx] = true + numToPrune-- + offerIdx++ + } else if offerPrice == levelPrice { + shouldPrune[offerIdx] = false + offerIdx++ + // do not increment levelIdx because we could have two offers or levels at the same price. This will resolve in the next iteration automatically. + } else { + levelIdx++ + } } - return pruneOps, offers + return shouldPrune } // PreUpdate impl diff --git a/plugins/sellSideStrategy_test.go b/plugins/sellSideStrategy_test.go new file mode 100644 index 000000000..5fbb04796 --- /dev/null +++ b/plugins/sellSideStrategy_test.go @@ -0,0 +1,112 @@ +package plugins + +import ( + "fmt" + "testing" + + "github.com/lightyeario/kelp/api" + "github.com/lightyeario/kelp/model" + "github.com/stellar/go/clients/horizon" + "github.com/stretchr/testify/assert" +) + +func TestComputeOffersToPrune(t *testing.T) { + testCases := []struct { + offerPrices []float64 + levelPrices []float64 + want []bool + }{ + { + offerPrices: []float64{}, + levelPrices: []float64{}, + want: []bool{}, + }, { + offerPrices: []float64{}, + levelPrices: []float64{1.0}, + want: []bool{}, + }, { + offerPrices: []float64{1.0}, + levelPrices: []float64{1.0}, + want: []bool{false}, + }, { + offerPrices: []float64{1.0}, + levelPrices: []float64{1.0, 1.2}, + want: []bool{false}, + }, { + offerPrices: []float64{0.9}, + levelPrices: []float64{1.0, 1.2}, + want: []bool{false}, + }, { + offerPrices: []float64{1.0, 1.2}, + levelPrices: []float64{1.0}, + want: []bool{false, true}, + }, { + offerPrices: []float64{0.9, 1.0}, + levelPrices: []float64{1.0}, + want: []bool{true, false}, + }, { + offerPrices: []float64{10.0, 11.0}, + levelPrices: []float64{1.0}, + want: []bool{false, true}, + }, { + offerPrices: []float64{0.9, 1.2}, + levelPrices: []float64{1.0}, + want: []bool{true, false}, + }, { + offerPrices: []float64{1.0, 1.0}, + levelPrices: []float64{1.0}, + want: []bool{false, true}, + }, { + offerPrices: []float64{1.0, 1.0, 1.2}, + levelPrices: []float64{1.0}, + want: []bool{false, true, true}, + }, { + offerPrices: []float64{1.0, 1.0}, + levelPrices: []float64{1.0, 1.0}, + want: []bool{false, false}, + }, { + offerPrices: []float64{1.0, 1.2}, + levelPrices: []float64{1.0, 1.2}, + want: []bool{false, false}, + }, { + offerPrices: []float64{1.0}, + levelPrices: []float64{1.0, 1.0}, + want: []bool{false}, + }, { + offerPrices: []float64{1.0}, + levelPrices: []float64{1.0, 1.2}, + want: []bool{false}, + }, + } + + for i, kase := range testCases { + t.Run(fmt.Sprintf("%d", i), func(t *testing.T) { + if !assert.Equal(t, len(kase.want), len(kase.offerPrices), "invalid test case") { + return + } + + offers := []horizon.Offer{} + for _, p := range kase.offerPrices { + num, den, e := model.NumberFromFloat(p, 8).AsRatio() + if !assert.NoError(t, e) { + return + } + offer := horizon.Offer{} + offer.PriceR.N = num + offer.PriceR.D = den + offers = append(offers, offer) + } + + levels := []api.Level{} + for _, p := range kase.levelPrices { + levels = append(levels, api.Level{ + Price: *model.NumberFromFloat(p, 8), + Amount: *model.NumberFromFloat(1, 8), + }) + } + + result := computeOffersToPrune(offers, levels) + assert.Equal(t, kase.want, result) + }) + } +}