Version 11 of Doing arithmetic on currency

Updated 2008-07-04 03:22:11 by miguel

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

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

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.18
 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 {entier(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.


Category Mathematics