From 4686e365014b088ee314ba3033aec9ade48ecf93 Mon Sep 17 00:00:00 2001 From: Natercio Date: Mon, 10 Nov 2025 13:38:49 +0000 Subject: [PATCH] separate trading212 logic --- internal/direction.go | 28 ++++++ internal/direction_test.go | 60 ++++++++++++ internal/record.go | 18 ++++ internal/trading212/record.go | 108 +++++++++++++++++++++ internal/trading212/record_test.go | 145 +++++++++++++++++++++++++++++ 5 files changed, 359 insertions(+) create mode 100644 internal/direction.go create mode 100644 internal/direction_test.go create mode 100644 internal/record.go create mode 100644 internal/trading212/record.go create mode 100644 internal/trading212/record_test.go diff --git a/internal/direction.go b/internal/direction.go new file mode 100644 index 0000000..1d8f3ff --- /dev/null +++ b/internal/direction.go @@ -0,0 +1,28 @@ +package internal + +type Direction uint + +const ( + DirectionUnknown Direction = 0 + DirectionBuy Direction = 1 + DirectionSell Direction = 2 +) + +func (d Direction) String() string { + switch d { + case 1: + return "buy" + case 2: + return "sell" + default: + return "unknown" + } +} + +func (d Direction) IsBuy() bool { + return d == DirectionBuy +} + +func (d Direction) IsSell() bool { + return d == DirectionSell +} diff --git a/internal/direction_test.go b/internal/direction_test.go new file mode 100644 index 0000000..88c9071 --- /dev/null +++ b/internal/direction_test.go @@ -0,0 +1,60 @@ +package internal + +import "testing" + +func TestDirection_String(t *testing.T) { + tests := []struct { + name string + d Direction + want string + }{ + {"buy", DirectionBuy, "buy"}, + {"sell", DirectionSell, "sell"}, + {"unknown", DirectionUnknown, "unknown"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.d.String(); got != tt.want { + t.Errorf("want Direction.String() to be %v but got %v", tt.want, got) + } + }) + } +} + +func TestDirection_IsBuy(t *testing.T) { + tests := []struct { + name string + d Direction + want bool + }{ + {"buy", DirectionBuy, true}, + {"sell", DirectionSell, false}, + {"unknown", DirectionUnknown, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.d.IsBuy(); got != tt.want { + t.Errorf("want Direction.IsBuy() to be %v but got %v", tt.want, got) + } + }) + } +} + +func TestDirection_IsSell(t *testing.T) { + tests := []struct { + name string + d Direction + want bool + }{ + {"buy", DirectionBuy, false}, + {"sell", DirectionSell, true}, + {"unknown", DirectionUnknown, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.d.IsSell(); got != tt.want { + t.Errorf("want Direction.IsSell() to be %v but got %v", tt.want, got) + } + }) + } +} diff --git a/internal/record.go b/internal/record.go new file mode 100644 index 0000000..0f96de2 --- /dev/null +++ b/internal/record.go @@ -0,0 +1,18 @@ +package internal + +import ( + "math/big" + "time" +) + +type Record interface { + Symbol() string + Price() *big.Float + Quantity() *big.Float + Direction() Direction + Timestamp() time.Time +} + +type RecordReader interface { + ReadRecord() (Record, error) +} diff --git a/internal/trading212/record.go b/internal/trading212/record.go new file mode 100644 index 0000000..1f6b399 --- /dev/null +++ b/internal/trading212/record.go @@ -0,0 +1,108 @@ +package trading212 + +import ( + "encoding/csv" + "fmt" + "io" + "math/big" + "strings" + "time" + + "git.naterciomoniz.net/applications/broker2anexoj/internal" +) + +type Record struct { + symbol string + direction internal.Direction + quantity *big.Float + price *big.Float + timestamp time.Time +} + +func (r Record) Symbol() string { + return r.symbol +} + +func (r Record) Direction() internal.Direction { + return r.direction +} + +func (r Record) Quantity() *big.Float { + return r.quantity +} + +func (r Record) Price() *big.Float { + return r.price +} + +func (r Record) Timestamp() time.Time { + return r.timestamp +} + +type RecordReader struct { + reader *csv.Reader +} + +func NewRecordReader(r io.Reader) *RecordReader { + return &RecordReader{ + reader: csv.NewReader(r), + } +} + +const ( + MarketBuy = "market buy" + MarketSell = "market sell" + LimitBuy = "limit buy" + LimitSell = "limit sell" +) + +func (rr RecordReader) ReadRecord() (Record, error) { + for { + raw, err := rr.reader.Read() + if err != nil { + return Record{}, fmt.Errorf("read record: %w", err) + } + + var dir internal.Direction + switch strings.ToLower(raw[0]) { + case MarketBuy, LimitBuy: + dir = internal.DirectionBuy + case MarketSell, LimitSell: + dir = internal.DirectionSell + case "action", "stock split open", "stock split close": + continue + default: + return Record{}, fmt.Errorf("parse record type: %s", raw[0]) + } + + qant, err := parseDecimal(raw[6]) + if err != nil { + return Record{}, fmt.Errorf("parse record quantity: %w", err) + } + + price, err := parseDecimal(raw[7]) + if err != nil { + return Record{}, fmt.Errorf("parse record price: %w", err) + } + + ts, err := time.Parse(time.DateTime, raw[1]) + if err != nil { + return Record{}, fmt.Errorf("parse record timestamp: %w", err) + } + + return Record{ + symbol: raw[2], + direction: dir, + quantity: qant, + price: price, + timestamp: ts, + }, nil + } +} + +// 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) (*big.Float, error) { + f, _, err := big.ParseFloat(s, 10, 128, big.ToZero) + return f, err +} diff --git a/internal/trading212/record_test.go b/internal/trading212/record_test.go new file mode 100644 index 0000000..bd46b85 --- /dev/null +++ b/internal/trading212/record_test.go @@ -0,0 +1,145 @@ +package trading212 + +import ( + "bytes" + "io" + "math/big" + "testing" + "time" + + "git.naterciomoniz.net/applications/broker2anexoj/internal" +) + +func TestRecordReader_ReadRecord(t *testing.T) { + tests := []struct { + name string + r io.Reader + want Record + wantErr bool + }{ + { + name: "empty reader", + r: bytes.NewBufferString(""), + want: Record{}, + wantErr: true, + }, + { + name: "well formed buy", + r: bytes.NewBufferString(`Market buy,2025-07-03 10:44:29,SYM123456ABXY,ABXY,"Aspargus Brocoli",EOF987654321,2.4387014200,7.3690000000,USD,1.17995999,,"EUR",15.25,"EUR",,,0.02,"EUR",,`), + want: Record{ + symbol: "SYM123456ABXY", + direction: internal.DirectionBuy, + quantity: ShouldParseDecimal(t, "2.4387014200"), + price: ShouldParseDecimal(t, "7.3690000000"), + timestamp: time.Date(2025, 7, 3, 10, 44, 29, 0, time.UTC), + }, + wantErr: false, + }, + { + name: "well formed sell", + r: bytes.NewBufferString(`Market sell,2025-08-04 11:45:30,IE000GA3D489,ABXY,"Aspargus Brocoli",EOF987654321,2.4387014200,7.9999999999,USD,1.17995999,,"EUR",15.25,"EUR",,,0.02,"EUR",,`), + want: Record{ + symbol: "IE000GA3D489", + direction: internal.DirectionSell, + quantity: ShouldParseDecimal(t, "2.4387014200"), + price: ShouldParseDecimal(t, "7.9999999999"), + timestamp: time.Date(2025, 8, 4, 11, 45, 30, 0, time.UTC), + }, + wantErr: false, + }, + { + name: "malformed direction", + r: bytes.NewBufferString(`Aljksdaf Balsjdkf,2025-08-04 11:45:39,IE000GA3D489,ABXY,"Aspargus Brocoli",EOF987654321,2.4387014200,7.9999999999,USD,1.17995999,,"EUR",15.25,"EUR",,,0.02,"EUR",,`), + want: Record{}, + wantErr: true, + }, + { + name: "empty direction", + r: bytes.NewBufferString(`,2025-08-04 11:45:39,IE000GA3D489,ABXY,"Aspargus Brocoli",EOF987654321,0x1234,7.9999999999,USD,1.17995999,,"EUR",15.25,"EUR",,,0.02,"EUR",,`), + want: Record{}, + wantErr: true, + }, + { + name: "malformed qantity", + r: bytes.NewBufferString(`Market sell,2025-08-04 11:45:39,IE000GA3D489,ABXY,"Aspargus Brocoli",EOF987654321,0x1234,7.9999999999,USD,1.17995999,,"EUR",15.25,"EUR",,,0.02,"EUR",,`), + want: Record{}, + wantErr: true, + }, + { + name: "empty qantity", + r: bytes.NewBufferString(`Market sell,2025-08-04 11:45:39,IE000GA3D489,ABXY,"Aspargus Brocoli",EOF987654321,,7.9999999999,USD,1.17995999,,"EUR",15.25,"EUR",,,0.02,"EUR",,`), + want: Record{}, + wantErr: true, + }, + { + name: "malformed price", + r: bytes.NewBufferString(`Market sell,2025-08-04 11:45:39,IE000GA3D489,ABXY,"Aspargus Brocoli",EOF987654321,2.4387014200,0b101010,USD,1.17995999,,"EUR",15.25,"EUR",,,0.02,"EUR",,`), + want: Record{}, + wantErr: true, + }, + { + name: "empty price", + r: bytes.NewBufferString(`Market sell,2025-08-04 11:45:39,IE000GA3D489,ABXY,"Aspargus Brocoli",EOF987654321,2.4387014200,,USD,1.17995999,,"EUR",15.25,"EUR",,,0.02,"EUR",,`), + want: Record{}, + wantErr: true, + }, + { + name: "malformed timestamp", + r: bytes.NewBufferString(`Market sell,2006-01-02T15:04:05Z07:00,IE000GA3D489,ABXY,"Aspargus Brocoli",EOF987654321,2.4387014200,7.9999999999,USD,1.17995999,,"EUR",15.25,"EUR",,,0.02,"EUR",,`), + want: Record{}, + wantErr: true, + }, + { + name: "empty timestamp", + r: bytes.NewBufferString(`Market sell,,IE000GA3D489,ABXY,"Aspargus Brocoli",EOF987654321,2.4387014200,7.9999999999,USD,1.17995999,,"EUR",15.25,"EUR",,,0.02,"EUR",,`), + want: Record{}, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rr := NewRecordReader(tt.r) + got, gotErr := rr.ReadRecord() + if gotErr != nil { + if !tt.wantErr { + t.Fatalf("ReadRecord() failed: %v", gotErr) + } + return + } + + if tt.wantErr { + t.Fatalf("ReadRecord() expected an error") + } + + if got.symbol != tt.want.symbol { + t.Fatalf("want symbol %v but got %v", tt.want.symbol, got.symbol) + } + + if got.direction != tt.want.direction { + t.Fatalf("want direction %v but got %v", tt.want.direction, got.direction) + } + + if got.price.Cmp(tt.want.price) != 0 { + t.Fatalf("want price %v but got %v", tt.want.price, got.price) + } + + if got.quantity.Cmp(tt.want.quantity) != 0 { + t.Fatalf("want quantity %v but got %v", tt.want.quantity, got.quantity) + } + + if !got.timestamp.Equal(tt.want.timestamp) { + t.Fatalf("want timestamp %v but got %v", tt.want.timestamp, got.timestamp) + } + }) + } +} + +func ShouldParseDecimal(t testing.TB, sf string) *big.Float { + t.Helper() + + bf, err := parseDecimal(sf) + if err != nil { + t.Fatalf("parsing decimal: %s", sf) + } + return bf +}