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/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/mocks/mocks_gen.go b/internal/mocks/mocks_gen.go index 0c7df4a..fa38472 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() internal.Nature { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Nature") + ret0, _ := ret[0].(internal.Nature) + 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 internal.Nature) *MockRecordNatureCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +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() internal.Nature) *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/nature.go b/internal/nature.go new file mode 100644 index 0000000..049c066 --- /dev/null +++ b/internal/nature.go @@ -0,0 +1,22 @@ +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" +) + +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..eb7baec --- /dev/null +++ b/internal/nature_test.go @@ -0,0 +1,38 @@ +package internal_test + +import ( + "testing" + + "github.com/nmoniz/any2anexoj/internal" +) + +func TestNature_String(t *testing.T) { + tests := []struct { + name string + nature internal.Nature + want string + }{ + { + name: "return unknown", + want: "unknown", + }, + { + name: "return G01", + nature: internal.NatureG01, + want: "G01", + }, + { + name: "return G20", + 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) + } + }) + } +} diff --git a/internal/open_figi.go b/internal/open_figi.go new file mode 100644 index 0000000..958e4e1 --- /dev/null +++ b/internal/open_figi.go @@ -0,0 +1,93 @@ +package internal + +import ( + "bytes" + "context" + "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 + mappingLimiter *rate.Limiter +} + +func NewOpenFIGI(c *http.Client) *OpenFIGI { + return &OpenFIGI{ + 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, + }}) + 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) + } + + 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) + } + defer res.Body.Close() + + 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) + } + + 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"` +} + +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..8325f2c --- /dev/null +++ b/internal/open_figi_test.go @@ -0,0 +1,134 @@ +package internal_test + +import ( + "bytes" + "context" + "fmt" + "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 + client *http.Client + isin string + want string + wantErr bool + }{ + { + name: "all good", + 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: "bad status code", + 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", + 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", + 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) { + of := internal.NewOpenFIGI(tt.client) + + 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, + } +} diff --git a/internal/report.go b/internal/report.go index 226ac0d..1aa9ca1 100644 --- a/internal/report.go +++ b/internal/report.go @@ -12,6 +12,7 @@ import ( type Record interface { Symbol() string + Nature() Nature BrokerCountry() int64 AssetCountry() int64 Side() Side @@ -29,6 +30,7 @@ type RecordReader interface { type ReportItem struct { Symbol string + Nature Nature BrokerCountry int64 AssetCountry int64 BuyValue decimal.Decimal @@ -115,7 +117,8 @@ 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 { return fmt.Errorf("write report item: %w", err) 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 } 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 a291c82..61f2ff8 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" @@ -15,18 +17,25 @@ 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) Timestamp() time.Time { + return r.timestamp +} + func (r Record) BrokerCountry() int64 { return int64(Country) } @@ -47,10 +56,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 } @@ -59,13 +64,19 @@ func (r Record) Taxes() decimal.Decimal { return r.taxes } -type RecordReader struct { - reader *csv.Reader +func (r Record) Nature() internal.Nature { + return r.natureGetter() } -func NewRecordReader(r io.Reader) *RecordReader { +type RecordReader struct { + reader *csv.Reader + figi *internal.OpenFIGI +} + +func NewRecordReader(r io.Reader, f *internal.OpenFIGI) *RecordReader { return &RecordReader{ reader: csv.NewReader(r), + figi: f, } } @@ -76,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 { @@ -110,43 +121,64 @@ func (rr RecordReader) ReadRecord(_ 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) } return Record{ - symbol: raw[2], - side: side, - quantity: qant, - price: price, - timestamp: ts, - fees: conversionFee, - taxes: stampDutyTax.Add(frenchTxTax), + 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. +// 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 } diff --git a/internal/trading212/record_test.go b/internal/trading212/record_test.go index 6dae9ae..9938eec 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" @@ -25,28 +27,30 @@ 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", - 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: "XX1234567890", + 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"`), + 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", - 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: "XX1234567890", + 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, "IR1234567890") + 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) +}