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

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