Compare commits
2
Commits
main
..
0b15a339e0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b15a339e0 | ||
|
|
d913e5fb53 |
@@ -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,41 +1,19 @@
|
||||
name: Generate check
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- '**/*_gen.go'
|
||||
pull_request:
|
||||
types: [opened, reopened, synchronize]
|
||||
paths:
|
||||
- '**/*_gen.go'
|
||||
|
||||
jobs:
|
||||
check-changes:
|
||||
check-generate:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
has_gen_changes: ${{ steps.check.outputs.has_gen_changes }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Check for generated file changes
|
||||
id: check
|
||||
run: |
|
||||
if git diff --name-only origin/${{ github.base_ref }}...HEAD | grep -E '_gen\.go$|/generate\.go$'; then
|
||||
echo "has_gen_changes=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "has_gen_changes=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
verify-generate:
|
||||
runs-on: ubuntu-latest
|
||||
needs: check-changes
|
||||
if: needs.check-changes.outputs.has_gen_changes == 'true'
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v4
|
||||
with:
|
||||
go-version: 1.25
|
||||
|
||||
- name: Save pre-generate git state
|
||||
run: git status --porcelain > pre-generate.txt
|
||||
|
||||
@@ -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
|
||||
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,30 +1,23 @@
|
||||
# any2anexoj
|
||||
# broker2anexoj
|
||||
|
||||
[](https://coveralls.io/github/nmoniz/any2anexoj?branch=main)
|
||||
|
||||
<p align="center">
|
||||
<img src="https://i.ibb.co/0yRtwq2C/0-FBA40-FD-D97-A-4-AFB-8618-49582-DB98-F3-C.png" alt="Screenshot" border="0">
|
||||
</p>
|
||||
|
||||
This tool converts the statements from known brokers and exchanges into a format compatible with section 9 from the Portuguese IRS form: [Mod_3_anexo_j](https://info.portaldasfinancas.gov.pt/pt/apoio_contribuinte/modelos_formularios/irs/Documents/Mod_3_anexo_J.pdf)
|
||||
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)
|
||||
|
||||
> [!WARNING]
|
||||
> Although I made significant efforts to ensure the correctness of the calculations you should verify any outputs produced by this tool on your own or with a certified accountant.
|
||||
|
||||
> [!NOTE]
|
||||
> This tool is in early stages of development. Use at your own risk!
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
go install github.com/nmoniz/any2anexoj/cmd/any2anexoj-cli@latest
|
||||
go install git.naterciomoniz.net/applications/broker2anexoj@latest
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
cat statement.csv | any2anexoj-cli --platform=tranding212
|
||||
broker2anexoj
|
||||
```
|
||||
|
||||
## 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"time"
|
||||
|
||||
"github.com/nmoniz/any2anexoj/internal"
|
||||
"github.com/nmoniz/any2anexoj/internal/ofigi"
|
||||
"github.com/nmoniz/any2anexoj/internal/trading212"
|
||||
"github.com/spf13/pflag"
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
var (
|
||||
// TODO: once we support more brokers or exchanges we should make this parameter required and
|
||||
// remove/change default
|
||||
platform = pflag.StringP("platform", "p", "trading212", "One of the supported platforms")
|
||||
lang = pflag.StringP("language", "l", language.Portuguese.String(), "The 2 letter language code")
|
||||
debug = pflag.BoolP("debug", "d", false, "Activate to log debug messages")
|
||||
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() {
|
||||
pflag.Parse()
|
||||
|
||||
err := run(context.Background())
|
||||
if err != nil {
|
||||
slog.Error("found a fatal issue", slog.Any("err", err))
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// run is the production entry point. It wires the CLI flags to runWithIO
|
||||
// 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)
|
||||
defer cancel()
|
||||
return runWithIO(ctx, os.Stdin, os.Stdout)
|
||||
}
|
||||
|
||||
// runWithIO is the testable core of the CLI. It reads broker records from
|
||||
// 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 {
|
||||
return fmt.Errorf("--platform flag is required")
|
||||
}
|
||||
|
||||
if lang == nil || len(*lang) == 0 {
|
||||
return fmt.Errorf("--language flag is required")
|
||||
}
|
||||
|
||||
figiClient := ofigi.NewOpenFIGI(&http.Client{Timeout: 5 * time.Second}, *ofAPIKey)
|
||||
|
||||
reader, err := getReader(*platform, stdin, figiClient)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting reader: %w", err)
|
||||
}
|
||||
|
||||
store, err := buildStore(*stateFile, *platform, figiClient)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
writer := internal.NewAggregatorWriter()
|
||||
|
||||
selector, err := internal.ParseSelectors(*selectors)
|
||||
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 FileStore 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.NewFileStore(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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/signal"
|
||||
|
||||
"git.naterciomoniz.net/applications/broker2anexoj/internal"
|
||||
"git.naterciomoniz.net/applications/broker2anexoj/internal/trading212"
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
func main() {
|
||||
err := run(context.Background())
|
||||
if err != nil {
|
||||
slog.Error("found a fatal issue", slog.Any("err", err))
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run(ctx context.Context) error {
|
||||
ctx, cancel := signal.NotifyContext(ctx, os.Kill, os.Interrupt)
|
||||
defer cancel()
|
||||
|
||||
eg, ctx := errgroup.WithContext(ctx)
|
||||
|
||||
f, err := os.Open("test.csv")
|
||||
if err != nil {
|
||||
return fmt.Errorf("open statement: %w", err)
|
||||
}
|
||||
|
||||
reader := trading212.NewRecordReader(f)
|
||||
|
||||
writer := internal.NewStdOutLogger()
|
||||
|
||||
eg.Go(func() error {
|
||||
return internal.BuildReport(ctx, reader, writer)
|
||||
})
|
||||
|
||||
err = eg.Wait()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
slog.Info("Finish processing statement")
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,25 +1,14 @@
|
||||
module github.com/nmoniz/any2anexoj
|
||||
module git.naterciomoniz.net/applications/broker2anexoj
|
||||
|
||||
go 1.26.4
|
||||
go 1.25.3
|
||||
|
||||
require (
|
||||
github.com/biter777/countries v1.7.5
|
||||
github.com/jedib0t/go-pretty/v6 v6.7.2
|
||||
github.com/shopspring/decimal v1.4.0
|
||||
github.com/spf13/pflag v1.0.10
|
||||
go.uber.org/mock v0.6.0
|
||||
golang.org/x/time v0.14.0
|
||||
golang.org/x/sync v0.18.0
|
||||
)
|
||||
|
||||
require golang.org/x/sync v0.18.0 // indirect
|
||||
|
||||
require (
|
||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||
github.com/nicksnyder/go-i18n/v2 v2.6.0
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
golang.org/x/mod v0.27.0 // indirect
|
||||
golang.org/x/sys v0.35.0 // indirect
|
||||
golang.org/x/text v0.23.0
|
||||
golang.org/x/tools v0.36.0 // indirect
|
||||
)
|
||||
|
||||
|
||||
@@ -1,40 +1,17 @@
|
||||
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/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/jedib0t/go-pretty/v6 v6.7.2 h1:EYWgQNIH/+JsyHki7ns9OHyBKuHPkzrBo02uYjran7w=
|
||||
github.com/jedib0t/go-pretty/v6 v6.7.2/go.mod h1:YwC5CE4fJ1HFUDeivSV1r//AmANFHyqczZk+U6BDALU=
|
||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/nicksnyder/go-i18n/v2 v2.6.0 h1:C/m2NNWNiTB6SK4Ao8df5EWm3JETSTIGNXBpMJTxzxQ=
|
||||
github.com/nicksnyder/go-i18n/v2 v2.6.0/go.mod h1:88sRqr0C6OPyJn0/KRNaEz1uWorjxIKP7rUUcvycecE=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
|
||||
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
|
||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
|
||||
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
|
||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
||||
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
||||
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
|
||||
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
|
||||
@@ -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,153 +0,0 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// FileStore 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 FileStore struct {
|
||||
filename string
|
||||
platform string
|
||||
serializer RecordSerializer
|
||||
}
|
||||
|
||||
// NewFileStore constructs a FileStore that reads from and writes to the given
|
||||
// filename. The FileStore 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 NewFileStore(filename string, platform string, serializer RecordSerializer) (*FileStore, error) {
|
||||
if filename == "" {
|
||||
return nil, fmt.Errorf("filename cannot be empty")
|
||||
}
|
||||
if serializer == nil {
|
||||
return nil, fmt.Errorf("serializer cannot be nil")
|
||||
}
|
||||
return &FileStore{
|
||||
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 *FileStore) 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 *FileStore) 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
|
||||
}
|
||||
|
||||
ext := filepath.Ext(fs.filename)
|
||||
name, _ := strings.CutSuffix(fs.filename, ext)
|
||||
backupFilename := name + "." + strconv.FormatInt(time.Now().UnixMilli(), 10) + ext
|
||||
err := os.Rename(fs.filename, backupFilename)
|
||||
if err != nil {
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
return fmt.Errorf("rename old state file: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
enc := json.NewEncoder(buf)
|
||||
enc.SetIndent("", " ")
|
||||
if err := enc.Encode(state); err != nil {
|
||||
return fmt.Errorf("encoding state: %w", err)
|
||||
}
|
||||
|
||||
dst, err := os.Create(fs.filename)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating new state file: %w", err)
|
||||
}
|
||||
|
||||
if _, err := io.Copy(dst, buf); err != nil {
|
||||
return fmt.Errorf("writing to new state file: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,435 +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 TestFileStore_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)
|
||||
}
|
||||
if strings.Contains(string(data), `"reader_data"`) {
|
||||
t.Errorf("saved state file still contains legacy key \"reader_data\"; want only \"record_data\"")
|
||||
}
|
||||
if !strings.Contains(string(data), `"record_data"`) {
|
||||
t.Errorf("saved state file does not contain expected key \"record_data\"")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileStore_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 TestFileStore_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 TestFileStore_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 TestFileStore_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 TestFileStore_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 TestFileStore_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 TestFileStore_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 TestNewFileStore_ValidatesArguments(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
ser := mocks.NewMockRecordSerializer(ctrl)
|
||||
|
||||
if _, err := internal.NewFileStore("", "fake", ser); err == nil {
|
||||
t.Errorf("NewFileStore with empty filename should fail")
|
||||
}
|
||||
if _, err := internal.NewFileStore("/tmp/x", "fake", nil); err == nil {
|
||||
t.Errorf("NewFileStore with nil serializer should fail")
|
||||
}
|
||||
}
|
||||
|
||||
// newRecord builds a MockRecord whose Symbol() returns the given symbol.
|
||||
// The FileStore 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 FileStore 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.FileStore, string) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "state.json")
|
||||
store, err := internal.NewFileStore(path, platform, ser)
|
||||
if err != nil {
|
||||
t.Fatalf("NewFileStore returned unexpected error: %v", err)
|
||||
}
|
||||
return store, path
|
||||
}
|
||||
|
||||
// roundTripSerializer returns a serializer mock whose MarshalRecord encodes
|
||||
// the Symbol into bytes and whose UnmarshalRecord decodes those 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) ([]byte, error) {
|
||||
return []byte(r.Symbol()), nil
|
||||
}).
|
||||
AnyTimes()
|
||||
ser.EXPECT().
|
||||
UnmarshalRecord(gomock.Any(), gomock.Any()).
|
||||
DoAndReturn(func(_ context.Context, b []byte) (internal.Record, error) {
|
||||
return newRecord(ctrl, string(b)), 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,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
|
||||
|
||||
//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,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)
|
||||
}
|
||||
})
|
||||
}
|
||||
+62
-404
@@ -1,9 +1,9 @@
|
||||
// Code generated by MockGen. DO NOT EDIT.
|
||||
// Source: github.com/nmoniz/any2anexoj/internal (interfaces: RecordReader,Record,ReportWriter,RecordEncoder,RecordDecoder,RecordSerializer)
|
||||
// Source: git.naterciomoniz.net/applications/broker2anexoj/internal (interfaces: RecordReader,Record,ReportWriter)
|
||||
//
|
||||
// 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.
|
||||
@@ -11,11 +11,11 @@ package mocks
|
||||
|
||||
import (
|
||||
context "context"
|
||||
big "math/big"
|
||||
reflect "reflect"
|
||||
time "time"
|
||||
|
||||
internal "github.com/nmoniz/any2anexoj/internal"
|
||||
decimal "github.com/shopspring/decimal"
|
||||
internal "git.naterciomoniz.net/applications/broker2anexoj/internal"
|
||||
gomock "go.uber.org/mock/gomock"
|
||||
)
|
||||
|
||||
@@ -106,87 +106,11 @@ func (m *MockRecord) EXPECT() *MockRecordMockRecorder {
|
||||
return m.recorder
|
||||
}
|
||||
|
||||
// AssetCountry mocks base method.
|
||||
func (m *MockRecord) AssetCountry() int64 {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "AssetCountry")
|
||||
ret0, _ := ret[0].(int64)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// AssetCountry indicates an expected call of AssetCountry.
|
||||
func (mr *MockRecordMockRecorder) AssetCountry() *MockRecordAssetCountryCall {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AssetCountry", reflect.TypeOf((*MockRecord)(nil).AssetCountry))
|
||||
return &MockRecordAssetCountryCall{Call: call}
|
||||
}
|
||||
|
||||
// MockRecordAssetCountryCall wrap *gomock.Call
|
||||
type MockRecordAssetCountryCall struct {
|
||||
*gomock.Call
|
||||
}
|
||||
|
||||
// Return rewrite *gomock.Call.Return
|
||||
func (c *MockRecordAssetCountryCall) Return(arg0 int64) *MockRecordAssetCountryCall {
|
||||
c.Call = c.Call.Return(arg0)
|
||||
return c
|
||||
}
|
||||
|
||||
// Do rewrite *gomock.Call.Do
|
||||
func (c *MockRecordAssetCountryCall) Do(f func() int64) *MockRecordAssetCountryCall {
|
||||
c.Call = c.Call.Do(f)
|
||||
return c
|
||||
}
|
||||
|
||||
// DoAndReturn rewrite *gomock.Call.DoAndReturn
|
||||
func (c *MockRecordAssetCountryCall) DoAndReturn(f func() int64) *MockRecordAssetCountryCall {
|
||||
c.Call = c.Call.DoAndReturn(f)
|
||||
return c
|
||||
}
|
||||
|
||||
// BrokerCountry mocks base method.
|
||||
func (m *MockRecord) BrokerCountry() int64 {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "BrokerCountry")
|
||||
ret0, _ := ret[0].(int64)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// BrokerCountry indicates an expected call of BrokerCountry.
|
||||
func (mr *MockRecordMockRecorder) BrokerCountry() *MockRecordBrokerCountryCall {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BrokerCountry", reflect.TypeOf((*MockRecord)(nil).BrokerCountry))
|
||||
return &MockRecordBrokerCountryCall{Call: call}
|
||||
}
|
||||
|
||||
// MockRecordBrokerCountryCall wrap *gomock.Call
|
||||
type MockRecordBrokerCountryCall struct {
|
||||
*gomock.Call
|
||||
}
|
||||
|
||||
// Return rewrite *gomock.Call.Return
|
||||
func (c *MockRecordBrokerCountryCall) Return(arg0 int64) *MockRecordBrokerCountryCall {
|
||||
c.Call = c.Call.Return(arg0)
|
||||
return c
|
||||
}
|
||||
|
||||
// Do rewrite *gomock.Call.Do
|
||||
func (c *MockRecordBrokerCountryCall) Do(f func() int64) *MockRecordBrokerCountryCall {
|
||||
c.Call = c.Call.Do(f)
|
||||
return c
|
||||
}
|
||||
|
||||
// DoAndReturn rewrite *gomock.Call.DoAndReturn
|
||||
func (c *MockRecordBrokerCountryCall) DoAndReturn(f func() int64) *MockRecordBrokerCountryCall {
|
||||
c.Call = c.Call.DoAndReturn(f)
|
||||
return c
|
||||
}
|
||||
|
||||
// Fees mocks base method.
|
||||
func (m *MockRecord) Fees() decimal.Decimal {
|
||||
func (m *MockRecord) Fees() *big.Float {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "Fees")
|
||||
ret0, _ := ret[0].(decimal.Decimal)
|
||||
ret0, _ := ret[0].(*big.Float)
|
||||
return ret0
|
||||
}
|
||||
|
||||
@@ -203,104 +127,28 @@ type MockRecordFeesCall struct {
|
||||
}
|
||||
|
||||
// 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)
|
||||
return c
|
||||
}
|
||||
|
||||
// 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)
|
||||
return c
|
||||
}
|
||||
|
||||
// DoAndReturn rewrite *gomock.Call.DoAndReturn
|
||||
func (c *MockRecordFeesCall) DoAndReturn(f func() decimal.Decimal) *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 {
|
||||
func (c *MockRecordFeesCall) DoAndReturn(f func() *big.Float) *MockRecordFeesCall {
|
||||
c.Call = c.Call.DoAndReturn(f)
|
||||
return c
|
||||
}
|
||||
|
||||
// Price mocks base method.
|
||||
func (m *MockRecord) Price() decimal.Decimal {
|
||||
func (m *MockRecord) Price() *big.Float {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "Price")
|
||||
ret0, _ := ret[0].(decimal.Decimal)
|
||||
ret0, _ := ret[0].(*big.Float)
|
||||
return ret0
|
||||
}
|
||||
|
||||
@@ -317,28 +165,28 @@ type MockRecordPriceCall struct {
|
||||
}
|
||||
|
||||
// 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)
|
||||
return c
|
||||
}
|
||||
|
||||
// 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)
|
||||
return c
|
||||
}
|
||||
|
||||
// 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)
|
||||
return c
|
||||
}
|
||||
|
||||
// Quantity mocks base method.
|
||||
func (m *MockRecord) Quantity() decimal.Decimal {
|
||||
func (m *MockRecord) Quantity() *big.Float {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "Quantity")
|
||||
ret0, _ := ret[0].(decimal.Decimal)
|
||||
ret0, _ := ret[0].(*big.Float)
|
||||
return ret0
|
||||
}
|
||||
|
||||
@@ -355,19 +203,57 @@ type MockRecordQuantityCall struct {
|
||||
}
|
||||
|
||||
// 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)
|
||||
return c
|
||||
}
|
||||
|
||||
// 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)
|
||||
return c
|
||||
}
|
||||
|
||||
// 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)
|
||||
return c
|
||||
}
|
||||
@@ -411,10 +297,10 @@ func (c *MockRecordSymbolCall) DoAndReturn(f func() string) *MockRecordSymbolCal
|
||||
}
|
||||
|
||||
// Taxes mocks base method.
|
||||
func (m *MockRecord) Taxes() decimal.Decimal {
|
||||
func (m *MockRecord) Taxes() *big.Float {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "Taxes")
|
||||
ret0, _ := ret[0].(decimal.Decimal)
|
||||
ret0, _ := ret[0].(*big.Float)
|
||||
return ret0
|
||||
}
|
||||
|
||||
@@ -431,19 +317,19 @@ type MockRecordTaxesCall struct {
|
||||
}
|
||||
|
||||
// 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)
|
||||
return c
|
||||
}
|
||||
|
||||
// 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)
|
||||
return c
|
||||
}
|
||||
|
||||
// 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)
|
||||
return c
|
||||
}
|
||||
@@ -547,231 +433,3 @@ func (c *MockReportWriterWriteCall) DoAndReturn(f func(context.Context, internal
|
||||
c.Call = c.Call.DoAndReturn(f)
|
||||
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) ([]byte, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "MarshalRecord", arg0, arg1)
|
||||
ret0, _ := ret[0].([]byte)
|
||||
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 []byte, 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) ([]byte, 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) ([]byte, 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 []byte) (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, []byte) (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, []byte) (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) ([]byte, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "MarshalRecord", arg0, arg1)
|
||||
ret0, _ := ret[0].([]byte)
|
||||
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 []byte, 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) ([]byte, 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) ([]byte, error)) *MockRecordSerializerMarshalRecordCall {
|
||||
c.Call = c.Call.DoAndReturn(f)
|
||||
return c
|
||||
}
|
||||
|
||||
// UnmarshalRecord mocks base method.
|
||||
func (m *MockRecordSerializer) UnmarshalRecord(arg0 context.Context, arg1 []byte) (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, []byte) (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, []byte) (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"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"math/big"
|
||||
"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 {
|
||||
// ReadRecord should return Records until an error is found.
|
||||
ReadRecord(context.Context) (Record, error)
|
||||
}
|
||||
|
||||
type ReportItem struct {
|
||||
Symbol string
|
||||
Nature Nature
|
||||
BrokerCountry int64
|
||||
AssetCountry int64
|
||||
BuyValue decimal.Decimal
|
||||
BuyTimestamp time.Time
|
||||
SellValue decimal.Decimal
|
||||
SellTimestamp time.Time
|
||||
Fees decimal.Decimal
|
||||
Taxes decimal.Decimal
|
||||
}
|
||||
|
||||
func (ri ReportItem) RealisedPnL() decimal.Decimal {
|
||||
return ri.SellValue.Sub(ri.BuyValue)
|
||||
}
|
||||
|
||||
type ReportWriter interface {
|
||||
// ReportWriter writes report items
|
||||
Write(context.Context, ReportItem) error
|
||||
}
|
||||
|
||||
type optionals struct {
|
||||
selector Selector
|
||||
store Store
|
||||
}
|
||||
func BuildReport(ctx context.Context, reader RecordReader, writer ReportWriter) error {
|
||||
buys := make(map[string]*RecordQueue)
|
||||
|
||||
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 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-progTicker.C:
|
||||
slog.InfoContext(
|
||||
ctx, "Progress update",
|
||||
slog.Int64("records_count", recordsCount),
|
||||
slog.Time("last_record_timestamp", lastTimestamp),
|
||||
)
|
||||
default:
|
||||
rec, err := reader.ReadRecord(ctx)
|
||||
if err != nil {
|
||||
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 err
|
||||
}
|
||||
|
||||
if !rec.Kind().Valid() {
|
||||
return fmt.Errorf("found invalid Kind(%d)", rec.Kind())
|
||||
}
|
||||
|
||||
lastTimestamp = rec.Timestamp()
|
||||
recordsCount++
|
||||
|
||||
buyQueue, ok := buys[rec.Symbol()]
|
||||
if !ok {
|
||||
buyQueue = new(FillerQueue)
|
||||
buyQueue = new(RecordQueue)
|
||||
buys[rec.Symbol()] = buyQueue
|
||||
}
|
||||
|
||||
err = processRecord(ctx, buyQueue, rec, optionals.selector, writer)
|
||||
err = processRecord(ctx, buyQueue, rec, writer)
|
||||
if err != nil {
|
||||
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
|
||||
// sell record.
|
||||
func processRecord(ctx context.Context, q *FillerQueue, rec Record, sel Selector, writer ReportWriter) error {
|
||||
slog.Debug(
|
||||
"Report: processing record",
|
||||
slog.String("symbol", rec.Symbol()),
|
||||
slog.String("side", rec.Kind().String()),
|
||||
)
|
||||
func processRecord(ctx context.Context, q *RecordQueue, rec Record, writer ReportWriter) error {
|
||||
switch rec.Side() {
|
||||
case SideBuy:
|
||||
q.Push(rec)
|
||||
|
||||
switch rec.Kind() {
|
||||
case KindBuy:
|
||||
// Selectors are only applied when processing sell records for performance reasons. It's much
|
||||
// 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 SideSell:
|
||||
unmatchedQty := new(big.Float).Copy(rec.Quantity())
|
||||
zero := new(big.Float)
|
||||
|
||||
case KindSell:
|
||||
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() {
|
||||
for unmatchedQty.Cmp(zero) > 0 {
|
||||
buy, ok := q.Peek()
|
||||
if !ok {
|
||||
return ErrInsufficientBoughtVolume
|
||||
}
|
||||
|
||||
matchedQty, filled := buy.Fill(unmatchedQty)
|
||||
|
||||
if filled {
|
||||
_, ok := q.Pop()
|
||||
if !ok {
|
||||
return fmt.Errorf("pop empty filler queue")
|
||||
}
|
||||
var matchedQty *big.Float
|
||||
if buy.Quantity().Cmp(unmatchedQty) > 0 {
|
||||
matchedQty = unmatchedQty
|
||||
buy.Quantity().Sub(buy.Quantity(), unmatchedQty)
|
||||
} else {
|
||||
matchedQty = buy.Quantity()
|
||||
q.Pop()
|
||||
}
|
||||
|
||||
unmatchedQty = unmatchedQty.Sub(matchedQty)
|
||||
unmatchedQty.Sub(unmatchedQty, matchedQty)
|
||||
|
||||
buyValue := matchedQty.Mul(buy.Price())
|
||||
sellValue := matchedQty.Mul(rec.Price())
|
||||
sellValue := new(big.Float).Mul(matchedQty, rec.Price())
|
||||
buyValue := new(big.Float).Mul(matchedQty, buy.Price())
|
||||
|
||||
err := writer.Write(ctx, ReportItem{
|
||||
Symbol: rec.Symbol(),
|
||||
BrokerCountry: rec.BrokerCountry(),
|
||||
AssetCountry: rec.AssetCountry(),
|
||||
BuyValue: buyValue,
|
||||
BuyTimestamp: buy.Timestamp(),
|
||||
SellValue: sellValue,
|
||||
SellTimestamp: rec.Timestamp(),
|
||||
Fees: buy.Fees().Add(rec.Fees()),
|
||||
Taxes: buy.Taxes().Add(rec.Taxes()),
|
||||
Nature: buy.Nature(),
|
||||
Fees: new(big.Float).Add(buy.Fees(), rec.Fees()),
|
||||
Taxes: new(big.Float).Add(buy.Taxes(), rec.Fees()),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("write report item: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
case KindSplit:
|
||||
q.AdjustForSplit(rec.Quantity())
|
||||
|
||||
default:
|
||||
return fmt.Errorf("unknown side: %v", rec.Kind())
|
||||
return fmt.Errorf("unknown side: %v", rec.Side())
|
||||
}
|
||||
|
||||
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")
|
||||
|
||||
+17
-142
@@ -2,27 +2,24 @@ package internal_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"reflect"
|
||||
"math/big"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/biter777/countries"
|
||||
"github.com/nmoniz/any2anexoj/internal"
|
||||
"github.com/nmoniz/any2anexoj/internal/mocks"
|
||||
"github.com/shopspring/decimal"
|
||||
"git.naterciomoniz.net/applications/broker2anexoj/internal"
|
||||
"git.naterciomoniz.net/applications/broker2anexoj/internal/mocks"
|
||||
"go.uber.org/mock/gomock"
|
||||
)
|
||||
|
||||
func TestBuildReport(t *testing.T) {
|
||||
func TestReporter_Run(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),
|
||||
mockRecord(ctrl, 25.0, 10.0, internal.KindSell, now.Add(1)),
|
||||
mockRecord(ctrl, 20.0, 10.0, internal.SideBuy, now),
|
||||
mockRecord(ctrl, 25.0, 10.0, internal.SideSell, now.Add(1)),
|
||||
}
|
||||
reader.EXPECT().ReadRecord(gomock.Any()).DoAndReturn(func(ctx context.Context) (internal.Record, error) {
|
||||
if len(records) > 0 {
|
||||
@@ -35,13 +32,13 @@ func TestBuildReport(t *testing.T) {
|
||||
}).Times(3)
|
||||
|
||||
writer := mocks.NewMockReportWriter(ctrl)
|
||||
writer.EXPECT().Write(gomock.Any(), eqReportItem(internal.ReportItem{
|
||||
BuyValue: decimal.NewFromFloat(200.0),
|
||||
writer.EXPECT().Write(gomock.Any(), gomock.Eq(internal.ReportItem{
|
||||
BuyValue: new(big.Float).SetFloat64(200.0),
|
||||
BuyTimestamp: now,
|
||||
SellValue: decimal.NewFromFloat(250.0),
|
||||
SellValue: new(big.Float).SetFloat64(250.0),
|
||||
SellTimestamp: now.Add(1),
|
||||
Fees: decimal.Decimal{},
|
||||
Taxes: decimal.Decimal{},
|
||||
Fees: new(big.Float),
|
||||
Taxes: new(big.Float),
|
||||
})).Times(1)
|
||||
|
||||
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.EXPECT().Price().Return(big.NewFloat(price)).AnyTimes()
|
||||
rec.EXPECT().Quantity().Return(big.NewFloat(quantity)).AnyTimes()
|
||||
rec.EXPECT().Side().Return(side).AnyTimes()
|
||||
rec.EXPECT().Symbol().Return("TEST").AnyTimes()
|
||||
rec.EXPECT().BrokerCountry().Return(int64(countries.PT)).AnyTimes()
|
||||
rec.EXPECT().AssetCountry().Return(int64(countries.USA)).AnyTimes()
|
||||
rec.EXPECT().Price().Return(decimal.NewFromFloat(price)).AnyTimes()
|
||||
rec.EXPECT().Quantity().Return(decimal.NewFromFloat(quantity)).AnyTimes()
|
||||
rec.EXPECT().Kind().Return(kind).AnyTimes()
|
||||
rec.EXPECT().Timestamp().Return(ts).AnyTimes()
|
||||
rec.EXPECT().Fees().Return(decimal.Decimal{}).AnyTimes()
|
||||
rec.EXPECT().Taxes().Return(decimal.Decimal{}).AnyTimes()
|
||||
rec.EXPECT().Nature().Return(internal.NatureG01).AnyTimes()
|
||||
rec.EXPECT().Fees().Return(new(big.Float)).AnyTimes()
|
||||
rec.EXPECT().Taxes().Return(new(big.Float)).AnyTimes()
|
||||
return rec
|
||||
}
|
||||
|
||||
func eqReportItem(ri internal.ReportItem) ReportItemMatcher {
|
||||
return ReportItemMatcher{
|
||||
ReportItem: ri,
|
||||
}
|
||||
}
|
||||
|
||||
type ReportItemMatcher struct {
|
||||
internal.ReportItem
|
||||
}
|
||||
|
||||
// Matches implements gomock.Matcher.
|
||||
func (m ReportItemMatcher) Matches(x any) bool {
|
||||
if x == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
switch other := x.(type) {
|
||||
case internal.ReportItem:
|
||||
return m.BuyValue.Equal(other.BuyValue) &&
|
||||
m.BuyTimestamp.Equal(other.BuyTimestamp) &&
|
||||
m.SellValue.Equal(other.SellValue) &&
|
||||
m.SellTimestamp.Equal(other.SellTimestamp) &&
|
||||
m.Fees.Equal(other.Fees) &&
|
||||
m.Taxes.Equal(other.Taxes)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (m ReportItemMatcher) String() string {
|
||||
return fmt.Sprintf("is equivalent to %v", m.ReportItem)
|
||||
}
|
||||
|
||||
var _ gomock.Matcher = (*ReportItemMatcher)(nil)
|
||||
|
||||
// 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,46 +0,0 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
|
||||
// StateVersion is the schema version of the persisted State struct.
|
||||
const StateVersion = "1"
|
||||
|
||||
// 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 []byte `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 byte representation.
|
||||
type RecordEncoder interface {
|
||||
MarshalRecord(context.Context, Record) ([]byte, error)
|
||||
}
|
||||
|
||||
// RecordDecoder decodes broker-specific bytes back into a Record.
|
||||
type RecordDecoder interface {
|
||||
UnmarshalRecord(context.Context, []byte) (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,32 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
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"
|
||||
|
||||
"git.naterciomoniz.net/applications/broker2anexoj/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
-144
@@ -5,103 +5,85 @@ import (
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"math/big"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/biter777/countries"
|
||||
"github.com/nmoniz/any2anexoj/internal"
|
||||
"github.com/nmoniz/any2anexoj/internal/ofigi"
|
||||
"github.com/shopspring/decimal"
|
||||
"git.naterciomoniz.net/applications/broker2anexoj/internal"
|
||||
)
|
||||
|
||||
type Record struct {
|
||||
symbol string
|
||||
side internal.Side
|
||||
quantity *big.Float
|
||||
price *big.Float
|
||||
timestamp time.Time
|
||||
kind internal.Kind
|
||||
quantity decimal.Decimal
|
||||
price decimal.Decimal
|
||||
fees decimal.Decimal
|
||||
taxes decimal.Decimal
|
||||
|
||||
// natureGetter allows us to defer the operation of figuring out the nature to only when/if needed.
|
||||
natureGetter func() internal.Nature
|
||||
fees *big.Float
|
||||
taxes *big.Float
|
||||
}
|
||||
|
||||
func (r Record) Symbol() string {
|
||||
return r.symbol
|
||||
}
|
||||
|
||||
func (r Record) Side() internal.Side {
|
||||
return r.side
|
||||
}
|
||||
|
||||
func (r Record) Quantity() *big.Float {
|
||||
return r.quantity
|
||||
}
|
||||
|
||||
func (r Record) Price() *big.Float {
|
||||
return r.price
|
||||
}
|
||||
|
||||
func (r Record) Timestamp() time.Time {
|
||||
return r.timestamp
|
||||
}
|
||||
|
||||
func (r Record) BrokerCountry() int64 {
|
||||
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 {
|
||||
func (r Record) Fees() *big.Float {
|
||||
return r.fees
|
||||
}
|
||||
|
||||
func (r Record) Taxes() decimal.Decimal {
|
||||
func (r Record) Taxes() *big.Float {
|
||||
return r.taxes
|
||||
}
|
||||
|
||||
func (r Record) Nature() internal.Nature {
|
||||
return r.natureGetter()
|
||||
}
|
||||
|
||||
type RecordReader struct {
|
||||
reader *csv.Reader
|
||||
figi *ofigi.Client
|
||||
}
|
||||
|
||||
func NewRecordReader(r io.Reader, f *ofigi.Client) *RecordReader {
|
||||
func NewRecordReader(r io.Reader) *RecordReader {
|
||||
return &RecordReader{
|
||||
reader: csv.NewReader(r),
|
||||
figi: f,
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
MarketBuy = "market buy"
|
||||
MarketSell = "market sell"
|
||||
LimitBuy = "limit buy"
|
||||
LimitSell = "limit sell"
|
||||
StockSplitOpen = "stock split open"
|
||||
StockSplitClose = "stock split close"
|
||||
StokDistribution = "stock distribution"
|
||||
MarketBuy = "market buy"
|
||||
MarketSell = "market sell"
|
||||
LimitBuy = "limit buy"
|
||||
LimitSell = "limit sell"
|
||||
)
|
||||
|
||||
func (rr RecordReader) ReadRecord(ctx context.Context) (internal.Record, error) {
|
||||
var splitRec *splitRecord
|
||||
|
||||
func (rr RecordReader) ReadRecord(_ context.Context) (internal.Record, error) {
|
||||
for {
|
||||
raw, err := rr.reader.Read()
|
||||
if err != nil {
|
||||
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
|
||||
default:
|
||||
return Record{}, fmt.Errorf("parse record type: %s", raw[0])
|
||||
}
|
||||
|
||||
qant, err := parseDecimal(raw[6])
|
||||
@@ -119,118 +101,47 @@ func (rr RecordReader) ReadRecord(ctx context.Context) (internal.Record, error)
|
||||
return Record{}, fmt.Errorf("parse record timestamp: %w", err)
|
||||
}
|
||||
|
||||
conversionFee, err := parseOptionalDecimal(raw[16])
|
||||
convertionFee, err := parseOptinalDecimal(raw[16])
|
||||
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 {
|
||||
return Record{}, fmt.Errorf("parse record stamp duty tax: %w", err)
|
||||
}
|
||||
|
||||
frenchTxTax, err := parseOptionalDecimal(raw[18])
|
||||
frenchTxTax, err := parseOptinalDecimal(raw[18])
|
||||
if err != nil {
|
||||
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: raw[2],
|
||||
kind: internal.KindSplit,
|
||||
quantity: qant,
|
||||
price: price,
|
||||
fees: conversionFee,
|
||||
taxes: stampDutyTax.Add(frenchTxTax),
|
||||
timestamp: ts,
|
||||
natureGetter: figiNatureGetter(ctx, rr.figi, raw[2]),
|
||||
},
|
||||
}
|
||||
|
||||
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("Found stock distribution but can't handle it")
|
||||
continue
|
||||
|
||||
default:
|
||||
return Record{}, fmt.Errorf("parse record type: %s", raw[0])
|
||||
}
|
||||
|
||||
return Record{
|
||||
symbol: raw[2],
|
||||
kind: kind,
|
||||
quantity: qant,
|
||||
price: price,
|
||||
fees: conversionFee,
|
||||
taxes: stampDutyTax.Add(frenchTxTax),
|
||||
timestamp: ts,
|
||||
natureGetter: figiNatureGetter(ctx, rr.figi, raw[2]),
|
||||
symbol: raw[2],
|
||||
side: side,
|
||||
quantity: qant,
|
||||
price: price,
|
||||
timestamp: ts,
|
||||
fees: convertionFee,
|
||||
taxes: new(big.Float).Add(stampDutyTax, frenchTxTax),
|
||||
}, 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.
|
||||
// Using this function helps avoid issues around converting values due to minor parameter changes.
|
||||
func parseDecimal(s string) (decimal.Decimal, error) {
|
||||
return decimal.NewFromString(s)
|
||||
// Using this function helps avoid issues around converting values due to sligh parameter changes.
|
||||
func parseDecimal(s string) (*big.Float, error) {
|
||||
f, _, err := big.ParseFloat(s, 10, 128, big.ToZero)
|
||||
return f, err
|
||||
}
|
||||
|
||||
// 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.
|
||||
// Using this function helps avoid issues around converting values due to minor parameter changes.
|
||||
func parseOptionalDecimal(s string) (decimal.Decimal, error) {
|
||||
// Using this function helps avoid issues around converting values due to sligh parameter changes.
|
||||
func parseOptinalDecimal(s string) (*big.Float, error) {
|
||||
if len(s) == 0 {
|
||||
return decimal.Decimal{}, nil
|
||||
return new(big.Float), nil
|
||||
}
|
||||
|
||||
return parseDecimal(s)
|
||||
}
|
||||
|
||||
type splitRecord struct {
|
||||
Record
|
||||
|
||||
ratio decimal.Decimal
|
||||
}
|
||||
|
||||
func (sr splitRecord) Quantity() decimal.Decimal {
|
||||
return sr.ratio
|
||||
}
|
||||
|
||||
@@ -2,15 +2,12 @@ package trading212
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"math/big"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/nmoniz/any2anexoj/internal"
|
||||
"github.com/nmoniz/any2anexoj/internal/ofigi"
|
||||
"github.com/shopspring/decimal"
|
||||
"git.naterciomoniz.net/applications/broker2anexoj/internal"
|
||||
)
|
||||
|
||||
func TestRecordReader_ReadRecord(t *testing.T) {
|
||||
@@ -28,86 +25,74 @@ func TestRecordReader_ReadRecord(t *testing.T) {
|
||||
},
|
||||
{
|
||||
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{
|
||||
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.UTC),
|
||||
fees: ShouldParseDecimal(t, "0.02"),
|
||||
taxes: ShouldParseDecimal(t, "0.25"),
|
||||
natureGetter: func() internal.Nature { return internal.NatureG01 },
|
||||
symbol: "SYM123456ABXY",
|
||||
side: internal.SideBuy,
|
||||
quantity: ShouldParseDecimal(t, "2.4387014200"),
|
||||
price: ShouldParseDecimal(t, "7.3690000000"),
|
||||
timestamp: time.Date(2025, 7, 3, 10, 44, 29, 0, time.UTC),
|
||||
fees: ShouldParseDecimal(t, "0.02"),
|
||||
taxes: ShouldParseDecimal(t, "0.25"),
|
||||
},
|
||||
},
|
||||
{
|
||||
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{
|
||||
symbol: "XX1234567890",
|
||||
kind: internal.KindSell,
|
||||
quantity: ShouldParseDecimal(t, "2.4387014200"),
|
||||
price: ShouldParseDecimal(t, "7.9999999999"),
|
||||
timestamp: time.Date(2025, 8, 4, 11, 45, 30, 0, time.UTC),
|
||||
fees: ShouldParseDecimal(t, "0.02"),
|
||||
taxes: ShouldParseDecimal(t, "0.1"),
|
||||
natureGetter: func() internal.Nature { return internal.NatureG01 },
|
||||
symbol: "IE000GA3D489",
|
||||
side: internal.SideSell,
|
||||
quantity: ShouldParseDecimal(t, "2.4387014200"),
|
||||
price: ShouldParseDecimal(t, "7.9999999999"),
|
||||
timestamp: time.Date(2025, 8, 4, 11, 45, 30, 0, time.UTC),
|
||||
fees: ShouldParseDecimal(t, "0.02"),
|
||||
taxes: ShouldParseDecimal(t, "0.1"),
|
||||
},
|
||||
},
|
||||
{
|
||||
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,
|
||||
},
|
||||
{
|
||||
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,
|
||||
},
|
||||
{
|
||||
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,
|
||||
},
|
||||
{
|
||||
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,
|
||||
},
|
||||
{
|
||||
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,
|
||||
},
|
||||
{
|
||||
name: "empty price",
|
||||
r: bytes.NewBufferString(`Market sell,2025-08-04 11:45:39,IE000GA3D489,ABXY,"Aspargus Broccoli",EOF987654321,2.4387014200,,USD,1.17995999,,"EUR",15.25,"EUR",,,0.02,"EUR",,`),
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "malformed fees",
|
||||
r: bytes.NewBufferString(`Market sell,2025-08-04 11:45:30,IE000GA3D489,ABXY,"Aspargus Broccoli",EOF987654321,2.4387014200,7.9999999999,USD,1.17995999,,"EUR",15.25,"EUR",,,BAD,"EUR",0.1,"EUR"`),
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "malformed taxes",
|
||||
r: bytes.NewBufferString(`Market sell,2025-08-04 11:45:30,IE000GA3D489,ABXY,"Aspargus Broccoli",EOF987654321,2.4387014200,7.9999999999,USD,1.17995999,,"EUR",15.25,"EUR",,,0.02,"EUR",BAD,"EUR"`),
|
||||
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 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,
|
||||
},
|
||||
{
|
||||
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,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
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())
|
||||
if gotErr != nil {
|
||||
if !tt.wantErr {
|
||||
@@ -124,8 +109,8 @@ func TestRecordReader_ReadRecord(t *testing.T) {
|
||||
t.Fatalf("want symbol %v but got %v", tt.want.symbol, got.Symbol())
|
||||
}
|
||||
|
||||
if got.Kind() != tt.want.kind {
|
||||
t.Fatalf("want side %v but got %v", tt.want.kind, got.Kind())
|
||||
if got.Side() != tt.want.side {
|
||||
t.Fatalf("want side %v but got %v", tt.want.side, got.Side())
|
||||
}
|
||||
|
||||
if got.Price().Cmp(tt.want.price) != 0 {
|
||||
@@ -147,117 +132,11 @@ func TestRecordReader_ReadRecord(t *testing.T) {
|
||||
if got.Taxes().Cmp(tt.want.taxes) != 0 {
|
||||
t.Fatalf("want taxes %v but got %v", tt.want.taxes, got.Taxes())
|
||||
}
|
||||
|
||||
if tt.want.natureGetter != nil && tt.want.Nature() != got.Nature() {
|
||||
t.Fatalf("want nature %v but got %v", tt.want.Nature(), got.Nature())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordReader_ReadRecord_Split(t *testing.T) {
|
||||
// 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 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 {
|
||||
func ShouldParseDecimal(t testing.TB, sf string) *big.Float {
|
||||
t.Helper()
|
||||
|
||||
bf, err := parseDecimal(sf)
|
||||
@@ -266,40 +145,3 @@ func ShouldParseDecimal(t testing.TB, sf string) decimal.Decimal {
|
||||
}
|
||||
return bf
|
||||
}
|
||||
|
||||
type RoundTripFunc func(req *http.Request) (*http.Response, error)
|
||||
|
||||
func (f RoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return f(req)
|
||||
}
|
||||
|
||||
func NewFigiClientSecurityTypeStub(t testing.TB, securityType string) *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) ([]byte, 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 []byte) (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