Skip to content

Commit

Permalink
chores: add README.md
Browse files Browse the repository at this point in the history
  • Loading branch information
quagmt committed Oct 13, 2024
1 parent 0f2e1f3 commit a886b18
Show file tree
Hide file tree
Showing 9 changed files with 215 additions and 78 deletions.
156 changes: 147 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,156 @@
[![GoDoc](https://pkg.go.dev/badge/github.com/quagmt/udecimal)](https://pkg.go.dev/github.com/quagmt/udecimal)
[![Go Report Card](https://goreportcard.com/badge/github.com/quagmt/udecimal)](https://goreportcard.com/report/github.com/quagmt/udecimal)

Blazing fast, high precision fixed-point decimal number library. Specifically designed for high-traffic financial applications.

## Features

- High precision (up to 19 decimal places) and no precision loss during arithmetic operations
- Panic-free operations
- Optimized for speed (see [benchmarks](#benchmarks)) and zero memory allocation (in most cases, check out [why](#faq))
- Various rounding methods: HALF AWAY FROM ZERO, HALF TOWARD ZERO, and Banker's rounding
- Intuitive API and easy to use
High performance, high precision fixed-point decimal number for financial applications.

## Installation

```sh
go get github.com/quagmt/udecimal
```

## Features

- **High Precision**: Supports up to 19 decimal places with no precision loss during arithmetic operations.
- **Optimized for Speed**: Designed for high performance with zero memory allocation in most cases (see [benchmarks](#benchmarks) and [How it works](#how-it-works)).
- **Panic-Free**: All errors are returned as values, ensuring no unexpected panics.
- **Versatile Rounding Methods**: Includes HALF AWAY FROM ZERO, HALF TOWARD ZERO, and Banker's rounding.
<br/>

**NOTE**: This library does not perform implicit rounding. If the result of an operation exceeds the maximum precision, extra digits are truncated. All rounding methods must be explicitly invoked. (see [Rounding Methods](#rounding-methods) for more details)

## Documentation

- Checkout [documentation](#docs) and [FAQ](FAQ.md) for more information.

## Usage

```go
package main

import (
"fmt"

"github.com/quagmt/udecimal"
)

func main() {
// Create a new decimal number
a, _ := udecimal.NewFromInt64(123456, 3) // a = 123.456
b, _ := udecimal.NewFromInt64(-123456, 4) // b = -12.3456
c, _ := udecimal.NewFromFloat64(1.2345) // c = 1.2345
d, _ := udecimal.Parse("4123547.1234567890123456789") // d = 4123547.1234567890123456789

// Basic arithmetic operations
fmt.Println(a.Add(b)) // 123.456 - 12.3456 = 111.1104
fmt.Println(a.Sub(b)) // 123.456 + 12.3456 = 135.8016
fmt.Println(a.Mul(b)) // 123.456 * -12.3456 = -1524.1383936
fmt.Println(a.Div(b)) // 123.456 / -12.3456 = -10
fmt.Println(a.Div(d)) // 123.456 / 4123547.1234567890123456789 = 0.0000299392722585176

// Divide with precision, extra digits are truncated
fmt.Println(a.DivExact(c, 10)) // 123.456 / 1.2345 = 100.0048602673

// Rounding
fmt.Println(c.RoundBank(3)) // banker's rounding: 1.2345 -> 1.234
fmt.Println(c.RoundHAZ(3)) // half away from zero: 1.2345 -> 1.235
fmt.Println(c.RoundHTZ(3)) // half towards zero: 1.2345 -> 1.234
fmt.Println(c.Trunc(2)) // truncate: 1.2345 -> 1.23
fmt.Println(c.Floor()) // floor: 1.2345 -> 1
fmt.Println(c.Ceil()) // ceil: 1.2345 -> 2

// Display
fmt.Println(a.String()) // 123.456
fmt.Println(a.StringFixed(10)) // 123.4560000000
fmt.Println(a.InexactFloat64()) // 123.456
}
```

## Rounding Methods

Rounding can be challenging and often confusing, as there are [numerous ways](https://www.mathsisfun.com/numbers/rounding-methods.html) to round a number. Each method has specific use cases, and developers frequently make mistakes or incorrect assumptions about rounding. For instance, round(1.5) might result in either 1 or 2, depending on the chosen rounding method.

This issue is particularly critical in financial applications, where even minor rounding mistakes can be accumulated and result in significant financial losses. To prevent such mistakes, this library avoids implicit rounding and requires developers to explicitly invoke the rounding method. The supported rounding methods are:

- [Banker's rounding](https://en.wikipedia.org/wiki/Rounding#Rounding_half_to_even) or round half to even
- [Half away from zero](https://en.wikipedia.org/wiki/Rounding#Rounding_half_away_from_zero) (HAZ)
- [Half toward zero](https://en.wikipedia.org/wiki/Rounding#Rounding_half_toward_zero) (HTZ)

### Examples:

```go
package main

import (
"fmt"

"github.com/quagmt/udecimal"
)

func main() {
// Create a new decimal number
a, _ := udecimal.NewFromFloat64(1.5) // a = 1.5

// Rounding
fmt.Println(a.RoundBank(0)) // banker's rounding: 1.5 -> 2
fmt.Println(a.RoundHAZ(0)) // half away from zero: 1.5 -> 2
fmt.Println(a.RoundHTZ(0)) // half towards zero: 1.5 -> 1
}
```

## How it works

As mentioned above, this library is not always memory allocation free. To understand why, let's take a look at how the `Decimal` type is implemented.

The `Decimal` type represents a fixed-point decimal number. It consists of three components: sign, coefficient, and scale. The number is represented as:

```go
// decimal value = (neg == true ? -1 : 1) * coef * 10^(-scale)
type Decimal struct {
neg bool
coef bint
scale uint8 // 0 <= scale <= 19
}

// Example:
// 123.456 = 123456 * 10^-3
// -> neg = false, coef = 123456, scale = 3

// -123.456 = -123456 / 10^-3
// -> neg = true, coef = 123456, scale = 3
```

<br/>

You can notice that `coef` data type is `bint`, which is a custom data type:

```go
type bint struct {
// Indicates if the coefficient exceeds 128-bit limit
overflow bool

// Indicates if the coefficient exceeds 128-bit limit
u128 u128

// For coefficients exceeding 128-bit
bigInt *big.Int
}
```

The `bint` type can store coefficients up to `2^128 - 1` using `u128`. Arithmetic operations with `u128` are fast and require no memory allocation. If result of an arithmetic operation exceeds u128 capacity, the whole operation will be performed using `big.Int` API. Such operations are slower and do involve memory allocation. However, those cases are rare in financial applications due to the extensive range provided by a 128-bit unsigned integer, for example:

- If precision is 0, the decimal range it can store is:
`[-340282366920938463463374607431768211455, 340282366920938463463374607431768211456]`(approximately -340 to 340 undecillion)

- If precision is 19, the decimal range becomes:
`[-34028236692093846346.3374607431768211455, 34028236692093846346.3374607431768211455]` (approximately -34 to 34 quintillion)

Therefore, in most cases you can expect high performance and no memory allocation when using this library.

## Credits

This library is inspired by [govalues/decimal](https://github.com/govalues/decimal) and [lukechampine/uint128](https://github.com/lukechampine/uint128)

## License

**MIT**
11 changes: 6 additions & 5 deletions bint.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,15 @@ var (
bigTen = big.NewInt(10)
)

// bint stores the whole decimal number, without the decimal place
// the value is always positive, even though fallback is big.Int
// bint represents a whole decimal number without a decimal point.
// The value is always positive and is stored in a 128-bit unsigned integer.
// If the value exceeds the 128-bit limit, it falls back to using big.Int.
type bint struct {
// flag to indicate if the value is overflow and stored in big.Int
// Indicates if the value has overflowed and is stored in big.Int.
overflow bool

// use for storing small number, with high performance and zero allocation
// Value range from -10^38 + 1 < u128 < 10^38 - 1
// Stores small numbers with high performance and zero allocation operations.
// The value range is 0 <= u128 <= 2^128 - 1
u128 u128

// fall back, in case the value is our of u128 range
Expand Down
2 changes: 1 addition & 1 deletion codec.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ func (d Decimal) String() string {
// Trailing zeros will not be removed.
// Special case: if the decimal is zero, it will return "0" regardless of the scale.
func (d Decimal) StringFixed(scale uint8) string {
d1 := d.Rescale(scale)
d1 := d.rescale(scale)

if !d1.coef.overflow {
return d1.stringU128(false)
Expand Down
47 changes: 22 additions & 25 deletions decimal.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ var (
// if not specified
defaultScale uint8 = 19

// maxDefaultScale is the maximum number of digits after the decimal point
maxDefaultScale uint8 = 19
// maxScale is the maximum number of digits after the decimal point
maxScale uint8 = 19

// maxStrLen is the maximum length of string input when using Parse/MustParse
// set it to 200 so string length value can fit in 1 byte (for MarshalBinary).
Expand Down Expand Up @@ -90,9 +90,7 @@ var pow10Big = [20]*big.Int{

var (
errOverflow = fmt.Errorf("overflow")
)

var (
// ErrScaleOutOfRange is returned when the scale is greater than the default scale
// default scale can be configured using SetDefaultScale, and its value is up to 19
ErrScaleOutOfRange = fmt.Errorf("scale out of range. Only support maximum %d digits after the decimal point", defaultScale)
Expand Down Expand Up @@ -123,7 +121,7 @@ var (
var (
Zero = Decimal{}
One = MustFromInt64(1, 0)
OneUint = MustFromUint64(1, 19)
oneUnit = MustFromUint64(1, 19)
)

// Decimal represents a fixed-point decimal number.
Expand All @@ -136,25 +134,23 @@ type Decimal struct {
scale uint8
}

// SetDefaultScale changes the default scale for decimal numbers in the package.
// The scale determines the number of digits after the decimal point.
// Maximum default scale is 19
// SetDefaultPrecision changes the default precision for decimal numbers in the package.
// Max precision is 19 and is also default.
//
// This function is particularly useful when you want to have your precision of the deicmal smaller than 19
// across the whole application.
// NOTE: numbers have more precision than defaultScale is still truncated
// across the whole application. It should be called only once at the beginning of your application
//
// Panics if the new scale is greater than 19 (maxDefaultScale) or new scale is 0
func SetDefaultScale(scale uint8) {
if scale > maxDefaultScale {
panic(fmt.Sprintf("scale out of range. Only allow maximum %d digits after the decimal points", maxDefaultScale))
// Panics if the new scale is greater than 19 (maxScale) or new scale is 0
func SetDefaultPrecision(prec uint8) {
if prec > maxScale {
panic(fmt.Sprintf("scale out of range. Only allow maximum %d digits after the decimal points", maxScale))
}

if scale == 0 {
if prec == 0 {
panic("scale must be greater than 0")
}

defaultScale = scale
defaultScale = prec
}

func NewFromHiLo(neg bool, hi uint64, lo uint64, scale uint8) (Decimal, error) {
Expand Down Expand Up @@ -256,8 +252,9 @@ func MustFromFloat64(f float64) Decimal {
// Caution: this method will not return the exact number if the decimal is too large.
//
// e.g. 123456789012345678901234567890123456789.9999999999999999999 -> 123456789012345680000000000000000000000
func (d Decimal) InexactFloat64() (float64, error) {
return strconv.ParseFloat(d.String(), 64)
func (d Decimal) InexactFloat64() float64 {
f, _ := strconv.ParseFloat(d.String(), 64)
return f
}

// Parse parses a number in string to Decimal.
Expand Down Expand Up @@ -665,15 +662,15 @@ func tryCmpU128(d, e Decimal) (int, error) {
}

// Rescale returns the decimal with the new scale only if the new scale is greater than the current scale.
// Useful when you want to increase the scale of the decimal.
// Useful when you want to increase the scale of the decimal for display purposes.
//
// Example:
//
// d := MustParse("123.456")
// d.Rescale(5) // 123.45600
func (d Decimal) Rescale(scale uint8) Decimal {
if scale > maxDefaultScale {
scale = maxDefaultScale
// d := MustParse("123.456") // 123.456, scale = 3
// d.rescale(5) // 123.45600, scale = 5
func (d Decimal) rescale(scale uint8) Decimal {
if scale > maxScale {
scale = maxScale
}

if scale <= d.scale {
Expand Down Expand Up @@ -1019,7 +1016,7 @@ func trailingZerosBigInt(n *big.Int) uint8 {
if m.Cmp(bigZero) == 0 {
zeros += 16

// shortcut because maxDefaultScale = 19
// shortcut because maxScale = 19
_, m = z.QuoRem(n, pow10Big[zeros+2], m)
if m.Cmp(bigZero) == 0 {
zeros += 2
Expand Down
28 changes: 13 additions & 15 deletions decimal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,21 +13,21 @@ import (
func TestSetDefaultScale(t *testing.T) {
// NOTE: must be careful with tests that change the default scale
// it can affect other tests, especially tests in different packages which can run in parallel
defer SetDefaultScale(maxDefaultScale)
defer SetDefaultPrecision(maxScale)

require.Equal(t, uint8(19), defaultScale)

SetDefaultScale(10)
SetDefaultPrecision(10)
require.Equal(t, uint8(10), defaultScale)

// expect panic if scale is 0
require.PanicsWithValue(t, "scale must be greater than 0", func() {
SetDefaultScale(0)
SetDefaultPrecision(0)
})

// expect panic if scale is > maxDefaultScale
require.PanicsWithValue(t, fmt.Sprintf("scale out of range. Only allow maximum %d digits after the decimal points", maxDefaultScale), func() {
SetDefaultScale(maxDefaultScale + 1)
// expect panic if scale is > maxScale
require.PanicsWithValue(t, fmt.Sprintf("scale out of range. Only allow maximum %d digits after the decimal points", maxScale), func() {
SetDefaultPrecision(maxScale + 1)
})
}

Expand Down Expand Up @@ -938,7 +938,7 @@ func TestDiv(t *testing.T) {
d := MustParse(cc.String())
e := c.Sub(d)

require.LessOrEqual(t, e.Abs().Cmp(OneUint), 0, "expected %s, got %s", cc.String(), c.String())
require.LessOrEqual(t, e.Abs().Cmp(oneUnit), 0, "expected %s, got %s", cc.String(), c.String())
})
}
}
Expand Down Expand Up @@ -1025,14 +1025,14 @@ func TestDivExact(t *testing.T) {
d := MustParse(cc.String())
e := c.Sub(d)

require.LessOrEqual(t, e.Abs().Cmp(OneUint), 0, "expected %s, got %s", cc.String(), c.String())
require.LessOrEqual(t, e.Abs().Cmp(oneUnit), 0, "expected %s, got %s", cc.String(), c.String())
})
}
}

func TestDivWithCustomScale(t *testing.T) {
SetDefaultScale(14)
defer SetDefaultScale(maxDefaultScale)
SetDefaultPrecision(14)
defer SetDefaultPrecision(maxScale)

testcases := []struct {
a, b string
Expand Down Expand Up @@ -1122,7 +1122,7 @@ func TestDivWithCustomScale(t *testing.T) {
d := MustParse(cc.String())
e := c.Sub(d)

require.LessOrEqual(t, e.Abs().Cmp(OneUint), 0, "expected %s, got %s", cc.String(), c.String())
require.LessOrEqual(t, e.Abs().Cmp(oneUnit), 0, "expected %s, got %s", cc.String(), c.String())
})
}
}
Expand Down Expand Up @@ -1189,7 +1189,7 @@ func TestDiv64(t *testing.T) {
d := MustParse(cc.String())
e := c.Sub(d)

require.LessOrEqual(t, e.Abs().Cmp(OneUint), 0, "expected %s, got %s", cc.String(), c.String())
require.LessOrEqual(t, e.Abs().Cmp(oneUnit), 0, "expected %s, got %s", cc.String(), c.String())
})
}
}
Expand Down Expand Up @@ -2238,9 +2238,7 @@ func TestInexactFloat64(t *testing.T) {
a, err := Parse(tc.a)
require.NoError(t, err)

got, err := a.InexactFloat64()
require.NoError(t, err)

got := a.InexactFloat64()
require.Equal(t, tc.want, got)

// cross check with shopspring/decimal
Expand Down
Loading

0 comments on commit a886b18

Please sign in to comment.