print a pretty table with correct country coder

This commit is contained in:
2025-11-18 16:07:41 +00:00
parent 05a981b3a0
commit d097b01288
13 changed files with 318 additions and 160 deletions

View File

@@ -51,7 +51,7 @@ func run(ctx context.Context, platform string) error {
reader := factory()
writer := internal.NewStdOutLogger()
writer := internal.NewTableWriter(os.Stdout)
eg.Go(func() error {
return internal.BuildReport(ctx, reader, writer)
@@ -62,7 +62,7 @@ func run(ctx context.Context, platform string) error {
return err
}
slog.Info("Finish processing statement")
writer.Render()
return nil
}

6
go.mod
View File

@@ -5,12 +5,18 @@ go 1.25.3
require (
go.uber.org/mock v0.6.0
golang.org/x/sync v0.18.0
github.com/biter777/countries v1.7.5
)
require (
github.com/jedib0t/go-pretty/v6 v6.7.2 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/shopspring/decimal v1.4.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
golang.org/x/mod v0.27.0 // indirect
golang.org/x/sys v0.35.0 // indirect
golang.org/x/text v0.22.0 // indirect
golang.org/x/tools v0.36.0 // indirect
)

14
go.sum
View File

@@ -1,21 +1,35 @@
github.com/biter777/countries v1.7.5 h1:MJ+n3+rSxWQdqVJU8eBy9RqcdH6ePPn4PJHocVWUa+Q=
github.com/biter777/countries v1.7.5/go.mod h1:1HSpZ526mYqKJcpT5Ti1kcGQ0L0SrXWIaptUWjFfv2E=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/jedib0t/go-pretty/v6 v6.7.2 h1:EYWgQNIH/+JsyHki7ns9OHyBKuHPkzrBo02uYjran7w=
github.com/jedib0t/go-pretty/v6 v6.7.2/go.mod h1:YwC5CE4fJ1HFUDeivSV1r//AmANFHyqczZk+U6BDALU=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

View File

@@ -106,6 +106,82 @@ func (m *MockRecord) EXPECT() *MockRecordMockRecorder {
return m.recorder
}
// AssetCountry mocks base method.
func (m *MockRecord) AssetCountry() int64 {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "AssetCountry")
ret0, _ := ret[0].(int64)
return ret0
}
// AssetCountry indicates an expected call of AssetCountry.
func (mr *MockRecordMockRecorder) AssetCountry() *MockRecordAssetCountryCall {
mr.mock.ctrl.T.Helper()
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AssetCountry", reflect.TypeOf((*MockRecord)(nil).AssetCountry))
return &MockRecordAssetCountryCall{Call: call}
}
// MockRecordAssetCountryCall wrap *gomock.Call
type MockRecordAssetCountryCall struct {
*gomock.Call
}
// Return rewrite *gomock.Call.Return
func (c *MockRecordAssetCountryCall) Return(arg0 int64) *MockRecordAssetCountryCall {
c.Call = c.Call.Return(arg0)
return c
}
// Do rewrite *gomock.Call.Do
func (c *MockRecordAssetCountryCall) Do(f func() int64) *MockRecordAssetCountryCall {
c.Call = c.Call.Do(f)
return c
}
// DoAndReturn rewrite *gomock.Call.DoAndReturn
func (c *MockRecordAssetCountryCall) DoAndReturn(f func() int64) *MockRecordAssetCountryCall {
c.Call = c.Call.DoAndReturn(f)
return c
}
// BrokerCountry mocks base method.
func (m *MockRecord) BrokerCountry() int64 {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "BrokerCountry")
ret0, _ := ret[0].(int64)
return ret0
}
// BrokerCountry indicates an expected call of BrokerCountry.
func (mr *MockRecordMockRecorder) BrokerCountry() *MockRecordBrokerCountryCall {
mr.mock.ctrl.T.Helper()
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BrokerCountry", reflect.TypeOf((*MockRecord)(nil).BrokerCountry))
return &MockRecordBrokerCountryCall{Call: call}
}
// MockRecordBrokerCountryCall wrap *gomock.Call
type MockRecordBrokerCountryCall struct {
*gomock.Call
}
// Return rewrite *gomock.Call.Return
func (c *MockRecordBrokerCountryCall) Return(arg0 int64) *MockRecordBrokerCountryCall {
c.Call = c.Call.Return(arg0)
return c
}
// Do rewrite *gomock.Call.Do
func (c *MockRecordBrokerCountryCall) Do(f func() int64) *MockRecordBrokerCountryCall {
c.Call = c.Call.Do(f)
return c
}
// DoAndReturn rewrite *gomock.Call.DoAndReturn
func (c *MockRecordBrokerCountryCall) DoAndReturn(f func() int64) *MockRecordBrokerCountryCall {
c.Call = c.Call.DoAndReturn(f)
return c
}
// Fees mocks base method.
func (m *MockRecord) Fees() decimal.Decimal {
m.ctrl.T.Helper()

View File

@@ -1,17 +0,0 @@
package internal
import (
"time"
"github.com/shopspring/decimal"
)
type Record interface {
Symbol() string
Side() Side
Price() decimal.Decimal
Quantity() decimal.Decimal
Timestamp() time.Time
Fees() decimal.Decimal
Taxes() decimal.Decimal
}

View File

@@ -10,11 +10,39 @@ import (
"github.com/shopspring/decimal"
)
type Record interface {
Symbol() string
BrokerCountry() int64
AssetCountry() int64
Side() Side
Price() decimal.Decimal
Quantity() decimal.Decimal
Timestamp() time.Time
Fees() decimal.Decimal
Taxes() decimal.Decimal
}
type RecordReader interface {
// ReadRecord should return Records until an error is found.
ReadRecord(context.Context) (Record, error)
}
type ReportItem struct {
Symbol string
BrokerCountry int64
AssetCountry int64
BuyValue decimal.Decimal
BuyTimestamp time.Time
SellValue decimal.Decimal
SellTimestamp time.Time
Fees decimal.Decimal
Taxes decimal.Decimal
}
func (ri ReportItem) RealisedPnL() decimal.Decimal {
return ri.SellValue.Sub(ri.BuyValue)
}
type ReportWriter interface {
// ReportWriter writes report items
Write(context.Context, ReportItem) error
@@ -79,6 +107,9 @@ func processRecord(ctx context.Context, q *FillerQueue, rec Record, writer Repor
sellValue := matchedQty.Mul(rec.Price())
err := writer.Write(ctx, ReportItem{
Symbol: rec.Symbol(),
BrokerCountry: rec.BrokerCountry(),
AssetCountry: rec.AssetCountry(),
BuyValue: buyValue,
BuyTimestamp: buy.Timestamp(),
SellValue: sellValue,
@@ -97,16 +128,3 @@ func processRecord(ctx context.Context, q *FillerQueue, rec Record, writer Repor
return nil
}
type ReportItem struct {
BuyValue decimal.Decimal
BuyTimestamp time.Time
SellValue decimal.Decimal
SellTimestamp time.Time
Fees decimal.Decimal
Taxes decimal.Decimal
}
func (ri ReportItem) RealisedPnL() decimal.Decimal {
return ri.SellValue.Sub(ri.BuyValue)
}

View File

@@ -7,6 +7,7 @@ import (
"testing"
"time"
"github.com/biter777/countries"
"github.com/nmoniz/any2anexoj/internal"
"github.com/nmoniz/any2anexoj/internal/mocks"
"github.com/shopspring/decimal"
@@ -50,10 +51,12 @@ func TestBuildReport(t *testing.T) {
func mockRecord(ctrl *gomock.Controller, price, quantity float64, side internal.Side, 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().Symbol().Return("TEST").AnyTimes()
rec.EXPECT().Timestamp().Return(ts).AnyTimes()
rec.EXPECT().Fees().Return(decimal.Decimal{}).AnyTimes()
rec.EXPECT().Taxes().Return(decimal.Decimal{}).AnyTimes()

View File

@@ -1,34 +0,0 @@
package internal
import (
"context"
"fmt"
"io"
"os"
"time"
)
// ReportLogger writes a simple, human readable, line to the provided io.Writer for each
// ReportItem received.
type ReportLogger struct {
counter int
writer io.Writer
}
func NewStdOutLogger() *ReportLogger {
return &ReportLogger{
writer: os.Stdout,
}
}
func NewReportLogger(w io.Writer) *ReportLogger {
return &ReportLogger{
writer: w,
}
}
func (rl *ReportLogger) Write(_ context.Context, ri ReportItem) error {
rl.counter++
_, err := fmt.Fprintf(rl.writer, "%6d: realised %s on %s\n", rl.counter, ri.RealisedPnL().String(), ri.SellTimestamp.Format(time.RFC3339))
return err
}

View File

@@ -1,93 +0,0 @@
package internal_test
import (
"bytes"
"fmt"
"testing"
"time"
"github.com/nmoniz/any2anexoj/internal"
"github.com/shopspring/decimal"
)
func TestReportLogger_Write(t *testing.T) {
tNow := time.Now()
tests := []struct {
name string
items []internal.ReportItem
want []string
}{
{
name: "empty",
},
{
name: "single item positive",
items: []internal.ReportItem{
{
BuyValue: decimal.NewFromFloat(100.0),
SellValue: decimal.NewFromFloat(200.0),
SellTimestamp: tNow,
},
},
want: []string{
fmt.Sprintf("%6d: realised 100 on %s\n", 1, tNow.Format(time.RFC3339)),
},
},
{
name: "single item negative",
items: []internal.ReportItem{
{
BuyValue: decimal.NewFromFloat(200.0),
SellValue: decimal.NewFromFloat(150.0),
SellTimestamp: tNow,
},
},
want: []string{
fmt.Sprintf("%6d: realised -50 on %s\n", 1, tNow.Format(time.RFC3339)),
},
},
{
name: "multiple items",
items: []internal.ReportItem{
{
BuyValue: decimal.NewFromFloat(100.0),
SellValue: decimal.NewFromFloat(200.0),
SellTimestamp: tNow,
},
{
BuyValue: decimal.NewFromFloat(200.0),
SellValue: decimal.NewFromFloat(150.0),
SellTimestamp: tNow.Add(1),
},
},
want: []string{
fmt.Sprintf("%6d: realised 100 on %s\n", 1, tNow.Format(time.RFC3339)),
fmt.Sprintf("%6d: realised -50 on %s\n", 2, tNow.Add(1).Format(time.RFC3339)),
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
buf := new(bytes.Buffer)
rw := internal.NewReportLogger(buf)
for _, item := range tt.items {
err := rw.Write(t.Context(), item)
if err != nil {
t.Fatalf("unexpected error on write: %v", err)
}
}
for _, wantLine := range tt.want {
gotLine, err := buf.ReadString(byte('\n'))
if err != nil {
t.Fatalf("unexpected error on buffer read: %v", err)
}
if wantLine != gotLine {
t.Fatalf("want line %q but got %q", wantLine, gotLine)
}
}
})
}
}

53
internal/table_writer.go Normal file
View File

@@ -0,0 +1,53 @@
package internal
import (
"context"
"io"
"github.com/jedib0t/go-pretty/v6/table"
"github.com/jedib0t/go-pretty/v6/text"
"github.com/shopspring/decimal"
)
// TableWriter writes a simple, human readable, table row to the provided io.Writer for each
// ReportItem received.
type TableWriter struct {
table table.Writer
output io.Writer
totalEarned decimal.Decimal
totalSpent decimal.Decimal
totalFees decimal.Decimal
totalTaxes decimal.Decimal
}
func NewTableWriter(w io.Writer) *TableWriter {
t := table.NewWriter()
t.SetOutputMirror(w)
t.SetAutoIndex(true)
t.SetStyle(table.StyleLight)
t.AppendHeader(table.Row{"", "", "Realisation", "Realisation", "Realisation", "Realisation", "Aquisition", "Aquisition", "Aquisition", "Aquisition", "", "", ""}, table.RowConfig{AutoMerge: true})
t.AppendHeader(table.Row{"Source Country", "Code", "Year", "Month", "Day", "Value", "Year", "Month", "Day", "Value", "Expenses", "Payed Taxes", "Counter Country"})
return &TableWriter{
table: t,
output: w,
}
}
func (tw *TableWriter) Write(_ context.Context, ri ReportItem) error {
tw.totalEarned = tw.totalEarned.Add(ri.SellValue)
tw.totalSpent = tw.totalSpent.Add(ri.BuyValue)
tw.totalFees = tw.totalFees.Add(ri.Fees)
tw.totalTaxes = tw.totalTaxes.Add(ri.Taxes)
tw.table.AppendRow(table.Row{ri.AssetCountry, ri.Symbol, ri.SellTimestamp.Year(), int(ri.SellTimestamp.Month()), ri.SellTimestamp.Day(), ri.SellValue, ri.BuyTimestamp.Year(), ri.BuyTimestamp.Month(), ri.BuyTimestamp.Day(), ri.BuyValue, ri.Fees, ri.Taxes, ri.BrokerCountry})
return nil
}
func (tw *TableWriter) Render() {
tw.table.AppendFooter(table.Row{"SUM", "SUM", "SUM", "SUM", "SUM", tw.totalEarned, "", "", "", tw.totalSpent, tw.totalFees, tw.totalTaxes}, table.RowConfig{AutoMerge: true, AutoMergeAlign: text.AlignRight})
tw.table.Render()
}

View File

@@ -0,0 +1,116 @@
package internal
import (
"bytes"
"testing"
"time"
"github.com/shopspring/decimal"
)
func TestTableWriter_Write(t *testing.T) {
tNow := time.Now()
tests := []struct {
name string
items []ReportItem
wantTotalSpent decimal.Decimal
wantTotalEarned decimal.Decimal
wantTotalTaxes decimal.Decimal
wantTotalFees decimal.Decimal
}{
{
name: "empty",
},
{
name: "single item positive",
items: []ReportItem{
{
BuyValue: decimal.NewFromFloat(100.0),
SellValue: decimal.NewFromFloat(200.0),
SellTimestamp: tNow,
Taxes: decimal.NewFromFloat(2.5),
Fees: decimal.NewFromFloat(2.5),
},
},
wantTotalSpent: decimal.NewFromFloat(100.0),
wantTotalEarned: decimal.NewFromFloat(200.0),
wantTotalTaxes: decimal.NewFromFloat(2.5),
wantTotalFees: decimal.NewFromFloat(2.5),
},
{
name: "single item negative",
items: []ReportItem{
{
BuyValue: decimal.NewFromFloat(200.0),
SellValue: decimal.NewFromFloat(150.0),
SellTimestamp: tNow,
Taxes: decimal.NewFromFloat(2.5),
Fees: decimal.NewFromFloat(2.5),
},
},
wantTotalSpent: decimal.NewFromFloat(200.0),
wantTotalEarned: decimal.NewFromFloat(150.0),
wantTotalTaxes: decimal.NewFromFloat(2.5),
wantTotalFees: decimal.NewFromFloat(2.5),
},
{
name: "multiple items",
items: []ReportItem{
{
Symbol: "US1912161007",
BuyValue: decimal.NewFromFloat(100.0),
SellValue: decimal.NewFromFloat(200.0),
SellTimestamp: tNow,
Taxes: decimal.NewFromFloat(2.5),
Fees: decimal.NewFromFloat(2.5),
},
{
Symbol: "US1912161007",
BuyValue: decimal.NewFromFloat(200.0),
SellValue: decimal.NewFromFloat(150.0),
SellTimestamp: tNow.Add(1),
Taxes: decimal.NewFromFloat(2.5),
Fees: decimal.NewFromFloat(2.5),
},
},
wantTotalSpent: decimal.NewFromFloat(300.0),
wantTotalEarned: decimal.NewFromFloat(350.0),
wantTotalTaxes: decimal.NewFromFloat(5.0),
wantTotalFees: decimal.NewFromFloat(5.0),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
buf := new(bytes.Buffer)
tw := NewTableWriter(buf)
for _, item := range tt.items {
err := tw.Write(t.Context(), item)
if err != nil {
t.Fatalf("unexpected error on write: %v", err)
}
}
if tw.table.Length() != len(tt.items) {
t.Fatalf("want %d items in table but got %d", len(tt.items), tw.table.Length())
}
if !tw.totalSpent.Equal(tt.wantTotalSpent) {
t.Errorf("want totalSpent to be %v but got %v", tt.wantTotalSpent, tw.totalSpent)
}
if !tw.totalEarned.Equal(tt.wantTotalEarned) {
t.Errorf("want totalEarned to be %v but got %v", tt.wantTotalEarned, tw.totalEarned)
}
if !tw.totalTaxes.Equal(tt.wantTotalTaxes) {
t.Errorf("want totalTaxes to be %v but got %v", tt.wantTotalTaxes, tw.totalTaxes)
}
if !tw.totalFees.Equal(tt.wantTotalFees) {
t.Errorf("want totalFees to be %v but got %v", tt.wantTotalFees, tw.totalFees)
}
})
}
}

View File

@@ -0,0 +1,7 @@
package trading212
import (
"github.com/biter777/countries"
)
const Country = countries.Cyprus

View File

@@ -8,6 +8,7 @@ import (
"strings"
"time"
"github.com/biter777/countries"
"github.com/nmoniz/any2anexoj/internal"
"github.com/shopspring/decimal"
)
@@ -26,6 +27,14 @@ func (r Record) Symbol() string {
return r.symbol
}
func (r Record) BrokerCountry() int64 {
return int64(Country)
}
func (r Record) AssetCountry() int64 {
return int64(countries.ByName(r.Symbol()[:2]).Info().Code)
}
func (r Record) Side() internal.Side {
return r.side
}