Compare commits
1
Commits
main
..
8ee834536c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ee834536c |
@@ -1,32 +0,0 @@
|
|||||||
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/[email protected]
|
|
||||||
|
|
||||||
- name: Send coverage
|
|
||||||
env:
|
|
||||||
COVERALLS_TOKEN: ${{ secrets.COVERALLS_REPO_TOKEN}}
|
|
||||||
run: goveralls -coverprofile=coverage.out -service=github
|
|
||||||
@@ -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/[email protected]
|
||||||
|
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
|
||||||
@@ -1,37 +1,20 @@
|
|||||||
name: Generate check
|
name: Generate check
|
||||||
|
|
||||||
on:
|
on:
|
||||||
|
push:
|
||||||
|
paths:
|
||||||
|
- '**/*_gen.go'
|
||||||
pull_request:
|
pull_request:
|
||||||
types: [opened, reopened, synchronize]
|
paths:
|
||||||
|
- '**/*_gen.go'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
check-changes:
|
check-generate:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
outputs:
|
|
||||||
has_gen_changes: ${{ steps.check.outputs.has_gen_changes }}
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v3
|
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
|
- name: Setup Go
|
||||||
uses: actions/setup-go@v4
|
uses: actions/setup-go@v4
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -1,74 +0,0 @@
|
|||||||
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
|
|
||||||
|
|
||||||
static-checks:
|
|
||||||
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.26
|
|
||||||
cache: true
|
|
||||||
|
|
||||||
- name: Run go vet
|
|
||||||
run: go vet ./...
|
|
||||||
|
|
||||||
- name: Check go mod tidy
|
|
||||||
run: |
|
|
||||||
go mod tidy
|
|
||||||
if [ -n "$(git status --porcelain go.mod go.sum)" ]; then
|
|
||||||
echo "go.mod or go.sum changed after go mod tidy; please run go mod tidy and commit the result" >&2
|
|
||||||
git diff go.mod go.sum >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Run govulncheck
|
|
||||||
run: |
|
|
||||||
go install golang.org/x/vuln/cmd/govulncheck@latest
|
|
||||||
govulncheck ./...
|
|
||||||
|
|
||||||
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.26
|
|
||||||
cache: true
|
|
||||||
|
|
||||||
- name: Run Unit tests
|
|
||||||
run: |
|
|
||||||
go test -race -covermode atomic -coverprofile=coverage.out ./...
|
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
name: Tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types: [opened, reopened, synchronize]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
tests:
|
||||||
|
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 tests
|
||||||
|
run: go test -v ./...
|
||||||
+55
@@ -618,3 +618,58 @@ an absolute waiver of all civil liability in connection with the
|
|||||||
Program, unless a warranty or assumption of liability accompanies a
|
Program, unless a warranty or assumption of liability accompanies a
|
||||||
copy of the Program in return for a fee.
|
copy of the Program in return for a fee.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
|
If you develop a new program, and you want it to be of the greatest
|
||||||
|
possible use to the public, the best way to achieve this is to make it
|
||||||
|
free software which everyone can redistribute and change under these terms.
|
||||||
|
|
||||||
|
To do so, attach the following notices to the program. It is safest
|
||||||
|
to attach them to the start of each source file to most effectively
|
||||||
|
state the exclusion of warranty; and each file should have at least
|
||||||
|
the "copyright" line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
|
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/>.
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
If the program does terminal interaction, make it output a short
|
||||||
|
notice like this when it starts in an interactive mode:
|
||||||
|
|
||||||
|
<program> Copyright (C) <year> <name of author>
|
||||||
|
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||||
|
This is free software, and you are welcome to redistribute it
|
||||||
|
under certain conditions; type `show c' for details.
|
||||||
|
|
||||||
|
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||||
|
parts of the General Public License. Of course, your program's commands
|
||||||
|
might be different; for a GUI interface, you would use an "about box".
|
||||||
|
|
||||||
|
You should also get your employer (if you work as a programmer) or school,
|
||||||
|
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||||
|
For more information on this, and how to apply and follow the GNU GPL, see
|
||||||
|
<https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
The GNU General Public License does not permit incorporating your program
|
||||||
|
into proprietary programs. If your program is a subroutine library, you
|
||||||
|
may consider it more useful to permit linking proprietary applications with
|
||||||
|
the library. If this is what you want to do, use the GNU Lesser General
|
||||||
|
Public License instead of this License. But first, please read
|
||||||
|
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
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/>.
|
|
||||||
@@ -1,16 +1,13 @@
|
|||||||
# any2anexoj
|
# any2anexoj
|
||||||
|
|
||||||
[](https://coveralls.io/github/nmoniz/any2anexoj?branch=main)
|
This tool converts the statements from brokers and exchanges into a format compatible with the Portuguese IRS form: [Mod_3_anexo_j](https://info.portaldasfinancas.gov.pt/pt/apoio_contribuinte/modelos_formularios/irs/Documents/Mod_3_anexo_J.pdf)
|
||||||
|
|
||||||
<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]
|
> [!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.
|
> Although I made significant efforts to ensure the correctness of the calculations you should verify any outputs produced by this tool on your own or with a certified accountant.
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> This tool is in early stages of development. Use at your own risk!
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -20,36 +17,5 @@ go install github.com/nmoniz/any2anexoj/cmd/any2anexoj-cli@latest
|
|||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cat statement.csv | any2anexoj-cli --platform=tranding212
|
cat statement.csv | broker2anexoj --platform=tranding212
|
||||||
```
|
```
|
||||||
|
|
||||||
### Incremental Reports
|
|
||||||
|
|
||||||
For year over year reporting, pass `--state-file <path>` to keep the FIFO buy-queue between runs. The typical workflow is:
|
|
||||||
|
|
||||||
1. Generate the first year Anexo J report from the first year broker statement and keep the state file.
|
|
||||||
2. Next year, generate the next report from the second year broker statement **only**, using the same state file.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# first year
|
|
||||||
cat 2024-statement.csv | any2anexoj-cli --platform=trading212 --state-file=trading212-state.json
|
|
||||||
|
|
||||||
# following year
|
|
||||||
cat 2025-statement.csv | any2anexoj-cli --platform=trading212 --state-file=trading212-state.json
|
|
||||||
```
|
|
||||||
|
|
||||||
The first run behaves like a normal run and creates the state file.
|
|
||||||
On later runs the tool loads the previously saved buy queues per symbol (including partially filled lots), processes only the new transactions from the input, and writes the updated state.
|
|
||||||
The state file is a JSON document.
|
|
||||||
|
|
||||||
> [!IMPORTANT]
|
|
||||||
> Each new input must contain **only** the new transactions, not the previous ones.
|
|
||||||
> The state file already holds the unmatched buy lots from earlier runs, so including previous trades again will cause them to be processed twice and may fail with insufficient bought volume.
|
|
||||||
|
|
||||||
State is only persisted once the input reaches EOF, so a crash or `Ctrl-C` before the end of input will discard progress from that run.
|
|
||||||
|
|
||||||
## 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.
|
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/csv"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
|
|
||||||
"github.com/nmoniz/any2anexoj/internal"
|
|
||||||
)
|
|
||||||
|
|
||||||
type CSVWriter struct {
|
|
||||||
w *csv.Writer
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewCSVWriter(w io.Writer) *CSVWriter {
|
|
||||||
return &CSVWriter{w: csv.NewWriter(w)}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (cw *CSVWriter) Render(aw *internal.AggregatorWriter) error {
|
|
||||||
err := cw.w.Write([]string{
|
|
||||||
"source_country", "code",
|
|
||||||
"realization_year", "realization_month", "realization_day", "realization_value",
|
|
||||||
"acquisition_year", "acquisition_month", "acquisition_day", "acquisition_value",
|
|
||||||
"expenses", "foreign_tax_paid", "counter_country",
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("write csv header: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for ri := range aw.Iter() {
|
|
||||||
err := cw.w.Write(reportItemToRow(ri))
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("write csv row: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cw.w.Flush()
|
|
||||||
return cw.w.Error()
|
|
||||||
}
|
|
||||||
|
|
||||||
func reportItemToRow(ri internal.ReportItem) []string {
|
|
||||||
return []string{
|
|
||||||
fmt.Sprintf("%d", ri.AssetCountry),
|
|
||||||
string(ri.Nature),
|
|
||||||
fmt.Sprintf("%d", ri.SellTimestamp.Year()),
|
|
||||||
fmt.Sprintf("%d", int(ri.SellTimestamp.Month())),
|
|
||||||
fmt.Sprintf("%d", ri.SellTimestamp.Day()),
|
|
||||||
ri.SellValue.StringFixed(2),
|
|
||||||
fmt.Sprintf("%d", ri.BuyTimestamp.Year()),
|
|
||||||
fmt.Sprintf("%d", int(ri.BuyTimestamp.Month())),
|
|
||||||
fmt.Sprintf("%d", ri.BuyTimestamp.Day()),
|
|
||||||
ri.BuyValue.StringFixed(2),
|
|
||||||
ri.Fees.StringFixed(2),
|
|
||||||
ri.Taxes.StringFixed(2),
|
|
||||||
fmt.Sprintf("%d", ri.BrokerCountry),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+29
-106
@@ -3,143 +3,66 @@ package main
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/nmoniz/any2anexoj/internal"
|
"github.com/nmoniz/any2anexoj/internal"
|
||||||
"github.com/nmoniz/any2anexoj/internal/ofigi"
|
|
||||||
"github.com/nmoniz/any2anexoj/internal/trading212"
|
"github.com/nmoniz/any2anexoj/internal/trading212"
|
||||||
"github.com/spf13/pflag"
|
"github.com/spf13/pflag"
|
||||||
"golang.org/x/text/language"
|
"golang.org/x/sync/errgroup"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
// TODO: once we support more brokers or exchanges we should make this parameter required and
|
||||||
// TODO: once we support more brokers or exchanges we should make this parameter required and
|
// remove/change default
|
||||||
// remove/change default
|
var platform = pflag.StringP("platform", "p", "trading212", "one of the supported platforms")
|
||||||
platform = pflag.StringP("platform", "p", "trading212", "One of the supported platforms")
|
|
||||||
lang = pflag.StringP("language", "l", language.Portuguese.String(), "The 2 letter language code")
|
var readerFactories = map[string]func() internal.RecordReader{
|
||||||
debug = pflag.BoolP("debug", "d", false, "Activate to log debug messages")
|
"trading212": func() internal.RecordReader { return trading212.NewRecordReader(os.Stdin) },
|
||||||
format = pflag.StringP("format", "f", "table", "Output format: table or csv")
|
}
|
||||||
ofAPIKey = pflag.String("open-figi-api-key", "", "An OpenFIGI API key for faster report generation (better rate api rate limits)")
|
|
||||||
selectors = pflag.StringSlice("selectors", nil, "Only process entries that conform to all the selectors: code, assetCountry")
|
|
||||||
stateFile = pflag.String("state-file", "", "Path to a state file for incremental processing")
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
pflag.Parse()
|
pflag.Parse()
|
||||||
|
|
||||||
err := run(context.Background())
|
if platform == nil || len(*platform) == 0 {
|
||||||
|
slog.Error("--platform flag is required")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := run(context.Background(), *platform)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("found a fatal issue", slog.Any("err", err))
|
slog.Error("found a fatal issue", slog.Any("err", err))
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// run is the production entry point. It wires the CLI flags to runWithIO
|
func run(ctx context.Context, platform string) error {
|
||||||
// using the process's actual stdin/stdout and installs OS-signal-driven
|
|
||||||
// cancellation so a Ctrl-C cancels in-flight processing.
|
|
||||||
func run(ctx context.Context) error {
|
|
||||||
ctx, cancel := signal.NotifyContext(ctx, os.Kill, os.Interrupt)
|
ctx, cancel := signal.NotifyContext(ctx, os.Kill, os.Interrupt)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
return runWithIO(ctx, os.Stdin, os.Stdout)
|
|
||||||
}
|
|
||||||
|
|
||||||
// runWithIO is the testable core of the CLI. It reads broker records from
|
eg, ctx := errgroup.WithContext(ctx)
|
||||||
// stdin, writes the formatted report to stdout, and optionally persists
|
|
||||||
// incremental state to the path supplied via --state-file.
|
|
||||||
func runWithIO(ctx context.Context, stdin io.Reader, stdout io.Writer) error {
|
|
||||||
logLevel := slog.LevelInfo
|
|
||||||
if *debug {
|
|
||||||
logLevel = slog.LevelDebug
|
|
||||||
}
|
|
||||||
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: logLevel})))
|
|
||||||
|
|
||||||
if platform == nil || len(*platform) == 0 {
|
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, nil)))
|
||||||
return fmt.Errorf("--platform flag is required")
|
|
||||||
|
factory, ok := readerFactories[platform]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("unsupported platform: %s", platform)
|
||||||
}
|
}
|
||||||
|
|
||||||
if lang == nil || len(*lang) == 0 {
|
reader := factory()
|
||||||
return fmt.Errorf("--language flag is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
figiClient := ofigi.NewOpenFIGI(&http.Client{Timeout: 5 * time.Second}, *ofAPIKey)
|
writer := internal.NewStdOutLogger()
|
||||||
|
|
||||||
reader, err := getReader(*platform, stdin, figiClient)
|
eg.Go(func() error {
|
||||||
if err != nil {
|
return internal.BuildReport(ctx, reader, writer)
|
||||||
return fmt.Errorf("getting reader: %w", err)
|
})
|
||||||
}
|
|
||||||
|
|
||||||
store, err := buildStore(*stateFile, *platform, figiClient)
|
err := eg.Wait()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
writer := internal.NewAggregatorWriter()
|
slog.Info("Finish processing statement")
|
||||||
|
|
||||||
selector, err := internal.ParseSelectors(*selectors)
|
return nil
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("parsing selectors: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = internal.BuildReport(
|
|
||||||
ctx,
|
|
||||||
reader,
|
|
||||||
writer,
|
|
||||||
internal.WithSelector(selector),
|
|
||||||
internal.WithStore(store),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
switch *format {
|
|
||||||
case "csv":
|
|
||||||
return NewCSVWriter(stdout).Render(writer)
|
|
||||||
case "table":
|
|
||||||
loc, err := NewLocalizer(*lang)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("create localizer: %w", err)
|
|
||||||
}
|
|
||||||
NewPrettyPrinter(stdout, loc).Render(writer)
|
|
||||||
return nil
|
|
||||||
default:
|
|
||||||
return fmt.Errorf("unsupported format %q: must be table or csv", *format)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildStore returns the Store implementation that BuildReport should use.
|
|
||||||
// When --state-file is empty an EphemeralStore is used so behaviour is
|
|
||||||
// identical to pre-persistence runs. Otherwise a JSON-backed JSONStore is
|
|
||||||
// returned, wired to a platform-specific RecordSerializer.
|
|
||||||
func buildStore(stateFile, platform string, figi *ofigi.Client) (internal.Store, error) {
|
|
||||||
if stateFile == "" {
|
|
||||||
return internal.EphemeralStore{}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var serializer internal.RecordSerializer
|
|
||||||
switch platform {
|
|
||||||
case "trading212":
|
|
||||||
serializer = trading212.NewRecordSerializer(figi)
|
|
||||||
default:
|
|
||||||
return nil, fmt.Errorf("unsupported platform for state persistence: %s", platform)
|
|
||||||
}
|
|
||||||
|
|
||||||
store, err := internal.NewJSONStore(stateFile, platform, serializer)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("creating file store: %w", err)
|
|
||||||
}
|
|
||||||
return store, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func getReader(platform string, r io.Reader, figi *ofigi.Client) (internal.RecordReader, error) {
|
|
||||||
switch platform {
|
|
||||||
case "trading212":
|
|
||||||
return trading212.NewRecordReader(r, figi), nil
|
|
||||||
default:
|
|
||||||
return nil, fmt.Errorf("unsupported platform: %s", platform)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,168 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/spf13/pflag"
|
|
||||||
)
|
|
||||||
|
|
||||||
// resetFlags puts every pflag-backed variable back to its default so each
|
|
||||||
// test that calls runWithIO sees a clean slate regardless of test ordering
|
|
||||||
// or arguments the previous test set via pflag.Set.
|
|
||||||
func resetFlags(t *testing.T) {
|
|
||||||
t.Helper()
|
|
||||||
if err := pflag.Set("platform", "trading212"); err != nil {
|
|
||||||
t.Fatalf("reset platform flag: %v", err)
|
|
||||||
}
|
|
||||||
if err := pflag.Set("language", "en"); err != nil {
|
|
||||||
t.Fatalf("reset language flag: %v", err)
|
|
||||||
}
|
|
||||||
if err := pflag.Set("debug", "false"); err != nil {
|
|
||||||
t.Fatalf("reset debug flag: %v", err)
|
|
||||||
}
|
|
||||||
if err := pflag.Set("format", "table"); err != nil {
|
|
||||||
t.Fatalf("reset format flag: %v", err)
|
|
||||||
}
|
|
||||||
if err := pflag.Set("open-figi-api-key", ""); err != nil {
|
|
||||||
t.Fatalf("reset open-figi-api-key flag: %v", err)
|
|
||||||
}
|
|
||||||
if err := pflag.Set("selectors", ""); err != nil {
|
|
||||||
t.Fatalf("reset selectors flag: %v", err)
|
|
||||||
}
|
|
||||||
if err := pflag.Set("state-file", ""); err != nil {
|
|
||||||
t.Fatalf("reset state-file flag: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// trading212SampleCSV is a minimal Trading212 export with a header row and
|
|
||||||
// one market buy + one matching market sell so BuildReport reaches EOF and
|
|
||||||
// exercises the store.Save path. The line format mirrors the fixtures in
|
|
||||||
// internal/trading212/record_test.go (20 columns).
|
|
||||||
const trading212SampleCSV = `Action,Time,ISIN,Ticker,Name,Notes,Quantity,Price,Price currency,Exchange rate,Result,Result currency,Charges,Charges currency,Stamp duty,Stamp duty currency,Conversion fee,Conversion fee currency,French transaction tax,French transaction tax currency
|
|
||||||
Market buy,2025-07-03 10:44:29,XX1234567890,ABXY,"Asparagus Broccoli",EOF987654321,2.4387014200,7.3690000000,USD,1.17995999,,"EUR",15.25,"EUR",0.25,"EUR",0.02,"EUR",,
|
|
||||||
Market sell,2025-08-04 11:45:30,XX1234567890,ABXY,"Asparagus Broccoli",EOF987654321,2.4387014200,7.9999999999,USD,1.17995999,,"EUR",15.25,"EUR",,,0.02,"EUR",0.1,"EUR"
|
|
||||||
`
|
|
||||||
|
|
||||||
// runWithStdin runs runWithIO against the supplied stdin payload.
|
|
||||||
func runWithStdin(t *testing.T, stdin string, stdout *bytes.Buffer) error {
|
|
||||||
t.Helper()
|
|
||||||
if err := runWithIO(t.Context(), strings.NewReader(stdin), stdout); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestRunWithIO_StateFileCreated verifies that running the CLI with
|
|
||||||
// --state-file produces a state file on disk after a successful EOF.
|
|
||||||
func TestRunWithIO_StateFileCreated(t *testing.T) {
|
|
||||||
resetFlags(t)
|
|
||||||
t.Cleanup(func() { resetFlags(t) })
|
|
||||||
|
|
||||||
dir := t.TempDir()
|
|
||||||
statePath := filepath.Join(dir, "state.json")
|
|
||||||
|
|
||||||
if err := pflag.Set("state-file", statePath); err != nil {
|
|
||||||
t.Fatalf("set state-file flag: %v", err)
|
|
||||||
}
|
|
||||||
if err := pflag.Set("format", "csv"); err != nil {
|
|
||||||
t.Fatalf("set format flag: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var stdout bytes.Buffer
|
|
||||||
if err := runWithStdin(t, trading212SampleCSV, &stdout); err != nil {
|
|
||||||
t.Fatalf("runWithIO returned an error: %v\nstdout: %s", err, stdout.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
info, err := os.Stat(statePath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("expected state file at %s but stat returned error: %v", statePath, err)
|
|
||||||
}
|
|
||||||
if info.Size() == 0 {
|
|
||||||
t.Fatalf("state file at %s is empty", statePath)
|
|
||||||
}
|
|
||||||
|
|
||||||
// State file must look like JSON with the expected version field.
|
|
||||||
body, err := os.ReadFile(statePath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("read state file: %v", err)
|
|
||||||
}
|
|
||||||
if !bytes.Contains(body, []byte(`"version"`)) {
|
|
||||||
t.Errorf("state file missing version field, got: %s", body)
|
|
||||||
}
|
|
||||||
if !bytes.Contains(body, []byte(`"trading212"`)) {
|
|
||||||
t.Errorf("state file missing trading212 platform, got: %s", body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestRunWithIO_NoStateFileByDefault verifies that omitting --state-file
|
|
||||||
// behaves exactly like the pre-persistence CLI: nothing is written to disk
|
|
||||||
// and the report still renders.
|
|
||||||
func TestRunWithIO_NoStateFileByDefault(t *testing.T) {
|
|
||||||
resetFlags(t)
|
|
||||||
t.Cleanup(func() { resetFlags(t) })
|
|
||||||
|
|
||||||
// Use a temp working directory so any accidental file write would
|
|
||||||
// show up clearly via t.TempDir's cleanup listing.
|
|
||||||
dir := t.TempDir()
|
|
||||||
cwd, err := os.Getwd()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("getwd: %v", err)
|
|
||||||
}
|
|
||||||
if err := os.Chdir(dir); err != nil {
|
|
||||||
t.Fatalf("chdir: %v", err)
|
|
||||||
}
|
|
||||||
t.Cleanup(func() { _ = os.Chdir(cwd) })
|
|
||||||
|
|
||||||
if err := pflag.Set("format", "csv"); err != nil {
|
|
||||||
t.Fatalf("set format flag: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var stdout bytes.Buffer
|
|
||||||
if err := runWithStdin(t, trading212SampleCSV, &stdout); err != nil {
|
|
||||||
t.Fatalf("runWithIO returned an error: %v\nstdout: %s", err, stdout.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
entries, err := os.ReadDir(dir)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("read tmp dir: %v", err)
|
|
||||||
}
|
|
||||||
for _, e := range entries {
|
|
||||||
t.Errorf("unexpected file written without --state-file: %s", e.Name())
|
|
||||||
}
|
|
||||||
|
|
||||||
if stdout.Len() == 0 {
|
|
||||||
t.Errorf("expected non-empty csv output on stdout")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestRunWithIO_UnsupportedPlatformForPersistence verifies that using
|
|
||||||
// --state-file with an unknown platform surfaces a clear error rather than
|
|
||||||
// silently falling back to EphemeralStore.
|
|
||||||
func TestRunWithIO_UnsupportedPlatformForPersistence(t *testing.T) {
|
|
||||||
resetFlags(t)
|
|
||||||
t.Cleanup(func() { resetFlags(t) })
|
|
||||||
|
|
||||||
if err := pflag.Set("state-file", filepath.Join(t.TempDir(), "state.json")); err != nil {
|
|
||||||
t.Fatalf("set state-file flag: %v", err)
|
|
||||||
}
|
|
||||||
// Currently only trading212 is wired through buildStore, but the
|
|
||||||
// reader switch also only supports trading212, so the reader error
|
|
||||||
// fires first. Either error is acceptable; we just need a clear
|
|
||||||
// failure message.
|
|
||||||
if err := pflag.Set("platform", "unknown-broker"); err != nil {
|
|
||||||
t.Fatalf("set platform flag: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var stdout bytes.Buffer
|
|
||||||
err := runWithStdin(t, trading212SampleCSV, &stdout)
|
|
||||||
if err == nil {
|
|
||||||
t.Fatalf("expected an error for unsupported platform")
|
|
||||||
}
|
|
||||||
if !strings.Contains(err.Error(), "platform") {
|
|
||||||
t.Errorf("expected error to mention platform, got: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
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)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/nmoniz/any2anexoj/internal"
|
|
||||||
"github.com/shopspring/decimal"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestPrettyPrinter_Render(t *testing.T) {
|
|
||||||
// Create test data
|
|
||||||
aw := internal.NewAggregatorWriter()
|
|
||||||
ctx := t.Context()
|
|
||||||
|
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,25 +1,15 @@
|
|||||||
module github.com/nmoniz/any2anexoj
|
module github.com/nmoniz/any2anexoj
|
||||||
|
|
||||||
go 1.26.4
|
go 1.25.3
|
||||||
|
|
||||||
require (
|
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
|
go.uber.org/mock v0.6.0
|
||||||
golang.org/x/time v0.14.0
|
golang.org/x/sync v0.18.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require golang.org/x/sync v0.18.0 // indirect
|
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
github.com/spf13/pflag v1.0.10 // 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/mod v0.27.0 // indirect
|
||||||
golang.org/x/sys v0.35.0 // indirect
|
|
||||||
golang.org/x/text v0.23.0
|
|
||||||
golang.org/x/tools v0.36.0 // indirect
|
golang.org/x/tools v0.36.0 // indirect
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,40 +1,19 @@
|
|||||||
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
|
|
||||||
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
|
||||||
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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
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 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
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 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
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.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
github.com/stretchr/testify v1.9.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 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
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 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
|
||||||
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
|
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 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
||||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
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.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 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
|
||||||
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
|
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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
|||||||
@@ -1,78 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,287 +0,0 @@
|
|||||||
package internal_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"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 := t.Context()
|
|
||||||
|
|
||||||
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 := t.Context()
|
|
||||||
|
|
||||||
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 := t.Context()
|
|
||||||
|
|
||||||
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 := t.Context()
|
|
||||||
|
|
||||||
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())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
package internal
|
|
||||||
|
|
||||||
import "context"
|
|
||||||
|
|
||||||
// EphemeralStore loads an empty state and discards everything on save.
|
|
||||||
type EphemeralStore struct{}
|
|
||||||
|
|
||||||
func (EphemeralStore) Load(context.Context) (map[string]*FillerQueue, error) {
|
|
||||||
return make(map[string]*FillerQueue), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (EphemeralStore) Save(context.Context, map[string]*FillerQueue) error {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
package internal_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/nmoniz/any2anexoj/internal"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Verify that EphemeralStore.Load returns a non-nil empty map and no error.
|
|
||||||
func TestEphemeralStore_Load(t *testing.T) {
|
|
||||||
store := internal.EphemeralStore{}
|
|
||||||
|
|
||||||
queues, err := store.Load(t.Context())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Load returned unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
if queues == nil {
|
|
||||||
t.Fatalf("Load returned a nil map; expected an empty map")
|
|
||||||
}
|
|
||||||
if len(queues) != 0 {
|
|
||||||
t.Fatalf("Load returned %d entries; expected 0", len(queues))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify that EphemeralStore.Save accepts a queue map without error and
|
|
||||||
// discards its contents.
|
|
||||||
func TestEphemeralStore_Save(t *testing.T) {
|
|
||||||
store := internal.EphemeralStore{}
|
|
||||||
|
|
||||||
var q internal.FillerQueue
|
|
||||||
|
|
||||||
err := store.Save(t.Context(), map[string]*internal.FillerQueue{
|
|
||||||
"TEST": &q,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Save returned unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save is a no-op, so a subsequent Load must still be empty.
|
|
||||||
queues, err := store.Load(t.Context())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Load after Save returned unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
if len(queues) != 0 {
|
|
||||||
t.Fatalf("Load after Save returned %d entries; expected 0", len(queues))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
package internal
|
|
||||||
|
|
||||||
import "fmt"
|
|
||||||
|
|
||||||
var ErrInsufficientBoughtVolume = fmt.Errorf("insufficient bought volume")
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
package internal
|
|
||||||
|
|
||||||
import (
|
|
||||||
"container/list"
|
|
||||||
|
|
||||||
"github.com/shopspring/decimal"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Filler struct {
|
|
||||||
Record
|
|
||||||
|
|
||||||
filled decimal.Decimal
|
|
||||||
quantity decimal.Decimal
|
|
||||||
price decimal.Decimal
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewFiller(r Record) *Filler {
|
|
||||||
return &Filler{
|
|
||||||
Record: r,
|
|
||||||
quantity: r.Quantity(),
|
|
||||||
price: r.Price(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewFillerFromState constructs a Filler from a previously persisted state.
|
|
||||||
// It bypasses the Record-derived defaults so callers can restore a lot that
|
|
||||||
// may have been split-adjusted or partially filled since the Record was first
|
|
||||||
// created.
|
|
||||||
func NewFillerFromState(record Record, quantity, price, filled decimal.Decimal) *Filler {
|
|
||||||
return &Filler{
|
|
||||||
Record: record,
|
|
||||||
filled: filled,
|
|
||||||
quantity: quantity,
|
|
||||||
price: price,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *Filler) Quantity() decimal.Decimal { return f.quantity }
|
|
||||||
|
|
||||||
func (f *Filler) Price() decimal.Decimal { return f.price }
|
|
||||||
|
|
||||||
// Filled returns how much of the Filler's quantity has already been consumed.
|
|
||||||
func (f *Filler) Filled() decimal.Decimal { return f.filled }
|
|
||||||
|
|
||||||
// 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.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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ApplySplit adjusts the lot for a stock split by the given ratio (newQty/oldQty).
|
|
||||||
// The total cost basis is preserved: quantity scales up, price scales down proportionally.
|
|
||||||
func (f *Filler) ApplySplit(ratio decimal.Decimal) {
|
|
||||||
f.quantity = f.quantity.Mul(ratio)
|
|
||||||
f.filled = f.filled.Mul(ratio)
|
|
||||||
f.price = f.price.Div(ratio)
|
|
||||||
}
|
|
||||||
|
|
||||||
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 there is one. If
|
|
||||||
// the queue is already empty returns false on the 2nd return value, otherwise returns true.
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdjustForSplit applies a stock split ratio to all lots in the queue.
|
|
||||||
func (fq *FillerQueue) AdjustForSplit(ratio decimal.Decimal) {
|
|
||||||
if fq == nil || fq.l == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for e := fq.l.Front(); e != nil; e = e.Next() {
|
|
||||||
e.Value.(*Filler).ApplySplit(ratio)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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()
|
|
||||||
}
|
|
||||||
@@ -1,317 +0,0 @@
|
|||||||
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(&testRecord{}))
|
|
||||||
}
|
|
||||||
|
|
||||||
type testRecord struct {
|
|
||||||
Record
|
|
||||||
|
|
||||||
id int
|
|
||||||
quantity decimal.Decimal
|
|
||||||
price decimal.Decimal
|
|
||||||
}
|
|
||||||
|
|
||||||
func (tr testRecord) Quantity() decimal.Decimal { return tr.quantity }
|
|
||||||
func (tr testRecord) Price() decimal.Decimal { return tr.price }
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFiller_ApplySplit(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
qty float64
|
|
||||||
price float64
|
|
||||||
prefilled float64
|
|
||||||
ratio float64
|
|
||||||
wantQty float64
|
|
||||||
wantPrice float64
|
|
||||||
wantFilled float64
|
|
||||||
wantCostBasis float64
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "5:1 split on unfilled lot preserves cost basis",
|
|
||||||
qty: 10, price: 100, prefilled: 0, ratio: 5,
|
|
||||||
wantQty: 50, wantPrice: 20, wantFilled: 0, wantCostBasis: 1000,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "5:1 split on partially filled lot",
|
|
||||||
qty: 10, price: 100, prefilled: 4, ratio: 5,
|
|
||||||
wantQty: 50, wantPrice: 20, wantFilled: 20, wantCostBasis: 1000,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "1:2 reverse split on unfilled lot preserves cost basis",
|
|
||||||
qty: 10, price: 100, prefilled: 0, ratio: 0.5,
|
|
||||||
wantQty: 5, wantPrice: 200, wantFilled: 0, wantCostBasis: 1000,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
f := NewFiller(&testRecord{
|
|
||||||
quantity: decimal.NewFromFloat(tt.qty),
|
|
||||||
price: decimal.NewFromFloat(tt.price),
|
|
||||||
})
|
|
||||||
if tt.prefilled > 0 {
|
|
||||||
f.Fill(decimal.NewFromFloat(tt.prefilled))
|
|
||||||
}
|
|
||||||
|
|
||||||
f.ApplySplit(decimal.NewFromFloat(tt.ratio))
|
|
||||||
|
|
||||||
if !f.Quantity().Equal(decimal.NewFromFloat(tt.wantQty)) {
|
|
||||||
t.Errorf("want quantity %v but got %v", tt.wantQty, f.Quantity())
|
|
||||||
}
|
|
||||||
if !f.Price().Equal(decimal.NewFromFloat(tt.wantPrice)) {
|
|
||||||
t.Errorf("want price %v but got %v", tt.wantPrice, f.Price())
|
|
||||||
}
|
|
||||||
if !f.filled.Equal(decimal.NewFromFloat(tt.wantFilled)) {
|
|
||||||
t.Errorf("want filled %v but got %v", tt.wantFilled, f.filled)
|
|
||||||
}
|
|
||||||
costBasis := f.Quantity().Mul(f.Price())
|
|
||||||
if !costBasis.Equal(decimal.NewFromFloat(tt.wantCostBasis)) {
|
|
||||||
t.Errorf("want cost basis %v but got %v", tt.wantCostBasis, costBasis)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFillerQueue_AdjustForSplit(t *testing.T) {
|
|
||||||
var fq FillerQueue
|
|
||||||
fq.Push(NewFiller(&testRecord{quantity: decimal.NewFromFloat(10), price: decimal.NewFromFloat(100)}))
|
|
||||||
fq.Push(NewFiller(&testRecord{quantity: decimal.NewFromFloat(5), price: decimal.NewFromFloat(200)}))
|
|
||||||
|
|
||||||
fq.AdjustForSplit(decimal.NewFromFloat(5))
|
|
||||||
|
|
||||||
lot1, _ := fq.Pop()
|
|
||||||
if !lot1.Quantity().Equal(decimal.NewFromFloat(50)) {
|
|
||||||
t.Errorf("lot1: want quantity 50 but got %v", lot1.Quantity())
|
|
||||||
}
|
|
||||||
if !lot1.Price().Equal(decimal.NewFromFloat(20)) {
|
|
||||||
t.Errorf("lot1: want price 20 but got %v", lot1.Price())
|
|
||||||
}
|
|
||||||
|
|
||||||
lot2, _ := fq.Pop()
|
|
||||||
if !lot2.Quantity().Equal(decimal.NewFromFloat(25)) {
|
|
||||||
t.Errorf("lot2: want quantity 25 but got %v", lot2.Quantity())
|
|
||||||
}
|
|
||||||
if !lot2.Price().Equal(decimal.NewFromFloat(40)) {
|
|
||||||
t.Errorf("lot2: want price 40 but got %v", lot2.Price())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFillerQueue_AdjustForSplit_NilReceiver(t *testing.T) {
|
|
||||||
var fq *FillerQueue
|
|
||||||
fq.AdjustForSplit(decimal.NewFromFloat(5)) // must not panic
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNewFillerFromState(t *testing.T) {
|
|
||||||
// The underlying Record reports very different values; NewFillerFromState
|
|
||||||
// must ignore them and use the caller-supplied overrides instead.
|
|
||||||
rec := &testRecord{
|
|
||||||
quantity: decimal.NewFromFloat(999),
|
|
||||||
price: decimal.NewFromFloat(999),
|
|
||||||
}
|
|
||||||
|
|
||||||
qty := decimal.NewFromFloat(50)
|
|
||||||
price := decimal.NewFromFloat(20)
|
|
||||||
filled := qty // fully filled on load
|
|
||||||
|
|
||||||
f := NewFillerFromState(rec, qty, price, filled)
|
|
||||||
|
|
||||||
if !f.Quantity().Equal(qty) {
|
|
||||||
t.Errorf("want quantity %v but got %v", qty, f.Quantity())
|
|
||||||
}
|
|
||||||
if !f.Price().Equal(price) {
|
|
||||||
t.Errorf("want price %v but got %v", price, f.Price())
|
|
||||||
}
|
|
||||||
if !f.IsFilled() {
|
|
||||||
t.Errorf("want IsFilled() to be true when filled == quantity")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Partially filled: filled < quantity => IsFilled must be false.
|
|
||||||
partial := NewFillerFromState(rec, qty, price, decimal.NewFromFloat(10))
|
|
||||||
if partial.IsFilled() {
|
|
||||||
t.Errorf("want IsFilled() to be false when filled < quantity")
|
|
||||||
}
|
|
||||||
if !partial.Quantity().Equal(qty) {
|
|
||||||
t.Errorf("want quantity %v but got %v", qty, partial.Quantity())
|
|
||||||
}
|
|
||||||
if !partial.Price().Equal(price) {
|
|
||||||
t.Errorf("want price %v but got %v", price, partial.Price())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filled from Fill() on a restored lot must work correctly against the
|
|
||||||
// restored (not Record-derived) quantity/price.
|
|
||||||
_, done := partial.Fill(decimal.NewFromFloat(40))
|
|
||||||
if !done {
|
|
||||||
t.Errorf("after filling the remaining 40, IsFilled() should be true")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
package internal
|
package internal
|
||||||
|
|
||||||
//go:generate go tool mockgen -destination=mocks/mocks_gen.go -package=mocks -typed . RecordReader,Record,ReportWriter,RecordEncoder,RecordDecoder,RecordSerializer
|
//go:generate go tool mockgen -destination=mocks/mocks_gen.go -package=mocks -typed . RecordReader,Record,ReportWriter
|
||||||
|
|||||||
@@ -1,166 +0,0 @@
|
|||||||
package internal
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// JSONStore is a Store backed by a single JSON file on disk. The on-disk
|
|
||||||
// schema is the platform-agnostic State struct; per-broker Record data is
|
|
||||||
// encoded and decoded through the supplied RecordSerializer.
|
|
||||||
type JSONStore struct {
|
|
||||||
filename string
|
|
||||||
platform string
|
|
||||||
serializer RecordSerializer
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewJSONStore constructs a JSONStore that reads from and writes to the given
|
|
||||||
// filename. The JSONStore does not keep an *os.File open — Load and Save each
|
|
||||||
// open the file themselves — so callers do not need to close it. The platform
|
|
||||||
// string is recorded into the saved state and validated on Load so a state
|
|
||||||
// file from a different broker cannot be loaded by mistake.
|
|
||||||
func NewJSONStore(filename string, platform string, serializer RecordSerializer) (*JSONStore, error) {
|
|
||||||
if filename == "" {
|
|
||||||
return nil, fmt.Errorf("filename cannot be empty")
|
|
||||||
}
|
|
||||||
if serializer == nil {
|
|
||||||
return nil, fmt.Errorf("serializer cannot be nil")
|
|
||||||
}
|
|
||||||
return &JSONStore{
|
|
||||||
filename: filename,
|
|
||||||
platform: platform,
|
|
||||||
serializer: serializer,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load reads the state file and reconstructs the per-symbol FillerQueue map.
|
|
||||||
// A missing file is not an error — it returns an empty map so the first run
|
|
||||||
// against a new state file Just Works.
|
|
||||||
func (fs *JSONStore) Load(ctx context.Context) (map[string]*FillerQueue, error) {
|
|
||||||
data, err := os.ReadFile(fs.filename)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, os.ErrNotExist) {
|
|
||||||
return make(map[string]*FillerQueue), nil
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("reading state file: %w", err)
|
|
||||||
}
|
|
||||||
if len(data) == 0 {
|
|
||||||
return make(map[string]*FillerQueue), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var s State
|
|
||||||
if err := json.Unmarshal(data, &s); err != nil {
|
|
||||||
return nil, fmt.Errorf("unmarshalling state: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if s.Version != StateVersion {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"unexpected state version %q: expected %q",
|
|
||||||
s.Version, StateVersion,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if s.Platform != fs.platform {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"unexpected state platform %q: expected %q",
|
|
||||||
s.Platform, fs.platform,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
queues := make(map[string]*FillerQueue)
|
|
||||||
for symbol, persisted := range s.Queues {
|
|
||||||
q := new(FillerQueue)
|
|
||||||
for _, pf := range persisted {
|
|
||||||
rec, err := fs.serializer.UnmarshalRecord(ctx, pf.RecordData)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"unmarshalling record for symbol %q: %w", symbol, err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
q.Push(NewFillerFromState(rec, pf.Quantity, pf.Price, pf.Filled))
|
|
||||||
}
|
|
||||||
queues[symbol] = q
|
|
||||||
}
|
|
||||||
return queues, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save serialises the queue map to disk. The write is done via a temp file in
|
|
||||||
// the same directory followed by an atomic rename so a crash mid-write cannot
|
|
||||||
// leave a half-written state file.
|
|
||||||
func (fs *JSONStore) Save(ctx context.Context, queue map[string]*FillerQueue) error {
|
|
||||||
state := State{
|
|
||||||
Version: StateVersion,
|
|
||||||
Platform: fs.platform,
|
|
||||||
Queues: make(map[string][]persistedFiller),
|
|
||||||
}
|
|
||||||
|
|
||||||
for symbol, q := range queue {
|
|
||||||
if q == nil || q.Len() == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
var persisted []persistedFiller
|
|
||||||
for e := q.l.Front(); e != nil; e = e.Next() {
|
|
||||||
f := e.Value.(*Filler)
|
|
||||||
data, err := fs.serializer.MarshalRecord(ctx, f.Record)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("marshalling record for symbol %q: %w", symbol, err)
|
|
||||||
}
|
|
||||||
persisted = append(persisted, persistedFiller{
|
|
||||||
RecordData: data,
|
|
||||||
Quantity: f.Quantity(),
|
|
||||||
Price: f.Price(),
|
|
||||||
Filled: f.Filled(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
state.Queues[symbol] = persisted
|
|
||||||
}
|
|
||||||
|
|
||||||
buf := &bytes.Buffer{}
|
|
||||||
enc := json.NewEncoder(buf)
|
|
||||||
enc.SetIndent("", " ")
|
|
||||||
if err := enc.Encode(state); err != nil {
|
|
||||||
return fmt.Errorf("encoding state: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
tmp, err := os.CreateTemp(filepath.Dir(fs.filename), filepath.Base(fs.filename)+".*.tmp")
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("creating temp state file: %w", err)
|
|
||||||
}
|
|
||||||
tmpFilename := tmp.Name()
|
|
||||||
defer os.Remove(tmpFilename)
|
|
||||||
defer tmp.Close()
|
|
||||||
|
|
||||||
if _, err := io.Copy(tmp, buf); err != nil {
|
|
||||||
return fmt.Errorf("writing temp state file: %w", err)
|
|
||||||
}
|
|
||||||
if err := tmp.Sync(); err != nil {
|
|
||||||
return fmt.Errorf("syncing temp state file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Closing the temp file before renaming/moving for cross-platform compatibility
|
|
||||||
if err := tmp.Close(); err != nil {
|
|
||||||
return fmt.Errorf("closing temp state file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
ext := filepath.Ext(fs.filename)
|
|
||||||
name, _ := strings.CutSuffix(fs.filename, ext)
|
|
||||||
backupFilename := name + "." + time.Now().Format("2006-01-02_150405") + ext
|
|
||||||
if err := os.Rename(fs.filename, backupFilename); err != nil {
|
|
||||||
if !errors.Is(err, os.ErrNotExist) {
|
|
||||||
return fmt.Errorf("rename old state file: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := os.Rename(tmpFilename, fs.filename); err != nil {
|
|
||||||
return fmt.Errorf("atomic rename of state file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,476 +0,0 @@
|
|||||||
package internal_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"io/fs"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/nmoniz/any2anexoj/internal"
|
|
||||||
"github.com/nmoniz/any2anexoj/internal/mocks"
|
|
||||||
"github.com/shopspring/decimal"
|
|
||||||
"go.uber.org/mock/gomock"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestJSONStore_RoundTrip(t *testing.T) {
|
|
||||||
ctrl := gomock.NewController(t)
|
|
||||||
ser := roundTripSerializer(ctrl)
|
|
||||||
store, path := newStore(t, "fake", ser)
|
|
||||||
|
|
||||||
original := map[string]*internal.FillerQueue{
|
|
||||||
"AAA": newQueue(
|
|
||||||
newFiller(ctrl, "AAA", 100, 50, 0),
|
|
||||||
newFiller(ctrl, "AAA", 25, 80, 5),
|
|
||||||
),
|
|
||||||
"BBB": newQueue(
|
|
||||||
newFiller(ctrl, "BBB", 7, 1000, 7),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := store.Save(t.Context(), original); err != nil {
|
|
||||||
t.Fatalf("Save returned unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
loaded, err := store.Load(t.Context())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Load returned unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(loaded) != len(original) {
|
|
||||||
t.Fatalf("want %d symbols but got %d", len(original), len(loaded))
|
|
||||||
}
|
|
||||||
for symbol, wantQ := range original {
|
|
||||||
gotQ, ok := loaded[symbol]
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("symbol %q missing from loaded state", symbol)
|
|
||||||
}
|
|
||||||
assertQueueEqual(t, symbol, gotQ, wantQ)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Regression: the on-disk JSON key for the per-record blob must be
|
|
||||||
// "record_data" (renamed from "reader_data") so the field name stays
|
|
||||||
// honest about what it carries.
|
|
||||||
data, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("read state file: %v", err)
|
|
||||||
}
|
|
||||||
body := string(data)
|
|
||||||
if strings.Contains(body, `"reader_data"`) {
|
|
||||||
t.Errorf("saved state file still contains legacy key \"reader_data\"; want only \"record_data\"")
|
|
||||||
}
|
|
||||||
if !strings.Contains(body, `"record_data"`) {
|
|
||||||
t.Errorf("saved state file does not contain expected key \"record_data\"")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Regression: the "record_data" value must be inline JSON, not a base64
|
|
||||||
// blob. Version-1 state files encoded the per-record JSON as a base64
|
|
||||||
// string (which always starts with "ey" — base64 of '{' followed by '"').
|
|
||||||
// After Chunk 1 of the refactor the value is json.RawMessage, which the
|
|
||||||
// JSON encoder emits inline. roundTripSerializer emits a JSON string per
|
|
||||||
// symbol, so the file should contain `"record_data": "AAA"` etc.
|
|
||||||
if strings.Contains(body, `"record_data": "ey`) {
|
|
||||||
t.Errorf("saved state file still has base64-encoded record_data (version-1 format); want inline JSON")
|
|
||||||
}
|
|
||||||
wantInlineAAA := `"record_data": "AAA"`
|
|
||||||
if !strings.Contains(body, wantInlineAAA) {
|
|
||||||
t.Errorf("saved state file does not contain inline JSON %q under record_data; got:\n%s", wantInlineAAA, body)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sanity: the file must be valid top-level JSON (it always was, but
|
|
||||||
// confirming the inline form is parseable guards against accidentally
|
|
||||||
// double-encoding it again in the future).
|
|
||||||
var parsed struct {
|
|
||||||
Queues map[string][]struct {
|
|
||||||
RecordData json.RawMessage `json:"record_data"`
|
|
||||||
} `json:"queues"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(data, &parsed); err != nil {
|
|
||||||
t.Fatalf("saved state file is not valid JSON: %v", err)
|
|
||||||
}
|
|
||||||
for symbol, lots := range parsed.Queues {
|
|
||||||
for i, lot := range lots {
|
|
||||||
if len(lot.RecordData) == 0 {
|
|
||||||
t.Errorf("symbol %q lot %d: record_data is empty", symbol, i)
|
|
||||||
}
|
|
||||||
if !json.Valid(lot.RecordData) {
|
|
||||||
t.Errorf("symbol %q lot %d: record_data is not valid JSON: %s", symbol, i, lot.RecordData)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestJSONStore_SaveSkipsEmptyQueue(t *testing.T) {
|
|
||||||
ctrl := gomock.NewController(t)
|
|
||||||
ser := roundTripSerializer(ctrl)
|
|
||||||
store, path := newStore(t, "fake", ser)
|
|
||||||
|
|
||||||
// A non-nil FillerQueue whose underlying list is nil — i.e. a symbol
|
|
||||||
// that was registered but never received a Push. Save must not panic
|
|
||||||
// when iterating and must not emit any entry for that symbol.
|
|
||||||
queues := map[string]*internal.FillerQueue{
|
|
||||||
"EMPTY": new(internal.FillerQueue),
|
|
||||||
"REAL": newQueue(newFiller(ctrl, "REAL", 5, 10, 0)),
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := store.Save(t.Context(), queues); err != nil {
|
|
||||||
t.Fatalf("Save returned unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
data, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("read state file: %v", err)
|
|
||||||
}
|
|
||||||
body := string(data)
|
|
||||||
if strings.Contains(body, `"EMPTY"`) {
|
|
||||||
t.Errorf("saved state file contains entry for empty queue \"EMPTY\"; want it skipped")
|
|
||||||
}
|
|
||||||
if !strings.Contains(body, `"REAL"`) {
|
|
||||||
t.Errorf("saved state file missing expected entry for \"REAL\"")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestJSONStore_LoadEmptyFileReturnsEmpty(t *testing.T) {
|
|
||||||
ctrl := gomock.NewController(t)
|
|
||||||
ser := roundTripSerializer(ctrl)
|
|
||||||
store, path := newStore(t, "fake", ser)
|
|
||||||
|
|
||||||
// Pre-create the state file as a zero-byte file. Load must treat this
|
|
||||||
// the same as a missing file rather than failing JSON unmarshal.
|
|
||||||
if err := os.WriteFile(path, []byte{}, 0o644); err != nil {
|
|
||||||
t.Fatalf("write empty state file: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
queues, err := store.Load(t.Context())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Load returned unexpected error for empty file: %v", err)
|
|
||||||
}
|
|
||||||
if queues == nil {
|
|
||||||
t.Fatalf("Load returned nil map; want empty map")
|
|
||||||
}
|
|
||||||
if len(queues) != 0 {
|
|
||||||
t.Fatalf("Load returned %d entries; want 0", len(queues))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestJSONStore_LoadMissingFileReturnsEmpty(t *testing.T) {
|
|
||||||
ctrl := gomock.NewController(t)
|
|
||||||
ser := roundTripSerializer(ctrl)
|
|
||||||
store, path := newStore(t, "fake", ser)
|
|
||||||
|
|
||||||
// Sanity: file doesn't exist.
|
|
||||||
if _, err := os.Stat(path); !errors.Is(err, fs.ErrNotExist) {
|
|
||||||
t.Fatalf("expected state file to be absent, got stat err: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
queues, err := store.Load(t.Context())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Load returned unexpected error for missing file: %v", err)
|
|
||||||
}
|
|
||||||
if queues == nil {
|
|
||||||
t.Fatalf("Load returned nil map; want empty map")
|
|
||||||
}
|
|
||||||
if len(queues) != 0 {
|
|
||||||
t.Fatalf("Load returned %d entries; want 0", len(queues))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestJSONStore_LoadVersionMismatch(t *testing.T) {
|
|
||||||
ctrl := gomock.NewController(t)
|
|
||||||
ser := roundTripSerializer(ctrl)
|
|
||||||
store, path := newStore(t, "fake", ser)
|
|
||||||
|
|
||||||
// Hand-craft an unsupported-version state file.
|
|
||||||
bad := struct {
|
|
||||||
Version string `json:"version"`
|
|
||||||
Platform string `json:"platform"`
|
|
||||||
Queues map[string][]json.RawMessage `json:"queues"`
|
|
||||||
}{
|
|
||||||
Version: "999",
|
|
||||||
Platform: "fake",
|
|
||||||
Queues: map[string][]json.RawMessage{},
|
|
||||||
}
|
|
||||||
data, err := json.MarshalIndent(bad, "", " ")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("marshal: %v", err)
|
|
||||||
}
|
|
||||||
if err := os.WriteFile(path, data, 0o644); err != nil {
|
|
||||||
t.Fatalf("write state: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = store.Load(t.Context())
|
|
||||||
if err == nil {
|
|
||||||
t.Fatalf("Load with bad version should return an error")
|
|
||||||
}
|
|
||||||
if !strings.Contains(err.Error(), `unexpected state version "999"`) {
|
|
||||||
t.Errorf("expected version-mismatch error, got: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestJSONStore_LoadPlatformMismatch(t *testing.T) {
|
|
||||||
ctrl := gomock.NewController(t)
|
|
||||||
ser := roundTripSerializer(ctrl)
|
|
||||||
store, path := newStore(t, "fake", ser)
|
|
||||||
|
|
||||||
// File claims a different platform.
|
|
||||||
bad := struct {
|
|
||||||
Version string `json:"version"`
|
|
||||||
Platform string `json:"platform"`
|
|
||||||
Queues map[string][]json.RawMessage `json:"queues"`
|
|
||||||
}{
|
|
||||||
Version: internal.StateVersion,
|
|
||||||
Platform: "other-broker",
|
|
||||||
Queues: map[string][]json.RawMessage{},
|
|
||||||
}
|
|
||||||
data, err := json.MarshalIndent(bad, "", " ")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("marshal: %v", err)
|
|
||||||
}
|
|
||||||
if err := os.WriteFile(path, data, 0o644); err != nil {
|
|
||||||
t.Fatalf("write state: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = store.Load(t.Context())
|
|
||||||
if err == nil {
|
|
||||||
t.Fatalf("Load with mismatched platform should return an error")
|
|
||||||
}
|
|
||||||
if !strings.Contains(err.Error(), `unexpected state platform "other-broker"`) {
|
|
||||||
t.Errorf("expected platform-mismatch error, got: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestJSONStore_SplitAdjustedLotSurvivesRoundTrip(t *testing.T) {
|
|
||||||
ctrl := gomock.NewController(t)
|
|
||||||
ser := roundTripSerializer(ctrl)
|
|
||||||
store, _ := newStore(t, "fake", ser)
|
|
||||||
|
|
||||||
// Simulate a lot that has been through a 5:1 split and is partially
|
|
||||||
// filled. Starting from 10 shares @ $100, after a 5:1 split we should
|
|
||||||
// have 50 shares @ $20 with 20 already filled.
|
|
||||||
f := newFiller(ctrl, "SPLIT", 10, 100, 0)
|
|
||||||
f.ApplySplit(decimal.NewFromInt(5))
|
|
||||||
f.Fill(decimal.NewFromInt(20))
|
|
||||||
|
|
||||||
q := newQueue(f)
|
|
||||||
|
|
||||||
if err := store.Save(t.Context(), map[string]*internal.FillerQueue{"SPLIT": q}); err != nil {
|
|
||||||
t.Fatalf("Save returned unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
loaded, err := store.Load(t.Context())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Load returned unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
gotQ := loaded["SPLIT"]
|
|
||||||
if gotQ == nil || gotQ.Len() != 1 {
|
|
||||||
t.Fatalf("want 1 lot for SPLIT, got %d", gotQ.Len())
|
|
||||||
}
|
|
||||||
got, _ := gotQ.Pop()
|
|
||||||
if !got.Quantity().Equal(decimal.NewFromInt(50)) {
|
|
||||||
t.Errorf("want quantity 50 but got %v", got.Quantity())
|
|
||||||
}
|
|
||||||
if !got.Price().Equal(decimal.NewFromInt(20)) {
|
|
||||||
t.Errorf("want price 20 but got %v", got.Price())
|
|
||||||
}
|
|
||||||
if !got.Filled().Equal(decimal.NewFromInt(20)) {
|
|
||||||
t.Errorf("want filled 20 but got %v", got.Filled())
|
|
||||||
}
|
|
||||||
if got.IsFilled() {
|
|
||||||
t.Errorf("want IsFilled() to be false after split-adjusted partial fill")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cost basis must round-trip exactly.
|
|
||||||
if !got.Quantity().Mul(got.Price()).Equal(decimal.NewFromInt(1000)) {
|
|
||||||
t.Errorf("want cost basis 1000 but got %v", got.Quantity().Mul(got.Price()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestJSONStore_PartiallyFilledLotSurvivesRoundTrip(t *testing.T) {
|
|
||||||
ctrl := gomock.NewController(t)
|
|
||||||
ser := roundTripSerializer(ctrl)
|
|
||||||
store, _ := newStore(t, "fake", ser)
|
|
||||||
|
|
||||||
// A non-split lot that has been partially filled.
|
|
||||||
f := newFiller(ctrl, "PART", 100, 50, 30)
|
|
||||||
q := newQueue(f)
|
|
||||||
|
|
||||||
if err := store.Save(t.Context(), map[string]*internal.FillerQueue{"PART": q}); err != nil {
|
|
||||||
t.Fatalf("Save returned unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
loaded, err := store.Load(t.Context())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Load returned unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
gotQ := loaded["PART"]
|
|
||||||
if gotQ == nil || gotQ.Len() != 1 {
|
|
||||||
t.Fatalf("want 1 lot for PART, got %d", gotQ.Len())
|
|
||||||
}
|
|
||||||
got, _ := gotQ.Pop()
|
|
||||||
if !got.Quantity().Equal(decimal.NewFromInt(100)) {
|
|
||||||
t.Errorf("want quantity 100 but got %v", got.Quantity())
|
|
||||||
}
|
|
||||||
if !got.Price().Equal(decimal.NewFromInt(50)) {
|
|
||||||
t.Errorf("want price 50 but got %v", got.Price())
|
|
||||||
}
|
|
||||||
if !got.Filled().Equal(decimal.NewFromInt(30)) {
|
|
||||||
t.Errorf("want filled 30 but got %v", got.Filled())
|
|
||||||
}
|
|
||||||
if got.IsFilled() {
|
|
||||||
t.Errorf("want IsFilled() to be false (30/100 filled)")
|
|
||||||
}
|
|
||||||
// Filling the remaining 70 must now make it filled.
|
|
||||||
_, done := got.Fill(decimal.NewFromInt(70))
|
|
||||||
if !done {
|
|
||||||
t.Errorf("after filling remaining 70, IsFilled() should be true")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNewJSONStore_ValidatesArguments(t *testing.T) {
|
|
||||||
ctrl := gomock.NewController(t)
|
|
||||||
ser := mocks.NewMockRecordSerializer(ctrl)
|
|
||||||
|
|
||||||
if _, err := internal.NewJSONStore("", "fake", ser); err == nil {
|
|
||||||
t.Errorf("NewJSONStore with empty filename should fail")
|
|
||||||
}
|
|
||||||
if _, err := internal.NewJSONStore("/tmp/x", "fake", nil); err == nil {
|
|
||||||
t.Errorf("NewJSONStore with nil serializer should fail")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// newRecord builds a MockRecord whose Symbol() returns the given symbol.
|
|
||||||
// The JSONStore only reads Symbol() off the loaded Record during tests
|
|
||||||
// (quantity/price/filled come from the persisted struct fields), so all
|
|
||||||
// other Record methods can be left as default-mocked values.
|
|
||||||
func newRecord(ctrl *gomock.Controller, symbol string) *mocks.MockRecord {
|
|
||||||
r := mocks.NewMockRecord(ctrl)
|
|
||||||
r.EXPECT().Symbol().Return(symbol).AnyTimes()
|
|
||||||
return r
|
|
||||||
}
|
|
||||||
|
|
||||||
// newFiller builds a Filler backed by a MockRecord with the given symbol,
|
|
||||||
// quantity, price, and filled amounts (in whole units). It mirrors the
|
|
||||||
// inline calls that previously littered every test.
|
|
||||||
func newFiller(ctrl *gomock.Controller, symbol string, quantity, price, filled int64) *internal.Filler {
|
|
||||||
return internal.NewFillerFromState(
|
|
||||||
newRecord(ctrl, symbol),
|
|
||||||
decimal.NewFromInt(quantity),
|
|
||||||
decimal.NewFromInt(price),
|
|
||||||
decimal.NewFromInt(filled),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// newQueue creates a FillerQueue pre-populated with the given fillers.
|
|
||||||
func newQueue(fillers ...*internal.Filler) *internal.FillerQueue {
|
|
||||||
q := new(internal.FillerQueue)
|
|
||||||
for _, f := range fillers {
|
|
||||||
q.Push(f)
|
|
||||||
}
|
|
||||||
return q
|
|
||||||
}
|
|
||||||
|
|
||||||
// newStore creates a JSONStore backed by a temp file and returns the store
|
|
||||||
// plus the path to the underlying state file.
|
|
||||||
func newStore(t *testing.T, platform string, ser internal.RecordSerializer) (*internal.JSONStore, string) {
|
|
||||||
t.Helper()
|
|
||||||
dir := t.TempDir()
|
|
||||||
path := filepath.Join(dir, "state.json")
|
|
||||||
store, err := internal.NewJSONStore(path, platform, ser)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("NewJSONStore returned unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
return store, path
|
|
||||||
}
|
|
||||||
|
|
||||||
// roundTripSerializer returns a serializer mock whose MarshalRecord encodes
|
|
||||||
// the Symbol as a JSON string (e.g. "AAA") and whose UnmarshalRecord decodes
|
|
||||||
// those JSON bytes back into a fresh MockRecord returning the encoded Symbol.
|
|
||||||
// Tests use this when they need Save + Load to round-trip equivalent Records.
|
|
||||||
func roundTripSerializer(ctrl *gomock.Controller) *mocks.MockRecordSerializer {
|
|
||||||
ser := mocks.NewMockRecordSerializer(ctrl)
|
|
||||||
ser.EXPECT().
|
|
||||||
MarshalRecord(gomock.Any(), gomock.Any()).
|
|
||||||
DoAndReturn(func(_ context.Context, r internal.Record) (json.RawMessage, error) {
|
|
||||||
return json.Marshal(r.Symbol())
|
|
||||||
}).
|
|
||||||
AnyTimes()
|
|
||||||
ser.EXPECT().
|
|
||||||
UnmarshalRecord(gomock.Any(), gomock.Any()).
|
|
||||||
DoAndReturn(func(_ context.Context, b json.RawMessage) (internal.Record, error) {
|
|
||||||
var symbol string
|
|
||||||
if err := json.Unmarshal(b, &symbol); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return newRecord(ctrl, symbol), nil
|
|
||||||
}).
|
|
||||||
AnyTimes()
|
|
||||||
return ser
|
|
||||||
}
|
|
||||||
|
|
||||||
// queueSnapshot drains the given FillerQueue (via Pop) and returns its
|
|
||||||
// contents in a side-effect-free shape suitable for value comparison.
|
|
||||||
// Callers should not use the queue afterwards.
|
|
||||||
func queueSnapshot(q *internal.FillerQueue) []queueEntry {
|
|
||||||
if q == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var out []queueEntry
|
|
||||||
for {
|
|
||||||
f, ok := q.Pop()
|
|
||||||
if !ok {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
out = append(out, queueEntry{
|
|
||||||
symbol: f.Symbol(),
|
|
||||||
quantity: f.Quantity(),
|
|
||||||
price: f.Price(),
|
|
||||||
filled: f.Filled(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// assertQueueEqual compares two FillerQueues by popping every element from
|
|
||||||
// each and comparing the resulting sequence of queueEntries. Both queues are
|
|
||||||
// drained as a side effect.
|
|
||||||
func assertQueueEqual(t *testing.T, symbol string, got, want *internal.FillerQueue) {
|
|
||||||
t.Helper()
|
|
||||||
if got == nil {
|
|
||||||
t.Fatalf("symbol %q: loaded queue is nil", symbol)
|
|
||||||
}
|
|
||||||
if got.Len() != want.Len() {
|
|
||||||
t.Fatalf("symbol %q: want %d lots but got %d", symbol, want.Len(), got.Len())
|
|
||||||
}
|
|
||||||
wantEntries := queueSnapshot(want)
|
|
||||||
gotEntries := queueSnapshot(got)
|
|
||||||
for i := range wantEntries {
|
|
||||||
w := wantEntries[i]
|
|
||||||
g := gotEntries[i]
|
|
||||||
if w.symbol != g.symbol {
|
|
||||||
t.Errorf("symbol %q lot %d: want symbol %q but got %q",
|
|
||||||
symbol, i, w.symbol, g.symbol)
|
|
||||||
}
|
|
||||||
if !w.quantity.Equal(g.quantity) {
|
|
||||||
t.Errorf("symbol %q lot %d: want quantity %v but got %v",
|
|
||||||
symbol, i, w.quantity, g.quantity)
|
|
||||||
}
|
|
||||||
if !w.price.Equal(g.price) {
|
|
||||||
t.Errorf("symbol %q lot %d: want price %v but got %v",
|
|
||||||
symbol, i, w.price, g.price)
|
|
||||||
}
|
|
||||||
if !w.filled.Equal(g.filled) {
|
|
||||||
t.Errorf("symbol %q lot %d: want filled %v but got %v",
|
|
||||||
symbol, i, w.filled, g.filled)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type queueEntry struct {
|
|
||||||
symbol string
|
|
||||||
quantity decimal.Decimal
|
|
||||||
price decimal.Decimal
|
|
||||||
filled decimal.Decimal
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
package internal
|
|
||||||
|
|
||||||
type Kind uint
|
|
||||||
|
|
||||||
const (
|
|
||||||
KindUnknown Kind = iota
|
|
||||||
KindBuy
|
|
||||||
KindSell
|
|
||||||
KindSplit
|
|
||||||
sentinelKind
|
|
||||||
)
|
|
||||||
|
|
||||||
// String returns a unique string value for Kind k
|
|
||||||
func (k Kind) String() string {
|
|
||||||
switch k {
|
|
||||||
case KindBuy:
|
|
||||||
return "buy"
|
|
||||||
case KindSell:
|
|
||||||
return "sell"
|
|
||||||
case KindSplit:
|
|
||||||
return "split"
|
|
||||||
default:
|
|
||||||
return "unknown"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Valid returns true if k is an accepted value for the Kind type
|
|
||||||
func (k Kind) Valid() bool {
|
|
||||||
return k > 0 && k < sentinelKind
|
|
||||||
}
|
|
||||||
|
|
||||||
// Is returns true when k and o are valid and equal.
|
|
||||||
func (k Kind) Is(o any) bool {
|
|
||||||
other, ok := o.(Kind)
|
|
||||||
return ok && k.Valid() && k == other
|
|
||||||
}
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
package internal
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestSide_String(t *testing.T) {
|
|
||||||
const unknown = "unknown"
|
|
||||||
|
|
||||||
seen := make(map[string]Kind, sentinelKind)
|
|
||||||
for k := Kind(1); k < sentinelKind; k++ {
|
|
||||||
t.Run(fmt.Sprintf("Kind %d", k), func(t *testing.T) {
|
|
||||||
str := k.String()
|
|
||||||
|
|
||||||
if other, ok := seen[str]; ok {
|
|
||||||
t.Errorf("want Kind(%d).String to be unique but was a duplicate of Kind(%d)", k, other)
|
|
||||||
} else {
|
|
||||||
seen[str] = k
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(str) == 0 {
|
|
||||||
t.Errorf("want Kind(%d).String to be non-empty", k)
|
|
||||||
}
|
|
||||||
|
|
||||||
if str == unknown {
|
|
||||||
t.Errorf("want Kind(%d).String to be a known value", k)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if KindUnknown.String() != unknown {
|
|
||||||
t.Errorf("want Kind(0) to be unknown")
|
|
||||||
}
|
|
||||||
|
|
||||||
if Kind(sentinelKind).String() != unknown {
|
|
||||||
t.Errorf("want Kind(%d) to be unknown", sentinelKind)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSide_Valid(t *testing.T) {
|
|
||||||
for k := Kind(1); k < sentinelKind; k++ {
|
|
||||||
if !k.Valid() {
|
|
||||||
t.Errorf("want %s(%d) to be valid", k, k)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if KindUnknown.Valid() {
|
|
||||||
t.Errorf("want Kind(0) to be invalid")
|
|
||||||
}
|
|
||||||
|
|
||||||
if Kind(sentinelKind).Valid() {
|
|
||||||
t.Errorf("want Kind(%d) to be invalid", sentinelKind)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSide_Is(t *testing.T) {
|
|
||||||
t.Run("valid is self", func(t *testing.T) {
|
|
||||||
for k := Kind(1); k < sentinelKind; k++ {
|
|
||||||
if !k.Is(k) {
|
|
||||||
t.Errorf("want Kind(%d).Is(%d) to be true", k, k)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("valid is unknown", func(t *testing.T) {
|
|
||||||
for k := Kind(1); k < sentinelKind; k++ {
|
|
||||||
if k.Is(KindUnknown) {
|
|
||||||
t.Errorf("want Kind(%d).Is(0) to be false", k)
|
|
||||||
}
|
|
||||||
|
|
||||||
if k.Is(sentinelKind) {
|
|
||||||
t.Errorf("want Kind(%d).Is(%d) to be false", k, sentinelKind)
|
|
||||||
}
|
|
||||||
|
|
||||||
if k.Is(struct{}{}) {
|
|
||||||
t.Errorf("want Kind(%d).Is(other type) to be false", k)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("unknown is unknown", func(t *testing.T) {
|
|
||||||
if KindUnknown.Is(KindUnknown) {
|
|
||||||
t.Errorf("want Kind(0).Is(0) to be false")
|
|
||||||
}
|
|
||||||
|
|
||||||
if KindUnknown.Is(sentinelKind) {
|
|
||||||
t.Errorf("want Kind(0).Is(%d) to be false", sentinelKind)
|
|
||||||
}
|
|
||||||
|
|
||||||
if sentinelKind.Is(sentinelKind) {
|
|
||||||
t.Errorf("want Kind(%d).Is(%d) to be false", sentinelKind, sentinelKind)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
+61
-404
@@ -1,9 +1,9 @@
|
|||||||
// Code generated by MockGen. DO NOT EDIT.
|
// Code generated by MockGen. DO NOT EDIT.
|
||||||
// Source: github.com/nmoniz/any2anexoj/internal (interfaces: RecordReader,Record,ReportWriter,RecordEncoder,RecordDecoder,RecordSerializer)
|
// Source: github.com/nmoniz/any2anexoj/internal (interfaces: RecordReader,Record,ReportWriter)
|
||||||
//
|
//
|
||||||
// Generated by this command:
|
// Generated by this command:
|
||||||
//
|
//
|
||||||
// mockgen -destination=mocks/mocks_gen.go -package=mocks -typed . RecordReader,Record,ReportWriter,RecordEncoder,RecordDecoder,RecordSerializer
|
// mockgen -destination=mocks/mocks_gen.go -package=mocks -typed . RecordReader,Record,ReportWriter
|
||||||
//
|
//
|
||||||
|
|
||||||
// Package mocks is a generated GoMock package.
|
// Package mocks is a generated GoMock package.
|
||||||
@@ -11,12 +11,11 @@ package mocks
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
context "context"
|
context "context"
|
||||||
json "encoding/json"
|
big "math/big"
|
||||||
reflect "reflect"
|
reflect "reflect"
|
||||||
time "time"
|
time "time"
|
||||||
|
|
||||||
internal "github.com/nmoniz/any2anexoj/internal"
|
internal "github.com/nmoniz/any2anexoj/internal"
|
||||||
decimal "github.com/shopspring/decimal"
|
|
||||||
gomock "go.uber.org/mock/gomock"
|
gomock "go.uber.org/mock/gomock"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -107,87 +106,11 @@ func (m *MockRecord) EXPECT() *MockRecordMockRecorder {
|
|||||||
return m.recorder
|
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.
|
// Fees mocks base method.
|
||||||
func (m *MockRecord) Fees() decimal.Decimal {
|
func (m *MockRecord) Fees() *big.Float {
|
||||||
m.ctrl.T.Helper()
|
m.ctrl.T.Helper()
|
||||||
ret := m.ctrl.Call(m, "Fees")
|
ret := m.ctrl.Call(m, "Fees")
|
||||||
ret0, _ := ret[0].(decimal.Decimal)
|
ret0, _ := ret[0].(*big.Float)
|
||||||
return ret0
|
return ret0
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,104 +127,28 @@ type MockRecordFeesCall struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Return rewrite *gomock.Call.Return
|
// Return rewrite *gomock.Call.Return
|
||||||
func (c *MockRecordFeesCall) Return(arg0 decimal.Decimal) *MockRecordFeesCall {
|
func (c *MockRecordFeesCall) Return(arg0 *big.Float) *MockRecordFeesCall {
|
||||||
c.Call = c.Call.Return(arg0)
|
c.Call = c.Call.Return(arg0)
|
||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
// Do rewrite *gomock.Call.Do
|
// Do rewrite *gomock.Call.Do
|
||||||
func (c *MockRecordFeesCall) Do(f func() decimal.Decimal) *MockRecordFeesCall {
|
func (c *MockRecordFeesCall) Do(f func() *big.Float) *MockRecordFeesCall {
|
||||||
c.Call = c.Call.Do(f)
|
c.Call = c.Call.Do(f)
|
||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
// DoAndReturn rewrite *gomock.Call.DoAndReturn
|
// DoAndReturn rewrite *gomock.Call.DoAndReturn
|
||||||
func (c *MockRecordFeesCall) DoAndReturn(f func() decimal.Decimal) *MockRecordFeesCall {
|
func (c *MockRecordFeesCall) DoAndReturn(f func() *big.Float) *MockRecordFeesCall {
|
||||||
c.Call = c.Call.DoAndReturn(f)
|
|
||||||
return c
|
|
||||||
}
|
|
||||||
|
|
||||||
// Kind mocks base method.
|
|
||||||
func (m *MockRecord) Kind() internal.Kind {
|
|
||||||
m.ctrl.T.Helper()
|
|
||||||
ret := m.ctrl.Call(m, "Kind")
|
|
||||||
ret0, _ := ret[0].(internal.Kind)
|
|
||||||
return ret0
|
|
||||||
}
|
|
||||||
|
|
||||||
// Kind indicates an expected call of Kind.
|
|
||||||
func (mr *MockRecordMockRecorder) Kind() *MockRecordKindCall {
|
|
||||||
mr.mock.ctrl.T.Helper()
|
|
||||||
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Kind", reflect.TypeOf((*MockRecord)(nil).Kind))
|
|
||||||
return &MockRecordKindCall{Call: call}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MockRecordKindCall wrap *gomock.Call
|
|
||||||
type MockRecordKindCall struct {
|
|
||||||
*gomock.Call
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return rewrite *gomock.Call.Return
|
|
||||||
func (c *MockRecordKindCall) Return(arg0 internal.Kind) *MockRecordKindCall {
|
|
||||||
c.Call = c.Call.Return(arg0)
|
|
||||||
return c
|
|
||||||
}
|
|
||||||
|
|
||||||
// Do rewrite *gomock.Call.Do
|
|
||||||
func (c *MockRecordKindCall) Do(f func() internal.Kind) *MockRecordKindCall {
|
|
||||||
c.Call = c.Call.Do(f)
|
|
||||||
return c
|
|
||||||
}
|
|
||||||
|
|
||||||
// DoAndReturn rewrite *gomock.Call.DoAndReturn
|
|
||||||
func (c *MockRecordKindCall) DoAndReturn(f func() internal.Kind) *MockRecordKindCall {
|
|
||||||
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)
|
c.Call = c.Call.DoAndReturn(f)
|
||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
// Price mocks base method.
|
// Price mocks base method.
|
||||||
func (m *MockRecord) Price() decimal.Decimal {
|
func (m *MockRecord) Price() *big.Float {
|
||||||
m.ctrl.T.Helper()
|
m.ctrl.T.Helper()
|
||||||
ret := m.ctrl.Call(m, "Price")
|
ret := m.ctrl.Call(m, "Price")
|
||||||
ret0, _ := ret[0].(decimal.Decimal)
|
ret0, _ := ret[0].(*big.Float)
|
||||||
return ret0
|
return ret0
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -318,28 +165,28 @@ type MockRecordPriceCall struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Return rewrite *gomock.Call.Return
|
// Return rewrite *gomock.Call.Return
|
||||||
func (c *MockRecordPriceCall) Return(arg0 decimal.Decimal) *MockRecordPriceCall {
|
func (c *MockRecordPriceCall) Return(arg0 *big.Float) *MockRecordPriceCall {
|
||||||
c.Call = c.Call.Return(arg0)
|
c.Call = c.Call.Return(arg0)
|
||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
// Do rewrite *gomock.Call.Do
|
// Do rewrite *gomock.Call.Do
|
||||||
func (c *MockRecordPriceCall) Do(f func() decimal.Decimal) *MockRecordPriceCall {
|
func (c *MockRecordPriceCall) Do(f func() *big.Float) *MockRecordPriceCall {
|
||||||
c.Call = c.Call.Do(f)
|
c.Call = c.Call.Do(f)
|
||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
// DoAndReturn rewrite *gomock.Call.DoAndReturn
|
// DoAndReturn rewrite *gomock.Call.DoAndReturn
|
||||||
func (c *MockRecordPriceCall) DoAndReturn(f func() decimal.Decimal) *MockRecordPriceCall {
|
func (c *MockRecordPriceCall) DoAndReturn(f func() *big.Float) *MockRecordPriceCall {
|
||||||
c.Call = c.Call.DoAndReturn(f)
|
c.Call = c.Call.DoAndReturn(f)
|
||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
// Quantity mocks base method.
|
// Quantity mocks base method.
|
||||||
func (m *MockRecord) Quantity() decimal.Decimal {
|
func (m *MockRecord) Quantity() *big.Float {
|
||||||
m.ctrl.T.Helper()
|
m.ctrl.T.Helper()
|
||||||
ret := m.ctrl.Call(m, "Quantity")
|
ret := m.ctrl.Call(m, "Quantity")
|
||||||
ret0, _ := ret[0].(decimal.Decimal)
|
ret0, _ := ret[0].(*big.Float)
|
||||||
return ret0
|
return ret0
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -356,19 +203,57 @@ type MockRecordQuantityCall struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Return rewrite *gomock.Call.Return
|
// Return rewrite *gomock.Call.Return
|
||||||
func (c *MockRecordQuantityCall) Return(arg0 decimal.Decimal) *MockRecordQuantityCall {
|
func (c *MockRecordQuantityCall) Return(arg0 *big.Float) *MockRecordQuantityCall {
|
||||||
c.Call = c.Call.Return(arg0)
|
c.Call = c.Call.Return(arg0)
|
||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
// Do rewrite *gomock.Call.Do
|
// Do rewrite *gomock.Call.Do
|
||||||
func (c *MockRecordQuantityCall) Do(f func() decimal.Decimal) *MockRecordQuantityCall {
|
func (c *MockRecordQuantityCall) Do(f func() *big.Float) *MockRecordQuantityCall {
|
||||||
c.Call = c.Call.Do(f)
|
c.Call = c.Call.Do(f)
|
||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
// DoAndReturn rewrite *gomock.Call.DoAndReturn
|
// DoAndReturn rewrite *gomock.Call.DoAndReturn
|
||||||
func (c *MockRecordQuantityCall) DoAndReturn(f func() decimal.Decimal) *MockRecordQuantityCall {
|
func (c *MockRecordQuantityCall) DoAndReturn(f func() *big.Float) *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)
|
c.Call = c.Call.DoAndReturn(f)
|
||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
@@ -412,10 +297,10 @@ func (c *MockRecordSymbolCall) DoAndReturn(f func() string) *MockRecordSymbolCal
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Taxes mocks base method.
|
// Taxes mocks base method.
|
||||||
func (m *MockRecord) Taxes() decimal.Decimal {
|
func (m *MockRecord) Taxes() *big.Float {
|
||||||
m.ctrl.T.Helper()
|
m.ctrl.T.Helper()
|
||||||
ret := m.ctrl.Call(m, "Taxes")
|
ret := m.ctrl.Call(m, "Taxes")
|
||||||
ret0, _ := ret[0].(decimal.Decimal)
|
ret0, _ := ret[0].(*big.Float)
|
||||||
return ret0
|
return ret0
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -432,19 +317,19 @@ type MockRecordTaxesCall struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Return rewrite *gomock.Call.Return
|
// Return rewrite *gomock.Call.Return
|
||||||
func (c *MockRecordTaxesCall) Return(arg0 decimal.Decimal) *MockRecordTaxesCall {
|
func (c *MockRecordTaxesCall) Return(arg0 *big.Float) *MockRecordTaxesCall {
|
||||||
c.Call = c.Call.Return(arg0)
|
c.Call = c.Call.Return(arg0)
|
||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
// Do rewrite *gomock.Call.Do
|
// Do rewrite *gomock.Call.Do
|
||||||
func (c *MockRecordTaxesCall) Do(f func() decimal.Decimal) *MockRecordTaxesCall {
|
func (c *MockRecordTaxesCall) Do(f func() *big.Float) *MockRecordTaxesCall {
|
||||||
c.Call = c.Call.Do(f)
|
c.Call = c.Call.Do(f)
|
||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
// DoAndReturn rewrite *gomock.Call.DoAndReturn
|
// DoAndReturn rewrite *gomock.Call.DoAndReturn
|
||||||
func (c *MockRecordTaxesCall) DoAndReturn(f func() decimal.Decimal) *MockRecordTaxesCall {
|
func (c *MockRecordTaxesCall) DoAndReturn(f func() *big.Float) *MockRecordTaxesCall {
|
||||||
c.Call = c.Call.DoAndReturn(f)
|
c.Call = c.Call.DoAndReturn(f)
|
||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
@@ -548,231 +433,3 @@ func (c *MockReportWriterWriteCall) DoAndReturn(f func(context.Context, internal
|
|||||||
c.Call = c.Call.DoAndReturn(f)
|
c.Call = c.Call.DoAndReturn(f)
|
||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
// MockRecordEncoder is a mock of RecordEncoder interface.
|
|
||||||
type MockRecordEncoder struct {
|
|
||||||
ctrl *gomock.Controller
|
|
||||||
recorder *MockRecordEncoderMockRecorder
|
|
||||||
isgomock struct{}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MockRecordEncoderMockRecorder is the mock recorder for MockRecordEncoder.
|
|
||||||
type MockRecordEncoderMockRecorder struct {
|
|
||||||
mock *MockRecordEncoder
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewMockRecordEncoder creates a new mock instance.
|
|
||||||
func NewMockRecordEncoder(ctrl *gomock.Controller) *MockRecordEncoder {
|
|
||||||
mock := &MockRecordEncoder{ctrl: ctrl}
|
|
||||||
mock.recorder = &MockRecordEncoderMockRecorder{mock}
|
|
||||||
return mock
|
|
||||||
}
|
|
||||||
|
|
||||||
// EXPECT returns an object that allows the caller to indicate expected use.
|
|
||||||
func (m *MockRecordEncoder) EXPECT() *MockRecordEncoderMockRecorder {
|
|
||||||
return m.recorder
|
|
||||||
}
|
|
||||||
|
|
||||||
// MarshalRecord mocks base method.
|
|
||||||
func (m *MockRecordEncoder) MarshalRecord(arg0 context.Context, arg1 internal.Record) (json.RawMessage, error) {
|
|
||||||
m.ctrl.T.Helper()
|
|
||||||
ret := m.ctrl.Call(m, "MarshalRecord", arg0, arg1)
|
|
||||||
ret0, _ := ret[0].(json.RawMessage)
|
|
||||||
ret1, _ := ret[1].(error)
|
|
||||||
return ret0, ret1
|
|
||||||
}
|
|
||||||
|
|
||||||
// MarshalRecord indicates an expected call of MarshalRecord.
|
|
||||||
func (mr *MockRecordEncoderMockRecorder) MarshalRecord(arg0, arg1 any) *MockRecordEncoderMarshalRecordCall {
|
|
||||||
mr.mock.ctrl.T.Helper()
|
|
||||||
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MarshalRecord", reflect.TypeOf((*MockRecordEncoder)(nil).MarshalRecord), arg0, arg1)
|
|
||||||
return &MockRecordEncoderMarshalRecordCall{Call: call}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MockRecordEncoderMarshalRecordCall wrap *gomock.Call
|
|
||||||
type MockRecordEncoderMarshalRecordCall struct {
|
|
||||||
*gomock.Call
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return rewrite *gomock.Call.Return
|
|
||||||
func (c *MockRecordEncoderMarshalRecordCall) Return(arg0 json.RawMessage, arg1 error) *MockRecordEncoderMarshalRecordCall {
|
|
||||||
c.Call = c.Call.Return(arg0, arg1)
|
|
||||||
return c
|
|
||||||
}
|
|
||||||
|
|
||||||
// Do rewrite *gomock.Call.Do
|
|
||||||
func (c *MockRecordEncoderMarshalRecordCall) Do(f func(context.Context, internal.Record) (json.RawMessage, error)) *MockRecordEncoderMarshalRecordCall {
|
|
||||||
c.Call = c.Call.Do(f)
|
|
||||||
return c
|
|
||||||
}
|
|
||||||
|
|
||||||
// DoAndReturn rewrite *gomock.Call.DoAndReturn
|
|
||||||
func (c *MockRecordEncoderMarshalRecordCall) DoAndReturn(f func(context.Context, internal.Record) (json.RawMessage, error)) *MockRecordEncoderMarshalRecordCall {
|
|
||||||
c.Call = c.Call.DoAndReturn(f)
|
|
||||||
return c
|
|
||||||
}
|
|
||||||
|
|
||||||
// MockRecordDecoder is a mock of RecordDecoder interface.
|
|
||||||
type MockRecordDecoder struct {
|
|
||||||
ctrl *gomock.Controller
|
|
||||||
recorder *MockRecordDecoderMockRecorder
|
|
||||||
isgomock struct{}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MockRecordDecoderMockRecorder is the mock recorder for MockRecordDecoder.
|
|
||||||
type MockRecordDecoderMockRecorder struct {
|
|
||||||
mock *MockRecordDecoder
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewMockRecordDecoder creates a new mock instance.
|
|
||||||
func NewMockRecordDecoder(ctrl *gomock.Controller) *MockRecordDecoder {
|
|
||||||
mock := &MockRecordDecoder{ctrl: ctrl}
|
|
||||||
mock.recorder = &MockRecordDecoderMockRecorder{mock}
|
|
||||||
return mock
|
|
||||||
}
|
|
||||||
|
|
||||||
// EXPECT returns an object that allows the caller to indicate expected use.
|
|
||||||
func (m *MockRecordDecoder) EXPECT() *MockRecordDecoderMockRecorder {
|
|
||||||
return m.recorder
|
|
||||||
}
|
|
||||||
|
|
||||||
// UnmarshalRecord mocks base method.
|
|
||||||
func (m *MockRecordDecoder) UnmarshalRecord(arg0 context.Context, arg1 json.RawMessage) (internal.Record, error) {
|
|
||||||
m.ctrl.T.Helper()
|
|
||||||
ret := m.ctrl.Call(m, "UnmarshalRecord", arg0, arg1)
|
|
||||||
ret0, _ := ret[0].(internal.Record)
|
|
||||||
ret1, _ := ret[1].(error)
|
|
||||||
return ret0, ret1
|
|
||||||
}
|
|
||||||
|
|
||||||
// UnmarshalRecord indicates an expected call of UnmarshalRecord.
|
|
||||||
func (mr *MockRecordDecoderMockRecorder) UnmarshalRecord(arg0, arg1 any) *MockRecordDecoderUnmarshalRecordCall {
|
|
||||||
mr.mock.ctrl.T.Helper()
|
|
||||||
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UnmarshalRecord", reflect.TypeOf((*MockRecordDecoder)(nil).UnmarshalRecord), arg0, arg1)
|
|
||||||
return &MockRecordDecoderUnmarshalRecordCall{Call: call}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MockRecordDecoderUnmarshalRecordCall wrap *gomock.Call
|
|
||||||
type MockRecordDecoderUnmarshalRecordCall struct {
|
|
||||||
*gomock.Call
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return rewrite *gomock.Call.Return
|
|
||||||
func (c *MockRecordDecoderUnmarshalRecordCall) Return(arg0 internal.Record, arg1 error) *MockRecordDecoderUnmarshalRecordCall {
|
|
||||||
c.Call = c.Call.Return(arg0, arg1)
|
|
||||||
return c
|
|
||||||
}
|
|
||||||
|
|
||||||
// Do rewrite *gomock.Call.Do
|
|
||||||
func (c *MockRecordDecoderUnmarshalRecordCall) Do(f func(context.Context, json.RawMessage) (internal.Record, error)) *MockRecordDecoderUnmarshalRecordCall {
|
|
||||||
c.Call = c.Call.Do(f)
|
|
||||||
return c
|
|
||||||
}
|
|
||||||
|
|
||||||
// DoAndReturn rewrite *gomock.Call.DoAndReturn
|
|
||||||
func (c *MockRecordDecoderUnmarshalRecordCall) DoAndReturn(f func(context.Context, json.RawMessage) (internal.Record, error)) *MockRecordDecoderUnmarshalRecordCall {
|
|
||||||
c.Call = c.Call.DoAndReturn(f)
|
|
||||||
return c
|
|
||||||
}
|
|
||||||
|
|
||||||
// MockRecordSerializer is a mock of RecordSerializer interface.
|
|
||||||
type MockRecordSerializer struct {
|
|
||||||
ctrl *gomock.Controller
|
|
||||||
recorder *MockRecordSerializerMockRecorder
|
|
||||||
isgomock struct{}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MockRecordSerializerMockRecorder is the mock recorder for MockRecordSerializer.
|
|
||||||
type MockRecordSerializerMockRecorder struct {
|
|
||||||
mock *MockRecordSerializer
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewMockRecordSerializer creates a new mock instance.
|
|
||||||
func NewMockRecordSerializer(ctrl *gomock.Controller) *MockRecordSerializer {
|
|
||||||
mock := &MockRecordSerializer{ctrl: ctrl}
|
|
||||||
mock.recorder = &MockRecordSerializerMockRecorder{mock}
|
|
||||||
return mock
|
|
||||||
}
|
|
||||||
|
|
||||||
// EXPECT returns an object that allows the caller to indicate expected use.
|
|
||||||
func (m *MockRecordSerializer) EXPECT() *MockRecordSerializerMockRecorder {
|
|
||||||
return m.recorder
|
|
||||||
}
|
|
||||||
|
|
||||||
// MarshalRecord mocks base method.
|
|
||||||
func (m *MockRecordSerializer) MarshalRecord(arg0 context.Context, arg1 internal.Record) (json.RawMessage, error) {
|
|
||||||
m.ctrl.T.Helper()
|
|
||||||
ret := m.ctrl.Call(m, "MarshalRecord", arg0, arg1)
|
|
||||||
ret0, _ := ret[0].(json.RawMessage)
|
|
||||||
ret1, _ := ret[1].(error)
|
|
||||||
return ret0, ret1
|
|
||||||
}
|
|
||||||
|
|
||||||
// MarshalRecord indicates an expected call of MarshalRecord.
|
|
||||||
func (mr *MockRecordSerializerMockRecorder) MarshalRecord(arg0, arg1 any) *MockRecordSerializerMarshalRecordCall {
|
|
||||||
mr.mock.ctrl.T.Helper()
|
|
||||||
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MarshalRecord", reflect.TypeOf((*MockRecordSerializer)(nil).MarshalRecord), arg0, arg1)
|
|
||||||
return &MockRecordSerializerMarshalRecordCall{Call: call}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MockRecordSerializerMarshalRecordCall wrap *gomock.Call
|
|
||||||
type MockRecordSerializerMarshalRecordCall struct {
|
|
||||||
*gomock.Call
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return rewrite *gomock.Call.Return
|
|
||||||
func (c *MockRecordSerializerMarshalRecordCall) Return(arg0 json.RawMessage, arg1 error) *MockRecordSerializerMarshalRecordCall {
|
|
||||||
c.Call = c.Call.Return(arg0, arg1)
|
|
||||||
return c
|
|
||||||
}
|
|
||||||
|
|
||||||
// Do rewrite *gomock.Call.Do
|
|
||||||
func (c *MockRecordSerializerMarshalRecordCall) Do(f func(context.Context, internal.Record) (json.RawMessage, error)) *MockRecordSerializerMarshalRecordCall {
|
|
||||||
c.Call = c.Call.Do(f)
|
|
||||||
return c
|
|
||||||
}
|
|
||||||
|
|
||||||
// DoAndReturn rewrite *gomock.Call.DoAndReturn
|
|
||||||
func (c *MockRecordSerializerMarshalRecordCall) DoAndReturn(f func(context.Context, internal.Record) (json.RawMessage, error)) *MockRecordSerializerMarshalRecordCall {
|
|
||||||
c.Call = c.Call.DoAndReturn(f)
|
|
||||||
return c
|
|
||||||
}
|
|
||||||
|
|
||||||
// UnmarshalRecord mocks base method.
|
|
||||||
func (m *MockRecordSerializer) UnmarshalRecord(arg0 context.Context, arg1 json.RawMessage) (internal.Record, error) {
|
|
||||||
m.ctrl.T.Helper()
|
|
||||||
ret := m.ctrl.Call(m, "UnmarshalRecord", arg0, arg1)
|
|
||||||
ret0, _ := ret[0].(internal.Record)
|
|
||||||
ret1, _ := ret[1].(error)
|
|
||||||
return ret0, ret1
|
|
||||||
}
|
|
||||||
|
|
||||||
// UnmarshalRecord indicates an expected call of UnmarshalRecord.
|
|
||||||
func (mr *MockRecordSerializerMockRecorder) UnmarshalRecord(arg0, arg1 any) *MockRecordSerializerUnmarshalRecordCall {
|
|
||||||
mr.mock.ctrl.T.Helper()
|
|
||||||
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UnmarshalRecord", reflect.TypeOf((*MockRecordSerializer)(nil).UnmarshalRecord), arg0, arg1)
|
|
||||||
return &MockRecordSerializerUnmarshalRecordCall{Call: call}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MockRecordSerializerUnmarshalRecordCall wrap *gomock.Call
|
|
||||||
type MockRecordSerializerUnmarshalRecordCall struct {
|
|
||||||
*gomock.Call
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return rewrite *gomock.Call.Return
|
|
||||||
func (c *MockRecordSerializerUnmarshalRecordCall) Return(arg0 internal.Record, arg1 error) *MockRecordSerializerUnmarshalRecordCall {
|
|
||||||
c.Call = c.Call.Return(arg0, arg1)
|
|
||||||
return c
|
|
||||||
}
|
|
||||||
|
|
||||||
// Do rewrite *gomock.Call.Do
|
|
||||||
func (c *MockRecordSerializerUnmarshalRecordCall) Do(f func(context.Context, json.RawMessage) (internal.Record, error)) *MockRecordSerializerUnmarshalRecordCall {
|
|
||||||
c.Call = c.Call.Do(f)
|
|
||||||
return c
|
|
||||||
}
|
|
||||||
|
|
||||||
// DoAndReturn rewrite *gomock.Call.DoAndReturn
|
|
||||||
func (c *MockRecordSerializerUnmarshalRecordCall) DoAndReturn(f func(context.Context, json.RawMessage) (internal.Record, error)) *MockRecordSerializerUnmarshalRecordCall {
|
|
||||||
c.Call = c.Call.DoAndReturn(f)
|
|
||||||
return c
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
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.Valid() {
|
|
||||||
return string(n)
|
|
||||||
}
|
|
||||||
return "unknown"
|
|
||||||
}
|
|
||||||
|
|
||||||
func (n Nature) Valid() bool {
|
|
||||||
switch n {
|
|
||||||
case NatureG01, NatureG20:
|
|
||||||
return true
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
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 for empty",
|
|
||||||
want: "unknown",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "return unknown for bad value",
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
package ofigi
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"log/slog"
|
|
||||||
"net/http"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/biter777/countries"
|
|
||||||
"golang.org/x/time/rate"
|
|
||||||
)
|
|
||||||
|
|
||||||
var OpenFIGIAPIKeyHeader = http.CanonicalHeaderKey("X-OPENFIGI-APIKEY")
|
|
||||||
|
|
||||||
// Client is a thin adapter for the openfigi.com api.
|
|
||||||
type Client struct {
|
|
||||||
client *http.Client
|
|
||||||
apiKey string
|
|
||||||
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 eviction
|
|
||||||
// ourselves.
|
|
||||||
securityTypeCache map[string]string
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewOpenFIGI creates an OpenFIGI client that uses the API key if provided
|
|
||||||
func NewOpenFIGI(c *http.Client, apiKey string) *Client {
|
|
||||||
// Rate limits as per https://www.openfigi.com/api/documentation#rate-limits
|
|
||||||
limiter := rate.NewLimiter(rate.Every(time.Minute), 25)
|
|
||||||
if len(apiKey) > 0 {
|
|
||||||
slog.Debug("OpenFIGI client: created with API Key rate limits")
|
|
||||||
limiter = rate.NewLimiter(rate.Every(time.Second*6), 25)
|
|
||||||
} else {
|
|
||||||
slog.Debug("OpenFIGI client: created with puplic rate limits")
|
|
||||||
}
|
|
||||||
|
|
||||||
return &Client{
|
|
||||||
client: c,
|
|
||||||
apiKey: apiKey,
|
|
||||||
mappingLimiter: limiter,
|
|
||||||
securityTypeCache: make(map[string]string),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (of *Client) SecurityTypeByISIN(ctx context.Context, isin string) (string, error) {
|
|
||||||
of.mu.RLock()
|
|
||||||
if secType, ok := of.securityTypeCache[isin]; ok {
|
|
||||||
of.mu.RUnlock()
|
|
||||||
slog.Debug("OpenFIGI client: SecurityTypeByISIN cache hit",
|
|
||||||
slog.String("isin", isin),
|
|
||||||
slog.String("security_type", secType))
|
|
||||||
return secType, nil
|
|
||||||
}
|
|
||||||
of.mu.RUnlock()
|
|
||||||
|
|
||||||
slog.Debug("OpenFIGI client: SecurityTypeByISIN cache miss",
|
|
||||||
slog.String("isin", isin))
|
|
||||||
|
|
||||||
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")
|
|
||||||
|
|
||||||
if len(of.apiKey) > 0 {
|
|
||||||
req.Header.Add(OpenFIGIAPIKeyHeader, of.apiKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !of.mappingLimiter.Allow() {
|
|
||||||
slog.Debug("OpenFIGI client: mapping limiter waiting for rate limiter capacity")
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
slog.Debug("OpenFIGI client: SecurityTypeByISIN cached mapping",
|
|
||||||
slog.String("isin", isin),
|
|
||||||
slog.String("security_type", 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"`
|
|
||||||
}
|
|
||||||
@@ -1,230 +0,0 @@
|
|||||||
package ofigi_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/nmoniz/any2anexoj/internal/ofigi"
|
|
||||||
)
|
|
||||||
|
|
||||||
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 := ofigi.NewOpenFIGI(tt.client, "")
|
|
||||||
|
|
||||||
got, gotErr := of.SecurityTypeByISIN(t.Context(), 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 := ofigi.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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestOpenFIGI_SecurityTypeByISIN_APIKey(t *testing.T) {
|
|
||||||
t.Run("with API key", func(t *testing.T) {
|
|
||||||
wantAPIKey := "123abc-456xyz"
|
|
||||||
|
|
||||||
c := NewTestClient(t, func(req *http.Request) (*http.Response, error) {
|
|
||||||
value, ok := req.Header[ofigi.OpenFIGIAPIKeyHeader]
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("want %q header but got none: %v", ofigi.OpenFIGIAPIKeyHeader, req.Header)
|
|
||||||
}
|
|
||||||
if len(value) != 1 {
|
|
||||||
t.Fatalf("want exactly one %q header value but got %d", ofigi.OpenFIGIAPIKeyHeader, len(value))
|
|
||||||
}
|
|
||||||
if value[0] != wantAPIKey {
|
|
||||||
t.Fatalf("want %q header value %q but got %q", ofigi.OpenFIGIAPIKeyHeader, wantAPIKey, value[0])
|
|
||||||
}
|
|
||||||
return &http.Response{
|
|
||||||
Status: http.StatusText(http.StatusOK),
|
|
||||||
StatusCode: http.StatusOK,
|
|
||||||
Body: io.NopCloser(bytes.NewBufferString(`[{"data":[{"securityType":"Common Stock"}]}]`)),
|
|
||||||
}, nil
|
|
||||||
})
|
|
||||||
of := ofigi.NewOpenFIGI(c, wantAPIKey)
|
|
||||||
|
|
||||||
_, err := of.SecurityTypeByISIN(t.Context(), "US1234567890")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("want success but got an error: %s", err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("without API key", func(t *testing.T) {
|
|
||||||
c := NewTestClient(t, func(req *http.Request) (*http.Response, error) {
|
|
||||||
_, ok := req.Header[ofigi.OpenFIGIAPIKeyHeader]
|
|
||||||
if ok {
|
|
||||||
t.Fatalf("want no %s header but got one", ofigi.OpenFIGIAPIKeyHeader)
|
|
||||||
}
|
|
||||||
return &http.Response{
|
|
||||||
Status: http.StatusText(http.StatusOK),
|
|
||||||
StatusCode: http.StatusOK,
|
|
||||||
Body: io.NopCloser(bytes.NewBufferString(`[{"data":[{"securityType":"Common Stock"}]}]`)),
|
|
||||||
}, nil
|
|
||||||
})
|
|
||||||
of := ofigi.NewOpenFIGI(c, "")
|
|
||||||
_, err := of.SecurityTypeByISIN(t.Context(), "US1234567890")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("want success but got an error: %s", err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package internal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"container/list"
|
||||||
|
"math/big"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Record interface {
|
||||||
|
Symbol() string
|
||||||
|
Side() Side
|
||||||
|
Price() *big.Float
|
||||||
|
Quantity() *big.Float
|
||||||
|
Timestamp() time.Time
|
||||||
|
Fees() *big.Float
|
||||||
|
Taxes() *big.Float
|
||||||
|
}
|
||||||
|
|
||||||
|
type RecordQueue struct {
|
||||||
|
l *list.List
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push inserts the Record at the back of the queue. If pushing a nil Record then it's a no-op.
|
||||||
|
func (rq *RecordQueue) Push(r Record) {
|
||||||
|
if r == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if rq == nil {
|
||||||
|
// This would cause a panic anyway so, we panic with a more meaningful message
|
||||||
|
panic("Push to nil RecordQueue")
|
||||||
|
}
|
||||||
|
|
||||||
|
if rq.l == nil {
|
||||||
|
rq.l = list.New()
|
||||||
|
}
|
||||||
|
|
||||||
|
rq.l.PushBack(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pop removes and returns the first Record of the queue in the 1st return value. If the list is
|
||||||
|
// empty returns false on the 2nd return value, true otherwise.
|
||||||
|
func (rq *RecordQueue) Pop() (Record, bool) {
|
||||||
|
el := rq.frontElement()
|
||||||
|
if el == nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
val := rq.l.Remove(el)
|
||||||
|
|
||||||
|
return val.(Record), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Peek returns the front Record of the queue in the 1st return value. If the list is empty returns
|
||||||
|
// false on the 2nd return value, true otherwise.
|
||||||
|
func (rq *RecordQueue) Peek() (Record, bool) {
|
||||||
|
el := rq.frontElement()
|
||||||
|
if el == nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
return el.Value.(Record), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rq *RecordQueue) frontElement() *list.Element {
|
||||||
|
if rq == nil || rq.l == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return rq.l.Front()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Len returns how many elements are currently on the queue
|
||||||
|
func (rq *RecordQueue) Len() int {
|
||||||
|
if rq == nil || rq.l == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return rq.l.Len()
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
package internal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRecordQueue(t *testing.T) {
|
||||||
|
var recCount int
|
||||||
|
newRecord := func() Record {
|
||||||
|
recCount++
|
||||||
|
return testRecord{
|
||||||
|
id: recCount,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var rq RecordQueue
|
||||||
|
|
||||||
|
if rq.Len() != 0 {
|
||||||
|
t.Fatalf("zero value should have zero lenght")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, ok := rq.Pop()
|
||||||
|
if ok {
|
||||||
|
t.Fatalf("Pop() should return (_,false) on a zero value")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, ok = rq.Peek()
|
||||||
|
if ok {
|
||||||
|
t.Fatalf("Peek() should return (_,false) on a zero value")
|
||||||
|
}
|
||||||
|
|
||||||
|
rq.Push(nil)
|
||||||
|
if rq.Len() != 0 {
|
||||||
|
t.Fatalf("pushing nil should be a no-op")
|
||||||
|
}
|
||||||
|
|
||||||
|
rq.Push(newRecord())
|
||||||
|
if rq.Len() != 1 {
|
||||||
|
t.Fatalf("pushing 1st record should result in lenght of 1")
|
||||||
|
}
|
||||||
|
|
||||||
|
rq.Push(newRecord())
|
||||||
|
if rq.Len() != 2 {
|
||||||
|
t.Fatalf("pushing 2nd record should result in lenght of 2")
|
||||||
|
}
|
||||||
|
|
||||||
|
peekRec, ok := rq.Peek()
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("Peek() should return (_,true) when the list is not empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
if peekRec, ok := peekRec.(testRecord); ok {
|
||||||
|
if peekRec.id != 1 {
|
||||||
|
t.Fatalf("Peek() should return the 1st record pushed but returned %d", peekRec.id)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
t.Fatalf("Peek() should return the original record type")
|
||||||
|
}
|
||||||
|
|
||||||
|
if rq.Len() != 2 {
|
||||||
|
t.Fatalf("Peek() should not affect the list length")
|
||||||
|
}
|
||||||
|
|
||||||
|
popRec, ok := rq.Pop()
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("Pop() should return (_,true) when the list is not empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
if rec, ok := popRec.(testRecord); ok {
|
||||||
|
if rec.id != 1 {
|
||||||
|
t.Fatalf("Pop() should return the first record pushed but returned %d", rec.id)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
t.Fatalf("Pop() should return the original record")
|
||||||
|
}
|
||||||
|
|
||||||
|
if rq.Len() != 1 {
|
||||||
|
t.Fatalf("Pop() should remove an element from the list")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecordQueueNilReceiver(t *testing.T) {
|
||||||
|
var rq *RecordQueue
|
||||||
|
|
||||||
|
if rq.Len() > 0 {
|
||||||
|
t.Fatalf("nil receiver should have zero lenght")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, ok := rq.Peek()
|
||||||
|
if ok {
|
||||||
|
t.Fatalf("Peek() on a nil receiver should return (_,false)")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, ok = rq.Pop()
|
||||||
|
if ok {
|
||||||
|
t.Fatalf("Pop() on a nil receiver should return (_,false)")
|
||||||
|
}
|
||||||
|
|
||||||
|
rq.Push(nil)
|
||||||
|
if rq.Len() != 0 {
|
||||||
|
t.Fatalf("Push(nil) on a nil receiver should be a no-op")
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
r := recover()
|
||||||
|
if r == nil {
|
||||||
|
t.Fatalf("expected a panic but got nothing")
|
||||||
|
}
|
||||||
|
|
||||||
|
expMsg := "Push to nil RecordQueue"
|
||||||
|
if msg, ok := r.(string); !ok || msg != expMsg {
|
||||||
|
t.Fatalf(`want panic message %q but got "%v"`, expMsg, r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
rq.Push(testRecord{})
|
||||||
|
}
|
||||||
|
|
||||||
|
type testRecord struct {
|
||||||
|
Record
|
||||||
|
|
||||||
|
id int
|
||||||
|
}
|
||||||
+41
-151
@@ -5,142 +5,43 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"math/big"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/shopspring/decimal"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type Record interface {
|
|
||||||
Symbol() string
|
|
||||||
Nature() Nature
|
|
||||||
BrokerCountry() int64
|
|
||||||
AssetCountry() int64
|
|
||||||
Kind() Kind
|
|
||||||
Price() decimal.Decimal
|
|
||||||
Quantity() decimal.Decimal
|
|
||||||
Timestamp() time.Time
|
|
||||||
Fees() decimal.Decimal
|
|
||||||
Taxes() decimal.Decimal
|
|
||||||
}
|
|
||||||
|
|
||||||
type RecordReader interface {
|
type RecordReader interface {
|
||||||
// ReadRecord should return Records until an error is found.
|
// ReadRecord should return Records until an error is found.
|
||||||
ReadRecord(context.Context) (Record, error)
|
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 {
|
type ReportWriter interface {
|
||||||
// ReportWriter writes report items
|
// ReportWriter writes report items
|
||||||
Write(context.Context, ReportItem) error
|
Write(context.Context, ReportItem) error
|
||||||
}
|
}
|
||||||
|
|
||||||
type optionals struct {
|
func BuildReport(ctx context.Context, reader RecordReader, writer ReportWriter) error {
|
||||||
selector Selector
|
buys := make(map[string]*RecordQueue)
|
||||||
store Store
|
|
||||||
}
|
|
||||||
|
|
||||||
func applyOptions(defaults optionals, opts []Option) optionals {
|
|
||||||
for _, opt := range opts {
|
|
||||||
opt(&defaults)
|
|
||||||
}
|
|
||||||
return defaults
|
|
||||||
}
|
|
||||||
|
|
||||||
type Option func(*optionals)
|
|
||||||
|
|
||||||
// Selector returns true if a record should be selected for processing, false otherwise.
|
|
||||||
type Selector func(Record) bool
|
|
||||||
|
|
||||||
func WithSelector(s Selector) Option {
|
|
||||||
return func(o *optionals) {
|
|
||||||
o.selector = s
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type Store interface {
|
|
||||||
Load(context.Context) (map[string]*FillerQueue, error)
|
|
||||||
Save(context.Context, map[string]*FillerQueue) error
|
|
||||||
}
|
|
||||||
|
|
||||||
func WithStore(s Store) Option {
|
|
||||||
return func(o *optionals) {
|
|
||||||
o.store = s
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// BuildReport reads records from a RecordReader and, if the record passes the Selector, it is
|
|
||||||
// processed into the ReportWriter.
|
|
||||||
func BuildReport(ctx context.Context, reader RecordReader, writer ReportWriter, options ...Option) error {
|
|
||||||
optionals := applyOptions(optionals{
|
|
||||||
selector: Any(),
|
|
||||||
store: EphemeralStore{},
|
|
||||||
}, options)
|
|
||||||
|
|
||||||
buys, err := optionals.store.Load(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("loading state: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
recordsCount int64
|
|
||||||
lastTimestamp time.Time
|
|
||||||
progTicker = time.NewTicker(10 * time.Second)
|
|
||||||
)
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return ctx.Err()
|
return ctx.Err()
|
||||||
case <-progTicker.C:
|
|
||||||
slog.InfoContext(
|
|
||||||
ctx, "Progress update",
|
|
||||||
slog.Int64("records_count", recordsCount),
|
|
||||||
slog.Time("last_record_timestamp", lastTimestamp),
|
|
||||||
)
|
|
||||||
default:
|
default:
|
||||||
rec, err := reader.ReadRecord(ctx)
|
rec, err := reader.ReadRecord(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, io.EOF) {
|
if errors.Is(err, io.EOF) {
|
||||||
err = optionals.store.Save(ctx, buys)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("saving state: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if !rec.Kind().Valid() {
|
|
||||||
return fmt.Errorf("found invalid Kind(%d)", rec.Kind())
|
|
||||||
}
|
|
||||||
|
|
||||||
lastTimestamp = rec.Timestamp()
|
|
||||||
recordsCount++
|
|
||||||
|
|
||||||
buyQueue, ok := buys[rec.Symbol()]
|
buyQueue, ok := buys[rec.Symbol()]
|
||||||
if !ok {
|
if !ok {
|
||||||
buyQueue = new(FillerQueue)
|
buyQueue = new(RecordQueue)
|
||||||
buys[rec.Symbol()] = buyQueue
|
buys[rec.Symbol()] = buyQueue
|
||||||
}
|
}
|
||||||
|
|
||||||
err = processRecord(ctx, buyQueue, rec, optionals.selector, writer)
|
err = processRecord(ctx, buyQueue, rec, writer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("processing record: %w", err)
|
return fmt.Errorf("processing record: %w", err)
|
||||||
}
|
}
|
||||||
@@ -148,77 +49,66 @@ func BuildReport(ctx context.Context, reader RecordReader, writer ReportWriter,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// processRecord either adds buys to the queue or consumes buys from the queue when processing a
|
func processRecord(ctx context.Context, q *RecordQueue, rec Record, writer ReportWriter) error {
|
||||||
// sell record.
|
switch rec.Side() {
|
||||||
func processRecord(ctx context.Context, q *FillerQueue, rec Record, sel Selector, writer ReportWriter) error {
|
case SideBuy:
|
||||||
slog.Debug(
|
q.Push(rec)
|
||||||
"Report: processing record",
|
|
||||||
slog.String("symbol", rec.Symbol()),
|
|
||||||
slog.String("side", rec.Kind().String()),
|
|
||||||
)
|
|
||||||
|
|
||||||
switch rec.Kind() {
|
case SideSell:
|
||||||
case KindBuy:
|
unmatchedQty := new(big.Float).Copy(rec.Quantity())
|
||||||
// Selectors are only applied when processing sell records for performance reasons. It's much
|
zero := new(big.Float)
|
||||||
// cheaper to just accumulate buys and only actually inspect any records once a sell happens. This
|
|
||||||
// avoids potential network requests to for every single record.
|
|
||||||
q.Push(NewFiller(rec))
|
|
||||||
|
|
||||||
case KindSell:
|
for unmatchedQty.Cmp(zero) > 0 {
|
||||||
if !sel(rec) {
|
|
||||||
slog.Debug(
|
|
||||||
"Report: skipping record",
|
|
||||||
slog.String("symbol", rec.Symbol()),
|
|
||||||
slog.String("side", rec.Kind().String()),
|
|
||||||
)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
unmatchedQty := rec.Quantity()
|
|
||||||
|
|
||||||
for unmatchedQty.IsPositive() {
|
|
||||||
buy, ok := q.Peek()
|
buy, ok := q.Peek()
|
||||||
if !ok {
|
if !ok {
|
||||||
return ErrInsufficientBoughtVolume
|
return ErrInsufficientBoughtVolume
|
||||||
}
|
}
|
||||||
|
|
||||||
matchedQty, filled := buy.Fill(unmatchedQty)
|
var matchedQty *big.Float
|
||||||
|
if buy.Quantity().Cmp(unmatchedQty) > 0 {
|
||||||
if filled {
|
matchedQty = unmatchedQty
|
||||||
_, ok := q.Pop()
|
buy.Quantity().Sub(buy.Quantity(), unmatchedQty)
|
||||||
if !ok {
|
} else {
|
||||||
return fmt.Errorf("pop empty filler queue")
|
matchedQty = buy.Quantity()
|
||||||
}
|
q.Pop()
|
||||||
}
|
}
|
||||||
|
|
||||||
unmatchedQty = unmatchedQty.Sub(matchedQty)
|
unmatchedQty.Sub(unmatchedQty, matchedQty)
|
||||||
|
|
||||||
buyValue := matchedQty.Mul(buy.Price())
|
sellValue := new(big.Float).Mul(matchedQty, rec.Price())
|
||||||
sellValue := matchedQty.Mul(rec.Price())
|
buyValue := new(big.Float).Mul(matchedQty, buy.Price())
|
||||||
|
|
||||||
err := writer.Write(ctx, ReportItem{
|
err := writer.Write(ctx, ReportItem{
|
||||||
Symbol: rec.Symbol(),
|
|
||||||
BrokerCountry: rec.BrokerCountry(),
|
|
||||||
AssetCountry: rec.AssetCountry(),
|
|
||||||
BuyValue: buyValue,
|
BuyValue: buyValue,
|
||||||
BuyTimestamp: buy.Timestamp(),
|
BuyTimestamp: buy.Timestamp(),
|
||||||
SellValue: sellValue,
|
SellValue: sellValue,
|
||||||
SellTimestamp: rec.Timestamp(),
|
SellTimestamp: rec.Timestamp(),
|
||||||
Fees: buy.Fees().Add(rec.Fees()),
|
Fees: new(big.Float).Add(buy.Fees(), rec.Fees()),
|
||||||
Taxes: buy.Taxes().Add(rec.Taxes()),
|
Taxes: new(big.Float).Add(buy.Taxes(), rec.Fees()),
|
||||||
Nature: buy.Nature(),
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("write report item: %w", err)
|
return fmt.Errorf("write report item: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
case KindSplit:
|
|
||||||
q.AdjustForSplit(rec.Quantity())
|
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("unknown side: %v", rec.Kind())
|
return fmt.Errorf("unknown side: %v", rec.Side())
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ReportItem struct {
|
||||||
|
BuyValue *big.Float
|
||||||
|
BuyTimestamp time.Time
|
||||||
|
SellValue *big.Float
|
||||||
|
SellTimestamp time.Time
|
||||||
|
Fees *big.Float
|
||||||
|
Taxes *big.Float
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ri ReportItem) RealisedPnL() *big.Float {
|
||||||
|
return new(big.Float).Sub(ri.SellValue, ri.BuyValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
var ErrInsufficientBoughtVolume = fmt.Errorf("insufficient bought volume")
|
||||||
|
|||||||
+15
-140
@@ -2,27 +2,24 @@ package internal_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
"io"
|
"io"
|
||||||
"reflect"
|
"math/big"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/biter777/countries"
|
|
||||||
"github.com/nmoniz/any2anexoj/internal"
|
"github.com/nmoniz/any2anexoj/internal"
|
||||||
"github.com/nmoniz/any2anexoj/internal/mocks"
|
"github.com/nmoniz/any2anexoj/internal/mocks"
|
||||||
"github.com/shopspring/decimal"
|
|
||||||
"go.uber.org/mock/gomock"
|
"go.uber.org/mock/gomock"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestBuildReport(t *testing.T) {
|
func TestReporter_Run(t *testing.T) {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
ctrl := gomock.NewController(t)
|
ctrl := gomock.NewController(t)
|
||||||
|
|
||||||
reader := mocks.NewMockRecordReader(ctrl)
|
reader := mocks.NewMockRecordReader(ctrl)
|
||||||
records := []internal.Record{
|
records := []internal.Record{
|
||||||
mockRecord(ctrl, 20.0, 10.0, internal.KindBuy, now),
|
mockRecord(ctrl, 20.0, 10.0, internal.SideBuy, now),
|
||||||
mockRecord(ctrl, 25.0, 10.0, internal.KindSell, now.Add(1)),
|
mockRecord(ctrl, 25.0, 10.0, internal.SideSell, now.Add(1)),
|
||||||
}
|
}
|
||||||
reader.EXPECT().ReadRecord(gomock.Any()).DoAndReturn(func(ctx context.Context) (internal.Record, error) {
|
reader.EXPECT().ReadRecord(gomock.Any()).DoAndReturn(func(ctx context.Context) (internal.Record, error) {
|
||||||
if len(records) > 0 {
|
if len(records) > 0 {
|
||||||
@@ -35,13 +32,13 @@ func TestBuildReport(t *testing.T) {
|
|||||||
}).Times(3)
|
}).Times(3)
|
||||||
|
|
||||||
writer := mocks.NewMockReportWriter(ctrl)
|
writer := mocks.NewMockReportWriter(ctrl)
|
||||||
writer.EXPECT().Write(gomock.Any(), eqReportItem(internal.ReportItem{
|
writer.EXPECT().Write(gomock.Any(), gomock.Eq(internal.ReportItem{
|
||||||
BuyValue: decimal.NewFromFloat(200.0),
|
BuyValue: new(big.Float).SetFloat64(200.0),
|
||||||
BuyTimestamp: now,
|
BuyTimestamp: now,
|
||||||
SellValue: decimal.NewFromFloat(250.0),
|
SellValue: new(big.Float).SetFloat64(250.0),
|
||||||
SellTimestamp: now.Add(1),
|
SellTimestamp: now.Add(1),
|
||||||
Fees: decimal.Decimal{},
|
Fees: new(big.Float),
|
||||||
Taxes: decimal.Decimal{},
|
Taxes: new(big.Float),
|
||||||
})).Times(1)
|
})).Times(1)
|
||||||
|
|
||||||
gotErr := internal.BuildReport(t.Context(), reader, writer)
|
gotErr := internal.BuildReport(t.Context(), reader, writer)
|
||||||
@@ -50,136 +47,14 @@ func TestBuildReport(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func mockRecord(ctrl *gomock.Controller, price, quantity float64, kind internal.Kind, ts time.Time) *mocks.MockRecord {
|
func mockRecord(ctrl *gomock.Controller, price, quantity float64, side internal.Side, ts time.Time) *mocks.MockRecord {
|
||||||
rec := mocks.NewMockRecord(ctrl)
|
rec := mocks.NewMockRecord(ctrl)
|
||||||
|
rec.EXPECT().Price().Return(big.NewFloat(price)).AnyTimes()
|
||||||
|
rec.EXPECT().Quantity().Return(big.NewFloat(quantity)).AnyTimes()
|
||||||
|
rec.EXPECT().Side().Return(side).AnyTimes()
|
||||||
rec.EXPECT().Symbol().Return("TEST").AnyTimes()
|
rec.EXPECT().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().Kind().Return(kind).AnyTimes()
|
|
||||||
rec.EXPECT().Timestamp().Return(ts).AnyTimes()
|
rec.EXPECT().Timestamp().Return(ts).AnyTimes()
|
||||||
rec.EXPECT().Fees().Return(decimal.Decimal{}).AnyTimes()
|
rec.EXPECT().Fees().Return(new(big.Float)).AnyTimes()
|
||||||
rec.EXPECT().Taxes().Return(decimal.Decimal{}).AnyTimes()
|
rec.EXPECT().Taxes().Return(new(big.Float)).AnyTimes()
|
||||||
rec.EXPECT().Nature().Return(internal.NatureG01).AnyTimes()
|
|
||||||
return rec
|
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)
|
|
||||||
|
|
||||||
// recordingStore is a test fake for internal.Store that records how many
|
|
||||||
// times Load/Save were invoked and captures the maps it handed back so a
|
|
||||||
// test can verify BuildReport wires the store correctly.
|
|
||||||
type recordingStore struct {
|
|
||||||
loadCalls int
|
|
||||||
saveCalls int
|
|
||||||
loaded map[string]*internal.FillerQueue
|
|
||||||
saved map[string]*internal.FillerQueue
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *recordingStore) Load(_ context.Context) (map[string]*internal.FillerQueue, error) {
|
|
||||||
s.loadCalls++
|
|
||||||
if s.loaded == nil {
|
|
||||||
s.loaded = map[string]*internal.FillerQueue{}
|
|
||||||
}
|
|
||||||
return s.loaded, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *recordingStore) Save(_ context.Context, queues map[string]*internal.FillerQueue) error {
|
|
||||||
s.saveCalls++
|
|
||||||
s.saved = queues
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildReport_WithStore(t *testing.T) {
|
|
||||||
now := time.Now()
|
|
||||||
ctrl := gomock.NewController(t)
|
|
||||||
|
|
||||||
reader := mocks.NewMockRecordReader(ctrl)
|
|
||||||
records := []internal.Record{
|
|
||||||
mockRecord(ctrl, 20.0, 10.0, internal.KindBuy, now),
|
|
||||||
}
|
|
||||||
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
|
|
||||||
}
|
|
||||||
return nil, io.EOF
|
|
||||||
}).Times(2)
|
|
||||||
|
|
||||||
// No sells, so the writer must not be called.
|
|
||||||
writer := mocks.NewMockReportWriter(ctrl)
|
|
||||||
|
|
||||||
store := &recordingStore{}
|
|
||||||
|
|
||||||
gotErr := internal.BuildReport(t.Context(), reader, writer, internal.WithStore(store))
|
|
||||||
if gotErr != nil {
|
|
||||||
t.Fatalf("got unexpected err: %v", gotErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
if store.loadCalls != 1 {
|
|
||||||
t.Errorf("Load calls: want 1 but got %d", store.loadCalls)
|
|
||||||
}
|
|
||||||
if store.saveCalls != 1 {
|
|
||||||
t.Errorf("Save calls: want 1 but got %d", store.saveCalls)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load must run before any records are read, so it has already been
|
|
||||||
// invoked by the time Save is observed.
|
|
||||||
if store.loadCalls < store.saveCalls {
|
|
||||||
t.Errorf("Load (%d) should be called at least as many times as Save (%d)", store.loadCalls, store.saveCalls)
|
|
||||||
}
|
|
||||||
|
|
||||||
if store.loaded == nil {
|
|
||||||
t.Fatalf("Load was not called or returned a nil map")
|
|
||||||
}
|
|
||||||
if store.saved == nil {
|
|
||||||
t.Fatalf("Save was not called or received a nil map")
|
|
||||||
}
|
|
||||||
|
|
||||||
// The map handed to Save must be the same map returned by Load so
|
|
||||||
// that buy-queue mutations done while processing flow into Save.
|
|
||||||
if reflect.ValueOf(store.loaded).Pointer() != reflect.ValueOf(store.saved).Pointer() {
|
|
||||||
t.Errorf("map passed to Save is not the same map returned by Load")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sanity: the buy record above should have populated an entry for the
|
|
||||||
// "TEST" symbol in the shared map.
|
|
||||||
if _, ok := store.saved["TEST"]; !ok {
|
|
||||||
t.Errorf("want saved map to contain symbol %q but it was missing", "TEST")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,117 +0,0 @@
|
|||||||
package internal
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
func Any() Selector {
|
|
||||||
return func(r Record) bool { return true }
|
|
||||||
}
|
|
||||||
|
|
||||||
func And(a, b Selector) Selector {
|
|
||||||
return func(r Record) bool {
|
|
||||||
return a(r) && b(r)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// OnlyNature will only select records with the given Nature n (G01, G20, etc...).
|
|
||||||
func OnlyNature(n Nature) Selector {
|
|
||||||
return func(r Record) bool {
|
|
||||||
return r.Nature() == n
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// OnlyAssetCountry will only select records with the given ISO code c (620 for Portugal, 196 for
|
|
||||||
// Cyprus, etc...).
|
|
||||||
func OnlyAssetCountry(c int64) Selector {
|
|
||||||
return func(r Record) bool {
|
|
||||||
return r.AssetCountry() == c
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// selectorParser is a function that parses a selector value string into a Selector
|
|
||||||
type selectorParser func(string) (Selector, error)
|
|
||||||
|
|
||||||
// parsers maps selector keys to their parser functions
|
|
||||||
var parsers = map[string]selectorParser{
|
|
||||||
"code": parseNature,
|
|
||||||
"assetCountry": parseAssetCountry,
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseNature(value string) (Selector, error) {
|
|
||||||
if value == "" {
|
|
||||||
return nil, fmt.Errorf("code selector requires a non-empty value")
|
|
||||||
}
|
|
||||||
nature := Nature(value)
|
|
||||||
if !nature.Valid() {
|
|
||||||
return nil, fmt.Errorf("invalid nature code %q", value)
|
|
||||||
}
|
|
||||||
return OnlyNature(nature), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseAssetCountry(value string) (Selector, error) {
|
|
||||||
if value == "" {
|
|
||||||
return nil, fmt.Errorf("assetCountry selector requires a non-empty value")
|
|
||||||
}
|
|
||||||
i, err := strconv.ParseInt(value, 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("assetCountry value must be a valid integer: %w", err)
|
|
||||||
}
|
|
||||||
return OnlyAssetCountry(i), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ParseSelectors parses a list of selector strings into a composed Selector.
|
|
||||||
// Each selector string must be in the format "key:value" where key is one of:
|
|
||||||
// - code: filter by nature (e.g., "code:G01")
|
|
||||||
// - assetCountry: filter by asset country code (e.g., "assetCountry:840")
|
|
||||||
//
|
|
||||||
// Multiple selectors are combined with AND logic.
|
|
||||||
// If no selectors are provided, returns Any() which matches all records.
|
|
||||||
func ParseSelectors(sl []string) (Selector, error) {
|
|
||||||
if len(sl) == 0 {
|
|
||||||
return Any(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse the first selector
|
|
||||||
first, err := parseSingleSelector(sl[0])
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// If there's only one, return it
|
|
||||||
if len(sl) == 1 {
|
|
||||||
return first, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Recursively parse the rest and combine with AND
|
|
||||||
rest, err := ParseSelectors(sl[1:])
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return And(first, rest), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseSingleSelector(s string) (Selector, error) {
|
|
||||||
key, value, found := strings.Cut(s, ":")
|
|
||||||
if !found {
|
|
||||||
return nil, fmt.Errorf("invalid selector format %q: must be 'key:value'", s)
|
|
||||||
}
|
|
||||||
|
|
||||||
parser, ok := parsers[key]
|
|
||||||
if !ok {
|
|
||||||
return nil, fmt.Errorf("unknown selector key %q: supported keys are %v", key, supportedKeys())
|
|
||||||
}
|
|
||||||
|
|
||||||
return parser(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
func supportedKeys() []string {
|
|
||||||
keys := make([]string, 0, len(parsers))
|
|
||||||
for k := range parsers {
|
|
||||||
keys = append(keys, k)
|
|
||||||
}
|
|
||||||
return keys
|
|
||||||
}
|
|
||||||
@@ -1,434 +0,0 @@
|
|||||||
package internal_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/nmoniz/any2anexoj/internal"
|
|
||||||
"github.com/shopspring/decimal"
|
|
||||||
)
|
|
||||||
|
|
||||||
type testRecord struct {
|
|
||||||
symbol string
|
|
||||||
nature internal.Nature
|
|
||||||
brokerCountry int64
|
|
||||||
assetCountry int64
|
|
||||||
side internal.Kind
|
|
||||||
price decimal.Decimal
|
|
||||||
quantity decimal.Decimal
|
|
||||||
timestamp time.Time
|
|
||||||
fees decimal.Decimal
|
|
||||||
taxes decimal.Decimal
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m testRecord) Symbol() string { return m.symbol }
|
|
||||||
func (m testRecord) Nature() internal.Nature { return m.nature }
|
|
||||||
func (m testRecord) BrokerCountry() int64 { return m.brokerCountry }
|
|
||||||
func (m testRecord) AssetCountry() int64 { return m.assetCountry }
|
|
||||||
func (m testRecord) Kind() internal.Kind { return m.side }
|
|
||||||
func (m testRecord) Price() decimal.Decimal { return m.price }
|
|
||||||
func (m testRecord) Quantity() decimal.Decimal { return m.quantity }
|
|
||||||
func (m testRecord) Timestamp() time.Time { return m.timestamp }
|
|
||||||
func (m testRecord) Fees() decimal.Decimal { return m.fees }
|
|
||||||
func (m testRecord) Taxes() decimal.Decimal { return m.taxes }
|
|
||||||
|
|
||||||
func TestAny(t *testing.T) {
|
|
||||||
selector := internal.Any()
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
record internal.Record
|
|
||||||
want bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "returns true for any record",
|
|
||||||
record: testRecord{
|
|
||||||
symbol: "AAPL",
|
|
||||||
nature: internal.NatureG01,
|
|
||||||
assetCountry: 1,
|
|
||||||
},
|
|
||||||
want: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "returns true for record with unknown nature",
|
|
||||||
record: testRecord{
|
|
||||||
symbol: "MSFT",
|
|
||||||
nature: internal.NatureUnknown,
|
|
||||||
assetCountry: 2,
|
|
||||||
},
|
|
||||||
want: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "returns true for empty record",
|
|
||||||
record: testRecord{},
|
|
||||||
want: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
got := selector(tt.record)
|
|
||||||
if got != tt.want {
|
|
||||||
t.Fatalf("want %v but got %v", tt.want, got)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestOnlyNature(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
nature internal.Nature
|
|
||||||
record internal.Record
|
|
||||||
want bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "matches G01 nature",
|
|
||||||
nature: internal.NatureG01,
|
|
||||||
record: testRecord{
|
|
||||||
symbol: "AAPL",
|
|
||||||
nature: internal.NatureG01,
|
|
||||||
},
|
|
||||||
want: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "matches G20 nature",
|
|
||||||
nature: internal.NatureG20,
|
|
||||||
record: testRecord{
|
|
||||||
symbol: "ETF",
|
|
||||||
nature: internal.NatureG20,
|
|
||||||
},
|
|
||||||
want: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "does not match different nature",
|
|
||||||
nature: internal.NatureG01,
|
|
||||||
record: testRecord{
|
|
||||||
symbol: "AAPL",
|
|
||||||
nature: internal.NatureG20,
|
|
||||||
},
|
|
||||||
want: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "does not match unknown nature",
|
|
||||||
nature: internal.NatureG01,
|
|
||||||
record: testRecord{
|
|
||||||
symbol: "AAPL",
|
|
||||||
nature: internal.NatureUnknown,
|
|
||||||
},
|
|
||||||
want: false,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
selector := internal.OnlyNature(tt.nature)
|
|
||||||
got := selector(tt.record)
|
|
||||||
if got != tt.want {
|
|
||||||
t.Fatalf("want %v but got %v", tt.want, got)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestOnlyAssetCountry(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
country int64
|
|
||||||
record internal.Record
|
|
||||||
want bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "matches asset country",
|
|
||||||
country: 840, // USA
|
|
||||||
record: testRecord{
|
|
||||||
symbol: "AAPL",
|
|
||||||
assetCountry: 840,
|
|
||||||
},
|
|
||||||
want: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "matches different country",
|
|
||||||
country: 826, // UK
|
|
||||||
record: testRecord{
|
|
||||||
symbol: "BP",
|
|
||||||
assetCountry: 826,
|
|
||||||
},
|
|
||||||
want: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "does not match different asset country",
|
|
||||||
country: 840,
|
|
||||||
record: testRecord{
|
|
||||||
symbol: "BP",
|
|
||||||
assetCountry: 826,
|
|
||||||
},
|
|
||||||
want: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "does not match zero country",
|
|
||||||
country: 0,
|
|
||||||
record: testRecord{
|
|
||||||
symbol: "AAPL",
|
|
||||||
assetCountry: 840,
|
|
||||||
},
|
|
||||||
want: false,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
selector := internal.OnlyAssetCountry(tt.country)
|
|
||||||
got := selector(tt.record)
|
|
||||||
if got != tt.want {
|
|
||||||
t.Fatalf("want %v but got %v", tt.want, got)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAnd(t *testing.T) {
|
|
||||||
record := testRecord{}
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
selectorA internal.Selector
|
|
||||||
selectorB internal.Selector
|
|
||||||
want bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "both selectors return true",
|
|
||||||
selectorA: func(internal.Record) bool { return true },
|
|
||||||
selectorB: func(internal.Record) bool { return true },
|
|
||||||
want: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "first selector returns true, second returns false",
|
|
||||||
selectorA: func(internal.Record) bool { return true },
|
|
||||||
selectorB: func(internal.Record) bool { return false },
|
|
||||||
want: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "first selector returns false, second returns true",
|
|
||||||
selectorA: func(internal.Record) bool { return false },
|
|
||||||
selectorB: func(internal.Record) bool { return true },
|
|
||||||
want: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "both selectors return false",
|
|
||||||
selectorA: func(internal.Record) bool { return false },
|
|
||||||
selectorB: func(internal.Record) bool { return false },
|
|
||||||
want: false,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
selector := internal.And(tt.selectorA, tt.selectorB)
|
|
||||||
got := selector(record)
|
|
||||||
if got != tt.want {
|
|
||||||
t.Fatalf("want %v but got %v", tt.want, got)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseSelectors_Empty(t *testing.T) {
|
|
||||||
selector, err := internal.ParseSelectors([]string{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error for empty selectors: %v", err)
|
|
||||||
}
|
|
||||||
record := testRecord{
|
|
||||||
nature: internal.NatureG01,
|
|
||||||
assetCountry: 840,
|
|
||||||
}
|
|
||||||
|
|
||||||
if !selector(record) {
|
|
||||||
t.Fatalf("empty selectors should match all records")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseSelectors_OnlyNature(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
selector string
|
|
||||||
nature internal.Nature
|
|
||||||
want bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "matches G01",
|
|
||||||
selector: "code:G01",
|
|
||||||
nature: internal.NatureG01,
|
|
||||||
want: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "matches G20",
|
|
||||||
selector: "code:G20",
|
|
||||||
nature: internal.NatureG20,
|
|
||||||
want: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "does not match different nature",
|
|
||||||
selector: "code:G01",
|
|
||||||
nature: internal.NatureG20,
|
|
||||||
want: false,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
selector, err := internal.ParseSelectors([]string{tt.selector})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
record := testRecord{
|
|
||||||
nature: tt.nature,
|
|
||||||
assetCountry: 840,
|
|
||||||
}
|
|
||||||
|
|
||||||
got := selector(record)
|
|
||||||
if got != tt.want {
|
|
||||||
t.Fatalf("want %v but got %v", tt.want, got)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseSelectors_OnlyAssetCountry(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
selector string
|
|
||||||
assetCountry int64
|
|
||||||
want bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "matches USA",
|
|
||||||
selector: "assetCountry:840",
|
|
||||||
assetCountry: 840,
|
|
||||||
want: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "matches UK",
|
|
||||||
selector: "assetCountry:826",
|
|
||||||
assetCountry: 826,
|
|
||||||
want: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "does not match different country",
|
|
||||||
selector: "assetCountry:840",
|
|
||||||
assetCountry: 826,
|
|
||||||
want: false,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
selector, err := internal.ParseSelectors([]string{tt.selector})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
record := testRecord{
|
|
||||||
nature: internal.NatureG01,
|
|
||||||
assetCountry: tt.assetCountry,
|
|
||||||
}
|
|
||||||
|
|
||||||
got := selector(record)
|
|
||||||
if got != tt.want {
|
|
||||||
t.Fatalf("want %v but got %v", tt.want, got)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseSelectors_Multiple(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
selectors []string
|
|
||||||
nature internal.Nature
|
|
||||||
assetCountry int64
|
|
||||||
want bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "both selectors match",
|
|
||||||
selectors: []string{"code:G01", "assetCountry:840"},
|
|
||||||
nature: internal.NatureG01,
|
|
||||||
assetCountry: 840,
|
|
||||||
want: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "first selector matches, second does not",
|
|
||||||
selectors: []string{"code:G01", "assetCountry:826"},
|
|
||||||
nature: internal.NatureG01,
|
|
||||||
assetCountry: 840,
|
|
||||||
want: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "first selector does not match, second does",
|
|
||||||
selectors: []string{"code:G20", "assetCountry:840"},
|
|
||||||
nature: internal.NatureG01,
|
|
||||||
assetCountry: 840,
|
|
||||||
want: false,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
selector, err := internal.ParseSelectors(tt.selectors)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
record := testRecord{
|
|
||||||
nature: tt.nature,
|
|
||||||
assetCountry: tt.assetCountry,
|
|
||||||
}
|
|
||||||
|
|
||||||
got := selector(record)
|
|
||||||
if got != tt.want {
|
|
||||||
t.Fatalf("want %v but got %v", tt.want, got)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseSelectors_InvalidAssetCountry(t *testing.T) {
|
|
||||||
t.Run("returns error for non-numeric asset country", func(t *testing.T) {
|
|
||||||
_, err := internal.ParseSelectors([]string{"assetCountry:notanumber"})
|
|
||||||
if err == nil {
|
|
||||||
t.Fatalf("expected error for non-numeric asset country")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseSelectors_UnknownSelector(t *testing.T) {
|
|
||||||
t.Run("returns error for unknown selector type", func(t *testing.T) {
|
|
||||||
_, err := internal.ParseSelectors([]string{"unknown:value"})
|
|
||||||
if err == nil {
|
|
||||||
t.Fatalf("expected error for unknown selector type")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseSelectors_EmptyValue(t *testing.T) {
|
|
||||||
t.Run("rejects code with empty value", func(t *testing.T) {
|
|
||||||
_, err := internal.ParseSelectors([]string{"code:"})
|
|
||||||
if err == nil {
|
|
||||||
t.Fatalf("expected error for empty code value")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("rejects assetCountry with empty value", func(t *testing.T) {
|
|
||||||
_, err := internal.ParseSelectors([]string{"assetCountry:"})
|
|
||||||
if err == nil {
|
|
||||||
t.Fatalf("expected error for empty assetCountry value")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseSelectors_MissingColon(t *testing.T) {
|
|
||||||
t.Run("rejects input without colon", func(t *testing.T) {
|
|
||||||
_, err := internal.ParseSelectors([]string{"code"})
|
|
||||||
if err == nil {
|
|
||||||
t.Fatalf("expected error for input without colon")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d Side) IsBuy() bool {
|
||||||
|
return d == SideBuy
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d Side) IsSell() bool {
|
||||||
|
return d == SideSell
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
package internal
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
|
|
||||||
"github.com/shopspring/decimal"
|
|
||||||
)
|
|
||||||
|
|
||||||
// StateVersion is the schema version of the persisted State struct.
|
|
||||||
const StateVersion = "2"
|
|
||||||
|
|
||||||
// State is the platform-agnostic representation of the buy-queue state that is
|
|
||||||
// written to disk after a successful run and reloaded on the next run.
|
|
||||||
type State struct {
|
|
||||||
Version string `json:"version"`
|
|
||||||
Platform string `json:"platform"`
|
|
||||||
Queues map[string][]persistedFiller `json:"queues"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// persistedFiller is the on-disk representation of a single Filler. The
|
|
||||||
// Record-specific data is kept as opaque bytes so the internal package does
|
|
||||||
// not need to know about any broker package's concrete Record type.
|
|
||||||
type persistedFiller struct {
|
|
||||||
RecordData json.RawMessage `json:"record_data"`
|
|
||||||
Quantity decimal.Decimal `json:"quantity"`
|
|
||||||
Price decimal.Decimal `json:"price"`
|
|
||||||
Filled decimal.Decimal `json:"filled"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// RecordEncoder encodes a Record into its broker-specific JSON representation.
|
|
||||||
type RecordEncoder interface {
|
|
||||||
MarshalRecord(context.Context, Record) (json.RawMessage, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// RecordDecoder decodes broker-specific JSON back into a Record.
|
|
||||||
type RecordDecoder interface {
|
|
||||||
UnmarshalRecord(context.Context, json.RawMessage) (Record, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// RecordSerializer composes encoding and decoding of Records. Broker packages
|
|
||||||
// own the implementation so that lazy/closured Record fields can be re-wired
|
|
||||||
// on load.
|
|
||||||
type RecordSerializer interface {
|
|
||||||
RecordEncoder
|
|
||||||
RecordDecoder
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package internal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ReportLogger writes a simple, human readable, line to the provided io.Writer for each
|
||||||
|
// ReportItem received.
|
||||||
|
type ReportLogger struct {
|
||||||
|
counter int
|
||||||
|
writer io.Writer
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewStdOutLogger() *ReportLogger {
|
||||||
|
return &ReportLogger{
|
||||||
|
writer: os.Stdout,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewReportLogger(w io.Writer) *ReportLogger {
|
||||||
|
return &ReportLogger{
|
||||||
|
writer: w,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rl *ReportLogger) Write(_ context.Context, ri ReportItem) error {
|
||||||
|
rl.counter++
|
||||||
|
_, err := fmt.Fprintf(rl.writer, "%6d - realised %+f on %s\n", rl.counter, ri.RealisedPnL(), ri.SellTimestamp.Format(time.RFC3339))
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package internal_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"math/big"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/nmoniz/any2anexoj/internal"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestReportLogger_Write(t *testing.T) {
|
||||||
|
tNow := time.Now()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
items []internal.ReportItem
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "empty",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "single item positive",
|
||||||
|
items: []internal.ReportItem{
|
||||||
|
{
|
||||||
|
BuyValue: new(big.Float).SetFloat64(100.0),
|
||||||
|
SellValue: new(big.Float).SetFloat64(200.0),
|
||||||
|
SellTimestamp: tNow,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
want: []string{
|
||||||
|
fmt.Sprintf("%6d - realised +100.000000 on %s\n", 1, tNow.Format(time.RFC3339)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "single item negative",
|
||||||
|
items: []internal.ReportItem{
|
||||||
|
{
|
||||||
|
BuyValue: new(big.Float).SetFloat64(200.0),
|
||||||
|
SellValue: new(big.Float).SetFloat64(150.0),
|
||||||
|
SellTimestamp: tNow,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
want: []string{
|
||||||
|
fmt.Sprintf("%6d - realised -50.000000 on %s\n", 1, tNow.Format(time.RFC3339)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple items",
|
||||||
|
items: []internal.ReportItem{
|
||||||
|
{
|
||||||
|
BuyValue: new(big.Float).SetFloat64(100.0),
|
||||||
|
SellValue: new(big.Float).SetFloat64(200.0),
|
||||||
|
SellTimestamp: tNow,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
BuyValue: new(big.Float).SetFloat64(200.0),
|
||||||
|
SellValue: new(big.Float).SetFloat64(150.0),
|
||||||
|
SellTimestamp: tNow.Add(1),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
want: []string{
|
||||||
|
fmt.Sprintf("%6d - realised +100.000000 on %s\n", 1, tNow.Format(time.RFC3339)),
|
||||||
|
fmt.Sprintf("%6d - realised -50.000000 on %s\n", 2, tNow.Add(1).Format(time.RFC3339)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
buf := new(bytes.Buffer)
|
||||||
|
rw := internal.NewReportLogger(buf)
|
||||||
|
|
||||||
|
for _, item := range tt.items {
|
||||||
|
err := rw.Write(t.Context(), item)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error on write: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, wantLine := range tt.want {
|
||||||
|
gotLine, err := buf.ReadString(byte('\n'))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error on buffer read: %v", err)
|
||||||
|
}
|
||||||
|
if wantLine != gotLine {
|
||||||
|
t.Fatalf("want line %q but got %q", wantLine, gotLine)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
package trading212
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/biter777/countries"
|
|
||||||
)
|
|
||||||
|
|
||||||
const Country = countries.Cyprus
|
|
||||||
+55
-181
@@ -5,108 +5,87 @@ import (
|
|||||||
"encoding/csv"
|
"encoding/csv"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"math/big"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/biter777/countries"
|
|
||||||
"github.com/nmoniz/any2anexoj/internal"
|
"github.com/nmoniz/any2anexoj/internal"
|
||||||
"github.com/nmoniz/any2anexoj/internal/ofigi"
|
|
||||||
"github.com/shopspring/decimal"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type Record struct {
|
type Record struct {
|
||||||
symbol string
|
symbol string
|
||||||
|
side internal.Side
|
||||||
|
quantity *big.Float
|
||||||
|
price *big.Float
|
||||||
timestamp time.Time
|
timestamp time.Time
|
||||||
kind internal.Kind
|
fees *big.Float
|
||||||
quantity decimal.Decimal
|
taxes *big.Float
|
||||||
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 {
|
func (r Record) Symbol() string {
|
||||||
return r.symbol
|
return r.symbol
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r Record) Side() internal.Side {
|
||||||
|
return r.side
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r Record) Quantity() *big.Float {
|
||||||
|
return r.quantity
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r Record) Price() *big.Float {
|
||||||
|
return r.price
|
||||||
|
}
|
||||||
|
|
||||||
func (r Record) Timestamp() time.Time {
|
func (r Record) Timestamp() time.Time {
|
||||||
return r.timestamp
|
return r.timestamp
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r Record) BrokerCountry() int64 {
|
func (r Record) Fees() *big.Float {
|
||||||
return int64(Country)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r Record) AssetCountry() int64 {
|
|
||||||
return int64(countries.ByName(r.Symbol()[:2]).Info().Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r Record) Kind() internal.Kind {
|
|
||||||
return r.kind
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
return r.fees
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r Record) Taxes() decimal.Decimal {
|
func (r Record) Taxes() *big.Float {
|
||||||
return r.taxes
|
return r.taxes
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r Record) Nature() internal.Nature {
|
|
||||||
return r.natureGetter()
|
|
||||||
}
|
|
||||||
|
|
||||||
type RecordReader struct {
|
type RecordReader struct {
|
||||||
reader *csv.Reader
|
reader *csv.Reader
|
||||||
figi *ofigi.Client
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewRecordReader(r io.Reader, f *ofigi.Client) *RecordReader {
|
func NewRecordReader(r io.Reader) *RecordReader {
|
||||||
return &RecordReader{
|
return &RecordReader{
|
||||||
reader: csv.NewReader(r),
|
reader: csv.NewReader(r),
|
||||||
figi: f,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
MarketBuy = "market buy"
|
MarketBuy = "market buy"
|
||||||
MarketSell = "market sell"
|
MarketSell = "market sell"
|
||||||
LimitBuy = "limit buy"
|
LimitBuy = "limit buy"
|
||||||
LimitSell = "limit sell"
|
LimitSell = "limit sell"
|
||||||
StockSplitOpen = "stock split open"
|
|
||||||
StockSplitClose = "stock split close"
|
|
||||||
StokDistribution = "stock distribution"
|
|
||||||
SpinOff = "spin off"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func (rr RecordReader) ReadRecord(ctx context.Context) (internal.Record, error) {
|
func (rr RecordReader) ReadRecord(_ context.Context) (internal.Record, error) {
|
||||||
var splitRec *splitRecord
|
|
||||||
|
|
||||||
for {
|
for {
|
||||||
raw, err := rr.reader.Read()
|
raw, err := rr.reader.Read()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Record{}, fmt.Errorf("read record: %w", err)
|
return Record{}, fmt.Errorf("read record: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.ToLower(raw[0]) == "action" {
|
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
|
continue
|
||||||
|
default:
|
||||||
|
return Record{}, fmt.Errorf("parse record type: %s", raw[0])
|
||||||
}
|
}
|
||||||
|
|
||||||
symbol := raw[2]
|
|
||||||
|
|
||||||
qant, err := parseDecimal(raw[6])
|
qant, err := parseDecimal(raw[6])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Record{}, fmt.Errorf("parse record quantity: %w", err)
|
return Record{}, fmt.Errorf("parse record quantity: %w", err)
|
||||||
@@ -117,157 +96,52 @@ func (rr RecordReader) ReadRecord(ctx context.Context) (internal.Record, error)
|
|||||||
return Record{}, fmt.Errorf("parse record price: %w", err)
|
return Record{}, fmt.Errorf("parse record price: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
ts, err := parseTimestamp(raw[1])
|
ts, err := time.Parse(time.DateTime, raw[1])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Record{}, fmt.Errorf("parse record timestamp: %w", err)
|
return Record{}, fmt.Errorf("parse record timestamp: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
conversionFee, err := parseOptionalDecimal(raw[16])
|
convertionFee, err := parseOptinalDecimal(raw[16])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Record{}, fmt.Errorf("parse record conversion fee: %w", err)
|
return Record{}, fmt.Errorf("parse record convertion fee: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
stampDutyTax, err := parseOptionalDecimal(raw[14])
|
stampDutyTax, err := parseOptinalDecimal(raw[14])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Record{}, fmt.Errorf("parse record stamp duty tax: %w", err)
|
return Record{}, fmt.Errorf("parse record stamp duty tax: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
frenchTxTax, err := parseOptionalDecimal(raw[18])
|
frenchTxTax, err := parseOptinalDecimal(raw[18])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Record{}, fmt.Errorf("parse record french transaction tax: %w", err)
|
return Record{}, fmt.Errorf("parse record french transaction tax: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var kind internal.Kind
|
|
||||||
switch strings.ToLower(raw[0]) {
|
|
||||||
case MarketBuy, LimitBuy:
|
|
||||||
kind = internal.KindBuy
|
|
||||||
case MarketSell, LimitSell:
|
|
||||||
kind = internal.KindSell
|
|
||||||
case StockSplitOpen:
|
|
||||||
if splitRec != nil {
|
|
||||||
return nil, fmt.Errorf("split already open")
|
|
||||||
}
|
|
||||||
|
|
||||||
splitRec = &splitRecord{
|
|
||||||
Record: Record{
|
|
||||||
symbol: symbol,
|
|
||||||
kind: internal.KindSplit,
|
|
||||||
quantity: qant,
|
|
||||||
price: price,
|
|
||||||
fees: conversionFee,
|
|
||||||
taxes: stampDutyTax.Add(frenchTxTax),
|
|
||||||
timestamp: ts,
|
|
||||||
natureGetter: figiNatureGetter(ctx, rr.figi, symbol),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
continue
|
|
||||||
|
|
||||||
case StockSplitClose:
|
|
||||||
if splitRec == nil {
|
|
||||||
return nil, fmt.Errorf("missing split open")
|
|
||||||
}
|
|
||||||
splitRec.ratio = splitRec.Record.Quantity().Div(qant)
|
|
||||||
return splitRec, nil
|
|
||||||
|
|
||||||
case StokDistribution:
|
|
||||||
slog.Warn("Skipping 'stock distribution' event!",
|
|
||||||
slog.Any("ISIN", symbol),
|
|
||||||
slog.Any("Quantity", qant),
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
case SpinOff:
|
|
||||||
slog.Warn("Skipping 'spin off' event!",
|
|
||||||
slog.Any("ISIN", symbol),
|
|
||||||
slog.Any("Quantity", qant),
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
default:
|
|
||||||
return Record{}, fmt.Errorf("parse record type: %s", raw[0])
|
|
||||||
}
|
|
||||||
|
|
||||||
return Record{
|
return Record{
|
||||||
symbol: symbol,
|
symbol: raw[2],
|
||||||
kind: kind,
|
side: side,
|
||||||
quantity: qant,
|
quantity: qant,
|
||||||
price: price,
|
price: price,
|
||||||
fees: conversionFee,
|
timestamp: ts,
|
||||||
taxes: stampDutyTax.Add(frenchTxTax),
|
fees: convertionFee,
|
||||||
timestamp: ts,
|
taxes: new(big.Float).Add(stampDutyTax, frenchTxTax),
|
||||||
natureGetter: figiNatureGetter(ctx, rr.figi, symbol),
|
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func figiNatureGetter(ctx context.Context, of *ofigi.Client, 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", "ADR", "REIT":
|
|
||||||
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.
|
// 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.
|
// Using this function helps avoid issues around converting values due to sligh parameter changes.
|
||||||
func parseDecimal(s string) (decimal.Decimal, error) {
|
func parseDecimal(s string) (*big.Float, error) {
|
||||||
return decimal.NewFromString(s)
|
f, _, err := big.ParseFloat(s, 10, 128, big.ToZero)
|
||||||
|
return f, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseOptionalDecimal behaves the same as parseDecimal but returns 0 when len(s) is 0 instead of
|
// parseOptinalDecimal behaves the same as parseDecimal but returns 0 when len(s) is 0 instead of
|
||||||
// error.
|
// error.
|
||||||
// Using this function helps avoid issues around converting values due to minor parameter changes.
|
// Using this function helps avoid issues around converting values due to sligh parameter changes.
|
||||||
func parseOptionalDecimal(s string) (decimal.Decimal, error) {
|
func parseOptinalDecimal(s string) (*big.Float, error) {
|
||||||
if len(s) == 0 {
|
if len(s) == 0 {
|
||||||
return decimal.Decimal{}, nil
|
return new(big.Float), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return parseDecimal(s)
|
return parseDecimal(s)
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseTimestamp parses a Trading212 timestamp string. Trading212 has shipped
|
|
||||||
// rows both without a timezone (treated here as UTC) and with an explicit
|
|
||||||
// numeric offset (e.g. "+00:00", "-05:00", "+01:00") or a "Z" UTC marker.
|
|
||||||
// The layouts are tried in order; the first match wins.
|
|
||||||
func parseTimestamp(s string) (time.Time, error) {
|
|
||||||
layouts := [3]string{
|
|
||||||
"2006-01-02 15:04:05-07:00", // numeric offset
|
|
||||||
time.DateTime, // "2006-01-02 15:04:05"
|
|
||||||
"2006-01-02 15:04:05Z", // explicit UTC marker
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
lastErr error
|
|
||||||
ts time.Time
|
|
||||||
)
|
|
||||||
for _, layout := range layouts {
|
|
||||||
ts, lastErr = time.Parse(layout, s)
|
|
||||||
if lastErr == nil {
|
|
||||||
return ts, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return time.Time{}, lastErr
|
|
||||||
}
|
|
||||||
|
|
||||||
type splitRecord struct {
|
|
||||||
Record
|
|
||||||
|
|
||||||
ratio decimal.Decimal
|
|
||||||
}
|
|
||||||
|
|
||||||
func (sr splitRecord) Quantity() decimal.Decimal {
|
|
||||||
return sr.ratio
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,16 +2,12 @@ package trading212
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"math/big"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/nmoniz/any2anexoj/internal"
|
"github.com/nmoniz/any2anexoj/internal"
|
||||||
"github.com/nmoniz/any2anexoj/internal/ofigi"
|
|
||||||
"github.com/shopspring/decimal"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestRecordReader_ReadRecord(t *testing.T) {
|
func TestRecordReader_ReadRecord(t *testing.T) {
|
||||||
@@ -29,119 +25,74 @@ func TestRecordReader_ReadRecord(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "well-formed buy",
|
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",,`),
|
r: bytes.NewBufferString(`Market buy,2025-07-03 10:44:29,SYM123456ABXY,ABXY,"Aspargus Brocoli",EOF987654321,2.4387014200,7.3690000000,USD,1.17995999,,"EUR",15.25,"EUR",0.25,"EUR",0.02,"EUR",,`),
|
||||||
want: Record{
|
want: Record{
|
||||||
symbol: "XX1234567890",
|
symbol: "SYM123456ABXY",
|
||||||
kind: internal.KindBuy,
|
side: internal.SideBuy,
|
||||||
quantity: ShouldParseDecimal(t, "2.4387014200"),
|
quantity: ShouldParseDecimal(t, "2.4387014200"),
|
||||||
price: ShouldParseDecimal(t, "7.3690000000"),
|
price: ShouldParseDecimal(t, "7.3690000000"),
|
||||||
timestamp: time.Date(2025, 7, 3, 10, 44, 29, 0, time.UTC),
|
timestamp: time.Date(2025, 7, 3, 10, 44, 29, 0, time.UTC),
|
||||||
fees: ShouldParseDecimal(t, "0.02"),
|
fees: ShouldParseDecimal(t, "0.02"),
|
||||||
taxes: ShouldParseDecimal(t, "0.25"),
|
taxes: ShouldParseDecimal(t, "0.25"),
|
||||||
natureGetter: func() internal.Nature { return internal.NatureG01 },
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "well-formed sell",
|
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"`),
|
r: bytes.NewBufferString(`Market sell,2025-08-04 11:45:30,IE000GA3D489,ABXY,"Aspargus Brocoli",EOF987654321,2.4387014200,7.9999999999,USD,1.17995999,,"EUR",15.25,"EUR",,,0.02,"EUR",0.1,"EUR"`),
|
||||||
want: Record{
|
want: Record{
|
||||||
symbol: "XX1234567890",
|
symbol: "IE000GA3D489",
|
||||||
kind: internal.KindSell,
|
side: internal.SideSell,
|
||||||
quantity: ShouldParseDecimal(t, "2.4387014200"),
|
quantity: ShouldParseDecimal(t, "2.4387014200"),
|
||||||
price: ShouldParseDecimal(t, "7.9999999999"),
|
price: ShouldParseDecimal(t, "7.9999999999"),
|
||||||
timestamp: time.Date(2025, 8, 4, 11, 45, 30, 0, time.UTC),
|
timestamp: time.Date(2025, 8, 4, 11, 45, 30, 0, time.UTC),
|
||||||
fees: ShouldParseDecimal(t, "0.02"),
|
fees: ShouldParseDecimal(t, "0.02"),
|
||||||
taxes: ShouldParseDecimal(t, "0.1"),
|
taxes: ShouldParseDecimal(t, "0.1"),
|
||||||
natureGetter: func() internal.Nature { return internal.NatureG01 },
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "malformed side",
|
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",,`),
|
r: bytes.NewBufferString(`Aljksdaf Balsjdkf,2025-08-04 11:45:39,IE000GA3D489,ABXY,"Aspargus Brocoli",EOF987654321,2.4387014200,7.9999999999,USD,1.17995999,,"EUR",15.25,"EUR",,,0.02,"EUR",,`),
|
||||||
wantErr: true,
|
wantErr: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "empty side",
|
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",,`),
|
r: bytes.NewBufferString(`,2025-08-04 11:45:39,IE000GA3D489,ABXY,"Aspargus Brocoli",EOF987654321,0x1234,7.9999999999,USD,1.17995999,,"EUR",15.25,"EUR",,,0.02,"EUR",,`),
|
||||||
wantErr: true,
|
wantErr: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "malformed qantity",
|
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",,`),
|
r: bytes.NewBufferString(`Market sell,2025-08-04 11:45:39,IE000GA3D489,ABXY,"Aspargus Brocoli",EOF987654321,0x1234,7.9999999999,USD,1.17995999,,"EUR",15.25,"EUR",,,0.02,"EUR",,`),
|
||||||
wantErr: true,
|
wantErr: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "empty qantity",
|
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",,`),
|
r: bytes.NewBufferString(`Market sell,2025-08-04 11:45:39,IE000GA3D489,ABXY,"Aspargus Brocoli",EOF987654321,,7.9999999999,USD,1.17995999,,"EUR",15.25,"EUR",,,0.02,"EUR",,`),
|
||||||
wantErr: true,
|
wantErr: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "malformed price",
|
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",,`),
|
r: bytes.NewBufferString(`Market sell,2025-08-04 11:45:39,IE000GA3D489,ABXY,"Aspargus Brocoli",EOF987654321,2.4387014200,0b101010,USD,1.17995999,,"EUR",15.25,"EUR",,,0.02,"EUR",,`),
|
||||||
wantErr: true,
|
wantErr: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "empty price",
|
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",,`),
|
r: bytes.NewBufferString(`Market sell,2025-08-04 11:45:39,IE000GA3D489,ABXY,"Aspargus Brocoli",EOF987654321,2.4387014200,,USD,1.17995999,,"EUR",15.25,"EUR",,,0.02,"EUR",,`),
|
||||||
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,
|
wantErr: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "malformed timestamp",
|
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",,`),
|
r: bytes.NewBufferString(`Market sell,2006-01-02T15:04:05Z07:00,IE000GA3D489,ABXY,"Aspargus Brocoli",EOF987654321,2.4387014200,7.9999999999,USD,1.17995999,,"EUR",15.25,"EUR",,,0.02,"EUR",,`),
|
||||||
wantErr: true,
|
wantErr: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "empty timestamp",
|
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",,`),
|
r: bytes.NewBufferString(`Market sell,,IE000GA3D489,ABXY,"Aspargus Brocoli",EOF987654321,2.4387014200,7.9999999999,USD,1.17995999,,"EUR",15.25,"EUR",,,0.02,"EUR",,`),
|
||||||
wantErr: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "well-formed buy with new offset format +00:00",
|
|
||||||
r: bytes.NewBufferString(`Market buy,2025-07-03 10:44:29+00:00,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",
|
|
||||||
kind: internal.KindBuy,
|
|
||||||
quantity: ShouldParseDecimal(t, "2.4387014200"),
|
|
||||||
price: ShouldParseDecimal(t, "7.3690000000"),
|
|
||||||
timestamp: time.Date(2025, 7, 3, 10, 44, 29, 0, time.FixedZone("UTC", 0)),
|
|
||||||
fees: ShouldParseDecimal(t, "0.02"),
|
|
||||||
taxes: ShouldParseDecimal(t, "0.25"),
|
|
||||||
natureGetter: func() internal.Nature { return internal.NatureG01 },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "well-formed buy with new offset format +01:00",
|
|
||||||
r: bytes.NewBufferString(`Market buy,2025-07-03 10:44:29+01:00,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",
|
|
||||||
kind: internal.KindBuy,
|
|
||||||
quantity: ShouldParseDecimal(t, "2.4387014200"),
|
|
||||||
price: ShouldParseDecimal(t, "7.3690000000"),
|
|
||||||
timestamp: time.Date(2025, 7, 3, 10, 44, 29, 0, time.FixedZone("+01:00", 1*60*60)),
|
|
||||||
fees: ShouldParseDecimal(t, "0.02"),
|
|
||||||
taxes: ShouldParseDecimal(t, "0.25"),
|
|
||||||
natureGetter: func() internal.Nature { return internal.NatureG01 },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "well-formed buy with garbage timestamp returns error",
|
|
||||||
r: bytes.NewBufferString(`Market buy,not-a-timestamp,IE000GA3D489,ABXY,"Aspargus Broccoli",EOF987654321,2.4387014200,7.3690000000,USD,1.17995999,,"EUR",15.25,"EUR",0.25,"EUR",0.02,"EUR",,`),
|
|
||||||
wantErr: true,
|
wantErr: true,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
rr := NewRecordReader(tt.r, NewFigiClientSecurityTypeStub(t, "Common Stock"))
|
rr := NewRecordReader(tt.r)
|
||||||
got, gotErr := rr.ReadRecord(t.Context())
|
got, gotErr := rr.ReadRecord(t.Context())
|
||||||
if gotErr != nil {
|
if gotErr != nil {
|
||||||
if !tt.wantErr {
|
if !tt.wantErr {
|
||||||
@@ -158,8 +109,8 @@ func TestRecordReader_ReadRecord(t *testing.T) {
|
|||||||
t.Fatalf("want symbol %v but got %v", tt.want.symbol, got.Symbol())
|
t.Fatalf("want symbol %v but got %v", tt.want.symbol, got.Symbol())
|
||||||
}
|
}
|
||||||
|
|
||||||
if got.Kind() != tt.want.kind {
|
if got.Side() != tt.want.side {
|
||||||
t.Fatalf("want side %v but got %v", tt.want.kind, got.Kind())
|
t.Fatalf("want side %v but got %v", tt.want.side, got.Side())
|
||||||
}
|
}
|
||||||
|
|
||||||
if got.Price().Cmp(tt.want.price) != 0 {
|
if got.Price().Cmp(tt.want.price) != 0 {
|
||||||
@@ -181,188 +132,11 @@ func TestRecordReader_ReadRecord(t *testing.T) {
|
|||||||
if got.Taxes().Cmp(tt.want.taxes) != 0 {
|
if got.Taxes().Cmp(tt.want.taxes) != 0 {
|
||||||
t.Fatalf("want taxes %v but got %v", tt.want.taxes, got.Taxes())
|
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 TestRecordReader_ReadRecord_Split(t *testing.T) {
|
func ShouldParseDecimal(t testing.TB, sf string) *big.Float {
|
||||||
// open row has the NEW (post-split) position: more shares at lower price
|
|
||||||
// close row has the OLD (pre-split) position: fewer shares at higher price
|
|
||||||
// ratio = openQty / closeQty = 0.5 / 0.1 = 5 (a 5:1 split)
|
|
||||||
splitOpen := `Stock split open,2025-06-03 05:34:16,XX1234567890,ABXY,"Aspargus Broccoli",EOF111111111,0.5000000000,20.0000000000,EUR,1.00000000,,,10.00,"EUR",,,,,,`
|
|
||||||
splitClose := `Stock split close,2025-06-03 05:34:16,XX1234567890,ABXY,"Aspargus Broccoli",EOF222222222,0.1000000000,100.0000000000,EUR,1.00000000,0.00,"EUR",10.00,"EUR",,,,,,`
|
|
||||||
|
|
||||||
t.Run("well-formed split pair returns split record with correct ratio", func(t *testing.T) {
|
|
||||||
rr := NewRecordReader(
|
|
||||||
bytes.NewBufferString(splitOpen+"\n"+splitClose),
|
|
||||||
NewFigiClientSecurityTypeStub(t, "Common Stock"),
|
|
||||||
)
|
|
||||||
|
|
||||||
got, err := rr.ReadRecord(t.Context())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ReadRecord() failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if got.Kind() != internal.KindSplit {
|
|
||||||
t.Errorf("want kind %v but got %v", internal.KindSplit, got.Kind())
|
|
||||||
}
|
|
||||||
if got.Symbol() != "XX1234567890" {
|
|
||||||
t.Errorf("want symbol NO0013536151 but got %v", got.Symbol())
|
|
||||||
}
|
|
||||||
|
|
||||||
wantTimestamp := time.Date(2025, 6, 3, 5, 34, 16, 0, time.UTC)
|
|
||||||
if !got.Timestamp().Equal(wantTimestamp) {
|
|
||||||
t.Errorf("want timestamp %v but got %v", wantTimestamp, got.Timestamp())
|
|
||||||
}
|
|
||||||
|
|
||||||
// ratio = openQty / closeQty = 0.1245045 / 0.0249009 ≈ 5
|
|
||||||
openQty := ShouldParseDecimal(t, "0.1245045000")
|
|
||||||
closeQty := ShouldParseDecimal(t, "0.0249009000")
|
|
||||||
wantRatio := openQty.Div(closeQty)
|
|
||||||
if !got.Quantity().Equal(wantRatio) {
|
|
||||||
t.Errorf("want ratio %v but got %v", wantRatio, got.Quantity())
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("close without prior open errors", func(t *testing.T) {
|
|
||||||
rr := NewRecordReader(
|
|
||||||
bytes.NewBufferString(splitClose),
|
|
||||||
NewFigiClientSecurityTypeStub(t, "Common Stock"),
|
|
||||||
)
|
|
||||||
|
|
||||||
_, err := rr.ReadRecord(t.Context())
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected error but got none")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("two opens without close errors", func(t *testing.T) {
|
|
||||||
rr := NewRecordReader(
|
|
||||||
bytes.NewBufferString(splitOpen+"\n"+splitOpen),
|
|
||||||
NewFigiClientSecurityTypeStub(t, "Common Stock"),
|
|
||||||
)
|
|
||||||
|
|
||||||
_, err := rr.ReadRecord(t.Context())
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected error but got none")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseTimestamp(t *testing.T) {
|
|
||||||
utc := time.FixedZone("UTC", 0)
|
|
||||||
plusOne := time.FixedZone("+01:00", 1*60*60)
|
|
||||||
minusFive := time.FixedZone("-05:00", -5*60*60)
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
input string
|
|
||||||
want time.Time
|
|
||||||
wantErr bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "legacy format without offset parses as UTC",
|
|
||||||
input: "2025-05-22 13:15:52",
|
|
||||||
want: time.Date(2025, 5, 22, 13, 15, 52, 0, time.UTC),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "new format with +00:00 offset",
|
|
||||||
input: "2025-05-22 13:15:52+00:00",
|
|
||||||
want: time.Date(2025, 5, 22, 13, 15, 52, 0, utc),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "new format with +01:00 offset",
|
|
||||||
input: "2025-05-22 13:15:52+01:00",
|
|
||||||
want: time.Date(2025, 5, 22, 13, 15, 52, 0, plusOne),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "new format with -05:00 offset",
|
|
||||||
input: "2025-05-22 13:15:52-05:00",
|
|
||||||
want: time.Date(2025, 5, 22, 13, 15, 52, 0, minusFive),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "explicit Z UTC marker",
|
|
||||||
input: "2025-05-22 13:15:52Z",
|
|
||||||
want: time.Date(2025, 5, 22, 13, 15, 52, 0, time.UTC),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "garbage input returns error",
|
|
||||||
input: "not-a-timestamp",
|
|
||||||
wantErr: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "RFC3339 with T separator is rejected",
|
|
||||||
input: "2025-05-22T13:15:52Z",
|
|
||||||
wantErr: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "empty string returns error",
|
|
||||||
input: "",
|
|
||||||
wantErr: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
got, err := parseTimestamp(tt.input)
|
|
||||||
if err != nil {
|
|
||||||
if !tt.wantErr {
|
|
||||||
t.Fatalf("parseTimestamp(%q) returned error: %v", tt.input, err)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if tt.wantErr {
|
|
||||||
t.Fatalf("parseTimestamp(%q) expected an error", tt.input)
|
|
||||||
}
|
|
||||||
if !got.Equal(tt.want) {
|
|
||||||
t.Fatalf("parseTimestamp(%q) = %v, want %v", tt.input, got, tt.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func Test_figiNatureGetter(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string // description of this test case
|
|
||||||
of *ofigi.Client
|
|
||||||
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()
|
t.Helper()
|
||||||
|
|
||||||
bf, err := parseDecimal(sf)
|
bf, err := parseDecimal(sf)
|
||||||
@@ -371,70 +145,3 @@ func ShouldParseDecimal(t testing.TB, sf string) decimal.Decimal {
|
|||||||
}
|
}
|
||||||
return bf
|
return bf
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRecordReader_ReadRecord_SkipsCorporateActions(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
row string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "spin off",
|
|
||||||
row: `Spin off,2026-02-11 13:41:43+00:00,US9418481035,WAT,"Waters",EOF46451921527,0.0406450000,0E-10,USD,,,,0.00,"EUR",,,,,,`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "stock distribution",
|
|
||||||
row: `Stock distribution,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",,`,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
rr := NewRecordReader(
|
|
||||||
bytes.NewBufferString(tt.row),
|
|
||||||
NewFigiClientSecurityTypeStub(t, "Common Stock"),
|
|
||||||
)
|
|
||||||
|
|
||||||
_, err := rr.ReadRecord(t.Context())
|
|
||||||
if !errors.Is(err, io.EOF) {
|
|
||||||
t.Fatalf("want EOF after skipping %s, got: %v", tt.name, err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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) *ofigi.Client {
|
|
||||||
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 ofigi.NewOpenFIGI(c, "")
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewFigiClientErrorStub(t testing.TB, err error) *ofigi.Client {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
c := &http.Client{
|
|
||||||
Timeout: time.Second,
|
|
||||||
Transport: RoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
||||||
return nil, err
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
|
|
||||||
return ofigi.NewOpenFIGI(c, "")
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,88 +0,0 @@
|
|||||||
package trading212
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/nmoniz/any2anexoj/internal"
|
|
||||||
"github.com/nmoniz/any2anexoj/internal/ofigi"
|
|
||||||
"github.com/shopspring/decimal"
|
|
||||||
)
|
|
||||||
|
|
||||||
// recordState is the on-disk representation of a trading212.Record.
|
|
||||||
// Nature is intentionally omitted because it is resolved lazily via the
|
|
||||||
// OpenFIGI client on demand.
|
|
||||||
type recordState struct {
|
|
||||||
Symbol string `json:"symbol"`
|
|
||||||
Timestamp time.Time `json:"timestamp"`
|
|
||||||
Kind internal.Kind `json:"kind"`
|
|
||||||
Quantity decimal.Decimal `json:"quantity"`
|
|
||||||
Price decimal.Decimal `json:"price"`
|
|
||||||
Fees decimal.Decimal `json:"fees"`
|
|
||||||
Taxes decimal.Decimal `json:"taxes"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// RecordSerializer encodes and decodes trading212.Record values for the
|
|
||||||
// internal persistence layer. On decode it re-wires the lazy natureGetter to
|
|
||||||
// the supplied OpenFIGI client so a restored Record resolves Nature() via a
|
|
||||||
// fresh API call when needed.
|
|
||||||
type RecordSerializer struct {
|
|
||||||
figi *ofigi.Client
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewRecordSerializer returns a RecordSerializer that uses figi to resolve
|
|
||||||
// Record.Nature() on load.
|
|
||||||
func NewRecordSerializer(figi *ofigi.Client) *RecordSerializer {
|
|
||||||
return &RecordSerializer{figi: figi}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MarshalRecord encodes the given internal.Record as JSON. It returns an
|
|
||||||
// error if the record is not a trading212.Record (or a *trading212.Record).
|
|
||||||
func (s *RecordSerializer) MarshalRecord(_ context.Context, r internal.Record) (json.RawMessage, error) {
|
|
||||||
var rec Record
|
|
||||||
switch v := r.(type) {
|
|
||||||
case Record:
|
|
||||||
rec = v
|
|
||||||
case *Record:
|
|
||||||
if v == nil {
|
|
||||||
return nil, fmt.Errorf("trading212: cannot marshal nil *trading212.Record")
|
|
||||||
}
|
|
||||||
rec = *v
|
|
||||||
default:
|
|
||||||
return nil, fmt.Errorf("trading212: cannot marshal %T as trading212.Record", r)
|
|
||||||
}
|
|
||||||
|
|
||||||
state := recordState{
|
|
||||||
Symbol: rec.symbol,
|
|
||||||
Timestamp: rec.timestamp,
|
|
||||||
Kind: rec.kind,
|
|
||||||
Quantity: rec.quantity,
|
|
||||||
Price: rec.price,
|
|
||||||
Fees: rec.fees,
|
|
||||||
Taxes: rec.taxes,
|
|
||||||
}
|
|
||||||
|
|
||||||
return json.Marshal(state)
|
|
||||||
}
|
|
||||||
|
|
||||||
// UnmarshalRecord decodes a JSON-encoded trading212.Record and re-wires its
|
|
||||||
// natureGetter to the serializer's OpenFIGI client.
|
|
||||||
func (s *RecordSerializer) UnmarshalRecord(ctx context.Context, data json.RawMessage) (internal.Record, error) {
|
|
||||||
var state recordState
|
|
||||||
if err := json.Unmarshal(data, &state); err != nil {
|
|
||||||
return nil, fmt.Errorf("unmarshal trading212 record: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return Record{
|
|
||||||
symbol: state.Symbol,
|
|
||||||
timestamp: state.Timestamp,
|
|
||||||
kind: state.Kind,
|
|
||||||
quantity: state.Quantity,
|
|
||||||
price: state.Price,
|
|
||||||
fees: state.Fees,
|
|
||||||
taxes: state.Taxes,
|
|
||||||
natureGetter: figiNatureGetter(ctx, s.figi, state.Symbol),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
@@ -1,151 +0,0 @@
|
|||||||
package trading212
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/nmoniz/any2anexoj/internal"
|
|
||||||
"github.com/nmoniz/any2anexoj/internal/ofigi"
|
|
||||||
"github.com/shopspring/decimal"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestRecordSerializer_RoundTrip(t *testing.T) {
|
|
||||||
want := Record{
|
|
||||||
symbol: "XX1234567890",
|
|
||||||
timestamp: time.Date(2025, 7, 3, 10, 44, 29, 0, time.UTC),
|
|
||||||
kind: internal.KindBuy,
|
|
||||||
quantity: ShouldParseDecimal(t, "2.4387014200"),
|
|
||||||
price: ShouldParseDecimal(t, "7.3690000000"),
|
|
||||||
fees: ShouldParseDecimal(t, "0.02"),
|
|
||||||
taxes: ShouldParseDecimal(t, "0.25"),
|
|
||||||
natureGetter: func() internal.Nature { return internal.NatureG01 },
|
|
||||||
}
|
|
||||||
|
|
||||||
s := NewRecordSerializer(NewFigiClientSecurityTypeStub(t, "Common Stock"))
|
|
||||||
|
|
||||||
data, err := s.MarshalRecord(t.Context(), want)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("MarshalRecord: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
got, err := s.UnmarshalRecord(t.Context(), data)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("UnmarshalRecord: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if got.Symbol() != want.Symbol() {
|
|
||||||
t.Errorf("Symbol: want %q but got %q", want.Symbol(), got.Symbol())
|
|
||||||
}
|
|
||||||
if got.Kind() != want.Kind() {
|
|
||||||
t.Errorf("Kind: want %v but got %v", want.Kind(), got.Kind())
|
|
||||||
}
|
|
||||||
if !got.Price().Equal(want.Price()) {
|
|
||||||
t.Errorf("Price: want %v but got %v", want.Price(), got.Price())
|
|
||||||
}
|
|
||||||
if !got.Quantity().Equal(want.Quantity()) {
|
|
||||||
t.Errorf("Quantity: want %v but got %v", want.Quantity(), got.Quantity())
|
|
||||||
}
|
|
||||||
if !got.Fees().Equal(want.Fees()) {
|
|
||||||
t.Errorf("Fees: want %v but got %v", want.Fees(), got.Fees())
|
|
||||||
}
|
|
||||||
if !got.Taxes().Equal(want.Taxes()) {
|
|
||||||
t.Errorf("Taxes: want %v but got %v", want.Taxes(), got.Taxes())
|
|
||||||
}
|
|
||||||
if !got.Timestamp().Equal(want.Timestamp()) {
|
|
||||||
t.Errorf("Timestamp: want %v but got %v", want.Timestamp(), got.Timestamp())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRecordSerializer_UnmarshalRecord_NatureTriggersOpenFIGI(t *testing.T) {
|
|
||||||
var calls int
|
|
||||||
client := &http.Client{
|
|
||||||
Timeout: time.Second,
|
|
||||||
Transport: RoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
||||||
calls++
|
|
||||||
return &http.Response{
|
|
||||||
Status: http.StatusText(http.StatusOK),
|
|
||||||
StatusCode: http.StatusOK,
|
|
||||||
Body: io.NopCloser(bytes.NewBufferString(`[{"data":[{"securityType":"Common Stock"}]}]`)),
|
|
||||||
Request: req,
|
|
||||||
}, nil
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
|
|
||||||
s := NewRecordSerializer(ofigi.NewOpenFIGI(client, ""))
|
|
||||||
|
|
||||||
original := Record{
|
|
||||||
symbol: "XX1234567890",
|
|
||||||
timestamp: time.Date(2025, 7, 3, 10, 44, 29, 0, time.UTC),
|
|
||||||
kind: internal.KindBuy,
|
|
||||||
quantity: ShouldParseDecimal(t, "2.4387014200"),
|
|
||||||
price: ShouldParseDecimal(t, "7.3690000000"),
|
|
||||||
fees: ShouldParseDecimal(t, "0.02"),
|
|
||||||
taxes: ShouldParseDecimal(t, "0.25"),
|
|
||||||
// Pre-populated so MarshalRecord doesn't accidentally trigger an
|
|
||||||
// OpenFIGI call when encoding the original record.
|
|
||||||
natureGetter: func() internal.Nature { return internal.NatureG01 },
|
|
||||||
}
|
|
||||||
|
|
||||||
data, err := s.MarshalRecord(t.Context(), original)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("MarshalRecord: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if calls != 0 {
|
|
||||||
t.Fatalf("OpenFIGI called during MarshalRecord: %d", calls)
|
|
||||||
}
|
|
||||||
|
|
||||||
got, err := s.UnmarshalRecord(t.Context(), data)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("UnmarshalRecord: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Nature must not have been resolved yet — natureGetter is lazy.
|
|
||||||
if calls != 0 {
|
|
||||||
t.Fatalf("OpenFIGI called before Nature(): %d", calls)
|
|
||||||
}
|
|
||||||
|
|
||||||
if nature := got.Nature(); nature != internal.NatureG01 {
|
|
||||||
t.Errorf("Nature: want %v but got %v", internal.NatureG01, nature)
|
|
||||||
}
|
|
||||||
|
|
||||||
if calls != 1 {
|
|
||||||
t.Errorf("OpenFIGI request count: want 1 but got %d", calls)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Subsequent Nature() calls should not re-trigger the request (the
|
|
||||||
// underlying sync.OnceValue caches the result on the client too).
|
|
||||||
if nature := got.Nature(); nature != internal.NatureG01 {
|
|
||||||
t.Errorf("Nature (cached): want %v but got %v", internal.NatureG01, nature)
|
|
||||||
}
|
|
||||||
if calls != 1 {
|
|
||||||
t.Errorf("OpenFIGI request count after re-read: want 1 but got %d", calls)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRecordSerializer_MarshalRecord_WrongType(t *testing.T) {
|
|
||||||
s := NewRecordSerializer(NewFigiClientSecurityTypeStub(t, "Common Stock"))
|
|
||||||
|
|
||||||
_, err := s.MarshalRecord(t.Context(), stubRecord{})
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("want error but got nil")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// stubRecord is a non-trading212 implementation of internal.Record used to
|
|
||||||
// verify that MarshalRecord rejects unrelated record types.
|
|
||||||
type stubRecord struct{}
|
|
||||||
|
|
||||||
func (stubRecord) Symbol() string { return "STUB" }
|
|
||||||
func (stubRecord) Nature() internal.Nature { return internal.NatureUnknown }
|
|
||||||
func (stubRecord) BrokerCountry() int64 { return 0 }
|
|
||||||
func (stubRecord) AssetCountry() int64 { return 0 }
|
|
||||||
func (stubRecord) Kind() internal.Kind { return internal.KindUnknown }
|
|
||||||
func (stubRecord) Price() decimal.Decimal { return decimal.Zero }
|
|
||||||
func (stubRecord) Quantity() decimal.Decimal { return decimal.Zero }
|
|
||||||
func (stubRecord) Timestamp() time.Time { return time.Time{} }
|
|
||||||
func (stubRecord) Fees() decimal.Decimal { return decimal.Zero }
|
|
||||||
func (stubRecord) Taxes() decimal.Decimal { return decimal.Zero }
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
Copyright (c) 2019 Biter, biter2004@yandex.ru. 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.
|
|
||||||
|
|
||||||
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.
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
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.
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
MIT License
|
|
||||||
|
|
||||||
Copyright (c) 2018 jedib0t
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
|
||||||
copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
SOFTWARE.
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
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.
|
|
||||||
@@ -1,202 +0,0 @@
|
|||||||
|
|
||||||
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.
|
|
||||||
Reference in New Issue
Block a user