Several improvements and bug fixes for 2025 tax return #25

Merged
natercio merged 10 commits from fix-bugs-for-2025-report into main 2026-05-17 09:47:29 +01:00
9 changed files with 146 additions and 142 deletions
Showing only changes of commit 5cdccfcdb1 - Show all commits

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

View File

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

View File

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

View File

@@ -20,8 +20,8 @@ func TestBuildReport(t *testing.T) {
reader := mocks.NewMockRecordReader(ctrl)
records := []internal.Record{
mockRecord(ctrl, 20.0, 10.0, internal.SideBuy, now),
mockRecord(ctrl, 25.0, 10.0, internal.SideSell, now.Add(1)),
mockRecord(ctrl, 20.0, 10.0, internal.KindBuy, now),
mockRecord(ctrl, 25.0, 10.0, internal.KindSell, now.Add(1)),
}
reader.EXPECT().ReadRecord(gomock.Any()).DoAndReturn(func(ctx context.Context) (internal.Record, error) {
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.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().Kind().Return(kind).AnyTimes()
rec.EXPECT().Timestamp().Return(ts).AnyTimes()
rec.EXPECT().Fees().Return(decimal.Decimal{}).AnyTimes()
rec.EXPECT().Taxes().Return(decimal.Decimal{}).AnyTimes()

View File

@@ -9,28 +9,28 @@ import (
)
type testRecord struct {
symbol string
nature internal.Nature
symbol string
nature internal.Nature
brokerCountry int64
assetCountry int64
side internal.Side
price decimal.Decimal
quantity decimal.Decimal
timestamp time.Time
fees decimal.Decimal
taxes decimal.Decimal
assetCountry int64
side internal.Kind
price decimal.Decimal
quantity decimal.Decimal
timestamp time.Time
fees decimal.Decimal
taxes decimal.Decimal
}
func (m testRecord) Symbol() string { return m.symbol }
func (m testRecord) Nature() internal.Nature { return m.nature }
func (m testRecord) BrokerCountry() int64 { return m.brokerCountry }
func (m testRecord) AssetCountry() int64 { return m.assetCountry }
func (m testRecord) Side() internal.Side { return m.side }
func (m testRecord) Price() decimal.Decimal { return m.price }
func (m testRecord) Quantity() decimal.Decimal { return m.quantity }
func (m testRecord) Timestamp() time.Time { return m.timestamp }
func (m testRecord) Fees() decimal.Decimal { return m.fees }
func (m testRecord) Taxes() decimal.Decimal { return m.taxes }
func (m testRecord) Symbol() string { return m.symbol }
func (m testRecord) Nature() internal.Nature { return m.nature }
func (m testRecord) BrokerCountry() int64 { return m.brokerCountry }
func (m testRecord) AssetCountry() int64 { return m.assetCountry }
func (m testRecord) Kind() internal.Kind { return m.side }
func (m testRecord) Price() decimal.Decimal { return m.price }
func (m testRecord) Quantity() decimal.Decimal { return m.quantity }
func (m testRecord) Timestamp() time.Time { return m.timestamp }
func (m testRecord) Fees() decimal.Decimal { return m.fees }
func (m testRecord) Taxes() decimal.Decimal { return m.taxes }
func TestAny(t *testing.T) {
selector := internal.Any()
@@ -43,9 +43,9 @@ func TestAny(t *testing.T) {
{
name: "returns true for any record",
record: testRecord{
symbol: "AAPL",
nature: internal.NatureG01,
assetCountry: 1,
symbol: "AAPL",
nature: internal.NatureG01,
assetCountry: 1,
},
want: true,
},
@@ -59,7 +59,7 @@ func TestAny(t *testing.T) {
want: true,
},
{
name: "returns true for empty record",
name: "returns true for empty record",
record: testRecord{},
want: true,
},
@@ -77,10 +77,10 @@ func TestAny(t *testing.T) {
func TestOnlyNature(t *testing.T) {
tests := []struct {
name string
nature internal.Nature
record internal.Record
want bool
name string
nature internal.Nature
record internal.Record
want bool
}{
{
name: "matches G01 nature",
@@ -133,10 +133,10 @@ func TestOnlyNature(t *testing.T) {
func TestOnlyAssetCountry(t *testing.T) {
tests := []struct {
name string
country int64
record internal.Record
want bool
name string
country int64
record internal.Record
want bool
}{
{
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 {
symbol string
timestamp time.Time
side internal.Side
side internal.Kind
quantity decimal.Decimal
price decimal.Decimal
fees decimal.Decimal
@@ -44,7 +44,7 @@ func (r Record) AssetCountry() int64 {
return int64(countries.ByName(r.Symbol()[:2]).Info().Code)
}
func (r Record) Side() internal.Side {
func (r Record) Kind() internal.Kind {
return r.side
}
@@ -81,10 +81,12 @@ func NewRecordReader(r io.Reader, f *internal.OpenFIGI) *RecordReader {
}
const (
MarketBuy = "market buy"
MarketSell = "market sell"
LimitBuy = "limit buy"
LimitSell = "limit sell"
MarketBuy = "market buy"
MarketSell = "market sell"
LimitBuy = "limit buy"
LimitSell = "limit sell"
StockSplitOpen = "Stock split open"
StockSplitClose = "Stock split close"
)
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)
}
var side internal.Side
var side internal.Kind
switch strings.ToLower(raw[0]) {
case MarketBuy, LimitBuy:
side = internal.SideBuy
side = internal.KindBuy
case MarketSell, LimitSell:
side = internal.SideSell
case "action", "stock split open", "stock split close":
side = internal.KindSell
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
default:
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",,`),
want: Record{
symbol: "XX1234567890",
side: internal.SideBuy,
side: internal.KindBuy,
quantity: ShouldParseDecimal(t, "2.4387014200"),
price: ShouldParseDecimal(t, "7.3690000000"),
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"`),
want: Record{
symbol: "XX1234567890",
side: internal.SideSell,
side: internal.KindSell,
quantity: ShouldParseDecimal(t, "2.4387014200"),
price: ShouldParseDecimal(t, "7.9999999999"),
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())
}
if got.Side() != tt.want.side {
t.Fatalf("want side %v but got %v", tt.want.side, got.Side())
if got.Kind() != tt.want.side {
t.Fatalf("want side %v but got %v", tt.want.side, got.Kind())
}
if got.Price().Cmp(tt.want.price) != 0 {