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 {