Report includes the type/nature of row #17

Merged
natercio merged 11 commits from record-type into main 2025-11-24 16:29:44 +00:00
13 changed files with 509 additions and 49 deletions

View File

@@ -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() {

9
go.mod
View File

@@ -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

5
go.sum
View File

@@ -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=

View File

@@ -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()

22
internal/nature.go Normal file
View File

@@ -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)
}

38
internal/nature_test.go Normal file
View File

@@ -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)
}
})
}
}

93
internal/open_figi.go Normal file
View File

@@ -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"`
}

134
internal/open_figi_test.go Normal file
View File

@@ -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,
}
}

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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)
}