diff --git a/cmd/any2anexoj-cli/main.go b/cmd/any2anexoj-cli/main.go index d41dcd5..cb7b239 100644 --- a/cmd/any2anexoj-cli/main.go +++ b/cmd/any2anexoj-cli/main.go @@ -16,21 +16,32 @@ import ( "golang.org/x/text/language" ) -// TODO: once we support more brokers or exchanges we should make this parameter required and -// remove/change default -var platform = pflag.StringP("platform", "p", "trading212", "one of the supported platforms") +var ( + // TODO: once we support more brokers or exchanges we should make this parameter required and + // remove/change default + 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") - -var readerFactories = map[string]func() internal.RecordReader{ - "trading212": func() internal.RecordReader { - return trading212.NewRecordReader(os.Stdin, internal.NewOpenFIGI(&http.Client{Timeout: 5 * time.Second})) - }, -} + readerFactories = map[string]func() internal.RecordReader{ + "trading212": func() internal.RecordReader { + return trading212.NewRecordReader(os.Stdin, internal.NewOpenFIGI(&http.Client{Timeout: 5 * time.Second})) + }, + } +) func main() { 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 { slog.Error("--platform flag is required") os.Exit(1) @@ -41,14 +52,6 @@ func main() { 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) defer cancel() @@ -56,25 +59,30 @@ func run(ctx context.Context, platform, lang string) error { slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, nil))) - factory, ok := readerFactories[platform] + factory, ok := readerFactories[*platform] if !ok { - return fmt.Errorf("unsupported platform: %s", platform) + return fmt.Errorf("unsupported platform: %s", *platform) } reader := factory() writer := internal.NewAggregatorWriter() + selector, err := internal.ParseSelectors(*selectors) + if err != nil { + return fmt.Errorf("parsing selectors: %w", err) + } + 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 { return err } - loc, err := NewLocalizer(lang) + loc, err := NewLocalizer(*lang) if err != nil { return fmt.Errorf("create localizer: %w", err) } diff --git a/internal/nature.go b/internal/nature.go index 049c066..1539f86 100644 --- a/internal/nature.go +++ b/internal/nature.go @@ -15,8 +15,17 @@ const ( ) func (n Nature) String() string { - if n == "" { - return "unknown" + if n.Valid() { + return string(n) + } + return "unknown" +} + +func (n Nature) Valid() bool { + switch n { + case NatureG01, NatureG20: + return true + default: + return false } - return string(n) } diff --git a/internal/nature_test.go b/internal/nature_test.go index eb7baec..e6828ed 100644 --- a/internal/nature_test.go +++ b/internal/nature_test.go @@ -13,7 +13,11 @@ func TestNature_String(t *testing.T) { want string }{ { - name: "return unknown", + name: "return unknown for empty", + want: "unknown", + }, + { + name: "return unknown for bad value", want: "unknown", }, { diff --git a/internal/report.go b/internal/report.go index 1aa9ca1..e341ab9 100644 --- a/internal/report.go +++ b/internal/report.go @@ -50,7 +50,12 @@ type ReportWriter interface { 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) for { @@ -66,6 +71,10 @@ func BuildReport(ctx context.Context, reader RecordReader, writer ReportWriter) return err } + if !s(rec) { + continue + } + buyQueue, ok := buys[rec.Symbol()] if !ok { buyQueue = new(FillerQueue) diff --git a/internal/report_test.go b/internal/report_test.go index 40c2184..f530ddb 100644 --- a/internal/report_test.go +++ b/internal/report_test.go @@ -43,7 +43,7 @@ func TestBuildReport(t *testing.T) { Taxes: decimal.Decimal{}, })).Times(1) - gotErr := internal.BuildReport(t.Context(), reader, writer) + gotErr := internal.BuildReport(t.Context(), reader, writer, internal.Any()) if gotErr != nil { t.Fatalf("got unexpected err: %v", gotErr) } diff --git a/internal/selectors.go b/internal/selectors.go new file mode 100644 index 0000000..895e300 --- /dev/null +++ b/internal/selectors.go @@ -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 +} diff --git a/internal/selectors_test.go b/internal/selectors_test.go new file mode 100644 index 0000000..836d285 --- /dev/null +++ b/internal/selectors_test.go @@ -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") + } + }) +}