Skip to content

Commit

Permalink
Add value distribution algorithms for AnyCurrency
Browse files Browse the repository at this point in the history
Motivation:

Arithmetics with currency are difficult to get right, so to save developers' time, it is desirable to have common algorithms baked into the library.

Modifications:

- Add `inverseValue` computed property that provides an opposite signed currency value
- Add `distributedEvenly(intoParts:)` algorithm for evenly splitting a single value
- Add `distributedProportionally(between:)` algorithm for proportional splitting of a value

Result:

Developers should have a reliable and battle tested way of doing certain algorithms with currency
  • Loading branch information
Mordil committed Jan 20, 2020
1 parent 44425d8 commit 46f75f0
Show file tree
Hide file tree
Showing 4 changed files with 272 additions and 0 deletions.
97 changes: 97 additions & 0 deletions Sources/Currency/AnyCurrency+Algorithms.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
//===----------------------------------------------------------------------===//
//
// This source file is part of the Currency open source project
//
// Copyright (c) 2020 Currency project authors
// Licensed under MIT License
//
// See LICENSE.txt for license information
// See CONTRIBUTORS.txt for the list of Currency project authors
//
// SPDX-License-Identifier: MIT
//
//===----------------------------------------------------------------------===//

import Foundation

// MARK: Value Distribution

extension AnyCurrency {
/// Distributes the current amount into a set number of parts as evenly as possible.
/// - Note: Passing a negative or `0` value will result in an empty result.
/// - Complexity: O(*n*), where *n* is the `numParts`.
/// - Parameter numParts: The count of new values the single value should be distributed between as evenly as possible.
/// - Returns: A collection of currency values with their share of the amount distribution.
public func distributedEvenly(intoParts numParts: Int) -> [Self] {
guard numParts > 0 else { return [] }

let count = Int64(numParts)

// courtesy of https://codereview.stackexchange.com/a/221221
let units = self.minorUnits
let fraction = units / count
let remainder = Int(abs(units) % count)

var results: [Self] = .init(repeating: Self(exactly: 0), count: numParts)
for index in 0..<remainder {
results[index] = Self(exactly: fraction + units.signum())
}
for index in remainder..<numParts {
results[index] = Self(exactly: fraction)
}

return results
}

/// Distributes the current amount between other amounts proportionally based on their original value.
///
/// The resulting amounts will match the same sign (negative or positive) as the amount being distributed.
///
/// For example:
///
/// let result = USD(-10).distributedProportionally(between: [5, 8.25])
/// // result == [USD(-3.77), USD(-6.23)]
///
/// - Note: In situations where all `originalValues` are equal, the amount will not be evenly distributed. The remainder will be biased towards the last
/// element in the `originalValues`.
///
/// For example:
///
/// let result = USD(10.05).distributedProportionally(between: [1, 1, 1, 1, 1, 1])
/// // result == [USD(1.67), USD(1.67), USD(1.67), USD(1.67), USD(1.67), USD(1.70)]
///
/// In this case, it is more appropriate to call `distributedEvenly(intoParts:)`.
///
/// - Complexity: O(2*n*), where *n* is the number of `originalValues`.
/// - Parameter originalValues: A collection of values that should be scaled proportionally so that their sum equals this currency's amount.
/// - Returns: A collection of currency values that are scaled proportionally from an original value whose sum equals this currency's amount.
public func distributedProportionally<C: Collection>(
between originalValues: C
) -> [Self] where C.Element == Self {
guard originalValues.count > 0 else { return [] }

var results: [Self] = .init(repeating: Self(0), count: originalValues.count)

let desiredTotalUnits = self.minorUnits
guard desiredTotalUnits != 0 else { return results }

let originalTotalUnits = originalValues.sum().minorUnits

var currentTotalUnits: Int64 = 0
var index = 0
for value in originalValues.dropLast() {
defer { index += 1 }

let proportion = Decimal(value.minorUnits) / .init(originalTotalUnits)
let newValue = Self(proportion * self.amount)

defer { currentTotalUnits += newValue.minorUnits }

results[index] = newValue
}

results[originalValues.count - 1] = Self(exactly: desiredTotalUnits - currentTotalUnits)

return results
}
}
8 changes: 8 additions & 0 deletions Sources/Currency/AnyCurrency.swift
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,14 @@ extension AnyCurrency {
significand: 1
)
}

/// The current value with it's sign reversed.
///
/// For example:
///
/// USD(3.40).inverse == USD(-3.40)
///
public var inverseAmount: Self { return .init(exactly: self.minorUnits * -1) }
}

extension AnyCurrency where Self: CurrencyMetadata {
Expand Down
159 changes: 159 additions & 0 deletions Tests/CurrencyTests/AnyCurrencyAlgorithmsTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
//===----------------------------------------------------------------------===//
//
// This source file is part of the Currency open source project
//
// Copyright (c) 2020 Currency project authors
// Licensed under MIT License
//
// See LICENSE.txt for license information
// See CONTRIBUTORS.txt for the list of Currency project authors
//
// SPDX-License-Identifier: MIT
//
//===----------------------------------------------------------------------===//

import Currency
import XCTest

public final class AnyCurrencyAlgorithmsTests: XCTestCase { }

// MARK: Distributed Evenly

extension AnyCurrencyAlgorithmsTests {
func test_distributedEvenly() {
let amount = USD(15.01)
XCTAssertEqual(amount.distributedEvenly(intoParts: 3), [5.01, 5, 5])
XCTAssertEqual(amount.distributedEvenly(intoParts: 0), [])
XCTAssertEqual(amount.distributedEvenly(intoParts: -1), [])
XCTAssertEqual(amount.inverseAmount.distributedEvenly(intoParts: 4), [-3.76, -3.75, -3.75, -3.75])
}

// minorUnits == 2
func test_distributedEvenly_usd() {
self.run_distributedEvenlyTest(
sourceAmount: USD(10.05),
numParts: 6,
expectedResults: .init(repeating: USD(1.68), count: 3) + .init(repeating: USD(1.67), count: 3)
)
}

// minorUnits == 3
func test_distributedEvenly_bhd() {
self.run_distributedEvenlyTest(
sourceAmount: BHD(180),
numParts: 7,
expectedResults: .init(repeating: BHD(25.715), count: 2) + .init(repeating: BHD(25.714), count: 5)
)
self.run_distributedEvenlyTest(
sourceAmount: BHD(10.1983),
numParts: 3,
expectedResults: [BHD(3.4)] + .init(repeating: BHD(3.399), count: 2)
)
}

/// minorUnits == 0
func test_distributedEvenly_bif() {
self.run_distributedEvenlyTest(
sourceAmount: BIF(298),
numParts: 3,
expectedResults: [100] + .init(repeating: BIF(99), count: 2)
)
self.run_distributedEvenlyTest(
sourceAmount: BIF(157.982),
numParts: 9,
expectedResults: .init(repeating: BIF(18), count: 5) + .init(repeating: BIF(17), count: 4)
)
}

private func run_distributedEvenlyTest<Currency: AnyCurrency & Equatable>(
sourceAmount: Currency,
numParts count: Int,
expectedResults: [Currency]
) {
guard count == expectedResults.count else {
return XCTFail("Inconsistent desire: Asked for \(count) parts, but expect \(expectedResults.count) results")
}
let actualResults = sourceAmount.distributedEvenly(intoParts: count)
XCTAssertEqual(actualResults, expectedResults)
XCTAssertEqual(sourceAmount, expectedResults.sum())
XCTAssertEqual(
sourceAmount.inverseAmount.distributedEvenly(intoParts: count),
expectedResults.map({ $0.inverseAmount })
)
}
}

// MARK: Distributed Proportionally

extension AnyCurrencyAlgorithmsTests {
func test_distributedProportionally() {
let amount = USD(10)
XCTAssertEqual(amount.distributedProportionally(between: [2.5, 2.5]), [5, 5])
XCTAssertEqual(amount.distributedProportionally(between: []), [])
XCTAssertEqual(amount.inverseAmount.distributedProportionally(between: [5, 8.25]), [-3.77, -6.23])
}

// minorUnits == 2
func test_distributedProportionally_usd() {
self.run_distributedProportionallyTest(
sourceAmount: USD(10.05),
originalValues: .init(repeating: USD(1), count: 6),
expectedValues: .init(repeating: USD(1.67), count: 5) + [USD(1.7)]
)

let sourceValues: [USD] = [4, 103, 0.99, 68, 100] // 275.99 USD
self.run_distributedProportionallyTest(
sourceAmount: .init(201.385),
originalValues: sourceValues,
expectedValues: [2.92, 75.16, 0.72, 49.62, 72.96]
)
self.run_distributedProportionallyTest(
sourceAmount: .init(583),
originalValues: sourceValues,
expectedValues: [8.45, 217.58, 2.09, 143.64, 211.24]
)
}

// minorUnits == 3
func test_distributedProportionally_bhd() {
let sourceValues: [BHD] = [4.1982, 39.2983, 12.1345, 17.293, 100] // 172.924 BHD
self.run_distributedProportionallyTest(
sourceAmount: .init(180),
originalValues: sourceValues,
expectedValues: [4.37, 40.906, 12.631, 18.001, 104.092]
)
self.run_distributedProportionallyTest(
sourceAmount: .init(10.1983),
originalValues: sourceValues,
expectedValues: [0.248, 2.318, 0.716, 1.02, 5.896]
)
}

// minorUnits == 0
func test_distributedProportionally_bif() {
let sourceValues: [BIF] = [4, 39, 12, 1, 100.2983] // 156 BIF
self.run_distributedProportionallyTest(
sourceAmount: .init(298),
originalValues: sourceValues,
expectedValues: [8, 74, 23, 2, 191]
)
self.run_distributedProportionallyTest(
sourceAmount: .init(47.582),
originalValues: sourceValues,
expectedValues: [1, 12, 4, 0, 31]
)
}

private func run_distributedProportionallyTest<Currency: AnyCurrency & Equatable>(
sourceAmount: Currency,
originalValues: [Currency],
expectedValues: [Currency]
) {
guard originalValues.count == expectedValues.count else {
return XCTFail("Inconsistent desire: Provided \(originalValues.count) values, but expect \(expectedValues.count) results")
}
let actualResults = sourceAmount.distributedProportionally(between: originalValues)
XCTAssertEqual(actualResults, expectedValues)
XCTAssertEqual(sourceAmount, actualResults.sum())
}
}
8 changes: 8 additions & 0 deletions swift-currency.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@

/* Begin PBXBuildFile section */
D838378B23CEBF2D0017B4D2 /* AnyCurrency+Sequence.swift in Sources */ = {isa = PBXBuildFile; fileRef = D838378A23CEBF2D0017B4D2 /* AnyCurrency+Sequence.swift */; };
D893657D23D2944B0006FAE1 /* AnyCurrencyAlgorithmsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D893657C23D2944B0006FAE1 /* AnyCurrencyAlgorithmsTests.swift */; };
D893657F23D294850006FAE1 /* AnyCurrency+Algorithms.swift in Sources */ = {isa = PBXBuildFile; fileRef = D893657E23D294850006FAE1 /* AnyCurrency+Algorithms.swift */; };
OBJ_31 /* AnyCurrency.swift in Sources */ = {isa = PBXBuildFile; fileRef = OBJ_11 /* AnyCurrency.swift */; };
OBJ_32 /* CurrencyMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = OBJ_12 /* CurrencyMetadata.swift */; };
OBJ_33 /* CurrencyMint.swift in Sources */ = {isa = PBXBuildFile; fileRef = OBJ_13 /* CurrencyMint.swift */; };
Expand Down Expand Up @@ -52,6 +54,8 @@

/* Begin PBXFileReference section */
D838378A23CEBF2D0017B4D2 /* AnyCurrency+Sequence.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "AnyCurrency+Sequence.swift"; sourceTree = "<group>"; };
D893657C23D2944B0006FAE1 /* AnyCurrencyAlgorithmsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnyCurrencyAlgorithmsTests.swift; sourceTree = "<group>"; };
D893657E23D294850006FAE1 /* AnyCurrency+Algorithms.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AnyCurrency+Algorithms.swift"; sourceTree = "<group>"; };
OBJ_10 /* CurrencyMint.swift.gyb */ = {isa = PBXFileReference; lastKnownFileType = text; path = CurrencyMint.swift.gyb; sourceTree = "<group>"; };
OBJ_11 /* AnyCurrency.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnyCurrency.swift; sourceTree = "<group>"; };
OBJ_12 /* CurrencyMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrencyMetadata.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -102,6 +106,7 @@
OBJ_17 /* AnyCurrencyTests.swift */,
OBJ_18 /* CurrencyMintTests.swift */,
OBJ_19 /* XCTestManifests.swift */,
D893657C23D2944B0006FAE1 /* AnyCurrencyAlgorithmsTests.swift */,
);
name = CurrencyTests;
path = Tests/CurrencyTests;
Expand Down Expand Up @@ -147,6 +152,7 @@
OBJ_12 /* CurrencyMetadata.swift */,
OBJ_13 /* CurrencyMint.swift */,
OBJ_14 /* ISOCurrencies.swift */,
D893657E23D294850006FAE1 /* AnyCurrency+Algorithms.swift */,
);
name = Currency;
path = Sources/Currency;
Expand Down Expand Up @@ -239,6 +245,7 @@
OBJ_31 /* AnyCurrency.swift in Sources */,
OBJ_32 /* CurrencyMetadata.swift in Sources */,
D838378B23CEBF2D0017B4D2 /* AnyCurrency+Sequence.swift in Sources */,
D893657F23D294850006FAE1 /* AnyCurrency+Algorithms.swift in Sources */,
OBJ_33 /* CurrencyMint.swift in Sources */,
OBJ_34 /* ISOCurrencies.swift in Sources */,
);
Expand All @@ -249,6 +256,7 @@
buildActionMask = 0;
files = (
OBJ_41 /* AnyCurrencyTests.swift in Sources */,
D893657D23D2944B0006FAE1 /* AnyCurrencyAlgorithmsTests.swift in Sources */,
OBJ_42 /* CurrencyMintTests.swift in Sources */,
OBJ_43 /* XCTestManifests.swift in Sources */,
);
Expand Down

0 comments on commit 46f75f0

Please sign in to comment.