1
0
mirror of https://github.com/aclindsa/moneygo.git synced 2025-06-14 13:58:37 -04:00

Store currency/security values/prices using big.Rat natively

This adds 'shadow' types used only by the store/db internal package whch
handle converting these types to their DB-equivalent values. This change
should allow reports to be generated significantly faster since it
allows a large portion of the computation to be shifted to the database
engines.
This commit is contained in:
2017-12-12 19:40:38 -05:00
parent 483adb5c56
commit a357d38eee
22 changed files with 695 additions and 201 deletions

156
internal/models/amounts.go Normal file
View File

@ -0,0 +1,156 @@
package models
import (
"encoding/json"
"fmt"
"math"
"math/big"
"strings"
)
type Amount struct {
big.Rat
}
type PrecisionError struct {
message string
}
func (p PrecisionError) Error() string {
return p.message
}
// Whole returns the integral portion of the Amount
func (amount Amount) Whole() (int64, error) {
var whole big.Int
whole.Quo(amount.Num(), amount.Denom())
if whole.IsInt64() {
return whole.Int64(), nil
}
return 0, PrecisionError{"integral portion of Amount cannot be represented as an int64"}
}
// Fractional returns the fractional portion of the Amount, multiplied by
// 10^precision
func (amount Amount) Fractional(precision uint64) (int64, error) {
if precision < amount.Precision() {
return 0, PrecisionError{"Fractional portion of Amount cannot be represented with the given precision"}
}
// Reduce the fraction to its simplest form
var r, gcd, d, n big.Int
r.Rem(amount.Num(), amount.Denom())
gcd.GCD(nil, nil, &r, amount.Denom())
if gcd.Sign() != 0 {
n.Quo(&r, &gcd)
d.Quo(amount.Denom(), &gcd)
} else {
n.Set(&r)
d.Set(amount.Denom())
}
// Figure out what we need to multiply the numerator by to get the
// denominator to be 10^precision
var prec, multiplier big.Int
prec.SetUint64(precision)
multiplier.SetInt64(10)
multiplier.Exp(&multiplier, &prec, nil)
multiplier.Quo(&multiplier, &d)
n.Mul(&n, &multiplier)
if n.IsInt64() {
return n.Int64(), nil
}
return 0, fmt.Errorf("Fractional portion of Amount does not fit in int64 with given precision")
}
// FromParts re-assembles an Amount from the results from previous calls to
// Whole and Fractional
func (amount *Amount) FromParts(whole, fractional int64, precision uint64) {
var fracnum, fracdenom, power big.Int
fracnum.SetInt64(fractional)
fracdenom.SetInt64(10)
power.SetUint64(precision)
fracdenom.Exp(&fracdenom, &power, nil)
var fracrat big.Rat
fracrat.SetFrac(&fracnum, &fracdenom)
amount.Rat.SetInt64(whole)
amount.Rat.Add(&amount.Rat, &fracrat)
}
// Round rounds the given Amount to the given precision
func (amount *Amount) Round(precision uint64) {
// This probably isn't exactly the most efficient way to do this...
amount.SetString(amount.FloatString(int(precision)))
}
func (amount Amount) String() string {
return amount.FloatString(int(amount.Precision()))
}
func (amount *Amount) UnmarshalJSON(bytes []byte) error {
var value string
if err := json.Unmarshal(bytes, &value); err != nil {
return err
}
value = strings.TrimSpace(value)
if _, ok := amount.SetString(value); !ok {
return fmt.Errorf("Failed to parse '%s' into Amount", value)
}
return nil
}
func (amount Amount) MarshalJSON() ([]byte, error) {
return json.Marshal(amount.String())
}
// Precision returns the minimum positive integer p such that if you multiplied
// this Amount by 10^p, it would become an integer
func (amount Amount) Precision() uint64 {
if amount.IsInt() || amount.Sign() == 0 {
return 0
}
// Find d, the denominator of the reduced fractional portion of 'amount'
var r, gcd, d big.Int
r.Rem(amount.Num(), amount.Denom())
gcd.GCD(nil, nil, &r, amount.Denom())
if gcd.Sign() != 0 {
d.Quo(amount.Denom(), &gcd)
} else {
d.Set(amount.Denom())
}
d.Abs(&d)
var power, result big.Int
one := big.NewInt(1)
ten := big.NewInt(10)
// Estimate an initial power
if d.IsUint64() {
power.SetInt64(int64(math.Log10(float64(d.Uint64()))))
} else {
// If the simplified denominator wasn't a uint64, its > 10^19
power.SetInt64(19)
}
// If the initial estimate was too high, bring it down
result.Exp(ten, &power, nil)
for result.Cmp(&d) > 0 {
power.Sub(&power, one)
result.Exp(ten, &power, nil)
}
// If it was too low, bring it up
for result.Cmp(&d) < 0 {
power.Add(&power, one)
result.Exp(ten, &power, nil)
}
if !power.IsUint64() {
panic("Unable to represent Amount's precision as a uint64")
}
return power.Uint64()
}

View File

@ -0,0 +1,159 @@
package models_test
import (
"github.com/aclindsa/moneygo/internal/models"
"testing"
)
func expectedPrecision(t *testing.T, amount *models.Amount, precision uint64) {
t.Helper()
if amount.Precision() != precision {
t.Errorf("Expected precision %d for %s, found %d", precision, amount.String(), amount.Precision())
}
}
func TestAmountPrecision(t *testing.T) {
var a models.Amount
a.SetString("1.1928712")
expectedPrecision(t, &a, 7)
a.SetString("0")
expectedPrecision(t, &a, 0)
a.SetString("-0.7")
expectedPrecision(t, &a, 1)
a.SetString("-1.1837281037509137509173049173052130957210361309572047598275398265926351231426357130289523647634895285603247284245928712")
expectedPrecision(t, &a, 118)
a.SetInt64(1050)
expectedPrecision(t, &a, 0)
}
func TestAmountRound(t *testing.T) {
var a models.Amount
tests := []struct {
String string
RoundTo uint64
Expected string
}{
{"0", 5, "0"},
{"929.92928", 2, "929.93"},
{"-105.499999", 4, "-105.5"},
{"0.5111111", 1, "0.5"},
{"0.5111111", 0, "1"},
{"9.876456", 3, "9.876"},
}
for _, test := range tests {
a.SetString(test.String)
a.Round(test.RoundTo)
if a.String() != test.Expected {
t.Errorf("Expected '%s' after Round(%d) to be %s intead of %s\n", test.String, test.RoundTo, test.Expected, a.String())
}
}
}
func TestAmountString(t *testing.T) {
var a models.Amount
for _, s := range []string{
"1.1928712",
"0",
"-0.7",
"-1.1837281037509137509173049173052130957210361309572047598275398265926351231426357130289523647634895285603247284245928712",
"1050",
} {
a.SetString(s)
if s != a.String() {
t.Errorf("Expected '%s', found '%s'", s, a.String())
}
}
a.SetString("+182.27")
if "182.27" != a.String() {
t.Errorf("Expected '182.27', found '%s'", a.String())
}
a.SetString("-0")
if "0" != a.String() {
t.Errorf("Expected '0', found '%s'", a.String())
}
}
func TestWhole(t *testing.T) {
var a models.Amount
tests := []struct {
String string
Whole int64
}{
{"0", 0},
{"-0", 0},
{"181.1293871230", 181},
{"-0.1821", 0},
{"99992737.9", 99992737},
{"-7380.000009", -7380},
{"4108740192740912741", 4108740192740912741},
}
for _, test := range tests {
a.SetString(test.String)
val, err := a.Whole()
if err != nil {
t.Errorf("Unexpected error: %s\n", err)
} else if val != test.Whole {
t.Errorf("Expected '%s'.Whole() to return %d intead of %d\n", test.String, test.Whole, val)
}
}
a.SetString("81367662642302823790328492349823472634926342")
_, err := a.Whole()
if err == nil {
t.Errorf("Expected error for overflowing int64")
}
}
func TestFractional(t *testing.T) {
var a models.Amount
tests := []struct {
String string
Precision uint64
Fractional int64
}{
{"0", 5, 0},
{"181.1293871230", 9, 129387123},
{"181.1293871230", 10, 1293871230},
{"181.1293871230", 15, 129387123000000},
{"1828.37", 7, 3700000},
{"-0.748", 5, -74800},
{"-9", 5, 0},
{"-9.9", 1, -9},
}
for _, test := range tests {
a.SetString(test.String)
val, err := a.Fractional(test.Precision)
if err != nil {
t.Errorf("Unexpected error: %s\n", err)
} else if val != test.Fractional {
t.Errorf("Expected '%s'.Fractional(%d) to return %d intead of %d\n", test.String, test.Precision, test.Fractional, val)
}
}
}
func TestFromParts(t *testing.T) {
var a models.Amount
tests := []struct {
Whole int64
Fractional int64
Precision uint64
Result string
}{
{839, 9080, 4, "839.908"},
{-10, 0, 5, "-10"},
{0, 900, 10, "0.00000009"},
{9128713621, 87272727, 20, "9128713621.00000000000087272727"},
{89, 1, 0, "90"}, // Not sure if this should really be supported, but it is
}
for _, test := range tests {
a.FromParts(test.Whole, test.Fractional, test.Precision)
if a.String() != test.Result {
t.Errorf("Expected Amount.FromParts(%d, %d, %d) to return %s intead of %s\n", test.Whole, test.Fractional, test.Precision, test.Result, a.String())
}
}
}

View File

@ -12,7 +12,7 @@ type Price struct {
SecurityId int64
CurrencyId int64
Date time.Time
Value string // String representation of decimal price of Security in Currency units, suitable for passing to big.Rat.SetString()
Value Amount // price of Security in Currency units
RemoteId string // unique ID from source, for detecting duplicates
}

View File

@ -23,6 +23,9 @@ func GetSecurityType(typestring string) SecurityType {
}
}
// MaxPrexision denotes the maximum valid value for Security.Precision
const MaxPrecision uint64 = 15
type Security struct {
SecurityId int64
UserId int64
@ -31,7 +34,7 @@ type Security struct {
Symbol string
// Number of decimal digits (to the right of the decimal point) this
// security is precise to
Precision int `db:"Preciseness"`
Precision uint64 `db:"Preciseness"`
Type SecurityType
// AlternateId is CUSIP for Type=Stock, ISO4217 for Type=Currency
AlternateId string

View File

@ -2,8 +2,6 @@ package models
import (
"encoding/json"
"errors"
"math/big"
"net/http"
"strings"
"time"
@ -49,28 +47,11 @@ type Split struct {
RemoteId string // unique ID from server, for detecting duplicates
Number string // Check or reference number
Memo string
Amount string // String representation of decimal, suitable for passing to big.Rat.SetString()
}
func GetBigAmount(amt string) (*big.Rat, error) {
var r big.Rat
_, success := r.SetString(amt)
if !success {
return nil, errors.New("Couldn't convert string amount to big.Rat via SetString()")
}
return &r, nil
}
func (s *Split) GetAmount() (*big.Rat, error) {
return GetBigAmount(s.Amount)
Amount Amount
}
func (s *Split) Valid() bool {
if (s.AccountId == -1) == (s.SecurityId == -1) {
return false
}
_, err := s.GetAmount()
return err == nil
return (s.AccountId == -1) != (s.SecurityId == -1)
}
type Transaction struct {
@ -89,8 +70,8 @@ type AccountTransactionsList struct {
Account *Account
Transactions *[]*Transaction
TotalTransactions int64
BeginningBalance string
EndingBalance string
BeginningBalance Amount
EndingBalance Amount
}
func (t *Transaction) Write(w http.ResponseWriter) error {