Support filtering by nature and asset country #24

Merged
natercio merged 1 commits from filters into main 2026-05-16 09:12:24 +01:00
7 changed files with 607 additions and 29 deletions

View File

@@ -16,21 +16,32 @@ import (
"golang.org/x/text/language" "golang.org/x/text/language"
) )
var (
// TODO: once we support more brokers or exchanges we should make this parameter required and // TODO: once we support more brokers or exchanges we should make this parameter required and
// remove/change default // remove/change default
var platform = pflag.StringP("platform", "p", "trading212", "one of the supported platforms") platform = pflag.StringP("platform", "p", "trading212", "One of the supported platforms")
lang = pflag.StringP("language", "l", language.Portuguese.String(), "The 2 letter language code")
// TODO: improve documentation on selectors
selectors = pflag.StringSlice("selectors", nil, "Only process entries that conform to all the selectors:")
var lang = pflag.StringP("language", "l", language.Portuguese.String(), "2 letter language code") readerFactories = map[string]func() internal.RecordReader{
var readerFactories = map[string]func() internal.RecordReader{
"trading212": func() internal.RecordReader { "trading212": func() internal.RecordReader {
return trading212.NewRecordReader(os.Stdin, internal.NewOpenFIGI(&http.Client{Timeout: 5 * time.Second})) return trading212.NewRecordReader(os.Stdin, internal.NewOpenFIGI(&http.Client{Timeout: 5 * time.Second}))
}, },
} }
)
func main() { func main() {
pflag.Parse() pflag.Parse()
err := run(context.Background())
if err != nil {
slog.Error("found a fatal issue", slog.Any("err", err))
os.Exit(1)
}
}
func run(ctx context.Context) error {
if platform == nil || len(*platform) == 0 { if platform == nil || len(*platform) == 0 {
slog.Error("--platform flag is required") slog.Error("--platform flag is required")
os.Exit(1) os.Exit(1)
@@ -41,14 +52,6 @@ func main() {
os.Exit(1) os.Exit(1)
} }
err := run(context.Background(), *platform, *lang)
if err != nil {
slog.Error("found a fatal issue", slog.Any("err", err))
os.Exit(1)
}
}
func run(ctx context.Context, platform, lang string) error {
ctx, cancel := signal.NotifyContext(ctx, os.Kill, os.Interrupt) ctx, cancel := signal.NotifyContext(ctx, os.Kill, os.Interrupt)
defer cancel() defer cancel()
@@ -56,25 +59,30 @@ func run(ctx context.Context, platform, lang string) error {
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, nil))) slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, nil)))
factory, ok := readerFactories[platform] factory, ok := readerFactories[*platform]
if !ok { if !ok {
return fmt.Errorf("unsupported platform: %s", platform) return fmt.Errorf("unsupported platform: %s", *platform)
} }
reader := factory() reader := factory()
writer := internal.NewAggregatorWriter() writer := internal.NewAggregatorWriter()
selector, err := internal.ParseSelectors(*selectors)
if err != nil {
return fmt.Errorf("parsing selectors: %w", err)
}
eg.Go(func() error { eg.Go(func() error {
return internal.BuildReport(ctx, reader, writer) return internal.BuildReport(ctx, reader, writer, selector)
}) })
err := eg.Wait() err = eg.Wait()
if err != nil { if err != nil {
return err return err
} }
loc, err := NewLocalizer(lang) loc, err := NewLocalizer(*lang)
if err != nil { if err != nil {
return fmt.Errorf("create localizer: %w", err) return fmt.Errorf("create localizer: %w", err)
} }

View File

@@ -15,8 +15,17 @@ const (
) )
func (n Nature) String() string { func (n Nature) String() string {
if n == "" { if n.Valid() {
return "unknown"
}
return string(n) return string(n)
} }
return "unknown"
}
func (n Nature) Valid() bool {
switch n {
case NatureG01, NatureG20:
return true
default:
return false
}
}

View File

@@ -13,7 +13,11 @@ func TestNature_String(t *testing.T) {
want string want string
}{ }{
{ {
name: "return unknown", name: "return unknown for empty",
want: "unknown",
},
{
name: "return unknown for bad value",
want: "unknown", want: "unknown",
}, },
{ {

View File

@@ -50,7 +50,12 @@ type ReportWriter interface {
Write(context.Context, ReportItem) error Write(context.Context, ReportItem) error
} }
func BuildReport(ctx context.Context, reader RecordReader, writer ReportWriter) error { // Selector returns true if a record should be selected for processing, false otherwise.
type Selector func(Record) bool
// BuildReport reads records from a RecordReader and, if the record passes the Selector, it is
// processed into the report
func BuildReport(ctx context.Context, reader RecordReader, writer ReportWriter, s Selector) error {
buys := make(map[string]*FillerQueue) buys := make(map[string]*FillerQueue)
for { for {
@@ -66,6 +71,10 @@ func BuildReport(ctx context.Context, reader RecordReader, writer ReportWriter)
return err return err
} }
if !s(rec) {
continue
}
buyQueue, ok := buys[rec.Symbol()] buyQueue, ok := buys[rec.Symbol()]
if !ok { if !ok {
buyQueue = new(FillerQueue) buyQueue = new(FillerQueue)

View File

@@ -43,7 +43,7 @@ func TestBuildReport(t *testing.T) {
Taxes: decimal.Decimal{}, Taxes: decimal.Decimal{},
})).Times(1) })).Times(1)
gotErr := internal.BuildReport(t.Context(), reader, writer) gotErr := internal.BuildReport(t.Context(), reader, writer, internal.Any())
if gotErr != nil { if gotErr != nil {
t.Fatalf("got unexpected err: %v", gotErr) t.Fatalf("got unexpected err: %v", gotErr)
} }

114
internal/selectors.go Normal file
View File

@@ -0,0 +1,114 @@
package internal
import (
"fmt"
"strconv"
"strings"
)
func Any() Selector {
return func(r Record) bool { return true }
}
func And(a, b Selector) Selector {
return func(r Record) bool {
return a(r) && b(r)
}
}
func OnlyNature(n Nature) Selector {
return func(r Record) bool {
return r.Nature() == n
}
}
func OnlyAssetCountry(c int64) Selector {
return func(r Record) bool {
return r.AssetCountry() == c
}
}
// selectorParser is a function that parses a selector value string into a Selector
type selectorParser func(string) (Selector, error)
// parsers maps selector keys to their parser functions
var parsers = map[string]selectorParser{
"code": parseNature,
"assetCountry": parseAssetCountry,
}
func parseNature(value string) (Selector, error) {
if value == "" {
return nil, fmt.Errorf("code selector requires a non-empty value")
}
nature := Nature(value)
if !nature.Valid() {
return nil, fmt.Errorf("invalid nature code %q", value)
}
return OnlyNature(nature), nil
}
func parseAssetCountry(value string) (Selector, error) {
if value == "" {
return nil, fmt.Errorf("assetCountry selector requires a non-empty value")
}
i, err := strconv.ParseInt(value, 10, 64)
if err != nil {
return nil, fmt.Errorf("assetCountry value must be a valid integer: %w", err)
}
return OnlyAssetCountry(i), nil
}
// ParseSelectors parses a list of selector strings into a composed Selector.
// Each selector string must be in the format "key:value" where key is one of:
// - code: filter by nature (e.g., "code:G01")
// - assetCountry: filter by asset country code (e.g., "assetCountry:840")
//
// Multiple selectors are combined with AND logic.
// If no selectors are provided, returns Any() which matches all records.
func ParseSelectors(sl []string) (Selector, error) {
if len(sl) == 0 {
return Any(), nil
}
// Parse the first selector
first, err := parseSingleSelector(sl[0])
if err != nil {
return nil, err
}
// If there's only one, return it
if len(sl) == 1 {
return first, nil
}
// Recursively parse the rest and combine with AND
rest, err := ParseSelectors(sl[1:])
if err != nil {
return nil, err
}
return And(first, rest), nil
}
func parseSingleSelector(s string) (Selector, error) {
key, value, found := strings.Cut(s, ":")
if !found {
return nil, fmt.Errorf("invalid selector format %q: must be 'key:value'", s)
}
parser, ok := parsers[key]
if !ok {
return nil, fmt.Errorf("unknown selector key %q: supported keys are %v", key, supportedKeys())
}
return parser(value)
}
func supportedKeys() []string {
keys := make([]string, 0, len(parsers))
for k := range parsers {
keys = append(keys, k)
}
return keys
}

434
internal/selectors_test.go Normal file
View File

@@ -0,0 +1,434 @@
package internal_test
import (
"testing"
"time"
"github.com/nmoniz/any2anexoj/internal"
"github.com/shopspring/decimal"
)
type testRecord struct {
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
}
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 TestAny(t *testing.T) {
selector := internal.Any()
tests := []struct {
name string
record internal.Record
want bool
}{
{
name: "returns true for any record",
record: testRecord{
symbol: "AAPL",
nature: internal.NatureG01,
assetCountry: 1,
},
want: true,
},
{
name: "returns true for record with unknown nature",
record: testRecord{
symbol: "MSFT",
nature: internal.NatureUnknown,
assetCountry: 2,
},
want: true,
},
{
name: "returns true for empty record",
record: testRecord{},
want: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := selector(tt.record)
if got != tt.want {
t.Fatalf("want %v but got %v", tt.want, got)
}
})
}
}
func TestOnlyNature(t *testing.T) {
tests := []struct {
name string
nature internal.Nature
record internal.Record
want bool
}{
{
name: "matches G01 nature",
nature: internal.NatureG01,
record: testRecord{
symbol: "AAPL",
nature: internal.NatureG01,
},
want: true,
},
{
name: "matches G20 nature",
nature: internal.NatureG20,
record: testRecord{
symbol: "ETF",
nature: internal.NatureG20,
},
want: true,
},
{
name: "does not match different nature",
nature: internal.NatureG01,
record: testRecord{
symbol: "AAPL",
nature: internal.NatureG20,
},
want: false,
},
{
name: "does not match unknown nature",
nature: internal.NatureG01,
record: testRecord{
symbol: "AAPL",
nature: internal.NatureUnknown,
},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
selector := internal.OnlyNature(tt.nature)
got := selector(tt.record)
if got != tt.want {
t.Fatalf("want %v but got %v", tt.want, got)
}
})
}
}
func TestOnlyAssetCountry(t *testing.T) {
tests := []struct {
name string
country int64
record internal.Record
want bool
}{
{
name: "matches asset country",
country: 840, // USA
record: testRecord{
symbol: "AAPL",
assetCountry: 840,
},
want: true,
},
{
name: "matches different country",
country: 826, // UK
record: testRecord{
symbol: "BP",
assetCountry: 826,
},
want: true,
},
{
name: "does not match different asset country",
country: 840,
record: testRecord{
symbol: "BP",
assetCountry: 826,
},
want: false,
},
{
name: "does not match zero country",
country: 0,
record: testRecord{
symbol: "AAPL",
assetCountry: 840,
},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
selector := internal.OnlyAssetCountry(tt.country)
got := selector(tt.record)
if got != tt.want {
t.Fatalf("want %v but got %v", tt.want, got)
}
})
}
}
func TestAnd(t *testing.T) {
record := testRecord{}
tests := []struct {
name string
selectorA internal.Selector
selectorB internal.Selector
want bool
}{
{
name: "both selectors return true",
selectorA: func(internal.Record) bool { return true },
selectorB: func(internal.Record) bool { return true },
want: true,
},
{
name: "first selector returns true, second returns false",
selectorA: func(internal.Record) bool { return true },
selectorB: func(internal.Record) bool { return false },
want: false,
},
{
name: "first selector returns false, second returns true",
selectorA: func(internal.Record) bool { return false },
selectorB: func(internal.Record) bool { return true },
want: false,
},
{
name: "both selectors return false",
selectorA: func(internal.Record) bool { return false },
selectorB: func(internal.Record) bool { return false },
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
selector := internal.And(tt.selectorA, tt.selectorB)
got := selector(record)
if got != tt.want {
t.Fatalf("want %v but got %v", tt.want, got)
}
})
}
}
func TestParseSelectors_Empty(t *testing.T) {
selector, err := internal.ParseSelectors([]string{})
if err != nil {
t.Fatalf("unexpected error for empty selectors: %v", err)
}
record := testRecord{
nature: internal.NatureG01,
assetCountry: 840,
}
if !selector(record) {
t.Fatalf("empty selectors should match all records")
}
}
func TestParseSelectors_OnlyNature(t *testing.T) {
tests := []struct {
name string
selector string
nature internal.Nature
want bool
}{
{
name: "matches G01",
selector: "code:G01",
nature: internal.NatureG01,
want: true,
},
{
name: "matches G20",
selector: "code:G20",
nature: internal.NatureG20,
want: true,
},
{
name: "does not match different nature",
selector: "code:G01",
nature: internal.NatureG20,
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
selector, err := internal.ParseSelectors([]string{tt.selector})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
record := testRecord{
nature: tt.nature,
assetCountry: 840,
}
got := selector(record)
if got != tt.want {
t.Fatalf("want %v but got %v", tt.want, got)
}
})
}
}
func TestParseSelectors_OnlyAssetCountry(t *testing.T) {
tests := []struct {
name string
selector string
assetCountry int64
want bool
}{
{
name: "matches USA",
selector: "assetCountry:840",
assetCountry: 840,
want: true,
},
{
name: "matches UK",
selector: "assetCountry:826",
assetCountry: 826,
want: true,
},
{
name: "does not match different country",
selector: "assetCountry:840",
assetCountry: 826,
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
selector, err := internal.ParseSelectors([]string{tt.selector})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
record := testRecord{
nature: internal.NatureG01,
assetCountry: tt.assetCountry,
}
got := selector(record)
if got != tt.want {
t.Fatalf("want %v but got %v", tt.want, got)
}
})
}
}
func TestParseSelectors_Multiple(t *testing.T) {
tests := []struct {
name string
selectors []string
nature internal.Nature
assetCountry int64
want bool
}{
{
name: "both selectors match",
selectors: []string{"code:G01", "assetCountry:840"},
nature: internal.NatureG01,
assetCountry: 840,
want: true,
},
{
name: "first selector matches, second does not",
selectors: []string{"code:G01", "assetCountry:826"},
nature: internal.NatureG01,
assetCountry: 840,
want: false,
},
{
name: "first selector does not match, second does",
selectors: []string{"code:G20", "assetCountry:840"},
nature: internal.NatureG01,
assetCountry: 840,
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
selector, err := internal.ParseSelectors(tt.selectors)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
record := testRecord{
nature: tt.nature,
assetCountry: tt.assetCountry,
}
got := selector(record)
if got != tt.want {
t.Fatalf("want %v but got %v", tt.want, got)
}
})
}
}
func TestParseSelectors_InvalidAssetCountry(t *testing.T) {
t.Run("returns error for non-numeric asset country", func(t *testing.T) {
_, err := internal.ParseSelectors([]string{"assetCountry:notanumber"})
if err == nil {
t.Fatalf("expected error for non-numeric asset country")
}
})
}
func TestParseSelectors_UnknownSelector(t *testing.T) {
t.Run("returns error for unknown selector type", func(t *testing.T) {
_, err := internal.ParseSelectors([]string{"unknown:value"})
if err == nil {
t.Fatalf("expected error for unknown selector type")
}
})
}
func TestParseSelectors_EmptyValue(t *testing.T) {
t.Run("rejects code with empty value", func(t *testing.T) {
_, err := internal.ParseSelectors([]string{"code:"})
if err == nil {
t.Fatalf("expected error for empty code value")
}
})
t.Run("rejects assetCountry with empty value", func(t *testing.T) {
_, err := internal.ParseSelectors([]string{"assetCountry:"})
if err == nil {
t.Fatalf("expected error for empty assetCountry value")
}
})
}
func TestParseSelectors_MissingColon(t *testing.T) {
t.Run("rejects input without colon", func(t *testing.T) {
_, err := internal.ParseSelectors([]string{"code"})
if err == nil {
t.Fatalf("expected error for input without colon")
}
})
}