{adamcoding}
Part I
02
Chapter 2

Numbers That Lie

The problem

Run this:

go
package main

import "fmt"

func main() {
	fmt.Println(0.1 + 0.2)
}

You get:

0.30000000000000004

That is not a Go bug. Python does it. JavaScript does it. C does it. Your phone's calculator does it and hides the evidence. It's a property of how computers store decimal numbers, and if you're going to write code that touches money, you need to understand it rather than work around it by accident.

Why it happens

Computers store numbers in binary - powers of two. In base ten, we write fractions as tenths, hundredths, thousandths. In binary, they're halves, quarters, eighths, sixteenths.

Some fractions convert perfectly. 0.5 is exactly one half. 0.25 is exactly one quarter. Fine.

But 0.1 in binary is 0.0001100110011001100110011... - the pattern repeats forever, exactly the way 1/3 in decimal is 0.3333... forever. The computer has finite space (64 bits for a float64), so it stores the closest value it can and stops.

That stored value isn't quite 0.1. It's fractionally off. Add two fractionally-off numbers and the error becomes visible.

   what you wrote     what's stored
   0.1          →     0.1000000000000000055511151231257827...
   0.2          →     0.2000000000000000111022302462515654...
                      ─────────────────────────────────────
   sum                0.3000000000000000444089209850062616...
                      which prints as 0.30000000000000004

The rule that follows: never test floating-point numbers for exact equality.

go
if 0.1+0.2 == 0.3 {           // false! never do this
	fmt.Println("equal")
}

Compare against a tolerance instead:

go
import "math"

func nearlyEqual(a, b float64) bool {
	return math.Abs(a-b) < 1e-9
}

The money rule

Here's the consequence that matters, and it's the reason this chapter exists:

Never store money in a float.

Not account balances, not order quantities, not cumulative P&L. Every real trading system, every bank, every accounting package stores money as integers of the smallest unit - cents, satoshis, pips.

Instead of float64 euros:

go
balance := 1000.00      // wrong for money

use int64 cents:

go
balanceCents := int64(100000)     // €1000.00, exactly

Integers are exact. There is no rounding error, ever. Adding a million transactions in cents gives you precisely the right answer; adding a million transactions in floats gives you something that drifts.

The cost is that you have to convert for display:

go
func formatCents(cents int64) string {
	return fmt.Sprintf("%d.%02d", cents/100, cents%100)
}

fmt.Println(formatCents(100000))    // 1000.00
fmt.Println(formatCents(4530))      // 45.30

Notice cents/100 and cents%100 - integer division gives whole euros, remainder gives the leftover cents. That "trap" from Chapter 1 is now doing useful work. %02d means "an integer, at least 2 digits, pad with zeros," so 5 cents prints as 05 rather than 5.

So when is a float fine?

Floats are fine for anything where a rounding error in the fifteenth decimal place doesn't matter:

  • Prices used for calculation and display
  • Indicator values - moving averages, ATR, volatility
  • Percentages and ratios
  • Statistical output

Floats are not fine for:

  • Account balances
  • Realised profit and loss you're accumulating
  • Order quantities on exchanges with strict lot sizes
  • Anything you'll compare for exact equality
  • Anything that has to reconcile against someone else's number to the penny

The distinction is roughly: is this a measurement, or is it a count of something? Measurements can be approximate. Counts must be exact.

Integer limits

Integers are exact but not infinite. An int64 holds roughly ±9.2 quintillion, which sounds like plenty, and is - until you're counting satoshis in a large position, or nanoseconds since 1970 multiplied by something.

go
import "math"

fmt.Println(math.MaxInt64)      // 9223372036854775807

Exceeding it doesn't produce an error. It wraps around to a large negative number, silently:

go
var x int64 = math.MaxInt64
x = x + 1
fmt.Println(x)                  // -9223372036854775808

This is overflow, and it's a genuine source of catastrophic bugs in financial code, because it's silent and the resulting number looks like a real value.

The types you'll actually use

Go has many number types. You need four.

TypeUse it for
intcounting, loop indices, array positions
int64money in cents, timestamps, anything needing an explicit width
float64prices, indicators, statistics
booltrue/false

Default to int for counting and float64 for measuring. Reach for int64 when you're representing money or a timestamp. Ignore the rest until you need them.

Run It Yourself

Everything above is a claim. Here's the whole chapter as one program you can actually run. Save it as money.go and go run money.go.

go
package main

import (
	"fmt"
	"math"
)

func nearlyEqual(a, b, tolerance float64) bool {
	return math.Abs(a-b) < tolerance
}

func formatCents(cents int64) string {
	sign := ""
	if cents < 0 {
		sign = "-"
		cents = -cents
	}
	return fmt.Sprintf("%s%d.%02d", sign, cents/100, cents%100)
}

func main() {
	fmt.Println("=== 1. floats are approximate ===")
	fmt.Printf("0.1 + 0.2          = %.20f\n", 0.1+0.2)
	fmt.Printf("0.1 + 0.2 == 0.3   ? %t\n", 0.1+0.2 == 0.3)
	fmt.Printf("nearlyEqual        ? %t\n", nearlyEqual(0.1+0.2, 0.3, 1e-9))

	fmt.Println("\n=== 2. the drift is real ===")
	floatTotal := 0.0
	var centsTotal int64
	for i := 0; i < 1_000_000; i++ {
		floatTotal += 0.01 // one cent, as a float
		centsTotal += 1    // one cent, as an integer
	}
	fmt.Printf("float sum          = %.10f\n", floatTotal)
	fmt.Printf("integer sum        = %s\n", formatCents(centsTotal))
	fmt.Printf("difference         = %.10f\n",
		floatTotal-float64(centsTotal)/100)

	fmt.Println("\n=== 3. integer division does useful work ===")
	for _, c := range []int64{100000, 4530, 5, -4530} {
		fmt.Printf("%8d cents = %s\n", c, formatCents(c))
	}

	fmt.Println("\n=== 4. overflow is silent ===")
	var x int64 = math.MaxInt64
	fmt.Printf("MaxInt64           = %d\n", x)
	fmt.Printf("MaxInt64 + 1       = %d   <-- no error, just wrong\n", x+1)
}

Expected output (the drift figure will differ slightly on your machine):

=== 1. floats are approximate ===
0.1 + 0.2          = 0.30000000000000004441
0.1 + 0.2 == 0.3   ? false
nearlyEqual        ? true

=== 2. the drift is real ===
float sum          = 10000.0000000181
integer sum        = 10000.00
difference         = 0.0000000181
...

Now break it on purpose. Three experiments:

  1. Change the loop to 10 million iterations. Does the drift grow proportionally?
  2. Change 0.01 to 0.5 and re-run. Why does the drift vanish? (Chapter 2 told you - a half is exact in binary.)
  3. Remove the - handling from formatCents and pass it -4530. What do you get, and why?

Exercises

2.1 Predict, then check:

go
fmt.Println(0.1 + 0.2 == 0.3)
fmt.Println(0.5 + 0.25 == 0.75)
fmt.Println(1.0 / 3.0 * 3.0 == 1.0)

Why does the second one behave differently from the first?

2.2 Write nearlyEqual(a, b, tolerance float64) bool and use it to check whether 0.1+0.2 and 0.3 are equal within 1e-9.

2.3 Write formatCents(cents int64) string handling negatives correctly. formatCents(-4530) should give -45.30, not -45.-30. (Hint: work out the sign first, then format the absolute value.)

2.4 You buy 3 shares at €45.30, 7 at €45.35, and 2 at €45.28. Compute the total cost two ways - once with float64 euros, once with int64 cents - and print both to four decimal places. Do they agree?

2.5 A position accumulates €0.01 profit, 1,000,000 times. Compute the total as a float64 sum in a loop, and as an int64 sum of cents. Print both. How far apart are they? (You'll need Chapter 4's loops - either skip ahead or come back to this one.)


Solutions

2.1 false, true, and the third depends on your machine but is usually false. The second works because 0.5, 0.25 and 0.75 are all exact sums of powers of two - a half, a quarter, and three quarters convert to binary perfectly. 0.1 and 0.3 do not.

2.2

go
func nearlyEqual(a, b, tolerance float64) bool {
	return math.Abs(a-b) < tolerance
}
fmt.Println(nearlyEqual(0.1+0.2, 0.3, 1e-9))    // true

2.3

go
func formatCents(cents int64) string {
	sign := ""
	if cents < 0 {
		sign = "-"
		cents = -cents
	}
	return fmt.Sprintf("%s%d.%02d", sign, cents/100, cents%100)
}

2.4

go
floatTotal := 3*45.30 + 7*45.35 + 2*45.28
centsTotal := int64(3*4530 + 7*4535 + 2*4528)
fmt.Printf("float: %.4f\n", floatTotal)
fmt.Printf("cents: %.4f\n", float64(centsTotal)/100)

They agree to four decimal places here, but the float version is already carrying a tiny error you can see if you print fifteen decimals. The point isn't that one purchase breaks it - it's that a million do.

2.5 The float sum drifts measurably from 10000.00; the integer sum is exactly 1000000 cents. This is the whole chapter in one experiment, and it's worth actually running.