Compare commits

..

2 Commits

Author SHA1 Message Date
7a38ae1696 implement reporter
also introduce gomock and tests
2025-11-11 16:35:05 +00:00
38113f21af add disclaimer to readme 2025-11-11 15:30:22 +00:00
11 changed files with 497 additions and 79 deletions

View File

@@ -1,6 +1,9 @@
# broker2anexoj
This tool converts the statements from brokers into a format compatible with the Portuguese IRS form: [Mod_3_anexo_j](https://info.portaldasfinancas.gov.pt/pt/apoio_contribuinte/modelos_formularios/irs/Documents/Mod_3_anexo_J.pdf)
This tool converts the statements from brokers and exchanges into a format compatible with the Portuguese IRS form: [Mod_3_anexo_j](https://info.portaldasfinancas.gov.pt/pt/apoio_contribuinte/modelos_formularios/irs/Documents/Mod_3_anexo_J.pdf)
> [!WARNING]
> Although I made significant efforts to ensure the correctness of the calculations you should verify any outputs produced by this tool on your own or with a certified accountant.
> [!NOTE]
> This tool is in early stages of development. Use at your own risk!

2
go.mod
View File

@@ -1,3 +1,5 @@
module git.naterciomoniz.net/applications/broker2anexoj
go 1.25.3
require go.uber.org/mock v0.6.0

2
go.sum Normal file
View File

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

3
internal/generate.go Normal file
View File

@@ -0,0 +1,3 @@
package internal
//go:generate mockgen -destination=mocks/mocks_gen.go -package=mocks -typed . RecordReader,Record

296
internal/mocks/mocks_gen.go Normal file
View File

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

View File

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

130
internal/reporter.go Normal file
View File

@@ -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")

42
internal/reporter_test.go Normal file
View File

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

View File

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

View File

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

72
main.go
View File

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