Compare commits

...

77 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
edc1628674 Merge pull request 'fix licensing' (#11) from fix-licenses into main
All checks were successful
Generate check / check-generate (push) Has been skipped
Reviewed-on: #11
2025-11-14 16:37:52 +00:00
7709023ef4 only run tests if there are changes to go files
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-14 16:37:34 +00:00
9ae1e959e9 fix licensing
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) Successful in 5s
2025-11-14 16:34:59 +00:00
67d77e35a0 Merge pull request 'renamed broker2anexoj-cli to any2anexoj-cli' (#9) from fix-readme into main
All checks were successful
Generate check / check-generate (push) Has been skipped
Reviewed-on: #9
2025-11-14 15:30:16 +00:00
ad0bfc6979 renamed broker2anexoj-cli to any2anexoj-cli
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) Successful in 6s
2025-11-14 15:29:19 +00:00
7dbbfc3702 Merge pull request 'always run the job but skip when irrelevant' (#10) from check-gen-condition into main
All checks were successful
Generate check / check-generate (push) Has been skipped
Reviewed-on: #10
2025-11-14 15:28:10 +00:00
5dc9601e28 always run the job but skip when irrelevant
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) Successful in 6s
2025-11-14 15:27:26 +00:00
4b520f6164 Merge pull request 'Better usability' (#8) from better-usability into main
All checks were successful
Generate check / check-generate (push) Successful in 9s
Reviewed-on: #8
2025-11-14 15:01:06 +00:00
60d6f26162 rename supportedPlatforms
All checks were successful
Generate check / check-generate (pull_request) Successful in 23s
Tests / tests (pull_request) Successful in 11s
2025-11-14 14:59:04 +00:00
14cfe33f95 update readme and platform param 2025-11-14 14:57:10 +00:00
f356d2f7e1 rename module to match github repo 2025-11-14 14:14:32 +00:00
e4088e4aec read from stdin and write logs to stderr 2025-11-14 09:52:07 +00:00
bad41b431d Merge pull request 'Handle context cancelation' (#6) from context-everywhere into main
All checks were successful
Generate check / check-generate (push) Successful in 7s
Reviewed-on: applications/broker2anexoj#6
2025-11-14 09:19:37 +00:00
791306acf1 regenerate mocks with new methods
All checks were successful
Generate check / check-generate (pull_request) Successful in 13s
Tests / tests (pull_request) Successful in 6s
2025-11-14 09:17:06 +00:00
f3d0f5d71a handle context cancelation 2025-11-14 09:17:06 +00:00
54fced39aa Merge pull request 'setup go before in check-generate gitea job' (#7) from fix-check-gen-action into main
Reviewed-on: applications/broker2anexoj#7
2025-11-14 09:16:12 +00:00
c477023041 setup go before running go generate
All checks were successful
Tests / tests (pull_request) Successful in 4s
2025-11-14 09:14:10 +00:00
6b4373c889 Merge pull request 'Generated changes must not be tampered' (#5) from action-check-generated-code into main
Reviewed-on: applications/broker2anexoj#5
2025-11-13 15:59:18 +00:00
4b9a91b98e check if generated changes were not tampered 2025-11-13 15:59:18 +00:00
ecdc279de2 Merge pull request 'isolate record reading and writing from processing' (#4) from refactor-report-logic into main
Reviewed-on: applications/broker2anexoj#4
2025-11-13 14:22:35 +00:00
70bd8622de isolate record reading and writing from processing
All checks were successful
Tests / tests (pull_request) Successful in 17s
2025-11-13 14:07:08 +00:00
d3fa025a92 Merge pull request 'Peek method for the RecordQueue type' (#3) from peeking-record-queue into main
Reviewed-on: applications/broker2anexoj#3
2025-11-13 13:59:25 +00:00
93689754be implement and test the Peek for the RecordQueue type
All checks were successful
Tests / tests (pull_request) Successful in 6s
2025-11-13 13:58:07 +00:00
7800b1163b Merge pull request 'record-support-taxes-and-fees' (#2) from record-support-taxes-and-fees into main
Reviewed-on: applications/broker2anexoj#2
2025-11-13 13:51:54 +00:00
7450c0d571 fix unused imports
All checks were successful
Tests / tests (pull_request) Successful in 17s
2025-11-13 13:47:41 +00:00
8e2163cce6 parse trading212 record fees
Some checks failed
Tests / tests (pull_request) Failing after 43s
2025-11-13 13:44:40 +00:00
bb93798c0f add Fees and Taxes to the Record type 2025-11-13 13:44:40 +00:00
388fd439a1 add tests gitea workflow 2025-11-13 13:33:36 +00:00
39 changed files with 2718 additions and 459 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

@@ -0,0 +1,56 @@
name: Generate check
on:
pull_request:
types: [opened, reopened, synchronize]
jobs:
check-changes:
runs-on: ubuntu-latest
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:
go-version: 1.25
- name: Save pre-generate git state
run: git status --porcelain > pre-generate.txt
- name: Run go generate
run: go generate ./...
- name: Save post-generate git state
run: git status --porcelain > post-generate.txt
- name: Check for changes
run: |
if ! diff pre-generate.txt post-generate.txt | grep .; then
echo "No files changed by go generate"
else
echo "go generate produced changes; commit those first" >&2
exit 1
fi

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 ./...

14
NOTICE.md Normal file
View File

@@ -0,0 +1,14 @@
Copyright (C) 2025 Natercio Moniz
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.

View File

@@ -1,23 +1,31 @@
# broker2anexoj
# 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
go install git.naterciomoniz.net/applications/broker2anexoj@latest
go install github.com/nmoniz/any2anexoj/cmd/any2anexoj-cli@latest
```
## Usage
```bash
broker2anexoj
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

@@ -0,0 +1,87 @@
package main
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, internal.NewOpenFIGI(&http.Client{Timeout: 5 * time.Second}))
},
}
func main() {
pflag.Parse()
if platform == nil || len(*platform) == 0 {
slog.Error("--platform flag is required")
os.Exit(1)
}
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, lang string) error {
ctx, cancel := signal.NotifyContext(ctx, os.Kill, os.Interrupt)
defer cancel()
eg, ctx := errgroup.WithContext(ctx)
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, nil)))
factory, ok := readerFactories[platform]
if !ok {
return fmt.Errorf("unsupported platform: %s", platform)
}
reader := factory()
writer := internal.NewAggregatorWriter()
eg.Go(func() error {
return internal.BuildReport(ctx, reader, writer)
})
err := eg.Wait()
if err != nil {
return err
}
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"
}
}

24
go.mod
View File

@@ -1,5 +1,25 @@
module git.naterciomoniz.net/applications/broker2anexoj
module github.com/nmoniz/any2anexoj
go 1.25.3
require go.uber.org/mock v0.6.0
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/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
)
tool go.uber.org/mock/mockgen

39
go.sum
View File

@@ -1,2 +1,41 @@
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.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=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

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

@@ -1,3 +1,3 @@
package internal
//go:generate mockgen -destination=mocks/mocks_gen.go -package=mocks -typed . RecordReader,Record
//go:generate go tool mockgen -destination=mocks/mocks_gen.go -package=mocks -typed . RecordReader,Record,ReportWriter

View File

@@ -1,20 +1,21 @@
// Code generated by MockGen. DO NOT EDIT.
// Source: git.naterciomoniz.net/applications/broker2anexoj/internal (interfaces: RecordReader,Record)
// Source: github.com/nmoniz/any2anexoj/internal (interfaces: RecordReader,Record,ReportWriter)
//
// Generated by this command:
//
// mockgen -destination=mocks/mocks_gen.go -package=mocks -typed . RecordReader,Record
// mockgen -destination=mocks/mocks_gen.go -package=mocks -typed . RecordReader,Record,ReportWriter
//
// Package mocks is a generated GoMock package.
package mocks
import (
big "math/big"
context "context"
reflect "reflect"
time "time"
internal "git.naterciomoniz.net/applications/broker2anexoj/internal"
internal "github.com/nmoniz/any2anexoj/internal"
decimal "github.com/shopspring/decimal"
gomock "go.uber.org/mock/gomock"
)
@@ -43,18 +44,18 @@ func (m *MockRecordReader) EXPECT() *MockRecordReaderMockRecorder {
}
// ReadRecord mocks base method.
func (m *MockRecordReader) ReadRecord() (internal.Record, error) {
func (m *MockRecordReader) ReadRecord(arg0 context.Context) (internal.Record, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ReadRecord")
ret := m.ctrl.Call(m, "ReadRecord", arg0)
ret0, _ := ret[0].(internal.Record)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// ReadRecord indicates an expected call of ReadRecord.
func (mr *MockRecordReaderMockRecorder) ReadRecord() *MockRecordReaderReadRecordCall {
func (mr *MockRecordReaderMockRecorder) ReadRecord(arg0 any) *MockRecordReaderReadRecordCall {
mr.mock.ctrl.T.Helper()
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReadRecord", reflect.TypeOf((*MockRecordReader)(nil).ReadRecord))
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReadRecord", reflect.TypeOf((*MockRecordReader)(nil).ReadRecord), arg0)
return &MockRecordReaderReadRecordCall{Call: call}
}
@@ -70,13 +71,13 @@ func (c *MockRecordReaderReadRecordCall) Return(arg0 internal.Record, arg1 error
}
// Do rewrite *gomock.Call.Do
func (c *MockRecordReaderReadRecordCall) Do(f func() (internal.Record, error)) *MockRecordReaderReadRecordCall {
func (c *MockRecordReaderReadRecordCall) Do(f func(context.Context) (internal.Record, error)) *MockRecordReaderReadRecordCall {
c.Call = c.Call.Do(f)
return c
}
// DoAndReturn rewrite *gomock.Call.DoAndReturn
func (c *MockRecordReaderReadRecordCall) DoAndReturn(f func() (internal.Record, error)) *MockRecordReaderReadRecordCall {
func (c *MockRecordReaderReadRecordCall) DoAndReturn(f func(context.Context) (internal.Record, error)) *MockRecordReaderReadRecordCall {
c.Call = c.Call.DoAndReturn(f)
return c
}
@@ -105,11 +106,163 @@ func (m *MockRecord) EXPECT() *MockRecordMockRecorder {
return m.recorder
}
// AssetCountry mocks base method.
func (m *MockRecord) AssetCountry() int64 {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "AssetCountry")
ret0, _ := ret[0].(int64)
return ret0
}
// AssetCountry indicates an expected call of AssetCountry.
func (mr *MockRecordMockRecorder) AssetCountry() *MockRecordAssetCountryCall {
mr.mock.ctrl.T.Helper()
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AssetCountry", reflect.TypeOf((*MockRecord)(nil).AssetCountry))
return &MockRecordAssetCountryCall{Call: call}
}
// MockRecordAssetCountryCall wrap *gomock.Call
type MockRecordAssetCountryCall struct {
*gomock.Call
}
// Return rewrite *gomock.Call.Return
func (c *MockRecordAssetCountryCall) Return(arg0 int64) *MockRecordAssetCountryCall {
c.Call = c.Call.Return(arg0)
return c
}
// Do rewrite *gomock.Call.Do
func (c *MockRecordAssetCountryCall) Do(f func() int64) *MockRecordAssetCountryCall {
c.Call = c.Call.Do(f)
return c
}
// DoAndReturn rewrite *gomock.Call.DoAndReturn
func (c *MockRecordAssetCountryCall) DoAndReturn(f func() int64) *MockRecordAssetCountryCall {
c.Call = c.Call.DoAndReturn(f)
return c
}
// BrokerCountry mocks base method.
func (m *MockRecord) BrokerCountry() int64 {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "BrokerCountry")
ret0, _ := ret[0].(int64)
return ret0
}
// BrokerCountry indicates an expected call of BrokerCountry.
func (mr *MockRecordMockRecorder) BrokerCountry() *MockRecordBrokerCountryCall {
mr.mock.ctrl.T.Helper()
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BrokerCountry", reflect.TypeOf((*MockRecord)(nil).BrokerCountry))
return &MockRecordBrokerCountryCall{Call: call}
}
// MockRecordBrokerCountryCall wrap *gomock.Call
type MockRecordBrokerCountryCall struct {
*gomock.Call
}
// Return rewrite *gomock.Call.Return
func (c *MockRecordBrokerCountryCall) Return(arg0 int64) *MockRecordBrokerCountryCall {
c.Call = c.Call.Return(arg0)
return c
}
// Do rewrite *gomock.Call.Do
func (c *MockRecordBrokerCountryCall) Do(f func() int64) *MockRecordBrokerCountryCall {
c.Call = c.Call.Do(f)
return c
}
// DoAndReturn rewrite *gomock.Call.DoAndReturn
func (c *MockRecordBrokerCountryCall) DoAndReturn(f func() int64) *MockRecordBrokerCountryCall {
c.Call = c.Call.DoAndReturn(f)
return c
}
// Fees mocks base method.
func (m *MockRecord) Fees() decimal.Decimal {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Fees")
ret0, _ := ret[0].(decimal.Decimal)
return ret0
}
// Fees indicates an expected call of Fees.
func (mr *MockRecordMockRecorder) Fees() *MockRecordFeesCall {
mr.mock.ctrl.T.Helper()
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Fees", reflect.TypeOf((*MockRecord)(nil).Fees))
return &MockRecordFeesCall{Call: call}
}
// MockRecordFeesCall wrap *gomock.Call
type MockRecordFeesCall struct {
*gomock.Call
}
// Return rewrite *gomock.Call.Return
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() decimal.Decimal) *MockRecordFeesCall {
c.Call = c.Call.Do(f)
return c
}
// DoAndReturn rewrite *gomock.Call.DoAndReturn
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
}
@@ -126,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
}
@@ -164,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
}
@@ -257,6 +410,44 @@ func (c *MockRecordSymbolCall) DoAndReturn(f func() string) *MockRecordSymbolCal
return c
}
// Taxes mocks base method.
func (m *MockRecord) Taxes() decimal.Decimal {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Taxes")
ret0, _ := ret[0].(decimal.Decimal)
return ret0
}
// Taxes indicates an expected call of Taxes.
func (mr *MockRecordMockRecorder) Taxes() *MockRecordTaxesCall {
mr.mock.ctrl.T.Helper()
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Taxes", reflect.TypeOf((*MockRecord)(nil).Taxes))
return &MockRecordTaxesCall{Call: call}
}
// MockRecordTaxesCall wrap *gomock.Call
type MockRecordTaxesCall struct {
*gomock.Call
}
// Return rewrite *gomock.Call.Return
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() decimal.Decimal) *MockRecordTaxesCall {
c.Call = c.Call.Do(f)
return c
}
// DoAndReturn rewrite *gomock.Call.DoAndReturn
func (c *MockRecordTaxesCall) DoAndReturn(f func() decimal.Decimal) *MockRecordTaxesCall {
c.Call = c.Call.DoAndReturn(f)
return c
}
// Timestamp mocks base method.
func (m *MockRecord) Timestamp() time.Time {
m.ctrl.T.Helper()
@@ -294,3 +485,65 @@ func (c *MockRecordTimestampCall) DoAndReturn(f func() time.Time) *MockRecordTim
c.Call = c.Call.DoAndReturn(f)
return c
}
// MockReportWriter is a mock of ReportWriter interface.
type MockReportWriter struct {
ctrl *gomock.Controller
recorder *MockReportWriterMockRecorder
isgomock struct{}
}
// MockReportWriterMockRecorder is the mock recorder for MockReportWriter.
type MockReportWriterMockRecorder struct {
mock *MockReportWriter
}
// NewMockReportWriter creates a new mock instance.
func NewMockReportWriter(ctrl *gomock.Controller) *MockReportWriter {
mock := &MockReportWriter{ctrl: ctrl}
mock.recorder = &MockReportWriterMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockReportWriter) EXPECT() *MockReportWriterMockRecorder {
return m.recorder
}
// Write mocks base method.
func (m *MockReportWriter) Write(arg0 context.Context, arg1 internal.ReportItem) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Write", arg0, arg1)
ret0, _ := ret[0].(error)
return ret0
}
// Write indicates an expected call of Write.
func (mr *MockReportWriterMockRecorder) Write(arg0, arg1 any) *MockReportWriterWriteCall {
mr.mock.ctrl.T.Helper()
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Write", reflect.TypeOf((*MockReportWriter)(nil).Write), arg0, arg1)
return &MockReportWriterWriteCall{Call: call}
}
// MockReportWriterWriteCall wrap *gomock.Call
type MockReportWriterWriteCall struct {
*gomock.Call
}
// Return rewrite *gomock.Call.Return
func (c *MockReportWriterWriteCall) Return(arg0 error) *MockReportWriterWriteCall {
c.Call = c.Call.Return(arg0)
return c
}
// Do rewrite *gomock.Call.Do
func (c *MockReportWriterWriteCall) Do(f func(context.Context, internal.ReportItem) error) *MockReportWriterWriteCall {
c.Call = c.Call.Do(f)
return c
}
// DoAndReturn rewrite *gomock.Call.DoAndReturn
func (c *MockReportWriterWriteCall) DoAndReturn(f func(context.Context, internal.ReportItem) error) *MockReportWriterWriteCall {
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,62 +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
}
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 list as the first return value. If the list is
// empty returns falso on the 2nd return value, true otherwise.
func (rq *RecordQueue) Pop() (Record, bool) {
if rq == nil || rq.l == nil {
return nil, false
}
el := rq.l.Front()
if el == nil {
return nil, false
}
val := rq.l.Remove(el)
return val.(Record), true
}
func (rq *RecordQueue) Len() int {
if rq == nil || rq.l == nil {
return 0
}
return rq.l.Len()
}

View File

@@ -1,90 +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")
}
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")
}
rec, ok := rq.Pop()
if !ok {
t.Fatalf("Pop() should return (_,true) when the list is not empty")
}
if rec, ok := rec.(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")
}
}
func TestRecordQueueNilReceiver(t *testing.T) {
var rq *RecordQueue
if rq.Len() > 0 {
t.Fatalf("nil receiver should have zero lenght")
}
_, 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
}

133
internal/report.go Normal file
View File

@@ -0,0 +1,133 @@
package internal
import (
"context"
"errors"
"fmt"
"io"
"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]*FillerQueue)
for {
select {
case <-ctx.Done():
return ctx.Err()
default:
rec, err := reader.ReadRecord(ctx)
if err != nil {
if errors.Is(err, io.EOF) {
return nil
}
return err
}
buyQueue, ok := buys[rec.Symbol()]
if !ok {
buyQueue = new(FillerQueue)
buys[rec.Symbol()] = buyQueue
}
err = processRecord(ctx, buyQueue, rec, writer)
if err != nil {
return fmt.Errorf("processing record: %w", err)
}
}
}
}
func processRecord(ctx context.Context, q *FillerQueue, rec Record, writer ReportWriter) error {
switch rec.Side() {
case SideBuy:
q.Push(NewFiller(rec))
case SideSell:
unmatchedQty := rec.Quantity()
for unmatchedQty.IsPositive() {
buy, ok := q.Peek()
if !ok {
return ErrInsufficientBoughtVolume
}
matchedQty, filled := buy.Fill(unmatchedQty)
if filled {
_, ok := q.Pop()
if !ok {
return fmt.Errorf("pop empty filler queue")
}
}
unmatchedQty = unmatchedQty.Sub(matchedQty)
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: 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)
}
}
default:
return fmt.Errorf("unknown side: %v", rec.Side())
}
return nil
}

100
internal/report_test.go Normal file
View File

@@ -0,0 +1,100 @@
package internal_test
import (
"context"
"fmt"
"io"
"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 TestBuildReport(t *testing.T) {
now := time.Now()
ctrl := gomock.NewController(t)
reader := mocks.NewMockRecordReader(ctrl)
records := []internal.Record{
mockRecord(ctrl, 20.0, 10.0, internal.SideBuy, now),
mockRecord(ctrl, 25.0, 10.0, internal.SideSell, now.Add(1)),
}
reader.EXPECT().ReadRecord(gomock.Any()).DoAndReturn(func(ctx context.Context) (internal.Record, error) {
if len(records) > 0 {
r := records[0]
records = records[1:]
return r, nil
} else {
return nil, io.EOF
}
}).Times(3)
writer := mocks.NewMockReportWriter(ctrl)
writer.EXPECT().Write(gomock.Any(), eqReportItem(internal.ReportItem{
BuyValue: decimal.NewFromFloat(200.0),
BuyTimestamp: now,
SellValue: decimal.NewFromFloat(250.0),
SellTimestamp: now.Add(1),
Fees: decimal.Decimal{},
Taxes: decimal.Decimal{},
})).Times(1)
gotErr := internal.BuildReport(t.Context(), reader, writer)
if gotErr != nil {
t.Fatalf("got unexpected err: %v", gotErr)
}
}
func mockRecord(ctrl *gomock.Controller, price, quantity float64, side internal.Side, ts time.Time) *mocks.MockRecord {
rec := mocks.NewMockRecord(ctrl)
rec.EXPECT().Symbol().Return("TEST").AnyTimes()
rec.EXPECT().BrokerCountry().Return(int64(countries.PT)).AnyTimes()
rec.EXPECT().AssetCountry().Return(int64(countries.USA)).AnyTimes()
rec.EXPECT().Price().Return(decimal.NewFromFloat(price)).AnyTimes()
rec.EXPECT().Quantity().Return(decimal.NewFromFloat(quantity)).AnyTimes()
rec.EXPECT().Side().Return(side).AnyTimes()
rec.EXPECT().Timestamp().Return(ts).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

@@ -1,130 +0,0 @@
package internal
import (
"errors"
"fmt"
"io"
"log/slog"
"math/big"
"sync"
)
type RecordReader interface {
// ReadRecord should return Records until an error is found.
ReadRecord() (Record, error)
}
// Reporter consumes each record to produce ReportItem.
type Reporter struct {
reader RecordReader
}
func NewReporter(rr RecordReader) *Reporter {
return &Reporter{
reader: rr,
}
}
func (r *Reporter) Run() error {
forewarders := make(map[string]chan Record)
aggregator := make(chan processResult)
defer close(aggregator)
go func() {
for result := range aggregator {
fmt.Printf("%v\n", result)
}
}()
wg := sync.WaitGroup{}
defer func() {
wg.Wait()
}()
for {
rec, err := r.reader.ReadRecord()
if err != nil {
if errors.Is(err, io.EOF) {
return nil
}
return err
}
router, ok := forewarders[rec.Symbol()]
if !ok {
router = make(chan Record, 1)
defer close(router)
wg.Go(func() {
processRecords(router, aggregator)
})
forewarders[rec.Symbol()] = router
}
router <- rec
}
}
func processRecords(records <-chan Record, results chan<- processResult) {
var q RecordQueue
for rec := range records {
switch rec.Side() {
case SideBuy:
q.Push(rec)
case SideSell:
unmatchedQty := new(big.Float).Copy(rec.Quantity())
zero := new(big.Float)
for unmatchedQty.Cmp(zero) > 0 {
buy, ok := q.Pop()
if !ok {
results <- processResult{
err: ErrSellWithoutBuy,
}
return
}
var matchedQty *big.Float
if buy.Quantity().Cmp(unmatchedQty) > 0 {
matchedQty = unmatchedQty
buy.Quantity().Sub(buy.Quantity(), unmatchedQty)
} else {
matchedQty = buy.Quantity()
}
unmatchedQty.Sub(unmatchedQty, matchedQty)
sellValue := new(big.Float).Mul(matchedQty, rec.Price())
buyValue := new(big.Float).Mul(matchedQty, buy.Price())
realisedPnL := new(big.Float).Sub(sellValue, buyValue)
slog.Info("Realised PnL",
slog.Any("Symbol", rec.Symbol()),
slog.Any("PnL", realisedPnL),
slog.Any("Timestamp", rec.Timestamp()))
results <- processResult{
item: ReportItem{},
}
}
default:
results <- processResult{
err: fmt.Errorf("unknown side: %v", rec.Side()),
}
return
}
}
}
type processResult struct {
item ReportItem
err error
}
type ReportItem struct{}
var ErrSellWithoutBuy = fmt.Errorf("found sell without bought volume")

View File

@@ -1,42 +0,0 @@
package internal_test
import (
"io"
"math/big"
"testing"
"git.naterciomoniz.net/applications/broker2anexoj/internal"
"git.naterciomoniz.net/applications/broker2anexoj/internal/mocks"
"go.uber.org/mock/gomock"
)
func TestReporter_Run(t *testing.T) {
ctrl := gomock.NewController(t)
rec := mocks.NewMockRecord(ctrl)
rec.EXPECT().Price().Return(big.NewFloat(1.25)).AnyTimes()
rec.EXPECT().Quantity().Return(big.NewFloat(10)).AnyTimes()
rec.EXPECT().Side().Return(internal.SideBuy).AnyTimes()
rec.EXPECT().Symbol().Return("TEST").AnyTimes()
reader := mocks.NewMockRecordReader(ctrl)
records := []internal.Record{
rec,
rec,
}
reader.EXPECT().ReadRecord().DoAndReturn(func() (internal.Record, error) {
if len(records) > 0 {
r := records[0]
records = records[1:]
return r, nil
} else {
return nil, io.EOF
}
}).AnyTimes()
reporter := internal.NewReporter(reader)
gotErr := reporter.Run()
if gotErr != nil {
t.Fatalf("got unexpected err: %v", gotErr)
}
}

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

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

View File

@@ -1,51 +1,82 @@
package trading212
import (
"context"
"encoding/csv"
"fmt"
"io"
"math/big"
"log/slog"
"strings"
"sync"
"time"
"git.naterciomoniz.net/applications/broker2anexoj/internal"
"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
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
}
type RecordReader struct {
reader *csv.Reader
func (r Record) BrokerCountry() int64 {
return int64(Country)
}
func NewRecordReader(r io.Reader) *RecordReader {
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() 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, f *internal.OpenFIGI) *RecordReader {
return &RecordReader{
reader: csv.NewReader(r),
figi: f,
}
}
@@ -56,7 +87,7 @@ const (
LimitSell = "limit sell"
)
func (rr RecordReader) ReadRecord() (internal.Record, error) {
func (rr RecordReader) ReadRecord(ctx context.Context) (internal.Record, error) {
for {
raw, err := rr.reader.Read()
if err != nil {
@@ -90,19 +121,67 @@ func (rr RecordReader) ReadRecord() (internal.Record, error) {
return Record{}, fmt.Errorf("parse record timestamp: %w", err)
}
conversionFee, err := parseOptionalDecimal(raw[16])
if err != nil {
return Record{}, fmt.Errorf("parse record conversion fee: %w", err)
}
stampDutyTax, err := parseOptionalDecimal(raw[14])
if err != nil {
return Record{}, fmt.Errorf("parse record stamp duty tax: %w", err)
}
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,
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
}
})
}
// 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 minor parameter changes.
func parseOptionalDecimal(s string) (decimal.Decimal, error) {
if len(s) == 0 {
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"
"git.naterciomoniz.net/applications/broker2anexoj/internal"
"github.com/nmoniz/any2anexoj/internal"
"github.com/shopspring/decimal"
)
func TestRecordReader_ReadRecord(t *testing.T) {
@@ -24,82 +26,88 @@ func TestRecordReader_ReadRecord(t *testing.T) {
wantErr: true,
},
{
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.02,"EUR",,`),
name: "well-formed buy",
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),
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 },
},
wantErr: false,
},
{
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",,`),
name: "well-formed sell",
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),
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 },
},
wantErr: false,
},
{
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",,`),
want: Record{},
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",,`),
want: Record{},
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",,`),
want: Record{},
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",,`),
want: Record{},
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",,`),
want: Record{},
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",,`),
want: Record{},
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",,`),
want: Record{},
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",,`),
want: Record{},
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)
got, gotErr := rr.ReadRecord()
rr := NewRecordReader(tt.r, NewFigiClientSecurityTypeStub(t, "Common Stock"))
got, gotErr := rr.ReadRecord(t.Context())
if gotErr != nil {
if !tt.wantErr {
t.Fatalf("ReadRecord() failed: %v", gotErr)
@@ -130,11 +138,61 @@ func TestRecordReader_ReadRecord(t *testing.T) {
if !got.Timestamp().Equal(tt.want.timestamp) {
t.Fatalf("want timestamp %v but got %v", tt.want.timestamp, got.Timestamp())
}
if got.Fees().Cmp(tt.want.fees) != 0 {
t.Fatalf("want fees %v but got %v", tt.want.fees, got.Fees())
}
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)
@@ -143,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)
}

27
licenses/golang.md Normal file
View File

@@ -0,0 +1,27 @@
Copyright 2009 The Go Authors.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above
copyright notice, this list of conditions and the following disclaimer
in the documentation and/or other materials provided with the
distribution.
* Neither the name of Google LLC nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

28
licenses/spf13-pflag.md Normal file
View File

@@ -0,0 +1,28 @@
Copyright (c) 2012 Alex Ogier. All rights reserved.
Copyright (c) 2012 The Go Authors. All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above
copyright notice, this list of conditions and the following disclaimer
in the documentation and/or other materials provided with the
distribution.
* Neither the name of Google Inc. nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

202
licenses/uber-go-mock.md Normal file
View File

@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

37
main.go
View File

@@ -1,37 +0,0 @@
package main
import (
"fmt"
"log/slog"
"os"
"git.naterciomoniz.net/applications/broker2anexoj/internal"
"git.naterciomoniz.net/applications/broker2anexoj/internal/trading212"
)
func main() {
err := run()
if err != nil {
slog.Error("fatal error", slog.Any("err", err))
}
}
func run() error {
f, err := os.Open("test.csv")
if err != nil {
return fmt.Errorf("open statement: %w", err)
}
reader := trading212.NewRecordReader(f)
reporter := internal.NewReporter(reader)
err = reporter.Run()
if err != nil {
return err
}
slog.Info("Finish processing statement")
return nil
}