Compare commits
88 Commits
06dc25ae88
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 4626f08b9c | |||
| 0d97432c6c | |||
| 10e1e99683 | |||
| 38db50b879 | |||
| 21147954cb | |||
| b12c519fdb | |||
| f651ce8597 | |||
| a19b02f1f6 | |||
| a079ea8ae5 | |||
| 5a0fb8b6aa | |||
| f2bfefcea5 | |||
| d415fcd752 | |||
| 950143e17d | |||
| 066b5b76a8 | |||
| 2a3f13e91a | |||
| 0b6b35e736 | |||
| 5060fca7be | |||
| 4a2884a0df | |||
| 64bbf8d129 | |||
| 57ee768006 | |||
| 0d3e3df9e7 | |||
| 5f13ebaf6a | |||
| 70466b7886 | |||
| bd101ce46a | |||
| 93f1dab3d2 | |||
| c323047175 | |||
| 8c784f3b74 | |||
| a1ea13ff2f | |||
| 6b5552b559 | |||
| 23614d51db | |||
| ef0a4476a7 | |||
| b4b12ad625 | |||
| 1106705eb2 | |||
| f716c2e897 | |||
| ef350b2659 | |||
| 914ead1681 | |||
| c363652f49 | |||
| a347443c81 | |||
| 89bcd15b17 | |||
| 9ba5116c03 | |||
| 1c3fd0397a | |||
| 961f0eed38 | |||
| 290593a9aa | |||
| f49377a6dd | |||
| 91f6bd1a3e | |||
| d097b01288 | |||
| 05a981b3a0 | |||
| f6e870d7b7 | |||
| a6d56d7441 | |||
| edc1628674 | |||
| 7709023ef4 | |||
| 9ae1e959e9 | |||
| 67d77e35a0 | |||
| ad0bfc6979 | |||
| 7dbbfc3702 | |||
| 5dc9601e28 | |||
| 4b520f6164 | |||
| 60d6f26162 | |||
| 14cfe33f95 | |||
| f356d2f7e1 | |||
| e4088e4aec | |||
| bad41b431d | |||
| 791306acf1 | |||
| f3d0f5d71a | |||
| 54fced39aa | |||
| c477023041 | |||
| 6b4373c889 | |||
| 4b9a91b98e | |||
| ecdc279de2 | |||
| 70bd8622de | |||
| d3fa025a92 | |||
| 93689754be | |||
| 7800b1163b | |||
| 7450c0d571 | |||
| 8e2163cce6 | |||
| bb93798c0f | |||
| 388fd439a1 | |||
| 7a26e67070 | |||
| 410e0a9318 | |||
| 7d6ba54f8a | |||
| 910c53155b | |||
| 7a38ae1696 | |||
| 38113f21af | |||
| 8e4093b647 | |||
| 265d115647 | |||
| a274718f0b | |||
| dca43fe014 | |||
| 4686e36501 |
32
.gitea/workflows/badges.yml
Normal file
32
.gitea/workflows/badges.yml
Normal 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
|
||||
43
.gitea/workflows/claude.yml
Normal file
43
.gitea/workflows/claude.yml
Normal file
@@ -0,0 +1,43 @@
|
||||
name: Claude Assistant
|
||||
|
||||
on:
|
||||
# Trigger on issue comments (works on both issues and pull requests in Gitea)
|
||||
issue_comment:
|
||||
types: [created]
|
||||
# Trigger on issues being opened or assigned
|
||||
issues:
|
||||
types: [opened, assigned]
|
||||
# Note: pull_request_review_comment has limited support in Gitea
|
||||
# Use issue_comment instead which covers PR comments
|
||||
|
||||
jobs:
|
||||
claude-assistant:
|
||||
# Basic trigger detection - check for @claude in comments or issue body
|
||||
if: |
|
||||
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || github.event.action == 'assigned'))
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
issues: write
|
||||
# Note: Gitea Actions may not require id-token: write for basic functionality
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Run Claude Assistant
|
||||
uses: markwylde/claude-code-gitea-action@v1.0.20
|
||||
with:
|
||||
gitea_token: ${{ secrets.GITEA_TOKEN }} # Use standard workflow token
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
timeout_minutes: "60"
|
||||
trigger_phrase: "@claude"
|
||||
# Optional: Customize for Gitea environment
|
||||
custom_instructions: |
|
||||
You are working in a Gitea environment. Be aware that:
|
||||
- Some GitHub Actions features may behave differently
|
||||
- Focus on core functionality and avoid advanced GitHub-specific features
|
||||
- Use standard git operations when possible
|
||||
56
.gitea/workflows/gen_code.yml
Normal file
56
.gitea/workflows/gen_code.yml
Normal 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
|
||||
42
.gitea/workflows/quality.yml
Normal file
42
.gitea/workflows/quality.yml
Normal 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
14
NOTICE.md
Normal 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/>.
|
||||
23
README.md
23
README.md
@@ -1,20 +1,31 @@
|
||||
# broker2anexoj
|
||||
# any2anexoj
|
||||
|
||||
This tool converts the statements from brokers 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)
|
||||
[](https://goreportcard.com/report/github.com/nmoniz/any2anexoj)
|
||||
[](https://coveralls.io/github/nmoniz/any2anexoj?branch=main)
|
||||
|
||||
> [!NOTE]
|
||||
> This tool is in early stages of development. Use at your own risk!
|
||||
<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.
|
||||
|
||||
## 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.
|
||||
|
||||
52
cmd/any2anexoj-cli/localizer.go
Normal file
52
cmd/any2anexoj-cli/localizer.go
Normal 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
|
||||
}
|
||||
24
cmd/any2anexoj-cli/localizer_test.go
Normal file
24
cmd/any2anexoj-cli/localizer_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
87
cmd/any2anexoj-cli/main.go
Normal file
87
cmd/any2anexoj-cli/main.go
Normal 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
|
||||
}
|
||||
122
cmd/any2anexoj-cli/pretty_printer.go
Normal file
122
cmd/any2anexoj-cli/pretty_printer.go
Normal 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)
|
||||
},
|
||||
}
|
||||
}
|
||||
84
cmd/any2anexoj-cli/pretty_printer_test.go
Normal file
84
cmd/any2anexoj-cli/pretty_printer_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
46
cmd/any2anexoj-cli/translations/en.json
Normal file
46
cmd/any2anexoj-cli/translations/en.json
Normal 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"
|
||||
}
|
||||
}
|
||||
46
cmd/any2anexoj-cli/translations/pt.json
Normal file
46
cmd/any2anexoj-cli/translations/pt.json
Normal 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
24
go.mod
@@ -1,3 +1,25 @@
|
||||
module git.naterciomoniz.net/applications/broker2anexoj
|
||||
module github.com/nmoniz/any2anexoj
|
||||
|
||||
go 1.25.3
|
||||
|
||||
require (
|
||||
github.com/biter777/countries v1.7.5
|
||||
github.com/jedib0t/go-pretty/v6 v6.7.2
|
||||
github.com/shopspring/decimal v1.4.0
|
||||
github.com/spf13/pflag v1.0.10
|
||||
go.uber.org/mock v0.6.0
|
||||
golang.org/x/sync v0.18.0
|
||||
golang.org/x/time v0.14.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/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
|
||||
|
||||
41
go.sum
Normal file
41
go.sum
Normal file
@@ -0,0 +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=
|
||||
78
internal/aggregator_writer.go
Normal file
78
internal/aggregator_writer.go
Normal 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
|
||||
}
|
||||
288
internal/aggregator_writer_test.go
Normal file
288
internal/aggregator_writer_test.go
Normal 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
5
internal/errors.go
Normal file
@@ -0,0 +1,5 @@
|
||||
package internal
|
||||
|
||||
import "fmt"
|
||||
|
||||
var ErrInsufficientBoughtVolume = fmt.Errorf("insufficient bought volume")
|
||||
96
internal/filler.go
Normal file
96
internal/filler.go
Normal 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
187
internal/filler_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
3
internal/generate.go
Normal file
3
internal/generate.go
Normal file
@@ -0,0 +1,3 @@
|
||||
package internal
|
||||
|
||||
//go:generate go tool mockgen -destination=mocks/mocks_gen.go -package=mocks -typed . RecordReader,Record,ReportWriter
|
||||
549
internal/mocks/mocks_gen.go
Normal file
549
internal/mocks/mocks_gen.go
Normal file
@@ -0,0 +1,549 @@
|
||||
// Code generated by MockGen. DO NOT EDIT.
|
||||
// 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,ReportWriter
|
||||
//
|
||||
|
||||
// Package mocks is a generated GoMock package.
|
||||
package mocks
|
||||
|
||||
import (
|
||||
context "context"
|
||||
reflect "reflect"
|
||||
time "time"
|
||||
|
||||
internal "github.com/nmoniz/any2anexoj/internal"
|
||||
decimal "github.com/shopspring/decimal"
|
||||
gomock "go.uber.org/mock/gomock"
|
||||
)
|
||||
|
||||
// MockRecordReader is a mock of RecordReader interface.
|
||||
type MockRecordReader struct {
|
||||
ctrl *gomock.Controller
|
||||
recorder *MockRecordReaderMockRecorder
|
||||
isgomock struct{}
|
||||
}
|
||||
|
||||
// MockRecordReaderMockRecorder is the mock recorder for MockRecordReader.
|
||||
type MockRecordReaderMockRecorder struct {
|
||||
mock *MockRecordReader
|
||||
}
|
||||
|
||||
// NewMockRecordReader creates a new mock instance.
|
||||
func NewMockRecordReader(ctrl *gomock.Controller) *MockRecordReader {
|
||||
mock := &MockRecordReader{ctrl: ctrl}
|
||||
mock.recorder = &MockRecordReaderMockRecorder{mock}
|
||||
return mock
|
||||
}
|
||||
|
||||
// EXPECT returns an object that allows the caller to indicate expected use.
|
||||
func (m *MockRecordReader) EXPECT() *MockRecordReaderMockRecorder {
|
||||
return m.recorder
|
||||
}
|
||||
|
||||
// ReadRecord mocks base method.
|
||||
func (m *MockRecordReader) ReadRecord(arg0 context.Context) (internal.Record, error) {
|
||||
m.ctrl.T.Helper()
|
||||
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(arg0 any) *MockRecordReaderReadRecordCall {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReadRecord", reflect.TypeOf((*MockRecordReader)(nil).ReadRecord), arg0)
|
||||
return &MockRecordReaderReadRecordCall{Call: call}
|
||||
}
|
||||
|
||||
// MockRecordReaderReadRecordCall wrap *gomock.Call
|
||||
type MockRecordReaderReadRecordCall struct {
|
||||
*gomock.Call
|
||||
}
|
||||
|
||||
// Return rewrite *gomock.Call.Return
|
||||
func (c *MockRecordReaderReadRecordCall) Return(arg0 internal.Record, arg1 error) *MockRecordReaderReadRecordCall {
|
||||
c.Call = c.Call.Return(arg0, arg1)
|
||||
return c
|
||||
}
|
||||
|
||||
// Do rewrite *gomock.Call.Do
|
||||
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(context.Context) (internal.Record, error)) *MockRecordReaderReadRecordCall {
|
||||
c.Call = c.Call.DoAndReturn(f)
|
||||
return c
|
||||
}
|
||||
|
||||
// MockRecord is a mock of Record interface.
|
||||
type MockRecord struct {
|
||||
ctrl *gomock.Controller
|
||||
recorder *MockRecordMockRecorder
|
||||
isgomock struct{}
|
||||
}
|
||||
|
||||
// MockRecordMockRecorder is the mock recorder for MockRecord.
|
||||
type MockRecordMockRecorder struct {
|
||||
mock *MockRecord
|
||||
}
|
||||
|
||||
// NewMockRecord creates a new mock instance.
|
||||
func NewMockRecord(ctrl *gomock.Controller) *MockRecord {
|
||||
mock := &MockRecord{ctrl: ctrl}
|
||||
mock.recorder = &MockRecordMockRecorder{mock}
|
||||
return mock
|
||||
}
|
||||
|
||||
// EXPECT returns an object that allows the caller to indicate expected use.
|
||||
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() decimal.Decimal {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "Price")
|
||||
ret0, _ := ret[0].(decimal.Decimal)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// Price indicates an expected call of Price.
|
||||
func (mr *MockRecordMockRecorder) Price() *MockRecordPriceCall {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Price", reflect.TypeOf((*MockRecord)(nil).Price))
|
||||
return &MockRecordPriceCall{Call: call}
|
||||
}
|
||||
|
||||
// MockRecordPriceCall wrap *gomock.Call
|
||||
type MockRecordPriceCall struct {
|
||||
*gomock.Call
|
||||
}
|
||||
|
||||
// Return rewrite *gomock.Call.Return
|
||||
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() decimal.Decimal) *MockRecordPriceCall {
|
||||
c.Call = c.Call.Do(f)
|
||||
return c
|
||||
}
|
||||
|
||||
// DoAndReturn rewrite *gomock.Call.DoAndReturn
|
||||
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() decimal.Decimal {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "Quantity")
|
||||
ret0, _ := ret[0].(decimal.Decimal)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// Quantity indicates an expected call of Quantity.
|
||||
func (mr *MockRecordMockRecorder) Quantity() *MockRecordQuantityCall {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Quantity", reflect.TypeOf((*MockRecord)(nil).Quantity))
|
||||
return &MockRecordQuantityCall{Call: call}
|
||||
}
|
||||
|
||||
// MockRecordQuantityCall wrap *gomock.Call
|
||||
type MockRecordQuantityCall struct {
|
||||
*gomock.Call
|
||||
}
|
||||
|
||||
// Return rewrite *gomock.Call.Return
|
||||
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() decimal.Decimal) *MockRecordQuantityCall {
|
||||
c.Call = c.Call.Do(f)
|
||||
return c
|
||||
}
|
||||
|
||||
// DoAndReturn rewrite *gomock.Call.DoAndReturn
|
||||
func (c *MockRecordQuantityCall) DoAndReturn(f func() decimal.Decimal) *MockRecordQuantityCall {
|
||||
c.Call = c.Call.DoAndReturn(f)
|
||||
return c
|
||||
}
|
||||
|
||||
// Side mocks base method.
|
||||
func (m *MockRecord) Side() internal.Side {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "Side")
|
||||
ret0, _ := ret[0].(internal.Side)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// Side indicates an expected call of Side.
|
||||
func (mr *MockRecordMockRecorder) Side() *MockRecordSideCall {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Side", reflect.TypeOf((*MockRecord)(nil).Side))
|
||||
return &MockRecordSideCall{Call: call}
|
||||
}
|
||||
|
||||
// MockRecordSideCall wrap *gomock.Call
|
||||
type MockRecordSideCall struct {
|
||||
*gomock.Call
|
||||
}
|
||||
|
||||
// Return rewrite *gomock.Call.Return
|
||||
func (c *MockRecordSideCall) Return(arg0 internal.Side) *MockRecordSideCall {
|
||||
c.Call = c.Call.Return(arg0)
|
||||
return c
|
||||
}
|
||||
|
||||
// Do rewrite *gomock.Call.Do
|
||||
func (c *MockRecordSideCall) Do(f func() internal.Side) *MockRecordSideCall {
|
||||
c.Call = c.Call.Do(f)
|
||||
return c
|
||||
}
|
||||
|
||||
// DoAndReturn rewrite *gomock.Call.DoAndReturn
|
||||
func (c *MockRecordSideCall) DoAndReturn(f func() internal.Side) *MockRecordSideCall {
|
||||
c.Call = c.Call.DoAndReturn(f)
|
||||
return c
|
||||
}
|
||||
|
||||
// Symbol mocks base method.
|
||||
func (m *MockRecord) Symbol() string {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "Symbol")
|
||||
ret0, _ := ret[0].(string)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// Symbol indicates an expected call of Symbol.
|
||||
func (mr *MockRecordMockRecorder) Symbol() *MockRecordSymbolCall {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Symbol", reflect.TypeOf((*MockRecord)(nil).Symbol))
|
||||
return &MockRecordSymbolCall{Call: call}
|
||||
}
|
||||
|
||||
// MockRecordSymbolCall wrap *gomock.Call
|
||||
type MockRecordSymbolCall struct {
|
||||
*gomock.Call
|
||||
}
|
||||
|
||||
// Return rewrite *gomock.Call.Return
|
||||
func (c *MockRecordSymbolCall) Return(arg0 string) *MockRecordSymbolCall {
|
||||
c.Call = c.Call.Return(arg0)
|
||||
return c
|
||||
}
|
||||
|
||||
// Do rewrite *gomock.Call.Do
|
||||
func (c *MockRecordSymbolCall) Do(f func() string) *MockRecordSymbolCall {
|
||||
c.Call = c.Call.Do(f)
|
||||
return c
|
||||
}
|
||||
|
||||
// DoAndReturn rewrite *gomock.Call.DoAndReturn
|
||||
func (c *MockRecordSymbolCall) DoAndReturn(f func() string) *MockRecordSymbolCall {
|
||||
c.Call = c.Call.DoAndReturn(f)
|
||||
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()
|
||||
ret := m.ctrl.Call(m, "Timestamp")
|
||||
ret0, _ := ret[0].(time.Time)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// Timestamp indicates an expected call of Timestamp.
|
||||
func (mr *MockRecordMockRecorder) Timestamp() *MockRecordTimestampCall {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Timestamp", reflect.TypeOf((*MockRecord)(nil).Timestamp))
|
||||
return &MockRecordTimestampCall{Call: call}
|
||||
}
|
||||
|
||||
// MockRecordTimestampCall wrap *gomock.Call
|
||||
type MockRecordTimestampCall struct {
|
||||
*gomock.Call
|
||||
}
|
||||
|
||||
// Return rewrite *gomock.Call.Return
|
||||
func (c *MockRecordTimestampCall) Return(arg0 time.Time) *MockRecordTimestampCall {
|
||||
c.Call = c.Call.Return(arg0)
|
||||
return c
|
||||
}
|
||||
|
||||
// Do rewrite *gomock.Call.Do
|
||||
func (c *MockRecordTimestampCall) Do(f func() time.Time) *MockRecordTimestampCall {
|
||||
c.Call = c.Call.Do(f)
|
||||
return c
|
||||
}
|
||||
|
||||
// DoAndReturn rewrite *gomock.Call.DoAndReturn
|
||||
func (c *MockRecordTimestampCall) DoAndReturn(f func() time.Time) *MockRecordTimestampCall {
|
||||
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
22
internal/nature.go
Normal 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
38
internal/nature_test.go
Normal 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
126
internal/open_figi.go
Normal 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
182
internal/open_figi_test.go
Normal 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,
|
||||
}
|
||||
}
|
||||
133
internal/report.go
Normal file
133
internal/report.go
Normal 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
100
internal/report_test.go
Normal 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)
|
||||
30
internal/side.go
Normal file
30
internal/side.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package internal
|
||||
|
||||
type Side uint
|
||||
|
||||
const (
|
||||
SideUnknown Side = iota
|
||||
SideBuy
|
||||
SideSell
|
||||
)
|
||||
|
||||
func (d Side) String() string {
|
||||
switch d {
|
||||
case SideBuy:
|
||||
return "buy"
|
||||
case SideSell:
|
||||
return "sell"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// IsBuy returns true if the s == SideBuy
|
||||
func (d Side) IsBuy() bool {
|
||||
return d == SideBuy
|
||||
}
|
||||
|
||||
// IsSell returns true if the s == SideSell
|
||||
func (d Side) IsSell() bool {
|
||||
return d == SideSell
|
||||
}
|
||||
60
internal/side_test.go
Normal file
60
internal/side_test.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package internal
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSide_String(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
side Side
|
||||
want string
|
||||
}{
|
||||
{"buy", SideBuy, "buy"},
|
||||
{"sell", SideSell, "sell"},
|
||||
{"unknown", SideUnknown, "unknown"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := tt.side.String(); got != tt.want {
|
||||
t.Errorf("want Side.String() to be %v but got %v", tt.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSide_IsBuy(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
side Side
|
||||
want bool
|
||||
}{
|
||||
{"buy", SideBuy, true},
|
||||
{"sell", SideSell, false},
|
||||
{"unknown", SideUnknown, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := tt.side.IsBuy(); got != tt.want {
|
||||
t.Errorf("want Side.IsBuy() to be %v but got %v", tt.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSide_IsSell(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
side Side
|
||||
want bool
|
||||
}{
|
||||
{"buy", SideBuy, false},
|
||||
{"sell", SideSell, true},
|
||||
{"unknown", SideUnknown, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := tt.side.IsSell(); got != tt.want {
|
||||
t.Errorf("want Side.IsSell() to be %v but got %v", tt.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
7
internal/trading212/constants.go
Normal file
7
internal/trading212/constants.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package trading212
|
||||
|
||||
import (
|
||||
"github.com/biter777/countries"
|
||||
)
|
||||
|
||||
const Country = countries.Cyprus
|
||||
187
internal/trading212/record.go
Normal file
187
internal/trading212/record.go
Normal file
@@ -0,0 +1,187 @@
|
||||
package trading212
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/biter777/countries"
|
||||
"github.com/nmoniz/any2anexoj/internal"
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
|
||||
type Record struct {
|
||||
symbol string
|
||||
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) Timestamp() time.Time {
|
||||
return r.timestamp
|
||||
}
|
||||
|
||||
func (r Record) BrokerCountry() int64 {
|
||||
return int64(Country)
|
||||
}
|
||||
|
||||
func (r Record) AssetCountry() int64 {
|
||||
return int64(countries.ByName(r.Symbol()[:2]).Info().Code)
|
||||
}
|
||||
|
||||
func (r Record) Side() internal.Side {
|
||||
return r.side
|
||||
}
|
||||
|
||||
func (r Record) Quantity() decimal.Decimal {
|
||||
return r.quantity
|
||||
}
|
||||
|
||||
func (r Record) Price() decimal.Decimal {
|
||||
return r.price
|
||||
}
|
||||
|
||||
func (r Record) Fees() decimal.Decimal {
|
||||
return r.fees
|
||||
}
|
||||
|
||||
func (r Record) Taxes() 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,
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
MarketBuy = "market buy"
|
||||
MarketSell = "market sell"
|
||||
LimitBuy = "limit buy"
|
||||
LimitSell = "limit sell"
|
||||
)
|
||||
|
||||
func (rr RecordReader) ReadRecord(ctx context.Context) (internal.Record, error) {
|
||||
for {
|
||||
raw, err := rr.reader.Read()
|
||||
if err != nil {
|
||||
return Record{}, fmt.Errorf("read record: %w", err)
|
||||
}
|
||||
|
||||
var side internal.Side
|
||||
switch strings.ToLower(raw[0]) {
|
||||
case MarketBuy, LimitBuy:
|
||||
side = internal.SideBuy
|
||||
case MarketSell, LimitSell:
|
||||
side = internal.SideSell
|
||||
case "action", "stock split open", "stock split close":
|
||||
continue
|
||||
default:
|
||||
return Record{}, fmt.Errorf("parse record type: %s", raw[0])
|
||||
}
|
||||
|
||||
qant, err := parseDecimal(raw[6])
|
||||
if err != nil {
|
||||
return Record{}, fmt.Errorf("parse record quantity: %w", err)
|
||||
}
|
||||
|
||||
price, err := parseDecimal(raw[7])
|
||||
if err != nil {
|
||||
return Record{}, fmt.Errorf("parse record price: %w", err)
|
||||
}
|
||||
|
||||
ts, err := time.Parse(time.DateTime, raw[1])
|
||||
if err != nil {
|
||||
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,
|
||||
fees: conversionFee,
|
||||
taxes: stampDutyTax.Add(frenchTxTax),
|
||||
timestamp: ts,
|
||||
natureGetter: figiNatureGetter(ctx, rr.figi, raw[2]),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
240
internal/trading212/record_test.go
Normal file
240
internal/trading212/record_test.go
Normal file
@@ -0,0 +1,240 @@
|
||||
package trading212
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/nmoniz/any2anexoj/internal"
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
|
||||
func TestRecordReader_ReadRecord(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
r io.Reader
|
||||
want Record
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "empty reader",
|
||||
r: bytes.NewBufferString(""),
|
||||
want: Record{},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
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: "XX1234567890",
|
||||
side: internal.SideBuy,
|
||||
quantity: ShouldParseDecimal(t, "2.4387014200"),
|
||||
price: ShouldParseDecimal(t, "7.3690000000"),
|
||||
timestamp: time.Date(2025, 7, 3, 10, 44, 29, 0, time.UTC),
|
||||
fees: ShouldParseDecimal(t, "0.02"),
|
||||
taxes: ShouldParseDecimal(t, "0.25"),
|
||||
natureGetter: func() internal.Nature { return internal.NatureG01 },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "well-formed sell",
|
||||
r: bytes.NewBufferString(`Market sell,2025-08-04 11:45:30,XX1234567890,ABXY,"Aspargus Broccoli",EOF987654321,2.4387014200,7.9999999999,USD,1.17995999,,"EUR",15.25,"EUR",,,0.02,"EUR",0.1,"EUR"`),
|
||||
want: Record{
|
||||
symbol: "XX1234567890",
|
||||
side: internal.SideSell,
|
||||
quantity: ShouldParseDecimal(t, "2.4387014200"),
|
||||
price: ShouldParseDecimal(t, "7.9999999999"),
|
||||
timestamp: time.Date(2025, 8, 4, 11, 45, 30, 0, time.UTC),
|
||||
fees: ShouldParseDecimal(t, "0.02"),
|
||||
taxes: ShouldParseDecimal(t, "0.1"),
|
||||
natureGetter: func() internal.Nature { return internal.NatureG01 },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "malformed side",
|
||||
r: bytes.NewBufferString(`Aljksdaf Balsjdkf,2025-08-04 11:45:39,IE000GA3D489,ABXY,"Aspargus 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 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 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 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 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 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 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 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, NewFigiClientSecurityTypeStub(t, "Common Stock"))
|
||||
got, gotErr := rr.ReadRecord(t.Context())
|
||||
if gotErr != nil {
|
||||
if !tt.wantErr {
|
||||
t.Fatalf("ReadRecord() failed: %v", gotErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if tt.wantErr {
|
||||
t.Fatalf("ReadRecord() expected an error")
|
||||
}
|
||||
|
||||
if got.Symbol() != tt.want.symbol {
|
||||
t.Fatalf("want symbol %v but got %v", tt.want.symbol, got.Symbol())
|
||||
}
|
||||
|
||||
if got.Side() != tt.want.side {
|
||||
t.Fatalf("want side %v but got %v", tt.want.side, got.Side())
|
||||
}
|
||||
|
||||
if got.Price().Cmp(tt.want.price) != 0 {
|
||||
t.Fatalf("want price %v but got %v", tt.want.price, got.Price())
|
||||
}
|
||||
|
||||
if got.Quantity().Cmp(tt.want.quantity) != 0 {
|
||||
t.Fatalf("want quantity %v but got %v", tt.want.quantity, got.Quantity())
|
||||
}
|
||||
|
||||
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 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)
|
||||
if err != nil {
|
||||
t.Fatalf("parsing decimal: %s", sf)
|
||||
}
|
||||
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
27
licenses/golang.md
Normal 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
28
licenses/spf13-pflag.md
Normal 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
202
licenses/uber-go-mock.md
Normal 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.
|
||||
195
main.go
195
main.go
@@ -1,195 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"container/list"
|
||||
"encoding/csv"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"math/big"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
r := NewRecordReader(f)
|
||||
|
||||
assets := make(map[string]*list.List)
|
||||
for {
|
||||
record, err := r.ReadRecord()
|
||||
if err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
return fmt.Errorf("read statement record: %w", err)
|
||||
}
|
||||
|
||||
switch record.Direction() {
|
||||
case DirectionBuy:
|
||||
lst, ok := assets[record.Symbol()]
|
||||
if !ok {
|
||||
lst = list.New()
|
||||
assets[record.Symbol()] = lst
|
||||
}
|
||||
lst.PushBack(record)
|
||||
|
||||
case DirectionSell:
|
||||
lst, ok := assets[record.Symbol()]
|
||||
if !ok {
|
||||
return ErrSellWithoutBuy
|
||||
}
|
||||
|
||||
unmatchedQty := new(big.Float).Copy(record.Quantity())
|
||||
zero := new(big.Float)
|
||||
|
||||
for unmatchedQty.Cmp(zero) > 0 {
|
||||
front := lst.Front()
|
||||
if front == nil {
|
||||
return ErrSellWithoutBuy
|
||||
}
|
||||
|
||||
next, ok := front.Value.(Record)
|
||||
if !ok {
|
||||
return fmt.Errorf("unexpected record type: %T", front)
|
||||
}
|
||||
|
||||
var matchedQty *big.Float
|
||||
if next.Quantity().Cmp(unmatchedQty) > 0 {
|
||||
matchedQty = unmatchedQty
|
||||
next.Quantity().Sub(next.Quantity(), unmatchedQty)
|
||||
} else {
|
||||
matchedQty = next.Quantity()
|
||||
lst.Remove(front)
|
||||
}
|
||||
|
||||
unmatchedQty.Sub(unmatchedQty, matchedQty)
|
||||
|
||||
sellValue := new(big.Float).Mul(matchedQty, record.Price())
|
||||
buyValue := new(big.Float).Mul(matchedQty, next.Price())
|
||||
realisedPnL := new(big.Float).Sub(sellValue, buyValue)
|
||||
slog.Info("Realised PnL",
|
||||
slog.Any("Symbol", record.Symbol()),
|
||||
slog.Any("PnL", realisedPnL))
|
||||
}
|
||||
|
||||
default:
|
||||
return fmt.Errorf("unknown direction: %s", record.Direction())
|
||||
}
|
||||
}
|
||||
|
||||
slog.Info("Finish processing statement", slog.Any("assets_count", len(assets)))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var ErrSellWithoutBuy = fmt.Errorf("found sell without bought volume")
|
||||
|
||||
type Record struct {
|
||||
symbol string
|
||||
direction Direction
|
||||
quantity *big.Float
|
||||
price *big.Float
|
||||
}
|
||||
|
||||
func (r Record) Symbol() string {
|
||||
return r.symbol
|
||||
}
|
||||
|
||||
func (r Record) Direction() Direction {
|
||||
return r.direction
|
||||
}
|
||||
|
||||
func (r Record) Quantity() *big.Float {
|
||||
return r.quantity
|
||||
}
|
||||
|
||||
func (r Record) Price() *big.Float {
|
||||
return r.price
|
||||
}
|
||||
|
||||
type RecordReader struct {
|
||||
reader *csv.Reader
|
||||
}
|
||||
|
||||
func NewRecordReader(r io.Reader) *RecordReader {
|
||||
return &RecordReader{
|
||||
reader: csv.NewReader(r),
|
||||
}
|
||||
}
|
||||
|
||||
func (rr RecordReader) ReadRecord() (Record, error) {
|
||||
for {
|
||||
raw, err := rr.reader.Read()
|
||||
if err != nil {
|
||||
return Record{}, fmt.Errorf("read record: %w", err)
|
||||
}
|
||||
|
||||
var dir Direction
|
||||
switch strings.ToLower(raw[0]) {
|
||||
case "market buy":
|
||||
dir = DirectionBuy
|
||||
case "market sell":
|
||||
dir = DirectionSell
|
||||
case "action", "stock split open", "stock split close":
|
||||
continue
|
||||
default:
|
||||
return Record{}, fmt.Errorf("unhandled record: %s", raw[0])
|
||||
}
|
||||
qant, _, err := big.ParseFloat(raw[6], 10, 20, big.ToZero)
|
||||
if err != nil {
|
||||
return Record{}, fmt.Errorf("parse quantity: %w", err)
|
||||
}
|
||||
|
||||
price, _, err := big.ParseFloat(raw[7], 10, 20, big.ToZero)
|
||||
if err != nil {
|
||||
return Record{}, fmt.Errorf("parse price: %w", err)
|
||||
}
|
||||
|
||||
return Record{
|
||||
symbol: raw[2],
|
||||
direction: dir,
|
||||
quantity: qant,
|
||||
price: price,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
type Direction uint
|
||||
|
||||
const (
|
||||
DirectionUnknown Direction = 0
|
||||
DirectionBuy = 1
|
||||
DirectionSell = 2
|
||||
)
|
||||
|
||||
func (d Direction) String() string {
|
||||
switch d {
|
||||
case 1:
|
||||
return "buy"
|
||||
case 2:
|
||||
return "sell"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
func (d Direction) IsBuy() bool {
|
||||
return d == DirectionBuy
|
||||
}
|
||||
|
||||
func (d Direction) IsSell() bool {
|
||||
return d == DirectionSell
|
||||
}
|
||||
Reference in New Issue
Block a user