Compare commits

...

49 Commits

Author SHA1 Message Date
4626f08b9c Merge pull request 'localization' (#23) from localization into main
All checks were successful
Badges / coveralls (push) Successful in 55s
Reviewed-on: #23
2025-12-04 17:00:37 +00:00
0d97432c6c improved error handling on translation
All checks were successful
Generate check / check-changes (pull_request) Successful in 18s
Quality / check-changes (pull_request) Successful in 17s
Generate check / verify-generate (pull_request) Has been skipped
Quality / run-tests (pull_request) Successful in 30s
2025-12-04 16:59:20 +00:00
10e1e99683 improve test coverage 2025-12-04 16:56:34 +00:00
38db50b879 add and fix tests
All checks were successful
Generate check / check-changes (pull_request) Successful in 18s
Quality / check-changes (pull_request) Successful in 18s
Generate check / verify-generate (pull_request) Has been skipped
Quality / run-tests (pull_request) Successful in 1m7s
2025-12-04 16:23:26 +00:00
21147954cb support translations
Some checks failed
Generate check / check-changes (pull_request) Failing after 0s
Quality / check-changes (pull_request) Successful in 19s
Generate check / verify-generate (pull_request) Has been skipped
Quality / run-tests (pull_request) Failing after 1m3s
2025-12-04 16:03:10 +00:00
b12c519fdb separate print logic from write logic 2025-12-04 13:04:30 +00:00
f651ce8597 Merge pull request 'fix misspelling of different' (#22) from fix-misspelling into main
All checks were successful
Badges / coveralls (push) Successful in 12s
Reviewed-on: #22
2025-11-26 11:17:42 +00:00
a19b02f1f6 fix misspelling of different
All checks were successful
Generate check / check-changes (pull_request) Successful in 3s
Quality / check-changes (pull_request) Successful in 4s
Generate check / verify-generate (pull_request) Has been skipped
Quality / run-tests (pull_request) Successful in 9s
2025-11-26 10:50:13 +00:00
a079ea8ae5 Merge pull request 'set coveralls badge link to point to main branch' (#21) from fix-badge-branch into main
All checks were successful
Badges / coveralls (push) Successful in 12s
Reviewed-on: #21
2025-11-26 10:47:55 +00:00
5a0fb8b6aa set coveralls badge link to point to main branch
All checks were successful
Generate check / check-changes (pull_request) Successful in 3s
Quality / check-changes (pull_request) Successful in 4s
Generate check / verify-generate (pull_request) Has been skipped
Quality / run-tests (pull_request) Has been skipped
2025-11-26 10:47:24 +00:00
f2bfefcea5 Merge pull request 'update coveralls badge when main is updated' (#20) from update-coveralls-badge into main
All checks were successful
Badges / coveralls (push) Successful in 25s
Reviewed-on: #20
2025-11-26 10:34:40 +00:00
d415fcd752 separate coveralls and refine workflow triggers
All checks were successful
Generate check / check-changes (pull_request) Successful in 3s
Quality / check-changes (pull_request) Successful in 3s
Generate check / verify-generate (pull_request) Has been skipped
Quality / run-tests (pull_request) Has been skipped
2025-11-26 10:32:27 +00:00
950143e17d update coveralls badge when main is updated
All checks were successful
Generate check / check-changes (push) Successful in 4s
Generate check / check-generate (push) Has been skipped
Generate check / check-changes (pull_request) Successful in 3s
Quality / check-changes (pull_request) Successful in 3s
Generate check / check-generate (pull_request) Has been skipped
Quality / tests (pull_request) Has been skipped
2025-11-26 10:19:43 +00:00
066b5b76a8 Merge pull request 'Present values rounded to cents' (#19) from rounding-to-cents into main
All checks were successful
Generate check / check-changes (push) Successful in 3s
Generate check / check-generate (push) Has been skipped
Reviewed-on: #19
2025-11-26 10:08:33 +00:00
2a3f13e91a remove readme image width constraint
All checks were successful
Generate check / check-changes (push) Successful in 3s
Generate check / check-changes (pull_request) Successful in 3s
Quality / check-changes (pull_request) Successful in 3s
Generate check / check-generate (push) Has been skipped
Generate check / check-generate (pull_request) Has been skipped
Quality / tests (pull_request) Successful in 11s
2025-11-26 10:03:35 +00:00
0b6b35e736 update readme screenshot and add section about rounding
All checks were successful
Generate check / check-changes (push) Successful in 4s
Generate check / check-generate (push) Has been skipped
Generate check / check-changes (pull_request) Successful in 3s
Quality / check-changes (pull_request) Successful in 3s
Generate check / check-generate (pull_request) Has been skipped
Quality / tests (pull_request) Successful in 14s
2025-11-26 09:56:49 +00:00
5060fca7be round to decimals and improve presentation
All checks were successful
Generate check / check-changes (push) Successful in 3s
Generate check / check-generate (push) Has been skipped
2025-11-25 15:14:49 +00:00
4a2884a0df Merge pull request 'Cache calls to SecurityTypeByISIN' (#18) from cache-figi-responses into main
All checks were successful
Generate check / check-changes (push) Successful in 4s
Generate check / check-generate (push) Has been skipped
Reviewed-on: #18
2025-11-25 13:52:05 +00:00
64bbf8d129 error on empty securityType and more efficient locks
All checks were successful
Generate check / check-changes (push) Successful in 3s
Generate check / check-changes (pull_request) Successful in 3s
Quality / check-changes (pull_request) Successful in 3s
Generate check / check-generate (push) Has been skipped
Generate check / check-generate (pull_request) Has been skipped
Quality / tests (pull_request) Successful in 14s
2025-11-25 13:50:17 +00:00
57ee768006 cache calls to SecurityTypeByISIN
All checks were successful
Generate check / check-changes (push) Successful in 4s
Generate check / check-generate (push) Has been skipped
Generate check / check-changes (pull_request) Successful in 3s
Quality / check-changes (pull_request) Successful in 3s
Generate check / check-generate (pull_request) Has been skipped
Quality / tests (pull_request) Successful in 14s
This avoids unecessary repeated calls to OpenFIGI api
2025-11-25 13:05:44 +00:00
0d3e3df9e7 Merge pull request 'Report includes the type/nature of row' (#17) from record-type into main
All checks were successful
Generate check / check-changes (push) Successful in 3s
Generate check / check-generate (push) Has been skipped
Reviewed-on: #17
2025-11-24 16:29:44 +00:00
5f13ebaf6a test expects Nature method call
All checks were successful
Generate check / check-changes (push) Successful in 3s
Generate check / check-changes (pull_request) Successful in 3s
Quality / check-changes (pull_request) Successful in 3s
Generate check / check-generate (push) Has been skipped
Generate check / check-generate (pull_request) Successful in 9s
Quality / tests (pull_request) Successful in 58s
2025-11-24 16:27:44 +00:00
70466b7886 fix typos and copy paste test names
Some checks failed
Generate check / check-changes (push) Successful in 4s
Generate check / check-changes (pull_request) Successful in 3s
Quality / check-changes (pull_request) Successful in 3s
Generate check / check-generate (push) Has been skipped
Generate check / check-generate (pull_request) Successful in 8s
Quality / tests (pull_request) Failing after 42s
2025-11-24 16:23:57 +00:00
bd101ce46a fix critical tax calculation 2025-11-24 16:18:02 +00:00
93f1dab3d2 fix isin in tests
Some checks failed
Generate check / check-changes (push) Successful in 4s
Generate check / check-changes (pull_request) Successful in 3s
Quality / check-changes (pull_request) Successful in 4s
Generate check / check-generate (push) Has been skipped
Generate check / check-generate (pull_request) Successful in 8s
Quality / tests (pull_request) Failing after 42s
2025-11-24 16:15:21 +00:00
c323047175 report show nature
Some checks failed
Generate check / check-changes (push) Successful in 4s
Generate check / check-generate (push) Has been skipped
Generate check / check-changes (pull_request) Successful in 4s
Quality / check-changes (pull_request) Successful in 3s
Generate check / check-generate (pull_request) Successful in 28s
Quality / tests (pull_request) Failing after 41s
2025-11-24 16:03:28 +00:00
8c784f3b74 check isin format and enforce rate limit 2025-11-24 16:02:27 +00:00
a1ea13ff2f add string method to the Nature type 2025-11-24 15:55:30 +00:00
6b5552b559 add Nature method to Record type 2025-11-24 15:55:30 +00:00
23614d51db add OpenFIGI adaptar with tests 2025-11-24 12:17:58 +00:00
ef0a4476a7 add Nature type 2025-11-24 12:17:32 +00:00
b4b12ad625 add new Nature method to Record interface 2025-11-21 13:06:26 +00:00
1106705eb2 Merge pull request 'add badge to readme' (#16) from coveralls-badge into main
All checks were successful
Generate check / check-changes (push) Successful in 3s
Generate check / check-generate (push) Has been skipped
Reviewed-on: #16
2025-11-20 23:52:18 +00:00
f716c2e897 add badge to readme
All checks were successful
Generate check / check-changes (push) Successful in 4s
Generate check / check-changes (pull_request) Successful in 3s
Quality / check-changes (pull_request) Successful in 4s
Generate check / check-generate (push) Has been skipped
Generate check / check-generate (pull_request) Has been skipped
Quality / tests (pull_request) Has been skipped
2025-11-20 23:50:37 +00:00
ef350b2659 Merge pull request 'add coveralls steps to the tests' (#15) from coveralls-badge into main
All checks were successful
Generate check / check-changes (push) Successful in 3s
Generate check / check-generate (push) Has been skipped
Reviewed-on: #15
2025-11-20 20:01:30 +00:00
914ead1681 fix issues with grepping output
All checks were successful
Generate check / check-changes (push) Successful in 4s
Generate check / check-changes (pull_request) Successful in 3s
Quality / check-changes (pull_request) Successful in 3s
Generate check / check-generate (push) Has been skipped
Generate check / check-generate (pull_request) Has been skipped
Quality / tests (pull_request) Successful in 16s
2025-11-20 19:59:45 +00:00
c363652f49 ignore generated files in coverage report
Some checks failed
Generate check / check-changes (push) Successful in 3s
Generate check / check-changes (pull_request) Successful in 3s
Quality / check-changes (pull_request) Successful in 3s
Generate check / check-generate (push) Has been skipped
Generate check / check-generate (pull_request) Has been skipped
Quality / tests (pull_request) Failing after 6s
2025-11-20 19:53:25 +00:00
a347443c81 add comments to side methods
All checks were successful
Generate check / check-changes (push) Successful in 3s
Generate check / check-changes (pull_request) Successful in 3s
Quality / check-changes (pull_request) Successful in 3s
Generate check / check-generate (push) Has been skipped
Generate check / check-generate (pull_request) Has been skipped
Quality / tests (pull_request) Successful in 49s
2025-11-20 19:41:12 +00:00
89bcd15b17 add coveralls steps to the tests
All checks were successful
Generate check / check-changes (push) Successful in 3s
Generate check / check-generate (push) Has been skipped
Generate check / check-changes (pull_request) Successful in 2s
Quality / check-changes (pull_request) Successful in 2s
Generate check / check-generate (pull_request) Has been skipped
Quality / tests (pull_request) Has been skipped
2025-11-20 19:32:31 +00:00
9ba5116c03 Merge pull request 'Add go report badge' (#14) from go-report-badge into main
All checks were successful
Generate check / check-changes (push) Successful in 3s
Generate check / check-generate (push) Has been skipped
Reviewed-on: #14
2025-11-19 11:41:54 +00:00
1c3fd0397a fix typos
All checks were successful
Generate check / check-changes (push) Successful in 3s
Generate check / check-generate (push) Has been skipped
Generate check / check-changes (pull_request) Successful in 3s
Tests / check-changes (pull_request) Successful in 3s
Generate check / check-generate (pull_request) Has been skipped
Tests / tests (pull_request) Successful in 7s
2025-11-19 11:40:09 +00:00
961f0eed38 add go report card 2025-11-19 11:38:46 +00:00
290593a9aa Merge pull request 'Improve presentation and correctness' (#13) from improve-presentation into main
All checks were successful
Generate check / check-changes (push) Successful in 3s
Generate check / check-generate (push) Has been skipped
Reviewed-on: #13
2025-11-18 16:22:57 +00:00
f49377a6dd improve description and include image
All checks were successful
Generate check / check-changes (push) Successful in 3s
Generate check / check-generate (push) Has been skipped
Generate check / check-changes (pull_request) Successful in 3s
Tests / check-changes (pull_request) Successful in 3s
Generate check / check-generate (pull_request) Successful in 24s
Tests / tests (pull_request) Successful in 20s
2025-11-18 16:20:28 +00:00
91f6bd1a3e remove the isin from the code and print placeholder instead 2025-11-18 16:13:26 +00:00
d097b01288 print a pretty table with correct country coder 2025-11-18 16:07:41 +00:00
05a981b3a0 Merge pull request 'use shopspring/decimal library everywhere' (#12) from fix-zero-entries into main
All checks were successful
Generate check / check-changes (push) Successful in 3s
Generate check / check-generate (push) Has been skipped
Reviewed-on: #12
2025-11-16 23:25:06 +00:00
f6e870d7b7 updated gitea workflows conditional logic
All checks were successful
Generate check / check-changes (push) Successful in 3s
Generate check / check-changes (pull_request) Successful in 3s
Tests / check-changes (pull_request) Successful in 3s
Generate check / check-generate (push) Has been skipped
Generate check / check-generate (pull_request) Successful in 22s
Tests / tests (pull_request) Successful in 12s
2025-11-16 23:23:06 +00:00
a6d56d7441 use shopspring/decimal library everywhere
All checks were successful
Generate check / check-generate (push) Has been skipped
Generate check / check-generate (pull_request) Has been skipped
Tests / tests (pull_request) Has been skipped
2025-11-16 23:03:47 +00:00
34 changed files with 2018 additions and 505 deletions

View File

@@ -0,0 +1,32 @@
name: Badges
on:
push:
branches:
- main
jobs:
coveralls:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Setup Go
uses: actions/setup-go@v4
with:
go-version: 1.25
- name: Run Unit tests with coverage
run: |
go test -covermode atomic -coverprofile=coverage.out ./...
grep -v -E "(main|_gen).go" coverage.out > coverage.filtered.out
mv coverage.filtered.out coverage.out
- name: Install goveralls
run: go install github.com/mattn/goveralls@v0.0.12
- name: Send coverage
env:
COVERALLS_TOKEN: ${{ secrets.COVERALLS_REPO_TOKEN}}
run: goveralls -coverprofile=coverage.out -service=github

View File

@@ -1,18 +1,37 @@
name: Generate check
on:
push:
pull_request:
types: [opened, reopened, synchronize]
jobs:
check-generate:
check-changes:
runs-on: ubuntu-latest
if: contains(github.event.pull_request.changed_files, '*_gen.go') ||
contains(github.event.pull_request.changed_files, '*/generate.go')
outputs:
has_gen_changes: ${{ steps.check.outputs.has_gen_changes }}
steps:
- name: Checkout code
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Check for generated file changes
id: check
run: |
if git diff --name-only origin/${{ github.base_ref }}...HEAD | grep -E '_gen\.go$|/generate\.go$'; then
echo "has_gen_changes=true" >> $GITHUB_OUTPUT
else
echo "has_gen_changes=false" >> $GITHUB_OUTPUT
fi
verify-generate:
runs-on: ubuntu-latest
needs: check-changes
if: needs.check-changes.outputs.has_gen_changes == 'true'
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Setup Go
uses: actions/setup-go@v4
with:

View File

@@ -0,0 +1,42 @@
name: Quality
on:
pull_request:
types: [opened, reopened, synchronize]
jobs:
check-changes:
runs-on: ubuntu-latest
outputs:
has_go_changes: ${{ steps.check.outputs.has_go_changes }}
steps:
- name: Checkout code
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Check for Go changes
id: check
run: |
if git diff --name-only origin/${{ github.base_ref }}...HEAD | grep -E '\.go$|go\.(mod|sum)$'; then
echo "has_go_changes=true" >> $GITHUB_OUTPUT
else
echo "has_go_changes=false" >> $GITHUB_OUTPUT
fi
run-tests:
runs-on: ubuntu-latest
needs: check-changes
if: needs.check-changes.outputs.has_go_changes == 'true'
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Setup Go
uses: actions/setup-go@v4
with:
go-version: 1.25
- name: Run Unit tests
run: |
go test -race -covermode atomic -coverprofile=coverage.out ./...

View File

@@ -1,21 +0,0 @@
name: Tests
on:
pull_request:
types: [opened, reopened, synchronize]
jobs:
tests:
runs-on: ubuntu-latest
if: contains(github.event.pull_request.changed_files, '*.go')
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Setup Go
uses: actions/setup-go@v4
with:
go-version: 1.25
- name: Run tests
run: go test -v ./...

View File

@@ -1,13 +1,17 @@
# any2anexoj
This tool converts the statements from brokers and exchanges into a format compatible with the Portuguese IRS form: [Mod_3_anexo_j](https://info.portaldasfinancas.gov.pt/pt/apoio_contribuinte/modelos_formularios/irs/Documents/Mod_3_anexo_J.pdf)
[![Go Report Card](https://goreportcard.com/badge/github.com/nmoniz/any2anexoj)](https://goreportcard.com/report/github.com/nmoniz/any2anexoj)
[![Coverage Status](https://coveralls.io/repos/github/nmoniz/any2anexoj/badge.svg?branch=main)](https://coveralls.io/github/nmoniz/any2anexoj?branch=main)
<p align="center">
<img src="https://i.ibb.co/0yRtwq2C/0-FBA40-FD-D97-A-4-AFB-8618-49582-DB98-F3-C.png" alt="Screenshot" border="0">
</p>
This tool converts the statements from known brokers and exchanges into a format compatible with section 9 from the Portuguese IRS form: [Mod_3_anexo_j](https://info.portaldasfinancas.gov.pt/pt/apoio_contribuinte/modelos_formularios/irs/Documents/Mod_3_anexo_J.pdf)
> [!WARNING]
> Although I made significant efforts to ensure the correctness of the calculations you should verify any outputs produced by this tool on your own or with a certified accountant.
> [!NOTE]
> This tool is in early stages of development. Use at your own risk!
## Install
```bash
@@ -17,5 +21,11 @@ go install github.com/nmoniz/any2anexoj/cmd/any2anexoj-cli@latest
## Usage
```bash
cat statement.csv | broker2anexoj --platform=tranding212
cat statement.csv | any2anexoj-cli --platform=tranding212
```
## Rounding
All Euro values are rounded to cents (2 decimal places) but internal calculations use the statement values with full precision.
There are no explicit rules or details about how to round Euro values in Anexo J.
This application rounds according to `Portaria n.º 1180/2001, art. 2.º, alínea c) e d)` (Ministerial Order / Government Order) examples, which imply we should round to the 2nd decimal place by rounding up (ceiling) or down (floor) depending on whether the third decimal place is ≥ 5 or < 5, respectively.

View File

@@ -0,0 +1,52 @@
package main
import (
"embed"
"encoding/json"
"fmt"
"log/slog"
"github.com/nicksnyder/go-i18n/v2/i18n"
"golang.org/x/text/language"
)
//go:embed translations/*.json
var translationsFS embed.FS
type Localizer struct {
*i18n.Localizer
}
func NewLocalizer(lang string) (*Localizer, error) {
bundle := i18n.NewBundle(language.English)
bundle.RegisterUnmarshalFunc("json", json.Unmarshal)
_, err := bundle.LoadMessageFileFS(translationsFS, "translations/en.json")
if err != nil {
return nil, fmt.Errorf("loading english messages: %w", err)
}
_, err = bundle.LoadMessageFileFS(translationsFS, "translations/pt.json")
if err != nil {
return nil, fmt.Errorf("loading portuguese messages: %w", err)
}
localizer := i18n.NewLocalizer(bundle, lang)
return &Localizer{
Localizer: localizer,
}, nil
}
func (t Localizer) Translate(key string, count int, values map[string]any) string {
txt, err := t.Localize(&i18n.LocalizeConfig{
MessageID: key,
TemplateData: values,
PluralCount: count,
})
if err != nil {
slog.Error("failed to translate message", slog.Any("err", err))
return "<ERROR>"
}
return txt
}

View File

@@ -0,0 +1,24 @@
package main
import "testing"
func TestNewLocalizer(t *testing.T) {
tests := []struct {
name string
lang string
}{
{"english", "en"},
{"portuguese", "pt"},
{"english with region", "en-US"},
{"portuguese with region", "pt-BR"},
{"unknown language falls back to default", "!!"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := NewLocalizer(tt.lang)
if err != nil {
t.Fatalf("want success call but failed: %v", err)
}
})
}
}

View File

@@ -4,21 +4,28 @@ import (
"context"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"time"
"github.com/nmoniz/any2anexoj/internal"
"github.com/nmoniz/any2anexoj/internal/trading212"
"github.com/spf13/pflag"
"golang.org/x/sync/errgroup"
"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 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) },
"trading212": func() internal.RecordReader {
return trading212.NewRecordReader(os.Stdin, internal.NewOpenFIGI(&http.Client{Timeout: 5 * time.Second}))
},
}
func main() {
@@ -29,14 +36,19 @@ func main() {
os.Exit(1)
}
err := run(context.Background(), *platform)
if lang == nil || len(*lang) == 0 {
slog.Error("--language flag is required")
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 string) error {
func run(ctx context.Context, platform, lang string) error {
ctx, cancel := signal.NotifyContext(ctx, os.Kill, os.Interrupt)
defer cancel()
@@ -51,7 +63,7 @@ func run(ctx context.Context, platform string) error {
reader := factory()
writer := internal.NewStdOutLogger()
writer := internal.NewAggregatorWriter()
eg.Go(func() error {
return internal.BuildReport(ctx, reader, writer)
@@ -62,7 +74,14 @@ func run(ctx context.Context, platform string) error {
return err
}
slog.Info("Finish processing statement")
loc, err := NewLocalizer(lang)
if err != nil {
return fmt.Errorf("create localizer: %w", err)
}
printer := NewPrettyPrinter(os.Stdout, loc)
printer.Render(writer)
return nil
}

View File

@@ -0,0 +1,122 @@
package main
import (
"fmt"
"io"
"github.com/biter777/countries"
"github.com/jedib0t/go-pretty/v6/table"
"github.com/jedib0t/go-pretty/v6/text"
"github.com/nmoniz/any2anexoj/internal"
)
// PrettyPrinter writes a simple, human readable, table row to the provided io.Writer for each
// ReportItem received.
type PrettyPrinter struct {
table table.Writer
output io.Writer
translator Translator
}
type Translator interface {
Translate(key string, count int, values map[string]any) string
}
func NewPrettyPrinter(w io.Writer, tr Translator) *PrettyPrinter {
tw := table.NewWriter()
tw.SetOutputMirror(w)
tw.SetAutoIndex(true)
tw.SetStyle(table.StyleLight)
tw.SetColumnConfigs([]table.ColumnConfig{
colCountry(1),
colOther(2),
colOther(3),
colOther(4),
colOther(5),
colEuros(6),
colOther(7),
colOther(8),
colOther(9),
colEuros(10),
colEuros(11),
colEuros(12),
colCountry(13),
})
return &PrettyPrinter{
table: tw,
output: w,
translator: tr,
}
}
func (pp *PrettyPrinter) Render(aw *internal.AggregatorWriter) {
realizationTxt := pp.translator.Translate("realization", 1, nil)
acquisitionTxt := pp.translator.Translate("acquisition", 1, nil)
yearTxt := pp.translator.Translate("year", 1, nil)
monthTxt := pp.translator.Translate("month", 1, nil)
dayTxt := pp.translator.Translate("day", 1, nil)
valorTxt := pp.translator.Translate("value", 1, nil)
pp.table.AppendHeader(table.Row{"", "", realizationTxt, realizationTxt, realizationTxt, realizationTxt, acquisitionTxt, acquisitionTxt, acquisitionTxt, acquisitionTxt, "", "", ""}, table.RowConfig{AutoMerge: true})
pp.table.AppendHeader(table.Row{
pp.translator.Translate("source_country", 1, nil), pp.translator.Translate("code", 1, nil),
yearTxt, monthTxt, dayTxt, valorTxt,
yearTxt, monthTxt, dayTxt, valorTxt,
pp.translator.Translate("expenses", 2, nil), pp.translator.Translate("foreign_tax_paid", 1, nil), pp.translator.Translate("counter_country", 1, nil),
})
for ri := range aw.Iter() {
pp.table.AppendRow(table.Row{
ri.AssetCountry, ri.Nature,
ri.SellTimestamp.Year(), int(ri.SellTimestamp.Month()), ri.SellTimestamp.Day(), ri.SellValue.StringFixed(2),
ri.BuyTimestamp.Year(), int(ri.BuyTimestamp.Month()), ri.BuyTimestamp.Day(), ri.BuyValue.StringFixed(2),
ri.Fees.StringFixed(2), ri.Taxes.StringFixed(2), ri.BrokerCountry,
})
}
pp.table.AppendFooter(table.Row{"SUM", "SUM", "SUM", "SUM", "SUM", aw.TotalEarned(), "", "", "", aw.TotalSpent(), aw.TotalFees(), aw.TotalTaxes()}, table.RowConfig{AutoMerge: true, AutoMergeAlign: text.AlignRight})
pp.table.Render()
}
func colEuros(n int) table.ColumnConfig {
return table.ColumnConfig{
Number: n,
Align: text.AlignRight,
AlignFooter: text.AlignRight,
AlignHeader: text.AlignRight,
WidthMin: 12,
WidthMax: 15,
Transformer: func(val any) string {
return fmt.Sprintf("%v €", val)
},
TransformerFooter: func(val any) string {
return fmt.Sprintf("%v €", val)
},
}
}
func colOther(n int) table.ColumnConfig {
return table.ColumnConfig{
Number: n,
Align: text.AlignLeft,
AlignFooter: text.AlignLeft,
AlignHeader: text.AlignLeft,
WidthMax: 12,
}
}
func colCountry(n int) table.ColumnConfig {
return table.ColumnConfig{
Number: n,
Align: text.AlignLeft,
AlignFooter: text.AlignLeft,
AlignHeader: text.AlignLeft,
WidthMax: 24,
WidthMaxEnforcer: text.Trim,
Transformer: func(val any) string {
countryCode := val.(int64)
return fmt.Sprintf("%v - %s", val, countries.ByNumeric(int(countryCode)).Info().Name)
},
}
}

View File

@@ -0,0 +1,84 @@
package main
import (
"bytes"
"context"
"testing"
"time"
"github.com/nmoniz/any2anexoj/internal"
"github.com/shopspring/decimal"
)
func TestPrettyPrinter_Render(t *testing.T) {
// Create test data
aw := internal.NewAggregatorWriter()
ctx := context.Background()
// Add some sample report items
err := aw.Write(ctx, internal.ReportItem{
Symbol: "AAPL",
Nature: internal.NatureG01,
BrokerCountry: 826, // United Kingdom
AssetCountry: 840, // United States
BuyValue: decimal.NewFromFloat(100.50),
BuyTimestamp: time.Date(2023, 1, 15, 0, 0, 0, 0, time.UTC),
SellValue: decimal.NewFromFloat(150.75),
SellTimestamp: time.Date(2023, 6, 20, 0, 0, 0, 0, time.UTC),
Fees: decimal.NewFromFloat(2.50),
Taxes: decimal.NewFromFloat(5.00),
})
if err != nil {
t.Fatalf("failed to write first report item: %v", err)
}
err = aw.Write(ctx, internal.ReportItem{
Symbol: "GOOGL",
Nature: internal.NatureG20,
BrokerCountry: 826, // United Kingdom
AssetCountry: 840, // United States
BuyValue: decimal.NewFromFloat(200.00),
BuyTimestamp: time.Date(2023, 3, 10, 0, 0, 0, 0, time.UTC),
SellValue: decimal.NewFromFloat(225.50),
SellTimestamp: time.Date(2023, 9, 5, 0, 0, 0, 0, time.UTC),
Fees: decimal.NewFromFloat(3.00),
Taxes: decimal.NewFromFloat(7.50),
})
if err != nil {
t.Fatalf("failed to write second report item: %v", err)
}
// Create English localizer
localizer, err := NewLocalizer("en")
if err != nil {
t.Fatalf("failed to create localizer: %v", err)
}
// Create pretty printer with buffer
var buf bytes.Buffer
pp := NewPrettyPrinter(&buf, localizer)
// Render the table
pp.Render(aw)
// Get the output
got := buf.String()
// Expected output
want := `┌───┬────────────────────────────┬───────────────────────────────────┬───────────────────────────────────┬──────────────────────────────────────────────────────────┐
│ │ │ REALIZATION │ ACQUISITION │ │
│ │ SOURCE COUNTRY │ CODE │ YEAR │ MONTH │ DAY │ VALUE │ YEAR │ MONTH │ DAY │ VALUE │ EXPENSES AND CH │ TAX PAID ABROAD │ COUNTER COUNTRY │
│ │ │ │ │ │ │ │ │ │ │ │ ARGES │ │ │
├───┼─────────────────────┼──────┼──────┼───────┼─────┼──────────────┼──────┼───────┼─────┼──────────────┼─────────────────┼─────────────────┼──────────────────────┤
│ 1 │ 840 - United States │ G01 │ 2023 │ 6 │ 20 │ 150.75 € │ 2023 │ 1 │ 15 │ 100.50 € │ 2.50 € │ 5.00 € │ 826 - United Kingdom │
│ 2 │ 840 - United States │ G20 │ 2023 │ 9 │ 5 │ 225.50 € │ 2023 │ 3 │ 10 │ 200.00 € │ 3.00 € │ 7.50 € │ 826 - United Kingdom │
├───┼─────────────────────┴──────┴──────┴───────┴─────┼──────────────┼──────┴───────┴─────┼──────────────┼─────────────────┼─────────────────┼──────────────────────┤
│ │ SUM │ 376.25 € │ │ 300.5 € │ 5.5 € │ 12.5 € │ │
└───┴─────────────────────────────────────────────────┴──────────────┴────────────────────┴──────────────┴─────────────────┴─────────────────┴──────────────────────┘
`
// Compare output
if got != want {
t.Errorf("PrettyPrinter.Render() output doesn't match expected.\n\nGot:\n%s\n\nWant:\n%s", got, want)
}
}

View File

@@ -0,0 +1,46 @@
{
"realization": {
"one": "Realization",
"other": "Realizations"
},
"acquisition": {
"one": "Acquisition",
"other": "Acquisitions"
},
"source_country": {
"one": "Source country",
"other": "Source countries"
},
"counter_country": {
"one": "Counter country",
"other": "Counter countries"
},
"year": {
"one": "Year",
"other": "Years"
},
"month": {
"one": "Month",
"other": "Months"
},
"day": {
"one": "Day",
"other": "Days"
},
"value": {
"one": "Value",
"other": "Values"
},
"code": {
"one": "Code",
"other": "Codes"
},
"expenses": {
"one": "Expense and charge",
"other": "Expenses and charges"
},
"foreign_tax_paid": {
"one": "Tax paid abroad",
"other": "Taxes paid abroad"
}
}

View File

@@ -0,0 +1,46 @@
{
"realization": {
"one": "Realização",
"other": "Realizações"
},
"acquisition": {
"one": "Aquisição",
"other": "Aquisições"
},
"source_country": {
"one": "País da fonte",
"other": "Países da fonte"
},
"counter_country": {
"one": "País da contraparte",
"other": "Países da contraparte"
},
"year": {
"one": "Ano",
"other": "Anos"
},
"month": {
"one": "Mês",
"other": "Meses"
},
"day": {
"one": "Dia",
"other": "Dias"
},
"value": {
"one": "Valor",
"other": "Valores"
},
"code": {
"one": "Código",
"other": "Códigos"
},
"expenses": {
"one": "Despesa e encargo",
"other": "Despesas e encargos"
},
"foreign_tax_paid": {
"one": "Imposto pago no estrangeiro",
"other": "Impostos pagos no estrangeiro"
}
}

11
go.mod
View File

@@ -3,13 +3,22 @@ module github.com/nmoniz/any2anexoj
go 1.25.3
require (
github.com/biter777/countries v1.7.5
github.com/jedib0t/go-pretty/v6 v6.7.2
github.com/shopspring/decimal v1.4.0
github.com/spf13/pflag v1.0.10
go.uber.org/mock v0.6.0
golang.org/x/sync v0.18.0
golang.org/x/time v0.14.0
)
require (
github.com/spf13/pflag v1.0.10 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/nicksnyder/go-i18n/v2 v2.6.0
github.com/rivo/uniseg v0.4.7 // indirect
golang.org/x/mod v0.27.0 // indirect
golang.org/x/sys v0.35.0 // indirect
golang.org/x/text v0.23.0 // indirect
golang.org/x/tools v0.36.0 // indirect
)

25
go.sum
View File

@@ -1,19 +1,40 @@
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/nicksnyder/go-i18n/v2 v2.6.0 h1:C/m2NNWNiTB6SK4Ao8df5EWm3JETSTIGNXBpMJTxzxQ=
github.com/nicksnyder/go-i18n/v2 v2.6.0/go.mod h1:88sRqr0C6OPyJn0/KRNaEz1uWorjxIKP7rUUcvycecE=
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=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
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/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
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

@@ -0,0 +1,78 @@
package internal
import (
"context"
"iter"
"sync"
"github.com/shopspring/decimal"
)
// AggregatorWriter tracks ReportItem totals.
type AggregatorWriter struct {
mu sync.RWMutex
items []ReportItem
totalEarned decimal.Decimal
totalSpent decimal.Decimal
totalFees decimal.Decimal
totalTaxes decimal.Decimal
}
func NewAggregatorWriter() *AggregatorWriter {
return &AggregatorWriter{}
}
func (aw *AggregatorWriter) Write(_ context.Context, ri ReportItem) error {
aw.mu.Lock()
defer aw.mu.Unlock()
aw.items = append(aw.items, ri)
aw.totalEarned = aw.totalEarned.Add(ri.SellValue.Round(2))
aw.totalSpent = aw.totalSpent.Add(ri.BuyValue.Round(2))
aw.totalFees = aw.totalFees.Add(ri.Fees.Round(2))
aw.totalTaxes = aw.totalTaxes.Add(ri.Taxes.Round(2))
return nil
}
func (aw *AggregatorWriter) Iter() iter.Seq[ReportItem] {
aw.mu.RLock()
itemsCopy := make([]ReportItem, len(aw.items))
copy(itemsCopy, aw.items)
aw.mu.RUnlock()
return func(yield func(ReportItem) bool) {
for _, ri := range itemsCopy {
if !yield(ri) {
return
}
}
}
}
func (aw *AggregatorWriter) TotalEarned() decimal.Decimal {
aw.mu.RLock()
defer aw.mu.RUnlock()
return aw.totalEarned
}
func (aw *AggregatorWriter) TotalSpent() decimal.Decimal {
aw.mu.RLock()
defer aw.mu.RUnlock()
return aw.totalSpent
}
func (aw *AggregatorWriter) TotalFees() decimal.Decimal {
aw.mu.RLock()
defer aw.mu.RUnlock()
return aw.totalFees
}
func (aw *AggregatorWriter) TotalTaxes() decimal.Decimal {
aw.mu.RLock()
defer aw.mu.RUnlock()
return aw.totalTaxes
}

View File

@@ -0,0 +1,288 @@
package internal_test
import (
"context"
"sync"
"testing"
"time"
"github.com/nmoniz/any2anexoj/internal"
"github.com/shopspring/decimal"
)
func TestAggregatorWriter_Write(t *testing.T) {
tests := []struct {
name string
items []internal.ReportItem
wantEarned decimal.Decimal
wantSpent decimal.Decimal
wantFees decimal.Decimal
wantTaxes decimal.Decimal
}{
{
name: "single write updates all totals",
items: []internal.ReportItem{
{
Symbol: "AAPL",
BuyValue: decimal.NewFromFloat(100.50),
SellValue: decimal.NewFromFloat(150.75),
Fees: decimal.NewFromFloat(2.50),
Taxes: decimal.NewFromFloat(5.25),
BuyTimestamp: time.Now(),
SellTimestamp: time.Now(),
},
},
wantEarned: decimal.NewFromFloat(150.75),
wantSpent: decimal.NewFromFloat(100.50),
wantFees: decimal.NewFromFloat(2.50),
wantTaxes: decimal.NewFromFloat(5.25),
},
{
name: "multiple writes accumulate totals",
items: []internal.ReportItem{
{
BuyValue: decimal.NewFromFloat(100.00),
SellValue: decimal.NewFromFloat(120.00),
Fees: decimal.NewFromFloat(1.00),
Taxes: decimal.NewFromFloat(2.00),
},
{
BuyValue: decimal.NewFromFloat(200.00),
SellValue: decimal.NewFromFloat(250.00),
Fees: decimal.NewFromFloat(3.00),
Taxes: decimal.NewFromFloat(4.00),
},
{
BuyValue: decimal.NewFromFloat(50.00),
SellValue: decimal.NewFromFloat(55.00),
Fees: decimal.NewFromFloat(0.50),
Taxes: decimal.NewFromFloat(1.50),
},
},
wantEarned: decimal.NewFromFloat(425.00),
wantSpent: decimal.NewFromFloat(350.00),
wantFees: decimal.NewFromFloat(4.50),
wantTaxes: decimal.NewFromFloat(7.50),
},
{
name: "empty writer returns zero totals",
items: []internal.ReportItem{},
wantEarned: decimal.Zero,
wantSpent: decimal.Zero,
wantFees: decimal.Zero,
wantTaxes: decimal.Zero,
},
{
name: "handles zero values",
items: []internal.ReportItem{
{
BuyValue: decimal.Zero,
SellValue: decimal.Zero,
Fees: decimal.Zero,
Taxes: decimal.Zero,
},
},
wantEarned: decimal.Zero,
wantSpent: decimal.Zero,
wantFees: decimal.Zero,
wantTaxes: decimal.Zero,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
aw := &internal.AggregatorWriter{}
ctx := context.Background()
for _, item := range tt.items {
if err := aw.Write(ctx, item); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
assertDecimalEqual(t, "TotalEarned", tt.wantEarned, aw.TotalEarned())
assertDecimalEqual(t, "TotalSpent", tt.wantSpent, aw.TotalSpent())
assertDecimalEqual(t, "TotalFees", tt.wantFees, aw.TotalFees())
assertDecimalEqual(t, "TotalTaxes", tt.wantTaxes, aw.TotalTaxes())
})
}
}
func TestAggregatorWriter_Rounding(t *testing.T) {
tests := []struct {
name string
items []internal.ReportItem
wantEarned decimal.Decimal
wantSpent decimal.Decimal
wantFees decimal.Decimal
wantTaxes decimal.Decimal
}{
{
name: "rounds to 2 decimal places",
items: []internal.ReportItem{
{
BuyValue: decimal.NewFromFloat(100.123456),
SellValue: decimal.NewFromFloat(150.987654),
Fees: decimal.NewFromFloat(2.555555),
Taxes: decimal.NewFromFloat(5.444444),
},
},
wantEarned: decimal.NewFromFloat(150.99),
wantSpent: decimal.NewFromFloat(100.12),
wantFees: decimal.NewFromFloat(2.56),
wantTaxes: decimal.NewFromFloat(5.44),
},
{
name: "rounding accumulates correctly across multiple writes",
items: []internal.ReportItem{
{
BuyValue: decimal.NewFromFloat(10.111),
SellValue: decimal.NewFromFloat(15.999),
Fees: decimal.NewFromFloat(0.555),
Taxes: decimal.NewFromFloat(1.445),
},
{
BuyValue: decimal.NewFromFloat(20.222),
SellValue: decimal.NewFromFloat(25.001),
Fees: decimal.NewFromFloat(0.444),
Taxes: decimal.NewFromFloat(0.555),
},
},
// Each write rounds individually, then accumulates
// First: 10.11 + 20.22 = 30.33
// Second: 16.00 + 25.00 = 41.00
// Fees: 0.56 + 0.44 = 1.00
// Taxes: 1.45 + 0.56 = 2.01
wantSpent: decimal.NewFromFloat(30.33),
wantEarned: decimal.NewFromFloat(41.00),
wantFees: decimal.NewFromFloat(1.00),
wantTaxes: decimal.NewFromFloat(2.01),
},
{
name: "handles small fractions",
items: []internal.ReportItem{
{
BuyValue: decimal.NewFromFloat(0.001),
SellValue: decimal.NewFromFloat(0.009),
Fees: decimal.NewFromFloat(0.0055),
Taxes: decimal.NewFromFloat(0.0045),
},
},
wantSpent: decimal.NewFromFloat(0.00),
wantEarned: decimal.NewFromFloat(0.01),
wantFees: decimal.NewFromFloat(0.01),
wantTaxes: decimal.NewFromFloat(0.00),
},
{
name: "handles large numbers with precision",
items: []internal.ReportItem{
{
BuyValue: decimal.NewFromFloat(999999.996),
SellValue: decimal.NewFromFloat(1000000.004),
Fees: decimal.NewFromFloat(12345.678),
Taxes: decimal.NewFromFloat(54321.123),
},
},
wantSpent: decimal.NewFromFloat(1000000.00),
wantEarned: decimal.NewFromFloat(1000000.00),
wantFees: decimal.NewFromFloat(12345.68),
wantTaxes: decimal.NewFromFloat(54321.12),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
aw := &internal.AggregatorWriter{}
ctx := context.Background()
for _, item := range tt.items {
if err := aw.Write(ctx, item); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
assertDecimalEqual(t, "TotalEarned", tt.wantEarned, aw.TotalEarned())
assertDecimalEqual(t, "TotalSpent", tt.wantSpent, aw.TotalSpent())
assertDecimalEqual(t, "TotalFees", tt.wantFees, aw.TotalFees())
assertDecimalEqual(t, "TotalTaxes", tt.wantTaxes, aw.TotalTaxes())
})
}
}
func TestAggregatorWriter_Items(t *testing.T) {
aw := &internal.AggregatorWriter{}
ctx := context.Background()
for range 5 {
item := internal.ReportItem{Symbol: "TEST"}
if err := aw.Write(ctx, item); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
count := 0
for range aw.Iter() {
count++
}
if count != 5 {
t.Errorf("expected for loop to stop at 5 items, got %d", count)
}
count = 0
for range aw.Iter() {
count++
if count == 3 {
break
}
}
if count != 3 {
t.Errorf("expected for loop to stop at 3 items, got %d", count)
}
}
func TestAggregatorWriter_ThreadSafety(t *testing.T) {
aw := &internal.AggregatorWriter{}
ctx := context.Background()
numGoroutines := 100
writesPerGoroutine := 100
var wg sync.WaitGroup
for range numGoroutines {
wg.Go(func() {
for range writesPerGoroutine {
item := internal.ReportItem{
BuyValue: decimal.NewFromFloat(1.00),
SellValue: decimal.NewFromFloat(2.00),
Fees: decimal.NewFromFloat(0.10),
Taxes: decimal.NewFromFloat(0.20),
}
if err := aw.Write(ctx, item); err != nil {
t.Errorf("unexpected error: %v", err)
}
}
})
}
wg.Wait()
// Verify totals are correct
wantWrites := numGoroutines * writesPerGoroutine
wantSpent := decimal.NewFromFloat(float64(wantWrites) * 1.00)
wantEarned := decimal.NewFromFloat(float64(wantWrites) * 2.00)
wantFees := decimal.NewFromFloat(float64(wantWrites) * 0.10)
wantTaxes := decimal.NewFromFloat(float64(wantWrites) * 0.20)
assertDecimalEqual(t, "TotalSpent", wantSpent, aw.TotalSpent())
assertDecimalEqual(t, "TotalEarned", wantEarned, aw.TotalEarned())
assertDecimalEqual(t, "TotalFees", wantFees, aw.TotalFees())
assertDecimalEqual(t, "TotalTaxes", wantTaxes, aw.TotalTaxes())
}
// Helper function to assert decimal equality
func assertDecimalEqual(t *testing.T, name string, expected, actual decimal.Decimal) {
t.Helper()
if !expected.Equal(actual) {
t.Errorf("want %s to be %s but got %s", name, expected.String(), actual.String())
}
}

5
internal/errors.go Normal file
View File

@@ -0,0 +1,5 @@
package internal
import "fmt"
var ErrInsufficientBoughtVolume = fmt.Errorf("insufficient bought volume")

96
internal/filler.go Normal file
View File

@@ -0,0 +1,96 @@
package internal
import (
"container/list"
"github.com/shopspring/decimal"
)
type Filler struct {
Record
filled decimal.Decimal
}
func NewFiller(r Record) *Filler {
return &Filler{
Record: r,
}
}
// Fill accrues some quantity. Returns how mutch was accrued in the 1st return value and whether
// it was filled or not on the 2nd return value.
func (f *Filler) Fill(quantity decimal.Decimal) (decimal.Decimal, bool) {
unfilled := f.Record.Quantity().Sub(f.filled)
delta := decimal.Min(unfilled, quantity)
f.filled = f.filled.Add(delta)
return delta, f.IsFilled()
}
// IsFilled returns true if the fill is equal to the record quantity.
func (f *Filler) IsFilled() bool {
return f.filled.Equal(f.Quantity())
}
type FillerQueue struct {
l *list.List
}
// Push inserts the Filler at the back of the queue.
func (fq *FillerQueue) Push(f *Filler) {
if f == nil {
return
}
if fq == nil {
// This would cause a panic anyway so, we panic with a more meaningful message
panic("Push to nil FillerQueue")
}
if fq.l == nil {
fq.l = list.New()
}
fq.l.PushBack(f)
}
// Pop removes and returns the first Filler of the queue in the 1st return value. If the list is
// empty returns false on the 2nd return value, true otherwise.
func (fq *FillerQueue) Pop() (*Filler, bool) {
el := fq.frontElement()
if el == nil {
return nil, false
}
val := fq.l.Remove(el)
return val.(*Filler), true
}
// Peek returns the front Filler of the queue in the 1st return value. If the list is empty returns
// false on the 2nd return value, true otherwise.
func (fq *FillerQueue) Peek() (*Filler, bool) {
el := fq.frontElement()
if el == nil {
return nil, false
}
return el.Value.(*Filler), true
}
func (fq *FillerQueue) frontElement() *list.Element {
if fq == nil || fq.l == nil {
return nil
}
return fq.l.Front()
}
// Len returns how many elements are currently on the queue
func (fq *FillerQueue) Len() int {
if fq == nil || fq.l == nil {
return 0
}
return fq.l.Len()
}

187
internal/filler_test.go Normal file
View File

@@ -0,0 +1,187 @@
package internal
import (
"testing"
"github.com/shopspring/decimal"
)
func TestFillerQueue(t *testing.T) {
var recCount int
newRecord := func() Record {
recCount++
return testRecord{
id: recCount,
}
}
var rq FillerQueue
if rq.Len() != 0 {
t.Fatalf("zero value should have zero length")
}
_, ok := rq.Pop()
if ok {
t.Fatalf("Pop() should return (_,false) on a zero value")
}
_, ok = rq.Peek()
if ok {
t.Fatalf("Peek() should return (_,false) on a zero value")
}
rq.Push(nil)
if rq.Len() != 0 {
t.Fatalf("pushing nil should be a no-op")
}
rq.Push(NewFiller(newRecord()))
if rq.Len() != 1 {
t.Fatalf("pushing 1st record should result in length of 1")
}
rq.Push(NewFiller(newRecord()))
if rq.Len() != 2 {
t.Fatalf("pushing 2nd record should result in length of 2")
}
peekFiller, ok := rq.Peek()
if !ok {
t.Fatalf("Peek() should return (_,true) when the list is not empty")
}
if rec, ok := peekFiller.Record.(testRecord); ok {
if rec.id != 1 {
t.Fatalf("Peek() should return the 1st record pushed but returned %d", rec.id)
}
} else {
t.Fatalf("Peek() should return the original record type")
}
if rq.Len() != 2 {
t.Fatalf("Peek() should not affect the list length")
}
popFiller, ok := rq.Pop()
if !ok {
t.Fatalf("Pop() should return (_,true) when the list is not empty")
}
if rec, ok := popFiller.Record.(testRecord); ok {
if rec.id != 1 {
t.Fatalf("Pop() should return the first record pushed but returned %d", rec.id)
}
} else {
t.Fatalf("Pop() should return the original record")
}
if rq.Len() != 1 {
t.Fatalf("Pop() should remove an element from the list")
}
}
func TestFillerQueueNilReceiver(t *testing.T) {
var rq *FillerQueue
if rq.Len() > 0 {
t.Fatalf("nil receiver should have zero length")
}
_, ok := rq.Peek()
if ok {
t.Fatalf("Peek() on a nil receiver should return (_,false)")
}
_, ok = rq.Pop()
if ok {
t.Fatalf("Pop() on a nil receiver should return (_,false)")
}
rq.Push(nil)
if rq.Len() != 0 {
t.Fatalf("Push(nil) on a nil receiver should be a no-op")
}
defer func() {
r := recover()
if r == nil {
t.Fatalf("expected a panic but got nothing")
}
expMsg := "Push to nil FillerQueue"
if msg, ok := r.(string); !ok || msg != expMsg {
t.Fatalf(`want panic message %q but got "%v"`, expMsg, r)
}
}()
rq.Push(NewFiller(nil))
}
type testRecord struct {
Record
id int
quantity decimal.Decimal
}
func (tr testRecord) Quantity() decimal.Decimal {
return tr.quantity
}
func TestFiller_Fill(t *testing.T) {
tests := []struct {
name string
r Record
quantity decimal.Decimal
want decimal.Decimal
wantBool bool
}{
{
name: "fills 0 of zero quantity",
r: &testRecord{quantity: decimal.NewFromFloat(0.0)},
quantity: decimal.Decimal{},
want: decimal.Decimal{},
wantBool: true,
},
{
name: "fills 0 of positive quantity",
r: &testRecord{quantity: decimal.NewFromFloat(100.0)},
quantity: decimal.Decimal{},
want: decimal.Decimal{},
wantBool: false,
},
{
name: "fills 10 out of 100 and no previous fills",
r: &testRecord{quantity: decimal.NewFromFloat(100.0)},
quantity: decimal.NewFromFloat(10),
want: decimal.NewFromFloat(10),
wantBool: false,
},
{
name: "fills 10 out of 10 and no previous fills",
r: &testRecord{quantity: decimal.NewFromFloat(10.0)},
quantity: decimal.NewFromFloat(10),
want: decimal.NewFromFloat(10),
wantBool: true,
},
{
name: "filling 100 fills 10 out of 10 and no previous fills",
r: &testRecord{quantity: decimal.NewFromFloat(10.0)},
quantity: decimal.NewFromFloat(100),
want: decimal.NewFromFloat(10),
wantBool: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f := NewFiller(tt.r)
got, gotBool := f.Fill(tt.quantity)
if !tt.want.Equal(got) {
t.Errorf("want 1st return value to be %v but got %v", tt.want, got)
}
if tt.wantBool != gotBool {
t.Errorf("want 2nd return value to be %v but got %v", tt.wantBool, gotBool)
}
})
}
}

View File

@@ -11,11 +11,11 @@ package mocks
import (
context "context"
big "math/big"
reflect "reflect"
time "time"
internal "github.com/nmoniz/any2anexoj/internal"
decimal "github.com/shopspring/decimal"
gomock "go.uber.org/mock/gomock"
)
@@ -106,11 +106,87 @@ 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() *big.Float {
func (m *MockRecord) Fees() decimal.Decimal {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Fees")
ret0, _ := ret[0].(*big.Float)
ret0, _ := ret[0].(decimal.Decimal)
return ret0
}
@@ -127,28 +203,66 @@ type MockRecordFeesCall struct {
}
// Return rewrite *gomock.Call.Return
func (c *MockRecordFeesCall) Return(arg0 *big.Float) *MockRecordFeesCall {
func (c *MockRecordFeesCall) Return(arg0 decimal.Decimal) *MockRecordFeesCall {
c.Call = c.Call.Return(arg0)
return c
}
// Do rewrite *gomock.Call.Do
func (c *MockRecordFeesCall) Do(f func() *big.Float) *MockRecordFeesCall {
func (c *MockRecordFeesCall) Do(f func() decimal.Decimal) *MockRecordFeesCall {
c.Call = c.Call.Do(f)
return c
}
// DoAndReturn rewrite *gomock.Call.DoAndReturn
func (c *MockRecordFeesCall) DoAndReturn(f func() *big.Float) *MockRecordFeesCall {
func (c *MockRecordFeesCall) DoAndReturn(f func() decimal.Decimal) *MockRecordFeesCall {
c.Call = c.Call.DoAndReturn(f)
return c
}
// Nature mocks base method.
func (m *MockRecord) Nature() internal.Nature {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Nature")
ret0, _ := ret[0].(internal.Nature)
return ret0
}
// Nature indicates an expected call of Nature.
func (mr *MockRecordMockRecorder) Nature() *MockRecordNatureCall {
mr.mock.ctrl.T.Helper()
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Nature", reflect.TypeOf((*MockRecord)(nil).Nature))
return &MockRecordNatureCall{Call: call}
}
// MockRecordNatureCall wrap *gomock.Call
type MockRecordNatureCall struct {
*gomock.Call
}
// Return rewrite *gomock.Call.Return
func (c *MockRecordNatureCall) Return(arg0 internal.Nature) *MockRecordNatureCall {
c.Call = c.Call.Return(arg0)
return c
}
// Do rewrite *gomock.Call.Do
func (c *MockRecordNatureCall) Do(f func() internal.Nature) *MockRecordNatureCall {
c.Call = c.Call.Do(f)
return c
}
// DoAndReturn rewrite *gomock.Call.DoAndReturn
func (c *MockRecordNatureCall) DoAndReturn(f func() internal.Nature) *MockRecordNatureCall {
c.Call = c.Call.DoAndReturn(f)
return c
}
// Price mocks base method.
func (m *MockRecord) Price() *big.Float {
func (m *MockRecord) Price() decimal.Decimal {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Price")
ret0, _ := ret[0].(*big.Float)
ret0, _ := ret[0].(decimal.Decimal)
return ret0
}
@@ -165,28 +279,28 @@ type MockRecordPriceCall struct {
}
// Return rewrite *gomock.Call.Return
func (c *MockRecordPriceCall) Return(arg0 *big.Float) *MockRecordPriceCall {
func (c *MockRecordPriceCall) Return(arg0 decimal.Decimal) *MockRecordPriceCall {
c.Call = c.Call.Return(arg0)
return c
}
// Do rewrite *gomock.Call.Do
func (c *MockRecordPriceCall) Do(f func() *big.Float) *MockRecordPriceCall {
func (c *MockRecordPriceCall) Do(f func() decimal.Decimal) *MockRecordPriceCall {
c.Call = c.Call.Do(f)
return c
}
// DoAndReturn rewrite *gomock.Call.DoAndReturn
func (c *MockRecordPriceCall) DoAndReturn(f func() *big.Float) *MockRecordPriceCall {
func (c *MockRecordPriceCall) DoAndReturn(f func() decimal.Decimal) *MockRecordPriceCall {
c.Call = c.Call.DoAndReturn(f)
return c
}
// Quantity mocks base method.
func (m *MockRecord) Quantity() *big.Float {
func (m *MockRecord) Quantity() decimal.Decimal {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Quantity")
ret0, _ := ret[0].(*big.Float)
ret0, _ := ret[0].(decimal.Decimal)
return ret0
}
@@ -203,19 +317,19 @@ type MockRecordQuantityCall struct {
}
// Return rewrite *gomock.Call.Return
func (c *MockRecordQuantityCall) Return(arg0 *big.Float) *MockRecordQuantityCall {
func (c *MockRecordQuantityCall) Return(arg0 decimal.Decimal) *MockRecordQuantityCall {
c.Call = c.Call.Return(arg0)
return c
}
// Do rewrite *gomock.Call.Do
func (c *MockRecordQuantityCall) Do(f func() *big.Float) *MockRecordQuantityCall {
func (c *MockRecordQuantityCall) Do(f func() decimal.Decimal) *MockRecordQuantityCall {
c.Call = c.Call.Do(f)
return c
}
// DoAndReturn rewrite *gomock.Call.DoAndReturn
func (c *MockRecordQuantityCall) DoAndReturn(f func() *big.Float) *MockRecordQuantityCall {
func (c *MockRecordQuantityCall) DoAndReturn(f func() decimal.Decimal) *MockRecordQuantityCall {
c.Call = c.Call.DoAndReturn(f)
return c
}
@@ -297,10 +411,10 @@ func (c *MockRecordSymbolCall) DoAndReturn(f func() string) *MockRecordSymbolCal
}
// Taxes mocks base method.
func (m *MockRecord) Taxes() *big.Float {
func (m *MockRecord) Taxes() decimal.Decimal {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Taxes")
ret0, _ := ret[0].(*big.Float)
ret0, _ := ret[0].(decimal.Decimal)
return ret0
}
@@ -317,19 +431,19 @@ type MockRecordTaxesCall struct {
}
// Return rewrite *gomock.Call.Return
func (c *MockRecordTaxesCall) Return(arg0 *big.Float) *MockRecordTaxesCall {
func (c *MockRecordTaxesCall) Return(arg0 decimal.Decimal) *MockRecordTaxesCall {
c.Call = c.Call.Return(arg0)
return c
}
// Do rewrite *gomock.Call.Do
func (c *MockRecordTaxesCall) Do(f func() *big.Float) *MockRecordTaxesCall {
func (c *MockRecordTaxesCall) Do(f func() decimal.Decimal) *MockRecordTaxesCall {
c.Call = c.Call.Do(f)
return c
}
// DoAndReturn rewrite *gomock.Call.DoAndReturn
func (c *MockRecordTaxesCall) DoAndReturn(f func() *big.Float) *MockRecordTaxesCall {
func (c *MockRecordTaxesCall) DoAndReturn(f func() decimal.Decimal) *MockRecordTaxesCall {
c.Call = c.Call.DoAndReturn(f)
return c
}

22
internal/nature.go Normal file
View File

@@ -0,0 +1,22 @@
package internal
type Nature string
const (
// NatureUnknown is the zero value of Nature type
NatureUnknown Nature = ""
// NatureG01 describes selling of stocks per table VII: Alienação onerosa de ações/partes sociais
NatureG01 Nature = "G01"
// NatureG20 describes selling units in investment funds (including ETFs) as per table VII:
// Resgates ou alienação de unidades de participação ou liquidação de fundos de investimento
NatureG20 Nature = "G20"
)
func (n Nature) String() string {
if n == "" {
return "unknown"
}
return string(n)
}

38
internal/nature_test.go Normal file
View File

@@ -0,0 +1,38 @@
package internal_test
import (
"testing"
"github.com/nmoniz/any2anexoj/internal"
)
func TestNature_String(t *testing.T) {
tests := []struct {
name string
nature internal.Nature
want string
}{
{
name: "return unknown",
want: "unknown",
},
{
name: "return G01",
nature: internal.NatureG01,
want: "G01",
},
{
name: "return G20",
nature: internal.NatureG20,
want: "G20",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tt.nature.String()
if tt.want != got {
t.Fatalf("want %q but got %q", tt.want, got)
}
})
}
}

126
internal/open_figi.go Normal file
View File

@@ -0,0 +1,126 @@
package internal
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"sync"
"time"
"github.com/biter777/countries"
"golang.org/x/time/rate"
)
// OpenFIGI is a small adapter for the openfigi.com api
type OpenFIGI struct {
client *http.Client
mappingLimiter *rate.Limiter
mu sync.RWMutex
// TODO: there's no eviction policy at the moment as this is only used by short-lived application
// which processes a relatively small amount of records. We need to consider using an external
// cache lib (like golang-lru or go-cache) if this becomes a problem or implement this ourselves.
securityTypeCache map[string]string
}
func NewOpenFIGI(c *http.Client) *OpenFIGI {
return &OpenFIGI{
client: c,
mappingLimiter: rate.NewLimiter(rate.Every(time.Minute), 25), // https://www.openfigi.com/api/documentation#rate-limits
securityTypeCache: make(map[string]string),
}
}
func (of *OpenFIGI) SecurityTypeByISIN(ctx context.Context, isin string) (string, error) {
of.mu.RLock()
if secType, ok := of.securityTypeCache[isin]; ok {
of.mu.RUnlock()
return secType, nil
}
of.mu.RUnlock()
of.mu.Lock()
defer of.mu.Unlock()
// we check again because there could be more than one concurrent cache miss and we want only one
// of them to result in an actual request. When the first one releases the lock the following
// reads will hit the cache.
if secType, ok := of.securityTypeCache[isin]; ok {
return secType, nil
}
if len(isin) != 12 || countries.ByName(isin[:2]) == countries.Unknown {
return "", fmt.Errorf("invalid ISIN: %s", isin)
}
rawBody, err := json.Marshal([]mappingRequestBody{{
IDType: "ID_ISIN",
IDValue: isin,
}})
if err != nil {
return "", fmt.Errorf("marshal mapping request body: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://api.openfigi.com/v3/mapping", bytes.NewBuffer(rawBody))
if err != nil {
return "", fmt.Errorf("create mapping request: %w", err)
}
req.Header.Add("Content-Type", "application/json")
err = of.mappingLimiter.Wait(ctx)
if err != nil {
return "", fmt.Errorf("wait for mapping request capacity: %w", err)
}
res, err := of.client.Do(req)
if err != nil {
return "", fmt.Errorf("make mapping request: %w", err)
}
defer res.Body.Close()
if res.StatusCode >= 400 {
return "", fmt.Errorf("bad mapping response status code: %s", res.Status)
}
var resBody []mappingResponseBody
err = json.NewDecoder(res.Body).Decode(&resBody)
if err != nil {
return "", fmt.Errorf("unmarshal response: %w", err)
}
if len(resBody) == 0 {
return "", fmt.Errorf("missing top-level elements")
}
if len(resBody[0].Data) == 0 {
return "", fmt.Errorf("missing data elements")
}
// It is not possible that an isin is assign to different security types, therefore we can assume
// all entries have the same securityType value.
secType := resBody[0].Data[0].SecurityType
if secType == "" {
return "", fmt.Errorf("empty security type returned for ISIN: %s", isin)
}
of.securityTypeCache[isin] = secType
return secType, nil
}
type mappingRequestBody struct {
IDType string `json:"idType"`
IDValue string `json:"idValue"`
}
type mappingResponseBody struct {
Data []struct {
FIGI string `json:"figi"`
SecurityType string `json:"securityType"`
Ticker string `json:"ticker"`
} `json:"data"`
}

182
internal/open_figi_test.go Normal file
View File

@@ -0,0 +1,182 @@
package internal_test
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"testing"
"time"
"github.com/nmoniz/any2anexoj/internal"
)
func TestOpenFIGI_SecurityTypeByISIN(t *testing.T) {
tests := []struct {
name string // description of this test case
client *http.Client
isin string
want string
wantErr bool
}{
{
name: "all good",
client: NewTestClient(t, func(req *http.Request) (*http.Response, error) {
return &http.Response{
Status: http.StatusText(http.StatusOK),
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewBufferString(`[{"data":[{"figi":"BBG000BJJR23","name":"AIRBUS SE","ticker":"EADSF","exchCode":"US","compositeFIGI":"BBG000BJJR23","securityType":"Common Stock","marketSector":"Equity","shareClassFIGI":"BBG001S8TFZ6","securityType2":"Common Stock","securityDescription":"EADSF"},{"figi":"BBG000BJJXJ2","name":"AIRBUS SE","ticker":"EADSF","exchCode":"PQ","compositeFIGI":"BBG000BJJR23","securityType":"Common Stock","marketSector":"Equity","shareClassFIGI":"BBG001S8TFZ6","securityType2":"Common Stock","securityDescription":"EADSF"}]}]`)),
}, nil
}),
isin: "NL0000235190",
want: "Common Stock",
},
{
name: "bad status code",
client: NewTestClient(t, func(req *http.Request) (*http.Response, error) {
return &http.Response{
Status: http.StatusText(http.StatusTooManyRequests),
StatusCode: http.StatusTooManyRequests,
}, nil
}),
isin: "NL0000235190",
wantErr: true,
},
{
name: "bad json",
client: NewTestClient(t, func(req *http.Request) (*http.Response, error) {
return &http.Response{
Status: http.StatusText(http.StatusOK),
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewBufferString(`{"bad": "json"}`)),
}, nil
}),
isin: "NL0000235190",
wantErr: true,
},
{
name: "empty top-level",
client: NewTestClient(t, func(req *http.Request) (*http.Response, error) {
return &http.Response{
Status: http.StatusText(http.StatusOK),
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewBufferString(`[]`)),
}, nil
}),
isin: "NL0000235190",
wantErr: true,
},
{
name: "empty data elements",
client: NewTestClient(t, func(req *http.Request) (*http.Response, error) {
return &http.Response{
Status: http.StatusText(http.StatusOK),
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewBufferString(`[{"data":[]}]`)),
}, nil
}),
isin: "NL0000235190",
wantErr: true,
},
{
name: "empty securityType",
client: NewTestClient(t, func(req *http.Request) (*http.Response, error) {
return &http.Response{
Status: http.StatusText(http.StatusOK),
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewBufferString(`[{"data":[{"securityType":""}]}]`)),
}, nil
}),
isin: "NL0000235190",
wantErr: true,
},
{
name: "client error",
client: NewTestClient(t, func(req *http.Request) (*http.Response, error) {
return nil, fmt.Errorf("boom")
}),
isin: "NL0000235190",
wantErr: true,
},
{
name: "empty isin",
client: NewTestClient(t, func(req *http.Request) (*http.Response, error) {
t.Fatalf("should not make api request")
return nil, nil
}),
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
of := internal.NewOpenFIGI(tt.client)
got, gotErr := of.SecurityTypeByISIN(context.Background(), tt.isin)
if gotErr != nil {
if !tt.wantErr {
t.Errorf("want success but failed: %v", gotErr)
}
return
}
if tt.wantErr {
t.Fatal("want error but none")
}
if tt.want != got {
t.Fatalf("want security type to be %s but got %s", tt.want, got)
}
})
}
}
func TestOpenFIGI_SecurityTypeByISIN_Cache(t *testing.T) {
var alreadyCalled bool
c := NewTestClient(t, func(req *http.Request) (*http.Response, error) {
if alreadyCalled {
t.Fatalf("want requests to be cached")
}
alreadyCalled = true
return &http.Response{
Status: http.StatusText(http.StatusOK),
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewBufferString(`[{"data":[{"securityType":"Common Stock"}]}]`)),
}, nil
})
of := internal.NewOpenFIGI(c)
got, gotErr := of.SecurityTypeByISIN(t.Context(), "NL0000235190")
if gotErr != nil {
t.Fatalf("want 1st success call but got error: %v", gotErr)
}
if got != "Common Stock" {
t.Fatalf("want 1st securityType to be %q but got %q", "Common Stock", got)
}
got, gotErr = of.SecurityTypeByISIN(t.Context(), "NL0000235190")
if gotErr != nil {
t.Fatalf("want 2nd success call but got error: %v", gotErr)
}
if got != "Common Stock" {
t.Fatalf("want 2nd securityType to be %q but got %q", "Common Stock", got)
}
}
type RoundTripFunc func(req *http.Request) (*http.Response, error)
func (f RoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
func NewTestClient(t testing.TB, fn RoundTripFunc) *http.Client {
t.Helper()
return &http.Client{
Timeout: time.Second,
Transport: fn,
}
}

View File

@@ -1,80 +0,0 @@
package internal
import (
"container/list"
"math/big"
"time"
)
type Record interface {
Symbol() string
Side() Side
Price() *big.Float
Quantity() *big.Float
Timestamp() time.Time
Fees() *big.Float
Taxes() *big.Float
}
type RecordQueue struct {
l *list.List
}
// Push inserts the Record at the back of the queue. If pushing a nil Record then it's a no-op.
func (rq *RecordQueue) Push(r Record) {
if r == nil {
return
}
if rq == nil {
// This would cause a panic anyway so, we panic with a more meaningful message
panic("Push to nil RecordQueue")
}
if rq.l == nil {
rq.l = list.New()
}
rq.l.PushBack(r)
}
// Pop removes and returns the first Record of the queue in the 1st return value. If the list is
// empty returns false on the 2nd return value, true otherwise.
func (rq *RecordQueue) Pop() (Record, bool) {
el := rq.frontElement()
if el == nil {
return nil, false
}
val := rq.l.Remove(el)
return val.(Record), true
}
// Peek returns the front Record of the queue in the 1st return value. If the list is empty returns
// false on the 2nd return value, true otherwise.
func (rq *RecordQueue) Peek() (Record, bool) {
el := rq.frontElement()
if el == nil {
return nil, false
}
return el.Value.(Record), true
}
func (rq *RecordQueue) frontElement() *list.Element {
if rq == nil || rq.l == nil {
return nil
}
return rq.l.Front()
}
// Len returns how many elements are currently on the queue
func (rq *RecordQueue) Len() int {
if rq == nil || rq.l == nil {
return 0
}
return rq.l.Len()
}

View File

@@ -1,122 +0,0 @@
package internal
import (
"testing"
)
func TestRecordQueue(t *testing.T) {
var recCount int
newRecord := func() Record {
recCount++
return testRecord{
id: recCount,
}
}
var rq RecordQueue
if rq.Len() != 0 {
t.Fatalf("zero value should have zero lenght")
}
_, ok := rq.Pop()
if ok {
t.Fatalf("Pop() should return (_,false) on a zero value")
}
_, ok = rq.Peek()
if ok {
t.Fatalf("Peek() should return (_,false) on a zero value")
}
rq.Push(nil)
if rq.Len() != 0 {
t.Fatalf("pushing nil should be a no-op")
}
rq.Push(newRecord())
if rq.Len() != 1 {
t.Fatalf("pushing 1st record should result in lenght of 1")
}
rq.Push(newRecord())
if rq.Len() != 2 {
t.Fatalf("pushing 2nd record should result in lenght of 2")
}
peekRec, ok := rq.Peek()
if !ok {
t.Fatalf("Peek() should return (_,true) when the list is not empty")
}
if peekRec, ok := peekRec.(testRecord); ok {
if peekRec.id != 1 {
t.Fatalf("Peek() should return the 1st record pushed but returned %d", peekRec.id)
}
} else {
t.Fatalf("Peek() should return the original record type")
}
if rq.Len() != 2 {
t.Fatalf("Peek() should not affect the list length")
}
popRec, ok := rq.Pop()
if !ok {
t.Fatalf("Pop() should return (_,true) when the list is not empty")
}
if rec, ok := popRec.(testRecord); ok {
if rec.id != 1 {
t.Fatalf("Pop() should return the first record pushed but returned %d", rec.id)
}
} else {
t.Fatalf("Pop() should return the original record")
}
if rq.Len() != 1 {
t.Fatalf("Pop() should remove an element from the list")
}
}
func TestRecordQueueNilReceiver(t *testing.T) {
var rq *RecordQueue
if rq.Len() > 0 {
t.Fatalf("nil receiver should have zero lenght")
}
_, ok := rq.Peek()
if ok {
t.Fatalf("Peek() on a nil receiver should return (_,false)")
}
_, ok = rq.Pop()
if ok {
t.Fatalf("Pop() on a nil receiver should return (_,false)")
}
rq.Push(nil)
if rq.Len() != 0 {
t.Fatalf("Push(nil) on a nil receiver should be a no-op")
}
defer func() {
r := recover()
if r == nil {
t.Fatalf("expected a panic but got nothing")
}
expMsg := "Push to nil RecordQueue"
if msg, ok := r.(string); !ok || msg != expMsg {
t.Fatalf(`want panic message %q but got "%v"`, expMsg, r)
}
}()
rq.Push(testRecord{})
}
type testRecord struct {
Record
id int
}

View File

@@ -5,22 +5,53 @@ import (
"errors"
"fmt"
"io"
"math/big"
"time"
"github.com/shopspring/decimal"
)
type Record interface {
Symbol() string
Nature() Nature
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
Nature Nature
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
}
func BuildReport(ctx context.Context, reader RecordReader, writer ReportWriter) error {
buys := make(map[string]*RecordQueue)
buys := make(map[string]*FillerQueue)
for {
select {
@@ -37,7 +68,7 @@ func BuildReport(ctx context.Context, reader RecordReader, writer ReportWriter)
buyQueue, ok := buys[rec.Symbol()]
if !ok {
buyQueue = new(RecordQueue)
buyQueue = new(FillerQueue)
buys[rec.Symbol()] = buyQueue
}
@@ -49,42 +80,45 @@ func BuildReport(ctx context.Context, reader RecordReader, writer ReportWriter)
}
}
func processRecord(ctx context.Context, q *RecordQueue, rec Record, writer ReportWriter) error {
func processRecord(ctx context.Context, q *FillerQueue, rec Record, writer ReportWriter) error {
switch rec.Side() {
case SideBuy:
q.Push(rec)
q.Push(NewFiller(rec))
case SideSell:
unmatchedQty := new(big.Float).Copy(rec.Quantity())
zero := new(big.Float)
unmatchedQty := rec.Quantity()
for unmatchedQty.Cmp(zero) > 0 {
for unmatchedQty.IsPositive() {
buy, ok := q.Peek()
if !ok {
return ErrInsufficientBoughtVolume
}
var matchedQty *big.Float
if buy.Quantity().Cmp(unmatchedQty) > 0 {
matchedQty = unmatchedQty
buy.Quantity().Sub(buy.Quantity(), unmatchedQty)
} else {
matchedQty = buy.Quantity()
q.Pop()
matchedQty, filled := buy.Fill(unmatchedQty)
if filled {
_, ok := q.Pop()
if !ok {
return fmt.Errorf("pop empty filler queue")
}
}
unmatchedQty.Sub(unmatchedQty, matchedQty)
unmatchedQty = unmatchedQty.Sub(matchedQty)
sellValue := new(big.Float).Mul(matchedQty, rec.Price())
buyValue := new(big.Float).Mul(matchedQty, buy.Price())
buyValue := matchedQty.Mul(buy.Price())
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,
SellTimestamp: rec.Timestamp(),
Fees: new(big.Float).Add(buy.Fees(), rec.Fees()),
Taxes: new(big.Float).Add(buy.Taxes(), rec.Fees()),
Fees: buy.Fees().Add(rec.Fees()),
Taxes: buy.Taxes().Add(rec.Taxes()),
Nature: buy.Nature(),
})
if err != nil {
return fmt.Errorf("write report item: %w", err)
@@ -97,18 +131,3 @@ func processRecord(ctx context.Context, q *RecordQueue, rec Record, writer Repor
return nil
}
type ReportItem struct {
BuyValue *big.Float
BuyTimestamp time.Time
SellValue *big.Float
SellTimestamp time.Time
Fees *big.Float
Taxes *big.Float
}
func (ri ReportItem) RealisedPnL() *big.Float {
return new(big.Float).Sub(ri.SellValue, ri.BuyValue)
}
var ErrInsufficientBoughtVolume = fmt.Errorf("insufficient bought volume")

View File

@@ -2,17 +2,19 @@ package internal_test
import (
"context"
"fmt"
"io"
"math/big"
"testing"
"time"
"github.com/biter777/countries"
"github.com/nmoniz/any2anexoj/internal"
"github.com/nmoniz/any2anexoj/internal/mocks"
"github.com/shopspring/decimal"
"go.uber.org/mock/gomock"
)
func TestReporter_Run(t *testing.T) {
func TestBuildReport(t *testing.T) {
now := time.Now()
ctrl := gomock.NewController(t)
@@ -32,13 +34,13 @@ func TestReporter_Run(t *testing.T) {
}).Times(3)
writer := mocks.NewMockReportWriter(ctrl)
writer.EXPECT().Write(gomock.Any(), gomock.Eq(internal.ReportItem{
BuyValue: new(big.Float).SetFloat64(200.0),
writer.EXPECT().Write(gomock.Any(), eqReportItem(internal.ReportItem{
BuyValue: decimal.NewFromFloat(200.0),
BuyTimestamp: now,
SellValue: new(big.Float).SetFloat64(250.0),
SellValue: decimal.NewFromFloat(250.0),
SellTimestamp: now.Add(1),
Fees: new(big.Float),
Taxes: new(big.Float),
Fees: decimal.Decimal{},
Taxes: decimal.Decimal{},
})).Times(1)
gotErr := internal.BuildReport(t.Context(), reader, writer)
@@ -49,12 +51,50 @@ func TestReporter_Run(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().Price().Return(big.NewFloat(price)).AnyTimes()
rec.EXPECT().Quantity().Return(big.NewFloat(quantity)).AnyTimes()
rec.EXPECT().Side().Return(side).AnyTimes()
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().Timestamp().Return(ts).AnyTimes()
rec.EXPECT().Fees().Return(new(big.Float)).AnyTimes()
rec.EXPECT().Taxes().Return(new(big.Float)).AnyTimes()
rec.EXPECT().Fees().Return(decimal.Decimal{}).AnyTimes()
rec.EXPECT().Taxes().Return(decimal.Decimal{}).AnyTimes()
rec.EXPECT().Nature().Return(internal.NatureG01).AnyTimes()
return rec
}
func eqReportItem(ri internal.ReportItem) ReportItemMatcher {
return ReportItemMatcher{
ReportItem: ri,
}
}
type ReportItemMatcher struct {
internal.ReportItem
}
// Matches implements gomock.Matcher.
func (m ReportItemMatcher) Matches(x any) bool {
if x == nil {
return false
}
switch other := x.(type) {
case internal.ReportItem:
return m.BuyValue.Equal(other.BuyValue) &&
m.BuyTimestamp.Equal(other.BuyTimestamp) &&
m.SellValue.Equal(other.SellValue) &&
m.SellTimestamp.Equal(other.SellTimestamp) &&
m.Fees.Equal(other.Fees) &&
m.Taxes.Equal(other.Taxes)
default:
return false
}
}
func (m ReportItemMatcher) String() string {
return fmt.Sprintf("is equivalent to %v", m.ReportItem)
}
var _ gomock.Matcher = (*ReportItemMatcher)(nil)

View File

@@ -19,10 +19,12 @@ func (d Side) String() string {
}
}
// 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

@@ -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 %+f on %s\n", rl.counter, ri.RealisedPnL(), ri.SellTimestamp.Format(time.RFC3339))
return err
}

View File

@@ -1,93 +0,0 @@
package internal_test
import (
"bytes"
"fmt"
"math/big"
"testing"
"time"
"github.com/nmoniz/any2anexoj/internal"
)
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: new(big.Float).SetFloat64(100.0),
SellValue: new(big.Float).SetFloat64(200.0),
SellTimestamp: tNow,
},
},
want: []string{
fmt.Sprintf("%6d - realised +100.000000 on %s\n", 1, tNow.Format(time.RFC3339)),
},
},
{
name: "single item negative",
items: []internal.ReportItem{
{
BuyValue: new(big.Float).SetFloat64(200.0),
SellValue: new(big.Float).SetFloat64(150.0),
SellTimestamp: tNow,
},
},
want: []string{
fmt.Sprintf("%6d - realised -50.000000 on %s\n", 1, tNow.Format(time.RFC3339)),
},
},
{
name: "multiple items",
items: []internal.ReportItem{
{
BuyValue: new(big.Float).SetFloat64(100.0),
SellValue: new(big.Float).SetFloat64(200.0),
SellTimestamp: tNow,
},
{
BuyValue: new(big.Float).SetFloat64(200.0),
SellValue: new(big.Float).SetFloat64(150.0),
SellTimestamp: tNow.Add(1),
},
},
want: []string{
fmt.Sprintf("%6d - realised +100.000000 on %s\n", 1, tNow.Format(time.RFC3339)),
fmt.Sprintf("%6d - realised -50.000000 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)
}
}
})
}
}

View File

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

View File

@@ -5,58 +5,78 @@ import (
"encoding/csv"
"fmt"
"io"
"math/big"
"log/slog"
"strings"
"sync"
"time"
"github.com/biter777/countries"
"github.com/nmoniz/any2anexoj/internal"
"github.com/shopspring/decimal"
)
type Record struct {
symbol string
side internal.Side
quantity *big.Float
price *big.Float
timestamp time.Time
fees *big.Float
taxes *big.Float
side internal.Side
quantity decimal.Decimal
price decimal.Decimal
fees decimal.Decimal
taxes decimal.Decimal
// natureGetter allows us to defer the operation of figuring out the nature to only when/if needed.
natureGetter func() internal.Nature
}
func (r Record) Symbol() string {
return r.symbol
}
func (r Record) Side() internal.Side {
return r.side
}
func (r Record) Quantity() *big.Float {
return r.quantity
}
func (r Record) Price() *big.Float {
return r.price
}
func (r Record) Timestamp() time.Time {
return r.timestamp
}
func (r Record) Fees() *big.Float {
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
}
func (r Record) Quantity() decimal.Decimal {
return r.quantity
}
func (r Record) Price() decimal.Decimal {
return r.price
}
func (r Record) Fees() decimal.Decimal {
return r.fees
}
func (r Record) Taxes() *big.Float {
func (r Record) Taxes() decimal.Decimal {
return r.taxes
}
func (r Record) Nature() internal.Nature {
return r.natureGetter()
}
type RecordReader struct {
reader *csv.Reader
figi *internal.OpenFIGI
}
func NewRecordReader(r io.Reader) *RecordReader {
func NewRecordReader(r io.Reader, f *internal.OpenFIGI) *RecordReader {
return &RecordReader{
reader: csv.NewReader(r),
figi: f,
}
}
@@ -67,7 +87,7 @@ const (
LimitSell = "limit sell"
)
func (rr RecordReader) ReadRecord(_ context.Context) (internal.Record, error) {
func (rr RecordReader) ReadRecord(ctx context.Context) (internal.Record, error) {
for {
raw, err := rr.reader.Read()
if err != nil {
@@ -101,46 +121,66 @@ func (rr RecordReader) ReadRecord(_ context.Context) (internal.Record, error) {
return Record{}, fmt.Errorf("parse record timestamp: %w", err)
}
convertionFee, err := parseOptinalDecimal(raw[16])
conversionFee, err := parseOptionalDecimal(raw[16])
if err != nil {
return Record{}, fmt.Errorf("parse record convertion fee: %w", err)
return Record{}, fmt.Errorf("parse record conversion fee: %w", err)
}
stampDutyTax, err := parseOptinalDecimal(raw[14])
stampDutyTax, err := parseOptionalDecimal(raw[14])
if err != nil {
return Record{}, fmt.Errorf("parse record stamp duty tax: %w", err)
}
frenchTxTax, err := parseOptinalDecimal(raw[18])
frenchTxTax, err := parseOptionalDecimal(raw[18])
if err != nil {
return Record{}, fmt.Errorf("parse record french transaction tax: %w", err)
}
return Record{
symbol: raw[2],
side: side,
quantity: qant,
price: price,
timestamp: ts,
fees: convertionFee,
taxes: new(big.Float).Add(stampDutyTax, frenchTxTax),
symbol: raw[2],
side: side,
quantity: qant,
price: price,
fees: conversionFee,
taxes: stampDutyTax.Add(frenchTxTax),
timestamp: ts,
natureGetter: figiNatureGetter(ctx, rr.figi, raw[2]),
}, nil
}
}
// parseFloat attempts to parse a string using a standard precision and rounding mode.
// Using this function helps avoid issues around converting values due to sligh parameter changes.
func parseDecimal(s string) (*big.Float, error) {
f, _, err := big.ParseFloat(s, 10, 128, big.ToZero)
return f, err
func figiNatureGetter(ctx context.Context, of *internal.OpenFIGI, isin string) func() internal.Nature {
return sync.OnceValue(func() internal.Nature {
secType, err := of.SecurityTypeByISIN(ctx, isin)
if err != nil {
slog.Error("failed to get security type by ISIN", slog.Any("err", err), slog.String("isin", isin))
return internal.NatureUnknown
}
switch secType {
case "Common Stock":
return internal.NatureG01
case "ETP":
return internal.NatureG20
default:
slog.Error("got unsupported security type for ISIN", slog.String("isin", isin), slog.String("securityType", secType))
return internal.NatureUnknown
}
})
}
// parseOptinalDecimal behaves the same as parseDecimal but returns 0 when len(s) is 0 instead of
// parseFloat attempts to parse a string using a standard precision and rounding mode.
// Using this function helps avoid issues around converting values due to minor parameter changes.
func parseDecimal(s string) (decimal.Decimal, error) {
return decimal.NewFromString(s)
}
// parseOptionalDecimal behaves the same as parseDecimal but returns 0 when len(s) is 0 instead of
// error.
// Using this function helps avoid issues around converting values due to sligh parameter changes.
func parseOptinalDecimal(s string) (*big.Float, error) {
// Using this function helps avoid issues around converting values due to minor parameter changes.
func parseOptionalDecimal(s string) (decimal.Decimal, error) {
if len(s) == 0 {
return new(big.Float), nil
return decimal.Decimal{}, nil
}
return parseDecimal(s)

View File

@@ -2,12 +2,14 @@ package trading212
import (
"bytes"
"fmt"
"io"
"math/big"
"net/http"
"testing"
"time"
"github.com/nmoniz/any2anexoj/internal"
"github.com/shopspring/decimal"
)
func TestRecordReader_ReadRecord(t *testing.T) {
@@ -25,74 +27,86 @@ func TestRecordReader_ReadRecord(t *testing.T) {
},
{
name: "well-formed buy",
r: bytes.NewBufferString(`Market buy,2025-07-03 10:44:29,SYM123456ABXY,ABXY,"Aspargus Brocoli",EOF987654321,2.4387014200,7.3690000000,USD,1.17995999,,"EUR",15.25,"EUR",0.25,"EUR",0.02,"EUR",,`),
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: "SYM123456ABXY",
side: internal.SideBuy,
quantity: ShouldParseDecimal(t, "2.4387014200"),
price: ShouldParseDecimal(t, "7.3690000000"),
timestamp: time.Date(2025, 7, 3, 10, 44, 29, 0, time.UTC),
fees: ShouldParseDecimal(t, "0.02"),
taxes: ShouldParseDecimal(t, "0.25"),
symbol: "XX1234567890",
side: internal.SideBuy,
quantity: ShouldParseDecimal(t, "2.4387014200"),
price: ShouldParseDecimal(t, "7.3690000000"),
timestamp: time.Date(2025, 7, 3, 10, 44, 29, 0, time.UTC),
fees: ShouldParseDecimal(t, "0.02"),
taxes: ShouldParseDecimal(t, "0.25"),
natureGetter: func() internal.Nature { return internal.NatureG01 },
},
},
{
name: "well-formed sell",
r: bytes.NewBufferString(`Market sell,2025-08-04 11:45:30,IE000GA3D489,ABXY,"Aspargus Brocoli",EOF987654321,2.4387014200,7.9999999999,USD,1.17995999,,"EUR",15.25,"EUR",,,0.02,"EUR",0.1,"EUR"`),
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: "IE000GA3D489",
side: internal.SideSell,
quantity: ShouldParseDecimal(t, "2.4387014200"),
price: ShouldParseDecimal(t, "7.9999999999"),
timestamp: time.Date(2025, 8, 4, 11, 45, 30, 0, time.UTC),
fees: ShouldParseDecimal(t, "0.02"),
taxes: ShouldParseDecimal(t, "0.1"),
symbol: "XX1234567890",
side: internal.SideSell,
quantity: ShouldParseDecimal(t, "2.4387014200"),
price: ShouldParseDecimal(t, "7.9999999999"),
timestamp: time.Date(2025, 8, 4, 11, 45, 30, 0, time.UTC),
fees: ShouldParseDecimal(t, "0.02"),
taxes: ShouldParseDecimal(t, "0.1"),
natureGetter: func() internal.Nature { return internal.NatureG01 },
},
},
{
name: "malformed side",
r: bytes.NewBufferString(`Aljksdaf Balsjdkf,2025-08-04 11:45:39,IE000GA3D489,ABXY,"Aspargus Brocoli",EOF987654321,2.4387014200,7.9999999999,USD,1.17995999,,"EUR",15.25,"EUR",,,0.02,"EUR",,`),
r: bytes.NewBufferString(`Aljksdaf Balsjdkf,2025-08-04 11:45:39,IE000GA3D489,ABXY,"Aspargus Broccoli",EOF987654321,2.4387014200,7.9999999999,USD,1.17995999,,"EUR",15.25,"EUR",,,0.02,"EUR",,`),
wantErr: true,
},
{
name: "empty side",
r: bytes.NewBufferString(`,2025-08-04 11:45:39,IE000GA3D489,ABXY,"Aspargus Brocoli",EOF987654321,0x1234,7.9999999999,USD,1.17995999,,"EUR",15.25,"EUR",,,0.02,"EUR",,`),
r: bytes.NewBufferString(`,2025-08-04 11:45:39,IE000GA3D489,ABXY,"Aspargus Broccoli",EOF987654321,0x1234,7.9999999999,USD,1.17995999,,"EUR",15.25,"EUR",,,0.02,"EUR",,`),
wantErr: true,
},
{
name: "malformed qantity",
r: bytes.NewBufferString(`Market sell,2025-08-04 11:45:39,IE000GA3D489,ABXY,"Aspargus Brocoli",EOF987654321,0x1234,7.9999999999,USD,1.17995999,,"EUR",15.25,"EUR",,,0.02,"EUR",,`),
r: bytes.NewBufferString(`Market sell,2025-08-04 11:45:39,IE000GA3D489,ABXY,"Aspargus Broccoli",EOF987654321,0x1234,7.9999999999,USD,1.17995999,,"EUR",15.25,"EUR",,,0.02,"EUR",,`),
wantErr: true,
},
{
name: "empty qantity",
r: bytes.NewBufferString(`Market sell,2025-08-04 11:45:39,IE000GA3D489,ABXY,"Aspargus Brocoli",EOF987654321,,7.9999999999,USD,1.17995999,,"EUR",15.25,"EUR",,,0.02,"EUR",,`),
r: bytes.NewBufferString(`Market sell,2025-08-04 11:45:39,IE000GA3D489,ABXY,"Aspargus Broccoli",EOF987654321,,7.9999999999,USD,1.17995999,,"EUR",15.25,"EUR",,,0.02,"EUR",,`),
wantErr: true,
},
{
name: "malformed price",
r: bytes.NewBufferString(`Market sell,2025-08-04 11:45:39,IE000GA3D489,ABXY,"Aspargus Brocoli",EOF987654321,2.4387014200,0b101010,USD,1.17995999,,"EUR",15.25,"EUR",,,0.02,"EUR",,`),
r: bytes.NewBufferString(`Market sell,2025-08-04 11:45:39,IE000GA3D489,ABXY,"Aspargus Broccoli",EOF987654321,2.4387014200,0b101010,USD,1.17995999,,"EUR",15.25,"EUR",,,0.02,"EUR",,`),
wantErr: true,
},
{
name: "empty price",
r: bytes.NewBufferString(`Market sell,2025-08-04 11:45:39,IE000GA3D489,ABXY,"Aspargus Brocoli",EOF987654321,2.4387014200,,USD,1.17995999,,"EUR",15.25,"EUR",,,0.02,"EUR",,`),
r: bytes.NewBufferString(`Market sell,2025-08-04 11:45:39,IE000GA3D489,ABXY,"Aspargus Broccoli",EOF987654321,2.4387014200,,USD,1.17995999,,"EUR",15.25,"EUR",,,0.02,"EUR",,`),
wantErr: true,
},
{
name: "malformed fees",
r: bytes.NewBufferString(`Market sell,2025-08-04 11:45:30,IE000GA3D489,ABXY,"Aspargus Broccoli",EOF987654321,2.4387014200,7.9999999999,USD,1.17995999,,"EUR",15.25,"EUR",,,BAD,"EUR",0.1,"EUR"`),
wantErr: true,
},
{
name: "malformed taxes",
r: bytes.NewBufferString(`Market sell,2025-08-04 11:45:30,IE000GA3D489,ABXY,"Aspargus Broccoli",EOF987654321,2.4387014200,7.9999999999,USD,1.17995999,,"EUR",15.25,"EUR",,,0.02,"EUR",BAD,"EUR"`),
wantErr: true,
},
{
name: "malformed timestamp",
r: bytes.NewBufferString(`Market sell,2006-01-02T15:04:05Z07:00,IE000GA3D489,ABXY,"Aspargus Brocoli",EOF987654321,2.4387014200,7.9999999999,USD,1.17995999,,"EUR",15.25,"EUR",,,0.02,"EUR",,`),
r: bytes.NewBufferString(`Market sell,2006-01-02T15:04:05Z07:00,IE000GA3D489,ABXY,"Aspargus Broccoli",EOF987654321,2.4387014200,7.9999999999,USD,1.17995999,,"EUR",15.25,"EUR",,,0.02,"EUR",,`),
wantErr: true,
},
{
name: "empty timestamp",
r: bytes.NewBufferString(`Market sell,,IE000GA3D489,ABXY,"Aspargus Brocoli",EOF987654321,2.4387014200,7.9999999999,USD,1.17995999,,"EUR",15.25,"EUR",,,0.02,"EUR",,`),
r: bytes.NewBufferString(`Market sell,,IE000GA3D489,ABXY,"Aspargus Broccoli",EOF987654321,2.4387014200,7.9999999999,USD,1.17995999,,"EUR",15.25,"EUR",,,0.02,"EUR",,`),
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
rr := NewRecordReader(tt.r)
rr := NewRecordReader(tt.r, NewFigiClientSecurityTypeStub(t, "Common Stock"))
got, gotErr := rr.ReadRecord(t.Context())
if gotErr != nil {
if !tt.wantErr {
@@ -132,11 +146,53 @@ func TestRecordReader_ReadRecord(t *testing.T) {
if got.Taxes().Cmp(tt.want.taxes) != 0 {
t.Fatalf("want taxes %v but got %v", tt.want.taxes, got.Taxes())
}
if tt.want.natureGetter != nil && tt.want.Nature() != got.Nature() {
t.Fatalf("want nature %v but got %v", tt.want.Nature(), got.Nature())
}
})
}
}
func ShouldParseDecimal(t testing.TB, sf string) *big.Float {
func Test_figiNatureGetter(t *testing.T) {
tests := []struct {
name string // description of this test case
of *internal.OpenFIGI
want internal.Nature
}{
{
name: "Common Stock translates to G01",
of: NewFigiClientSecurityTypeStub(t, "Common Stock"),
want: internal.NatureG01,
},
{
name: "ETP translates to G20",
of: NewFigiClientSecurityTypeStub(t, "ETP"),
want: internal.NatureG20,
},
{
name: "Other translates to Unknown",
of: NewFigiClientSecurityTypeStub(t, "Other"),
want: internal.NatureUnknown,
},
{
name: "Request fails",
of: NewFigiClientErrorStub(t, fmt.Errorf("boom")),
want: internal.NatureUnknown,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
getter := figiNatureGetter(t.Context(), tt.of, "IR1234567890")
got := getter()
if tt.want != got {
t.Errorf("want %v but got %v", tt.want, got)
}
})
}
}
func ShouldParseDecimal(t testing.TB, sf string) decimal.Decimal {
t.Helper()
bf, err := parseDecimal(sf)
@@ -145,3 +201,40 @@ func ShouldParseDecimal(t testing.TB, sf string) *big.Float {
}
return bf
}
type RoundTripFunc func(req *http.Request) (*http.Response, error)
func (f RoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
func NewFigiClientSecurityTypeStub(t testing.TB, securityType string) *internal.OpenFIGI {
t.Helper()
c := &http.Client{
Timeout: time.Second,
Transport: RoundTripFunc(func(req *http.Request) (*http.Response, error) {
return &http.Response{
Status: http.StatusText(http.StatusOK),
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`[{"data":[{"securityType":%q}]}]`, securityType))),
Request: req,
}, nil
}),
}
return internal.NewOpenFIGI(c)
}
func NewFigiClientErrorStub(t testing.TB, err error) *internal.OpenFIGI {
t.Helper()
c := &http.Client{
Timeout: time.Second,
Transport: RoundTripFunc(func(req *http.Request) (*http.Response, error) {
return nil, err
}),
}
return internal.NewOpenFIGI(c)
}