Using Bigdecimal to Work with Currencies

Using BigDecimal to work with currencies

Here are a few hints:

  1. Use BigDecimal for computations if you need the precision that it offers (Money values often need this).
  2. Use the NumberFormat class for display. This class will take care of localization issues for amounts in different currencies. However, it will take in only primitives; therefore, if you can accept the small change in accuracy due to transformation to a double, you could use this class.
  3. When using the NumberFormat class, use the scale() method on the BigDecimal instance to set the precision and the rounding method.

PS: In case you were wondering, BigDecimal is always better than double, when you have to represent money values in Java.

PPS:

Creating BigDecimal instances

This is fairly simple since BigDecimal provides constructors to take in primitive values, and String objects. You could use those, preferably the one taking the String object. For example,

BigDecimal modelVal = new BigDecimal("24.455");
BigDecimal displayVal = modelVal.setScale(2, RoundingMode.HALF_EVEN);

Displaying BigDecimal instances

You could use the setMinimumFractionDigits and setMaximumFractionDigits method calls to restrict the amount of data being displayed.

NumberFormat usdCostFormat = NumberFormat.getCurrencyInstance(Locale.US);
usdCostFormat.setMinimumFractionDigits( 1 );
usdCostFormat.setMaximumFractionDigits( 2 );
System.out.println( usdCostFormat.format(displayVal.doubleValue()) );

A realistic example where using BigDecimal for currency is strictly better than using double

I can see four basic ways that double can screw you when dealing with currency calculations.

Mantissa Too Small

With ~15 decimal digits of precision in the mantissa, you are you going to get the wrong result any time you deal with amounts larger than that. If you are tracking cents, problems would start to occur before 1013 (ten trillion) dollars.

While that's a big number, it's not that big. The US GDP of ~18 trillion exceeds it, so anything dealing with country or even corporation sized amounts could easily get the wrong answer.

Furthermore, there are plenty of ways that much smaller amounts could exceed this threshold during calculation. You might be doing a growth projection or a over a number of years, which results in a large final value. You might be doing a "what if" scenario analysis where various possible parameters are examined and some combination of parameters might result in very large values. You might be working under financial rules which allow fractions of a cent which could chop another two orders of magnitude or more off of your range, putting you roughly in line with the wealth of mere individuals in USD.

Finally, let's not take a US centric view of things. What about other currencies? One USD is worth is worth roughly 13,000 Indonesian Rupiah, so that's another 2 orders of magnitude you need to track currency amounts in that currency (assuming there are no "cents"!). You're almost getting down to amounts that are of interest to mere mortals.

Here is an example where a growth projection calculation starting from 1e9 at 5% goes wrong:

method   year                         amount           delta
double 0 $ 1,000,000,000.00
Decimal 0 $ 1,000,000,000.00 (0.0000000000)
double 10 $ 1,628,894,626.78
Decimal 10 $ 1,628,894,626.78 (0.0000004768)
double 20 $ 2,653,297,705.14
Decimal 20 $ 2,653,297,705.14 (0.0000023842)
double 30 $ 4,321,942,375.15
Decimal 30 $ 4,321,942,375.15 (0.0000057220)
double 40 $ 7,039,988,712.12
Decimal 40 $ 7,039,988,712.12 (0.0000123978)
double 50 $ 11,467,399,785.75
Decimal 50 $ 11,467,399,785.75 (0.0000247955)
double 60 $ 18,679,185,894.12
Decimal 60 $ 18,679,185,894.12 (0.0000534058)
double 70 $ 30,426,425,535.51
Decimal 70 $ 30,426,425,535.51 (0.0000915527)
double 80 $ 49,561,441,066.84
Decimal 80 $ 49,561,441,066.84 (0.0001678467)
double 90 $ 80,730,365,049.13
Decimal 90 $ 80,730,365,049.13 (0.0003051758)
double 100 $ 131,501,257,846.30
Decimal 100 $ 131,501,257,846.30 (0.0005645752)
double 110 $ 214,201,692,320.32
Decimal 110 $ 214,201,692,320.32 (0.0010375977)
double 120 $ 348,911,985,667.20
Decimal 120 $ 348,911,985,667.20 (0.0017700195)
double 130 $ 568,340,858,671.56
Decimal 130 $ 568,340,858,671.55 (0.0030517578)
double 140 $ 925,767,370,868.17
Decimal 140 $ 925,767,370,868.17 (0.0053710938)
double 150 $ 1,507,977,496,053.05
Decimal 150 $ 1,507,977,496,053.04 (0.0097656250)
double 160 $ 2,456,336,440,622.11
Decimal 160 $ 2,456,336,440,622.10 (0.0166015625)
double 170 $ 4,001,113,229,686.99
Decimal 170 $ 4,001,113,229,686.96 (0.0288085938)
double 180 $ 6,517,391,840,965.27
Decimal 180 $ 6,517,391,840,965.22 (0.0498046875)
double 190 $ 10,616,144,550,351.47
Decimal 190 $ 10,616,144,550,351.38 (0.0859375000)

The delta (difference between double and BigDecimal first hits > 1 cent at year 160, around 2 trillion (which might not be all that much 160 years from now), and of course just keeps getting worse.

Of course, the 53 bits of Mantissa mean that the relative error for this kind of calculation is likely to be very small (hopefully you don't lose your job over 1 cent out of 2 trillion). Indeed, the relative error basically holds fairly steady through most of the example. You could certainly organize it though so that you (for example) subtract two various with loss of precision in the mantissa resulting in an arbitrarily large error (exercise up to reader).

Changing Semantics

So you think you are pretty clever, and managed to come up with a rounding scheme that lets you use double and have exhaustively tested your methods on your local JVM. Go ahead and deploy it. Tomorrow or next week or whenever is worst for you, the results change and your tricks break.

Unlike almost every other basic language expression and certainly unlike integer or BigDecimal arithmetic, by default the results of many floating point expressions don't have a single standards defined value due to the strictfp feature. Platforms are free to use, at their discretion, higher precision intermediates, which may result in different results on different hardware, JVM versions, etc. The result, for the same inputs, may even vary at runtime when the method switches from interpreted to JIT-compiled!

If you had written your code in the pre-Java 1.2 days, you'd be pretty pissed when Java 1.2 suddenly introduces the now-default variable FP behavior. You might be tempted to just use strictfp everywhere and hope you don't run into any of the multitude of related bugs - but on some platforms you'd be throwing away much of the performance that double bought you in the first place.

There's nothing to say that the JVM spec won't again change in the future to accommodate further changes in FP hardware, or that the JVM implementors won't use the rope that the default non-strictfp behavior gives them to do something tricky.

Inexact Representations

As Roland pointed out in his answer, a key problem with double is that it doesn't have exact representations for some most non-integer values. Although a single non-exact value like 0.1 will often "roundtrip" OK in some scenarios (e.g., Double.toString(0.1).equals("0.1")), as soon as you do math on these imprecise values the error can compound, and this can be irrecoverable.

In particular, if you are "close" to a rounding point, e.g., ~1.005, you might get a value of 1.00499999... when the true value is 1.0050000001..., or vice-versa. Because the errors go in both directions, there is no rounding magic that can fix this. There is no way to tell if a value of 1.004999999... should be bumped up or not. Your roundToTwoPlaces() method (a type of double rounding) only works because it handled a case where 1.0049999 should be bumped up, but it will never be able to cross the boundary, e.g., if cumulative errors cause 1.0050000000001 to be turned into 1.00499999999999 it can't fix it.

You don't need big or small numbers to hit this. You only need some math and for the result to fall close to the boundary. The more math you do, the larger the possible deviations from the true result, and the more chance of straddling a boundary.

As requested here a searching test that does a simple calculation: amount * tax and rounds it to 2 decimal places (i.e., dollars and cents). There are a few rounding methods in there, the one currently used, roundToTwoPlacesB is a souped-up version of yours1 (by increasing the multiplier for n in the first rounding you make it a lot more sensitive - the original version fails right away on trivial inputs).

The test spits out the failures it finds, and they come in bunches. For example, the first few failures:

Failed for 1234.57 * 0.5000 = 617.28 vs 617.29
Raw result : 617.2850000000000000000000, Double.toString(): 617.29
Failed for 1234.61 * 0.5000 = 617.30 vs 617.31
Raw result : 617.3050000000000000000000, Double.toString(): 617.31
Failed for 1234.65 * 0.5000 = 617.32 vs 617.33
Raw result : 617.3250000000000000000000, Double.toString(): 617.33
Failed for 1234.69 * 0.5000 = 617.34 vs 617.35
Raw result : 617.3450000000000000000000, Double.toString(): 617.35

Note that the "raw result" (i.e., the exact unrounded result) is always close to a x.xx5000 boundary. Your rounding method errs both on the high and low sides. You can't fix it generically.

Imprecise Calculations

Several of the java.lang.Math methods don't require correctly rounded results, but rather allow errors of up to 2.5 ulp. Granted, you probably aren't going to be using the hyperbolic functions much with currency, but functions such as exp() and pow() often find their way into currency calculations and these only have an accuracy of 1 ulp. So the number is already "wrong" when it is returned.

This interacts with the "Inexact Representation" issue, since this type of error is much more serious than that from the normal mathematic operations which are at least choosing the best possible value from with the representable domain of double. It means that you can have many more round-boundary crossing events when you use these methods.

BigDecimal and Money

Yes, you should change all floats or doubles to take either ints, longs or BigDecimals.

Floats and doubles are not precise for financial calculations. It's a very good idea to use the Money pattern to deal with amounts and currencies (it's a special type of Quantity). To maintain a list of moneys possibly in multiple currencies, what you're effectively doing is a MoneyBag, a collection of Money that can then sum all values given a target currency and a CurrencyExchangeService (currency conversion rates should also be stored as BigDecimals).

Rounding should be done after every operation according to the number of desired decimal places and the rounding algorithm. The number of decimal places is normally a property of the Currency (look, e.g., at ISO 4217); unless a different number is desired (like when pricing gasoline, for example).

You should definitely look at Fowler's examples; but I've also created a very simple uni-currency Money class for an exercise. It uses dollars only and rounds to 2 decimal places; but it's still a good base for future extensions.

Java: BigDecimal for money and working with cents

These are alternatives. You should use BigDecimal, in dollars.

Is it compulsory to use BigDecimal for counting currency (Java)?

The floating point arithmetic has been a problem since long. That is why the usage BigDecimal is encouraged. To your question on how to use it properly, I've a simple example which can help you understand how BigDecimal can be used along with MathContext.

BigDecimal bd1 = new BigDecimal(0.13); // double value to BigDecimal
BigDecimal bd2 = new BigDecimal("0.21"); // String value to BigDecimal

MathContext mc = new MathContext(3, RoundingMode.HALF_EVEN); // first arg is the precision and the second is the RoundingMode.

BigDecimal bd3 = bd1.add(bd2, mc); // perform the addition with the context

System.out.println(bd3); // prints 0.340

Formatting BigDecimal to currency String with three decimal places

Use the NumberFormat methods setMaximumFractionDigits and setMinimumFractionDigits:

NumberFormat numberFormat = NumberFormat.getCurrencyInstance(getLocaleFromCurrency(currency));
numberFormat.setMaximumFractionDigits(3);
numberFormat.setMinimumFractionDigits(3);
return numberFormat.format(value);

Representing Monetary Values in Java

BigDecimal all the way. I've heard of some folks creating their own Cash or Money classes which encapsulate a cash value with the currency, but under the skin it's still a BigDecimal, probably with BigDecimal.ROUND_HALF_EVEN rounding.

Edit: As Don mentions in his answer, there are open sourced projects like timeandmoney, and whilst I applaud them for trying to prevent developers from having to reinvent the wheel, I just don't have enough confidence in a pre-alpha library to use it in a production environment. Besides, if you dig around under the hood, you'll see they use BigDecimal too.

This is my first program. I wanted to create a converts currencies and gives advices to user on how best to spend money. I need some advances

Some suggestions to improve the code

  • Break the program up into smaller pieces
  • Use a method for the conversion
  • Use methods for some of the if/else branches
  • Use constants for common information

This will make it easier to read and maintain

  • Use BigDecimal instead of double
  • Pick a rounding mode for your divisions (in the code I used "half even")

This will avoid unintended loss of precision.

As Turing85 mentioned in the comments, floating points (i.e. double) are not good for representing money. This is because floating points may not exactly represent the value (you could lose precision). See more here.

For example

import java.io.PrintStream;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.Scanner;

public class Test {

private final static Scanner IN = new Scanner(System.in);
private final static PrintStream OUT = System.out;

private static final BigDecimal RUBLES = BigDecimal.valueOf(100);
private static final BigDecimal RATE_USD = BigDecimal.valueOf(1.35);
private static final BigDecimal RATE_EUR = BigDecimal.valueOf(1.20);
private static final BigDecimal RATE_GBP = BigDecimal.valueOf(1.02);
private static final BigDecimal RATE_JPY = BigDecimal.valueOf(153.29);
private static final RoundingMode ROUNDING_MODE = RoundingMode.HALF_EVEN;

public static void main(String[] args) {
// Interface
OUT.println("Welcome to the Currency Converter Program \n");
OUT.println("Используйте одну из перечисленных валют для конвертации: \n 1 - Rubles \n 2 - US dollars \n 3 - Euros \n 4 - British Pounds \n 5 - Japanese Yen \n");
OUT.println("Please choose the input currency");
choose();
OUT.println("Сколько денег у вас осталось до зарплаты?");
BigDecimal moneyBeforeSalary = BigDecimal.valueOf(IN.nextDouble());
OUT.println("Введите команду. Доступные команды: convert и advice.");
String command = IN.next();
if (command.equals("convert")) {
convert(moneyBeforeSalary);
} else if (command.equals("advice")) {
OUT.println("Сколько дней до зарплаты?");
BigDecimal daysBeforeSalary = BigDecimal.valueOf(IN.nextInt());
advice(moneyBeforeSalary, daysBeforeSalary);
} else {
OUT.println("Извините, такой команды пока нет.");
}
}

private static void advice(BigDecimal moneyBeforeSalary, BigDecimal daysBeforeSalary) {
if (moneyBeforeSalary.compareTo(BigDecimal.valueOf(3000)) < 0) {
OUT.println("Сегодня лучше поесть дома. Экономьте, и вы дотянете до зарплаты!");
} else if (moneyBeforeSalary.compareTo(BigDecimal.valueOf(10000)) < 0) {
if (daysBeforeSalary.compareTo(BigDecimal.valueOf(10)) < 0) {
OUT.println("Окей, пора в Макдак!");
} else {
OUT.println("Сегодня лучше поесть дома. Экономьте, и вы дотянете до зарплаты!");
}
} else if (moneyBeforeSalary.compareTo(BigDecimal.valueOf(30000)) < 0) {
if (daysBeforeSalary.compareTo(BigDecimal.valueOf(10)) < 0) {
OUT.println("Неплохо! Прикупите долларов и зайдите поужинать в классное место. :)");
} else {
OUT.println("Окей, пора в Макдак!");
}
} else {
if (daysBeforeSalary.compareTo(BigDecimal.valueOf(10)) < 0) {
OUT.println("Отлично! Заказывайте крабов!");
} else {
OUT.println("Неплохо! Прикупите долларов и зайдите поужинать в классное место. :)");
}
}
}

private static void choose() {
int choice = IN.nextInt();
String inType;
switch (choice) {
case 1 -> inType = "Rubles >> " + RUBLES;
case 2 -> inType = "US Dollars >> " + RATE_USD;
case 3 -> inType = "Euros >> " + RATE_EUR;
case 4 -> inType = "British Pounds >> " + RATE_GBP;
case 5 -> inType = "Japanese Yen >> " + RATE_JPY;
default -> {
OUT.println("Какая печаль, я пока не знаю такой валюты.\nПожалуйста, перезапустите программу и выберите валюту из списка :)");
return;
}
}
OUT.println("In type: " + inType);
}

private static void convert(BigDecimal moneyBeforeSalary) {
OUT.println("Please choose the output currency");
OUT.println("В какую валюту хотите конвертировать рубли? Доступные варианты: USD, EUR, JPY, GBP.");
String currency = IN.next(); // считываю значения с помощью scanner
if (currency.equals("RUB")) {
OUT.println("Ваши сбережения в рублях: " + moneyBeforeSalary.divide(RUBLES, ROUNDING_MODE));
}
switch (currency) {
case "RUS" -> OUT.println("Ваши сбережения в рублях:" + RUBLES);
case "USD" -> OUT.println("Ваши сбережения в долларах: " + moneyBeforeSalary.divide(RATE_USD, ROUNDING_MODE));
case "EUR" -> OUT.println("Ваши сбережения в евро: " + moneyBeforeSalary.divide(RATE_EUR, ROUNDING_MODE));
case "GBP" -> OUT.println("Ваши сбережения в долларах: " + moneyBeforeSalary.divide(RATE_GBP, ROUNDING_MODE));
case "JPY" -> OUT.println("Ваши сбережения в иенах: " + moneyBeforeSalary.divide(RATE_JPY, ROUNDING_MODE));
default -> OUT.println("Валюта не поддерживается.");
}
}
}


Related Topics



Leave a reply



Submit