From c323047175d1bee8f7d1b11a91a629fa78a8de69 Mon Sep 17 00:00:00 2001 From: Natercio Moniz Date: Mon, 24 Nov 2025 16:03:28 +0000 Subject: [PATCH] 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) +}