rename side to kind

This commit is contained in:
2026-05-16 14:21:14 +01:00
parent d371aca767
commit 5cdccfcdb1
9 changed files with 146 additions and 142 deletions

29
internal/kind.go Normal file
View File

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

View File

@@ -5,12 +5,12 @@ import "testing"
func TestSide_String(t *testing.T) { func TestSide_String(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
side Side side Kind
want string want string
}{ }{
{"buy", SideBuy, "buy"}, {"buy", KindBuy, "buy"},
{"sell", SideSell, "sell"}, {"sell", KindSell, "sell"},
{"unknown", SideUnknown, "unknown"}, {"unknown", KindUnknown, "unknown"},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
@@ -24,16 +24,16 @@ func TestSide_String(t *testing.T) {
func TestSide_IsBuy(t *testing.T) { func TestSide_IsBuy(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
side Side side Kind
want bool want bool
}{ }{
{"buy", SideBuy, true}, {"buy", KindBuy, true},
{"sell", SideSell, false}, {"sell", KindSell, false},
{"unknown", SideUnknown, false}, {"unknown", KindUnknown, false},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { 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) 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) { func TestSide_IsSell(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
side Side side Kind
want bool want bool
}{ }{
{"buy", SideBuy, false}, {"buy", KindBuy, false},
{"sell", SideSell, true}, {"sell", KindSell, true},
{"unknown", SideUnknown, false}, {"unknown", KindUnknown, false},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { 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) t.Errorf("want Side.IsSell() to be %v but got %v", tt.want, got)
} }
}) })

View File

@@ -220,6 +220,44 @@ func (c *MockRecordFeesCall) DoAndReturn(f func() decimal.Decimal) *MockRecordFe
return c 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. // Nature mocks base method.
func (m *MockRecord) Nature() internal.Nature { func (m *MockRecord) Nature() internal.Nature {
m.ctrl.T.Helper() m.ctrl.T.Helper()
@@ -334,44 +372,6 @@ func (c *MockRecordQuantityCall) DoAndReturn(f func() decimal.Decimal) *MockReco
return c 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. // Symbol mocks base method.
func (m *MockRecord) Symbol() string { func (m *MockRecord) Symbol() string {
m.ctrl.T.Helper() m.ctrl.T.Helper()

View File

@@ -16,7 +16,7 @@ type Record interface {
Nature() Nature Nature() Nature
BrokerCountry() int64 BrokerCountry() int64
AssetCountry() int64 AssetCountry() int64
Side() Side Kind() Kind
Price() decimal.Decimal Price() decimal.Decimal
Quantity() decimal.Decimal Quantity() decimal.Decimal
Timestamp() time.Time Timestamp() time.Time
@@ -83,9 +83,9 @@ func BuildReport(ctx context.Context, reader RecordReader, writer ReportWriter,
return err return err
} }
if rec.Side().IsBuy() { if rec.Kind().Is(KindBuy) {
buysCount++ buysCount++
} else { } else if rec.Kind().Is(KindSell) {
sellsCount++ 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 // processRecord either adds buys to the queue or consumes buys from the queue when processing a
// sell record. // sell record.
// Selectors are only applied for sells for performance reasons. It's much cheaper to just accumulate // 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 // 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 { func processRecord(ctx context.Context, q *FillerQueue, rec Record, sel Selector, writer ReportWriter) error {
slog.Debug("Report: processing record", slog.Debug("Report: processing record",
slog.String("symbol", rec.Symbol()), slog.String("symbol", rec.Symbol()),
slog.String("side", rec.Side().String()), slog.String("side", rec.Kind().String()),
) )
switch rec.Side() { switch rec.Kind() {
case SideBuy: case KindBuy:
q.Push(NewFiller(rec)) q.Push(NewFiller(rec))
case SideSell: case KindSell:
if !sel(rec) { if !sel(rec) {
slog.Debug("Report: skipping record", slog.Debug("Report: skipping record",
slog.String("symbol", rec.Symbol()), slog.String("symbol", rec.Symbol()),
slog.String("side", rec.Side().String()), slog.String("side", rec.Kind().String()),
) )
return nil return nil
} }
@@ -169,7 +169,7 @@ func processRecord(ctx context.Context, q *FillerQueue, rec Record, sel Selector
} }
default: default:
return fmt.Errorf("unknown side: %v", rec.Side()) return fmt.Errorf("unknown side: %v", rec.Kind())
} }
return nil return nil

View File

@@ -20,8 +20,8 @@ func TestBuildReport(t *testing.T) {
reader := mocks.NewMockRecordReader(ctrl) reader := mocks.NewMockRecordReader(ctrl)
records := []internal.Record{ records := []internal.Record{
mockRecord(ctrl, 20.0, 10.0, internal.SideBuy, now), mockRecord(ctrl, 20.0, 10.0, internal.KindBuy, now),
mockRecord(ctrl, 25.0, 10.0, internal.SideSell, now.Add(1)), mockRecord(ctrl, 25.0, 10.0, internal.KindSell, now.Add(1)),
} }
reader.EXPECT().ReadRecord(gomock.Any()).DoAndReturn(func(ctx context.Context) (internal.Record, error) { reader.EXPECT().ReadRecord(gomock.Any()).DoAndReturn(func(ctx context.Context) (internal.Record, error) {
if len(records) > 0 { 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 := mocks.NewMockRecord(ctrl)
rec.EXPECT().Symbol().Return("TEST").AnyTimes() rec.EXPECT().Symbol().Return("TEST").AnyTimes()
rec.EXPECT().BrokerCountry().Return(int64(countries.PT)).AnyTimes() rec.EXPECT().BrokerCountry().Return(int64(countries.PT)).AnyTimes()
rec.EXPECT().AssetCountry().Return(int64(countries.USA)).AnyTimes() rec.EXPECT().AssetCountry().Return(int64(countries.USA)).AnyTimes()
rec.EXPECT().Price().Return(decimal.NewFromFloat(price)).AnyTimes() rec.EXPECT().Price().Return(decimal.NewFromFloat(price)).AnyTimes()
rec.EXPECT().Quantity().Return(decimal.NewFromFloat(quantity)).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().Timestamp().Return(ts).AnyTimes()
rec.EXPECT().Fees().Return(decimal.Decimal{}).AnyTimes() rec.EXPECT().Fees().Return(decimal.Decimal{}).AnyTimes()
rec.EXPECT().Taxes().Return(decimal.Decimal{}).AnyTimes() rec.EXPECT().Taxes().Return(decimal.Decimal{}).AnyTimes()

View File

@@ -9,28 +9,28 @@ import (
) )
type testRecord struct { type testRecord struct {
symbol string symbol string
nature internal.Nature nature internal.Nature
brokerCountry int64 brokerCountry int64
assetCountry int64 assetCountry int64
side internal.Side side internal.Kind
price decimal.Decimal price decimal.Decimal
quantity decimal.Decimal quantity decimal.Decimal
timestamp time.Time timestamp time.Time
fees decimal.Decimal fees decimal.Decimal
taxes decimal.Decimal taxes decimal.Decimal
} }
func (m testRecord) Symbol() string { return m.symbol } func (m testRecord) Symbol() string { return m.symbol }
func (m testRecord) Nature() internal.Nature { return m.nature } func (m testRecord) Nature() internal.Nature { return m.nature }
func (m testRecord) BrokerCountry() int64 { return m.brokerCountry } func (m testRecord) BrokerCountry() int64 { return m.brokerCountry }
func (m testRecord) AssetCountry() int64 { return m.assetCountry } func (m testRecord) AssetCountry() int64 { return m.assetCountry }
func (m testRecord) Side() internal.Side { return m.side } func (m testRecord) Kind() internal.Kind { return m.side }
func (m testRecord) Price() decimal.Decimal { return m.price } func (m testRecord) Price() decimal.Decimal { return m.price }
func (m testRecord) Quantity() decimal.Decimal { return m.quantity } func (m testRecord) Quantity() decimal.Decimal { return m.quantity }
func (m testRecord) Timestamp() time.Time { return m.timestamp } func (m testRecord) Timestamp() time.Time { return m.timestamp }
func (m testRecord) Fees() decimal.Decimal { return m.fees } func (m testRecord) Fees() decimal.Decimal { return m.fees }
func (m testRecord) Taxes() decimal.Decimal { return m.taxes } func (m testRecord) Taxes() decimal.Decimal { return m.taxes }
func TestAny(t *testing.T) { func TestAny(t *testing.T) {
selector := internal.Any() selector := internal.Any()
@@ -43,9 +43,9 @@ func TestAny(t *testing.T) {
{ {
name: "returns true for any record", name: "returns true for any record",
record: testRecord{ record: testRecord{
symbol: "AAPL", symbol: "AAPL",
nature: internal.NatureG01, nature: internal.NatureG01,
assetCountry: 1, assetCountry: 1,
}, },
want: true, want: true,
}, },
@@ -59,7 +59,7 @@ func TestAny(t *testing.T) {
want: true, want: true,
}, },
{ {
name: "returns true for empty record", name: "returns true for empty record",
record: testRecord{}, record: testRecord{},
want: true, want: true,
}, },
@@ -77,10 +77,10 @@ func TestAny(t *testing.T) {
func TestOnlyNature(t *testing.T) { func TestOnlyNature(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
nature internal.Nature nature internal.Nature
record internal.Record record internal.Record
want bool want bool
}{ }{
{ {
name: "matches G01 nature", name: "matches G01 nature",
@@ -133,10 +133,10 @@ func TestOnlyNature(t *testing.T) {
func TestOnlyAssetCountry(t *testing.T) { func TestOnlyAssetCountry(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
country int64 country int64
record internal.Record record internal.Record
want bool want bool
}{ }{
{ {
name: "matches asset country", name: "matches asset country",

View File

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

View File

@@ -18,7 +18,7 @@ import (
type Record struct { type Record struct {
symbol string symbol string
timestamp time.Time timestamp time.Time
side internal.Side side internal.Kind
quantity decimal.Decimal quantity decimal.Decimal
price decimal.Decimal price decimal.Decimal
fees decimal.Decimal fees decimal.Decimal
@@ -44,7 +44,7 @@ func (r Record) AssetCountry() int64 {
return int64(countries.ByName(r.Symbol()[:2]).Info().Code) return int64(countries.ByName(r.Symbol()[:2]).Info().Code)
} }
func (r Record) Side() internal.Side { func (r Record) Kind() internal.Kind {
return r.side return r.side
} }
@@ -81,10 +81,12 @@ func NewRecordReader(r io.Reader, f *internal.OpenFIGI) *RecordReader {
} }
const ( const (
MarketBuy = "market buy" MarketBuy = "market buy"
MarketSell = "market sell" MarketSell = "market sell"
LimitBuy = "limit buy" LimitBuy = "limit buy"
LimitSell = "limit sell" LimitSell = "limit sell"
StockSplitOpen = "Stock split open"
StockSplitClose = "Stock split close"
) )
func (rr RecordReader) ReadRecord(ctx context.Context) (internal.Record, error) { 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) return Record{}, fmt.Errorf("read record: %w", err)
} }
var side internal.Side var side internal.Kind
switch strings.ToLower(raw[0]) { switch strings.ToLower(raw[0]) {
case MarketBuy, LimitBuy: case MarketBuy, LimitBuy:
side = internal.SideBuy side = internal.KindBuy
case MarketSell, LimitSell: case MarketSell, LimitSell:
side = internal.SideSell side = internal.KindSell
case "action", "stock split open", "stock split close": 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 continue
default: default:
return Record{}, fmt.Errorf("parse record type: %s", raw[0]) return Record{}, fmt.Errorf("parse record type: %s", raw[0])

View File

@@ -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",,`), 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{ want: Record{
symbol: "XX1234567890", symbol: "XX1234567890",
side: internal.SideBuy, side: internal.KindBuy,
quantity: ShouldParseDecimal(t, "2.4387014200"), quantity: ShouldParseDecimal(t, "2.4387014200"),
price: ShouldParseDecimal(t, "7.3690000000"), price: ShouldParseDecimal(t, "7.3690000000"),
timestamp: time.Date(2025, 7, 3, 10, 44, 29, 0, time.UTC), 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"`), 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{ want: Record{
symbol: "XX1234567890", symbol: "XX1234567890",
side: internal.SideSell, side: internal.KindSell,
quantity: ShouldParseDecimal(t, "2.4387014200"), quantity: ShouldParseDecimal(t, "2.4387014200"),
price: ShouldParseDecimal(t, "7.9999999999"), price: ShouldParseDecimal(t, "7.9999999999"),
timestamp: time.Date(2025, 8, 4, 11, 45, 30, 0, time.UTC), 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()) t.Fatalf("want symbol %v but got %v", tt.want.symbol, got.Symbol())
} }
if got.Side() != tt.want.side { if got.Kind() != tt.want.side {
t.Fatalf("want side %v but got %v", tt.want.side, got.Side()) t.Fatalf("want side %v but got %v", tt.want.side, got.Kind())
} }
if got.Price().Cmp(tt.want.price) != 0 { if got.Price().Cmp(tt.want.price) != 0 {