From b4b12ad625d449b442cfb28c8c06d771904b90c8 Mon Sep 17 00:00:00 2001 From: Natercio Moniz Date: Thu, 20 Nov 2025 19:26:08 +0000 Subject: [PATCH 01/11] add new Nature method to Record interface --- internal/mocks/mocks_gen.go | 38 ++++++++++++++++++++++ internal/open_figi.go | 60 +++++++++++++++++++++++++++++++++++ internal/report.go | 2 ++ internal/trading212/record.go | 4 +++ 4 files changed, 104 insertions(+) create mode 100644 internal/open_figi.go diff --git a/internal/mocks/mocks_gen.go b/internal/mocks/mocks_gen.go index 0c7df4a..d1a7391 100644 --- a/internal/mocks/mocks_gen.go +++ b/internal/mocks/mocks_gen.go @@ -220,6 +220,44 @@ func (c *MockRecordFeesCall) DoAndReturn(f func() decimal.Decimal) *MockRecordFe return c } +// Nature mocks base method. +func (m *MockRecord) Nature() string { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Nature") + ret0, _ := ret[0].(string) + return ret0 +} + +// Nature indicates an expected call of Nature. +func (mr *MockRecordMockRecorder) Nature() *MockRecordNatureCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Nature", reflect.TypeOf((*MockRecord)(nil).Nature)) + return &MockRecordNatureCall{Call: call} +} + +// MockRecordNatureCall wrap *gomock.Call +type MockRecordNatureCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockRecordNatureCall) Return(arg0 string) *MockRecordNatureCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockRecordNatureCall) Do(f func() string) *MockRecordNatureCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockRecordNatureCall) DoAndReturn(f func() string) *MockRecordNatureCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + // Price mocks base method. func (m *MockRecord) Price() decimal.Decimal { m.ctrl.T.Helper() diff --git a/internal/open_figi.go b/internal/open_figi.go new file mode 100644 index 0000000..ee4770e --- /dev/null +++ b/internal/open_figi.go @@ -0,0 +1,60 @@ +package internal + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "time" +) + +type OpenFIGI struct { + client http.Client + + cache map[string]string +} + +func NewOpenFIGI() *OpenFIGI { + return &OpenFIGI{ + client: http.Client{ + Timeout: 5 * time.Second, + }, + } +} + +func (of *OpenFIGI) Category(ctx context.Context, isin, ticker string) (string, error) { + rawBody, err := json.Marshal(mappingRequestBody{ + IdType: "ID_ISIN", + IdValue: isin, + }) + if err != nil { + return "", fmt.Errorf("marshal mapping request body: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://api.openfigi.com/v3/mapping", bytes.NewBuffer(rawBody)) + if err != nil { + return "", fmt.Errorf("create mapping request: %w", err) + } + + res, err := of.client.Do(req) + if err != nil { + return "", fmt.Errorf("make mapping request: %w", err) + } + defer res.Body.Close() + + var resBody mappingResponseBody + err = json.NewDecoder(res.Body).Decode(&resBody) + if err != nil { + return "", fmt.Errorf("unmarshal response: %w", err) + } + + return "", nil +} + +type mappingRequestBody struct { + IdType string `json:"idType"` + IdValue string `json:"idValue"` +} + +type mappingResponseBody struct{} diff --git a/internal/report.go b/internal/report.go index 226ac0d..fb931d0 100644 --- a/internal/report.go +++ b/internal/report.go @@ -12,6 +12,7 @@ import ( type Record interface { Symbol() string + Nature() string BrokerCountry() int64 AssetCountry() int64 Side() Side @@ -29,6 +30,7 @@ type RecordReader interface { type ReportItem struct { Symbol string + Nature string BrokerCountry int64 AssetCountry int64 BuyValue decimal.Decimal diff --git a/internal/trading212/record.go b/internal/trading212/record.go index a291c82..34ae163 100644 --- a/internal/trading212/record.go +++ b/internal/trading212/record.go @@ -27,6 +27,10 @@ func (r Record) Symbol() string { return r.symbol } +func (r Record) Nature() string { + return "" // TODO: implement this +} + func (r Record) BrokerCountry() int64 { return int64(Country) } -- 2.49.1 From ef0a4476a7ebebc3cd6a0eec4aa91b46323eb2b3 Mon Sep 17 00:00:00 2001 From: Natercio Moniz Date: Mon, 24 Nov 2025 12:17:32 +0000 Subject: [PATCH 02/11] add Nature type --- internal/nature.go | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 internal/nature.go diff --git a/internal/nature.go b/internal/nature.go new file mode 100644 index 0000000..2b757c4 --- /dev/null +++ b/internal/nature.go @@ -0,0 +1,15 @@ +package internal + +type Nature string + +const ( + // NatureUnknown is the zero value of Nature type + NatureUnknown Nature = "" + + // NatureG01 describes selling of stocks per table VII: Alienação onerosa de ações/partes sociais + NatureG01 Nature = "G01" + + // NatureG20 describes selling units in investment funds (including ETFs) as per table VII: + // Resgates ou alienação de unidades de participação ou liquidação de fundos de investimento + NatureG20 Nature = "G20" +) -- 2.49.1 From 23614d51dbad73ac5f1efe4bbcaacd156fc4a29f Mon Sep 17 00:00:00 2001 From: Natercio Moniz Date: Mon, 24 Nov 2025 12:17:58 +0000 Subject: [PATCH 03/11] add OpenFIGI adaptar with tests --- internal/open_figi.go | 53 ++++++++++++------- internal/open_figi_test.go | 101 +++++++++++++++++++++++++++++++++++++ 2 files changed, 136 insertions(+), 18 deletions(-) create mode 100644 internal/open_figi_test.go diff --git a/internal/open_figi.go b/internal/open_figi.go index ee4770e..ca33709 100644 --- a/internal/open_figi.go +++ b/internal/open_figi.go @@ -6,28 +6,23 @@ import ( "encoding/json" "fmt" "net/http" - "time" ) type OpenFIGI struct { - client http.Client - - cache map[string]string + client *http.Client } -func NewOpenFIGI() *OpenFIGI { +func NewOpenFIGI(c *http.Client) *OpenFIGI { return &OpenFIGI{ - client: http.Client{ - Timeout: 5 * time.Second, - }, + client: c, } } -func (of *OpenFIGI) Category(ctx context.Context, isin, ticker string) (string, error) { - rawBody, err := json.Marshal(mappingRequestBody{ - IdType: "ID_ISIN", - IdValue: isin, - }) +func (of *OpenFIGI) SecurityTypeByISIN(ctx context.Context, isin string) (string, error) { + rawBody, err := json.Marshal([]mappingRequestBody{{ + IDType: "ID_ISIN", + IDValue: isin, + }}) if err != nil { return "", fmt.Errorf("marshal mapping request body: %w", err) } @@ -37,24 +32,46 @@ func (of *OpenFIGI) Category(ctx context.Context, isin, ticker string) (string, return "", fmt.Errorf("create mapping request: %w", err) } + req.Header.Add("Content-Type", "application/json") + res, err := of.client.Do(req) if err != nil { return "", fmt.Errorf("make mapping request: %w", err) } defer res.Body.Close() - var resBody mappingResponseBody + if res.StatusCode >= 400 { + return "", fmt.Errorf("bad mapping response status code: %s", res.Status) + } + + var resBody []mappingResponseBody err = json.NewDecoder(res.Body).Decode(&resBody) if err != nil { return "", fmt.Errorf("unmarshal response: %w", err) } - return "", nil + if len(resBody) == 0 { + return "", fmt.Errorf("missing top-level elements") + } + + if len(resBody[0].Data) == 0 { + return "", fmt.Errorf("missing data elements") + } + + // It is not possible that an isin is assign to diferent security types, therefore we can assume + // all entries have the same securityType value. + return resBody[0].Data[0].SecurityType, nil } type mappingRequestBody struct { - IdType string `json:"idType"` - IdValue string `json:"idValue"` + IDType string `json:"idType"` + IDValue string `json:"idValue"` } -type mappingResponseBody struct{} +type mappingResponseBody struct { + Data []struct { + FIGI string `json:"figi"` + SecurityType string `json:"securityType"` + Ticker string `json:"ticker"` + } `json:"data"` +} diff --git a/internal/open_figi_test.go b/internal/open_figi_test.go new file mode 100644 index 0000000..73a11c6 --- /dev/null +++ b/internal/open_figi_test.go @@ -0,0 +1,101 @@ +package internal_test + +import ( + "bytes" + "context" + "io" + "net/http" + "testing" + "time" + + "github.com/nmoniz/any2anexoj/internal" +) + +func TestOpenFIGI_SecurityTypeByISIN(t *testing.T) { + tests := []struct { + name string // description of this test case + response *http.Response + isin string + want string + wantErr bool + }{ + { + name: "all good", + response: &http.Response{ + Status: http.StatusText(http.StatusOK), + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewBufferString(`[{"data":[{"figi":"BBG000BJJR23","name":"AIRBUS SE","ticker":"EADSF","exchCode":"US","compositeFIGI":"BBG000BJJR23","securityType":"Common Stock","marketSector":"Equity","shareClassFIGI":"BBG001S8TFZ6","securityType2":"Common Stock","securityDescription":"EADSF"},{"figi":"BBG000BJJXJ2","name":"AIRBUS SE","ticker":"EADSF","exchCode":"PQ","compositeFIGI":"BBG000BJJR23","securityType":"Common Stock","marketSector":"Equity","shareClassFIGI":"BBG001S8TFZ6","securityType2":"Common Stock","securityDescription":"EADSF"}]}]`)), + }, + isin: "NL0000235190", + want: "Common Stock", + }, + { + name: "bas status code", + response: &http.Response{ + Status: http.StatusText(http.StatusTooManyRequests), + StatusCode: http.StatusTooManyRequests, + }, + isin: "NL0000235190", + wantErr: true, + }, + { + name: "empty top-level", + response: &http.Response{ + Status: http.StatusText(http.StatusOK), + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewBufferString(`[]`)), + }, + isin: "NL0000235190", + wantErr: true, + }, + { + name: "empty data elements", + response: &http.Response{ + Status: http.StatusText(http.StatusOK), + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewBufferString(`[{"data":[]}]`)), + }, + isin: "NL0000235190", + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := NewTestClient(t, func(req *http.Request) (*http.Response, error) { + return tt.response, nil + }) + + of := internal.NewOpenFIGI(c) + + got, gotErr := of.SecurityTypeByISIN(context.Background(), tt.isin) + if gotErr != nil { + if !tt.wantErr { + t.Errorf("want success but failed: %v", gotErr) + } + return + } + if tt.wantErr { + t.Fatal("want error but none") + } + + if tt.want != got { + t.Fatalf("want security type to be %s but got %s", tt.want, got) + } + }) + } +} + +type RoundTripFunc func(req *http.Request) (*http.Response, error) + +func (f RoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + +func NewTestClient(t testing.TB, fn RoundTripFunc) *http.Client { + t.Helper() + + return &http.Client{ + Timeout: time.Second, + Transport: fn, + } +} -- 2.49.1 From 6b5552b559936312346ad77323e0bd0e109d7b4e Mon Sep 17 00:00:00 2001 From: Natercio Moniz Date: Mon, 24 Nov 2025 12:18:38 +0000 Subject: [PATCH 04/11] add Nature method to Record type --- internal/report.go | 2 +- internal/trading212/record.go | 19 +++++++++++-------- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/internal/report.go b/internal/report.go index fb931d0..0cb392b 100644 --- a/internal/report.go +++ b/internal/report.go @@ -12,7 +12,7 @@ import ( type Record interface { Symbol() string - Nature() string + Nature() Nature BrokerCountry() int64 AssetCountry() int64 Side() Side diff --git a/internal/trading212/record.go b/internal/trading212/record.go index 34ae163..337ba4f 100644 --- a/internal/trading212/record.go +++ b/internal/trading212/record.go @@ -15,20 +15,23 @@ import ( type Record struct { symbol string + timestamp time.Time side internal.Side quantity decimal.Decimal price decimal.Decimal - timestamp time.Time fees decimal.Decimal taxes decimal.Decimal + + // natureGetter allows us to defer the operation of figuring out the nature to only when/if needed. + natureGetter func() internal.Nature } func (r Record) Symbol() string { return r.symbol } -func (r Record) Nature() string { - return "" // TODO: implement this +func (r Record) Timestamp() time.Time { + return r.timestamp } func (r Record) BrokerCountry() int64 { @@ -51,10 +54,6 @@ func (r Record) Price() decimal.Decimal { return r.price } -func (r Record) Timestamp() time.Time { - return r.timestamp -} - func (r Record) Fees() decimal.Decimal { return r.fees } @@ -63,6 +62,10 @@ func (r Record) Taxes() decimal.Decimal { return r.taxes } +func (r Record) Nature() internal.Nature { + return r.natureGetter() +} + type RecordReader struct { reader *csv.Reader } @@ -134,9 +137,9 @@ func (rr RecordReader) ReadRecord(_ context.Context) (internal.Record, error) { side: side, quantity: qant, price: price, - timestamp: ts, fees: conversionFee, taxes: stampDutyTax.Add(frenchTxTax), + timestamp: ts, }, nil } } -- 2.49.1 From a1ea13ff2f4854993ab0f9d21b9994aaae1616b3 Mon Sep 17 00:00:00 2001 From: Natercio Moniz Date: Mon, 24 Nov 2025 15:53:38 +0000 Subject: [PATCH 05/11] add string method to the Nature type --- internal/nature.go | 7 +++++++ internal/nature_test.go | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+) create mode 100644 internal/nature_test.go diff --git a/internal/nature.go b/internal/nature.go index 2b757c4..049c066 100644 --- a/internal/nature.go +++ b/internal/nature.go @@ -13,3 +13,10 @@ const ( // Resgates ou alienação de unidades de participação ou liquidação de fundos de investimento NatureG20 Nature = "G20" ) + +func (n Nature) String() string { + if n == "" { + return "unknown" + } + return string(n) +} diff --git a/internal/nature_test.go b/internal/nature_test.go new file mode 100644 index 0000000..dded2fb --- /dev/null +++ b/internal/nature_test.go @@ -0,0 +1,38 @@ +package internal_test + +import ( + "testing" + + "github.com/nmoniz/any2anexoj/internal" +) + +func TestNature_StringUnknow(t *testing.T) { + tests := []struct { + name string + nature internal.Nature + want string + }{ + { + name: "return unknown", + want: "unknown", + }, + { + name: "return unknown", + nature: internal.NatureG01, + want: "G01", + }, + { + name: "return unknown", + nature: internal.NatureG20, + want: "G20", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.nature.String() + if tt.want != got { + t.Fatalf("want %q but got %q", tt.want, got) + } + }) + } +} -- 2.49.1 From 8c784f3b747dba5f0b440c6a03d1dd468648e07c Mon Sep 17 00:00:00 2001 From: Natercio Moniz Date: Mon, 24 Nov 2025 16:01:15 +0000 Subject: [PATCH 06/11] check isin format and enforce rate limit --- go.mod | 9 ++-- go.sum | 5 ++- internal/open_figi.go | 20 ++++++++- internal/open_figi_test.go | 91 ++++++++++++++++++++++++++------------ 4 files changed, 88 insertions(+), 37 deletions(-) diff --git a/go.mod b/go.mod index d775733..9a68879 100644 --- a/go.mod +++ b/go.mod @@ -3,17 +3,18 @@ module github.com/nmoniz/any2anexoj go 1.25.3 require ( + github.com/biter777/countries v1.7.5 + github.com/jedib0t/go-pretty/v6 v6.7.2 + github.com/shopspring/decimal v1.4.0 + github.com/spf13/pflag v1.0.10 go.uber.org/mock v0.6.0 golang.org/x/sync v0.18.0 - github.com/biter777/countries v1.7.5 + golang.org/x/time v0.14.0 ) require ( - github.com/jedib0t/go-pretty/v6 v6.7.2 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect github.com/rivo/uniseg v0.4.7 // indirect - 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/sys v0.35.0 // indirect golang.org/x/text v0.22.0 // indirect diff --git a/go.sum b/go.sum index 84d147b..199b11d 100644 --- a/go.sum +++ b/go.sum @@ -17,9 +17,8 @@ github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp 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= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= @@ -30,6 +29,8 @@ golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/internal/open_figi.go b/internal/open_figi.go index ca33709..958e4e1 100644 --- a/internal/open_figi.go +++ b/internal/open_figi.go @@ -6,19 +6,30 @@ import ( "encoding/json" "fmt" "net/http" + "time" + + "github.com/biter777/countries" + "golang.org/x/time/rate" ) +// OpenFIGI is a small adapter for the openfigi.com api type OpenFIGI struct { - client *http.Client + client *http.Client + mappingLimiter *rate.Limiter } func NewOpenFIGI(c *http.Client) *OpenFIGI { return &OpenFIGI{ - client: c, + client: c, + mappingLimiter: rate.NewLimiter(rate.Every(time.Minute), 25), // https://www.openfigi.com/api/documentation#rate-limits } } func (of *OpenFIGI) SecurityTypeByISIN(ctx context.Context, isin string) (string, error) { + if len(isin) != 12 || countries.ByName(isin[:2]) == countries.Unknown { + return "", fmt.Errorf("invalid ISIN: %s", isin) + } + rawBody, err := json.Marshal([]mappingRequestBody{{ IDType: "ID_ISIN", IDValue: isin, @@ -34,6 +45,11 @@ func (of *OpenFIGI) SecurityTypeByISIN(ctx context.Context, isin string) (string req.Header.Add("Content-Type", "application/json") + err = of.mappingLimiter.Wait(ctx) + if err != nil { + return "", fmt.Errorf("wait for mapping request capacity: %w", err) + } + res, err := of.client.Do(req) if err != nil { return "", fmt.Errorf("make mapping request: %w", err) diff --git a/internal/open_figi_test.go b/internal/open_figi_test.go index 73a11c6..313a6d4 100644 --- a/internal/open_figi_test.go +++ b/internal/open_figi_test.go @@ -3,6 +3,7 @@ package internal_test import ( "bytes" "context" + "fmt" "io" "net/http" "testing" @@ -13,59 +14,91 @@ import ( func TestOpenFIGI_SecurityTypeByISIN(t *testing.T) { tests := []struct { - name string // description of this test case - response *http.Response - isin string - want string - wantErr bool + name string // description of this test case + client *http.Client + isin string + want string + wantErr bool }{ { name: "all good", - response: &http.Response{ - Status: http.StatusText(http.StatusOK), - StatusCode: http.StatusOK, - Body: io.NopCloser(bytes.NewBufferString(`[{"data":[{"figi":"BBG000BJJR23","name":"AIRBUS SE","ticker":"EADSF","exchCode":"US","compositeFIGI":"BBG000BJJR23","securityType":"Common Stock","marketSector":"Equity","shareClassFIGI":"BBG001S8TFZ6","securityType2":"Common Stock","securityDescription":"EADSF"},{"figi":"BBG000BJJXJ2","name":"AIRBUS SE","ticker":"EADSF","exchCode":"PQ","compositeFIGI":"BBG000BJJR23","securityType":"Common Stock","marketSector":"Equity","shareClassFIGI":"BBG001S8TFZ6","securityType2":"Common Stock","securityDescription":"EADSF"}]}]`)), - }, + client: NewTestClient(t, func(req *http.Request) (*http.Response, error) { + return &http.Response{ + Status: http.StatusText(http.StatusOK), + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewBufferString(`[{"data":[{"figi":"BBG000BJJR23","name":"AIRBUS SE","ticker":"EADSF","exchCode":"US","compositeFIGI":"BBG000BJJR23","securityType":"Common Stock","marketSector":"Equity","shareClassFIGI":"BBG001S8TFZ6","securityType2":"Common Stock","securityDescription":"EADSF"},{"figi":"BBG000BJJXJ2","name":"AIRBUS SE","ticker":"EADSF","exchCode":"PQ","compositeFIGI":"BBG000BJJR23","securityType":"Common Stock","marketSector":"Equity","shareClassFIGI":"BBG001S8TFZ6","securityType2":"Common Stock","securityDescription":"EADSF"}]}]`)), + }, nil + }), isin: "NL0000235190", want: "Common Stock", }, { name: "bas status code", - response: &http.Response{ - Status: http.StatusText(http.StatusTooManyRequests), - StatusCode: http.StatusTooManyRequests, - }, + client: NewTestClient(t, func(req *http.Request) (*http.Response, error) { + return &http.Response{ + Status: http.StatusText(http.StatusTooManyRequests), + StatusCode: http.StatusTooManyRequests, + }, nil + }), + isin: "NL0000235190", + wantErr: true, + }, + { + name: "bad json", + client: NewTestClient(t, func(req *http.Request) (*http.Response, error) { + return &http.Response{ + Status: http.StatusText(http.StatusOK), + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewBufferString(`{"bad": "json"}`)), + }, nil + }), isin: "NL0000235190", wantErr: true, }, { name: "empty top-level", - response: &http.Response{ - Status: http.StatusText(http.StatusOK), - StatusCode: http.StatusOK, - Body: io.NopCloser(bytes.NewBufferString(`[]`)), - }, + client: NewTestClient(t, func(req *http.Request) (*http.Response, error) { + return &http.Response{ + Status: http.StatusText(http.StatusOK), + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewBufferString(`[]`)), + }, nil + }), isin: "NL0000235190", wantErr: true, }, { name: "empty data elements", - response: &http.Response{ - Status: http.StatusText(http.StatusOK), - StatusCode: http.StatusOK, - Body: io.NopCloser(bytes.NewBufferString(`[{"data":[]}]`)), - }, + client: NewTestClient(t, func(req *http.Request) (*http.Response, error) { + return &http.Response{ + Status: http.StatusText(http.StatusOK), + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewBufferString(`[{"data":[]}]`)), + }, nil + }), isin: "NL0000235190", wantErr: true, }, + { + name: "client error", + client: NewTestClient(t, func(req *http.Request) (*http.Response, error) { + return nil, fmt.Errorf("boom") + }), + isin: "NL0000235190", + wantErr: true, + }, + { + name: "empty isin", + client: NewTestClient(t, func(req *http.Request) (*http.Response, error) { + t.Fatalf("should not make api request") + return nil, nil + }), + wantErr: true, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - c := NewTestClient(t, func(req *http.Request) (*http.Response, error) { - return tt.response, nil - }) - - of := internal.NewOpenFIGI(c) + of := internal.NewOpenFIGI(tt.client) got, gotErr := of.SecurityTypeByISIN(context.Background(), tt.isin) if gotErr != nil { -- 2.49.1 From c323047175d1bee8f7d1b11a91a629fa78a8de69 Mon Sep 17 00:00:00 2001 From: Natercio Moniz Date: Mon, 24 Nov 2025 16:03:28 +0000 Subject: [PATCH 07/11] report show nature --- cmd/any2anexoj-cli/main.go | 6 +- internal/mocks/mocks_gen.go | 10 +-- internal/report.go | 3 +- internal/table_writer.go | 2 +- internal/trading212/record.go | 43 +++++++--- internal/trading212/record_test.go | 123 +++++++++++++++++++++++++---- 6 files changed, 155 insertions(+), 32 deletions(-) diff --git a/cmd/any2anexoj-cli/main.go b/cmd/any2anexoj-cli/main.go index 9fba3cf..2b02d95 100644 --- a/cmd/any2anexoj-cli/main.go +++ b/cmd/any2anexoj-cli/main.go @@ -4,8 +4,10 @@ import ( "context" "fmt" "log/slog" + "net/http" "os" "os/signal" + "time" "github.com/nmoniz/any2anexoj/internal" "github.com/nmoniz/any2anexoj/internal/trading212" @@ -18,7 +20,9 @@ import ( var platform = pflag.StringP("platform", "p", "trading212", "one of the supported platforms") var readerFactories = map[string]func() internal.RecordReader{ - "trading212": func() internal.RecordReader { return trading212.NewRecordReader(os.Stdin) }, + "trading212": func() internal.RecordReader { + return trading212.NewRecordReader(os.Stdin, internal.NewOpenFIGI(&http.Client{Timeout: 5 * time.Second})) + }, } func main() { diff --git a/internal/mocks/mocks_gen.go b/internal/mocks/mocks_gen.go index d1a7391..fa38472 100644 --- a/internal/mocks/mocks_gen.go +++ b/internal/mocks/mocks_gen.go @@ -221,10 +221,10 @@ func (c *MockRecordFeesCall) DoAndReturn(f func() decimal.Decimal) *MockRecordFe } // Nature mocks base method. -func (m *MockRecord) Nature() string { +func (m *MockRecord) Nature() internal.Nature { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Nature") - ret0, _ := ret[0].(string) + ret0, _ := ret[0].(internal.Nature) return ret0 } @@ -241,19 +241,19 @@ type MockRecordNatureCall struct { } // Return rewrite *gomock.Call.Return -func (c *MockRecordNatureCall) Return(arg0 string) *MockRecordNatureCall { +func (c *MockRecordNatureCall) Return(arg0 internal.Nature) *MockRecordNatureCall { c.Call = c.Call.Return(arg0) return c } // Do rewrite *gomock.Call.Do -func (c *MockRecordNatureCall) Do(f func() string) *MockRecordNatureCall { +func (c *MockRecordNatureCall) Do(f func() internal.Nature) *MockRecordNatureCall { c.Call = c.Call.Do(f) return c } // DoAndReturn rewrite *gomock.Call.DoAndReturn -func (c *MockRecordNatureCall) DoAndReturn(f func() string) *MockRecordNatureCall { +func (c *MockRecordNatureCall) DoAndReturn(f func() internal.Nature) *MockRecordNatureCall { c.Call = c.Call.DoAndReturn(f) return c } diff --git a/internal/report.go b/internal/report.go index 0cb392b..4007167 100644 --- a/internal/report.go +++ b/internal/report.go @@ -30,7 +30,7 @@ type RecordReader interface { type ReportItem struct { Symbol string - Nature string + Nature Nature BrokerCountry int64 AssetCountry int64 BuyValue decimal.Decimal @@ -118,6 +118,7 @@ func processRecord(ctx context.Context, q *FillerQueue, rec Record, writer Repor SellTimestamp: rec.Timestamp(), Fees: buy.Fees().Add(rec.Fees()), Taxes: buy.Taxes().Add(rec.Fees()), + Nature: buy.Nature(), }) if err != nil { return fmt.Errorf("write report item: %w", err) diff --git a/internal/table_writer.go b/internal/table_writer.go index 0dd822d..95c181a 100644 --- a/internal/table_writer.go +++ b/internal/table_writer.go @@ -42,7 +42,7 @@ func (tw *TableWriter) Write(_ context.Context, ri ReportItem) error { tw.totalFees = tw.totalFees.Add(ri.Fees) tw.totalTaxes = tw.totalTaxes.Add(ri.Taxes) - tw.table.AppendRow(table.Row{ri.AssetCountry, "G!!", ri.SellTimestamp.Year(), int(ri.SellTimestamp.Month()), ri.SellTimestamp.Day(), ri.SellValue, ri.BuyTimestamp.Year(), ri.BuyTimestamp.Month(), ri.BuyTimestamp.Day(), ri.BuyValue, ri.Fees, ri.Taxes, ri.BrokerCountry}) + tw.table.AppendRow(table.Row{ri.AssetCountry, ri.Nature, ri.SellTimestamp.Year(), int(ri.SellTimestamp.Month()), ri.SellTimestamp.Day(), ri.SellValue, ri.BuyTimestamp.Year(), ri.BuyTimestamp.Month(), ri.BuyTimestamp.Day(), ri.BuyValue, ri.Fees, ri.Taxes, ri.BrokerCountry}) return nil } diff --git a/internal/trading212/record.go b/internal/trading212/record.go index 337ba4f..f5debd2 100644 --- a/internal/trading212/record.go +++ b/internal/trading212/record.go @@ -5,7 +5,9 @@ import ( "encoding/csv" "fmt" "io" + "log/slog" "strings" + "sync" "time" "github.com/biter777/countries" @@ -68,11 +70,13 @@ func (r Record) Nature() internal.Nature { type RecordReader struct { reader *csv.Reader + figi *internal.OpenFIGI } -func NewRecordReader(r io.Reader) *RecordReader { +func NewRecordReader(r io.Reader, f *internal.OpenFIGI) *RecordReader { return &RecordReader{ reader: csv.NewReader(r), + figi: f, } } @@ -83,7 +87,7 @@ const ( LimitSell = "limit sell" ) -func (rr RecordReader) ReadRecord(_ context.Context) (internal.Record, error) { +func (rr RecordReader) ReadRecord(ctx context.Context) (internal.Record, error) { for { raw, err := rr.reader.Read() if err != nil { @@ -133,17 +137,38 @@ func (rr RecordReader) ReadRecord(_ context.Context) (internal.Record, error) { } return Record{ - symbol: raw[2], - side: side, - quantity: qant, - price: price, - fees: conversionFee, - taxes: stampDutyTax.Add(frenchTxTax), - timestamp: ts, + symbol: raw[2], + side: side, + quantity: qant, + price: price, + fees: conversionFee, + taxes: stampDutyTax.Add(frenchTxTax), + timestamp: ts, + natureGetter: figiNatureGetter(ctx, rr.figi, raw[2]), }, nil } } +func figiNatureGetter(ctx context.Context, of *internal.OpenFIGI, isin string) func() internal.Nature { + return sync.OnceValue(func() internal.Nature { + secType, err := of.SecurityTypeByISIN(ctx, isin) + if err != nil { + slog.Error("failed to get security type by ISIN", slog.Any("err", err), slog.String("isin", isin)) + return internal.NatureUnknown + } + + switch secType { + case "Common Stock": + return internal.NatureG01 + case "ETP": + return internal.NatureG20 + default: + slog.Error("got unsupported security type for ISIN", slog.String("isin", isin), slog.String("securityType", secType)) + return internal.NatureUnknown + } + }) +} + // 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) (decimal.Decimal, error) { diff --git a/internal/trading212/record_test.go b/internal/trading212/record_test.go index 6dae9ae..2306cb4 100644 --- a/internal/trading212/record_test.go +++ b/internal/trading212/record_test.go @@ -2,7 +2,9 @@ package trading212 import ( "bytes" + "fmt" "io" + "net/http" "testing" "time" @@ -27,26 +29,28 @@ func TestRecordReader_ReadRecord(t *testing.T) { name: "well-formed buy", r: bytes.NewBufferString(`Market buy,2025-07-03 10:44:29,SYM123456ABXY,ABXY,"Aspargus Broccoli",EOF987654321,2.4387014200,7.3690000000,USD,1.17995999,,"EUR",15.25,"EUR",0.25,"EUR",0.02,"EUR",,`), want: Record{ - symbol: "SYM123456ABXY", - side: internal.SideBuy, - quantity: ShouldParseDecimal(t, "2.4387014200"), - price: ShouldParseDecimal(t, "7.3690000000"), - timestamp: time.Date(2025, 7, 3, 10, 44, 29, 0, time.UTC), - fees: ShouldParseDecimal(t, "0.02"), - taxes: ShouldParseDecimal(t, "0.25"), + symbol: "SYM123456ABXY", + side: internal.SideBuy, + quantity: ShouldParseDecimal(t, "2.4387014200"), + price: ShouldParseDecimal(t, "7.3690000000"), + timestamp: time.Date(2025, 7, 3, 10, 44, 29, 0, time.UTC), + fees: ShouldParseDecimal(t, "0.02"), + taxes: ShouldParseDecimal(t, "0.25"), + natureGetter: func() internal.Nature { return internal.NatureG01 }, }, }, { name: "well-formed sell", r: bytes.NewBufferString(`Market sell,2025-08-04 11:45:30,IE000GA3D489,ABXY,"Aspargus Broccoli",EOF987654321,2.4387014200,7.9999999999,USD,1.17995999,,"EUR",15.25,"EUR",,,0.02,"EUR",0.1,"EUR"`), want: Record{ - symbol: "IE000GA3D489", - side: internal.SideSell, - quantity: ShouldParseDecimal(t, "2.4387014200"), - price: ShouldParseDecimal(t, "7.9999999999"), - timestamp: time.Date(2025, 8, 4, 11, 45, 30, 0, time.UTC), - fees: ShouldParseDecimal(t, "0.02"), - taxes: ShouldParseDecimal(t, "0.1"), + symbol: "IE000GA3D489", + side: internal.SideSell, + quantity: ShouldParseDecimal(t, "2.4387014200"), + price: ShouldParseDecimal(t, "7.9999999999"), + timestamp: time.Date(2025, 8, 4, 11, 45, 30, 0, time.UTC), + fees: ShouldParseDecimal(t, "0.02"), + taxes: ShouldParseDecimal(t, "0.1"), + natureGetter: func() internal.Nature { return internal.NatureG01 }, }, }, { @@ -79,6 +83,16 @@ func TestRecordReader_ReadRecord(t *testing.T) { r: bytes.NewBufferString(`Market sell,2025-08-04 11:45:39,IE000GA3D489,ABXY,"Aspargus Broccoli",EOF987654321,2.4387014200,,USD,1.17995999,,"EUR",15.25,"EUR",,,0.02,"EUR",,`), wantErr: true, }, + { + name: "malformed fees", + r: bytes.NewBufferString(`Market sell,2025-08-04 11:45:30,IE000GA3D489,ABXY,"Aspargus Broccoli",EOF987654321,2.4387014200,7.9999999999,USD,1.17995999,,"EUR",15.25,"EUR",,,BAD,"EUR",0.1,"EUR"`), + wantErr: true, + }, + { + name: "malformed taxes", + r: bytes.NewBufferString(`Market sell,2025-08-04 11:45:30,IE000GA3D489,ABXY,"Aspargus Broccoli",EOF987654321,2.4387014200,7.9999999999,USD,1.17995999,,"EUR",15.25,"EUR",,,0.02,"EUR",BAD,"EUR"`), + wantErr: true, + }, { name: "malformed timestamp", r: bytes.NewBufferString(`Market sell,2006-01-02T15:04:05Z07:00,IE000GA3D489,ABXY,"Aspargus Broccoli",EOF987654321,2.4387014200,7.9999999999,USD,1.17995999,,"EUR",15.25,"EUR",,,0.02,"EUR",,`), @@ -92,7 +106,7 @@ func TestRecordReader_ReadRecord(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - rr := NewRecordReader(tt.r) + rr := NewRecordReader(tt.r, NewFigiClientSecurityTypeStub(t, "Common Stock")) got, gotErr := rr.ReadRecord(t.Context()) if gotErr != nil { if !tt.wantErr { @@ -132,6 +146,48 @@ func TestRecordReader_ReadRecord(t *testing.T) { if got.Taxes().Cmp(tt.want.taxes) != 0 { t.Fatalf("want taxes %v but got %v", tt.want.taxes, got.Taxes()) } + + if tt.want.natureGetter != nil && tt.want.Nature() != got.Nature() { + t.Fatalf("want nature %v but got %v", tt.want.Nature(), got.Nature()) + } + }) + } +} + +func Test_figiNatureGetter(t *testing.T) { + tests := []struct { + name string // description of this test case + of *internal.OpenFIGI + want internal.Nature + }{ + { + name: "Common Stock translates to G01", + of: NewFigiClientSecurityTypeStub(t, "Common Stock"), + want: internal.NatureG01, + }, + { + name: "ETP translates to G20", + of: NewFigiClientSecurityTypeStub(t, "ETP"), + want: internal.NatureG20, + }, + { + name: "Other translates to Unknown", + of: NewFigiClientSecurityTypeStub(t, "Other"), + want: internal.NatureUnknown, + }, + { + name: "Request fails", + of: NewFigiClientErrorStub(t, fmt.Errorf("boom")), + want: internal.NatureUnknown, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + getter := figiNatureGetter(t.Context(), tt.of, "IR123456789") + got := getter() + if tt.want != got { + t.Errorf("want %v but got %v", tt.want, got) + } }) } } @@ -145,3 +201,40 @@ func ShouldParseDecimal(t testing.TB, sf string) decimal.Decimal { } return bf } + +type RoundTripFunc func(req *http.Request) (*http.Response, error) + +func (f RoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + +func NewFigiClientSecurityTypeStub(t testing.TB, securityType string) *internal.OpenFIGI { + t.Helper() + + c := &http.Client{ + Timeout: time.Second, + Transport: RoundTripFunc(func(req *http.Request) (*http.Response, error) { + return &http.Response{ + Status: http.StatusText(http.StatusOK), + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`[{"data":[{"securityType":%q}]}]`, securityType))), + Request: req, + }, nil + }), + } + + return internal.NewOpenFIGI(c) +} + +func NewFigiClientErrorStub(t testing.TB, err error) *internal.OpenFIGI { + t.Helper() + + c := &http.Client{ + Timeout: time.Second, + Transport: RoundTripFunc(func(req *http.Request) (*http.Response, error) { + return nil, err + }), + } + + return internal.NewOpenFIGI(c) +} -- 2.49.1 From 93f1dab3d2db2d55b21e892d7de8604522d7d360 Mon Sep 17 00:00:00 2001 From: Natercio Moniz Date: Mon, 24 Nov 2025 16:15:21 +0000 Subject: [PATCH 08/11] fix isin in tests --- internal/trading212/record_test.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/internal/trading212/record_test.go b/internal/trading212/record_test.go index 2306cb4..9938eec 100644 --- a/internal/trading212/record_test.go +++ b/internal/trading212/record_test.go @@ -27,9 +27,9 @@ func TestRecordReader_ReadRecord(t *testing.T) { }, { name: "well-formed buy", - r: bytes.NewBufferString(`Market buy,2025-07-03 10:44:29,SYM123456ABXY,ABXY,"Aspargus Broccoli",EOF987654321,2.4387014200,7.3690000000,USD,1.17995999,,"EUR",15.25,"EUR",0.25,"EUR",0.02,"EUR",,`), + r: bytes.NewBufferString(`Market buy,2025-07-03 10:44:29,XX1234567890,ABXY,"Aspargus Broccoli",EOF987654321,2.4387014200,7.3690000000,USD,1.17995999,,"EUR",15.25,"EUR",0.25,"EUR",0.02,"EUR",,`), want: Record{ - symbol: "SYM123456ABXY", + symbol: "XX1234567890", side: internal.SideBuy, quantity: ShouldParseDecimal(t, "2.4387014200"), price: ShouldParseDecimal(t, "7.3690000000"), @@ -41,9 +41,9 @@ func TestRecordReader_ReadRecord(t *testing.T) { }, { name: "well-formed sell", - r: bytes.NewBufferString(`Market sell,2025-08-04 11:45:30,IE000GA3D489,ABXY,"Aspargus Broccoli",EOF987654321,2.4387014200,7.9999999999,USD,1.17995999,,"EUR",15.25,"EUR",,,0.02,"EUR",0.1,"EUR"`), + r: bytes.NewBufferString(`Market sell,2025-08-04 11:45:30,XX1234567890,ABXY,"Aspargus Broccoli",EOF987654321,2.4387014200,7.9999999999,USD,1.17995999,,"EUR",15.25,"EUR",,,0.02,"EUR",0.1,"EUR"`), want: Record{ - symbol: "IE000GA3D489", + symbol: "XX1234567890", side: internal.SideSell, quantity: ShouldParseDecimal(t, "2.4387014200"), price: ShouldParseDecimal(t, "7.9999999999"), @@ -183,7 +183,7 @@ func Test_figiNatureGetter(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - getter := figiNatureGetter(t.Context(), tt.of, "IR123456789") + getter := figiNatureGetter(t.Context(), tt.of, "IR1234567890") got := getter() if tt.want != got { t.Errorf("want %v but got %v", tt.want, got) -- 2.49.1 From bd101ce46a06ea116b74840ef9452bed04e5896d Mon Sep 17 00:00:00 2001 From: Natercio Moniz Date: Mon, 24 Nov 2025 16:18:02 +0000 Subject: [PATCH 09/11] fix critical tax calculation --- internal/nature_test.go | 2 +- internal/report.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/nature_test.go b/internal/nature_test.go index dded2fb..26c764c 100644 --- a/internal/nature_test.go +++ b/internal/nature_test.go @@ -6,7 +6,7 @@ import ( "github.com/nmoniz/any2anexoj/internal" ) -func TestNature_StringUnknow(t *testing.T) { +func TestNature_String(t *testing.T) { tests := []struct { name string nature internal.Nature diff --git a/internal/report.go b/internal/report.go index 4007167..1aa9ca1 100644 --- a/internal/report.go +++ b/internal/report.go @@ -117,7 +117,7 @@ func processRecord(ctx context.Context, q *FillerQueue, rec Record, writer Repor SellValue: sellValue, SellTimestamp: rec.Timestamp(), Fees: buy.Fees().Add(rec.Fees()), - Taxes: buy.Taxes().Add(rec.Fees()), + Taxes: buy.Taxes().Add(rec.Taxes()), Nature: buy.Nature(), }) if err != nil { -- 2.49.1 From 70466b7886ca006c97597c0595e056ad14d9ae46 Mon Sep 17 00:00:00 2001 From: Natercio Moniz Date: Mon, 24 Nov 2025 16:23:57 +0000 Subject: [PATCH 10/11] fix typos and copy paste test names --- internal/nature_test.go | 4 ++-- internal/open_figi_test.go | 2 +- internal/trading212/record.go | 14 +++++++------- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/internal/nature_test.go b/internal/nature_test.go index 26c764c..eb7baec 100644 --- a/internal/nature_test.go +++ b/internal/nature_test.go @@ -17,12 +17,12 @@ func TestNature_String(t *testing.T) { want: "unknown", }, { - name: "return unknown", + name: "return G01", nature: internal.NatureG01, want: "G01", }, { - name: "return unknown", + name: "return G20", nature: internal.NatureG20, want: "G20", }, diff --git a/internal/open_figi_test.go b/internal/open_figi_test.go index 313a6d4..8325f2c 100644 --- a/internal/open_figi_test.go +++ b/internal/open_figi_test.go @@ -33,7 +33,7 @@ func TestOpenFIGI_SecurityTypeByISIN(t *testing.T) { want: "Common Stock", }, { - name: "bas status code", + name: "bad status code", client: NewTestClient(t, func(req *http.Request) (*http.Response, error) { return &http.Response{ Status: http.StatusText(http.StatusTooManyRequests), diff --git a/internal/trading212/record.go b/internal/trading212/record.go index f5debd2..61f2ff8 100644 --- a/internal/trading212/record.go +++ b/internal/trading212/record.go @@ -121,17 +121,17 @@ func (rr RecordReader) ReadRecord(ctx context.Context) (internal.Record, error) return Record{}, fmt.Errorf("parse record timestamp: %w", err) } - conversionFee, err := parseOptinalDecimal(raw[16]) + conversionFee, err := parseOptionalDecimal(raw[16]) if err != nil { return Record{}, fmt.Errorf("parse record conversion fee: %w", err) } - stampDutyTax, err := parseOptinalDecimal(raw[14]) + stampDutyTax, err := parseOptionalDecimal(raw[14]) if err != nil { return Record{}, fmt.Errorf("parse record stamp duty tax: %w", err) } - frenchTxTax, err := parseOptinalDecimal(raw[18]) + frenchTxTax, err := parseOptionalDecimal(raw[18]) if err != nil { return Record{}, fmt.Errorf("parse record french transaction tax: %w", err) } @@ -170,15 +170,15 @@ func figiNatureGetter(ctx context.Context, of *internal.OpenFIGI, isin string) f } // 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. +// Using this function helps avoid issues around converting values due to minor parameter changes. 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 +// parseOptionalDecimal 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) (decimal.Decimal, error) { +// Using this function helps avoid issues around converting values due to minor parameter changes. +func parseOptionalDecimal(s string) (decimal.Decimal, error) { if len(s) == 0 { return decimal.Decimal{}, nil } -- 2.49.1 From 5f13ebaf6a4e665ae2a1989b849b4423e4d94c44 Mon Sep 17 00:00:00 2001 From: Natercio Moniz Date: Mon, 24 Nov 2025 16:27:44 +0000 Subject: [PATCH 11/11] test expects Nature method call --- internal/report_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/report_test.go b/internal/report_test.go index d9edfe4..40c2184 100644 --- a/internal/report_test.go +++ b/internal/report_test.go @@ -60,6 +60,7 @@ func mockRecord(ctrl *gomock.Controller, price, quantity float64, side internal. rec.EXPECT().Timestamp().Return(ts).AnyTimes() rec.EXPECT().Fees().Return(decimal.Decimal{}).AnyTimes() rec.EXPECT().Taxes().Return(decimal.Decimal{}).AnyTimes() + rec.EXPECT().Nature().Return(internal.NatureG01).AnyTimes() return rec } -- 2.49.1