料率計算における小数点数の扱いについて

Merpay Advent Calendar 2020 の3日目です。

メルペイでバックエンドエンジニアをしている iwata です。
メルペイスマート払いの開発をしている Credit Design というチームに所属しています。

私は2019年の入社以来、「メルペイスマート払い(定額払い)」(以下、定額払い)の開発を担当しており、今年の7月にようやくリリースすることができました。
この定額払いの手数料計算のために、「1万分の1を1とする単位」であるベーシスポイントを扱うGo言語のパッケージ go.mercari.io/go-bps を作成しました。
ちょうど1年前に、 mercari.go #12 で「料率計算における小数の扱いについて」として発表しましたが、当時はオープンソースとして公開していませんでした。
今回オープンソースとして公開しましたので、改めてパッケージを紹介します。

料率計算について

お金のやりとりがあると色々と手数料を計算する機会があります。
例を挙げると以下のような手数料が存在します。

  • 決済手数料
  • 加盟店手数料
  • 延滞事務手数料
  • 振込手数料
  • 遅延損害金

トランザクション単位で固定額が発生する手数料もありますが、多くのものは○%などの料率計算が必要です。
決済事業をやる上では必須といえる機能であり、小数の扱いについて考える必要性がでてきます。

一方でこれらの値は時期によって変動することもありえます。
キャンペーンで一時的に手数料を無料にしたり、料率を変更したりすることは往々にして発生しうるので、要件によっては固定値ではなく、データベースに保存することも考えないといけません。

浮動小数点数の扱いについて

Goで以下のコードを実行するとどうなるでしょうか。

package main

import "fmt"

func main() {
    var n float64
    for i := 0; i < 1000; i++ {
        n += 0.01
    }
    fmt.Println(n)
}

実際にGo Playgroundで確認してみると以下のように出力され、10にはなりません。
0.01の丸め誤差
これは0.01が2進数で表現できないため、内部的に近似値が使われ、丸め誤差が生じているせいです。

次に以下のコードをみてみます。

package main

import (
    "fmt"
)

func main() {
    a := float64(1e12)
    var b float64 = 1 / 1e6
    c := a + b
    fmt.Println(c)
    if a == c {
        fmt.Println("equal")
    }
}

cは大きな値aと小さな値bを加算した結果になっています。
このコードをGo Playgroundで実行してみると以下のような出力となります。
情報落ち

これは小数の小さな値に情報落ちが発生して起こる誤差です。

これらの理由から、金銭計算にはfloat64といった浮動小数点数を使わずに小数点数を扱う必要性がでてきます。
ではどのような方法が考えられるでしょうか?
以降では下に挙げる3つのGoパッケージについて取り上げ、メリット・デメリットをそれぞれ確認します。

math/big.Rat

*big.Ratは Go の標準パッケージで有理数を表現するための型を提供します。
big.NewRat(1, 3)で3分の1を表現する、といった具合です。
以下サンプルコード

package main

import (
    "fmt"
    "math/big"
)

func main() {
    a := big.NewRat(1, 3)
    b := big.NewRat(2, 3)
    fmt.Println(a, b)                               // 1/3 2/3
    fmt.Println(a.FloatString(3), b.FloatString(3)) // 0.333 0.667
}

0.01を1000回足してみましょう。

package main

import (
    "fmt"
    "math/big"
)

func main() {
    n := new(big.Rat)
    for i := 0; i < 1000; i++ {
        // big.Rat#Add は 引数 2つ を足した結果を receiver にセット&返す動作
        n = new(big.Rat).Add(n, big.NewRat(1, 100))
    }
    fmt.Println(n.FloatString(1)) // 10.0
}

Go Playgroundで試してみるとちゃんと10.0となることが分かります。
次に情報落ちの例をみてみます。

package main

import (
    "fmt"
    "math/big"
)

func main() {
    a := big.NewRat(1e12, 1)
    b := big.NewRat(1, 1e6)
    c := new(big.Rat).Add(a, b)
    fmt.Println(c.FloatString(6))
}

Go Playgroundで実行してみると、正しく1000000000000.000001と出力されます。

このように*big.Rat型を用いれば誤差なく小数を扱えそうなことが分かります。
但し、*big.Ratは扱いづらいというデメリットがあります。
以下のサンプルコードをみてください。

package main

import "fmt"
import "math/big"

func main() {
    a := big.NewRat(1, 3)
    b := big.NewRat(2, 3)
    c := a // 1/3
    d := b // 2/3
    z := a.Add(a, b) // someone might misuse the API
    fmt.Println(a, b, z)
    fmt.Println(c.Cmp(d) < 0) // they might expect this to print "true"
    fmt.Println(c, d) // but instead, c was changed because it points to the same big.Rat as a
}

*big.Rat型のメソッドはミュータブルであるため、a.Add(a, b)を実行するとレシーバであるa自身の値も更新されます。したがって変数azは同じ値1/1となります。
またポインタ型となるため変数aが更新されると、aが代入れている変数cも予期せず更新されてしまいます。

冒頭でも述べたように料率をデータベースに保存したい時にはひと工夫が必要になります。
FLOAT型で保存すると誤差が発生してしまうので、分母と分子で別々のカラムに保存しておくか、STRING型で保存などです。
一方で Google Cloud Spanner であれば先日対応されたNUMERIC型を使用することで*big.Ratへの変換を自動的に行えるようです。
(*big.Ratの扱いにくさから筆者はまだ試用するに至っていません)

github.com/shopspring/decimal

github.com/shopspring/decimal は固定小数点数を扱うパッケージです。
2の31乗までの精度であれば誤差なく小数点数を扱うことができます。

以下はREADMEに記載されているサンプルコードです。

package main

import (
    "fmt"
    "github.com/shopspring/decimal"
)

func main() {
    price, err := decimal.NewFromString("136.02")
    if err != nil {
        panic(err)
    }

    quantity := decimal.NewFromInt(3)

    fee, _ := decimal.NewFromString(".035")
    taxRate, _ := decimal.NewFromString(".08875")

    subtotal := price.Mul(quantity)

    preTax := subtotal.Mul(fee.Add(decimal.NewFromFloat(1)))

    total := preTax.Mul(taxRate.Add(decimal.NewFromFloat(1)))

    fmt.Println("Subtotal:", subtotal)                      // Subtotal: 408.06
    fmt.Println("Pre-tax:", preTax)                         // Pre-tax: 422.3421
    fmt.Println("Taxes:", total.Sub(preTax))                // Taxes: 37.482861375
    fmt.Println("Total:", total)                            // Total: 459.824961375
    fmt.Println("Tax rate:", total.Sub(preTax).Div(preTax)) // Tax rate: 0.08875
}

サンプルから分かるようにdecimal.Decimal型のメソッドは*big.Rat型とは違い、イミュータブルです。
すなわちレシーバに対する演算結果を返り値として返すので扱い易くなっています。

0.01を1000回足してみます。

package main

import (
    "fmt"

    "github.com/shopspring/decimal"
)

func main() {
    n := decimal.RequireFromString("0")
    for i := 0; i < 1000; i++ {
        n = n.Add(decimal.NewFromFloat(.01))
    }
    fmt.Println(n) // 10
}

Go Playgroundで試すとちゃんと10が出力されます。
次に情報落ちについてはどうでしょうか。

package main

import (
    "fmt"

    "github.com/shopspring/decimal"
)

func main() {
    a := decimal.NewFromFloat(float64(1e12))
    b := decimal.NewFromFloat(1e-6)
    fmt.Println(a.Add(b)) // 1000000000000.000001
}

こちらも誤差なく1000000000000.000001と出力されます。
https://play.golang.org/p/MbsPWk5FD2d

データベースに保存する際はどうでしょうか。
文字列で保存しておいて取得時にはdecimal.NewFromString()でパースすれば誤差なく保存できそうです。
しかしながらパースに失敗したり、そもそも数値を文字列として保存すると集計時などで後々問題になることは想像に難くありません。

ベーシスポイントとは?

上記のような悩みがあったので社内の Go Friday で小数点数の扱いについて質問したところ教えてもらったのがベーシスポイント(Basis Point)です。
パーセントが100分の1を1とし、百分率(%)と呼ばれるのに対して、ベーシスポイントは1万分の1を1とする単位で万分率(‱)と呼ばれます。
ベーシスポイントを用いると0.01%までを整数として扱うことができるというわけです。
当然0.01%以下の桁がでてくると小数で表現する必要性が生じるため、アプリケーション上小数点以下の桁数をどこまで扱う必要があるかによって基底の単位を決める必要があります。

go.mercari.io/go-bps

go.mercari.io/go-bps はこのベーシスポイントをGoで扱うためのパッケージです。
以下にサンプルコードを挙げてみます。

package main

import (
    "fmt"

    "go.mercari.io/go-bps/bps"
)

func main() {
    var price int64 = 15980
    var (
        rate1 = bps.NewFromPercentage(8)   // rate1 is 8.0%
        rate2 = bps.MustFromString(".045") // rate2 is 0.045 = 4.5%
        rate3 = bps.NewFromBasisPoint(264) // rate3 is 2.64% = 264 basis points
    )

    // fee: 15980 * 8% = 1278.4
    // Output: 1278 127840
    fmt.Println(rate1.Mul(price).Amounts(), rate1.Mul(price).BasisPoints())
    // fee: 15980 * 4.5% = 719.1
    // Output: 719 71910
    fmt.Println(rate2.Mul(price).Amounts(), rate2.Mul(price).BasisPoints())
    // fee: 15980 * 2.64 = 421.872
    // Output: 421 42187(always rounded down)
    fmt.Println(rate3.Mul(price).Amounts(), rate3.Mul(price).BasisPoints())
}

bps.NewXxx()*bps.BPSオブジェクト(rate1など)を返します。
四則演算はdecimal.Decimal同様、メソッドとして提供されます。

go-bps0.01を1000回足してみます。

package main

import (
    "fmt"

    "go.mercari.io/go-bps/bps"
)

func main() {
    n := bps.NewFromAmount(0)
    for i := 0; i < 1000; i++ {
        n = n.Add(bps.MustFromString(".01"))
    }
    fmt.Println(n.Amounts()) // 10
}

Go Playground で試すと正しく10が表示されます。
次に大きい数値と小さい数値を足してみます。

package main

import (
    "fmt"

    "go.mercari.io/go-bps/bps"
)

func main() {
    a := bps.NewFromAmount(1e12)
    b := bps.MustFromString(".000001")
    fmt.Println(a.Add(b))           // 1000000000000000001 ppm
    fmt.Println(a.Add(b).Amounts()) // 1000000000000
}

*bps.BPS#Amounts()は小数点以下を切り捨てるので1000000000000と表示されますが、インスタンス内では情報落ちせずに値を保持していることがわかります。
https://play.golang.org/p/lb2IcejpqSa

go-bpsではパーツパーミリオン(ppm)を最小の単位として扱っています。つまり*bps.BPS.value*big.Intとして保持している値はパーツパーミリオンです。
したがって100万分の1までであれば整数として処理可能です。

データベースに保存する際は必要な精度に応じて整数で保存できます。
1万分の1の値まででよければ*bps.BPS#BasisPoints()の返す整数を保存すればよく、100万分の1の値まで必要であれば*bps.BPS#PPMs()の整数を保存する必要があります。
この辺りはシステムの要件によって決まってくるはずです。

メルペイでは実際にgo-bpsを使って計算した手数料を Cloud Spanner に保存・取得するようなコードが実装され本番環境でも稼動しています。

まとめ

Goでベーシスポイントを扱うためのパッケージgo.mercari.io/go-bpsを紹介しました。
去年のmercari.goでの発表時には本番実績はなく、リポジトリ自体もプライベートリポジトリとなっていましたが晴れてオープンソースとして公開することができました。
mercari.goでの発表時に公開して欲しいという声を少なからずいただいていましたが、定額払いの実装で実用に耐えうることが確認できたタイミングでの公開となりました。

パッケージ内でやっていることは整数の桁上げ、桁下げをしているだけなのですが、単純な整数の扱いであるが故にベーシスポイントの型が定義されていることでコードの可読性が向上できていると感じています。

明日の Merpay Advent Calendar 2020 執筆担当は、 Solutionsチーム の @vvakame さんです。引き続きお楽しみください。

  • X
  • Facebook
  • linkedin
  • このエントリーをはてなブックマークに追加