diff --git a/model/number.go b/model/number.go index e3da9695f..ece246944 100644 --- a/model/number.go +++ b/model/number.go @@ -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 { diff --git a/model/number_test.go b/model/number_test.go index b5afb0c2e..22023ddf6 100644 --- a/model/number_test.go +++ b/model/number_test.go @@ -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, }, } diff --git a/plugins/mirrorStrategy.go b/plugins/mirrorStrategy.go index f93d54499..5b0faf4ad 100644 --- a/plugins/mirrorStrategy.go +++ b/plugins/mirrorStrategy.go @@ -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 } diff --git a/plugins/mirrorStrategy_test.go b/plugins/mirrorStrategy_test.go index 57f21d7f3..0903598cb 100644 --- a/plugins/mirrorStrategy_test.go +++ b/plugins/mirrorStrategy_test.go @@ -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 {