From 91885b1993b58f0333ee484e02429f407bf12d25 Mon Sep 17 00:00:00 2001 From: Natercio Moniz Date: Sat, 16 May 2026 09:33:51 +0100 Subject: [PATCH 01/10] support ASD --- internal/trading212/record.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/trading212/record.go b/internal/trading212/record.go index 61f2ff8..11a6ea6 100644 --- a/internal/trading212/record.go +++ b/internal/trading212/record.go @@ -158,7 +158,7 @@ func figiNatureGetter(ctx context.Context, of *internal.OpenFIGI, isin string) f } switch secType { - case "Common Stock": + case "Common Stock", "ASD": return internal.NatureG01 case "ETP": return internal.NatureG20 From b0d91e7eeebcf83e5cdb1ee3b3e978ead798a3fe Mon Sep 17 00:00:00 2001 From: Natercio Moniz Date: Sat, 16 May 2026 10:36:14 +0100 Subject: [PATCH 02/10] support openfigi api key --- cmd/any2anexoj-cli/main.go | 38 +++++++++++---------- internal/open_figi.go | 24 +++++++++++--- internal/open_figi_test.go | 53 ++++++++++++++++++++++++++++-- internal/trading212/record_test.go | 4 +-- 4 files changed, 92 insertions(+), 27 deletions(-) diff --git a/cmd/any2anexoj-cli/main.go b/cmd/any2anexoj-cli/main.go index cb7b239..e512cba 100644 --- a/cmd/any2anexoj-cli/main.go +++ b/cmd/any2anexoj-cli/main.go @@ -21,14 +21,9 @@ var ( // remove/change default platform = pflag.StringP("platform", "p", "trading212", "One of the supported platforms") lang = pflag.StringP("language", "l", language.Portuguese.String(), "The 2 letter language code") + ofAPIKey = pflag.String("open-figi-api-key", "", "An OpenFIGI API key for faster report generation (better rate api rate limits)") // TODO: improve documentation on selectors selectors = pflag.StringSlice("selectors", nil, "Only process entries that conform to all the selectors:") - - readerFactories = map[string]func() internal.RecordReader{ - "trading212": func() internal.RecordReader { - return trading212.NewRecordReader(os.Stdin, internal.NewOpenFIGI(&http.Client{Timeout: 5 * time.Second})) - }, - } ) func main() { @@ -42,6 +37,13 @@ func main() { } func run(ctx context.Context) error { + ctx, cancel := signal.NotifyContext(ctx, os.Kill, os.Interrupt) + defer cancel() + + eg, ctx := errgroup.WithContext(ctx) + + slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, nil))) + if platform == nil || len(*platform) == 0 { slog.Error("--platform flag is required") os.Exit(1) @@ -52,20 +54,11 @@ func run(ctx context.Context) error { os.Exit(1) } - ctx, cancel := signal.NotifyContext(ctx, os.Kill, os.Interrupt) - defer cancel() - - eg, ctx := errgroup.WithContext(ctx) - - slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, nil))) - - factory, ok := readerFactories[*platform] - if !ok { - return fmt.Errorf("unsupported platform: %s", *platform) + reader, err := getReader(*platform, *ofAPIKey) + if err != nil { + return fmt.Errorf("getting reader: %w", err) } - reader := factory() - writer := internal.NewAggregatorWriter() selector, err := internal.ParseSelectors(*selectors) @@ -93,3 +86,12 @@ func run(ctx context.Context) error { return nil } + +func getReader(platform string, ofAPIKey string) (internal.RecordReader, error) { + switch platform { + case "trading212": + return trading212.NewRecordReader(os.Stdin, internal.NewOpenFIGI(&http.Client{Timeout: 5 * time.Second}, ofAPIKey)), nil + default: + return nil, fmt.Errorf("unsupported platform: %s", platform) + } +} diff --git a/internal/open_figi.go b/internal/open_figi.go index e5e1e2d..bdad997 100644 --- a/internal/open_figi.go +++ b/internal/open_figi.go @@ -13,9 +13,12 @@ import ( "golang.org/x/time/rate" ) -// OpenFIGI is a small adapter for the openfigi.com api +var OpenFIGIAPIKeyHeader = http.CanonicalHeaderKey("X-OPENFIGI-APIKEY") + +// OpenFIGI is a small adapter for the openfigi.com api. type OpenFIGI struct { client *http.Client + apiKey string mappingLimiter *rate.Limiter mu sync.RWMutex @@ -25,11 +28,18 @@ type OpenFIGI struct { securityTypeCache map[string]string } -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 +// NewOpenFIGI creates an OpenFIGI client that uses the API key if provided +func NewOpenFIGI(c *http.Client, apiKey string) *OpenFIGI { + // Rate limits as per https://www.openfigi.com/api/documentation#rate-limits + limiter := rate.NewLimiter(rate.Every(time.Minute), 25) + if len(apiKey) > 0 { + limiter = rate.NewLimiter(rate.Every(time.Second*6), 25) + } + return &OpenFIGI{ + client: c, + apiKey: apiKey, + mappingLimiter: limiter, securityTypeCache: make(map[string]string), } } @@ -71,6 +81,10 @@ func (of *OpenFIGI) SecurityTypeByISIN(ctx context.Context, isin string) (string req.Header.Add("Content-Type", "application/json") + if len(of.apiKey) > 0 { + req.Header.Add(OpenFIGIAPIKeyHeader, of.apiKey) + } + err = of.mappingLimiter.Wait(ctx) if err != nil { return "", fmt.Errorf("wait for mapping request capacity: %w", err) diff --git a/internal/open_figi_test.go b/internal/open_figi_test.go index 4d3870a..3b31107 100644 --- a/internal/open_figi_test.go +++ b/internal/open_figi_test.go @@ -110,7 +110,7 @@ func TestOpenFIGI_SecurityTypeByISIN(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - of := internal.NewOpenFIGI(tt.client) + of := internal.NewOpenFIGI(tt.client, "") got, gotErr := of.SecurityTypeByISIN(context.Background(), tt.isin) if gotErr != nil { @@ -145,7 +145,7 @@ func TestOpenFIGI_SecurityTypeByISIN_Cache(t *testing.T) { }, nil }) - of := internal.NewOpenFIGI(c) + of := internal.NewOpenFIGI(c, "") got, gotErr := of.SecurityTypeByISIN(t.Context(), "NL0000235190") if gotErr != nil { @@ -166,6 +166,55 @@ func TestOpenFIGI_SecurityTypeByISIN_Cache(t *testing.T) { } } +func TestOpenFIGI_SecurityTypeByISIN_APIKey(t *testing.T) { + t.Run("with API key", func(t *testing.T) { + wantAPIKey := "123abc-456xyz" + + c := NewTestClient(t, func(req *http.Request) (*http.Response, error) { + value, ok := req.Header[internal.OpenFIGIAPIKeyHeader] + if !ok { + t.Fatalf("want %q header but got none: %v", internal.OpenFIGIAPIKeyHeader, req.Header) + } + if len(value) != 1 { + t.Fatalf("want exactly one %q header value but got %d", internal.OpenFIGIAPIKeyHeader, len(value)) + } + if value[0] != wantAPIKey { + t.Fatalf("want %q header value %q but got %q", internal.OpenFIGIAPIKeyHeader, wantAPIKey, value[0]) + } + return &http.Response{ + Status: http.StatusText(http.StatusOK), + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewBufferString(`[{"data":[{"securityType":"Common Stock"}]}]`)), + }, nil + }) + of := internal.NewOpenFIGI(c, wantAPIKey) + + _, err := of.SecurityTypeByISIN(t.Context(), "US1234567890") + if err != nil { + t.Fatalf("want success but got an error: %s", err) + } + }) + + t.Run("without API key", func(t *testing.T) { + c := NewTestClient(t, func(req *http.Request) (*http.Response, error) { + _, ok := req.Header[internal.OpenFIGIAPIKeyHeader] + if ok { + t.Fatalf("want no %s header but got one", internal.OpenFIGIAPIKeyHeader) + } + return &http.Response{ + Status: http.StatusText(http.StatusOK), + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewBufferString(`[{"data":[{"securityType":"Common Stock"}]}]`)), + }, nil + }) + of := internal.NewOpenFIGI(c, "") + _, err := of.SecurityTypeByISIN(t.Context(), "US1234567890") + if err != nil { + t.Fatalf("want success but got an error: %s", err) + } + }) +} + type RoundTripFunc func(req *http.Request) (*http.Response, error) func (f RoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { diff --git a/internal/trading212/record_test.go b/internal/trading212/record_test.go index 9938eec..e47c084 100644 --- a/internal/trading212/record_test.go +++ b/internal/trading212/record_test.go @@ -223,7 +223,7 @@ func NewFigiClientSecurityTypeStub(t testing.TB, securityType string) *internal. }), } - return internal.NewOpenFIGI(c) + return internal.NewOpenFIGI(c, "") } func NewFigiClientErrorStub(t testing.TB, err error) *internal.OpenFIGI { @@ -236,5 +236,5 @@ func NewFigiClientErrorStub(t testing.TB, err error) *internal.OpenFIGI { }), } - return internal.NewOpenFIGI(c) + return internal.NewOpenFIGI(c, "") } From 1c29f52cceb8b25a7c05b1efa2ce2289794cada0 Mon Sep 17 00:00:00 2001 From: Natercio Moniz Date: Sat, 16 May 2026 10:43:52 +0100 Subject: [PATCH 03/10] support REIT security type --- internal/trading212/record.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/trading212/record.go b/internal/trading212/record.go index 11a6ea6..6ab1b52 100644 --- a/internal/trading212/record.go +++ b/internal/trading212/record.go @@ -158,7 +158,7 @@ func figiNatureGetter(ctx context.Context, of *internal.OpenFIGI, isin string) f } switch secType { - case "Common Stock", "ASD": + case "Common Stock", "ASD", "REIT": return internal.NatureG01 case "ETP": return internal.NatureG20 From c110a2cc700aed78fbeeaf505b67444fcec691c9 Mon Sep 17 00:00:00 2001 From: Natercio Moniz Date: Sat, 16 May 2026 10:48:47 +0100 Subject: [PATCH 04/10] fix ASD to ADR --- internal/trading212/record.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/trading212/record.go b/internal/trading212/record.go index 6ab1b52..4ac8eeb 100644 --- a/internal/trading212/record.go +++ b/internal/trading212/record.go @@ -158,7 +158,7 @@ func figiNatureGetter(ctx context.Context, of *internal.OpenFIGI, isin string) f } switch secType { - case "Common Stock", "ASD", "REIT": + case "Common Stock", "ADR", "REIT": return internal.NatureG01 case "ETP": return internal.NatureG20 From 24c2814eeffe1d19cbe102b21172447eaf4cf900 Mon Sep 17 00:00:00 2001 From: Natercio Moniz Date: Sat, 16 May 2026 11:44:35 +0100 Subject: [PATCH 05/10] improved logging for debug --- cmd/any2anexoj-cli/main.go | 7 ++++++- internal/open_figi.go | 18 ++++++++++++++++++ internal/report.go | 30 ++++++++++++++++++++++++++++++ 3 files changed, 54 insertions(+), 1 deletion(-) diff --git a/cmd/any2anexoj-cli/main.go b/cmd/any2anexoj-cli/main.go index e512cba..68da9b9 100644 --- a/cmd/any2anexoj-cli/main.go +++ b/cmd/any2anexoj-cli/main.go @@ -21,6 +21,7 @@ var ( // remove/change default platform = pflag.StringP("platform", "p", "trading212", "One of the supported platforms") lang = pflag.StringP("language", "l", language.Portuguese.String(), "The 2 letter language code") + debug = pflag.BoolP("debug", "d", false, "Activate to log debug messages") ofAPIKey = pflag.String("open-figi-api-key", "", "An OpenFIGI API key for faster report generation (better rate api rate limits)") // TODO: improve documentation on selectors selectors = pflag.StringSlice("selectors", nil, "Only process entries that conform to all the selectors:") @@ -42,7 +43,11 @@ func run(ctx context.Context) error { eg, ctx := errgroup.WithContext(ctx) - slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, nil))) + logLevel := slog.LevelInfo + if *debug { + logLevel = slog.LevelDebug + } + slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: logLevel}))) if platform == nil || len(*platform) == 0 { slog.Error("--platform flag is required") diff --git a/internal/open_figi.go b/internal/open_figi.go index bdad997..d4bce71 100644 --- a/internal/open_figi.go +++ b/internal/open_figi.go @@ -5,6 +5,7 @@ import ( "context" "encoding/json" "fmt" + "log/slog" "net/http" "sync" "time" @@ -33,7 +34,10 @@ func NewOpenFIGI(c *http.Client, apiKey string) *OpenFIGI { // Rate limits as per https://www.openfigi.com/api/documentation#rate-limits limiter := rate.NewLimiter(rate.Every(time.Minute), 25) if len(apiKey) > 0 { + slog.Debug("OpenFIGI client: created with API Key rate limits") limiter = rate.NewLimiter(rate.Every(time.Second*6), 25) + } else { + slog.Debug("OpenFIGI client: created with puplic rate limits") } return &OpenFIGI{ @@ -48,10 +52,16 @@ func (of *OpenFIGI) SecurityTypeByISIN(ctx context.Context, isin string) (string of.mu.RLock() if secType, ok := of.securityTypeCache[isin]; ok { of.mu.RUnlock() + slog.Debug("OpenFIGI client: SecurityTypeByISIN cache hit", + slog.String("isin", isin), + slog.String("security_type", secType)) return secType, nil } of.mu.RUnlock() + slog.Debug("OpenFIGI client: SecurityTypeByISIN cache miss", + slog.String("isin", isin)) + of.mu.Lock() defer of.mu.Unlock() @@ -85,6 +95,10 @@ func (of *OpenFIGI) SecurityTypeByISIN(ctx context.Context, isin string) (string req.Header.Add(OpenFIGIAPIKeyHeader, of.apiKey) } + if !of.mappingLimiter.Allow() { + slog.Debug("OpenFIGI client: mapping limiter waiting for rate limiter capacity") + } + err = of.mappingLimiter.Wait(ctx) if err != nil { return "", fmt.Errorf("wait for mapping request capacity: %w", err) @@ -123,6 +137,10 @@ func (of *OpenFIGI) SecurityTypeByISIN(ctx context.Context, isin string) (string of.securityTypeCache[isin] = secType + slog.Debug("OpenFIGI client: SecurityTypeByISIN cached mapping", + slog.String("isin", isin), + slog.String("security_type", secType)) + return secType, nil } diff --git a/internal/report.go b/internal/report.go index e341ab9..f296652 100644 --- a/internal/report.go +++ b/internal/report.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "io" + "log/slog" "time" "github.com/shopspring/decimal" @@ -58,10 +59,21 @@ type Selector func(Record) bool func BuildReport(ctx context.Context, reader RecordReader, writer ReportWriter, s Selector) error { buys := make(map[string]*FillerQueue) + var buysCount, sellsCount int64 + var lastTimestamp time.Time + progTicker := time.NewTicker(10 * time.Second) + for { select { case <-ctx.Done(): return ctx.Err() + case <-progTicker.C: + slog.InfoContext(ctx, "Progress update", + slog.Int64("total_records", buysCount+sellsCount), + slog.Int64("sell_records", sellsCount), + slog.Int64("buy_records", buysCount), + slog.Time("last_record_timestamp", lastTimestamp), + ) default: rec, err := reader.ReadRecord(ctx) if err != nil { @@ -72,9 +84,21 @@ func BuildReport(ctx context.Context, reader RecordReader, writer ReportWriter, } if !s(rec) { + slog.Debug("Report: skipping record", + slog.String("symbol", rec.Symbol()), + slog.String("side", rec.Side().String()), + ) continue } + if rec.Side().IsBuy() { + buysCount++ + } else { + sellsCount++ + } + + lastTimestamp = rec.Timestamp() + buyQueue, ok := buys[rec.Symbol()] if !ok { buyQueue = new(FillerQueue) @@ -85,11 +109,17 @@ func BuildReport(ctx context.Context, reader RecordReader, writer ReportWriter, if err != nil { return fmt.Errorf("processing record: %w", err) } + } } } func processRecord(ctx context.Context, q *FillerQueue, rec Record, writer ReportWriter) error { + slog.Debug("Report: processing record", + slog.String("symbol", rec.Symbol()), + slog.String("side", rec.Side().String()), + ) + switch rec.Side() { case SideBuy: q.Push(NewFiller(rec)) From d371aca7679009b76ff09ae13cea1f8e575f8b2e Mon Sep 17 00:00:00 2001 From: Natercio Moniz Date: Sat, 16 May 2026 11:58:35 +0100 Subject: [PATCH 06/10] apply selectors only on sells for performance --- internal/report.go | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/internal/report.go b/internal/report.go index f296652..24483e3 100644 --- a/internal/report.go +++ b/internal/report.go @@ -55,8 +55,8 @@ type ReportWriter interface { type Selector func(Record) bool // BuildReport reads records from a RecordReader and, if the record passes the Selector, it is -// processed into the report -func BuildReport(ctx context.Context, reader RecordReader, writer ReportWriter, s Selector) error { +// processed into the ReportWriter. +func BuildReport(ctx context.Context, reader RecordReader, writer ReportWriter, sel Selector) error { buys := make(map[string]*FillerQueue) var buysCount, sellsCount int64 @@ -83,14 +83,6 @@ func BuildReport(ctx context.Context, reader RecordReader, writer ReportWriter, return err } - if !s(rec) { - slog.Debug("Report: skipping record", - slog.String("symbol", rec.Symbol()), - slog.String("side", rec.Side().String()), - ) - continue - } - if rec.Side().IsBuy() { buysCount++ } else { @@ -105,7 +97,7 @@ func BuildReport(ctx context.Context, reader RecordReader, writer ReportWriter, buys[rec.Symbol()] = buyQueue } - err = processRecord(ctx, buyQueue, rec, writer) + err = processRecord(ctx, buyQueue, rec, sel, writer) if err != nil { return fmt.Errorf("processing record: %w", err) } @@ -114,7 +106,11 @@ func BuildReport(ctx context.Context, reader RecordReader, writer ReportWriter, } } -func processRecord(ctx context.Context, q *FillerQueue, rec Record, writer ReportWriter) error { +// processRecord either adds buys to the queue or consumes buys from the queue when processing a +// sell record. +// Selectors are only applied for sells for performance reasons. It's much cheaper to just accumulate +// buys and only actually inspect a record once a sell happens +func processRecord(ctx context.Context, q *FillerQueue, rec Record, sel Selector, writer ReportWriter) error { slog.Debug("Report: processing record", slog.String("symbol", rec.Symbol()), slog.String("side", rec.Side().String()), @@ -125,6 +121,14 @@ func processRecord(ctx context.Context, q *FillerQueue, rec Record, writer Repor q.Push(NewFiller(rec)) case SideSell: + if !sel(rec) { + slog.Debug("Report: skipping record", + slog.String("symbol", rec.Symbol()), + slog.String("side", rec.Side().String()), + ) + return nil + } + unmatchedQty := rec.Quantity() for unmatchedQty.IsPositive() { From 5cdccfcdb105647c51f54aeac6014001cb32d12f Mon Sep 17 00:00:00 2001 From: Natercio Moniz Date: Sat, 16 May 2026 14:21:14 +0100 Subject: [PATCH 07/10] rename side to kind --- internal/kind.go | 29 ++++++++++ internal/{side_test.go => kind_test.go} | 28 ++++----- internal/mocks/mocks_gen.go | 76 ++++++++++++------------- internal/report.go | 22 +++---- internal/report_test.go | 8 +-- internal/selectors_test.go | 62 ++++++++++---------- internal/side.go | 30 ---------- internal/trading212/record.go | 25 ++++---- internal/trading212/record_test.go | 8 +-- 9 files changed, 146 insertions(+), 142 deletions(-) create mode 100644 internal/kind.go rename internal/{side_test.go => kind_test.go} (66%) delete mode 100644 internal/side.go diff --git a/internal/kind.go b/internal/kind.go new file mode 100644 index 0000000..d62408b --- /dev/null +++ b/internal/kind.go @@ -0,0 +1,29 @@ +package internal + +type Kind uint + +const ( + KindUnknown Kind = iota + KindBuy + KindSell + KindSplit +) + +// String returns a human readable value +func (d Kind) String() string { + switch d { + case KindBuy: + return "buy" + case KindSell: + return "sell" + case KindSplit: + return "split" + default: + return "unknown" + } +} + +// Is returns true when k equals o +func (k Kind) Is(o Kind) bool { + return k == o +} diff --git a/internal/side_test.go b/internal/kind_test.go similarity index 66% rename from internal/side_test.go rename to internal/kind_test.go index fe798e9..a52d7e6 100644 --- a/internal/side_test.go +++ b/internal/kind_test.go @@ -5,12 +5,12 @@ import "testing" func TestSide_String(t *testing.T) { tests := []struct { name string - side Side + side Kind want string }{ - {"buy", SideBuy, "buy"}, - {"sell", SideSell, "sell"}, - {"unknown", SideUnknown, "unknown"}, + {"buy", KindBuy, "buy"}, + {"sell", KindSell, "sell"}, + {"unknown", KindUnknown, "unknown"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -24,16 +24,16 @@ func TestSide_String(t *testing.T) { func TestSide_IsBuy(t *testing.T) { tests := []struct { name string - side Side + side Kind want bool }{ - {"buy", SideBuy, true}, - {"sell", SideSell, false}, - {"unknown", SideUnknown, false}, + {"buy", KindBuy, true}, + {"sell", KindSell, false}, + {"unknown", KindUnknown, false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if got := tt.side.IsBuy(); got != tt.want { + if got := tt.side.Is(KindBuy); got != tt.want { t.Errorf("want Side.IsBuy() to be %v but got %v", tt.want, got) } }) @@ -43,16 +43,16 @@ func TestSide_IsBuy(t *testing.T) { func TestSide_IsSell(t *testing.T) { tests := []struct { name string - side Side + side Kind want bool }{ - {"buy", SideBuy, false}, - {"sell", SideSell, true}, - {"unknown", SideUnknown, false}, + {"buy", KindBuy, false}, + {"sell", KindSell, true}, + {"unknown", KindUnknown, false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if got := tt.side.IsSell(); got != tt.want { + if got := tt.side.Is(KindSell); got != tt.want { t.Errorf("want Side.IsSell() to be %v but got %v", tt.want, got) } }) diff --git a/internal/mocks/mocks_gen.go b/internal/mocks/mocks_gen.go index fa38472..427c3f4 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 } +// Kind mocks base method. +func (m *MockRecord) Kind() internal.Kind { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Kind") + ret0, _ := ret[0].(internal.Kind) + return ret0 +} + +// Kind indicates an expected call of Kind. +func (mr *MockRecordMockRecorder) Kind() *MockRecordKindCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Kind", reflect.TypeOf((*MockRecord)(nil).Kind)) + return &MockRecordKindCall{Call: call} +} + +// MockRecordKindCall wrap *gomock.Call +type MockRecordKindCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockRecordKindCall) Return(arg0 internal.Kind) *MockRecordKindCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockRecordKindCall) Do(f func() internal.Kind) *MockRecordKindCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockRecordKindCall) DoAndReturn(f func() internal.Kind) *MockRecordKindCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + // Nature mocks base method. func (m *MockRecord) Nature() internal.Nature { m.ctrl.T.Helper() @@ -334,44 +372,6 @@ func (c *MockRecordQuantityCall) DoAndReturn(f func() decimal.Decimal) *MockReco return c } -// Side mocks base method. -func (m *MockRecord) Side() internal.Side { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Side") - ret0, _ := ret[0].(internal.Side) - return ret0 -} - -// Side indicates an expected call of Side. -func (mr *MockRecordMockRecorder) Side() *MockRecordSideCall { - mr.mock.ctrl.T.Helper() - call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Side", reflect.TypeOf((*MockRecord)(nil).Side)) - return &MockRecordSideCall{Call: call} -} - -// MockRecordSideCall wrap *gomock.Call -type MockRecordSideCall struct { - *gomock.Call -} - -// Return rewrite *gomock.Call.Return -func (c *MockRecordSideCall) Return(arg0 internal.Side) *MockRecordSideCall { - c.Call = c.Call.Return(arg0) - return c -} - -// Do rewrite *gomock.Call.Do -func (c *MockRecordSideCall) Do(f func() internal.Side) *MockRecordSideCall { - c.Call = c.Call.Do(f) - return c -} - -// DoAndReturn rewrite *gomock.Call.DoAndReturn -func (c *MockRecordSideCall) DoAndReturn(f func() internal.Side) *MockRecordSideCall { - c.Call = c.Call.DoAndReturn(f) - return c -} - // Symbol mocks base method. func (m *MockRecord) Symbol() string { m.ctrl.T.Helper() diff --git a/internal/report.go b/internal/report.go index 24483e3..1f1312f 100644 --- a/internal/report.go +++ b/internal/report.go @@ -16,7 +16,7 @@ type Record interface { Nature() Nature BrokerCountry() int64 AssetCountry() int64 - Side() Side + Kind() Kind Price() decimal.Decimal Quantity() decimal.Decimal Timestamp() time.Time @@ -83,9 +83,9 @@ func BuildReport(ctx context.Context, reader RecordReader, writer ReportWriter, return err } - if rec.Side().IsBuy() { + if rec.Kind().Is(KindBuy) { buysCount++ - } else { + } else if rec.Kind().Is(KindSell) { sellsCount++ } @@ -108,23 +108,23 @@ func BuildReport(ctx context.Context, reader RecordReader, writer ReportWriter, // processRecord either adds buys to the queue or consumes buys from the queue when processing a // sell record. -// Selectors are only applied for sells for performance reasons. It's much cheaper to just accumulate -// buys and only actually inspect a record once a sell happens +// Selectors are only applied on sells for performance reasons. It's much cheaper to just accumulate +// buys and only actually inspect a record once a sell happens due to potential network requests to func processRecord(ctx context.Context, q *FillerQueue, rec Record, sel Selector, writer ReportWriter) error { slog.Debug("Report: processing record", slog.String("symbol", rec.Symbol()), - slog.String("side", rec.Side().String()), + slog.String("side", rec.Kind().String()), ) - switch rec.Side() { - case SideBuy: + switch rec.Kind() { + case KindBuy: q.Push(NewFiller(rec)) - case SideSell: + case KindSell: if !sel(rec) { slog.Debug("Report: skipping record", slog.String("symbol", rec.Symbol()), - slog.String("side", rec.Side().String()), + slog.String("side", rec.Kind().String()), ) return nil } @@ -169,7 +169,7 @@ func processRecord(ctx context.Context, q *FillerQueue, rec Record, sel Selector } default: - return fmt.Errorf("unknown side: %v", rec.Side()) + return fmt.Errorf("unknown side: %v", rec.Kind()) } return nil diff --git a/internal/report_test.go b/internal/report_test.go index f530ddb..5c5d024 100644 --- a/internal/report_test.go +++ b/internal/report_test.go @@ -20,8 +20,8 @@ func TestBuildReport(t *testing.T) { reader := mocks.NewMockRecordReader(ctrl) records := []internal.Record{ - mockRecord(ctrl, 20.0, 10.0, internal.SideBuy, now), - mockRecord(ctrl, 25.0, 10.0, internal.SideSell, now.Add(1)), + mockRecord(ctrl, 20.0, 10.0, internal.KindBuy, now), + mockRecord(ctrl, 25.0, 10.0, internal.KindSell, now.Add(1)), } reader.EXPECT().ReadRecord(gomock.Any()).DoAndReturn(func(ctx context.Context) (internal.Record, error) { if len(records) > 0 { @@ -49,14 +49,14 @@ func TestBuildReport(t *testing.T) { } } -func mockRecord(ctrl *gomock.Controller, price, quantity float64, side internal.Side, ts time.Time) *mocks.MockRecord { +func mockRecord(ctrl *gomock.Controller, price, quantity float64, kind internal.Kind, ts time.Time) *mocks.MockRecord { rec := mocks.NewMockRecord(ctrl) rec.EXPECT().Symbol().Return("TEST").AnyTimes() rec.EXPECT().BrokerCountry().Return(int64(countries.PT)).AnyTimes() rec.EXPECT().AssetCountry().Return(int64(countries.USA)).AnyTimes() rec.EXPECT().Price().Return(decimal.NewFromFloat(price)).AnyTimes() rec.EXPECT().Quantity().Return(decimal.NewFromFloat(quantity)).AnyTimes() - rec.EXPECT().Side().Return(side).AnyTimes() + rec.EXPECT().Kind().Return(kind).AnyTimes() rec.EXPECT().Timestamp().Return(ts).AnyTimes() rec.EXPECT().Fees().Return(decimal.Decimal{}).AnyTimes() rec.EXPECT().Taxes().Return(decimal.Decimal{}).AnyTimes() diff --git a/internal/selectors_test.go b/internal/selectors_test.go index 836d285..a0970db 100644 --- a/internal/selectors_test.go +++ b/internal/selectors_test.go @@ -9,28 +9,28 @@ import ( ) type testRecord struct { - symbol string - nature internal.Nature + symbol string + nature internal.Nature brokerCountry int64 - assetCountry int64 - side internal.Side - price decimal.Decimal - quantity decimal.Decimal - timestamp time.Time - fees decimal.Decimal - taxes decimal.Decimal + assetCountry int64 + side internal.Kind + price decimal.Decimal + quantity decimal.Decimal + timestamp time.Time + fees decimal.Decimal + taxes decimal.Decimal } -func (m testRecord) Symbol() string { return m.symbol } -func (m testRecord) Nature() internal.Nature { return m.nature } -func (m testRecord) BrokerCountry() int64 { return m.brokerCountry } -func (m testRecord) AssetCountry() int64 { return m.assetCountry } -func (m testRecord) Side() internal.Side { return m.side } -func (m testRecord) Price() decimal.Decimal { return m.price } -func (m testRecord) Quantity() decimal.Decimal { return m.quantity } -func (m testRecord) Timestamp() time.Time { return m.timestamp } -func (m testRecord) Fees() decimal.Decimal { return m.fees } -func (m testRecord) Taxes() decimal.Decimal { return m.taxes } +func (m testRecord) Symbol() string { return m.symbol } +func (m testRecord) Nature() internal.Nature { return m.nature } +func (m testRecord) BrokerCountry() int64 { return m.brokerCountry } +func (m testRecord) AssetCountry() int64 { return m.assetCountry } +func (m testRecord) Kind() internal.Kind { return m.side } +func (m testRecord) Price() decimal.Decimal { return m.price } +func (m testRecord) Quantity() decimal.Decimal { return m.quantity } +func (m testRecord) Timestamp() time.Time { return m.timestamp } +func (m testRecord) Fees() decimal.Decimal { return m.fees } +func (m testRecord) Taxes() decimal.Decimal { return m.taxes } func TestAny(t *testing.T) { selector := internal.Any() @@ -43,9 +43,9 @@ func TestAny(t *testing.T) { { name: "returns true for any record", record: testRecord{ - symbol: "AAPL", - nature: internal.NatureG01, - assetCountry: 1, + symbol: "AAPL", + nature: internal.NatureG01, + assetCountry: 1, }, want: true, }, @@ -59,7 +59,7 @@ func TestAny(t *testing.T) { want: true, }, { - name: "returns true for empty record", + name: "returns true for empty record", record: testRecord{}, want: true, }, @@ -77,10 +77,10 @@ func TestAny(t *testing.T) { func TestOnlyNature(t *testing.T) { tests := []struct { - name string - nature internal.Nature - record internal.Record - want bool + name string + nature internal.Nature + record internal.Record + want bool }{ { name: "matches G01 nature", @@ -133,10 +133,10 @@ func TestOnlyNature(t *testing.T) { func TestOnlyAssetCountry(t *testing.T) { tests := []struct { - name string - country int64 - record internal.Record - want bool + name string + country int64 + record internal.Record + want bool }{ { name: "matches asset country", diff --git a/internal/side.go b/internal/side.go deleted file mode 100644 index 0f16ee9..0000000 --- a/internal/side.go +++ /dev/null @@ -1,30 +0,0 @@ -package internal - -type Side uint - -const ( - SideUnknown Side = iota - SideBuy - SideSell -) - -func (d Side) String() string { - switch d { - case SideBuy: - return "buy" - case SideSell: - return "sell" - default: - return "unknown" - } -} - -// IsBuy returns true if the s == SideBuy -func (d Side) IsBuy() bool { - return d == SideBuy -} - -// IsSell returns true if the s == SideSell -func (d Side) IsSell() bool { - return d == SideSell -} diff --git a/internal/trading212/record.go b/internal/trading212/record.go index 4ac8eeb..3673a22 100644 --- a/internal/trading212/record.go +++ b/internal/trading212/record.go @@ -18,7 +18,7 @@ import ( type Record struct { symbol string timestamp time.Time - side internal.Side + side internal.Kind quantity decimal.Decimal price decimal.Decimal fees decimal.Decimal @@ -44,7 +44,7 @@ func (r Record) AssetCountry() int64 { return int64(countries.ByName(r.Symbol()[:2]).Info().Code) } -func (r Record) Side() internal.Side { +func (r Record) Kind() internal.Kind { return r.side } @@ -81,10 +81,12 @@ func NewRecordReader(r io.Reader, f *internal.OpenFIGI) *RecordReader { } const ( - MarketBuy = "market buy" - MarketSell = "market sell" - LimitBuy = "limit buy" - LimitSell = "limit sell" + MarketBuy = "market buy" + MarketSell = "market sell" + LimitBuy = "limit buy" + LimitSell = "limit sell" + StockSplitOpen = "Stock split open" + StockSplitClose = "Stock split close" ) func (rr RecordReader) ReadRecord(ctx context.Context) (internal.Record, error) { @@ -94,13 +96,16 @@ func (rr RecordReader) ReadRecord(ctx context.Context) (internal.Record, error) return Record{}, fmt.Errorf("read record: %w", err) } - var side internal.Side + var side internal.Kind switch strings.ToLower(raw[0]) { case MarketBuy, LimitBuy: - side = internal.SideBuy + side = internal.KindBuy case MarketSell, LimitSell: - side = internal.SideSell - case "action", "stock split open", "stock split close": + side = internal.KindSell + case StockSplitOpen, StockSplitClose: + // TODO: emit a special event that triggers a readjustment of unsold stock + continue + case "action": // TODO: this is the header, there's probably a better way to handle this continue default: return Record{}, fmt.Errorf("parse record type: %s", raw[0]) diff --git a/internal/trading212/record_test.go b/internal/trading212/record_test.go index e47c084..0969bae 100644 --- a/internal/trading212/record_test.go +++ b/internal/trading212/record_test.go @@ -30,7 +30,7 @@ func TestRecordReader_ReadRecord(t *testing.T) { 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: "XX1234567890", - side: internal.SideBuy, + side: internal.KindBuy, quantity: ShouldParseDecimal(t, "2.4387014200"), price: ShouldParseDecimal(t, "7.3690000000"), timestamp: time.Date(2025, 7, 3, 10, 44, 29, 0, time.UTC), @@ -44,7 +44,7 @@ func TestRecordReader_ReadRecord(t *testing.T) { 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: "XX1234567890", - side: internal.SideSell, + side: internal.KindSell, quantity: ShouldParseDecimal(t, "2.4387014200"), price: ShouldParseDecimal(t, "7.9999999999"), timestamp: time.Date(2025, 8, 4, 11, 45, 30, 0, time.UTC), @@ -123,8 +123,8 @@ func TestRecordReader_ReadRecord(t *testing.T) { t.Fatalf("want symbol %v but got %v", tt.want.symbol, got.Symbol()) } - if got.Side() != tt.want.side { - t.Fatalf("want side %v but got %v", tt.want.side, got.Side()) + if got.Kind() != tt.want.side { + t.Fatalf("want side %v but got %v", tt.want.side, got.Kind()) } if got.Price().Cmp(tt.want.price) != 0 { From 7cc5d1cf75acb59a0309afb96ed189dee54b617d Mon Sep 17 00:00:00 2001 From: Natercio Moniz Date: Sat, 16 May 2026 15:34:24 +0100 Subject: [PATCH 08/10] handle stock split --- internal/filler.go | 34 +++++++++-- internal/filler_test.go | 94 ++++++++++++++++++++++++++++-- internal/kind.go | 5 +- internal/report.go | 3 + internal/trading212/record.go | 85 ++++++++++++++++++++------- internal/trading212/record_test.go | 72 +++++++++++++++++++++-- 6 files changed, 258 insertions(+), 35 deletions(-) diff --git a/internal/filler.go b/internal/filler.go index 2d03e9d..ab1053e 100644 --- a/internal/filler.go +++ b/internal/filler.go @@ -9,19 +9,27 @@ import ( type Filler struct { Record - filled decimal.Decimal + filled decimal.Decimal + quantity decimal.Decimal + price decimal.Decimal } func NewFiller(r Record) *Filler { return &Filler{ - Record: r, + Record: r, + quantity: r.Quantity(), + price: r.Price(), } } +func (f *Filler) Quantity() decimal.Decimal { return f.quantity } + +func (f *Filler) Price() decimal.Decimal { return f.price } + // Fill accrues some quantity. Returns how mutch was accrued in the 1st return value and whether // it was filled or not on the 2nd return value. func (f *Filler) Fill(quantity decimal.Decimal) (decimal.Decimal, bool) { - unfilled := f.Record.Quantity().Sub(f.filled) + unfilled := f.quantity.Sub(f.filled) delta := decimal.Min(unfilled, quantity) f.filled = f.filled.Add(delta) return delta, f.IsFilled() @@ -29,7 +37,15 @@ func (f *Filler) Fill(quantity decimal.Decimal) (decimal.Decimal, bool) { // IsFilled returns true if the fill is equal to the record quantity. func (f *Filler) IsFilled() bool { - return f.filled.Equal(f.Quantity()) + return f.filled.Equal(f.quantity) +} + +// ApplySplit adjusts the lot for a stock split by the given ratio (newQty/oldQty). +// The total cost basis is preserved: quantity scales up, price scales down proportionally. +func (f *Filler) ApplySplit(ratio decimal.Decimal) { + f.quantity = f.quantity.Mul(ratio) + f.filled = f.filled.Mul(ratio) + f.price = f.price.Div(ratio) } type FillerQueue struct { @@ -86,6 +102,16 @@ func (fq *FillerQueue) frontElement() *list.Element { return fq.l.Front() } +// AdjustForSplit applies a stock split ratio to all lots in the queue. +func (fq *FillerQueue) AdjustForSplit(ratio decimal.Decimal) { + if fq == nil || fq.l == nil { + return + } + for e := fq.l.Front(); e != nil; e = e.Next() { + e.Value.(*Filler).ApplySplit(ratio) + } +} + // Len returns how many elements are currently on the queue func (fq *FillerQueue) Len() int { if fq == nil || fq.l == nil { diff --git a/internal/filler_test.go b/internal/filler_test.go index 0244a96..6b5726d 100644 --- a/internal/filler_test.go +++ b/internal/filler_test.go @@ -114,7 +114,7 @@ func TestFillerQueueNilReceiver(t *testing.T) { t.Fatalf(`want panic message %q but got "%v"`, expMsg, r) } }() - rq.Push(NewFiller(nil)) + rq.Push(NewFiller(&testRecord{})) } type testRecord struct { @@ -122,11 +122,11 @@ type testRecord struct { id int quantity decimal.Decimal + price decimal.Decimal } -func (tr testRecord) Quantity() decimal.Decimal { - return tr.quantity -} +func (tr testRecord) Quantity() decimal.Decimal { return tr.quantity } +func (tr testRecord) Price() decimal.Decimal { return tr.price } func TestFiller_Fill(t *testing.T) { tests := []struct { @@ -185,3 +185,89 @@ func TestFiller_Fill(t *testing.T) { }) } } + +func TestFiller_ApplySplit(t *testing.T) { + tests := []struct { + name string + qty float64 + price float64 + prefilled float64 + ratio float64 + wantQty float64 + wantPrice float64 + wantFilled float64 + wantCostBasis float64 + }{ + { + name: "5:1 split on unfilled lot preserves cost basis", + qty: 10, price: 100, prefilled: 0, ratio: 5, + wantQty: 50, wantPrice: 20, wantFilled: 0, wantCostBasis: 1000, + }, + { + name: "5:1 split on partially filled lot", + qty: 10, price: 100, prefilled: 4, ratio: 5, + wantQty: 50, wantPrice: 20, wantFilled: 20, wantCostBasis: 1000, + }, + { + name: "1:2 reverse split on unfilled lot preserves cost basis", + qty: 10, price: 100, prefilled: 0, ratio: 0.5, + wantQty: 5, wantPrice: 200, wantFilled: 0, wantCostBasis: 1000, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f := NewFiller(&testRecord{ + quantity: decimal.NewFromFloat(tt.qty), + price: decimal.NewFromFloat(tt.price), + }) + if tt.prefilled > 0 { + f.Fill(decimal.NewFromFloat(tt.prefilled)) + } + + f.ApplySplit(decimal.NewFromFloat(tt.ratio)) + + if !f.Quantity().Equal(decimal.NewFromFloat(tt.wantQty)) { + t.Errorf("want quantity %v but got %v", tt.wantQty, f.Quantity()) + } + if !f.Price().Equal(decimal.NewFromFloat(tt.wantPrice)) { + t.Errorf("want price %v but got %v", tt.wantPrice, f.Price()) + } + if !f.filled.Equal(decimal.NewFromFloat(tt.wantFilled)) { + t.Errorf("want filled %v but got %v", tt.wantFilled, f.filled) + } + costBasis := f.Quantity().Mul(f.Price()) + if !costBasis.Equal(decimal.NewFromFloat(tt.wantCostBasis)) { + t.Errorf("want cost basis %v but got %v", tt.wantCostBasis, costBasis) + } + }) + } +} + +func TestFillerQueue_AdjustForSplit(t *testing.T) { + var fq FillerQueue + fq.Push(NewFiller(&testRecord{quantity: decimal.NewFromFloat(10), price: decimal.NewFromFloat(100)})) + fq.Push(NewFiller(&testRecord{quantity: decimal.NewFromFloat(5), price: decimal.NewFromFloat(200)})) + + fq.AdjustForSplit(decimal.NewFromFloat(5)) + + lot1, _ := fq.Pop() + if !lot1.Quantity().Equal(decimal.NewFromFloat(50)) { + t.Errorf("lot1: want quantity 50 but got %v", lot1.Quantity()) + } + if !lot1.Price().Equal(decimal.NewFromFloat(20)) { + t.Errorf("lot1: want price 20 but got %v", lot1.Price()) + } + + lot2, _ := fq.Pop() + if !lot2.Quantity().Equal(decimal.NewFromFloat(25)) { + t.Errorf("lot2: want quantity 25 but got %v", lot2.Quantity()) + } + if !lot2.Price().Equal(decimal.NewFromFloat(40)) { + t.Errorf("lot2: want price 40 but got %v", lot2.Price()) + } +} + +func TestFillerQueue_AdjustForSplit_NilReceiver(t *testing.T) { + var fq *FillerQueue + fq.AdjustForSplit(decimal.NewFromFloat(5)) // must not panic +} diff --git a/internal/kind.go b/internal/kind.go index d62408b..50c8af0 100644 --- a/internal/kind.go +++ b/internal/kind.go @@ -24,6 +24,7 @@ func (d Kind) String() string { } // Is returns true when k equals o -func (k Kind) Is(o Kind) bool { - return k == o +func (k Kind) Is(o any) bool { + other, ok := o.(Kind) + return ok && k == other } diff --git a/internal/report.go b/internal/report.go index 1f1312f..fb72ea1 100644 --- a/internal/report.go +++ b/internal/report.go @@ -168,6 +168,9 @@ func processRecord(ctx context.Context, q *FillerQueue, rec Record, sel Selector } } + case KindSplit: + q.AdjustForSplit(rec.Quantity()) + default: return fmt.Errorf("unknown side: %v", rec.Kind()) } diff --git a/internal/trading212/record.go b/internal/trading212/record.go index 3673a22..5d456d1 100644 --- a/internal/trading212/record.go +++ b/internal/trading212/record.go @@ -18,7 +18,7 @@ import ( type Record struct { symbol string timestamp time.Time - side internal.Kind + kind internal.Kind quantity decimal.Decimal price decimal.Decimal fees decimal.Decimal @@ -45,7 +45,7 @@ func (r Record) AssetCountry() int64 { } func (r Record) Kind() internal.Kind { - return r.side + return r.kind } func (r Record) Quantity() decimal.Decimal { @@ -81,34 +81,26 @@ func NewRecordReader(r io.Reader, f *internal.OpenFIGI) *RecordReader { } const ( - MarketBuy = "market buy" - MarketSell = "market sell" - LimitBuy = "limit buy" - LimitSell = "limit sell" - StockSplitOpen = "Stock split open" - StockSplitClose = "Stock split close" + MarketBuy = "market buy" + MarketSell = "market sell" + LimitBuy = "limit buy" + LimitSell = "limit sell" + StockSplitOpen = "stock split open" + StockSplitClose = "stock split close" + StokDistribution = "stock distribution" ) func (rr RecordReader) ReadRecord(ctx context.Context) (internal.Record, error) { + var splitRec *splitRecord + for { raw, err := rr.reader.Read() if err != nil { return Record{}, fmt.Errorf("read record: %w", err) } - var side internal.Kind - switch strings.ToLower(raw[0]) { - case MarketBuy, LimitBuy: - side = internal.KindBuy - case MarketSell, LimitSell: - side = internal.KindSell - case StockSplitOpen, StockSplitClose: - // TODO: emit a special event that triggers a readjustment of unsold stock + if strings.ToLower(raw[0]) == "action" { continue - case "action": // TODO: this is the header, there's probably a better way to handle this - continue - default: - return Record{}, fmt.Errorf("parse record type: %s", raw[0]) } qant, err := parseDecimal(raw[6]) @@ -141,9 +133,50 @@ func (rr RecordReader) ReadRecord(ctx context.Context) (internal.Record, error) return Record{}, fmt.Errorf("parse record french transaction tax: %w", err) } + var kind internal.Kind + switch strings.ToLower(raw[0]) { + case MarketBuy, LimitBuy: + kind = internal.KindBuy + case MarketSell, LimitSell: + kind = internal.KindSell + case StockSplitOpen: + if splitRec != nil { + return nil, fmt.Errorf("split already open") + } + + splitRec = &splitRecord{ + Record: Record{ + symbol: raw[2], + kind: internal.KindSplit, + quantity: qant, + price: price, + fees: conversionFee, + taxes: stampDutyTax.Add(frenchTxTax), + timestamp: ts, + natureGetter: figiNatureGetter(ctx, rr.figi, raw[2]), + }, + } + + continue + + case StockSplitClose: + if splitRec == nil { + return nil, fmt.Errorf("missing split open") + } + splitRec.ratio = splitRec.Record.Quantity().Div(qant) + return splitRec, nil + + case StokDistribution: + slog.Warn("Found stock distribution but can't handle it") + continue + + default: + return Record{}, fmt.Errorf("parse record type: %s", raw[0]) + } + return Record{ symbol: raw[2], - side: side, + kind: kind, quantity: qant, price: price, fees: conversionFee, @@ -190,3 +223,13 @@ func parseOptionalDecimal(s string) (decimal.Decimal, error) { return parseDecimal(s) } + +type splitRecord struct { + Record + + ratio decimal.Decimal +} + +func (sr splitRecord) Quantity() decimal.Decimal { + return sr.ratio +} diff --git a/internal/trading212/record_test.go b/internal/trading212/record_test.go index 0969bae..9f55fbf 100644 --- a/internal/trading212/record_test.go +++ b/internal/trading212/record_test.go @@ -30,7 +30,7 @@ func TestRecordReader_ReadRecord(t *testing.T) { 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: "XX1234567890", - side: internal.KindBuy, + kind: internal.KindBuy, quantity: ShouldParseDecimal(t, "2.4387014200"), price: ShouldParseDecimal(t, "7.3690000000"), timestamp: time.Date(2025, 7, 3, 10, 44, 29, 0, time.UTC), @@ -44,7 +44,7 @@ func TestRecordReader_ReadRecord(t *testing.T) { 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: "XX1234567890", - side: internal.KindSell, + kind: internal.KindSell, quantity: ShouldParseDecimal(t, "2.4387014200"), price: ShouldParseDecimal(t, "7.9999999999"), timestamp: time.Date(2025, 8, 4, 11, 45, 30, 0, time.UTC), @@ -123,8 +123,8 @@ func TestRecordReader_ReadRecord(t *testing.T) { t.Fatalf("want symbol %v but got %v", tt.want.symbol, got.Symbol()) } - if got.Kind() != tt.want.side { - t.Fatalf("want side %v but got %v", tt.want.side, got.Kind()) + if got.Kind() != tt.want.kind { + t.Fatalf("want side %v but got %v", tt.want.kind, got.Kind()) } if got.Price().Cmp(tt.want.price) != 0 { @@ -154,6 +154,70 @@ func TestRecordReader_ReadRecord(t *testing.T) { } } +func TestRecordReader_ReadRecord_Split(t *testing.T) { + // open row has the NEW (post-split) position: more shares at lower price + // close row has the OLD (pre-split) position: fewer shares at higher price + // ratio = openQty / closeQty = 0.5 / 0.1 = 5 (a 5:1 split) + splitOpen := `Stock split open,2025-06-03 05:34:16,XX1234567890,ABXY,"Aspargus Broccoli",EOF111111111,0.5000000000,20.0000000000,EUR,1.00000000,,,10.00,"EUR",,,,,,` + splitClose := `Stock split close,2025-06-03 05:34:16,XX1234567890,ABXY,"Aspargus Broccoli",EOF222222222,0.1000000000,100.0000000000,EUR,1.00000000,0.00,"EUR",10.00,"EUR",,,,,,` + + t.Run("well-formed split pair returns split record with correct ratio", func(t *testing.T) { + rr := NewRecordReader( + bytes.NewBufferString(splitOpen+"\n"+splitClose), + NewFigiClientSecurityTypeStub(t, "Common Stock"), + ) + + got, err := rr.ReadRecord(t.Context()) + if err != nil { + t.Fatalf("ReadRecord() failed: %v", err) + } + + if got.Kind() != internal.KindSplit { + t.Errorf("want kind %v but got %v", internal.KindSplit, got.Kind()) + } + if got.Symbol() != "NO0013536151" { + t.Errorf("want symbol NO0013536151 but got %v", got.Symbol()) + } + + wantTimestamp := time.Date(2025, 6, 3, 5, 34, 16, 0, time.UTC) + if !got.Timestamp().Equal(wantTimestamp) { + t.Errorf("want timestamp %v but got %v", wantTimestamp, got.Timestamp()) + } + + // ratio = openQty / closeQty = 0.1245045 / 0.0249009 ≈ 5 + openQty := ShouldParseDecimal(t, "0.1245045000") + closeQty := ShouldParseDecimal(t, "0.0249009000") + wantRatio := openQty.Div(closeQty) + if !got.Quantity().Equal(wantRatio) { + t.Errorf("want ratio %v but got %v", wantRatio, got.Quantity()) + } + }) + + t.Run("close without prior open errors", func(t *testing.T) { + rr := NewRecordReader( + bytes.NewBufferString(splitClose), + NewFigiClientSecurityTypeStub(t, "Common Stock"), + ) + + _, err := rr.ReadRecord(t.Context()) + if err == nil { + t.Fatal("expected error but got none") + } + }) + + t.Run("two opens without close errors", func(t *testing.T) { + rr := NewRecordReader( + bytes.NewBufferString(splitOpen+"\n"+splitOpen), + NewFigiClientSecurityTypeStub(t, "Common Stock"), + ) + + _, err := rr.ReadRecord(t.Context()) + if err == nil { + t.Fatal("expected error but got none") + } + }) +} + func Test_figiNatureGetter(t *testing.T) { tests := []struct { name string // description of this test case From a4237aa00cd1ef607566c1de3734e082a57226cd Mon Sep 17 00:00:00 2001 From: Natercio Moniz Date: Sat, 16 May 2026 15:38:19 +0100 Subject: [PATCH 09/10] add a csv printer --- cmd/any2anexoj-cli/csv_writer.go | 57 ++++++++++++++++++++++++++++++++ cmd/any2anexoj-cli/main.go | 22 +++++++----- 2 files changed, 70 insertions(+), 9 deletions(-) create mode 100644 cmd/any2anexoj-cli/csv_writer.go diff --git a/cmd/any2anexoj-cli/csv_writer.go b/cmd/any2anexoj-cli/csv_writer.go new file mode 100644 index 0000000..9f31464 --- /dev/null +++ b/cmd/any2anexoj-cli/csv_writer.go @@ -0,0 +1,57 @@ +package main + +import ( + "encoding/csv" + "fmt" + "io" + + "github.com/nmoniz/any2anexoj/internal" +) + +type CSVWriter struct { + w *csv.Writer +} + +func NewCSVWriter(w io.Writer) *CSVWriter { + return &CSVWriter{w: csv.NewWriter(w)} +} + +func (cw *CSVWriter) Render(aw *internal.AggregatorWriter) error { + err := cw.w.Write([]string{ + "source_country", "code", + "realization_year", "realization_month", "realization_day", "realization_value", + "acquisition_year", "acquisition_month", "acquisition_day", "acquisition_value", + "expenses", "foreign_tax_paid", "counter_country", + }) + if err != nil { + return fmt.Errorf("write csv header: %w", err) + } + + for ri := range aw.Iter() { + err := cw.w.Write(reportItemToRow(ri)) + if err != nil { + return fmt.Errorf("write csv row: %w", err) + } + } + + cw.w.Flush() + return cw.w.Error() +} + +func reportItemToRow(ri internal.ReportItem) []string { + return []string{ + fmt.Sprintf("%d", ri.AssetCountry), + string(ri.Nature), + fmt.Sprintf("%d", ri.SellTimestamp.Year()), + fmt.Sprintf("%d", int(ri.SellTimestamp.Month())), + fmt.Sprintf("%d", ri.SellTimestamp.Day()), + ri.SellValue.StringFixed(2), + fmt.Sprintf("%d", ri.BuyTimestamp.Year()), + fmt.Sprintf("%d", int(ri.BuyTimestamp.Month())), + fmt.Sprintf("%d", ri.BuyTimestamp.Day()), + ri.BuyValue.StringFixed(2), + ri.Fees.StringFixed(2), + ri.Taxes.StringFixed(2), + fmt.Sprintf("%d", ri.BrokerCountry), + } +} diff --git a/cmd/any2anexoj-cli/main.go b/cmd/any2anexoj-cli/main.go index 68da9b9..c2887da 100644 --- a/cmd/any2anexoj-cli/main.go +++ b/cmd/any2anexoj-cli/main.go @@ -22,6 +22,7 @@ var ( platform = pflag.StringP("platform", "p", "trading212", "One of the supported platforms") lang = pflag.StringP("language", "l", language.Portuguese.String(), "The 2 letter language code") debug = pflag.BoolP("debug", "d", false, "Activate to log debug messages") + format = pflag.StringP("format", "f", "table", "Output format: table or csv") ofAPIKey = pflag.String("open-figi-api-key", "", "An OpenFIGI API key for faster report generation (better rate api rate limits)") // TODO: improve documentation on selectors selectors = pflag.StringSlice("selectors", nil, "Only process entries that conform to all the selectors:") @@ -80,16 +81,19 @@ func run(ctx context.Context) error { return err } - loc, err := NewLocalizer(*lang) - if err != nil { - return fmt.Errorf("create localizer: %w", err) + switch *format { + case "csv": + return NewCSVWriter(os.Stdout).Render(writer) + case "table": + loc, err := NewLocalizer(*lang) + if err != nil { + return fmt.Errorf("create localizer: %w", err) + } + NewPrettyPrinter(os.Stdout, loc).Render(writer) + return nil + default: + return fmt.Errorf("unsupported format %q: must be table or csv", *format) } - - printer := NewPrettyPrinter(os.Stdout, loc) - - printer.Render(writer) - - return nil } func getReader(platform string, ofAPIKey string) (internal.RecordReader, error) { From 22a0204fa2c4c71433eea843f12fbd0032f9ec12 Mon Sep 17 00:00:00 2001 From: Natercio Moniz Date: Sun, 17 May 2026 09:45:53 +0100 Subject: [PATCH 10/10] fix trading212 record test --- internal/trading212/record_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/trading212/record_test.go b/internal/trading212/record_test.go index 9f55fbf..d38d32b 100644 --- a/internal/trading212/record_test.go +++ b/internal/trading212/record_test.go @@ -158,7 +158,7 @@ func TestRecordReader_ReadRecord_Split(t *testing.T) { // open row has the NEW (post-split) position: more shares at lower price // close row has the OLD (pre-split) position: fewer shares at higher price // ratio = openQty / closeQty = 0.5 / 0.1 = 5 (a 5:1 split) - splitOpen := `Stock split open,2025-06-03 05:34:16,XX1234567890,ABXY,"Aspargus Broccoli",EOF111111111,0.5000000000,20.0000000000,EUR,1.00000000,,,10.00,"EUR",,,,,,` + splitOpen := `Stock split open,2025-06-03 05:34:16,XX1234567890,ABXY,"Aspargus Broccoli",EOF111111111,0.5000000000,20.0000000000,EUR,1.00000000,,,10.00,"EUR",,,,,,` splitClose := `Stock split close,2025-06-03 05:34:16,XX1234567890,ABXY,"Aspargus Broccoli",EOF222222222,0.1000000000,100.0000000000,EUR,1.00000000,0.00,"EUR",10.00,"EUR",,,,,,` t.Run("well-formed split pair returns split record with correct ratio", func(t *testing.T) { @@ -175,7 +175,7 @@ func TestRecordReader_ReadRecord_Split(t *testing.T) { if got.Kind() != internal.KindSplit { t.Errorf("want kind %v but got %v", internal.KindSplit, got.Kind()) } - if got.Symbol() != "NO0013536151" { + if got.Symbol() != "XX1234567890" { t.Errorf("want symbol NO0013536151 but got %v", got.Symbol()) }