diff --git a/rfq/negotiator.go b/rfq/negotiator.go index 97b414458..e0b36cfe2 100644 --- a/rfq/negotiator.go +++ b/rfq/negotiator.go @@ -518,6 +518,8 @@ func expiryWithinBounds(expiryUnixTimestamp uint64, // priceWithinBounds returns true if the difference between the first price and // the second price is within the given tolerance (in parts per million (PPM)). +// +// TODO(ffranr): Replace with FixedPoint[T].WithinTolerance. func pricesWithinBounds(firstPrice lnwire.MilliSatoshi, secondPrice lnwire.MilliSatoshi, tolerancePpm uint64) bool { diff --git a/rfqmath/fixed_point.go b/rfqmath/fixed_point.go index 3fdc48757..f77382fcf 100644 --- a/rfqmath/fixed_point.go +++ b/rfqmath/fixed_point.go @@ -103,6 +103,53 @@ func (f FixedPoint[T]) Equals(other FixedPoint[T]) bool { return f.Coefficient.Equals(other.Coefficient) && f.Scale == other.Scale } +// WithinTolerance returns true if the two FixedPoint values are within the +// given tolerance (in parts per million (PPM)). +func (f FixedPoint[T]) WithinTolerance( + other FixedPoint[T], tolerancePpm T) bool { + + // Determine the larger scale between the two fixed-point numbers. + // Both values will be scaled to this larger scale to ensure a + // consistent comparison. + var largerScale uint8 + if f.Scale > other.Scale { + largerScale = f.Scale + } else { + largerScale = other.Scale + } + + subjectFp := f.ScaleTo(largerScale) + otherFp := other.ScaleTo(largerScale) + + var ( + // delta will be the absolute difference between the two + // coefficients. + delta T + + // maxCoefficient is the larger of the two coefficients. + maxCoefficient T + ) + if subjectFp.Coefficient.Gt(otherFp.Coefficient) { + delta = subjectFp.Coefficient.Sub(otherFp.Coefficient) + maxCoefficient = subjectFp.Coefficient + } else { + delta = otherFp.Coefficient.Sub(subjectFp.Coefficient) + maxCoefficient = otherFp.Coefficient + } + + // Calculate the tolerance in absolute terms based on the largest + // coefficient. + // + // tolerancePpm is parts per million, therefore we multiply the delta by + // 1,000,000 instead of dividing the tolerance. + scaledDelta := delta.Mul(NewInt[T]().FromUint64(1_000_000)) + + // Compare the scaled delta to the product of the maximum coefficient + // and the tolerance. + toleranceCoefficient := maxCoefficient.Mul(tolerancePpm) + return toleranceCoefficient.Gte(scaledDelta) +} + // FixedPointFromUint64 creates a new FixedPoint from the given integer and // scale. Note that the input here should be *unscaled*. func FixedPointFromUint64[N Int[N]](value uint64, scale uint8) FixedPoint[N] { diff --git a/rfqmath/fixed_point_test.go b/rfqmath/fixed_point_test.go index 6e79e4c03..dfceea52f 100644 --- a/rfqmath/fixed_point_test.go +++ b/rfqmath/fixed_point_test.go @@ -159,6 +159,169 @@ func testFromUint64[N Int[N]](t *rapid.T) { require.Equal(t, coefficient, scaledBack.Coefficient.ToUint64()) } +// TestWithinTolerance tests that the FixedPoint.WithinTolerance method works as +// expected. +func testWithinTolerance[N Int[N]](t *rapid.T) { + type testCase struct { + // firstFp is the fixed-point to compare with secondFp. + firstFp FixedPoint[N] + + // secondFp is the fixed-point to compare with firstFp. + secondFp FixedPoint[N] + + // tolerancePpm is the tolerance in parts per million (PPM) that + // the second price can deviate from the first price and still + // be considered within bounds. + tolerancePpm uint64 + + // withinBounds is the expected result of the bounds check. + withinBounds bool + } + + testCases := []testCase{ + { + // Case where secondFp is 10% less than firstFp, + // tolerance allows 11.11% (111111 PPM). Diff within + // bounds. + firstFp: FixedPointFromUint64[N](1000000, 1), + secondFp: FixedPointFromUint64[N](900000, 1), + tolerancePpm: 111111, // 11.11% tolerance in PPM + withinBounds: true, + }, + { + // Case where firstFp is 15% less than secondFp, + // tolerance allows 17.65% (176470 PPM). Diff within + // bounds. + firstFp: FixedPointFromUint64[N](8_500_00, 1), + secondFp: FixedPointFromUint64[N](1_000_000, 1), + tolerancePpm: 176470, // 17.65% tolerance in PPM + withinBounds: true, + }, + { + // Case where firstFp is 15% less than secondFp, + // tolerance allows 17.65% (176470 PPM). Diff within + // bounds. + firstFp: FixedPointFromUint64[N](85_000, 3), + secondFp: FixedPointFromUint64[N](100_000, 2), + tolerancePpm: 176470, // 17.65% tolerance in PPM + withinBounds: true, + }, + { + // Case where secondFp is 15% less than firstFp, + // tolerance allows 10% (100000 PPM). Diff outside + // bounds. + firstFp: FixedPointFromUint64[N](85_000, 3), + secondFp: FixedPointFromUint64[N](100_000, 2), + tolerancePpm: 100000, // 10% tolerance in PPM + withinBounds: false, + }, + { + // Case where firstFp and secondFp are equal, + // tolerance is 0 PPM. Diff within bounds. + firstFp: FixedPointFromUint64[N](100_000, 2), + secondFp: FixedPointFromUint64[N](100_000, 4), + tolerancePpm: 0, // 0% tolerance in PPM + withinBounds: true, + }, + { + // Case where firstFp and secondFp are equal, + // tolerance is 0 PPM. Diff within bounds. + firstFp: FixedPointFromUint64[N](100_000, 2), + secondFp: FixedPointFromUint64[N](100_000, 2), + tolerancePpm: 0, // 0% tolerance in PPM + withinBounds: true, + }, + { + // Case where secondFp is 1% more than firstFp, + // tolerance allows 0.99% (9900 PPM). Diff outside + // bounds. + firstFp: FixedPointFromUint64[N](100_000, 2), + secondFp: FixedPointFromUint64[N](101_000, 3), + tolerancePpm: 9900, // 0.99% tolerance in PPM + withinBounds: false, + }, + { + // Case where secondFp is 5% less than firstFp, + // tolerance allows 5% (50000 PPM). Diff within bounds. + firstFp: FixedPointFromUint64[N](100_000, 1), + secondFp: FixedPointFromUint64[N](95000, 2), + tolerancePpm: 50000, // 5% tolerance in PPM + withinBounds: true, + }, + { + // Case where secondFp is greater than firstFp, + // tolerance allows 5% (50000 PPM). Diff within bounds. + firstFp: FixedPoint[N]{ + Coefficient: NewInt[N]().FromUint64(314), + Scale: 2, + }, + secondFp: FixedPoint[N]{ + Coefficient: NewInt[N]().FromUint64( + 314_159_265_359, + ), + Scale: 11, + }, + + tolerancePpm: 50000, // 5% tolerance in PPM + withinBounds: true, + }, + { + // Case where secondFp is 10% less than firstFp, + // tolerance allows 9% (90000 PPM). Diff outside bounds. + firstFp: FixedPointFromUint64[N](100_000, 3), + secondFp: FixedPointFromUint64[N](90_000, 1), + tolerancePpm: 90000, // 9% tolerance in PPM + withinBounds: false, + }, + { + // Case where secondFp is 9% less than firstFp, + // tolerance allows 10% (100000 PPM). Diff within + // bounds. + firstFp: FixedPointFromUint64[N](100_000, 4), + secondFp: FixedPointFromUint64[N](91_000, 1), + tolerancePpm: 100000, // 10% tolerance in PPM + withinBounds: true, + }, + { + // Case where both prices are zero, should be within + // bounds. + firstFp: FixedPointFromUint64[N](0, 0), + secondFp: FixedPointFromUint64[N](0, 0), + tolerancePpm: 100000, // any tolerance in PPM + withinBounds: true, + }, + { + // Case where firstFp is zero and secondFp is + // non-zero, should not be within bounds. + firstFp: FixedPointFromUint64[N](0, 0), + secondFp: FixedPointFromUint64[N](100_000, 4), + tolerancePpm: 100000, // any tolerance in PPM + withinBounds: false, + }, + { + // Case where secondFp is zero and firstFp is + // non-zero, should not be within bounds. + firstFp: FixedPointFromUint64[N](100_000, 4), + secondFp: FixedPointFromUint64[N](0, 0), + tolerancePpm: 100000, // any tolerance in PPM + withinBounds: false, + }, + } + + // Run the test cases. + for idx, tc := range testCases { + result := tc.firstFp.WithinTolerance( + tc.secondFp, NewInt[N]().FromUint64(tc.tolerancePpm), + ) + + // Compare bounds check result with expected test case within + // bounds flag. + require.Equal( + t, tc.withinBounds, result, "Test case %d failed", idx, + ) + } +} + // TestFixedPoint runs a series of property-based tests on the FixedPoint type // exercising key invariant properties. func TestFixedPoint(t *testing.T) { @@ -175,4 +338,6 @@ func TestFixedPoint(t *testing.T) { t.Run("equality", rapid.MakeCheck(testEquality[BigInt])) t.Run("from_uint64", rapid.MakeCheck(testFromUint64[BigInt])) + + t.Run("within_tolerance", rapid.MakeCheck(testWithinTolerance[BigInt])) }