diff --git a/go.mod b/go.mod index aa4eede..eb9e8be 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,5 @@ module git.naterciomoniz.net/applications/broker2anexoj go 1.25.3 + +require go.uber.org/mock v0.6.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..3a696b9 --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= diff --git a/internal/generate.go b/internal/generate.go new file mode 100644 index 0000000..9e087fd --- /dev/null +++ b/internal/generate.go @@ -0,0 +1,3 @@ +package internal + +//go:generate mockgen -destination=mocks/mocks_gen.go -package=mocks -typed . RecordReader,Record diff --git a/internal/mocks/mocks_gen.go b/internal/mocks/mocks_gen.go new file mode 100644 index 0000000..04325d7 --- /dev/null +++ b/internal/mocks/mocks_gen.go @@ -0,0 +1,296 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: git.naterciomoniz.net/applications/broker2anexoj/internal (interfaces: RecordReader,Record) +// +// Generated by this command: +// +// mockgen -destination=mocks/mocks_gen.go -package=mocks -typed . RecordReader,Record +// + +// Package mocks is a generated GoMock package. +package mocks + +import ( + big "math/big" + reflect "reflect" + time "time" + + internal "git.naterciomoniz.net/applications/broker2anexoj/internal" + gomock "go.uber.org/mock/gomock" +) + +// MockRecordReader is a mock of RecordReader interface. +type MockRecordReader struct { + ctrl *gomock.Controller + recorder *MockRecordReaderMockRecorder + isgomock struct{} +} + +// MockRecordReaderMockRecorder is the mock recorder for MockRecordReader. +type MockRecordReaderMockRecorder struct { + mock *MockRecordReader +} + +// NewMockRecordReader creates a new mock instance. +func NewMockRecordReader(ctrl *gomock.Controller) *MockRecordReader { + mock := &MockRecordReader{ctrl: ctrl} + mock.recorder = &MockRecordReaderMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockRecordReader) EXPECT() *MockRecordReaderMockRecorder { + return m.recorder +} + +// ReadRecord mocks base method. +func (m *MockRecordReader) ReadRecord() (internal.Record, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ReadRecord") + ret0, _ := ret[0].(internal.Record) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ReadRecord indicates an expected call of ReadRecord. +func (mr *MockRecordReaderMockRecorder) ReadRecord() *MockRecordReaderReadRecordCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReadRecord", reflect.TypeOf((*MockRecordReader)(nil).ReadRecord)) + return &MockRecordReaderReadRecordCall{Call: call} +} + +// MockRecordReaderReadRecordCall wrap *gomock.Call +type MockRecordReaderReadRecordCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockRecordReaderReadRecordCall) Return(arg0 internal.Record, arg1 error) *MockRecordReaderReadRecordCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockRecordReaderReadRecordCall) Do(f func() (internal.Record, error)) *MockRecordReaderReadRecordCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockRecordReaderReadRecordCall) DoAndReturn(f func() (internal.Record, error)) *MockRecordReaderReadRecordCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// MockRecord is a mock of Record interface. +type MockRecord struct { + ctrl *gomock.Controller + recorder *MockRecordMockRecorder + isgomock struct{} +} + +// MockRecordMockRecorder is the mock recorder for MockRecord. +type MockRecordMockRecorder struct { + mock *MockRecord +} + +// NewMockRecord creates a new mock instance. +func NewMockRecord(ctrl *gomock.Controller) *MockRecord { + mock := &MockRecord{ctrl: ctrl} + mock.recorder = &MockRecordMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockRecord) EXPECT() *MockRecordMockRecorder { + return m.recorder +} + +// Price mocks base method. +func (m *MockRecord) Price() *big.Float { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Price") + ret0, _ := ret[0].(*big.Float) + return ret0 +} + +// Price indicates an expected call of Price. +func (mr *MockRecordMockRecorder) Price() *MockRecordPriceCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Price", reflect.TypeOf((*MockRecord)(nil).Price)) + return &MockRecordPriceCall{Call: call} +} + +// MockRecordPriceCall wrap *gomock.Call +type MockRecordPriceCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockRecordPriceCall) Return(arg0 *big.Float) *MockRecordPriceCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockRecordPriceCall) Do(f func() *big.Float) *MockRecordPriceCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockRecordPriceCall) DoAndReturn(f func() *big.Float) *MockRecordPriceCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// Quantity mocks base method. +func (m *MockRecord) Quantity() *big.Float { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Quantity") + ret0, _ := ret[0].(*big.Float) + return ret0 +} + +// Quantity indicates an expected call of Quantity. +func (mr *MockRecordMockRecorder) Quantity() *MockRecordQuantityCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Quantity", reflect.TypeOf((*MockRecord)(nil).Quantity)) + return &MockRecordQuantityCall{Call: call} +} + +// MockRecordQuantityCall wrap *gomock.Call +type MockRecordQuantityCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockRecordQuantityCall) Return(arg0 *big.Float) *MockRecordQuantityCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockRecordQuantityCall) Do(f func() *big.Float) *MockRecordQuantityCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockRecordQuantityCall) DoAndReturn(f func() *big.Float) *MockRecordQuantityCall { + c.Call = c.Call.DoAndReturn(f) + 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() + ret := m.ctrl.Call(m, "Symbol") + ret0, _ := ret[0].(string) + return ret0 +} + +// Symbol indicates an expected call of Symbol. +func (mr *MockRecordMockRecorder) Symbol() *MockRecordSymbolCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Symbol", reflect.TypeOf((*MockRecord)(nil).Symbol)) + return &MockRecordSymbolCall{Call: call} +} + +// MockRecordSymbolCall wrap *gomock.Call +type MockRecordSymbolCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockRecordSymbolCall) Return(arg0 string) *MockRecordSymbolCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockRecordSymbolCall) Do(f func() string) *MockRecordSymbolCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockRecordSymbolCall) DoAndReturn(f func() string) *MockRecordSymbolCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// Timestamp mocks base method. +func (m *MockRecord) Timestamp() time.Time { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Timestamp") + ret0, _ := ret[0].(time.Time) + return ret0 +} + +// Timestamp indicates an expected call of Timestamp. +func (mr *MockRecordMockRecorder) Timestamp() *MockRecordTimestampCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Timestamp", reflect.TypeOf((*MockRecord)(nil).Timestamp)) + return &MockRecordTimestampCall{Call: call} +} + +// MockRecordTimestampCall wrap *gomock.Call +type MockRecordTimestampCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockRecordTimestampCall) Return(arg0 time.Time) *MockRecordTimestampCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockRecordTimestampCall) Do(f func() time.Time) *MockRecordTimestampCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockRecordTimestampCall) DoAndReturn(f func() time.Time) *MockRecordTimestampCall { + c.Call = c.Call.DoAndReturn(f) + return c +} diff --git a/internal/record.go b/internal/record.go index dcbecce..68b2301 100644 --- a/internal/record.go +++ b/internal/record.go @@ -36,7 +36,7 @@ func (rq *RecordQueue) Push(r Record) { rq.l.PushBack(r) } -// Pop removes and returns the first element of the list as the first return value. If the list is +// Pop removes and returns the first Record of the list as the first return value. If the list is // empty returns falso on the 2nd return value, true otherwise. func (rq *RecordQueue) Pop() (Record, bool) { if rq == nil || rq.l == nil { diff --git a/internal/reporter.go b/internal/reporter.go new file mode 100644 index 0000000..1004b17 --- /dev/null +++ b/internal/reporter.go @@ -0,0 +1,130 @@ +package internal + +import ( + "errors" + "fmt" + "io" + "log/slog" + "math/big" + "sync" +) + +type RecordReader interface { + // ReadRecord should return Records until an error is found. + ReadRecord() (Record, error) +} + +// Reporter consumes each record to produce ReportItem. +type Reporter struct { + reader RecordReader +} + +func NewReporter(rr RecordReader) *Reporter { + return &Reporter{ + reader: rr, + } +} + +func (r *Reporter) Run() error { + forewarders := make(map[string]chan Record) + + aggregator := make(chan processResult) + defer close(aggregator) + + go func() { + for result := range aggregator { + fmt.Printf("%v\n", result) + } + }() + + wg := sync.WaitGroup{} + defer func() { + wg.Wait() + }() + + for { + rec, err := r.reader.ReadRecord() + if err != nil { + if errors.Is(err, io.EOF) { + return nil + } + return err + } + + router, ok := forewarders[rec.Symbol()] + if !ok { + router = make(chan Record, 1) + defer close(router) + + wg.Go(func() { + processRecords(router, aggregator) + }) + + forewarders[rec.Symbol()] = router + } + + router <- rec + } +} + +func processRecords(records <-chan Record, results chan<- processResult) { + var q RecordQueue + + for rec := range records { + switch rec.Side() { + case SideBuy: + q.Push(rec) + + case SideSell: + unmatchedQty := new(big.Float).Copy(rec.Quantity()) + zero := new(big.Float) + + for unmatchedQty.Cmp(zero) > 0 { + buy, ok := q.Pop() + if !ok { + results <- processResult{ + err: ErrSellWithoutBuy, + } + return + } + + var matchedQty *big.Float + if buy.Quantity().Cmp(unmatchedQty) > 0 { + matchedQty = unmatchedQty + buy.Quantity().Sub(buy.Quantity(), unmatchedQty) + } else { + matchedQty = buy.Quantity() + } + + unmatchedQty.Sub(unmatchedQty, matchedQty) + + sellValue := new(big.Float).Mul(matchedQty, rec.Price()) + buyValue := new(big.Float).Mul(matchedQty, buy.Price()) + realisedPnL := new(big.Float).Sub(sellValue, buyValue) + slog.Info("Realised PnL", + slog.Any("Symbol", rec.Symbol()), + slog.Any("PnL", realisedPnL), + slog.Any("Timestamp", rec.Timestamp())) + + results <- processResult{ + item: ReportItem{}, + } + } + + default: + results <- processResult{ + err: fmt.Errorf("unknown side: %v", rec.Side()), + } + return + } + } +} + +type processResult struct { + item ReportItem + err error +} + +type ReportItem struct{} + +var ErrSellWithoutBuy = fmt.Errorf("found sell without bought volume") diff --git a/internal/reporter_test.go b/internal/reporter_test.go new file mode 100644 index 0000000..8d5be16 --- /dev/null +++ b/internal/reporter_test.go @@ -0,0 +1,42 @@ +package internal_test + +import ( + "io" + "math/big" + "testing" + + "git.naterciomoniz.net/applications/broker2anexoj/internal" + "git.naterciomoniz.net/applications/broker2anexoj/internal/mocks" + "go.uber.org/mock/gomock" +) + +func TestReporter_Run(t *testing.T) { + ctrl := gomock.NewController(t) + + rec := mocks.NewMockRecord(ctrl) + rec.EXPECT().Price().Return(big.NewFloat(1.25)).AnyTimes() + rec.EXPECT().Quantity().Return(big.NewFloat(10)).AnyTimes() + rec.EXPECT().Side().Return(internal.SideBuy).AnyTimes() + rec.EXPECT().Symbol().Return("TEST").AnyTimes() + + reader := mocks.NewMockRecordReader(ctrl) + records := []internal.Record{ + rec, + rec, + } + reader.EXPECT().ReadRecord().DoAndReturn(func() (internal.Record, error) { + if len(records) > 0 { + r := records[0] + records = records[1:] + return r, nil + } else { + return nil, io.EOF + } + }).AnyTimes() + + reporter := internal.NewReporter(reader) + gotErr := reporter.Run() + if gotErr != nil { + t.Fatalf("got unexpected err: %v", gotErr) + } +} diff --git a/internal/trading212/record.go b/internal/trading212/record.go index ff70cca..4fa598d 100644 --- a/internal/trading212/record.go +++ b/internal/trading212/record.go @@ -56,7 +56,7 @@ const ( LimitSell = "limit sell" ) -func (rr RecordReader) ReadRecord() (Record, error) { +func (rr RecordReader) ReadRecord() (internal.Record, error) { for { raw, err := rr.reader.Read() if err != nil { diff --git a/internal/trading212/record_test.go b/internal/trading212/record_test.go index eeabc4f..d1b0f1c 100644 --- a/internal/trading212/record_test.go +++ b/internal/trading212/record_test.go @@ -111,24 +111,24 @@ func TestRecordReader_ReadRecord(t *testing.T) { 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.Symbol() != tt.want.symbol { + 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.Side() != tt.want.side { + t.Fatalf("want side %v but got %v", tt.want.side, got.Side()) } - if got.price.Cmp(tt.want.price) != 0 { - t.Fatalf("want price %v but got %v", tt.want.price, got.price) + 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.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) + if !got.Timestamp().Equal(tt.want.timestamp) { + t.Fatalf("want timestamp %v but got %v", tt.want.timestamp, got.Timestamp()) } }) } diff --git a/main.go b/main.go index 4c69233..bc30e9b 100644 --- a/main.go +++ b/main.go @@ -1,12 +1,8 @@ package main import ( - "container/list" - "errors" "fmt" - "io" "log/slog" - "math/big" "os" "git.naterciomoniz.net/applications/broker2anexoj/internal" @@ -26,72 +22,16 @@ func run() error { return fmt.Errorf("open statement: %w", err) } - r := trading212.NewRecordReader(f) + reader := trading212.NewRecordReader(f) - assets := make(map[string]*list.List) - for { - record, err := r.ReadRecord() - if err != nil { - if errors.Is(err, io.EOF) { - break - } - return fmt.Errorf("read statement record: %w", err) - } + reporter := internal.NewReporter(reader) - switch record.Side() { - case internal.SideBuy: - lst, ok := assets[record.Symbol()] - if !ok { - lst = list.New() - assets[record.Symbol()] = lst - } - lst.PushBack(record) - - case internal.SideSell: - lst, ok := assets[record.Symbol()] - if !ok { - return ErrSellWithoutBuy - } - - unmatchedQty := new(big.Float).Copy(record.Quantity()) - zero := new(big.Float) - - for unmatchedQty.Cmp(zero) > 0 { - front := lst.Front() - if front == nil { - return ErrSellWithoutBuy - } - - next, ok := front.Value.(internal.Record) - if !ok { - return fmt.Errorf("unexpected record type: %T", front) - } - - var matchedQty *big.Float - if next.Quantity().Cmp(unmatchedQty) > 0 { - matchedQty = unmatchedQty - next.Quantity().Sub(next.Quantity(), unmatchedQty) - } else { - matchedQty = next.Quantity() - lst.Remove(front) - } - - unmatchedQty.Sub(unmatchedQty, matchedQty) - - sellValue := new(big.Float).Mul(matchedQty, record.Price()) - buyValue := new(big.Float).Mul(matchedQty, next.Price()) - realisedPnL := new(big.Float).Sub(sellValue, buyValue) - slog.Info("Realised PnL", - slog.Any("Symbol", record.Symbol()), - slog.Any("PnL", realisedPnL)) - } - - default: - return fmt.Errorf("unknown side: %s", record.Side()) - } + err = reporter.Run() + if err != nil { + return err } - slog.Info("Finish processing statement", slog.Any("assets_count", len(assets))) + slog.Info("Finish processing statement") return nil }