use shopspring/decimal library everywhere #12

Merged
natercio merged 2 commits from fix-zero-entries into main 2025-11-16 23:25:06 +00:00
16 changed files with 465 additions and 287 deletions

View File

@@ -5,14 +5,33 @@ on:
pull_request:
jobs:
check-generate:
check-changes:
runs-on: ubuntu-latest
if: contains(github.event.pull_request.changed_files, '*_gen.go') ||
contains(github.event.pull_request.changed_files, '*/generate.go')
outputs:
has_gen_changes: ${{ steps.check.outputs.has_gen_changes }}
steps:
- name: Checkout code
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Check for generated file changes
id: check
run: |
if git diff --name-only origin/${{ github.base_ref }}...HEAD | grep -E '_gen\.go$|/generate\.go$'; then
echo "has_gen_changes=true" >> $GITHUB_OUTPUT
else
echo "has_gen_changes=false" >> $GITHUB_OUTPUT
fi
check-generate:
runs-on: ubuntu-latest
needs: check-changes
if: needs.check-changes.outputs.has_gen_changes == 'true'
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Setup Go
uses: actions/setup-go@v4
with:

View File

@@ -5,9 +5,29 @@ on:
types: [opened, reopened, synchronize]
jobs:
check-changes:
runs-on: ubuntu-latest
outputs:
has_go_changes: ${{ steps.check.outputs.has_go_changes }}
steps:
- name: Checkout code
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Check for Go changes
id: check
run: |
if git diff --name-only origin/${{ github.base_ref }}...HEAD | grep -E '\.go$|go\.(mod|sum)$'; then
echo "has_go_changes=true" >> $GITHUB_OUTPUT
else
echo "has_go_changes=false" >> $GITHUB_OUTPUT
fi
tests:
runs-on: ubuntu-latest
if: contains(github.event.pull_request.changed_files, '*.go')
needs: check-changes
if: needs.check-changes.outputs.has_go_changes == 'true'
steps:
- name: Checkout code
uses: actions/checkout@v3

1
go.mod
View File

@@ -8,6 +8,7 @@ require (
)
require (
github.com/shopspring/decimal v1.4.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
golang.org/x/mod v0.27.0 // indirect
golang.org/x/tools v0.36.0 // indirect

2
go.sum
View File

@@ -4,6 +4,8 @@ github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=

5
internal/errors.go Normal file
View File

@@ -0,0 +1,5 @@
package internal
import "fmt"
var ErrInsufficientBoughtVolume = fmt.Errorf("insufficient bought volume")

96
internal/filler.go Normal file
View File

@@ -0,0 +1,96 @@
package internal
import (
"container/list"
"github.com/shopspring/decimal"
)
type Filler struct {
Record
filled decimal.Decimal
}
func NewFiller(r Record) *Filler {
return &Filler{
Record: r,
}
}
// Fill accrues some quantity. Returns how mutch was accrued in the 1st return value and whether
// it was filled or not on the 2nd return value.
func (f *Filler) Fill(quantity decimal.Decimal) (decimal.Decimal, bool) {
unfilled := f.Record.Quantity().Sub(f.filled)
delta := decimal.Min(unfilled, quantity)
f.filled = f.filled.Add(delta)
return delta, f.IsFilled()
}
// IsFilled returns true if the fill is equal to the record quantity.
func (f *Filler) IsFilled() bool {
return f.filled.Equal(f.Quantity())
}
type FillerQueue struct {
l *list.List
}
// Push inserts the Filler at the back of the queue.
func (fq *FillerQueue) Push(f *Filler) {
if f == nil {
return
}
if fq == nil {
// This would cause a panic anyway so, we panic with a more meaningful message
panic("Push to nil FillerQueue")
}
if fq.l == nil {
fq.l = list.New()
}
fq.l.PushBack(f)
}
// Pop removes and returns the first Filler of the queue in the 1st return value. If the list is
// empty returns false on the 2nd return value, true otherwise.
func (fq *FillerQueue) Pop() (*Filler, bool) {
el := fq.frontElement()
if el == nil {
return nil, false
}
val := fq.l.Remove(el)
return val.(*Filler), true
}
// Peek returns the front Filler of the queue in the 1st return value. If the list is empty returns
// false on the 2nd return value, true otherwise.
func (fq *FillerQueue) Peek() (*Filler, bool) {
el := fq.frontElement()
if el == nil {
return nil, false
}
return el.Value.(*Filler), true
}
func (fq *FillerQueue) frontElement() *list.Element {
if fq == nil || fq.l == nil {
return nil
}
return fq.l.Front()
}
// Len returns how many elements are currently on the queue
func (fq *FillerQueue) Len() int {
if fq == nil || fq.l == nil {
return 0
}
return fq.l.Len()
}

187
internal/filler_test.go Normal file
View File

@@ -0,0 +1,187 @@
package internal
import (
"testing"
"github.com/shopspring/decimal"
)
func TestFillerQueue(t *testing.T) {
var recCount int
newRecord := func() Record {
recCount++
return testRecord{
id: recCount,
}
}
var rq FillerQueue
if rq.Len() != 0 {
t.Fatalf("zero value should have zero lenght")
}
_, ok := rq.Pop()
if ok {
t.Fatalf("Pop() should return (_,false) on a zero value")
}
_, ok = rq.Peek()
if ok {
t.Fatalf("Peek() should return (_,false) on a zero value")
}
rq.Push(nil)
if rq.Len() != 0 {
t.Fatalf("pushing nil should be a no-op")
}
rq.Push(NewFiller(newRecord()))
if rq.Len() != 1 {
t.Fatalf("pushing 1st record should result in lenght of 1")
}
rq.Push(NewFiller(newRecord()))
if rq.Len() != 2 {
t.Fatalf("pushing 2nd record should result in lenght of 2")
}
peekFiller, ok := rq.Peek()
if !ok {
t.Fatalf("Peek() should return (_,true) when the list is not empty")
}
if rec, ok := peekFiller.Record.(testRecord); ok {
if rec.id != 1 {
t.Fatalf("Peek() should return the 1st record pushed but returned %d", rec.id)
}
} else {
t.Fatalf("Peek() should return the original record type")
}
if rq.Len() != 2 {
t.Fatalf("Peek() should not affect the list length")
}
popFiller, ok := rq.Pop()
if !ok {
t.Fatalf("Pop() should return (_,true) when the list is not empty")
}
if rec, ok := popFiller.Record.(testRecord); ok {
if rec.id != 1 {
t.Fatalf("Pop() should return the first record pushed but returned %d", rec.id)
}
} else {
t.Fatalf("Pop() should return the original record")
}
if rq.Len() != 1 {
t.Fatalf("Pop() should remove an element from the list")
}
}
func TestFillerQueueNilReceiver(t *testing.T) {
var rq *FillerQueue
if rq.Len() > 0 {
t.Fatalf("nil receiver should have zero lenght")
}
_, ok := rq.Peek()
if ok {
t.Fatalf("Peek() on a nil receiver should return (_,false)")
}
_, ok = rq.Pop()
if ok {
t.Fatalf("Pop() on a nil receiver should return (_,false)")
}
rq.Push(nil)
if rq.Len() != 0 {
t.Fatalf("Push(nil) on a nil receiver should be a no-op")
}
defer func() {
r := recover()
if r == nil {
t.Fatalf("expected a panic but got nothing")
}
expMsg := "Push to nil FillerQueue"
if msg, ok := r.(string); !ok || msg != expMsg {
t.Fatalf(`want panic message %q but got "%v"`, expMsg, r)
}
}()
rq.Push(NewFiller(nil))
}
type testRecord struct {
Record
id int
quantity decimal.Decimal
}
func (tr testRecord) Quantity() decimal.Decimal {
return tr.quantity
}
func TestFiller_Fill(t *testing.T) {
tests := []struct {
name string
r Record
quantity decimal.Decimal
want decimal.Decimal
wantBool bool
}{
{
name: "fills 0 of zero quantity",
r: &testRecord{quantity: decimal.NewFromFloat(0.0)},
quantity: decimal.Decimal{},
want: decimal.Decimal{},
wantBool: true,
},
{
name: "fills 0 of positive quantity",
r: &testRecord{quantity: decimal.NewFromFloat(100.0)},
quantity: decimal.Decimal{},
want: decimal.Decimal{},
wantBool: false,
},
{
name: "fills 10 out of 100 and no previous fills",
r: &testRecord{quantity: decimal.NewFromFloat(100.0)},
quantity: decimal.NewFromFloat(10),
want: decimal.NewFromFloat(10),
wantBool: false,
},
{
name: "fills 10 out of 10 and no previous fills",
r: &testRecord{quantity: decimal.NewFromFloat(10.0)},
quantity: decimal.NewFromFloat(10),
want: decimal.NewFromFloat(10),
wantBool: true,
},
{
name: "filling 100 fills 10 out of 10 and no previous fills",
r: &testRecord{quantity: decimal.NewFromFloat(10.0)},
quantity: decimal.NewFromFloat(100),
want: decimal.NewFromFloat(10),
wantBool: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f := NewFiller(tt.r)
got, gotBool := f.Fill(tt.quantity)
if !tt.want.Equal(got) {
t.Errorf("want 1st return value to be %v but got %v", tt.want, got)
}
if tt.wantBool != gotBool {
t.Errorf("want 2nd return value to be %v but got %v", tt.wantBool, gotBool)
}
})
}
}

View File

@@ -11,11 +11,11 @@ package mocks
import (
context "context"
big "math/big"
reflect "reflect"
time "time"
internal "github.com/nmoniz/any2anexoj/internal"
decimal "github.com/shopspring/decimal"
gomock "go.uber.org/mock/gomock"
)
@@ -107,10 +107,10 @@ func (m *MockRecord) EXPECT() *MockRecordMockRecorder {
}
// Fees mocks base method.
func (m *MockRecord) Fees() *big.Float {
func (m *MockRecord) Fees() decimal.Decimal {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Fees")
ret0, _ := ret[0].(*big.Float)
ret0, _ := ret[0].(decimal.Decimal)
return ret0
}
@@ -127,28 +127,28 @@ type MockRecordFeesCall struct {
}
// Return rewrite *gomock.Call.Return
func (c *MockRecordFeesCall) Return(arg0 *big.Float) *MockRecordFeesCall {
func (c *MockRecordFeesCall) Return(arg0 decimal.Decimal) *MockRecordFeesCall {
c.Call = c.Call.Return(arg0)
return c
}
// Do rewrite *gomock.Call.Do
func (c *MockRecordFeesCall) Do(f func() *big.Float) *MockRecordFeesCall {
func (c *MockRecordFeesCall) Do(f func() decimal.Decimal) *MockRecordFeesCall {
c.Call = c.Call.Do(f)
return c
}
// DoAndReturn rewrite *gomock.Call.DoAndReturn
func (c *MockRecordFeesCall) DoAndReturn(f func() *big.Float) *MockRecordFeesCall {
func (c *MockRecordFeesCall) DoAndReturn(f func() decimal.Decimal) *MockRecordFeesCall {
c.Call = c.Call.DoAndReturn(f)
return c
}
// Price mocks base method.
func (m *MockRecord) Price() *big.Float {
func (m *MockRecord) Price() decimal.Decimal {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Price")
ret0, _ := ret[0].(*big.Float)
ret0, _ := ret[0].(decimal.Decimal)
return ret0
}
@@ -165,28 +165,28 @@ type MockRecordPriceCall struct {
}
// Return rewrite *gomock.Call.Return
func (c *MockRecordPriceCall) Return(arg0 *big.Float) *MockRecordPriceCall {
func (c *MockRecordPriceCall) Return(arg0 decimal.Decimal) *MockRecordPriceCall {
c.Call = c.Call.Return(arg0)
return c
}
// Do rewrite *gomock.Call.Do
func (c *MockRecordPriceCall) Do(f func() *big.Float) *MockRecordPriceCall {
func (c *MockRecordPriceCall) Do(f func() decimal.Decimal) *MockRecordPriceCall {
c.Call = c.Call.Do(f)
return c
}
// DoAndReturn rewrite *gomock.Call.DoAndReturn
func (c *MockRecordPriceCall) DoAndReturn(f func() *big.Float) *MockRecordPriceCall {
func (c *MockRecordPriceCall) DoAndReturn(f func() decimal.Decimal) *MockRecordPriceCall {
c.Call = c.Call.DoAndReturn(f)
return c
}
// Quantity mocks base method.
func (m *MockRecord) Quantity() *big.Float {
func (m *MockRecord) Quantity() decimal.Decimal {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Quantity")
ret0, _ := ret[0].(*big.Float)
ret0, _ := ret[0].(decimal.Decimal)
return ret0
}
@@ -203,19 +203,19 @@ type MockRecordQuantityCall struct {
}
// Return rewrite *gomock.Call.Return
func (c *MockRecordQuantityCall) Return(arg0 *big.Float) *MockRecordQuantityCall {
func (c *MockRecordQuantityCall) Return(arg0 decimal.Decimal) *MockRecordQuantityCall {
c.Call = c.Call.Return(arg0)
return c
}
// Do rewrite *gomock.Call.Do
func (c *MockRecordQuantityCall) Do(f func() *big.Float) *MockRecordQuantityCall {
func (c *MockRecordQuantityCall) Do(f func() decimal.Decimal) *MockRecordQuantityCall {
c.Call = c.Call.Do(f)
return c
}
// DoAndReturn rewrite *gomock.Call.DoAndReturn
func (c *MockRecordQuantityCall) DoAndReturn(f func() *big.Float) *MockRecordQuantityCall {
func (c *MockRecordQuantityCall) DoAndReturn(f func() decimal.Decimal) *MockRecordQuantityCall {
c.Call = c.Call.DoAndReturn(f)
return c
}
@@ -297,10 +297,10 @@ func (c *MockRecordSymbolCall) DoAndReturn(f func() string) *MockRecordSymbolCal
}
// Taxes mocks base method.
func (m *MockRecord) Taxes() *big.Float {
func (m *MockRecord) Taxes() decimal.Decimal {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Taxes")
ret0, _ := ret[0].(*big.Float)
ret0, _ := ret[0].(decimal.Decimal)
return ret0
}
@@ -317,19 +317,19 @@ type MockRecordTaxesCall struct {
}
// Return rewrite *gomock.Call.Return
func (c *MockRecordTaxesCall) Return(arg0 *big.Float) *MockRecordTaxesCall {
func (c *MockRecordTaxesCall) Return(arg0 decimal.Decimal) *MockRecordTaxesCall {
c.Call = c.Call.Return(arg0)
return c
}
// Do rewrite *gomock.Call.Do
func (c *MockRecordTaxesCall) Do(f func() *big.Float) *MockRecordTaxesCall {
func (c *MockRecordTaxesCall) Do(f func() decimal.Decimal) *MockRecordTaxesCall {
c.Call = c.Call.Do(f)
return c
}
// DoAndReturn rewrite *gomock.Call.DoAndReturn
func (c *MockRecordTaxesCall) DoAndReturn(f func() *big.Float) *MockRecordTaxesCall {
func (c *MockRecordTaxesCall) DoAndReturn(f func() decimal.Decimal) *MockRecordTaxesCall {
c.Call = c.Call.DoAndReturn(f)
return c
}

View File

@@ -1,80 +1,17 @@
package internal
import (
"container/list"
"math/big"
"time"
"github.com/shopspring/decimal"
)
type Record interface {
Symbol() string
Side() Side
Price() *big.Float
Quantity() *big.Float
Price() decimal.Decimal
Quantity() decimal.Decimal
Timestamp() time.Time
Fees() *big.Float
Taxes() *big.Float
}
type RecordQueue struct {
l *list.List
}
// Push inserts the Record at the back of the queue. If pushing a nil Record then it's a no-op.
func (rq *RecordQueue) Push(r Record) {
if r == nil {
return
}
if rq == nil {
// This would cause a panic anyway so, we panic with a more meaningful message
panic("Push to nil RecordQueue")
}
if rq.l == nil {
rq.l = list.New()
}
rq.l.PushBack(r)
}
// Pop removes and returns the first Record of the queue in the 1st return value. If the list is
// empty returns false on the 2nd return value, true otherwise.
func (rq *RecordQueue) Pop() (Record, bool) {
el := rq.frontElement()
if el == nil {
return nil, false
}
val := rq.l.Remove(el)
return val.(Record), true
}
// Peek returns the front Record of the queue in the 1st return value. If the list is empty returns
// false on the 2nd return value, true otherwise.
func (rq *RecordQueue) Peek() (Record, bool) {
el := rq.frontElement()
if el == nil {
return nil, false
}
return el.Value.(Record), true
}
func (rq *RecordQueue) frontElement() *list.Element {
if rq == nil || rq.l == nil {
return nil
}
return rq.l.Front()
}
// Len returns how many elements are currently on the queue
func (rq *RecordQueue) Len() int {
if rq == nil || rq.l == nil {
return 0
}
return rq.l.Len()
Fees() decimal.Decimal
Taxes() decimal.Decimal
}

View File

@@ -1,122 +0,0 @@
package internal
import (
"testing"
)
func TestRecordQueue(t *testing.T) {
var recCount int
newRecord := func() Record {
recCount++
return testRecord{
id: recCount,
}
}
var rq RecordQueue
if rq.Len() != 0 {
t.Fatalf("zero value should have zero lenght")
}
_, ok := rq.Pop()
if ok {
t.Fatalf("Pop() should return (_,false) on a zero value")
}
_, ok = rq.Peek()
if ok {
t.Fatalf("Peek() should return (_,false) on a zero value")
}
rq.Push(nil)
if rq.Len() != 0 {
t.Fatalf("pushing nil should be a no-op")
}
rq.Push(newRecord())
if rq.Len() != 1 {
t.Fatalf("pushing 1st record should result in lenght of 1")
}
rq.Push(newRecord())
if rq.Len() != 2 {
t.Fatalf("pushing 2nd record should result in lenght of 2")
}
peekRec, ok := rq.Peek()
if !ok {
t.Fatalf("Peek() should return (_,true) when the list is not empty")
}
if peekRec, ok := peekRec.(testRecord); ok {
if peekRec.id != 1 {
t.Fatalf("Peek() should return the 1st record pushed but returned %d", peekRec.id)
}
} else {
t.Fatalf("Peek() should return the original record type")
}
if rq.Len() != 2 {
t.Fatalf("Peek() should not affect the list length")
}
popRec, ok := rq.Pop()
if !ok {
t.Fatalf("Pop() should return (_,true) when the list is not empty")
}
if rec, ok := popRec.(testRecord); ok {
if rec.id != 1 {
t.Fatalf("Pop() should return the first record pushed but returned %d", rec.id)
}
} else {
t.Fatalf("Pop() should return the original record")
}
if rq.Len() != 1 {
t.Fatalf("Pop() should remove an element from the list")
}
}
func TestRecordQueueNilReceiver(t *testing.T) {
var rq *RecordQueue
if rq.Len() > 0 {
t.Fatalf("nil receiver should have zero lenght")
}
_, ok := rq.Peek()
if ok {
t.Fatalf("Peek() on a nil receiver should return (_,false)")
}
_, ok = rq.Pop()
if ok {
t.Fatalf("Pop() on a nil receiver should return (_,false)")
}
rq.Push(nil)
if rq.Len() != 0 {
t.Fatalf("Push(nil) on a nil receiver should be a no-op")
}
defer func() {
r := recover()
if r == nil {
t.Fatalf("expected a panic but got nothing")
}
expMsg := "Push to nil RecordQueue"
if msg, ok := r.(string); !ok || msg != expMsg {
t.Fatalf(`want panic message %q but got "%v"`, expMsg, r)
}
}()
rq.Push(testRecord{})
}
type testRecord struct {
Record
id int
}

View File

@@ -5,8 +5,9 @@ import (
"errors"
"fmt"
"io"
"math/big"
"time"
"github.com/shopspring/decimal"
)
type RecordReader interface {
@@ -20,7 +21,7 @@ type ReportWriter interface {
}
func BuildReport(ctx context.Context, reader RecordReader, writer ReportWriter) error {
buys := make(map[string]*RecordQueue)
buys := make(map[string]*FillerQueue)
for {
select {
@@ -37,7 +38,7 @@ func BuildReport(ctx context.Context, reader RecordReader, writer ReportWriter)
buyQueue, ok := buys[rec.Symbol()]
if !ok {
buyQueue = new(RecordQueue)
buyQueue = new(FillerQueue)
buys[rec.Symbol()] = buyQueue
}
@@ -49,42 +50,41 @@ func BuildReport(ctx context.Context, reader RecordReader, writer ReportWriter)
}
}
func processRecord(ctx context.Context, q *RecordQueue, rec Record, writer ReportWriter) error {
func processRecord(ctx context.Context, q *FillerQueue, rec Record, writer ReportWriter) error {
switch rec.Side() {
case SideBuy:
q.Push(rec)
q.Push(NewFiller(rec))
case SideSell:
unmatchedQty := new(big.Float).Copy(rec.Quantity())
zero := new(big.Float)
unmatchedQty := rec.Quantity()
for unmatchedQty.Cmp(zero) > 0 {
for unmatchedQty.IsPositive() {
buy, ok := q.Peek()
if !ok {
return ErrInsufficientBoughtVolume
}
var matchedQty *big.Float
if buy.Quantity().Cmp(unmatchedQty) > 0 {
matchedQty = unmatchedQty
buy.Quantity().Sub(buy.Quantity(), unmatchedQty)
} else {
matchedQty = buy.Quantity()
q.Pop()
matchedQty, filled := buy.Fill(unmatchedQty)
if filled {
_, ok := q.Pop()
if !ok {
return fmt.Errorf("pop empty filler queue")
}
}
unmatchedQty.Sub(unmatchedQty, matchedQty)
unmatchedQty = unmatchedQty.Sub(matchedQty)
sellValue := new(big.Float).Mul(matchedQty, rec.Price())
buyValue := new(big.Float).Mul(matchedQty, buy.Price())
buyValue := matchedQty.Mul(buy.Price())
sellValue := matchedQty.Mul(rec.Price())
err := writer.Write(ctx, ReportItem{
BuyValue: buyValue,
BuyTimestamp: buy.Timestamp(),
SellValue: sellValue,
SellTimestamp: rec.Timestamp(),
Fees: new(big.Float).Add(buy.Fees(), rec.Fees()),
Taxes: new(big.Float).Add(buy.Taxes(), rec.Fees()),
Fees: buy.Fees().Add(rec.Fees()),
Taxes: buy.Taxes().Add(rec.Fees()),
})
if err != nil {
return fmt.Errorf("write report item: %w", err)
@@ -99,16 +99,14 @@ func processRecord(ctx context.Context, q *RecordQueue, rec Record, writer Repor
}
type ReportItem struct {
BuyValue *big.Float
BuyValue decimal.Decimal
BuyTimestamp time.Time
SellValue *big.Float
SellValue decimal.Decimal
SellTimestamp time.Time
Fees *big.Float
Taxes *big.Float
Fees decimal.Decimal
Taxes decimal.Decimal
}
func (ri ReportItem) RealisedPnL() *big.Float {
return new(big.Float).Sub(ri.SellValue, ri.BuyValue)
func (ri ReportItem) RealisedPnL() decimal.Decimal {
return ri.SellValue.Sub(ri.BuyValue)
}
var ErrInsufficientBoughtVolume = fmt.Errorf("insufficient bought volume")

View File

@@ -2,17 +2,18 @@ package internal_test
import (
"context"
"fmt"
"io"
"math/big"
"testing"
"time"
"github.com/nmoniz/any2anexoj/internal"
"github.com/nmoniz/any2anexoj/internal/mocks"
"github.com/shopspring/decimal"
"go.uber.org/mock/gomock"
)
func TestReporter_Run(t *testing.T) {
func TestBuildReport(t *testing.T) {
now := time.Now()
ctrl := gomock.NewController(t)
@@ -32,13 +33,13 @@ func TestReporter_Run(t *testing.T) {
}).Times(3)
writer := mocks.NewMockReportWriter(ctrl)
writer.EXPECT().Write(gomock.Any(), gomock.Eq(internal.ReportItem{
BuyValue: new(big.Float).SetFloat64(200.0),
writer.EXPECT().Write(gomock.Any(), eqReportItem(internal.ReportItem{
BuyValue: decimal.NewFromFloat(200.0),
BuyTimestamp: now,
SellValue: new(big.Float).SetFloat64(250.0),
SellValue: decimal.NewFromFloat(250.0),
SellTimestamp: now.Add(1),
Fees: new(big.Float),
Taxes: new(big.Float),
Fees: decimal.Decimal{},
Taxes: decimal.Decimal{},
})).Times(1)
gotErr := internal.BuildReport(t.Context(), reader, writer)
@@ -49,12 +50,47 @@ func TestReporter_Run(t *testing.T) {
func mockRecord(ctrl *gomock.Controller, price, quantity float64, side internal.Side, ts time.Time) *mocks.MockRecord {
rec := mocks.NewMockRecord(ctrl)
rec.EXPECT().Price().Return(big.NewFloat(price)).AnyTimes()
rec.EXPECT().Quantity().Return(big.NewFloat(quantity)).AnyTimes()
rec.EXPECT().Price().Return(decimal.NewFromFloat(price)).AnyTimes()
rec.EXPECT().Quantity().Return(decimal.NewFromFloat(quantity)).AnyTimes()
rec.EXPECT().Side().Return(side).AnyTimes()
rec.EXPECT().Symbol().Return("TEST").AnyTimes()
rec.EXPECT().Timestamp().Return(ts).AnyTimes()
rec.EXPECT().Fees().Return(new(big.Float)).AnyTimes()
rec.EXPECT().Taxes().Return(new(big.Float)).AnyTimes()
rec.EXPECT().Fees().Return(decimal.Decimal{}).AnyTimes()
rec.EXPECT().Taxes().Return(decimal.Decimal{}).AnyTimes()
return rec
}
func eqReportItem(ri internal.ReportItem) ReportItemMatcher {
return ReportItemMatcher{
ReportItem: ri,
}
}
type ReportItemMatcher struct {
internal.ReportItem
}
// Matches implements gomock.Matcher.
func (m ReportItemMatcher) Matches(x any) bool {
if x == nil {
return false
}
switch other := x.(type) {
case internal.ReportItem:
return m.BuyValue.Equal(other.BuyValue) &&
m.BuyTimestamp.Equal(other.BuyTimestamp) &&
m.SellValue.Equal(other.SellValue) &&
m.SellTimestamp.Equal(other.SellTimestamp) &&
m.Fees.Equal(other.Fees) &&
m.Taxes.Equal(other.Taxes)
default:
return false
}
}
func (m ReportItemMatcher) String() string {
return fmt.Sprintf("is equivalent to %v", m.ReportItem)
}
var _ gomock.Matcher = (*ReportItemMatcher)(nil)

View File

@@ -29,6 +29,6 @@ func NewReportLogger(w io.Writer) *ReportLogger {
func (rl *ReportLogger) Write(_ context.Context, ri ReportItem) error {
rl.counter++
_, err := fmt.Fprintf(rl.writer, "%6d - realised %+f on %s\n", rl.counter, ri.RealisedPnL(), ri.SellTimestamp.Format(time.RFC3339))
_, err := fmt.Fprintf(rl.writer, "%6d: realised %s on %s\n", rl.counter, ri.RealisedPnL().String(), ri.SellTimestamp.Format(time.RFC3339))
return err
}

View File

@@ -3,11 +3,11 @@ package internal_test
import (
"bytes"
"fmt"
"math/big"
"testing"
"time"
"github.com/nmoniz/any2anexoj/internal"
"github.com/shopspring/decimal"
)
func TestReportLogger_Write(t *testing.T) {
@@ -25,45 +25,45 @@ func TestReportLogger_Write(t *testing.T) {
name: "single item positive",
items: []internal.ReportItem{
{
BuyValue: new(big.Float).SetFloat64(100.0),
SellValue: new(big.Float).SetFloat64(200.0),
BuyValue: decimal.NewFromFloat(100.0),
SellValue: decimal.NewFromFloat(200.0),
SellTimestamp: tNow,
},
},
want: []string{
fmt.Sprintf("%6d - realised +100.000000 on %s\n", 1, tNow.Format(time.RFC3339)),
fmt.Sprintf("%6d: realised 100 on %s\n", 1, tNow.Format(time.RFC3339)),
},
},
{
name: "single item negative",
items: []internal.ReportItem{
{
BuyValue: new(big.Float).SetFloat64(200.0),
SellValue: new(big.Float).SetFloat64(150.0),
BuyValue: decimal.NewFromFloat(200.0),
SellValue: decimal.NewFromFloat(150.0),
SellTimestamp: tNow,
},
},
want: []string{
fmt.Sprintf("%6d - realised -50.000000 on %s\n", 1, tNow.Format(time.RFC3339)),
fmt.Sprintf("%6d: realised -50 on %s\n", 1, tNow.Format(time.RFC3339)),
},
},
{
name: "multiple items",
items: []internal.ReportItem{
{
BuyValue: new(big.Float).SetFloat64(100.0),
SellValue: new(big.Float).SetFloat64(200.0),
BuyValue: decimal.NewFromFloat(100.0),
SellValue: decimal.NewFromFloat(200.0),
SellTimestamp: tNow,
},
{
BuyValue: new(big.Float).SetFloat64(200.0),
SellValue: new(big.Float).SetFloat64(150.0),
BuyValue: decimal.NewFromFloat(200.0),
SellValue: decimal.NewFromFloat(150.0),
SellTimestamp: tNow.Add(1),
},
},
want: []string{
fmt.Sprintf("%6d - realised +100.000000 on %s\n", 1, tNow.Format(time.RFC3339)),
fmt.Sprintf("%6d - realised -50.000000 on %s\n", 2, tNow.Add(1).Format(time.RFC3339)),
fmt.Sprintf("%6d: realised 100 on %s\n", 1, tNow.Format(time.RFC3339)),
fmt.Sprintf("%6d: realised -50 on %s\n", 2, tNow.Add(1).Format(time.RFC3339)),
},
},
}

View File

@@ -5,21 +5,21 @@ import (
"encoding/csv"
"fmt"
"io"
"math/big"
"strings"
"time"
"github.com/nmoniz/any2anexoj/internal"
"github.com/shopspring/decimal"
)
type Record struct {
symbol string
side internal.Side
quantity *big.Float
price *big.Float
quantity decimal.Decimal
price decimal.Decimal
timestamp time.Time
fees *big.Float
taxes *big.Float
fees decimal.Decimal
taxes decimal.Decimal
}
func (r Record) Symbol() string {
@@ -30,11 +30,11 @@ func (r Record) Side() internal.Side {
return r.side
}
func (r Record) Quantity() *big.Float {
func (r Record) Quantity() decimal.Decimal {
return r.quantity
}
func (r Record) Price() *big.Float {
func (r Record) Price() decimal.Decimal {
return r.price
}
@@ -42,11 +42,11 @@ func (r Record) Timestamp() time.Time {
return r.timestamp
}
func (r Record) Fees() *big.Float {
func (r Record) Fees() decimal.Decimal {
return r.fees
}
func (r Record) Taxes() *big.Float {
func (r Record) Taxes() decimal.Decimal {
return r.taxes
}
@@ -123,24 +123,23 @@ func (rr RecordReader) ReadRecord(_ context.Context) (internal.Record, error) {
price: price,
timestamp: ts,
fees: convertionFee,
taxes: new(big.Float).Add(stampDutyTax, frenchTxTax),
taxes: stampDutyTax.Add(frenchTxTax),
}, nil
}
}
// parseFloat attempts to parse a string using a standard precision and rounding mode.
// Using this function helps avoid issues around converting values due to sligh parameter changes.
func parseDecimal(s string) (*big.Float, error) {
f, _, err := big.ParseFloat(s, 10, 128, big.ToZero)
return f, err
func parseDecimal(s string) (decimal.Decimal, error) {
return decimal.NewFromString(s)
}
// parseOptinalDecimal behaves the same as parseDecimal but returns 0 when len(s) is 0 instead of
// error.
// Using this function helps avoid issues around converting values due to sligh parameter changes.
func parseOptinalDecimal(s string) (*big.Float, error) {
func parseOptinalDecimal(s string) (decimal.Decimal, error) {
if len(s) == 0 {
return new(big.Float), nil
return decimal.Decimal{}, nil
}
return parseDecimal(s)

View File

@@ -3,11 +3,11 @@ package trading212
import (
"bytes"
"io"
"math/big"
"testing"
"time"
"github.com/nmoniz/any2anexoj/internal"
"github.com/shopspring/decimal"
)
func TestRecordReader_ReadRecord(t *testing.T) {
@@ -136,7 +136,7 @@ func TestRecordReader_ReadRecord(t *testing.T) {
}
}
func ShouldParseDecimal(t testing.TB, sf string) *big.Float {
func ShouldParseDecimal(t testing.TB, sf string) decimal.Decimal {
t.Helper()
bf, err := parseDecimal(sf)