Doing arithmetic on currency

Samy Zafrany wrote:

 >
 > Anyone has an explanation why the following happens?
 > Is it possible to rewrite the int function so it be completely robust?
 >
 >       tcl> expr int(8299.46 * 1000.0)
 >        result => 8299459

beernutmark 2011-05-02 The reason for this is that computers don't use decimal arithmetic normally. See the General Decimal Arithmetic Page for more info than you could ever want http://speleotrove.com/decimal/

See Decimal Arithmetic Package for tcl 8.5 for a solution.

No offense meant, but everything below is a hack. There is nothing special about money or dollars and cents. What you need to use is a decimal arithmetic for tcl. Try this simple test to see why.

% expr 8.20 - 0.20
7.999999999999999
% 

Using the decimal package you will see:

% ::Decimal::- [::Decimal::fromstr 8.20] [::Decimal::fromstr .20]
8.00

Very important for financial calculations. Much better than throwing around a bunch of round() functions which will quickly get you into trouble as I have recently learned.


KBK The short answer is, "no." The desired result is application dependent.

MS Isn't Samy actually looking for round()? The question is not precise enough to know.

 % expr round(8299.46 * 1000.0)
 8299460

The form of these numbers looks as if you may be dealing with currency. In this case, contemplate keeping only the cents (pence, centimes, pfennigs, groschen, ...) internally, and convert to floating point only for display. Remember that the floating point engine will represent all sufficiently small integers exactly!

Look at the following code for how to deal with currency properly. Remember that bankers think in integers: integer number of pennies, integer number of percentage points, etc. Scale everything to be an integer and work on it that way. Round to the nearest penny when appropriate.

US AFAIK bankers calculate with a precision of four decimal digits and round to two digits (cents) on output.

JMN 2008-07-03 despite this page having displayed the functions below for 7 years or so - this does not seem to be the 'proper' way to do things after all. The code suffers from the old octal problem.

 %dollarsToCents 10.08
 can't use invalid octal number as operand of "+"

Actually there seems to be surprisingly little Tcl-specific information relating to dealing with currency. In particular there doesn't seem to be anything finance-specific in Tcllib, and I haven't been able to find any code to do 'statistical rounding', aka 'bankers rounding' or 'round-to-even'. Is everyone just 'rolling their own' for accounting/finance stuff, or is hardly anyone ever dealing with money in Tcl?

I'm a little surprised because I happened to notice a while back that there's a even a company in my home town of Sydney that has an accounting package implemented in Tcl/Tk.

NEM It also doesn't seem to handle negative amounts either:

 % dollarsToCents -10.18
 -982

Here is a slightly better version (needs optimising):

proc dollarsToCents2 dollars {
  if {[scan $dollars %lld.%2d bucks cents] == 2} {
    set sign [expr {$bucks < 0 ? -1 : 1}]
    return [expr {$sign * (abs($bucks)*100 + $cents)}]
  } else {
    return -code error "not a valid dollar amount \"$dollars\""
  }
}

JMN It's actually faster than the other version, but even this one is not quite right:

 %dollarsToCents2 -0.42
 42

NEM Ugh, yeah. There must be a better way of handling the sign there (other than resorting back to regexp, which just makes me feel dirty when working with numbers).

Lars H: The problem is to verify that the format is correct and then get rid of the decimal point, is it not? A regsub does that easily:

 proc dollarsToCents3 { dollars } { # After dollarsToCents below.
     if { [regsub -expanded {
         ^                         # Match the start of the string
         ([-+]?[[:digit:]]*)       # Match the dollars, with an optional sign
         [.]                       # Match the decimal point
         ([[:digit:]][[:digit:]])  # Match the cents
         $                         # Match the end of the string
     } $dollars {\1\2} cents] } {
         scan $cents %lld cents
         return $cents
     } else {
         return -code error "Dollar amount must be a decimal number with\
                             two digits after the decimal point."
     }
 }

jmn That one overflowed at only a few billion dollars.. I edited the scan line to use the %lld trick that Neil used.

It seems to me that there are enough gotchas in using Tcl for currency work, that we really do need to get something standard into Tcllib for this sort of thing. I'm currently using Neils solution with a different check for the sign because it's faster. Not that the few microseconds are that important for me, but it seems like a reasonable way to choose.

 proc dollarsToCents4 {dollars} {
         if {[string match "-*" $dollars]} {
                 set sign -1
         } else {
                 set sign 1
         }
         if {[scan $dollars %lld.%2d%lld bucks cents _disallowed] == 2} {
                 return [expr {$sign * (abs($bucks)*100 + $cents)}]
         } else {
                 return -code error "Dollar amount must be a decimal number with\
                                  two digits after the decimal point."
         }
 }
 proc centsToDollars {cents} {
         if {[scan $cents %lld%c wholepennies _disallowed] == 1} {
                 return [format %.2f [expr {$wholepennies / 100.0}]]
         } else {
                 return -code error "Only an integer number of cents can be converted to dollars. Please round before converting."
         }
 }

MS may be overlooking something (it is late), but why looking at the string instead of using the math functions?

 proc dollarsToCents5 {dollars} {
     expr {round($dollars*100)}
 }

JMN Because the point is (as far as I'm aware) to encourage the user of the functions to be doing all their calculations and roundings on the 'cent' value. dollarsToCents also has a validation function in that it should require the dollar amount to be formatted with exactly 2 places after the decimal point.

Conversely centsToDollars should only accept a whole number of cents, so that no implicit rounding is done. The caller should be thinking about when they're rounding, and potentially even choosing a rounding method different to the [expr] round() function. e.g 'round-to-even'

MS Understood about validation. What I meant is that round() will always do the right thing on validated inputs: remove the noise caused by the floating point rep. That is

 proc dollarsToCents6 {dollars} {
     if {[lindex [scan $dollars %lld.%2d%lld] 2] ne {}} {
         return -code error "Dollar amount must be a decimal number with\
                             two digits after the decimal point."
     }
     expr {round($dollars*100)}
 }

jmn yes - agreed, the round() in the above case is fine. Your validation doesn't do what I'd desire - in that it also allows input with zero or 1 places after the decimal point - but I suppose to some extent that requirement is application specific. For me it seems a good idea to make it more likely to throw an error if a value in 'cents' is accidentally supplied. Actually the one I posted above also allows a single digit after the decimal point unfortunately.

Maybe *this* will be my final version..

 proc dollarsToCents7 {dollars} {
      if {[scan $dollars %lld.%1d%1d%lld bucks c1 c2 _disallowed] != 3} {
                  return -code error "Dollar amount must be a decimal number with\
                              two digits after the decimal point."
      }
      expr {round($dollars*100)}
 }

 # Take a dollar amount, expressed as nnn.nn, and convert to an
 # integer number of cents.
 
 proc dollarsToCents { dollars } {
     if { [regexp -expanded {
         ^                         # Match the start of the string
         ([-+]?[[:digit:]]*)       # Match the dollars, with an optional sign
         [.]                       # Match the decimal point
         ([[:digit:]][[:digit:]])  # Match the cents
         $                         # Match the end of the string
     } $dollars -> bucks pennies] } {
         return [expr { 100. * $bucks + $pennies }]
     } else {
         return -code error "Dollar amount must be a decimal number with\
                             two digits after the decimal point."
     }
 }
 
 # Take an integer number of cents and reformat as a dollar amount.
 
 proc centsToDollars { cents } {
     return [format {%d.%02d} [expr { $cents / 100 }] [expr { $cents % 100 }]]
 }
 
 # Convert a dollar amount to pennies.

 set amount [dollarsToCents "8299.56"]
 puts "8299.56 dollars are $amount cents"

 # Multiply a dollar amount by 1000.

 set result [expr { round( 1000.0 * $amount ) }]
 set resultString [centsToDollars $result]
 puts "One thousand times 8299.56 is $resultString"

 # Apply a percentage to a dollar amount. Round the result to the
 # nearest cent.

 set result2 [expr { round( 0.01 * $amount ) }]
 set result2String [centsToDollars $result2]
 puts "One percent of 8299.56 is $result2String"

See also A real problem.