Skip to content
This repository has been archived by the owner on Feb 1, 2024. It is now read-only.

Fix rounding issues in mirror strategy causing offers to not be placed, closes #541 #544

Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 47 additions & 8 deletions model/number.go
Original file line number Diff line number Diff line change
@@ -21,6 +21,9 @@ var NumberConstants = struct {
// InvertPrecision is the precision of the number after it is inverted
const InvertPrecision = 15

// InternalCalculationsPrecision is the precision to be used for internal calculations in a function
const InternalCalculationsPrecision = 15

// Number abstraction
type Number struct {
value float64
@@ -76,18 +79,30 @@ func (n Number) Subtract(n2 Number) *Number {
return NumberFromFloat(n.AsFloat()-n2.AsFloat(), newPrecision)
}

// Multiply returns a new Number after multiplying with the passed in Number
// Multiply returns a new Number after multiplying with the passed in Number by rounding up based on the smaller precision
func (n Number) Multiply(n2 Number) *Number {
newPrecision := minPrecision(n, n2)
return NumberFromFloat(n.AsFloat()*n2.AsFloat(), newPrecision)
}

// Divide returns a new Number after dividing by the passed in Number
// MultiplyRoundTruncate returns a new Number after multiplying with the passed in Number by truncating based on the smaller precision
func (n Number) MultiplyRoundTruncate(n2 Number) *Number {
newPrecision := minPrecision(n, n2)
return NumberFromFloatRoundTruncate(n.AsFloat()*n2.AsFloat(), newPrecision)
}

// Divide returns a new Number after dividing by the passed in Number by rounding up based on the smaller precision
func (n Number) Divide(n2 Number) *Number {
newPrecision := minPrecision(n, n2)
return NumberFromFloat(n.AsFloat()/n2.AsFloat(), newPrecision)
}

// DivideRoundTruncate returns a new Number after dividing by the passed in Number by truncating based on the smaller precision
func (n Number) DivideRoundTruncate(n2 Number) *Number {
newPrecision := minPrecision(n, n2)
return NumberFromFloatRoundTruncate(n.AsFloat()/n2.AsFloat(), newPrecision)
}

// Scale takes in a scalar with which to multiply the number using the same precision of the original number
func (n Number) Scale(scaleFactor float64) *Number {
return NumberFromFloat(n.AsFloat()*scaleFactor, n.precision)
@@ -103,10 +118,18 @@ func (n Number) String() string {
return n.AsString()
}

// NumberFromFloat makes a Number from a float
// NumberFromFloat makes a Number from a float by rounding up
func NumberFromFloat(f float64, precision int8) *Number {
return &Number{
value: toFixed(f, precision),
value: toFixed(f, precision, RoundUp),
precision: precision,
}
}

// NumberFromFloatRoundTruncate makes a Number from a float by truncating beyond the specified precision
func NumberFromFloatRoundTruncate(f float64, precision int8) *Number {
return &Number{
value: toFixed(f, precision, RoundTruncate),
precision: precision,
}
}
@@ -145,13 +168,29 @@ func NumberByCappingPrecision(n *Number, precision int8) *Number {
return n
}

func round(num float64) int64 {
return int64(num + math.Copysign(0.5, num))
func round(num float64, rounding Rounding) int64 {
if rounding == RoundUp {
return int64(num + math.Copysign(0.5, num))
} else if rounding == RoundTruncate {
return int64(num)
} else {
// error
return -1
}
}

func toFixed(num float64, precision int8) float64 {
// Rounding is a type that defines various approaching to rounding numbers
type Rounding int

// Rounding types
const (
RoundUp Rounding = iota
RoundTruncate
)

func toFixed(num float64, precision int8, rounding Rounding) float64 {
output := math.Pow(10, float64(precision))
return float64(round(num*output)) / output
return float64(round(num*output, rounding)) / output
}

func minPrecision(n1 Number, n2 Number) int8 {
132 changes: 96 additions & 36 deletions model/number_test.go
Original file line number Diff line number Diff line change
@@ -55,50 +55,110 @@ func TestNumberFromFloat(t *testing.T) {
}
}

func TestNumberFromFloatRoundTruncate(t *testing.T) {
testCases := []struct {
f float64
precision int8
wantString string
wantFloat float64
}{
{
f: 1.1,
precision: 1,
wantString: "1.1",
wantFloat: 1.1,
}, {
f: 1.1,
precision: 2,
wantString: "1.10",
wantFloat: 1.10,
}, {
f: 1.12,
precision: 1,
wantString: "1.1",
wantFloat: 1.1,
}, {
f: 1.15,
precision: 1,
wantString: "1.1",
wantFloat: 1.1,
}, {
f: 0.12,
precision: 1,
wantString: "0.1",
wantFloat: 0.1,
},
}

for _, kase := range testCases {
t.Run(fmt.Sprintf("%f_%d", kase.f, kase.precision), func(t *testing.T) {
n := NumberFromFloatRoundTruncate(kase.f, kase.precision)
if !assert.Equal(t, kase.wantString, n.AsString()) {
return
}
if !assert.Equal(t, kase.wantFloat, n.AsFloat()) {
return
}
})
}
}

func TestMath(t *testing.T) {
testCases := []struct {
n1 *Number
n2 *Number
wantAdd float64
wantSubtract float64
wantMultiply float64
wantDivide float64
n1 *Number
n2 *Number
wantAdd float64
wantSubtract float64
wantMultiply float64
wantMultiplyRoundTruncate float64
wantDivide float64
wantDivideRoundTruncate float64
}{
{
n1: NumberFromFloat(1.1, 1),
n2: NumberFromFloat(2.1, 1),
wantAdd: 3.2,
wantSubtract: -1.0,
wantMultiply: 2.3,
wantDivide: 0.5,
n1: NumberFromFloat(1.1, 1),
n2: NumberFromFloat(2.1, 1),
wantAdd: 3.2,
wantSubtract: -1.0,
wantMultiply: 2.3,
wantMultiplyRoundTruncate: 2.3,
wantDivide: 0.5,
wantDivideRoundTruncate: 0.5,
}, {
n1: NumberFromFloat(1.15, 1),
n2: NumberFromFloat(2.1, 1),
wantAdd: 3.3,
wantSubtract: -0.9,
wantMultiply: 2.5,
wantDivide: 0.6,
n1: NumberFromFloat(1.15, 1),
n2: NumberFromFloat(2.1, 1),
wantAdd: 3.3,
wantSubtract: -0.9,
wantMultiply: 2.5,
wantMultiplyRoundTruncate: 2.5,
wantDivide: 0.6,
wantDivideRoundTruncate: 0.5,
}, {
n1: NumberFromFloat(1.15, 2),
n2: NumberFromFloat(2.1, 1),
wantAdd: 3.3,
wantSubtract: -1.0,
wantMultiply: 2.4,
wantDivide: 0.5,
n1: NumberFromFloat(1.15, 2),
n2: NumberFromFloat(2.1, 1),
wantAdd: 3.3,
wantSubtract: -1.0,
wantMultiply: 2.4,
wantMultiplyRoundTruncate: 2.4,
wantDivide: 0.5,
wantDivideRoundTruncate: 0.5,
}, {
n1: NumberFromFloat(1.15, 2),
n2: NumberFromFloat(2.1, 2),
wantAdd: 3.25,
wantSubtract: -0.95,
wantMultiply: 2.42,
wantDivide: 0.55,
n1: NumberFromFloat(1.15, 2),
n2: NumberFromFloat(2.1, 2),
wantAdd: 3.25,
wantSubtract: -0.95,
wantMultiply: 2.42,
wantMultiplyRoundTruncate: 2.41,
wantDivide: 0.55,
wantDivideRoundTruncate: 0.54,
}, {
n1: NumberFromFloat(1.12, 2),
n2: NumberFromFloat(2.1, 1),
wantAdd: 3.2,
wantSubtract: -1.0,
wantMultiply: 2.4,
wantDivide: 0.5,
n1: NumberFromFloat(1.12, 2),
n2: NumberFromFloat(2.1, 1),
wantAdd: 3.2,
wantSubtract: -1.0,
wantMultiply: 2.4,
wantMultiplyRoundTruncate: 2.3,
wantDivide: 0.5,
wantDivideRoundTruncate: 0.5,
},
}

44 changes: 25 additions & 19 deletions plugins/mirrorStrategy.go
Original file line number Diff line number Diff line change
@@ -828,12 +828,17 @@ func (b *balanceCoordinator) getPlacedBackingUnits() *model.Number {
return b.placedBackingUnits
}

// checkBalance uses a larger precision for internal calculations because of this bug: https://github.com/stellar/kelp/issues/541
// it eventually rounds back to the precision of the passed in volume
func (b *balanceCoordinator) checkBalance(vol *model.Number, price *model.Number) (bool /*hasBackingBalance*/, *model.Number /*newBaseVolume*/, *model.Number /*newQuoteVolume*/) {
expandedPrecisionVolume := model.NumberFromFloat(vol.AsFloat(), model.InternalCalculationsPrecision)
expandedPrecisionPrice := model.NumberFromFloat(price.AsFloat(), model.InternalCalculationsPrecision)

// we want to constrain units on primary exchange to ensure we can mirror correctly
additionalPrimaryUnits := vol
additionalPrimaryUnits := expandedPrecisionVolume
if b.isPrimaryBuy { // buying base on primary, selling base on backing
// convert to quote units since we are selling quote on primary
additionalPrimaryUnits = vol.Multiply(*price)
additionalPrimaryUnits = vol.MultiplyRoundTruncate(*expandedPrecisionPrice)
}
remainingPrimaryUnits := b.primaryBalance.Subtract(*b.placedPrimaryUnits)
if remainingPrimaryUnits.AsFloat() < 0.0000002 {
@@ -846,10 +851,10 @@ func (b *balanceCoordinator) checkBalance(vol *model.Number, price *model.Number
}

// now do for backing exchange to ensure we can offset trades correctly
additionalBackingUnits := vol
additionalBackingUnits := expandedPrecisionVolume
if !b.isPrimaryBuy { // selling base on primary, buying base on backing
// convert to quote units since we are selling quote on backing
additionalBackingUnits = vol.Multiply(*price)
additionalBackingUnits = vol.MultiplyRoundTruncate(*expandedPrecisionPrice)
}
remainingBackingUnits := b.backingBalance.Subtract(*b.placedBackingUnits)
if remainingBackingUnits.AsFloat() < 0.0000002 {
@@ -865,9 +870,9 @@ func (b *balanceCoordinator) checkBalance(vol *model.Number, price *model.Number
normalizedPrimaryUnits := additionalPrimaryUnits
normalizedBackingUnits := additionalBackingUnits
if b.isPrimaryBuy {
normalizedPrimaryUnits = normalizedPrimaryUnits.Divide(*price)
normalizedPrimaryUnits = normalizedPrimaryUnits.DivideRoundTruncate(*expandedPrecisionPrice)
} else {
normalizedBackingUnits = normalizedBackingUnits.Divide(*price)
normalizedBackingUnits = normalizedBackingUnits.DivideRoundTruncate(*expandedPrecisionPrice)
}
var minBaseUnitsFloat float64
if normalizedBackingUnits.AsFloat() < normalizedPrimaryUnits.AsFloat() {
@@ -880,21 +885,22 @@ func (b *balanceCoordinator) checkBalance(vol *model.Number, price *model.Number
minBaseUnitsFloat = normalizedPrimaryUnits.AsFloat()
log.Printf("balanceCoordinator: nothing to constrain since normalizedPrimaryUnits and normalizedBackingUnits were equal (%.10f)\n", normalizedPrimaryUnits.AsFloat())
}
minPrecision := normalizedPrimaryUnits.Precision()
if normalizedBackingUnits.Precision() < normalizedPrimaryUnits.Precision() {
minPrecision = normalizedBackingUnits.Precision()
}
minBaseUnits := model.NumberFromFloat(minBaseUnitsFloat, minPrecision)
// finally convert back to quote units where necessary
minPrimaryUnits := minBaseUnits
minBackingUnits := minBaseUnits
minBaseUnits := model.NumberFromFloat(minBaseUnitsFloat, model.InternalCalculationsPrecision)

// convert to final precision values now
minBaseUnitsFinalPrecision := model.NumberFromFloatRoundTruncate(minBaseUnits.AsFloat(), vol.Precision())
minQuoteUnits := minBaseUnitsFinalPrecision.MultiplyRoundTruncate(*expandedPrecisionPrice)
minQuoteUnitsFinalPrecision := model.NumberFromFloatRoundTruncate(minQuoteUnits.AsFloat(), vol.Precision())
var minPrimaryUnitsFinalPrecision, minBackingUnitsFinalPrecision *model.Number
if b.isPrimaryBuy {
minPrimaryUnits = minPrimaryUnits.Multiply(*price)
minPrimaryUnitsFinalPrecision = minQuoteUnitsFinalPrecision
minBackingUnitsFinalPrecision = minBaseUnitsFinalPrecision
} else {
minBackingUnits = minBackingUnits.Multiply(*price)
minPrimaryUnitsFinalPrecision = minBaseUnitsFinalPrecision
minBackingUnitsFinalPrecision = minQuoteUnitsFinalPrecision
}

b.placedPrimaryUnits = b.placedPrimaryUnits.Add(*minPrimaryUnits)
b.placedBackingUnits = b.placedBackingUnits.Add(*minBackingUnits)
return true, minBaseUnits, minBaseUnits.Multiply(*price)
b.placedPrimaryUnits = b.placedPrimaryUnits.Add(*minPrimaryUnitsFinalPrecision)
b.placedBackingUnits = b.placedBackingUnits.Add(*minBackingUnitsFinalPrecision)
return true, minBaseUnitsFinalPrecision, minQuoteUnitsFinalPrecision
}
66 changes: 52 additions & 14 deletions plugins/mirrorStrategy_test.go
Original file line number Diff line number Diff line change
@@ -55,10 +55,10 @@ func TestBalanceCoordinatorCheckBalance(t *testing.T) {
inputVol: model.NumberFromFloat(3.14, 5),
inputPrice: model.NumberFromFloat(0.131, 5),
wantHasBackingBalance: true,
wantNewBaseVolume: model.NumberFromFloat(3.14, 5),
wantNewQuoteVolume: model.NumberFromFloat(0.41134, 5),
wantPlacedPrimaryUnits: model.NumberFromFloat(103.7, 5),
wantPlacedBackingUnits: model.NumberFromFloat(8.86834, 5),
wantNewBaseVolume: model.NumberFromFloat(3.13999, 5),
wantNewQuoteVolume: model.NumberFromFloat(0.41133, 5),
wantPlacedPrimaryUnits: model.NumberFromFloat(103.69999, 5),
wantPlacedBackingUnits: model.NumberFromFloat(8.86833, 5),
}, {
name: "3. sell primary-available backing-partial zero",
bc: &balanceCoordinator{
@@ -73,10 +73,10 @@ func TestBalanceCoordinatorCheckBalance(t *testing.T) {
inputVol: model.NumberFromFloat(1.0, 5),
inputPrice: model.NumberFromFloat(0.112, 5),
wantHasBackingBalance: true,
wantNewBaseVolume: model.NumberFromFloat(0.89286, 5),
wantNewQuoteVolume: model.NumberFromFloat(0.1, 5),
wantPlacedPrimaryUnits: model.NumberFromFloat(0.89286, 5),
wantPlacedBackingUnits: model.NumberFromFloat(0.1, 5),
wantNewBaseVolume: model.NumberFromFloat(0.89285, 5),
wantNewQuoteVolume: model.NumberFromFloat(0.09999, 5),
wantPlacedPrimaryUnits: model.NumberFromFloat(0.89285, 5),
wantPlacedBackingUnits: model.NumberFromFloat(0.09999, 5),
}, {
name: "4. sell primary-available backing-partial non-zero",
bc: &balanceCoordinator{
@@ -91,10 +91,10 @@ func TestBalanceCoordinatorCheckBalance(t *testing.T) {
inputVol: model.NumberFromFloat(1.0, 5),
inputPrice: model.NumberFromFloat(0.112, 5),
wantHasBackingBalance: true,
wantNewBaseVolume: model.NumberFromFloat(0.71429, 5),
wantNewQuoteVolume: model.NumberFromFloat(0.08, 5),
wantPlacedPrimaryUnits: model.NumberFromFloat(5.83429, 5),
wantPlacedBackingUnits: model.NumberFromFloat(0.1, 5),
wantNewBaseVolume: model.NumberFromFloat(0.71428, 5),
wantNewQuoteVolume: model.NumberFromFloat(0.07999, 5),
wantPlacedPrimaryUnits: model.NumberFromFloat(5.83428, 5),
wantPlacedBackingUnits: model.NumberFromFloat(0.09999, 5),
}, {
name: "5. sell primary-available backing-empty zero",
bc: &balanceCoordinator{
@@ -182,9 +182,9 @@ func TestBalanceCoordinatorCheckBalance(t *testing.T) {
inputPrice: model.NumberFromFloat(0.21, 5),
wantHasBackingBalance: true,
wantNewBaseVolume: model.NumberFromFloat(480.95238, 5),
wantNewQuoteVolume: model.NumberFromFloat(101.0, 5),
wantNewQuoteVolume: model.NumberFromFloat(100.99999, 5),
wantPlacedPrimaryUnits: model.NumberFromFloat(480.95238, 5),
wantPlacedBackingUnits: model.NumberFromFloat(101.0, 5),
wantPlacedBackingUnits: model.NumberFromFloat(100.99999, 5),
}, {
name: "10. sell primary-partial backing-partial non-zero",
bc: &balanceCoordinator{
@@ -672,6 +672,44 @@ func TestBalanceCoordinatorCheckBalance(t *testing.T) {
wantPlacedPrimaryUnits: model.NumberFromFloat(100.0, 5),
wantPlacedBackingUnits: model.NumberFromFloat(100.45, 5),
},
// these are the tests spawned from https://github.com/stellar/kelp/issues/541
{
name: "rounding - buy base on primary - truncate rounding",
bc: &balanceCoordinator{
primaryBalance: model.NumberFromFloat(646.1, 7),
placedPrimaryUnits: model.NumberConstants.Zero,
primaryAssetType: "quote",
isPrimaryBuy: true,
backingBalance: model.NumberFromFloat(1.9, 5),
placedBackingUnits: model.NumberConstants.Zero,
backingAssetType: "base",
},
inputVol: model.NumberFromFloat(2.0, 2), // intentionally use a less precise volume
inputPrice: model.NumberFromFloat(368.0, 8), // intentionally use a precision of price more than volume
wantHasBackingBalance: true,
wantNewBaseVolume: model.NumberFromFloat(1.75, 2), // don't modify precision volume nunmbers
wantNewQuoteVolume: model.NumberFromFloat(644.0, 2), // don't modify precision volume nunmbers
wantPlacedPrimaryUnits: model.NumberFromFloat(644.0, 2),
wantPlacedBackingUnits: model.NumberFromFloat(1.75, 2),
}, {
name: "rounding - sell base on primary - truncate rounding",
bc: &balanceCoordinator{
primaryBalance: model.NumberFromFloat(1.9, 7),
placedPrimaryUnits: model.NumberConstants.Zero,
primaryAssetType: "base",
isPrimaryBuy: false,
backingBalance: model.NumberFromFloat(646.1, 5),
placedBackingUnits: model.NumberConstants.Zero,
backingAssetType: "quote",
},
inputVol: model.NumberFromFloat(2.0, 2), // intentionally use a less precise volume
inputPrice: model.NumberFromFloat(368.0, 8), // intentionally use a precision of price more than volume
wantHasBackingBalance: true,
wantNewBaseVolume: model.NumberFromFloat(1.75, 2), // don't modify precision volume nunmbers
wantNewQuoteVolume: model.NumberFromFloat(644.0, 2), // don't modify precision volume nunmbers
wantPlacedPrimaryUnits: model.NumberFromFloat(1.75, 2),
wantPlacedBackingUnits: model.NumberFromFloat(644.0, 2),
},
}

for _, k := range testCases {