1

I would like to round down a decimal to the nearest increment of another number. For example, given a value of 2.23678301 and an increment of 0.0001, I would like to round this to 2.2367. Sometimes the increment could be something like 0.00022, in which case the value would be rounded down to 2.23674.

I tried to do this, but sometimes the result is not correct and tests aren't passing:

extension Decimal {
    func rounded(byIncrement increment: Self) -> Self {
        var multipleOfValue = self / increment
        var roundedMultipleOfValue = Decimal()
        NSDecimalRound(&roundedMultipleOfValue, &multipleOfValue, 0, .down)
        return roundedMultipleOfValue * increment
    }
}

/// Tests

class DecimalTests: XCTestCase {
    func testRoundedByIncrement() {
        // Given
        let value: Decimal = 2.2367830187654

        // Then
        XCTAssertEqual(value.rounded(byIncrement: 0.00010000), 2.2367)
        XCTAssertEqual(value.rounded(byIncrement: 0.00022), 2.23674)
        XCTAssertEqual(value.rounded(byIncrement: 0.0000001), 2.236783)
        XCTAssertEqual(value.rounded(byIncrement: 0.00000001), 2.23678301) // XCTAssertEqual failed: ("2.23678301") is not equal to ("2.236783009999999744")
        XCTAssertEqual(value.rounded(byIncrement: 3.5), 0)
        XCTAssertEqual(value.rounded(byIncrement: 0.000000000000001), 2.2367830187654) // XCTAssertEqual failed: ("2.2367830187653998323726489726140416") is not equal to ("2.236783018765400576")
    }
}

I'm not sure why the decimal calculations are making up numbers that were never there, like the last assertion. Is there a cleaner or more accurate way to do this?

TruMan1
  • 33,665
  • 59
  • 184
  • 335
  • 3
    "given a value of 2.23678301 ... the increment could be something like 0.00022, in which case the value would be rounded down to 2.23674" How? What's the arithmetical reasoning on that move? I think this will be a lot easier if you can specify the algorithm to other humans. – matt Sep 15 '21 at 21:12
  • The increment is actually a lot size. So the user can only order in increments of the lot size. This function would convert the user's entered value to increments of the lot size. – TruMan1 Sep 15 '21 at 21:23
  • Related: https://stackoverflow.com/q/42781785/1187415 – Martin R Sep 16 '21 at 03:08

1 Answers1

5

Your code is fine. You're just calling it incorrectly. This line doesn't do what you think:

let value: Decimal = 2.2367830187654

This is equivalent to:

let value = Decimal(double: Double(2.2367830187654))

The value is first converted to a Double, binary rounding it to 2.236783018765400576. That value is then converted to a Decimal.

You need to use the string initializer everywhere you want a Decimal from a digit string:

let value = Decimal(string: "2.2367830187654")!

XCTAssertEqual(value.rounded(byIncrement: Decimal(string: "0.00000001")!), Decimal(string: "2.23678301")!)

etc.

Or you can use the integer-based initializers:

let value = Decimal(sign: .plus, exponent: -13, significand: 22367830187654)

In iOS 15 there are some new initializers that don't return optionals (init(_:format:lenient:) for example), but you're still going to need to pass Strings, not floating point literals.

You could also do this, though it may be confusing to readers, and might lead to bugs if folks take the quotes away:

extension Decimal: ExpressibleByStringLiteral {
    public init(stringLiteral value: String) {
        self.init(string: value)!
    }
}

let value: Decimal = "2.2367830187654"

XCTAssertEqual(value.rounded(byIncrement: "0.00000001"), "2.23678301")

For test code, that's probably nice, but I'd be very careful about using it in production code.

Rob Napier
  • 286,113
  • 34
  • 456
  • 610
  • I have implemented the exactly same extension to be able to manually input some values and be sure that there is no precision loss – Leo Dabus Sep 15 '21 at 22:01
  • 2
    I did some testing, came to the same conclusion you did, and came back to post an answer, only to find that you beat me to it. (Voted.) – Duncan C Sep 15 '21 at 22:44