Compare commits
1
Commits
main
..
0a657682f6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a657682f6 |
No files matched your search
@@ -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
|
||||
@@ -24,37 +24,6 @@ jobs:
|
||||
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
|
||||
@@ -66,8 +35,7 @@ jobs:
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v4
|
||||
with:
|
||||
go-version: 1.26
|
||||
cache: true
|
||||
go-version: 1.25
|
||||
|
||||
- name: Run Unit tests
|
||||
run: |
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# any2anexoj
|
||||
|
||||
[](https://goreportcard.com/report/github.com/nmoniz/any2anexoj)
|
||||
[](https://coveralls.io/github/nmoniz/any2anexoj?branch=main)
|
||||
|
||||
<p align="center">
|
||||
@@ -20,42 +21,9 @@ go install github.com/nmoniz/any2anexoj/cmd/any2anexoj-cli@latest
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
cat statement.csv | any2anexoj-cli --platform=trading212
|
||||
cat statement.csv | any2anexoj-cli --platform=tranding212
|
||||
```
|
||||
|
||||
`trading212` is currently the only supported platform. The output format is selected with `-f`/`--format`:
|
||||
|
||||
- `table` (default): pretty terminal table
|
||||
- `csv`: the Anexo J CSV schema
|
||||
- `html`: a complete, standalone Bootstrap-styled HTML report — a `<!DOCTYPE html>` document with the Anexo J table embedded and the official Bootstrap 5.3.3 stylesheet inlined via `go:embed`, so the report renders fully offline
|
||||
|
||||
The `table` and `html` formats are translated according to `--language`/`-l` (`pt` by default, `en` available). Other flags: `--selectors` filters which entries are processed (`code`, `assetCountry`), `--open-figi-api-key` raises OpenFIGI rate limits for faster report generation, and `--debug` enables debug logging.
|
||||
|
||||
### Incremental Reports
|
||||
|
||||
For year over year reporting, pass `--state-file <path>` to keep the FIFO buy-queue between runs. The typical workflow is:
|
||||
|
||||
1. Generate the first year Anexo J report from the first year broker statement and keep the state file.
|
||||
2. Next year, generate the next report from the second year broker statement **only**, using the same state file.
|
||||
|
||||
```bash
|
||||
# first year
|
||||
cat 2024-statement.csv | any2anexoj-cli --platform=trading212 --state-file=trading212-state.json
|
||||
|
||||
# following year
|
||||
cat 2025-statement.csv | any2anexoj-cli --platform=trading212 --state-file=trading212-state.json
|
||||
```
|
||||
|
||||
The first run behaves like a normal run and creates the state file.
|
||||
On later runs the tool loads the previously saved buy queues per symbol (including partially filled lots), processes only the new transactions from the input, and writes the updated state.
|
||||
The state file is a JSON document.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Each new input must contain **only** the new transactions, not the previous ones.
|
||||
> The state file already holds the unmatched buy lots from earlier runs, so including previous trades again will cause them to be processed twice and may fail with insufficient bought volume.
|
||||
|
||||
State is only persisted once the input reaches EOF, so a crash or `Ctrl-C` before the end of input will discard progress from that run.
|
||||
|
||||
## Rounding
|
||||
|
||||
All Euro values are rounded to cents (2 decimal places) but internal calculations use the statement values with full precision.
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
// Package csv renders an internal.AggregatorWriter as the CSV schema
|
||||
// expected by Modelo 3 do Anexo J.
|
||||
package csv
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/csv"
|
||||
@@ -10,15 +8,15 @@ import (
|
||||
"github.com/nmoniz/any2anexoj/internal"
|
||||
)
|
||||
|
||||
type Writer struct {
|
||||
type CSVWriter struct {
|
||||
w *csv.Writer
|
||||
}
|
||||
|
||||
func NewWriter(w io.Writer) *Writer {
|
||||
return &Writer{w: csv.NewWriter(w)}
|
||||
func NewCSVWriter(w io.Writer) *CSVWriter {
|
||||
return &CSVWriter{w: csv.NewWriter(w)}
|
||||
}
|
||||
|
||||
func (cw *Writer) Render(aw *internal.AggregatorWriter) error {
|
||||
func (cw *CSVWriter) Render(aw *internal.AggregatorWriter) error {
|
||||
err := cw.w.Write([]string{
|
||||
"source_country", "code",
|
||||
"realization_year", "realization_month", "realization_day", "realization_value",
|
||||
@@ -1,4 +1,4 @@
|
||||
package writing
|
||||
package main
|
||||
|
||||
import (
|
||||
"embed"
|
||||
@@ -1,10 +1,6 @@
|
||||
package writing_test
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/nmoniz/any2anexoj/internal/writing"
|
||||
)
|
||||
import "testing"
|
||||
|
||||
func TestNewLocalizer(t *testing.T) {
|
||||
tests := []struct {
|
||||
@@ -19,7 +15,7 @@ func TestNewLocalizer(t *testing.T) {
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := writing.NewLocalizer(tt.lang)
|
||||
_, err := NewLocalizer(tt.lang)
|
||||
if err != nil {
|
||||
t.Fatalf("want success call but failed: %v", err)
|
||||
}
|
||||
+21
-72
@@ -3,7 +3,6 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -11,26 +10,22 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/nmoniz/any2anexoj/internal"
|
||||
"github.com/nmoniz/any2anexoj/internal/ofigi"
|
||||
"github.com/nmoniz/any2anexoj/internal/trading212"
|
||||
"github.com/nmoniz/any2anexoj/internal/writing"
|
||||
"github.com/nmoniz/any2anexoj/internal/writing/csv"
|
||||
htmlwriter "github.com/nmoniz/any2anexoj/internal/writing/html"
|
||||
"github.com/nmoniz/any2anexoj/internal/writing/table"
|
||||
"github.com/spf13/pflag"
|
||||
"golang.org/x/sync/errgroup"
|
||||
"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, csv or html")
|
||||
ofAPIKey = pflag.String("open-figi-api-key", "", "An OpenFIGI API key for faster report generation (better api rate limits)")
|
||||
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)")
|
||||
// TODO: improve documentation on selectors
|
||||
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() {
|
||||
@@ -43,19 +38,12 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
eg, ctx := errgroup.WithContext(ctx)
|
||||
|
||||
logLevel := slog.LevelInfo
|
||||
if *debug {
|
||||
logLevel = slog.LevelDebug
|
||||
@@ -70,18 +58,11 @@ func runWithIO(ctx context.Context, stdin io.Reader, stdout io.Writer) error {
|
||||
return fmt.Errorf("--language flag is required")
|
||||
}
|
||||
|
||||
figiClient := ofigi.NewOpenFIGI(&http.Client{Timeout: 5 * time.Second}, *ofAPIKey)
|
||||
|
||||
reader, err := getReader(*platform, stdin, figiClient)
|
||||
reader, err := getReader(*platform, *ofAPIKey)
|
||||
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)
|
||||
@@ -89,66 +70,34 @@ func runWithIO(ctx context.Context, stdin io.Reader, stdout io.Writer) error {
|
||||
return fmt.Errorf("parsing selectors: %w", err)
|
||||
}
|
||||
|
||||
err = internal.BuildReport(
|
||||
ctx,
|
||||
reader,
|
||||
writer,
|
||||
internal.WithSelector(selector),
|
||||
internal.WithStore(store),
|
||||
)
|
||||
eg.Go(func() error {
|
||||
return internal.BuildReport(ctx, reader, writer, internal.WithSelector(selector))
|
||||
})
|
||||
|
||||
err = eg.Wait()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch *format {
|
||||
case "csv":
|
||||
return csv.NewWriter(stdout).Render(writer)
|
||||
return NewCSVWriter(os.Stdout).Render(writer)
|
||||
case "table":
|
||||
loc, err := writing.NewLocalizer(*lang)
|
||||
loc, err := NewLocalizer(*lang)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create localizer: %w", err)
|
||||
}
|
||||
table.NewPrinter(stdout, loc).Render(writer)
|
||||
NewPrettyPrinter(os.Stdout, loc).Render(writer)
|
||||
return nil
|
||||
case "html":
|
||||
loc, err := writing.NewLocalizer(*lang)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create localizer: %w", err)
|
||||
}
|
||||
return htmlwriter.NewWriter(stdout, loc, *lang).Render(writer)
|
||||
default:
|
||||
return fmt.Errorf("unsupported format %q: must be table, csv or html", *format)
|
||||
return fmt.Errorf("unsupported format %q: must be table or csv", *format)
|
||||
}
|
||||
}
|
||||
|
||||
// buildStore returns the Store implementation that BuildReport should use.
|
||||
// When --state-file is empty an EphemeralStore is used so behaviour is
|
||||
// identical to pre-persistence runs. Otherwise a JSON-backed JSONStore is
|
||||
// returned, wired to a platform-specific RecordSerializer.
|
||||
func buildStore(stateFile, platform string, figi *ofigi.Client) (internal.Store, error) {
|
||||
if stateFile == "" {
|
||||
return internal.EphemeralStore{}, nil
|
||||
}
|
||||
|
||||
var serializer internal.RecordSerializer
|
||||
func getReader(platform string, ofAPIKey string) (internal.RecordReader, error) {
|
||||
switch platform {
|
||||
case "trading212":
|
||||
serializer = trading212.NewRecordSerializer(figi)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported platform for state persistence: %s", platform)
|
||||
}
|
||||
|
||||
store, err := internal.NewJSONStore(stateFile, platform, serializer)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating file store: %w", err)
|
||||
}
|
||||
return store, nil
|
||||
}
|
||||
|
||||
func getReader(platform string, r io.Reader, figi *ofigi.Client) (internal.RecordReader, error) {
|
||||
switch platform {
|
||||
case "trading212":
|
||||
return trading212.NewRecordReader(r, figi), nil
|
||||
return trading212.NewRecordReader(os.Stdin, internal.NewOpenFIGI(&http.Client{Timeout: 5 * time.Second}, ofAPIKey)), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported platform: %s", platform)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/biter777/countries"
|
||||
"github.com/jedib0t/go-pretty/v6/table"
|
||||
"github.com/jedib0t/go-pretty/v6/text"
|
||||
"github.com/nmoniz/any2anexoj/internal"
|
||||
)
|
||||
|
||||
// PrettyPrinter writes a simple, human readable, table row to the provided io.Writer for each
|
||||
// ReportItem received.
|
||||
type PrettyPrinter struct {
|
||||
table table.Writer
|
||||
output io.Writer
|
||||
translator Translator
|
||||
}
|
||||
|
||||
type Translator interface {
|
||||
Translate(key string, count int, values map[string]any) string
|
||||
}
|
||||
|
||||
func NewPrettyPrinter(w io.Writer, tr Translator) *PrettyPrinter {
|
||||
tw := table.NewWriter()
|
||||
tw.SetOutputMirror(w)
|
||||
tw.SetAutoIndex(true)
|
||||
tw.SetStyle(table.StyleLight)
|
||||
tw.SetColumnConfigs([]table.ColumnConfig{
|
||||
colCountry(1),
|
||||
colOther(2),
|
||||
colOther(3),
|
||||
colOther(4),
|
||||
colOther(5),
|
||||
colEuros(6),
|
||||
colOther(7),
|
||||
colOther(8),
|
||||
colOther(9),
|
||||
colEuros(10),
|
||||
colEuros(11),
|
||||
colEuros(12),
|
||||
colCountry(13),
|
||||
})
|
||||
|
||||
return &PrettyPrinter{
|
||||
table: tw,
|
||||
output: w,
|
||||
translator: tr,
|
||||
}
|
||||
}
|
||||
|
||||
func (pp *PrettyPrinter) Render(aw *internal.AggregatorWriter) {
|
||||
realizationTxt := pp.translator.Translate("realization", 1, nil)
|
||||
acquisitionTxt := pp.translator.Translate("acquisition", 1, nil)
|
||||
yearTxt := pp.translator.Translate("year", 1, nil)
|
||||
monthTxt := pp.translator.Translate("month", 1, nil)
|
||||
dayTxt := pp.translator.Translate("day", 1, nil)
|
||||
valorTxt := pp.translator.Translate("value", 1, nil)
|
||||
|
||||
pp.table.AppendHeader(table.Row{"", "", realizationTxt, realizationTxt, realizationTxt, realizationTxt, acquisitionTxt, acquisitionTxt, acquisitionTxt, acquisitionTxt, "", "", ""}, table.RowConfig{AutoMerge: true})
|
||||
pp.table.AppendHeader(table.Row{
|
||||
pp.translator.Translate("source_country", 1, nil), pp.translator.Translate("code", 1, nil),
|
||||
yearTxt, monthTxt, dayTxt, valorTxt,
|
||||
yearTxt, monthTxt, dayTxt, valorTxt,
|
||||
pp.translator.Translate("expenses", 2, nil), pp.translator.Translate("foreign_tax_paid", 1, nil), pp.translator.Translate("counter_country", 1, nil),
|
||||
})
|
||||
|
||||
for ri := range aw.Iter() {
|
||||
pp.table.AppendRow(table.Row{
|
||||
ri.AssetCountry, ri.Nature,
|
||||
ri.SellTimestamp.Year(), int(ri.SellTimestamp.Month()), ri.SellTimestamp.Day(), ri.SellValue.StringFixed(2),
|
||||
ri.BuyTimestamp.Year(), int(ri.BuyTimestamp.Month()), ri.BuyTimestamp.Day(), ri.BuyValue.StringFixed(2),
|
||||
ri.Fees.StringFixed(2), ri.Taxes.StringFixed(2), ri.BrokerCountry,
|
||||
})
|
||||
}
|
||||
|
||||
pp.table.AppendFooter(table.Row{"SUM", "SUM", "SUM", "SUM", "SUM", aw.TotalEarned(), "", "", "", aw.TotalSpent(), aw.TotalFees(), aw.TotalTaxes()}, table.RowConfig{AutoMerge: true, AutoMergeAlign: text.AlignRight})
|
||||
pp.table.Render()
|
||||
}
|
||||
|
||||
func colEuros(n int) table.ColumnConfig {
|
||||
return table.ColumnConfig{
|
||||
Number: n,
|
||||
Align: text.AlignRight,
|
||||
AlignFooter: text.AlignRight,
|
||||
AlignHeader: text.AlignRight,
|
||||
WidthMin: 12,
|
||||
WidthMax: 15,
|
||||
Transformer: func(val any) string {
|
||||
return fmt.Sprintf("%v €", val)
|
||||
},
|
||||
TransformerFooter: func(val any) string {
|
||||
return fmt.Sprintf("%v €", val)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func colOther(n int) table.ColumnConfig {
|
||||
return table.ColumnConfig{
|
||||
Number: n,
|
||||
Align: text.AlignLeft,
|
||||
AlignFooter: text.AlignLeft,
|
||||
AlignHeader: text.AlignLeft,
|
||||
WidthMax: 12,
|
||||
}
|
||||
}
|
||||
|
||||
func colCountry(n int) table.ColumnConfig {
|
||||
return table.ColumnConfig{
|
||||
Number: n,
|
||||
Align: text.AlignLeft,
|
||||
AlignFooter: text.AlignLeft,
|
||||
AlignHeader: text.AlignLeft,
|
||||
WidthMax: 24,
|
||||
WidthMaxEnforcer: text.Trim,
|
||||
Transformer: func(val any) string {
|
||||
countryCode := val.(int64)
|
||||
return fmt.Sprintf("%v - %s", val, countries.ByNumeric(int(countryCode)).Info().Name)
|
||||
},
|
||||
}
|
||||
}
|
||||
+11
-11
@@ -1,20 +1,19 @@
|
||||
package table_test
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/nmoniz/any2anexoj/internal"
|
||||
"github.com/nmoniz/any2anexoj/internal/writing"
|
||||
"github.com/nmoniz/any2anexoj/internal/writing/table"
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
|
||||
func TestPrinter_Render(t *testing.T) {
|
||||
func TestPrettyPrinter_Render(t *testing.T) {
|
||||
// Create test data
|
||||
aw := internal.NewAggregatorWriter()
|
||||
ctx := t.Context()
|
||||
ctx := context.Background()
|
||||
|
||||
// Add some sample report items
|
||||
err := aw.Write(ctx, internal.ReportItem{
|
||||
@@ -50,14 +49,14 @@ func TestPrinter_Render(t *testing.T) {
|
||||
}
|
||||
|
||||
// Create English localizer
|
||||
localizer, err := writing.NewLocalizer("en")
|
||||
localizer, err := NewLocalizer("en")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create localizer: %v", err)
|
||||
}
|
||||
|
||||
// Create writing printer with buffer
|
||||
// Create pretty printer with buffer
|
||||
var buf bytes.Buffer
|
||||
pp := table.NewPrinter(&buf, localizer)
|
||||
pp := NewPrettyPrinter(&buf, localizer)
|
||||
|
||||
// Render the table
|
||||
pp.Render(aw)
|
||||
@@ -68,17 +67,18 @@ func TestPrinter_Render(t *testing.T) {
|
||||
// Expected output
|
||||
want := `┌───┬────────────────────────────┬───────────────────────────────────┬───────────────────────────────────┬──────────────────────────────────────────────────────────┐
|
||||
│ │ │ REALIZATION │ ACQUISITION │ │
|
||||
│ │ SOURCE COUNTRY │ CODE │ YEAR │ MONTH │ DAY │ VALUE │ YEAR │ MONTH │ DAY │ VALUE │ EXPENSES AND C… │ TAX PAID ABROAD │ COUNTER COUNTRY │
|
||||
│ │ 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.50 € │ 5.50 € │ 12.50 € │ │
|
||||
│ │ SUM │ 376.25 € │ │ 300.5 € │ 5.5 € │ 12.5 € │ │
|
||||
└───┴─────────────────────────────────────────────────┴──────────────┴────────────────────┴──────────────┴─────────────────┴─────────────────┴──────────────────────┘
|
||||
`
|
||||
|
||||
// Compare output
|
||||
if got != want {
|
||||
t.Errorf("Printer.Render() output doesn't match expected.\n\nGot:\n%s\n\nWant:\n%s", got, want)
|
||||
t.Errorf("PrettyPrinter.Render() output doesn't match expected.\n\nGot:\n%s\n\nWant:\n%s", got, want)
|
||||
}
|
||||
}
|
||||
@@ -42,13 +42,5 @@
|
||||
"foreign_tax_paid": {
|
||||
"one": "Tax paid abroad",
|
||||
"other": "Taxes paid abroad"
|
||||
},
|
||||
"sum": {
|
||||
"one": "Sum",
|
||||
"other": "Sums"
|
||||
},
|
||||
"report_title": {
|
||||
"one": "Tax report",
|
||||
"other": "Tax reports"
|
||||
}
|
||||
}
|
||||
@@ -42,13 +42,5 @@
|
||||
"foreign_tax_paid": {
|
||||
"one": "Imposto pago no estrangeiro",
|
||||
"other": "Impostos pagos no estrangeiro"
|
||||
},
|
||||
"sum": {
|
||||
"one": "Soma",
|
||||
"other": "Somas"
|
||||
},
|
||||
"report_title": {
|
||||
"one": "Relatório fiscal",
|
||||
"other": "Relatórios fiscais"
|
||||
}
|
||||
}
|
||||
@@ -1,26 +1,25 @@
|
||||
module github.com/nmoniz/any2anexoj
|
||||
|
||||
go 1.26.4
|
||||
go 1.25.3
|
||||
|
||||
require (
|
||||
github.com/biter777/countries v1.7.5
|
||||
github.com/jedib0t/go-pretty/v6 v6.8.3
|
||||
github.com/shopspring/decimal v1.6.0
|
||||
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.16.0
|
||||
golang.org/x/sync v0.18.0
|
||||
golang.org/x/time v0.14.0
|
||||
)
|
||||
|
||||
require golang.org/x/sync v0.25.0 // indirect
|
||||
|
||||
require (
|
||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||
github.com/nicksnyder/go-i18n/v2 v2.7.0
|
||||
github.com/nicksnyder/go-i18n/v2 v2.6.0
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
golang.org/x/mod v0.42.0 // indirect
|
||||
golang.org/x/sys v0.49.0 // indirect
|
||||
golang.org/x/text v0.44.0
|
||||
golang.org/x/tools v0.52.0 // indirect
|
||||
golang.org/x/mod v0.27.0 // indirect
|
||||
golang.org/x/sys v0.35.0 // indirect
|
||||
golang.org/x/text v0.23.0 // indirect
|
||||
golang.org/x/tools v0.36.0 // indirect
|
||||
)
|
||||
|
||||
tool go.uber.org/mock/mockgen
|
||||
@@ -1,43 +1,41 @@
|
||||
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
||||
github.com/BurntSushi/toml v1.6.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.8.3 h1:yVSk5aemoYHCvcrtqyXklwqcgHQIQzmy/oUzFlmffSQ=
|
||||
github.com/jedib0t/go-pretty/v6 v6.8.3/go.mod h1:YwC5CE4fJ1HFUDeivSV1r//AmANFHyqczZk+U6BDALU=
|
||||
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.7.0 h1:oiVQUmuCeNggvm/fUVb6/R0aP82BDVphZnmVHwut+J0=
|
||||
github.com/nicksnyder/go-i18n/v2 v2.7.0/go.mod h1:ZMYiWg8ndQSGNk4zbzbyJjrrVjQjuQ8pERFrnBik+c8=
|
||||
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.6.0 h1:DcTwjCj4tJPLuGZYsR9W0hyX9pQwMIkdo26Ig1T9xX4=
|
||||
github.com/shopspring/decimal v1.6.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
|
||||
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
|
||||
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
|
||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||
golang.org/x/mod v0.42.0 h1:ICeL3t2L30ljSPHHxDnR4fqVcBt/zR2zJAb9juavmQg=
|
||||
golang.org/x/mod v0.42.0/go.mod h1:u5fzIrRInkL8MaO1cbRkUHT64v303YIT5KEqpNr7jRs=
|
||||
golang.org/x/sync v0.25.0 h1:dJnifr0nMV8/SBnBvdo4eSGGnrc3O4FFx6IqvtLcmRs=
|
||||
golang.org/x/sync v0.25.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
|
||||
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
golang.org/x/text v0.44.0 h1:YItdp/mQNGnsmrgpcdA5yQ3bnRxCfo/Tj/iByGW9GGo=
|
||||
golang.org/x/text v0.44.0/go.mod h1:S4FR1waQ9LbjQTls97SyRjDaUUVkaM+cXwwuG/G+MwY=
|
||||
golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE=
|
||||
golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s=
|
||||
golang.org/x/tools v0.52.0 h1:ryxg4U63oaUYk7uOLTF37n43f4qgtGvqV4Ioit1BaEs=
|
||||
golang.org/x/tools v0.52.0/go.mod h1:ItcP5UNaVGG+Yc4eIYmaFLNJ4soEopkx5Y2d4lcEurs=
|
||||
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
|
||||
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
|
||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
||||
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
||||
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
||||
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
||||
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
|
||||
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -1,6 +1,7 @@
|
||||
package internal_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -90,7 +91,7 @@ func TestAggregatorWriter_Write(t *testing.T) {
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
aw := &internal.AggregatorWriter{}
|
||||
ctx := t.Context()
|
||||
ctx := context.Background()
|
||||
|
||||
for _, item := range tt.items {
|
||||
if err := aw.Write(ctx, item); err != nil {
|
||||
@@ -190,7 +191,7 @@ func TestAggregatorWriter_Rounding(t *testing.T) {
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
aw := &internal.AggregatorWriter{}
|
||||
ctx := t.Context()
|
||||
ctx := context.Background()
|
||||
|
||||
for _, item := range tt.items {
|
||||
if err := aw.Write(ctx, item); err != nil {
|
||||
@@ -208,7 +209,7 @@ func TestAggregatorWriter_Rounding(t *testing.T) {
|
||||
|
||||
func TestAggregatorWriter_Items(t *testing.T) {
|
||||
aw := &internal.AggregatorWriter{}
|
||||
ctx := t.Context()
|
||||
ctx := context.Background()
|
||||
|
||||
for range 5 {
|
||||
item := internal.ReportItem{Symbol: "TEST"}
|
||||
@@ -240,7 +241,7 @@ func TestAggregatorWriter_Items(t *testing.T) {
|
||||
|
||||
func TestAggregatorWriter_ThreadSafety(t *testing.T) {
|
||||
aw := &internal.AggregatorWriter{}
|
||||
ctx := t.Context()
|
||||
ctx := context.Background()
|
||||
|
||||
numGoroutines := 100
|
||||
writesPerGoroutine := 100
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
+2
-18
@@ -22,26 +22,10 @@ func NewFiller(r Record) *Filler {
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
@@ -86,8 +70,8 @@ func (fq *FillerQueue) Push(f *Filler) {
|
||||
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.
|
||||
// Pop removes and returns the first Filler of the queue in the 1st return value. If the list is
|
||||
// empty returns false on the 2nd return value, true otherwise.
|
||||
func (fq *FillerQueue) Pop() (*Filler, bool) {
|
||||
el := fq.frontElement()
|
||||
if el == nil {
|
||||
|
||||
@@ -271,47 +271,3 @@ 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,179 +0,0 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// JSONStore is a Store backed by a single JSON file on disk. The on-disk
|
||||
// schema is the platform-agnostic State struct; per-broker Record data is
|
||||
// encoded and decoded through the supplied RecordSerializer.
|
||||
type JSONStore struct {
|
||||
filename string
|
||||
platform string
|
||||
serializer RecordSerializer
|
||||
}
|
||||
|
||||
// NewJSONStore constructs a JSONStore that reads from and writes to the given
|
||||
// filename. The JSONStore does not keep an *os.File open — Load and Save each
|
||||
// open the file themselves — so callers do not need to close it. The platform
|
||||
// string is recorded into the saved state and validated on Load so a state
|
||||
// file from a different broker cannot be loaded by mistake.
|
||||
func NewJSONStore(filename string, platform string, serializer RecordSerializer) (*JSONStore, error) {
|
||||
if filename == "" {
|
||||
return nil, fmt.Errorf("filename cannot be empty")
|
||||
}
|
||||
if serializer == nil {
|
||||
return nil, fmt.Errorf("serializer cannot be nil")
|
||||
}
|
||||
return &JSONStore{
|
||||
filename: filename,
|
||||
platform: platform,
|
||||
serializer: serializer,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Load reads the state file and reconstructs the per-symbol FillerQueue map.
|
||||
// A missing file is not an error — it returns an empty map so the first run
|
||||
// against a new state file Just Works.
|
||||
func (fs *JSONStore) Load(ctx context.Context) (map[string]*FillerQueue, error) {
|
||||
data, err := os.ReadFile(fs.filename)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return make(map[string]*FillerQueue), nil
|
||||
}
|
||||
return nil, fmt.Errorf("reading state file: %w", err)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return make(map[string]*FillerQueue), nil
|
||||
}
|
||||
|
||||
var s State
|
||||
if err := json.Unmarshal(data, &s); err != nil {
|
||||
return nil, fmt.Errorf("unmarshalling state: %w", err)
|
||||
}
|
||||
|
||||
if s.Version != StateVersion {
|
||||
return nil, fmt.Errorf(
|
||||
"unexpected state version %q: expected %q",
|
||||
s.Version, StateVersion,
|
||||
)
|
||||
}
|
||||
|
||||
if s.Platform != fs.platform {
|
||||
return nil, fmt.Errorf(
|
||||
"unexpected state platform %q: expected %q",
|
||||
s.Platform, fs.platform,
|
||||
)
|
||||
}
|
||||
|
||||
queues := make(map[string]*FillerQueue)
|
||||
for symbol, persisted := range s.Queues {
|
||||
q := new(FillerQueue)
|
||||
for _, pf := range persisted {
|
||||
rec, err := fs.serializer.UnmarshalRecord(ctx, pf.RecordData)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"unmarshalling record for symbol %q: %w", symbol, err,
|
||||
)
|
||||
}
|
||||
q.Push(NewFillerFromState(rec, pf.Quantity, pf.Price, pf.Filled))
|
||||
}
|
||||
queues[symbol] = q
|
||||
}
|
||||
return queues, nil
|
||||
}
|
||||
|
||||
// Save serialises the queue map to disk. The write is done via a temp file in
|
||||
// the same directory followed by an atomic rename so a crash mid-write cannot
|
||||
// leave a half-written state file.
|
||||
func (fs *JSONStore) Save(ctx context.Context, queue map[string]*FillerQueue) error {
|
||||
queues := make(map[string][]persistedFiller)
|
||||
latest := time.Time{}
|
||||
earliest := time.Time{}
|
||||
|
||||
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(),
|
||||
})
|
||||
|
||||
if f.Timestamp().After(latest) {
|
||||
latest = f.Timestamp()
|
||||
}
|
||||
if f.Timestamp().Before(earliest) || earliest.IsZero() {
|
||||
earliest = f.Timestamp()
|
||||
}
|
||||
}
|
||||
queues[symbol] = persisted
|
||||
}
|
||||
|
||||
state := State{
|
||||
Version: StateVersion,
|
||||
Platform: fs.platform,
|
||||
Queues: queues,
|
||||
Latest: latest,
|
||||
Earliest: earliest,
|
||||
}
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
enc := json.NewEncoder(buf)
|
||||
enc.SetIndent("", " ")
|
||||
if err := enc.Encode(state); err != nil {
|
||||
return fmt.Errorf("encoding state: %w", err)
|
||||
}
|
||||
|
||||
tmp, err := os.CreateTemp(filepath.Dir(fs.filename), filepath.Base(fs.filename)+".*.tmp")
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating temp state file: %w", err)
|
||||
}
|
||||
tmpFilename := tmp.Name()
|
||||
defer os.Remove(tmpFilename)
|
||||
defer tmp.Close()
|
||||
|
||||
if _, err := io.Copy(tmp, buf); err != nil {
|
||||
return fmt.Errorf("writing temp state file: %w", err)
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
return fmt.Errorf("syncing temp state file: %w", err)
|
||||
}
|
||||
|
||||
// Closing the temp file before renaming/moving for cross-platform compatibility
|
||||
if err := tmp.Close(); err != nil {
|
||||
return fmt.Errorf("closing temp state file: %w", err)
|
||||
}
|
||||
|
||||
ext := filepath.Ext(fs.filename)
|
||||
name, _ := strings.CutSuffix(fs.filename, ext)
|
||||
backupFilename := name + "." + time.Now().Format("2006-01-02_150405") + ext
|
||||
if err := os.Rename(fs.filename, backupFilename); err != nil {
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
return fmt.Errorf("rename old state file: %w", err)
|
||||
}
|
||||
}
|
||||
if err := os.Rename(tmpFilename, fs.filename); err != nil {
|
||||
return fmt.Errorf("atomic rename of state file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,496 +0,0 @@
|
||||
package internal_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/nmoniz/any2anexoj/internal"
|
||||
"github.com/nmoniz/any2anexoj/internal/mocks"
|
||||
"github.com/shopspring/decimal"
|
||||
"go.uber.org/mock/gomock"
|
||||
)
|
||||
|
||||
func TestJSONStore_RoundTrip(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
ser := roundTripSerializer(ctrl)
|
||||
store, path := newStore(t, "fake", ser)
|
||||
|
||||
original := map[string]*internal.FillerQueue{
|
||||
"AAA": newQueue(
|
||||
newFiller(ctrl, "AAA", 100, 50, 0),
|
||||
newFiller(ctrl, "AAA", 25, 80, 5),
|
||||
),
|
||||
"BBB": newQueue(
|
||||
newFiller(ctrl, "BBB", 7, 1000, 7),
|
||||
),
|
||||
}
|
||||
|
||||
if err := store.Save(t.Context(), original); err != nil {
|
||||
t.Fatalf("Save returned unexpected error: %v", err)
|
||||
}
|
||||
|
||||
loaded, err := store.Load(t.Context())
|
||||
if err != nil {
|
||||
t.Fatalf("Load returned unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if len(loaded) != len(original) {
|
||||
t.Fatalf("want %d symbols but got %d", len(original), len(loaded))
|
||||
}
|
||||
for symbol, wantQ := range original {
|
||||
gotQ, ok := loaded[symbol]
|
||||
if !ok {
|
||||
t.Fatalf("symbol %q missing from loaded state", symbol)
|
||||
}
|
||||
assertQueueEqual(t, symbol, gotQ, wantQ)
|
||||
}
|
||||
|
||||
// Regression: the on-disk JSON key for the per-record blob must be
|
||||
// "record_data" (renamed from "reader_data") so the field name stays
|
||||
// honest about what it carries.
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read state file: %v", err)
|
||||
}
|
||||
body := string(data)
|
||||
if strings.Contains(body, `"reader_data"`) {
|
||||
t.Errorf("saved state file still contains legacy key \"reader_data\"; want only \"record_data\"")
|
||||
}
|
||||
if !strings.Contains(body, `"record_data"`) {
|
||||
t.Errorf("saved state file does not contain expected key \"record_data\"")
|
||||
}
|
||||
|
||||
// Regression: the "record_data" value must be inline JSON, not a base64
|
||||
// blob. Version-1 state files encoded the per-record JSON as a base64
|
||||
// string (which always starts with "ey" — base64 of '{' followed by '"').
|
||||
// After Chunk 1 of the refactor the value is json.RawMessage, which the
|
||||
// JSON encoder emits inline. roundTripSerializer emits a JSON string per
|
||||
// symbol, so the file should contain `"record_data": "AAA"` etc.
|
||||
if strings.Contains(body, `"record_data": "ey`) {
|
||||
t.Errorf("saved state file still has base64-encoded record_data (version-1 format); want inline JSON")
|
||||
}
|
||||
wantInlineAAA := `"record_data": "AAA"`
|
||||
if !strings.Contains(body, wantInlineAAA) {
|
||||
t.Errorf("saved state file does not contain inline JSON %q under record_data; got:\n%s", wantInlineAAA, body)
|
||||
}
|
||||
|
||||
// Sanity: the file must be valid top-level JSON (it always was, but
|
||||
// confirming the inline form is parseable guards against accidentally
|
||||
// double-encoding it again in the future).
|
||||
var parsed struct {
|
||||
Latest time.Time `json:"latest"`
|
||||
Earliest time.Time `json:"earliest"`
|
||||
Queues map[string][]struct {
|
||||
RecordData json.RawMessage `json:"record_data"`
|
||||
} `json:"queues"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &parsed); err != nil {
|
||||
t.Fatalf("saved state file is not valid JSON: %v", err)
|
||||
}
|
||||
|
||||
// The latest/earliest bounds must be persisted alongside the queues.
|
||||
// Every record in this test returns testRecordTime, so both bounds must
|
||||
// equal it exactly.
|
||||
if !parsed.Latest.Equal(testRecordTime) {
|
||||
t.Errorf("state latest = %v; want %v", parsed.Latest, testRecordTime)
|
||||
}
|
||||
if !parsed.Earliest.Equal(testRecordTime) {
|
||||
t.Errorf("state earliest = %v; want %v", parsed.Earliest, testRecordTime)
|
||||
}
|
||||
for symbol, lots := range parsed.Queues {
|
||||
for i, lot := range lots {
|
||||
if len(lot.RecordData) == 0 {
|
||||
t.Errorf("symbol %q lot %d: record_data is empty", symbol, i)
|
||||
}
|
||||
if !json.Valid(lot.RecordData) {
|
||||
t.Errorf("symbol %q lot %d: record_data is not valid JSON: %s", symbol, i, lot.RecordData)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONStore_SaveSkipsEmptyQueue(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
ser := roundTripSerializer(ctrl)
|
||||
store, path := newStore(t, "fake", ser)
|
||||
|
||||
// A non-nil FillerQueue whose underlying list is nil — i.e. a symbol
|
||||
// that was registered but never received a Push. Save must not panic
|
||||
// when iterating and must not emit any entry for that symbol.
|
||||
queues := map[string]*internal.FillerQueue{
|
||||
"EMPTY": new(internal.FillerQueue),
|
||||
"REAL": newQueue(newFiller(ctrl, "REAL", 5, 10, 0)),
|
||||
}
|
||||
|
||||
if err := store.Save(t.Context(), queues); err != nil {
|
||||
t.Fatalf("Save returned unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read state file: %v", err)
|
||||
}
|
||||
body := string(data)
|
||||
if strings.Contains(body, `"EMPTY"`) {
|
||||
t.Errorf("saved state file contains entry for empty queue \"EMPTY\"; want it skipped")
|
||||
}
|
||||
if !strings.Contains(body, `"REAL"`) {
|
||||
t.Errorf("saved state file missing expected entry for \"REAL\"")
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONStore_LoadEmptyFileReturnsEmpty(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
ser := roundTripSerializer(ctrl)
|
||||
store, path := newStore(t, "fake", ser)
|
||||
|
||||
// Pre-create the state file as a zero-byte file. Load must treat this
|
||||
// the same as a missing file rather than failing JSON unmarshal.
|
||||
if err := os.WriteFile(path, []byte{}, 0o644); err != nil {
|
||||
t.Fatalf("write empty state file: %v", err)
|
||||
}
|
||||
|
||||
queues, err := store.Load(t.Context())
|
||||
if err != nil {
|
||||
t.Fatalf("Load returned unexpected error for empty file: %v", err)
|
||||
}
|
||||
if queues == nil {
|
||||
t.Fatalf("Load returned nil map; want empty map")
|
||||
}
|
||||
if len(queues) != 0 {
|
||||
t.Fatalf("Load returned %d entries; want 0", len(queues))
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONStore_LoadMissingFileReturnsEmpty(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
ser := roundTripSerializer(ctrl)
|
||||
store, path := newStore(t, "fake", ser)
|
||||
|
||||
// Sanity: file doesn't exist.
|
||||
if _, err := os.Stat(path); !errors.Is(err, fs.ErrNotExist) {
|
||||
t.Fatalf("expected state file to be absent, got stat err: %v", err)
|
||||
}
|
||||
|
||||
queues, err := store.Load(t.Context())
|
||||
if err != nil {
|
||||
t.Fatalf("Load returned unexpected error for missing file: %v", err)
|
||||
}
|
||||
if queues == nil {
|
||||
t.Fatalf("Load returned nil map; want empty map")
|
||||
}
|
||||
if len(queues) != 0 {
|
||||
t.Fatalf("Load returned %d entries; want 0", len(queues))
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONStore_LoadVersionMismatch(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
ser := roundTripSerializer(ctrl)
|
||||
store, path := newStore(t, "fake", ser)
|
||||
|
||||
// Hand-craft an unsupported-version state file.
|
||||
bad := struct {
|
||||
Version string `json:"version"`
|
||||
Platform string `json:"platform"`
|
||||
Queues map[string][]json.RawMessage `json:"queues"`
|
||||
}{
|
||||
Version: "999",
|
||||
Platform: "fake",
|
||||
Queues: map[string][]json.RawMessage{},
|
||||
}
|
||||
data, err := json.MarshalIndent(bad, "", " ")
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(path, data, 0o644); err != nil {
|
||||
t.Fatalf("write state: %v", err)
|
||||
}
|
||||
|
||||
_, err = store.Load(t.Context())
|
||||
if err == nil {
|
||||
t.Fatalf("Load with bad version should return an error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), `unexpected state version "999"`) {
|
||||
t.Errorf("expected version-mismatch error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONStore_LoadPlatformMismatch(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
ser := roundTripSerializer(ctrl)
|
||||
store, path := newStore(t, "fake", ser)
|
||||
|
||||
// File claims a different platform.
|
||||
bad := struct {
|
||||
Version string `json:"version"`
|
||||
Platform string `json:"platform"`
|
||||
Queues map[string][]json.RawMessage `json:"queues"`
|
||||
}{
|
||||
Version: internal.StateVersion,
|
||||
Platform: "other-broker",
|
||||
Queues: map[string][]json.RawMessage{},
|
||||
}
|
||||
data, err := json.MarshalIndent(bad, "", " ")
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(path, data, 0o644); err != nil {
|
||||
t.Fatalf("write state: %v", err)
|
||||
}
|
||||
|
||||
_, err = store.Load(t.Context())
|
||||
if err == nil {
|
||||
t.Fatalf("Load with mismatched platform should return an error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), `unexpected state platform "other-broker"`) {
|
||||
t.Errorf("expected platform-mismatch error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONStore_SplitAdjustedLotSurvivesRoundTrip(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
ser := roundTripSerializer(ctrl)
|
||||
store, _ := newStore(t, "fake", ser)
|
||||
|
||||
// Simulate a lot that has been through a 5:1 split and is partially
|
||||
// filled. Starting from 10 shares @ $100, after a 5:1 split we should
|
||||
// have 50 shares @ $20 with 20 already filled.
|
||||
f := newFiller(ctrl, "SPLIT", 10, 100, 0)
|
||||
f.ApplySplit(decimal.NewFromInt(5))
|
||||
f.Fill(decimal.NewFromInt(20))
|
||||
|
||||
q := newQueue(f)
|
||||
|
||||
if err := store.Save(t.Context(), map[string]*internal.FillerQueue{"SPLIT": q}); err != nil {
|
||||
t.Fatalf("Save returned unexpected error: %v", err)
|
||||
}
|
||||
|
||||
loaded, err := store.Load(t.Context())
|
||||
if err != nil {
|
||||
t.Fatalf("Load returned unexpected error: %v", err)
|
||||
}
|
||||
gotQ := loaded["SPLIT"]
|
||||
if gotQ == nil || gotQ.Len() != 1 {
|
||||
t.Fatalf("want 1 lot for SPLIT, got %d", gotQ.Len())
|
||||
}
|
||||
got, _ := gotQ.Pop()
|
||||
if !got.Quantity().Equal(decimal.NewFromInt(50)) {
|
||||
t.Errorf("want quantity 50 but got %v", got.Quantity())
|
||||
}
|
||||
if !got.Price().Equal(decimal.NewFromInt(20)) {
|
||||
t.Errorf("want price 20 but got %v", got.Price())
|
||||
}
|
||||
if !got.Filled().Equal(decimal.NewFromInt(20)) {
|
||||
t.Errorf("want filled 20 but got %v", got.Filled())
|
||||
}
|
||||
if got.IsFilled() {
|
||||
t.Errorf("want IsFilled() to be false after split-adjusted partial fill")
|
||||
}
|
||||
|
||||
// Cost basis must round-trip exactly.
|
||||
if !got.Quantity().Mul(got.Price()).Equal(decimal.NewFromInt(1000)) {
|
||||
t.Errorf("want cost basis 1000 but got %v", got.Quantity().Mul(got.Price()))
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONStore_PartiallyFilledLotSurvivesRoundTrip(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
ser := roundTripSerializer(ctrl)
|
||||
store, _ := newStore(t, "fake", ser)
|
||||
|
||||
// A non-split lot that has been partially filled.
|
||||
f := newFiller(ctrl, "PART", 100, 50, 30)
|
||||
q := newQueue(f)
|
||||
|
||||
if err := store.Save(t.Context(), map[string]*internal.FillerQueue{"PART": q}); err != nil {
|
||||
t.Fatalf("Save returned unexpected error: %v", err)
|
||||
}
|
||||
|
||||
loaded, err := store.Load(t.Context())
|
||||
if err != nil {
|
||||
t.Fatalf("Load returned unexpected error: %v", err)
|
||||
}
|
||||
gotQ := loaded["PART"]
|
||||
if gotQ == nil || gotQ.Len() != 1 {
|
||||
t.Fatalf("want 1 lot for PART, got %d", gotQ.Len())
|
||||
}
|
||||
got, _ := gotQ.Pop()
|
||||
if !got.Quantity().Equal(decimal.NewFromInt(100)) {
|
||||
t.Errorf("want quantity 100 but got %v", got.Quantity())
|
||||
}
|
||||
if !got.Price().Equal(decimal.NewFromInt(50)) {
|
||||
t.Errorf("want price 50 but got %v", got.Price())
|
||||
}
|
||||
if !got.Filled().Equal(decimal.NewFromInt(30)) {
|
||||
t.Errorf("want filled 30 but got %v", got.Filled())
|
||||
}
|
||||
if got.IsFilled() {
|
||||
t.Errorf("want IsFilled() to be false (30/100 filled)")
|
||||
}
|
||||
// Filling the remaining 70 must now make it filled.
|
||||
_, done := got.Fill(decimal.NewFromInt(70))
|
||||
if !done {
|
||||
t.Errorf("after filling remaining 70, IsFilled() should be true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewJSONStore_ValidatesArguments(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
ser := mocks.NewMockRecordSerializer(ctrl)
|
||||
|
||||
if _, err := internal.NewJSONStore("", "fake", ser); err == nil {
|
||||
t.Errorf("NewJSONStore with empty filename should fail")
|
||||
}
|
||||
if _, err := internal.NewJSONStore("/tmp/x", "fake", nil); err == nil {
|
||||
t.Errorf("NewJSONStore with nil serializer should fail")
|
||||
}
|
||||
}
|
||||
|
||||
// testRecordTime is the deterministic timestamp returned by every record
|
||||
// built in this file. Using a fixed value keeps the latest/earliest bounds
|
||||
// written by JSONStore.Save predictable and assertable.
|
||||
var testRecordTime = time.Date(2024, 6, 15, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
// newRecord builds a MockRecord whose Symbol() returns the given symbol and
|
||||
// whose Timestamp() returns testRecordTime. The JSONStore only reads Symbol()
|
||||
// and Timestamp() off the 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()
|
||||
r.EXPECT().Timestamp().Return(testRecordTime).AnyTimes()
|
||||
return r
|
||||
}
|
||||
|
||||
// newFiller builds a Filler backed by a MockRecord with the given symbol,
|
||||
// quantity, price, and filled amounts (in whole units). It mirrors the
|
||||
// inline calls that previously littered every test.
|
||||
func newFiller(ctrl *gomock.Controller, symbol string, quantity, price, filled int64) *internal.Filler {
|
||||
return internal.NewFillerFromState(
|
||||
newRecord(ctrl, symbol),
|
||||
decimal.NewFromInt(quantity),
|
||||
decimal.NewFromInt(price),
|
||||
decimal.NewFromInt(filled),
|
||||
)
|
||||
}
|
||||
|
||||
// newQueue creates a FillerQueue pre-populated with the given fillers.
|
||||
func newQueue(fillers ...*internal.Filler) *internal.FillerQueue {
|
||||
q := new(internal.FillerQueue)
|
||||
for _, f := range fillers {
|
||||
q.Push(f)
|
||||
}
|
||||
return q
|
||||
}
|
||||
|
||||
// newStore creates a JSONStore backed by a temp file and returns the store
|
||||
// plus the path to the underlying state file.
|
||||
func newStore(t *testing.T, platform string, ser internal.RecordSerializer) (*internal.JSONStore, string) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "state.json")
|
||||
store, err := internal.NewJSONStore(path, platform, ser)
|
||||
if err != nil {
|
||||
t.Fatalf("NewJSONStore returned unexpected error: %v", err)
|
||||
}
|
||||
return store, path
|
||||
}
|
||||
|
||||
// roundTripSerializer returns a serializer mock whose MarshalRecord encodes
|
||||
// the Symbol as a JSON string (e.g. "AAA") and whose UnmarshalRecord decodes
|
||||
// those JSON bytes back into a fresh MockRecord returning the encoded Symbol.
|
||||
// Tests use this when they need Save + Load to round-trip equivalent Records.
|
||||
func roundTripSerializer(ctrl *gomock.Controller) *mocks.MockRecordSerializer {
|
||||
ser := mocks.NewMockRecordSerializer(ctrl)
|
||||
ser.EXPECT().
|
||||
MarshalRecord(gomock.Any(), gomock.Any()).
|
||||
DoAndReturn(func(_ context.Context, r internal.Record) (json.RawMessage, error) {
|
||||
return json.Marshal(r.Symbol())
|
||||
}).
|
||||
AnyTimes()
|
||||
ser.EXPECT().
|
||||
UnmarshalRecord(gomock.Any(), gomock.Any()).
|
||||
DoAndReturn(func(_ context.Context, b json.RawMessage) (internal.Record, error) {
|
||||
var symbol string
|
||||
if err := json.Unmarshal(b, &symbol); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return newRecord(ctrl, symbol), nil
|
||||
}).
|
||||
AnyTimes()
|
||||
return ser
|
||||
}
|
||||
|
||||
// queueSnapshot drains the given FillerQueue (via Pop) and returns its
|
||||
// contents in a side-effect-free shape suitable for value comparison.
|
||||
// Callers should not use the queue afterwards.
|
||||
func queueSnapshot(q *internal.FillerQueue) []queueEntry {
|
||||
if q == nil {
|
||||
return nil
|
||||
}
|
||||
var out []queueEntry
|
||||
for {
|
||||
f, ok := q.Pop()
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
out = append(out, queueEntry{
|
||||
symbol: f.Symbol(),
|
||||
quantity: f.Quantity(),
|
||||
price: f.Price(),
|
||||
filled: f.Filled(),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// assertQueueEqual compares two FillerQueues by popping every element from
|
||||
// each and comparing the resulting sequence of queueEntries. Both queues are
|
||||
// drained as a side effect.
|
||||
func assertQueueEqual(t *testing.T, symbol string, got, want *internal.FillerQueue) {
|
||||
t.Helper()
|
||||
if got == nil {
|
||||
t.Fatalf("symbol %q: loaded queue is nil", symbol)
|
||||
}
|
||||
if got.Len() != want.Len() {
|
||||
t.Fatalf("symbol %q: want %d lots but got %d", symbol, want.Len(), got.Len())
|
||||
}
|
||||
wantEntries := queueSnapshot(want)
|
||||
gotEntries := queueSnapshot(got)
|
||||
for i := range wantEntries {
|
||||
w := wantEntries[i]
|
||||
g := gotEntries[i]
|
||||
if w.symbol != g.symbol {
|
||||
t.Errorf("symbol %q lot %d: want symbol %q but got %q",
|
||||
symbol, i, w.symbol, g.symbol)
|
||||
}
|
||||
if !w.quantity.Equal(g.quantity) {
|
||||
t.Errorf("symbol %q lot %d: want quantity %v but got %v",
|
||||
symbol, i, w.quantity, g.quantity)
|
||||
}
|
||||
if !w.price.Equal(g.price) {
|
||||
t.Errorf("symbol %q lot %d: want price %v but got %v",
|
||||
symbol, i, w.price, g.price)
|
||||
}
|
||||
if !w.filled.Equal(g.filled) {
|
||||
t.Errorf("symbol %q lot %d: want filled %v but got %v",
|
||||
symbol, i, w.filled, g.filled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type queueEntry struct {
|
||||
symbol string
|
||||
quantity decimal.Decimal
|
||||
price decimal.Decimal
|
||||
filled decimal.Decimal
|
||||
}
|
||||
+5
-11
@@ -7,12 +7,11 @@ const (
|
||||
KindBuy
|
||||
KindSell
|
||||
KindSplit
|
||||
sentinelKind
|
||||
)
|
||||
|
||||
// String returns a unique string value for Kind k
|
||||
func (k Kind) String() string {
|
||||
switch k {
|
||||
// String returns a human readable value
|
||||
func (d Kind) String() string {
|
||||
switch d {
|
||||
case KindBuy:
|
||||
return "buy"
|
||||
case KindSell:
|
||||
@@ -24,13 +23,8 @@ func (k Kind) String() string {
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
// Is returns true when k equals o
|
||||
func (k Kind) Is(o any) bool {
|
||||
other, ok := o.(Kind)
|
||||
return ok && k.Valid() && k == other
|
||||
return ok && k == other
|
||||
}
|
||||
+45
-80
@@ -1,95 +1,60 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
import "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)
|
||||
tests := []struct {
|
||||
name string
|
||||
side Kind
|
||||
want string
|
||||
}{
|
||||
{"buy", KindBuy, "buy"},
|
||||
{"sell", KindSell, "sell"},
|
||||
{"unknown", KindUnknown, "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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if KindUnknown.String() != unknown {
|
||||
t.Errorf("want Kind(0) to be unknown")
|
||||
func TestSide_IsBuy(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
side Kind
|
||||
want bool
|
||||
}{
|
||||
{"buy", KindBuy, true},
|
||||
{"sell", KindSell, false},
|
||||
{"unknown", KindUnknown, false},
|
||||
}
|
||||
|
||||
if Kind(sentinelKind).String() != unknown {
|
||||
t.Errorf("want Kind(%d) to be unknown", sentinelKind)
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := tt.side.Is(KindBuy); got != tt.want {
|
||||
t.Errorf("want Side.IsBuy() to be %v but got %v", tt.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
func TestSide_IsSell(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
side Kind
|
||||
want bool
|
||||
}{
|
||||
{"buy", KindBuy, false},
|
||||
{"sell", KindSell, true},
|
||||
{"unknown", KindUnknown, false},
|
||||
}
|
||||
|
||||
if KindUnknown.Valid() {
|
||||
t.Errorf("want Kind(0) to be invalid")
|
||||
}
|
||||
|
||||
if Kind(sentinelKind).Valid() {
|
||||
t.Errorf("want Kind(%d) to be invalid", sentinelKind)
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := tt.side.Is(KindSell); got != tt.want {
|
||||
t.Errorf("want Side.IsSell() to be %v but got %v", tt.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
+2
-231
@@ -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: github.com/nmoniz/any2anexoj/internal (interfaces: RecordReader,Record,ReportWriter)
|
||||
//
|
||||
// Generated by this command:
|
||||
//
|
||||
// mockgen -destination=mocks/mocks_gen.go -package=mocks -typed . RecordReader,Record,ReportWriter,RecordEncoder,RecordDecoder,RecordSerializer
|
||||
// mockgen -destination=mocks/mocks_gen.go -package=mocks -typed . RecordReader,Record,ReportWriter
|
||||
//
|
||||
|
||||
// Package mocks is a generated GoMock package.
|
||||
@@ -11,7 +11,6 @@ package mocks
|
||||
|
||||
import (
|
||||
context "context"
|
||||
json "encoding/json"
|
||||
reflect "reflect"
|
||||
time "time"
|
||||
|
||||
@@ -548,231 +547,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) (json.RawMessage, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "MarshalRecord", arg0, arg1)
|
||||
ret0, _ := ret[0].(json.RawMessage)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// MarshalRecord indicates an expected call of MarshalRecord.
|
||||
func (mr *MockRecordEncoderMockRecorder) MarshalRecord(arg0, arg1 any) *MockRecordEncoderMarshalRecordCall {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MarshalRecord", reflect.TypeOf((*MockRecordEncoder)(nil).MarshalRecord), arg0, arg1)
|
||||
return &MockRecordEncoderMarshalRecordCall{Call: call}
|
||||
}
|
||||
|
||||
// MockRecordEncoderMarshalRecordCall wrap *gomock.Call
|
||||
type MockRecordEncoderMarshalRecordCall struct {
|
||||
*gomock.Call
|
||||
}
|
||||
|
||||
// Return rewrite *gomock.Call.Return
|
||||
func (c *MockRecordEncoderMarshalRecordCall) Return(arg0 json.RawMessage, arg1 error) *MockRecordEncoderMarshalRecordCall {
|
||||
c.Call = c.Call.Return(arg0, arg1)
|
||||
return c
|
||||
}
|
||||
|
||||
// Do rewrite *gomock.Call.Do
|
||||
func (c *MockRecordEncoderMarshalRecordCall) Do(f func(context.Context, internal.Record) (json.RawMessage, error)) *MockRecordEncoderMarshalRecordCall {
|
||||
c.Call = c.Call.Do(f)
|
||||
return c
|
||||
}
|
||||
|
||||
// DoAndReturn rewrite *gomock.Call.DoAndReturn
|
||||
func (c *MockRecordEncoderMarshalRecordCall) DoAndReturn(f func(context.Context, internal.Record) (json.RawMessage, error)) *MockRecordEncoderMarshalRecordCall {
|
||||
c.Call = c.Call.DoAndReturn(f)
|
||||
return c
|
||||
}
|
||||
|
||||
// MockRecordDecoder is a mock of RecordDecoder interface.
|
||||
type MockRecordDecoder struct {
|
||||
ctrl *gomock.Controller
|
||||
recorder *MockRecordDecoderMockRecorder
|
||||
isgomock struct{}
|
||||
}
|
||||
|
||||
// MockRecordDecoderMockRecorder is the mock recorder for MockRecordDecoder.
|
||||
type MockRecordDecoderMockRecorder struct {
|
||||
mock *MockRecordDecoder
|
||||
}
|
||||
|
||||
// NewMockRecordDecoder creates a new mock instance.
|
||||
func NewMockRecordDecoder(ctrl *gomock.Controller) *MockRecordDecoder {
|
||||
mock := &MockRecordDecoder{ctrl: ctrl}
|
||||
mock.recorder = &MockRecordDecoderMockRecorder{mock}
|
||||
return mock
|
||||
}
|
||||
|
||||
// EXPECT returns an object that allows the caller to indicate expected use.
|
||||
func (m *MockRecordDecoder) EXPECT() *MockRecordDecoderMockRecorder {
|
||||
return m.recorder
|
||||
}
|
||||
|
||||
// UnmarshalRecord mocks base method.
|
||||
func (m *MockRecordDecoder) UnmarshalRecord(arg0 context.Context, arg1 json.RawMessage) (internal.Record, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "UnmarshalRecord", arg0, arg1)
|
||||
ret0, _ := ret[0].(internal.Record)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// UnmarshalRecord indicates an expected call of UnmarshalRecord.
|
||||
func (mr *MockRecordDecoderMockRecorder) UnmarshalRecord(arg0, arg1 any) *MockRecordDecoderUnmarshalRecordCall {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UnmarshalRecord", reflect.TypeOf((*MockRecordDecoder)(nil).UnmarshalRecord), arg0, arg1)
|
||||
return &MockRecordDecoderUnmarshalRecordCall{Call: call}
|
||||
}
|
||||
|
||||
// MockRecordDecoderUnmarshalRecordCall wrap *gomock.Call
|
||||
type MockRecordDecoderUnmarshalRecordCall struct {
|
||||
*gomock.Call
|
||||
}
|
||||
|
||||
// Return rewrite *gomock.Call.Return
|
||||
func (c *MockRecordDecoderUnmarshalRecordCall) Return(arg0 internal.Record, arg1 error) *MockRecordDecoderUnmarshalRecordCall {
|
||||
c.Call = c.Call.Return(arg0, arg1)
|
||||
return c
|
||||
}
|
||||
|
||||
// Do rewrite *gomock.Call.Do
|
||||
func (c *MockRecordDecoderUnmarshalRecordCall) Do(f func(context.Context, json.RawMessage) (internal.Record, error)) *MockRecordDecoderUnmarshalRecordCall {
|
||||
c.Call = c.Call.Do(f)
|
||||
return c
|
||||
}
|
||||
|
||||
// DoAndReturn rewrite *gomock.Call.DoAndReturn
|
||||
func (c *MockRecordDecoderUnmarshalRecordCall) DoAndReturn(f func(context.Context, json.RawMessage) (internal.Record, error)) *MockRecordDecoderUnmarshalRecordCall {
|
||||
c.Call = c.Call.DoAndReturn(f)
|
||||
return c
|
||||
}
|
||||
|
||||
// MockRecordSerializer is a mock of RecordSerializer interface.
|
||||
type MockRecordSerializer struct {
|
||||
ctrl *gomock.Controller
|
||||
recorder *MockRecordSerializerMockRecorder
|
||||
isgomock struct{}
|
||||
}
|
||||
|
||||
// MockRecordSerializerMockRecorder is the mock recorder for MockRecordSerializer.
|
||||
type MockRecordSerializerMockRecorder struct {
|
||||
mock *MockRecordSerializer
|
||||
}
|
||||
|
||||
// NewMockRecordSerializer creates a new mock instance.
|
||||
func NewMockRecordSerializer(ctrl *gomock.Controller) *MockRecordSerializer {
|
||||
mock := &MockRecordSerializer{ctrl: ctrl}
|
||||
mock.recorder = &MockRecordSerializerMockRecorder{mock}
|
||||
return mock
|
||||
}
|
||||
|
||||
// EXPECT returns an object that allows the caller to indicate expected use.
|
||||
func (m *MockRecordSerializer) EXPECT() *MockRecordSerializerMockRecorder {
|
||||
return m.recorder
|
||||
}
|
||||
|
||||
// MarshalRecord mocks base method.
|
||||
func (m *MockRecordSerializer) MarshalRecord(arg0 context.Context, arg1 internal.Record) (json.RawMessage, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "MarshalRecord", arg0, arg1)
|
||||
ret0, _ := ret[0].(json.RawMessage)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// MarshalRecord indicates an expected call of MarshalRecord.
|
||||
func (mr *MockRecordSerializerMockRecorder) MarshalRecord(arg0, arg1 any) *MockRecordSerializerMarshalRecordCall {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MarshalRecord", reflect.TypeOf((*MockRecordSerializer)(nil).MarshalRecord), arg0, arg1)
|
||||
return &MockRecordSerializerMarshalRecordCall{Call: call}
|
||||
}
|
||||
|
||||
// MockRecordSerializerMarshalRecordCall wrap *gomock.Call
|
||||
type MockRecordSerializerMarshalRecordCall struct {
|
||||
*gomock.Call
|
||||
}
|
||||
|
||||
// Return rewrite *gomock.Call.Return
|
||||
func (c *MockRecordSerializerMarshalRecordCall) Return(arg0 json.RawMessage, arg1 error) *MockRecordSerializerMarshalRecordCall {
|
||||
c.Call = c.Call.Return(arg0, arg1)
|
||||
return c
|
||||
}
|
||||
|
||||
// Do rewrite *gomock.Call.Do
|
||||
func (c *MockRecordSerializerMarshalRecordCall) Do(f func(context.Context, internal.Record) (json.RawMessage, error)) *MockRecordSerializerMarshalRecordCall {
|
||||
c.Call = c.Call.Do(f)
|
||||
return c
|
||||
}
|
||||
|
||||
// DoAndReturn rewrite *gomock.Call.DoAndReturn
|
||||
func (c *MockRecordSerializerMarshalRecordCall) DoAndReturn(f func(context.Context, internal.Record) (json.RawMessage, error)) *MockRecordSerializerMarshalRecordCall {
|
||||
c.Call = c.Call.DoAndReturn(f)
|
||||
return c
|
||||
}
|
||||
|
||||
// UnmarshalRecord mocks base method.
|
||||
func (m *MockRecordSerializer) UnmarshalRecord(arg0 context.Context, arg1 json.RawMessage) (internal.Record, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "UnmarshalRecord", arg0, arg1)
|
||||
ret0, _ := ret[0].(internal.Record)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// UnmarshalRecord indicates an expected call of UnmarshalRecord.
|
||||
func (mr *MockRecordSerializerMockRecorder) UnmarshalRecord(arg0, arg1 any) *MockRecordSerializerUnmarshalRecordCall {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UnmarshalRecord", reflect.TypeOf((*MockRecordSerializer)(nil).UnmarshalRecord), arg0, arg1)
|
||||
return &MockRecordSerializerUnmarshalRecordCall{Call: call}
|
||||
}
|
||||
|
||||
// MockRecordSerializerUnmarshalRecordCall wrap *gomock.Call
|
||||
type MockRecordSerializerUnmarshalRecordCall struct {
|
||||
*gomock.Call
|
||||
}
|
||||
|
||||
// Return rewrite *gomock.Call.Return
|
||||
func (c *MockRecordSerializerUnmarshalRecordCall) Return(arg0 internal.Record, arg1 error) *MockRecordSerializerUnmarshalRecordCall {
|
||||
c.Call = c.Call.Return(arg0, arg1)
|
||||
return c
|
||||
}
|
||||
|
||||
// Do rewrite *gomock.Call.Do
|
||||
func (c *MockRecordSerializerUnmarshalRecordCall) Do(f func(context.Context, json.RawMessage) (internal.Record, error)) *MockRecordSerializerUnmarshalRecordCall {
|
||||
c.Call = c.Call.Do(f)
|
||||
return c
|
||||
}
|
||||
|
||||
// DoAndReturn rewrite *gomock.Call.DoAndReturn
|
||||
func (c *MockRecordSerializerUnmarshalRecordCall) DoAndReturn(f func(context.Context, json.RawMessage) (internal.Record, error)) *MockRecordSerializerUnmarshalRecordCall {
|
||||
c.Call = c.Call.DoAndReturn(f)
|
||||
return c
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package ofigi
|
||||
package internal
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -16,8 +16,8 @@ import (
|
||||
|
||||
var OpenFIGIAPIKeyHeader = http.CanonicalHeaderKey("X-OPENFIGI-APIKEY")
|
||||
|
||||
// Client is a thin adapter for the openfigi.com api.
|
||||
type Client struct {
|
||||
// OpenFIGI is a small adapter for the openfigi.com api.
|
||||
type OpenFIGI struct {
|
||||
client *http.Client
|
||||
apiKey string
|
||||
mappingLimiter *rate.Limiter
|
||||
@@ -25,13 +25,12 @@ type Client struct {
|
||||
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.
|
||||
// cache lib (like golang-lru or go-cache) if this becomes a problem or implement this 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 {
|
||||
func NewOpenFIGI(c *http.Client, apiKey string) *OpenFIGI {
|
||||
// Rate limits as per https://www.openfigi.com/api/documentation#rate-limits
|
||||
limiter := rate.NewLimiter(rate.Every(time.Minute), 25)
|
||||
if len(apiKey) > 0 {
|
||||
@@ -41,7 +40,7 @@ func NewOpenFIGI(c *http.Client, apiKey string) *Client {
|
||||
slog.Debug("OpenFIGI client: created with puplic rate limits")
|
||||
}
|
||||
|
||||
return &Client{
|
||||
return &OpenFIGI{
|
||||
client: c,
|
||||
apiKey: apiKey,
|
||||
mappingLimiter: limiter,
|
||||
@@ -49,7 +48,7 @@ func NewOpenFIGI(c *http.Client, apiKey string) *Client {
|
||||
}
|
||||
}
|
||||
|
||||
func (of *Client) SecurityTypeByISIN(ctx context.Context, isin string) (string, error) {
|
||||
func (of *OpenFIGI) SecurityTypeByISIN(ctx context.Context, isin string) (string, error) {
|
||||
of.mu.RLock()
|
||||
if secType, ok := of.securityTypeCache[isin]; ok {
|
||||
of.mu.RUnlock()
|
||||
@@ -1,14 +1,15 @@
|
||||
package ofigi_test
|
||||
package internal_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/nmoniz/any2anexoj/internal/ofigi"
|
||||
"github.com/nmoniz/any2anexoj/internal"
|
||||
)
|
||||
|
||||
func TestOpenFIGI_SecurityTypeByISIN(t *testing.T) {
|
||||
@@ -109,9 +110,9 @@ func TestOpenFIGI_SecurityTypeByISIN(t *testing.T) {
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
of := ofigi.NewOpenFIGI(tt.client, "")
|
||||
of := internal.NewOpenFIGI(tt.client, "")
|
||||
|
||||
got, gotErr := of.SecurityTypeByISIN(t.Context(), tt.isin)
|
||||
got, gotErr := of.SecurityTypeByISIN(context.Background(), tt.isin)
|
||||
if gotErr != nil {
|
||||
if !tt.wantErr {
|
||||
t.Errorf("want success but failed: %v", gotErr)
|
||||
@@ -144,7 +145,7 @@ func TestOpenFIGI_SecurityTypeByISIN_Cache(t *testing.T) {
|
||||
}, nil
|
||||
})
|
||||
|
||||
of := ofigi.NewOpenFIGI(c, "")
|
||||
of := internal.NewOpenFIGI(c, "")
|
||||
|
||||
got, gotErr := of.SecurityTypeByISIN(t.Context(), "NL0000235190")
|
||||
if gotErr != nil {
|
||||
@@ -170,15 +171,15 @@ func TestOpenFIGI_SecurityTypeByISIN_APIKey(t *testing.T) {
|
||||
wantAPIKey := "123abc-456xyz"
|
||||
|
||||
c := NewTestClient(t, func(req *http.Request) (*http.Response, error) {
|
||||
value, ok := req.Header[ofigi.OpenFIGIAPIKeyHeader]
|
||||
value, ok := req.Header[internal.OpenFIGIAPIKeyHeader]
|
||||
if !ok {
|
||||
t.Fatalf("want %q header but got none: %v", ofigi.OpenFIGIAPIKeyHeader, req.Header)
|
||||
t.Fatalf("want %q header but got none: %v", internal.OpenFIGIAPIKeyHeader, req.Header)
|
||||
}
|
||||
if len(value) != 1 {
|
||||
t.Fatalf("want exactly one %q header value but got %d", ofigi.OpenFIGIAPIKeyHeader, len(value))
|
||||
t.Fatalf("want exactly one %q header value but got %d", internal.OpenFIGIAPIKeyHeader, len(value))
|
||||
}
|
||||
if value[0] != wantAPIKey {
|
||||
t.Fatalf("want %q header value %q but got %q", ofigi.OpenFIGIAPIKeyHeader, wantAPIKey, value[0])
|
||||
t.Fatalf("want %q header value %q but got %q", internal.OpenFIGIAPIKeyHeader, wantAPIKey, value[0])
|
||||
}
|
||||
return &http.Response{
|
||||
Status: http.StatusText(http.StatusOK),
|
||||
@@ -186,7 +187,7 @@ func TestOpenFIGI_SecurityTypeByISIN_APIKey(t *testing.T) {
|
||||
Body: io.NopCloser(bytes.NewBufferString(`[{"data":[{"securityType":"Common Stock"}]}]`)),
|
||||
}, nil
|
||||
})
|
||||
of := ofigi.NewOpenFIGI(c, wantAPIKey)
|
||||
of := internal.NewOpenFIGI(c, wantAPIKey)
|
||||
|
||||
_, err := of.SecurityTypeByISIN(t.Context(), "US1234567890")
|
||||
if err != nil {
|
||||
@@ -196,9 +197,9 @@ func TestOpenFIGI_SecurityTypeByISIN_APIKey(t *testing.T) {
|
||||
|
||||
t.Run("without API key", func(t *testing.T) {
|
||||
c := NewTestClient(t, func(req *http.Request) (*http.Response, error) {
|
||||
_, ok := req.Header[ofigi.OpenFIGIAPIKeyHeader]
|
||||
_, ok := req.Header[internal.OpenFIGIAPIKeyHeader]
|
||||
if ok {
|
||||
t.Fatalf("want no %s header but got one", ofigi.OpenFIGIAPIKeyHeader)
|
||||
t.Fatalf("want no %s header but got one", internal.OpenFIGIAPIKeyHeader)
|
||||
}
|
||||
return &http.Response{
|
||||
Status: http.StatusText(http.StatusOK),
|
||||
@@ -206,7 +207,7 @@ func TestOpenFIGI_SecurityTypeByISIN_APIKey(t *testing.T) {
|
||||
Body: io.NopCloser(bytes.NewBufferString(`[{"data":[{"securityType":"Common Stock"}]}]`)),
|
||||
}, nil
|
||||
})
|
||||
of := ofigi.NewOpenFIGI(c, "")
|
||||
of := internal.NewOpenFIGI(c, "")
|
||||
_, err := of.SecurityTypeByISIN(t.Context(), "US1234567890")
|
||||
if err != nil {
|
||||
t.Fatalf("want success but got an error: %s", err)
|
||||
+21
-33
@@ -53,7 +53,6 @@ type ReportWriter interface {
|
||||
|
||||
type optionals struct {
|
||||
selector Selector
|
||||
store Store
|
||||
}
|
||||
|
||||
func applyOptions(defaults optionals, opts []Option) optionals {
|
||||
@@ -65,44 +64,28 @@ func applyOptions(defaults optionals, opts []Option) optionals {
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
// Selector returns true if a record should be selected for processing, false otherwise.
|
||||
type Selector func(Record) bool
|
||||
|
||||
// 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)
|
||||
}
|
||||
buys := make(map[string]*FillerQueue)
|
||||
|
||||
var buysCount, sellsCount int64
|
||||
var lastTimestamp time.Time
|
||||
progTicker := time.NewTicker(10 * time.Second)
|
||||
|
||||
var (
|
||||
recordsCount int64
|
||||
lastTimestamp time.Time
|
||||
progTicker = time.NewTicker(10 * time.Second)
|
||||
)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
@@ -110,29 +93,27 @@ func BuildReport(ctx context.Context, reader RecordReader, writer ReportWriter,
|
||||
case <-progTicker.C:
|
||||
slog.InfoContext(
|
||||
ctx, "Progress update",
|
||||
slog.Int64("records_count", recordsCount),
|
||||
slog.Int64("total_records", buysCount+sellsCount),
|
||||
slog.Int64("sell_records", sellsCount),
|
||||
slog.Int64("buy_records", buysCount),
|
||||
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())
|
||||
if rec.Kind().Is(KindBuy) {
|
||||
buysCount++
|
||||
} else if rec.Kind().Is(KindSell) {
|
||||
sellsCount++
|
||||
}
|
||||
|
||||
lastTimestamp = rec.Timestamp()
|
||||
recordsCount++
|
||||
|
||||
buyQueue, ok := buys[rec.Symbol()]
|
||||
if !ok {
|
||||
@@ -144,6 +125,7 @@ func BuildReport(ctx context.Context, reader RecordReader, writer ReportWriter,
|
||||
if err != nil {
|
||||
return fmt.Errorf("processing record: %w", err)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -182,6 +164,12 @@ func processRecord(ctx context.Context, q *FillerQueue, rec Record, sel Selector
|
||||
return ErrInsufficientBoughtVolume
|
||||
}
|
||||
|
||||
// Since we don't apply selectors while processing buys we need to apply them here, befre we
|
||||
// actually use them.
|
||||
if !sel(buy) {
|
||||
continue
|
||||
}
|
||||
|
||||
matchedQty, filled := buy.Fill(unmatchedQty)
|
||||
|
||||
if filled {
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -99,87 +98,3 @@ func (m ReportItemMatcher) String() string {
|
||||
}
|
||||
|
||||
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,50 +0,0 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
|
||||
// StateVersion is the schema version of the persisted State struct.
|
||||
const StateVersion = "2"
|
||||
|
||||
// State is the platform-agnostic representation of the buy-queue state that is
|
||||
// written to disk after a successful run and reloaded on the next run.
|
||||
type State struct {
|
||||
Version string `json:"version"`
|
||||
Platform string `json:"platform"`
|
||||
Latest time.Time `json:"latest"`
|
||||
Earliest time.Time `json:"earliest"`
|
||||
Queues map[string][]persistedFiller `json:"queues"`
|
||||
}
|
||||
|
||||
// persistedFiller is the on-disk representation of a single Filler. The
|
||||
// Record-specific data is kept as opaque bytes so the internal package does
|
||||
// not need to know about any broker package's concrete Record type.
|
||||
type persistedFiller struct {
|
||||
RecordData json.RawMessage `json:"record_data"`
|
||||
Quantity decimal.Decimal `json:"quantity"`
|
||||
Price decimal.Decimal `json:"price"`
|
||||
Filled decimal.Decimal `json:"filled"`
|
||||
}
|
||||
|
||||
// RecordEncoder encodes a Record into its broker-specific JSON representation.
|
||||
type RecordEncoder interface {
|
||||
MarshalRecord(context.Context, Record) (json.RawMessage, error)
|
||||
}
|
||||
|
||||
// RecordDecoder decodes broker-specific JSON back into a Record.
|
||||
type RecordDecoder interface {
|
||||
UnmarshalRecord(context.Context, json.RawMessage) (Record, error)
|
||||
}
|
||||
|
||||
// RecordSerializer composes encoding and decoding of Records. Broker packages
|
||||
// own the implementation so that lazy/closured Record fields can be re-wired
|
||||
// on load.
|
||||
type RecordSerializer interface {
|
||||
RecordEncoder
|
||||
RecordDecoder
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
# Trading212
|
||||
|
||||
Turns Trading212 account statements (CSV) into the internal record model used
|
||||
to build Anexo J reports. This file records how statement events are
|
||||
interpreted, and why.
|
||||
|
||||
## Spin-off events
|
||||
|
||||
A spin-off distributes shares of one company to the shareholders of another.
|
||||
Trading212 reports it as an `Action` of `Spin off`:
|
||||
|
||||
```
|
||||
Spin off,2026-02-11 13:41:43+00:00,US9418481035,WAT,"Waters",EOF46451921527,0.0406450000,0E-10,USD,,,,0.00,"EUR",,,,,,
|
||||
```
|
||||
|
||||
These rows are booked as **buys with a cost of zero**: quantity and timestamp
|
||||
are kept, and every cost column (price, result, total, stamp duty reserve tax,
|
||||
currency conversion fee, French transaction tax) is required to be zero —
|
||||
anything non-zero, or a non-positive quantity, is logged and returned as an
|
||||
error, stopping the run. A row that surprises the reader must be looked at by
|
||||
a human, not silently booked.
|
||||
|
||||
### Why cost zero is fair
|
||||
|
||||
No money was paid for the new shares, and their value came out of the value
|
||||
of the parent shares — on the distribution date the parent's price drops by
|
||||
roughly the distributed value. The position's total basis is unchanged; it is
|
||||
just partitioned over two lots: the spin-off lot at `0` and the parent lot at
|
||||
its original, unreduced basis. Selling the spin-off lot books full proceeds as
|
||||
gain; selling the parent lot shows the offsetting smaller gain or bigger loss.
|
||||
Netted together, the result equals the true economic gain.
|
||||
|
||||
Example — buy 100 parent shares for 10,000; a spin-off delivers new shares
|
||||
worth 2,000; later sell the new shares for 2,200 and the parent for 8,100:
|
||||
|
||||
| Lot | Basis (cost 0) | Sold for | Gain (cost 0) | Gain (pro-rata) |
|
||||
| ------ | -------------: | -------: | ------------: | --------------: |
|
||||
| New | 0 | 2,200 | 2,200 | 200 |
|
||||
| Parent | 10,000 | 8,100 | -1,900 | 100 |
|
||||
| Total | | | **300** | **300** |
|
||||
|
||||
Either partition of the same 10,000 basis yields the same net gain of 300
|
||||
(true gain: 10,300 received − 10,000 paid) — as long as gains and losses are
|
||||
assessed together, as they are in Anexo J within the same year. If the two
|
||||
lots are sold in different years the per-year attribution differs (the total
|
||||
does not). Trading212 statements don't report a pro-rata basis allocation, so
|
||||
cost 0 also keeps the report reproducible from the statement alone.
|
||||
|
||||
> [!WARNING]
|
||||
> Verify any outputs of this tool on your own or with a certified accountant.
|
||||
+63
-207
@@ -12,7 +12,6 @@ import (
|
||||
|
||||
"github.com/biter777/countries"
|
||||
"github.com/nmoniz/any2anexoj/internal"
|
||||
"github.com/nmoniz/any2anexoj/internal/ofigi"
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
|
||||
@@ -71,10 +70,10 @@ func (r Record) Nature() internal.Nature {
|
||||
|
||||
type RecordReader struct {
|
||||
reader *csv.Reader
|
||||
figi *ofigi.Client
|
||||
figi *internal.OpenFIGI
|
||||
}
|
||||
|
||||
func NewRecordReader(r io.Reader, f *ofigi.Client) *RecordReader {
|
||||
func NewRecordReader(r io.Reader, f *internal.OpenFIGI) *RecordReader {
|
||||
return &RecordReader{
|
||||
reader: csv.NewReader(r),
|
||||
figi: f,
|
||||
@@ -89,13 +88,8 @@ const (
|
||||
StockSplitOpen = "stock split open"
|
||||
StockSplitClose = "stock split close"
|
||||
StokDistribution = "stock distribution"
|
||||
SpinOff = "spin off"
|
||||
)
|
||||
|
||||
// ReadRecord reads the next record from the statement, dispatching each
|
||||
// action to the parser that validates exactly the columns that matter for
|
||||
// it. Split open/close rows are paired up here because the pairing spans
|
||||
// rows: the ratio is only known once the close row is seen.
|
||||
func (rr RecordReader) ReadRecord(ctx context.Context) (internal.Record, error) {
|
||||
var splitRec *splitRecord
|
||||
|
||||
@@ -105,195 +99,95 @@ func (rr RecordReader) ReadRecord(ctx context.Context) (internal.Record, error)
|
||||
return Record{}, fmt.Errorf("read record: %w", err)
|
||||
}
|
||||
|
||||
action := strings.Trim(strings.ToLower(raw[0]), " ")
|
||||
if action == "action" { // header row
|
||||
if strings.ToLower(raw[0]) == "action" {
|
||||
continue
|
||||
}
|
||||
|
||||
switch action {
|
||||
qant, err := parseDecimal(raw[6])
|
||||
if err != nil {
|
||||
return Record{}, fmt.Errorf("parse record quantity: %w", err)
|
||||
}
|
||||
|
||||
price, err := parseDecimal(raw[7])
|
||||
if err != nil {
|
||||
return Record{}, fmt.Errorf("parse record price: %w", err)
|
||||
}
|
||||
|
||||
ts, err := time.Parse(time.DateTime, raw[1])
|
||||
if err != nil {
|
||||
return Record{}, fmt.Errorf("parse record timestamp: %w", err)
|
||||
}
|
||||
|
||||
conversionFee, err := parseOptionalDecimal(raw[16])
|
||||
if err != nil {
|
||||
return Record{}, fmt.Errorf("parse record conversion fee: %w", err)
|
||||
}
|
||||
|
||||
stampDutyTax, err := parseOptionalDecimal(raw[14])
|
||||
if err != nil {
|
||||
return Record{}, fmt.Errorf("parse record stamp duty tax: %w", err)
|
||||
}
|
||||
|
||||
frenchTxTax, err := parseOptionalDecimal(raw[18])
|
||||
if err != nil {
|
||||
return Record{}, fmt.Errorf("parse record french transaction tax: %w", err)
|
||||
}
|
||||
|
||||
var kind internal.Kind
|
||||
switch strings.ToLower(raw[0]) {
|
||||
case MarketBuy, LimitBuy:
|
||||
return rr.parseBuyRecord(ctx, raw)
|
||||
|
||||
kind = internal.KindBuy
|
||||
case MarketSell, LimitSell:
|
||||
return rr.parseSellRecord(ctx, raw)
|
||||
|
||||
case SpinOff:
|
||||
return rr.parseSpinOff(ctx, raw)
|
||||
|
||||
kind = internal.KindSell
|
||||
case StockSplitOpen:
|
||||
if splitRec != nil {
|
||||
return nil, fmt.Errorf("split already open")
|
||||
}
|
||||
|
||||
rec, err := rr.parseTradeRecord(ctx, raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
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]),
|
||||
},
|
||||
}
|
||||
rec.kind = internal.KindSplit
|
||||
splitRec = &splitRecord{Record: rec}
|
||||
|
||||
continue
|
||||
|
||||
case StockSplitClose:
|
||||
if splitRec == nil {
|
||||
return nil, fmt.Errorf("missing split open")
|
||||
}
|
||||
|
||||
closeQty, err := parsePositiveDecimal(raw[6])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse record quantity: %w", err)
|
||||
}
|
||||
splitRec.ratio = splitRec.Record.Quantity().Div(closeQty)
|
||||
splitRec.ratio = splitRec.Record.Quantity().Div(qant)
|
||||
return splitRec, nil
|
||||
|
||||
case StokDistribution:
|
||||
qant, err := parsePositiveDecimal(raw[6])
|
||||
if err != nil {
|
||||
return Record{}, fmt.Errorf("parse record quantity: %w", err)
|
||||
}
|
||||
slog.Warn("Skipping 'stock distribution' event!",
|
||||
slog.Any("ISIN", raw[2]),
|
||||
slog.Any("Quantity", qant),
|
||||
)
|
||||
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]),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// parseBuyRecord parses a market/limit buy row into a KindBuy Record.
|
||||
func (rr RecordReader) parseBuyRecord(ctx context.Context, raw []string) (Record, error) {
|
||||
rec, err := rr.parseTradeRecord(ctx, raw)
|
||||
if err != nil {
|
||||
return Record{}, err
|
||||
}
|
||||
rec.kind = internal.KindBuy
|
||||
return rec, nil
|
||||
}
|
||||
|
||||
// parseSellRecord parses a market/limit sell row into a KindSell Record.
|
||||
func (rr RecordReader) parseSellRecord(ctx context.Context, raw []string) (Record, error) {
|
||||
rec, err := rr.parseTradeRecord(ctx, raw)
|
||||
if err != nil {
|
||||
return Record{}, err
|
||||
}
|
||||
rec.kind = internal.KindSell
|
||||
return rec, nil
|
||||
}
|
||||
|
||||
// parseTradeRecord parses the columns shared by trade-shaped rows (quantity,
|
||||
// price, timestamp, fees and taxes) into a Record with an unset Kind,
|
||||
// leaving the kind decision to the caller. Split-open rows reuse it because
|
||||
// their row shape is identical.
|
||||
func (rr RecordReader) parseTradeRecord(ctx context.Context, raw []string) (Record, error) {
|
||||
qant, err := parsePositiveDecimal(raw[6])
|
||||
if err != nil {
|
||||
return Record{}, fmt.Errorf("parse record quantity: %w", err)
|
||||
}
|
||||
|
||||
price, err := parseDecimal(raw[7])
|
||||
if err != nil {
|
||||
return Record{}, fmt.Errorf("parse record price: %w", err)
|
||||
}
|
||||
|
||||
ts, err := parseTimestamp(raw[1])
|
||||
if err != nil {
|
||||
return Record{}, fmt.Errorf("parse record timestamp: %w", err)
|
||||
}
|
||||
|
||||
conversionFee, err := parseOptionalDecimal(raw[16])
|
||||
if err != nil {
|
||||
return Record{}, fmt.Errorf("parse record conversion fee: %w", err)
|
||||
}
|
||||
|
||||
stampDutyTax, err := parseOptionalDecimal(raw[14])
|
||||
if err != nil {
|
||||
return Record{}, fmt.Errorf("parse record stamp duty tax: %w", err)
|
||||
}
|
||||
|
||||
frenchTxTax, err := parseOptionalDecimal(raw[18])
|
||||
if err != nil {
|
||||
return Record{}, fmt.Errorf("parse record french transaction tax: %w", err)
|
||||
}
|
||||
|
||||
return Record{
|
||||
symbol: raw[2],
|
||||
quantity: qant,
|
||||
price: price,
|
||||
fees: conversionFee,
|
||||
taxes: stampDutyTax.Add(frenchTxTax),
|
||||
timestamp: ts,
|
||||
natureGetter: figiNatureGetter(ctx, rr.figi, raw[2]),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// parseSpinOff books a 'Spin off' statement row as a zero-cost buy. The
|
||||
// zero-cost interpretation is only sound when no money moved with the
|
||||
// distribution — see README.md in this package for the rationale — so every
|
||||
// cost column is parsed as optional (an empty column means zero) and
|
||||
// required to be zero: anything unexpected is logged and returned as an
|
||||
// error so the run stops instead of silently booking a wrong lot.
|
||||
func (rr RecordReader) parseSpinOff(ctx context.Context, raw []string) (Record, error) {
|
||||
symbol := raw[2]
|
||||
|
||||
qant, err := parsePositiveDecimal(raw[6])
|
||||
if err != nil {
|
||||
return Record{}, fmt.Errorf("parse record quantity: %w", err)
|
||||
}
|
||||
|
||||
ts, err := parseTimestamp(raw[1])
|
||||
if err != nil {
|
||||
return Record{}, fmt.Errorf("parse record timestamp: %w", err)
|
||||
}
|
||||
|
||||
// A slice (not a map) keeps validation and error reporting deterministic.
|
||||
// result and total are only ever read here, for this row kind.
|
||||
costs := []struct {
|
||||
name string
|
||||
column int
|
||||
}{
|
||||
{"price", 7},
|
||||
{"result", 10},
|
||||
{"total", 12},
|
||||
{"stamp duty reserve tax", 14},
|
||||
{"currency conversion fee", 16},
|
||||
{"french transaction tax", 18},
|
||||
}
|
||||
for _, cost := range costs {
|
||||
value, err := parseOptionalDecimal(raw[cost.column])
|
||||
if err != nil {
|
||||
return Record{}, fmt.Errorf("parse record %s: %w", cost.name, err)
|
||||
}
|
||||
if !value.IsZero() {
|
||||
reason := fmt.Errorf("spin off with a non-zero %s (%s) for ISIN %s: refusing to book it as a zero-cost buy",
|
||||
cost.name, value, symbol)
|
||||
slog.Error("Unexpected 'spin off' row!",
|
||||
slog.Any("err", reason),
|
||||
slog.Any("ISIN", symbol),
|
||||
)
|
||||
return Record{}, reason
|
||||
}
|
||||
}
|
||||
|
||||
slog.Info("Booking 'spin off' event as a zero-cost buy!",
|
||||
slog.Any("ISIN", symbol),
|
||||
slog.Any("Quantity", qant),
|
||||
)
|
||||
|
||||
return Record{
|
||||
symbol: symbol,
|
||||
kind: internal.KindBuy,
|
||||
quantity: qant,
|
||||
price: decimal.Zero,
|
||||
fees: decimal.Zero,
|
||||
taxes: decimal.Zero,
|
||||
timestamp: ts,
|
||||
natureGetter: figiNatureGetter(ctx, rr.figi, symbol),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func figiNatureGetter(ctx context.Context, of *ofigi.Client, isin string) func() internal.Nature {
|
||||
func figiNatureGetter(ctx context.Context, of *internal.OpenFIGI, isin string) func() internal.Nature {
|
||||
return sync.OnceValue(func() internal.Nature {
|
||||
secType, err := of.SecurityTypeByISIN(ctx, isin)
|
||||
if err != nil {
|
||||
@@ -330,44 +224,6 @@ func parseOptionalDecimal(s string) (decimal.Decimal, error) {
|
||||
return parseDecimal(s)
|
||||
}
|
||||
|
||||
// parsePositiveDecimal parses s as a decimal and requires it to be strictly
|
||||
// positive: empty, malformed, zero and negative values are rejected. A lot
|
||||
// (or a split-ratio denominator) is meaningless without a positive quantity.
|
||||
func parsePositiveDecimal(s string) (decimal.Decimal, error) {
|
||||
value, err := parseDecimal(s)
|
||||
if err != nil {
|
||||
return decimal.Decimal{}, err
|
||||
}
|
||||
if !value.IsPositive() {
|
||||
return decimal.Decimal{}, fmt.Errorf("non-positive value %q", s)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
// parseTimestamp parses a Trading212 timestamp string. Trading212 has shipped
|
||||
// rows both without a timezone (treated here as UTC) and with an explicit
|
||||
// numeric offset (e.g. "+00:00", "-05:00", "+01:00") or a "Z" UTC marker.
|
||||
// The layouts are tried in order; the first match wins.
|
||||
func parseTimestamp(s string) (time.Time, error) {
|
||||
layouts := [3]string{
|
||||
"2006-01-02 15:04:05-07:00", // numeric offset
|
||||
time.DateTime, // "2006-01-02 15:04:05"
|
||||
"2006-01-02 15:04:05Z", // explicit UTC marker
|
||||
}
|
||||
|
||||
var (
|
||||
lastErr error
|
||||
ts time.Time
|
||||
)
|
||||
for _, layout := range layouts {
|
||||
ts, lastErr = time.Parse(layout, s)
|
||||
if lastErr == nil {
|
||||
return ts, nil
|
||||
}
|
||||
}
|
||||
return time.Time{}, lastErr
|
||||
}
|
||||
|
||||
type splitRecord struct {
|
||||
Record
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ package trading212
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -10,7 +9,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/nmoniz/any2anexoj/internal"
|
||||
"github.com/nmoniz/any2anexoj/internal/ofigi"
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
|
||||
@@ -105,102 +103,6 @@ func TestRecordReader_ReadRecord(t *testing.T) {
|
||||
r: bytes.NewBufferString(`Market sell,,IE000GA3D489,ABXY,"Aspargus Broccoli",EOF987654321,2.4387014200,7.9999999999,USD,1.17995999,,"EUR",15.25,"EUR",,,0.02,"EUR",,`),
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "well-formed buy with new offset format +00:00",
|
||||
r: bytes.NewBufferString(`Market buy,2025-07-03 10:44:29+00:00,XX1234567890,ABXY,"Aspargus Broccoli",EOF987654321,2.4387014200,7.3690000000,USD,1.17995999,,"EUR",15.25,"EUR",0.25,"EUR",0.02,"EUR",,`),
|
||||
want: Record{
|
||||
symbol: "XX1234567890",
|
||||
kind: internal.KindBuy,
|
||||
quantity: ShouldParseDecimal(t, "2.4387014200"),
|
||||
price: ShouldParseDecimal(t, "7.3690000000"),
|
||||
timestamp: time.Date(2025, 7, 3, 10, 44, 29, 0, time.FixedZone("UTC", 0)),
|
||||
fees: ShouldParseDecimal(t, "0.02"),
|
||||
taxes: ShouldParseDecimal(t, "0.25"),
|
||||
natureGetter: func() internal.Nature { return internal.NatureG01 },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "well-formed buy with new offset format +01:00",
|
||||
r: bytes.NewBufferString(`Market buy,2025-07-03 10:44:29+01:00,XX1234567890,ABXY,"Aspargus Broccoli",EOF987654321,2.4387014200,7.3690000000,USD,1.17995999,,"EUR",15.25,"EUR",0.25,"EUR",0.02,"EUR",,`),
|
||||
want: Record{
|
||||
symbol: "XX1234567890",
|
||||
kind: internal.KindBuy,
|
||||
quantity: ShouldParseDecimal(t, "2.4387014200"),
|
||||
price: ShouldParseDecimal(t, "7.3690000000"),
|
||||
timestamp: time.Date(2025, 7, 3, 10, 44, 29, 0, time.FixedZone("+01:00", 1*60*60)),
|
||||
fees: ShouldParseDecimal(t, "0.02"),
|
||||
taxes: ShouldParseDecimal(t, "0.25"),
|
||||
natureGetter: func() internal.Nature { return internal.NatureG01 },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "well-formed buy with garbage timestamp returns error",
|
||||
r: bytes.NewBufferString(`Market buy,not-a-timestamp,IE000GA3D489,ABXY,"Aspargus Broccoli",EOF987654321,2.4387014200,7.3690000000,USD,1.17995999,,"EUR",15.25,"EUR",0.25,"EUR",0.02,"EUR",,`),
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "spin off booked as zero-cost buy",
|
||||
r: bytes.NewBufferString(`Spin off,2026-02-11 13:41:43+00:00,XX1234567890,ABXY,"Aspargus Broccoli",EOF987654321,0.0406450000,0E-10,USD,,,,0.00,"EUR",,,,,,`),
|
||||
want: Record{
|
||||
symbol: "XX1234567890",
|
||||
kind: internal.KindBuy,
|
||||
quantity: ShouldParseDecimal(t, "0.0406450000"),
|
||||
price: decimal.Zero,
|
||||
fees: decimal.Zero,
|
||||
taxes: decimal.Zero,
|
||||
timestamp: time.Date(2026, 2, 11, 13, 41, 43, 0, time.FixedZone("UTC", 0)),
|
||||
natureGetter: func() internal.Nature { return internal.NatureG01 },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "spin off with a non-zero price is rejected",
|
||||
r: bytes.NewBufferString(`Spin off,2026-02-11 13:41:43+00:00,XX1234567890,ABXY,"Aspargus Broccoli",EOF987654321,0.0406450000,45.2300000000,USD,,,,0.00,"EUR",,,,,,`),
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "spin off with a non-zero result is rejected",
|
||||
r: bytes.NewBufferString(`Spin off,2026-02-11 13:41:43+00:00,XX1234567890,ABXY,"Aspargus Broccoli",EOF987654321,0.0406450000,0E-10,USD,1.17995999,1.25,"EUR",0.00,"EUR",,,,,,`),
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "spin off with a non-zero total is rejected",
|
||||
r: bytes.NewBufferString(`Spin off,2026-02-11 13:41:43+00:00,XX1234567890,ABXY,"Aspargus Broccoli",EOF987654321,0.0406450000,0E-10,USD,,,,1.25,"EUR",,,,,,`),
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "spin off with a non-zero stamp duty reserve tax is rejected",
|
||||
r: bytes.NewBufferString(`Spin off,2026-02-11 13:41:43+00:00,XX1234567890,ABXY,"Aspargus Broccoli",EOF987654321,0.0406450000,0E-10,USD,,,,0.00,"EUR",0.25,"EUR",,,,,`),
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "spin off with a non-zero currency conversion fee is rejected",
|
||||
r: bytes.NewBufferString(`Spin off,2026-02-11 13:41:43+00:00,XX1234567890,ABXY,"Aspargus Broccoli",EOF987654321,0.0406450000,0E-10,USD,,,,0.00,"EUR",,,0.02,"EUR",,`),
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "spin off with a non-zero french transaction tax is rejected",
|
||||
r: bytes.NewBufferString(`Spin off,2026-02-11 13:41:43+00:00,XX1234567890,ABXY,"Aspargus Broccoli",EOF987654321,0.0406450000,0E-10,USD,,,,0.00,"EUR",,,,,0.1,"EUR"`),
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "spin off with a non-positive quantity is rejected",
|
||||
r: bytes.NewBufferString(`Spin off,2026-02-11 13:41:43+00:00,XX1234567890,ABXY,"Aspargus Broccoli",EOF987654321,0E-10,0E-10,USD,,,,0.00,"EUR",,,,,,`),
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "spin off with an empty price column is booked as a zero-cost buy",
|
||||
r: bytes.NewBufferString(`Spin off,2026-02-11 13:41:43+00:00,XX1234567890,ABXY,"Aspargus Broccoli",EOF987654321,0.0406450000,,USD,,,,0.00,"EUR",,,,,,`),
|
||||
want: Record{
|
||||
symbol: "XX1234567890",
|
||||
kind: internal.KindBuy,
|
||||
quantity: ShouldParseDecimal(t, "0.0406450000"),
|
||||
price: decimal.Zero,
|
||||
fees: decimal.Zero,
|
||||
taxes: decimal.Zero,
|
||||
timestamp: time.Date(2026, 2, 11, 13, 41, 43, 0, time.FixedZone("UTC", 0)),
|
||||
natureGetter: func() internal.Nature { return internal.NatureG01 },
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
@@ -316,81 +218,10 @@ func TestRecordReader_ReadRecord_Split(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestParseTimestamp(t *testing.T) {
|
||||
utc := time.FixedZone("UTC", 0)
|
||||
plusOne := time.FixedZone("+01:00", 1*60*60)
|
||||
minusFive := time.FixedZone("-05:00", -5*60*60)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want time.Time
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "legacy format without offset parses as UTC",
|
||||
input: "2025-05-22 13:15:52",
|
||||
want: time.Date(2025, 5, 22, 13, 15, 52, 0, time.UTC),
|
||||
},
|
||||
{
|
||||
name: "new format with +00:00 offset",
|
||||
input: "2025-05-22 13:15:52+00:00",
|
||||
want: time.Date(2025, 5, 22, 13, 15, 52, 0, utc),
|
||||
},
|
||||
{
|
||||
name: "new format with +01:00 offset",
|
||||
input: "2025-05-22 13:15:52+01:00",
|
||||
want: time.Date(2025, 5, 22, 13, 15, 52, 0, plusOne),
|
||||
},
|
||||
{
|
||||
name: "new format with -05:00 offset",
|
||||
input: "2025-05-22 13:15:52-05:00",
|
||||
want: time.Date(2025, 5, 22, 13, 15, 52, 0, minusFive),
|
||||
},
|
||||
{
|
||||
name: "explicit Z UTC marker",
|
||||
input: "2025-05-22 13:15:52Z",
|
||||
want: time.Date(2025, 5, 22, 13, 15, 52, 0, time.UTC),
|
||||
},
|
||||
{
|
||||
name: "garbage input returns error",
|
||||
input: "not-a-timestamp",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "RFC3339 with T separator is rejected",
|
||||
input: "2025-05-22T13:15:52Z",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "empty string returns error",
|
||||
input: "",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := parseTimestamp(tt.input)
|
||||
if err != nil {
|
||||
if !tt.wantErr {
|
||||
t.Fatalf("parseTimestamp(%q) returned error: %v", tt.input, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if tt.wantErr {
|
||||
t.Fatalf("parseTimestamp(%q) expected an error", tt.input)
|
||||
}
|
||||
if !got.Equal(tt.want) {
|
||||
t.Fatalf("parseTimestamp(%q) = %v, want %v", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_figiNatureGetter(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string // description of this test case
|
||||
of *ofigi.Client
|
||||
of *internal.OpenFIGI
|
||||
want internal.Nature
|
||||
}{
|
||||
{
|
||||
@@ -435,39 +266,13 @@ func ShouldParseDecimal(t testing.TB, sf string) decimal.Decimal {
|
||||
return bf
|
||||
}
|
||||
|
||||
func TestRecordReader_ReadRecord_SkipsCorporateActions(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
row string
|
||||
}{
|
||||
{
|
||||
name: "stock distribution",
|
||||
row: `Stock distribution,2025-08-04 11:45:30,XX1234567890,ABXY,"Aspargus Broccoli",EOF987654321,2.4387014200,7.9999999999,USD,1.17995999,,"EUR",15.25,"EUR",,,0.02,"EUR",,`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
rr := NewRecordReader(
|
||||
bytes.NewBufferString(tt.row),
|
||||
NewFigiClientSecurityTypeStub(t, "Common Stock"),
|
||||
)
|
||||
|
||||
_, err := rr.ReadRecord(t.Context())
|
||||
if !errors.Is(err, io.EOF) {
|
||||
t.Fatalf("want EOF after skipping %s, got: %v", tt.name, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type RoundTripFunc func(req *http.Request) (*http.Response, error)
|
||||
|
||||
func (f RoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return f(req)
|
||||
}
|
||||
|
||||
func NewFigiClientSecurityTypeStub(t testing.TB, securityType string) *ofigi.Client {
|
||||
func NewFigiClientSecurityTypeStub(t testing.TB, securityType string) *internal.OpenFIGI {
|
||||
t.Helper()
|
||||
|
||||
c := &http.Client{
|
||||
@@ -482,10 +287,10 @@ func NewFigiClientSecurityTypeStub(t testing.TB, securityType string) *ofigi.Cli
|
||||
}),
|
||||
}
|
||||
|
||||
return ofigi.NewOpenFIGI(c, "")
|
||||
return internal.NewOpenFIGI(c, "")
|
||||
}
|
||||
|
||||
func NewFigiClientErrorStub(t testing.TB, err error) *ofigi.Client {
|
||||
func NewFigiClientErrorStub(t testing.TB, err error) *internal.OpenFIGI {
|
||||
t.Helper()
|
||||
|
||||
c := &http.Client{
|
||||
@@ -495,5 +300,5 @@ func NewFigiClientErrorStub(t testing.TB, err error) *ofigi.Client {
|
||||
}),
|
||||
}
|
||||
|
||||
return ofigi.NewOpenFIGI(c, "")
|
||||
return internal.NewOpenFIGI(c, "")
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
package trading212
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/nmoniz/any2anexoj/internal"
|
||||
"github.com/nmoniz/any2anexoj/internal/ofigi"
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
|
||||
// recordState is the on-disk representation of a trading212.Record.
|
||||
// Nature is intentionally omitted because it is resolved lazily via the
|
||||
// OpenFIGI client on demand.
|
||||
type recordState struct {
|
||||
Symbol string `json:"symbol"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Kind internal.Kind `json:"kind"`
|
||||
Quantity decimal.Decimal `json:"quantity"`
|
||||
Price decimal.Decimal `json:"price"`
|
||||
Fees decimal.Decimal `json:"fees"`
|
||||
Taxes decimal.Decimal `json:"taxes"`
|
||||
}
|
||||
|
||||
// RecordSerializer encodes and decodes trading212.Record values for the
|
||||
// internal persistence layer. On decode it re-wires the lazy natureGetter to
|
||||
// the supplied OpenFIGI client so a restored Record resolves Nature() via a
|
||||
// fresh API call when needed.
|
||||
type RecordSerializer struct {
|
||||
figi *ofigi.Client
|
||||
}
|
||||
|
||||
// NewRecordSerializer returns a RecordSerializer that uses figi to resolve
|
||||
// Record.Nature() on load.
|
||||
func NewRecordSerializer(figi *ofigi.Client) *RecordSerializer {
|
||||
return &RecordSerializer{figi: figi}
|
||||
}
|
||||
|
||||
// MarshalRecord encodes the given internal.Record as JSON. It returns an
|
||||
// error if the record is not a trading212.Record (or a *trading212.Record).
|
||||
func (s *RecordSerializer) MarshalRecord(_ context.Context, r internal.Record) (json.RawMessage, error) {
|
||||
var rec Record
|
||||
switch v := r.(type) {
|
||||
case Record:
|
||||
rec = v
|
||||
case *Record:
|
||||
if v == nil {
|
||||
return nil, fmt.Errorf("trading212: cannot marshal nil *trading212.Record")
|
||||
}
|
||||
rec = *v
|
||||
default:
|
||||
return nil, fmt.Errorf("trading212: cannot marshal %T as trading212.Record", r)
|
||||
}
|
||||
|
||||
state := recordState{
|
||||
Symbol: rec.symbol,
|
||||
Timestamp: rec.timestamp,
|
||||
Kind: rec.kind,
|
||||
Quantity: rec.quantity,
|
||||
Price: rec.price,
|
||||
Fees: rec.fees,
|
||||
Taxes: rec.taxes,
|
||||
}
|
||||
|
||||
return json.Marshal(state)
|
||||
}
|
||||
|
||||
// UnmarshalRecord decodes a JSON-encoded trading212.Record and re-wires its
|
||||
// natureGetter to the serializer's OpenFIGI client.
|
||||
func (s *RecordSerializer) UnmarshalRecord(ctx context.Context, data json.RawMessage) (internal.Record, error) {
|
||||
var state recordState
|
||||
if err := json.Unmarshal(data, &state); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal trading212 record: %w", err)
|
||||
}
|
||||
|
||||
return Record{
|
||||
symbol: state.Symbol,
|
||||
timestamp: state.Timestamp,
|
||||
kind: state.Kind,
|
||||
quantity: state.Quantity,
|
||||
price: state.Price,
|
||||
fees: state.Fees,
|
||||
taxes: state.Taxes,
|
||||
natureGetter: figiNatureGetter(ctx, s.figi, state.Symbol),
|
||||
}, nil
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
package trading212
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/nmoniz/any2anexoj/internal"
|
||||
"github.com/nmoniz/any2anexoj/internal/ofigi"
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
|
||||
func TestRecordSerializer_RoundTrip(t *testing.T) {
|
||||
want := Record{
|
||||
symbol: "XX1234567890",
|
||||
timestamp: time.Date(2025, 7, 3, 10, 44, 29, 0, time.UTC),
|
||||
kind: internal.KindBuy,
|
||||
quantity: ShouldParseDecimal(t, "2.4387014200"),
|
||||
price: ShouldParseDecimal(t, "7.3690000000"),
|
||||
fees: ShouldParseDecimal(t, "0.02"),
|
||||
taxes: ShouldParseDecimal(t, "0.25"),
|
||||
natureGetter: func() internal.Nature { return internal.NatureG01 },
|
||||
}
|
||||
|
||||
s := NewRecordSerializer(NewFigiClientSecurityTypeStub(t, "Common Stock"))
|
||||
|
||||
data, err := s.MarshalRecord(t.Context(), want)
|
||||
if err != nil {
|
||||
t.Fatalf("MarshalRecord: %v", err)
|
||||
}
|
||||
|
||||
got, err := s.UnmarshalRecord(t.Context(), data)
|
||||
if err != nil {
|
||||
t.Fatalf("UnmarshalRecord: %v", err)
|
||||
}
|
||||
|
||||
if got.Symbol() != want.Symbol() {
|
||||
t.Errorf("Symbol: want %q but got %q", want.Symbol(), got.Symbol())
|
||||
}
|
||||
if got.Kind() != want.Kind() {
|
||||
t.Errorf("Kind: want %v but got %v", want.Kind(), got.Kind())
|
||||
}
|
||||
if !got.Price().Equal(want.Price()) {
|
||||
t.Errorf("Price: want %v but got %v", want.Price(), got.Price())
|
||||
}
|
||||
if !got.Quantity().Equal(want.Quantity()) {
|
||||
t.Errorf("Quantity: want %v but got %v", want.Quantity(), got.Quantity())
|
||||
}
|
||||
if !got.Fees().Equal(want.Fees()) {
|
||||
t.Errorf("Fees: want %v but got %v", want.Fees(), got.Fees())
|
||||
}
|
||||
if !got.Taxes().Equal(want.Taxes()) {
|
||||
t.Errorf("Taxes: want %v but got %v", want.Taxes(), got.Taxes())
|
||||
}
|
||||
if !got.Timestamp().Equal(want.Timestamp()) {
|
||||
t.Errorf("Timestamp: want %v but got %v", want.Timestamp(), got.Timestamp())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordSerializer_UnmarshalRecord_NatureTriggersOpenFIGI(t *testing.T) {
|
||||
var calls int
|
||||
client := &http.Client{
|
||||
Timeout: time.Second,
|
||||
Transport: RoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
calls++
|
||||
return &http.Response{
|
||||
Status: http.StatusText(http.StatusOK),
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(bytes.NewBufferString(`[{"data":[{"securityType":"Common Stock"}]}]`)),
|
||||
Request: req,
|
||||
}, nil
|
||||
}),
|
||||
}
|
||||
|
||||
s := NewRecordSerializer(ofigi.NewOpenFIGI(client, ""))
|
||||
|
||||
original := Record{
|
||||
symbol: "XX1234567890",
|
||||
timestamp: time.Date(2025, 7, 3, 10, 44, 29, 0, time.UTC),
|
||||
kind: internal.KindBuy,
|
||||
quantity: ShouldParseDecimal(t, "2.4387014200"),
|
||||
price: ShouldParseDecimal(t, "7.3690000000"),
|
||||
fees: ShouldParseDecimal(t, "0.02"),
|
||||
taxes: ShouldParseDecimal(t, "0.25"),
|
||||
// Pre-populated so MarshalRecord doesn't accidentally trigger an
|
||||
// OpenFIGI call when encoding the original record.
|
||||
natureGetter: func() internal.Nature { return internal.NatureG01 },
|
||||
}
|
||||
|
||||
data, err := s.MarshalRecord(t.Context(), original)
|
||||
if err != nil {
|
||||
t.Fatalf("MarshalRecord: %v", err)
|
||||
}
|
||||
|
||||
if calls != 0 {
|
||||
t.Fatalf("OpenFIGI called during MarshalRecord: %d", calls)
|
||||
}
|
||||
|
||||
got, err := s.UnmarshalRecord(t.Context(), data)
|
||||
if err != nil {
|
||||
t.Fatalf("UnmarshalRecord: %v", err)
|
||||
}
|
||||
|
||||
// Nature must not have been resolved yet — natureGetter is lazy.
|
||||
if calls != 0 {
|
||||
t.Fatalf("OpenFIGI called before Nature(): %d", calls)
|
||||
}
|
||||
|
||||
if nature := got.Nature(); nature != internal.NatureG01 {
|
||||
t.Errorf("Nature: want %v but got %v", internal.NatureG01, nature)
|
||||
}
|
||||
|
||||
if calls != 1 {
|
||||
t.Errorf("OpenFIGI request count: want 1 but got %d", calls)
|
||||
}
|
||||
|
||||
// Subsequent Nature() calls should not re-trigger the request (the
|
||||
// underlying sync.OnceValue caches the result on the client too).
|
||||
if nature := got.Nature(); nature != internal.NatureG01 {
|
||||
t.Errorf("Nature (cached): want %v but got %v", internal.NatureG01, nature)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Errorf("OpenFIGI request count after re-read: want 1 but got %d", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordSerializer_MarshalRecord_WrongType(t *testing.T) {
|
||||
s := NewRecordSerializer(NewFigiClientSecurityTypeStub(t, "Common Stock"))
|
||||
|
||||
_, err := s.MarshalRecord(t.Context(), stubRecord{})
|
||||
if err == nil {
|
||||
t.Fatal("want error but got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// stubRecord is a non-trading212 implementation of internal.Record used to
|
||||
// verify that MarshalRecord rejects unrelated record types.
|
||||
type stubRecord struct{}
|
||||
|
||||
func (stubRecord) Symbol() string { return "STUB" }
|
||||
func (stubRecord) Nature() internal.Nature { return internal.NatureUnknown }
|
||||
func (stubRecord) BrokerCountry() int64 { return 0 }
|
||||
func (stubRecord) AssetCountry() int64 { return 0 }
|
||||
func (stubRecord) Kind() internal.Kind { return internal.KindUnknown }
|
||||
func (stubRecord) Price() decimal.Decimal { return decimal.Zero }
|
||||
func (stubRecord) Quantity() decimal.Decimal { return decimal.Zero }
|
||||
func (stubRecord) Timestamp() time.Time { return time.Time{} }
|
||||
func (stubRecord) Fees() decimal.Decimal { return decimal.Zero }
|
||||
func (stubRecord) Taxes() decimal.Decimal { return decimal.Zero }
|
||||
@@ -1,78 +0,0 @@
|
||||
package csv_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/nmoniz/any2anexoj/internal"
|
||||
"github.com/nmoniz/any2anexoj/internal/writing/csv"
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
|
||||
func TestWriter_Render(t *testing.T) {
|
||||
aw := internal.NewAggregatorWriter()
|
||||
ctx := t.Context()
|
||||
|
||||
items := []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),
|
||||
},
|
||||
{
|
||||
Symbol: "VWRL",
|
||||
Nature: internal.NatureG20,
|
||||
BrokerCountry: 196, // Portugal equivalent demo
|
||||
AssetCountry: 372, // Ireland
|
||||
BuyValue: decimal.NewFromFloat(200.00),
|
||||
BuyTimestamp: time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC),
|
||||
SellValue: decimal.NewFromFloat(250.25),
|
||||
SellTimestamp: time.Date(2024, 7, 10, 0, 0, 0, 0, time.UTC),
|
||||
Fees: decimal.NewFromFloat(1.00),
|
||||
Taxes: decimal.NewFromFloat(0.00),
|
||||
},
|
||||
}
|
||||
|
||||
for _, ri := range items {
|
||||
if err := aw.Write(ctx, ri); err != nil {
|
||||
t.Fatalf("write report item: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := csv.NewWriter(&buf).Render(aw); err != nil {
|
||||
t.Fatalf("render: %v", err)
|
||||
}
|
||||
|
||||
out := buf.String()
|
||||
|
||||
wantHeader := "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 !strings.HasPrefix(out, wantHeader+"\n") {
|
||||
t.Errorf("expected header row %q, got output:\n%s", wantHeader, out)
|
||||
}
|
||||
|
||||
wantRow1 := "840,G01,2023,6,20,150.75,2023,1,15,100.50,2.50,5.00,826"
|
||||
if !strings.Contains(out, wantRow1+"\n") {
|
||||
t.Errorf("expected row 1 %q in output:\n%s", wantRow1, out)
|
||||
}
|
||||
|
||||
wantRow2 := "372,G20,2024,7,10,250.25,2024,2,1,200.00,1.00,0.00,196"
|
||||
if !strings.Contains(out, wantRow2+"\n") {
|
||||
t.Errorf("expected row 2 %q in output:\n%s", wantRow2, out)
|
||||
}
|
||||
|
||||
// Make sure the header only appears once and we have exactly 1 header + 2 data rows.
|
||||
lines := strings.Split(strings.TrimRight(out, "\n"), "\n")
|
||||
if got, want := len(lines), 3; got != want {
|
||||
t.Errorf("expected %d lines (1 header + 2 rows), got %d:\n%s", want, got, out)
|
||||
}
|
||||
}
|
||||
-6
@@ -1,6 +0,0 @@
|
||||
@charset "UTF-8";/*!
|
||||
* Bootstrap v5.3.3 (https://getbootstrap.com/)
|
||||
* Copyright 2011-2024 The Bootstrap Authors
|
||||
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
|
||||
*/:root,[data-bs-theme=light]{--bs-blue:#0d6efd;--bs-indigo:#6610f2;--bs-purple:#6f42c1;--bs-pink:#d63384;--bs-red:#dc3545;--bs-orange:#fd7e14;--bs-yellow:#ffc107;--bs-green:#198754;--bs-teal:#20c997;--bs-cyan:#0dcaf0;--bs-black:#000;--bs-white:#fff;--bs-gray:#6c757d;--bs-gray-dark:#343a40;--bs-gray-100:#f8f9fa;--bs-gray-200:#e9ecef;--bs-gray-300:#dee2e6;--bs-gray-400:#ced4da;--bs-gray-500:#adb5bd;--bs-gray-600:#6c757d;--bs-gray-700:#495057;--bs-gray-800:#343a40;--bs-gray-900:#212529;--bs-primary:#0d6efd;--bs-secondary:#6c757d;--bs-success:#198754;--bs-info:#0dcaf0;--bs-warning:#ffc107;--bs-danger:#dc3545;--bs-light:#f8f9fa;--bs-dark:#212529;--bs-primary-rgb:13,110,253;--bs-secondary-rgb:108,117,125;--bs-success-rgb:25,135,84;--bs-info-rgb:13,202,240;--bs-warning-rgb:255,193,7;--bs-danger-rgb:220,53,69;--bs-light-rgb:248,249,250;--bs-dark-rgb:33,37,41;--bs-primary-text-emphasis:#052c65;--bs-secondary-text-emphasis:#2b2f32;--bs-success-text-emphasis:#0a3622;--bs-info-text-emphasis:#055160;--bs-warning-text-emphasis:#664d03;--bs-danger-text-emphasis:#58151c;--bs-light-text-emphasis:#495057;--bs-dark-text-emphasis:#495057;--bs-primary-bg-subtle:#cfe2ff;--bs-secondary-bg-subtle:#e2e3e5;--bs-success-bg-subtle:#d1e7dd;--bs-info-bg-subtle:#cff4fc;--bs-warning-bg-subtle:#fff3cd;--bs-danger-bg-subtle:#f8d7da;--bs-light-bg-subtle:#fcfcfd;--bs-dark-bg-subtle:#ced4da;--bs-primary-border-subtle:#9ec5fe;--bs-secondary-border-subtle:#c4c8cb;--bs-success-border-subtle:#a3cfbb;--bs-info-border-subtle:#9eeaf9;--bs-warning-border-subtle:#ffe69c;--bs-danger-border-subtle:#f1aeb5;--bs-light-border-subtle:#e9ecef;--bs-dark-border-subtle:#adb5bd;--bs-white-rgb:255,255,255;--bs-black-rgb:0,0,0;--bs-font-sans-serif:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue","Noto Sans","Liberation Sans",Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--bs-font-monospace:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--bs-gradient:linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));--bs-body-font-family:var(--bs-font-sans-serif);--bs-body-font-size:1rem;--bs-body-font-weight:400;--bs-body-line-height:1.5;--bs-body-color:#212529;--bs-body-color-rgb:33,37,41;--bs-body-bg:#fff;--bs-body-bg-rgb:255,255,255;--bs-emphasis-color:#000;--bs-emphasis-color-rgb:0,0,0;--bs-secondary-color:rgba(33, 37, 41, 0.75);--bs-secondary-color-rgb:33,37,41;--bs-secondary-bg:#e9ecef;--bs-secondary-bg-rgb:233,236,239;--bs-tertiary-color:rgba(33, 37, 41, 0.5);--bs-tertiary-color-rgb:33,37,41;--bs-tertiary-bg:#f8f9fa;--bs-tertiary-bg-rgb:248,249,250;--bs-heading-color:inherit;--bs-link-color:#0d6efd;--bs-link-color-rgb:13,110,253;--bs-link-decoration:underline;--bs-link-hover-color:#0a58ca;--bs-link-hover-color-rgb:10,88,202;--bs-code-color:#d63384;--bs-highlight-color:#212529;--bs-highlight-bg:#fff3cd;--bs-border-width:1px;--bs-border-style:solid;--bs-border-color:#dee2e6;--bs-border-color-translucent:rgba(0, 0, 0, 0.175);--bs-border-radius:0.375rem;--bs-border-radius-sm:0.25rem;--bs-border-radius-lg:0.5rem;--bs-border-radius-xl:1rem;--bs-border-radius-xxl:2rem;--bs-border-radius-2xl:var(--bs-border-radius-xxl);--bs-border-radius-pill:50rem;--bs-box-shadow:0 0.5rem 1rem rgba(0, 0, 0, 0.15);--bs-box-shadow-sm:0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);--bs-box-shadow-lg:0 1rem 3rem rgba(0, 0, 0, 0.175);--bs-box-shadow-inset:inset 0 1px 2px rgba(0, 0, 0, 0.075);--bs-focus-ring-width:0.25rem;--bs-focus-ring-opacity:0.25;--bs-focus-ring-color:rgba(13, 110, 253, 0.25);--bs-form-valid-color:#198754;--bs-form-valid-border-color:#198754;--bs-form-invalid-color:#dc3545;--bs-form-invalid-border-color:#dc3545}[data-bs-theme=dark]{color-scheme:dark;--bs-body-color:#dee2e6;--bs-body-color-rgb:222,226,230;--bs-body-bg:#212529;--bs-body-bg-rgb:33,37,41;--bs-emphasis-color:#fff;--bs-emphasis-color-rgb:255,255,255;--bs-secondary-color:rgba(222, 226, 230, 0.75);--bs-secondary-color-rgb:222,226,230;--bs-secondary-bg:#343a40;--bs-secondary-bg-rgb:52,58,64;--bs-tertiary-color:rgba(222, 226, 230, 0.5);--bs-tertiary-color-rgb:222,226,230;--bs-tertiary-bg:#2b3035;--bs-tertiary-bg-rgb:43,48,53;--bs-primary-text-emphasis:#6ea8fe;--bs-secondary-text-emphasis:#a7acb1;--bs-success-text-emphasis:#75b798;--bs-info-text-emphasis:#6edff6;--bs-warning-text-emphasis:#ffda6a;--bs-danger-text-emphasis:#ea868f;--bs-light-text-emphasis:#f8f9fa;--bs-dark-text-emphasis:#dee2e6;--bs-primary-bg-subtle:#031633;--bs-secondary-bg-subtle:#161719;--bs-success-bg-subtle:#051b11;--bs-info-bg-subtle:#032830;--bs-warning-bg-subtle:#332701;--bs-danger-bg-subtle:#2c0b0e;--bs-light-bg-subtle:#343a40;--bs-dark-bg-subtle:#1a1d20;--bs-primary-border-subtle:#084298;--bs-secondary-border-subtle:#41464b;--bs-success-border-subtle:#0f5132;--bs-info-border-subtle:#087990;--bs-warning-border-subtle:#997404;--bs-danger-border-subtle:#842029;--bs-light-border-subtle:#495057;--bs-dark-border-subtle:#343a40;--bs-heading-color:inherLine truncated
|
||||
/*# sourceMappingURL=bootstrap.min.css.map */
|
||||
@@ -1,18 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{.Lang}}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{.Title}}</title>
|
||||
<style>{{.CSS}}</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="container-fluid py-4">
|
||||
<h1>{{.Title}}</h1>
|
||||
<div class="table-responsive">
|
||||
{{.TableFragment}}
|
||||
</div>
|
||||
<p class="text-muted small">Generated at {{.GeneratedAt}}</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,69 +0,0 @@
|
||||
package html
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/jedib0t/go-pretty/v6/table"
|
||||
"github.com/nmoniz/any2anexoj/internal"
|
||||
"github.com/nmoniz/any2anexoj/internal/writing/internal/tabledef"
|
||||
)
|
||||
|
||||
// htmlCSSClass is the CSS class list put on the rendered report table.
|
||||
const htmlCSSClass = "table table-striped table-hover table-bordered"
|
||||
|
||||
// widthIndex is the weight of the auto-index (row number) column; the other
|
||||
// column weights come from tabledef's width budgets.
|
||||
const widthIndex = 6
|
||||
|
||||
// htmlColumnWeights lists one weight per rendered column, starting with the
|
||||
// auto-index column, mirroring the column order of tabledef.Build so the
|
||||
// HTML table keeps columns in the same strict proportion to each other as
|
||||
// the terminal layout.
|
||||
var htmlColumnWeights = []int{
|
||||
widthIndex,
|
||||
tabledef.WidthCountry, tabledef.WidthOther, tabledef.WidthOther, tabledef.WidthOther, tabledef.WidthOther, tabledef.WidthEuros,
|
||||
tabledef.WidthOther, tabledef.WidthOther, tabledef.WidthOther, tabledef.WidthEuros,
|
||||
tabledef.WidthEuros, tabledef.WidthEuros, tabledef.WidthCountry,
|
||||
}
|
||||
|
||||
// renderTableFragment renders aw as the Bootstrap-styled HTML <table>
|
||||
// fragment embedded in the document. The table gets a fixed layout with
|
||||
// strictly proportional column widths, so the browser — not this package —
|
||||
// decides when a header or cell wraps: on wide screens everything fits on
|
||||
// one line, on narrow ones it wraps automatically.
|
||||
func (hw *Writer) renderTableFragment(aw *internal.AggregatorWriter) string {
|
||||
t := tabledef.Build(hw.translator, aw)
|
||||
t.Style().HTML = table.HTMLOptions{
|
||||
CSSClass: htmlCSSClass,
|
||||
EscapeText: true,
|
||||
Newline: "<br/>",
|
||||
EmptyColumn: " ",
|
||||
}
|
||||
html := t.RenderHTML()
|
||||
|
||||
// go-pretty cannot emit table-level attributes or a <colgroup>, so splice
|
||||
// them into its output: the fixed layout makes the colgroup's relative
|
||||
// widths authoritative instead of content-driven column sizing.
|
||||
openTag := `<table class="` + htmlCSSClass + `">`
|
||||
fixedTag := `<table class="` + htmlCSSClass + `" style="table-layout: fixed">` + "\n" + colgroupHTML()
|
||||
return strings.Replace(html, openTag, fixedTag, 1)
|
||||
}
|
||||
|
||||
// colgroupHTML returns a <colgroup> with one <col> per rendered column, each
|
||||
// sized strictly in proportion to its weight. With the fixed table layout
|
||||
// applied in renderTableFragment these widths are authoritative: wide
|
||||
// viewports fit headers on a single line, narrow ones wrap automatically.
|
||||
func colgroupHTML() string {
|
||||
var total int
|
||||
for _, w := range htmlColumnWeights {
|
||||
total += w
|
||||
}
|
||||
var sb strings.Builder
|
||||
sb.WriteString("<colgroup>")
|
||||
for _, w := range htmlColumnWeights {
|
||||
fmt.Fprintf(&sb, `<col style="width: %.2f%%">`, float64(w)/float64(total)*100)
|
||||
}
|
||||
sb.WriteString("</colgroup>")
|
||||
return sb.String()
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
package html_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/nmoniz/any2anexoj/internal/writing"
|
||||
htmlwriter "github.com/nmoniz/any2anexoj/internal/writing/html"
|
||||
)
|
||||
|
||||
// TestWriter_RenderTableFragment renders a Portuguese document and asserts
|
||||
// the embedded report table fragment carries the shared structure: the
|
||||
// translated headers, the totals footer, and the strictly proportional
|
||||
// colgroup that keeps HTML columns in the same relation to each other as
|
||||
// the terminal layout.
|
||||
func TestWriter_RenderTableFragment(t *testing.T) {
|
||||
aw := buildTestAggregator(t)
|
||||
|
||||
localizer, err := writing.NewLocalizer("pt")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create localizer: %v", err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
hw := htmlwriter.NewWriter(&buf, localizer, "pt")
|
||||
|
||||
if err := hw.Render(aw); err != nil {
|
||||
t.Fatalf("Render returned an error: %v", err)
|
||||
}
|
||||
got := buf.String()
|
||||
|
||||
wantSubstrings := []string{
|
||||
`<table class="table table-striped table-hover table-bordered" style="table-layout: fixed">`,
|
||||
`<colgroup>`,
|
||||
`</colgroup>`,
|
||||
`<col style="width: 3.03%">`,
|
||||
`<col style="width: 12.12%">`,
|
||||
`</table>`,
|
||||
`<thead>`,
|
||||
`</thead>`,
|
||||
`<tbody>`,
|
||||
`</tbody>`,
|
||||
`<tfoot>`,
|
||||
`</tfoot>`,
|
||||
"Realização",
|
||||
"Aquisição",
|
||||
"Soma",
|
||||
}
|
||||
for _, s := range wantSubstrings {
|
||||
if !strings.Contains(got, s) {
|
||||
t.Errorf("rendered document missing expected substring %q\nGot:\n%s", s, got)
|
||||
}
|
||||
}
|
||||
|
||||
// The fragment must be embedded raw, not HTML-escaped.
|
||||
if strings.Contains(got, "<table") {
|
||||
t.Errorf("table fragment appears to be HTML-escaped; expected raw <table> markup\nGot:\n%s", got)
|
||||
}
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
// Package html renders an internal.AggregatorWriter as a complete,
|
||||
// standalone HTML document: the report table fragment (built from the
|
||||
// shared table definition in internal/writing/internal/tabledef, so it can
|
||||
// never drift from the terminal layout) embedded in a Bootstrap-styled page
|
||||
// with a translated title and generation timestamp. The official Bootstrap
|
||||
// 5.3.3 stylesheet is inlined via go:embed, so the document renders fully
|
||||
// offline.
|
||||
package html
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"github.com/nmoniz/any2anexoj/internal"
|
||||
"github.com/nmoniz/any2anexoj/internal/writing/internal/tabledef"
|
||||
)
|
||||
|
||||
// bootstrapMinCSS is the official Bootstrap 5.3.3 minified CSS distribution
|
||||
// file, embedded so every rendered document renders fully offline. After any
|
||||
// version bump, re-verify provenance with:
|
||||
//
|
||||
// openssl dgst -sha384 -binary bootstrap.min.css | base64
|
||||
//
|
||||
// It must print the official SRI hash published at
|
||||
// https://getbootstrap.com/docs/5.3/getting-started/download/
|
||||
// (sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH).
|
||||
//
|
||||
//go:embed bootstrap.min.css
|
||||
var bootstrapMinCSS string
|
||||
|
||||
// Writer writes a complete HTML document for an AggregatorWriter.
|
||||
type Writer struct {
|
||||
output io.Writer
|
||||
translator tabledef.Translator
|
||||
lang string
|
||||
}
|
||||
|
||||
// NewWriter builds a Writer that writes a complete HTML document to w. The
|
||||
// report table is rendered from the shared table definition via tr, and the
|
||||
// document title is looked up from the "report_title" translation via tr,
|
||||
// so callers never pass a pre-baked title.
|
||||
func NewWriter(w io.Writer, tr tabledef.Translator, lang string) *Writer {
|
||||
return &Writer{
|
||||
output: w,
|
||||
translator: tr,
|
||||
lang: lang,
|
||||
}
|
||||
}
|
||||
|
||||
//go:embed document.tmpl
|
||||
var documentTemplateBody string
|
||||
|
||||
// documentTemplate renders the full HTML page from document.tmpl. The table
|
||||
// fragment is typed as template.HTML so the template engine emits it
|
||||
// verbatim without escaping the angle brackets in the <table> markup.
|
||||
var documentTemplate = template.Must(template.New("html").Parse(documentTemplateBody))
|
||||
|
||||
// Render writes a complete standalone HTML document for aw to the configured
|
||||
// output. The same Writer may be reused; each call rebuilds the table
|
||||
// fragment from aw so the rendered <tr> count reflects aw's current items and
|
||||
// no rows are duplicated across invocations.
|
||||
func (hw *Writer) Render(aw *internal.AggregatorWriter) error {
|
||||
fragment := hw.renderTableFragment(aw)
|
||||
title := hw.translator.Translate("report_title", 1, nil)
|
||||
data := struct {
|
||||
Lang string
|
||||
Title string
|
||||
CSS template.CSS
|
||||
TableFragment template.HTML
|
||||
GeneratedAt string
|
||||
}{
|
||||
Lang: hw.lang,
|
||||
Title: title,
|
||||
CSS: template.CSS(bootstrapMinCSS),
|
||||
TableFragment: template.HTML(fragment),
|
||||
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
if err := documentTemplate.Execute(hw.output, data); err != nil {
|
||||
return fmt.Errorf("render html document: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,188 +0,0 @@
|
||||
package html_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/nmoniz/any2anexoj/internal"
|
||||
"github.com/nmoniz/any2anexoj/internal/writing"
|
||||
htmlwriter "github.com/nmoniz/any2anexoj/internal/writing/html"
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
|
||||
// buildTestAggregator returns an AggregatorWriter seeded with two
|
||||
// deterministic ReportItems so every test can assert on a stable document
|
||||
// shape. Mirrors the fixture pattern used in
|
||||
// internal/writing/table/printer_test.go.
|
||||
func buildTestAggregator(t *testing.T) *internal.AggregatorWriter {
|
||||
t.Helper()
|
||||
aw := internal.NewAggregatorWriter()
|
||||
ctx := t.Context()
|
||||
|
||||
err := aw.Write(ctx, internal.ReportItem{
|
||||
Symbol: "AAPL",
|
||||
Nature: internal.NatureG01,
|
||||
BrokerCountry: 826,
|
||||
AssetCountry: 840,
|
||||
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,
|
||||
AssetCountry: 840,
|
||||
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)
|
||||
}
|
||||
|
||||
return aw
|
||||
}
|
||||
|
||||
func TestWriter_Render(t *testing.T) {
|
||||
loc, err := writing.NewLocalizer("en")
|
||||
if err != nil {
|
||||
t.Fatalf("create localizer: %v", err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
hw := htmlwriter.NewWriter(&buf, loc, "en")
|
||||
|
||||
if err := hw.Render(buildTestAggregator(t)); err != nil {
|
||||
t.Fatalf("Render returned an error: %v", err)
|
||||
}
|
||||
|
||||
got := buf.String()
|
||||
|
||||
wantSubstrings := []string{
|
||||
"<!DOCTYPE html>",
|
||||
`<html lang="en">`,
|
||||
`<meta charset="utf-8">`,
|
||||
`<meta name="viewport" content="width=device-width, initial-scale=1">`,
|
||||
`<title>Tax report</title>`,
|
||||
`<style>`,
|
||||
`Bootstrap v5.3.3`,
|
||||
`<main class="container-fluid py-4">`,
|
||||
`<h1>Tax report</h1>`,
|
||||
`<div class="table-responsive">`,
|
||||
`<table class="table table-striped table-hover table-bordered" style="table-layout: fixed">`,
|
||||
`<colgroup>`,
|
||||
`</table>`,
|
||||
`<tfoot>`,
|
||||
`</tfoot>`,
|
||||
`</html>`,
|
||||
`<p class="text-muted small">Generated at `,
|
||||
}
|
||||
for _, s := range wantSubstrings {
|
||||
if !strings.Contains(got, s) {
|
||||
t.Errorf("rendered HTML missing expected substring %q\nGot:\n%s", s, got)
|
||||
}
|
||||
}
|
||||
|
||||
// The table fragment must be embedded as raw HTML, not escaped.
|
||||
if strings.Contains(got, "<table") {
|
||||
t.Errorf("table fragment appears to be HTML-escaped; expected raw <table> markup\nGot:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "<table") {
|
||||
t.Errorf("expected raw <table> tag in output\nGot:\n%s", got)
|
||||
}
|
||||
|
||||
// The document must be fully self-contained: no external stylesheet
|
||||
// <link> and no CDN reference anywhere.
|
||||
if strings.Contains(got, "<link") {
|
||||
t.Errorf("expected no external stylesheet <link>; document must be self-contained\nGot:\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, "cdn.jsdelivr.net") {
|
||||
t.Errorf("expected no CDN references; document must be self-contained\nGot:\n%s", got)
|
||||
}
|
||||
|
||||
// Generated timestamp must be valid RFC3339 UTC.
|
||||
startIdx := strings.Index(got, "Generated at ")
|
||||
if startIdx == -1 {
|
||||
t.Fatalf("missing 'Generated at' prefix in output")
|
||||
}
|
||||
timestampLine := got[startIdx:]
|
||||
endIdx := strings.Index(timestampLine, "</p>")
|
||||
if endIdx == -1 {
|
||||
t.Fatalf("missing </p> closing tag after 'Generated at' line")
|
||||
}
|
||||
timestampStr := strings.TrimPrefix(strings.SplitN(timestampLine, "</p>", 2)[0], "Generated at ")
|
||||
if _, err := time.Parse(time.RFC3339, timestampStr); err != nil {
|
||||
t.Errorf("generation timestamp is not valid RFC3339: %q (err: %v)", timestampStr, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWriter_NoRowDuplication verifies that calling Render twice on the
|
||||
// same writer produces documents with the same number of <tr> rows, just
|
||||
// like table.Printer's no-duplication guarantee.
|
||||
func TestWriter_NoRowDuplication(t *testing.T) {
|
||||
loc, err := writing.NewLocalizer("en")
|
||||
if err != nil {
|
||||
t.Fatalf("create localizer: %v", err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
hw := htmlwriter.NewWriter(&buf, loc, "en")
|
||||
|
||||
aw := buildTestAggregator(t)
|
||||
|
||||
if err := hw.Render(aw); err != nil {
|
||||
t.Fatalf("first Render returned an error: %v", err)
|
||||
}
|
||||
first := buf.String()
|
||||
buf.Reset()
|
||||
|
||||
if err := hw.Render(aw); err != nil {
|
||||
t.Fatalf("second Render returned an error: %v", err)
|
||||
}
|
||||
second := buf.String()
|
||||
|
||||
firstRows := strings.Count(first, "<tr")
|
||||
secondRows := strings.Count(second, "<tr")
|
||||
if firstRows == 0 {
|
||||
t.Fatalf("expected at least one <tr> row in the first render; got:\n%s", first)
|
||||
}
|
||||
if firstRows != secondRows {
|
||||
t.Errorf("expected second Render to produce the same number of <tr> rows as the first (no duplication)\nfirst rows=%d, second rows=%d\nFirst:\n%s\nSecond:\n%s",
|
||||
firstRows, secondRows, first, second)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriter_LangAttribute(t *testing.T) {
|
||||
loc, err := writing.NewLocalizer("pt")
|
||||
if err != nil {
|
||||
t.Fatalf("create localizer: %v", err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
hw := htmlwriter.NewWriter(&buf, loc, "pt")
|
||||
|
||||
if err := hw.Render(buildTestAggregator(t)); err != nil {
|
||||
t.Fatalf("Render returned an error: %v", err)
|
||||
}
|
||||
|
||||
got := buf.String()
|
||||
if !strings.Contains(got, `<html lang="pt">`) {
|
||||
t.Errorf("expected <html lang=\"pt\"> attribute; got:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, `<h1>Relatório fiscal</h1>`) {
|
||||
t.Errorf("expected Portuguese title in <h1>; got:\n%s", got)
|
||||
}
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
// Package tabledef holds the single definition of the Anexo J report table
|
||||
// — column configuration, translated headers, data rows and the totals
|
||||
// footer — shared by every renderer under internal/writing. Living in a
|
||||
// writing-internal package lets the table and html packages project the
|
||||
// same structure into their own formats without any of them exposing
|
||||
// go-pretty types in their public API.
|
||||
package tabledef
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/biter777/countries"
|
||||
"github.com/jedib0t/go-pretty/v6/table"
|
||||
"github.com/jedib0t/go-pretty/v6/text"
|
||||
"github.com/nmoniz/any2anexoj/internal"
|
||||
)
|
||||
|
||||
// Translator is the contract the report table renderers rely on for
|
||||
// human-readable column labels. *writing.Localizer satisfies it.
|
||||
type Translator interface {
|
||||
Translate(key string, count int, values map[string]any) string
|
||||
}
|
||||
|
||||
// Column width budgets enforced by the terminal renderer. The html package
|
||||
// derives its proportional column widths from the same values, keeping both
|
||||
// renderings in the same strict proportion to each other.
|
||||
const (
|
||||
// WidthCountry is the width budget of a country column ("code - name").
|
||||
WidthCountry = 24
|
||||
// WidthOther is the width budget of a plain column (code, year, ...).
|
||||
WidthOther = 12
|
||||
// WidthEuros is the width budget of a money column ("123.45 €").
|
||||
WidthEuros = 15
|
||||
)
|
||||
|
||||
// Build constructs a fully configured go-pretty table.Writer from the items
|
||||
// in aw: one column per report field, translated headers, data rows and the
|
||||
// totals footer. Each call returns a fresh table so callers can render the
|
||||
// same data multiple times without duplicating rows, and the returned table
|
||||
// is not wired to any output.
|
||||
//
|
||||
// Change columns, headers or footers here so every renderer stays in sync.
|
||||
func Build(tr Translator, aw *internal.AggregatorWriter) table.Writer {
|
||||
tw := table.NewWriter()
|
||||
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),
|
||||
})
|
||||
|
||||
realizationTxt := tr.Translate("realization", 1, nil)
|
||||
acquisitionTxt := tr.Translate("acquisition", 1, nil)
|
||||
yearTxt := tr.Translate("year", 1, nil)
|
||||
monthTxt := tr.Translate("month", 1, nil)
|
||||
dayTxt := tr.Translate("day", 1, nil)
|
||||
valorTxt := tr.Translate("value", 1, nil)
|
||||
|
||||
tw.AppendHeader(table.Row{"", "", realizationTxt, realizationTxt, realizationTxt, realizationTxt, acquisitionTxt, acquisitionTxt, acquisitionTxt, acquisitionTxt, "", "", ""}, table.RowConfig{AutoMerge: true})
|
||||
tw.AppendHeader(table.Row{
|
||||
tr.Translate("source_country", 1, nil), tr.Translate("code", 1, nil),
|
||||
yearTxt, monthTxt, dayTxt, valorTxt,
|
||||
yearTxt, monthTxt, dayTxt, valorTxt,
|
||||
tr.Translate("expenses", 2, nil), tr.Translate("foreign_tax_paid", 1, nil), tr.Translate("counter_country", 1, nil),
|
||||
})
|
||||
|
||||
for ri := range aw.Iter() {
|
||||
tw.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,
|
||||
})
|
||||
}
|
||||
|
||||
sumTxt := tr.Translate("sum", 1, nil)
|
||||
// The footer lists one cell per column (13) so it never relies on
|
||||
// go-pretty's implicit short-row padding; the trailing empty cell is the
|
||||
// counter-country column, which has no total. Totals are formatted with
|
||||
// StringFixed(2) so they match the two-decimal data rows.
|
||||
tw.AppendFooter(table.Row{sumTxt, sumTxt, sumTxt, sumTxt, sumTxt, aw.TotalEarned().StringFixed(2), "", "", "", aw.TotalSpent().StringFixed(2), aw.TotalFees().StringFixed(2), aw.TotalTaxes().StringFixed(2), ""}, table.RowConfig{AutoMerge: true, AutoMergeAlign: text.AlignRight})
|
||||
|
||||
return tw
|
||||
}
|
||||
|
||||
func colEuros(n int) table.ColumnConfig {
|
||||
return table.ColumnConfig{
|
||||
Number: n,
|
||||
Align: text.AlignRight,
|
||||
AlignFooter: text.AlignRight,
|
||||
AlignHeader: text.AlignRight,
|
||||
WidthMin: WidthOther,
|
||||
WidthMax: WidthEuros,
|
||||
WidthMaxEnforcer: truncateHeader,
|
||||
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: WidthOther,
|
||||
WidthMaxEnforcer: truncateHeader,
|
||||
}
|
||||
}
|
||||
|
||||
func colCountry(n int) table.ColumnConfig {
|
||||
return table.ColumnConfig{
|
||||
Number: n,
|
||||
Align: text.AlignLeft,
|
||||
AlignFooter: text.AlignLeft,
|
||||
AlignHeader: text.AlignLeft,
|
||||
WidthMax: WidthCountry,
|
||||
WidthMaxEnforcer: text.Trim,
|
||||
Transformer: func(val any) string {
|
||||
countryCode := val.(int64)
|
||||
return fmt.Sprintf("%v - %s", val, countries.ByNumeric(int(countryCode)).Info().Name)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// truncateHeader enforces a column's WidthMax on terminal output by cutting
|
||||
// the text exactly at the width limit — mid-word if that is where it lands —
|
||||
// and marking the cut with an ellipsis: keeping as many leading runes
|
||||
// readable beats a tidy word boundary. All indexing is rune-based so
|
||||
// multibyte labels (e.g. Portuguese) truncate correctly.
|
||||
func truncateHeader(val string, width int) string {
|
||||
runes := []rune(val)
|
||||
if len(runes) <= width {
|
||||
return val
|
||||
}
|
||||
// leave room for the ellipsis
|
||||
limit := max(width-1, 1)
|
||||
return string(runes[:limit]) + "…"
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
package tabledef
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestTruncateHeader(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
val string
|
||||
width int
|
||||
want string
|
||||
}{
|
||||
{name: "fits unchanged", val: "TAX PAID ABROAD", width: 15, want: "TAX PAID ABROAD"},
|
||||
{name: "cut mid-word keeps the most runes", val: "EXPENSES AND CHARGES", width: 15, want: "EXPENSES AND C…"},
|
||||
{name: "single overlong word is hard cut", val: "ESTRANGEIRO", width: 8, want: "ESTRANG…"},
|
||||
{name: "cut ignores word boundaries", val: "IMPOSTO PAGO NO ESTRANGEIRO", width: 15, want: "IMPOSTO PAGO N…"},
|
||||
{name: "multibyte runes count as one", val: "PAÍS DA CONTRAPARTE", width: 12, want: "PAÍS DA CON…"},
|
||||
{name: "narrow width", val: "ABCD", width: 2, want: "A…"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := truncateHeader(tt.val, tt.width); got != tt.want {
|
||||
t.Errorf("%s: truncateHeader(%q, %d) = %q, want %q", tt.name, tt.val, tt.width, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
// Package table renders an internal.AggregatorWriter as the human-readable
|
||||
// Anexo J report table for the terminal. The shared table definition it (and
|
||||
// the html package) renders from lives in internal/writing/internal/tabledef.
|
||||
package table
|
||||
|
||||
import (
|
||||
"io"
|
||||
|
||||
"github.com/nmoniz/any2anexoj/internal"
|
||||
"github.com/nmoniz/any2anexoj/internal/writing/internal/tabledef"
|
||||
)
|
||||
|
||||
// Printer writes a simple, human readable, table to the provided io.Writer
|
||||
// for the report items in an AggregatorWriter. Each Render call builds a
|
||||
// fresh table from the aggregator, so invoking it repeatedly on the same
|
||||
// instance never duplicates rows.
|
||||
type Printer struct {
|
||||
output io.Writer
|
||||
translator tabledef.Translator
|
||||
}
|
||||
|
||||
func NewPrinter(w io.Writer, tr tabledef.Translator) *Printer {
|
||||
return &Printer{
|
||||
output: w,
|
||||
translator: tr,
|
||||
}
|
||||
}
|
||||
|
||||
// Render writes the terminal table for aw to the configured output.
|
||||
func (pp *Printer) Render(aw *internal.AggregatorWriter) {
|
||||
t := tabledef.Build(pp.translator, aw)
|
||||
t.SetOutputMirror(pp.output)
|
||||
t.Render()
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"extends": [
|
||||
"config:base",
|
||||
":disableDependencyDashboard"
|
||||
],
|
||||
"packageRules": [
|
||||
{
|
||||
"updateTypes": [
|
||||
"minor",
|
||||
"patch",
|
||||
"pin",
|
||||
"digest"
|
||||
],
|
||||
"automerge": false
|
||||
}
|
||||
],
|
||||
"postUpdateOptions": [
|
||||
"gomodTidy"
|
||||
]
|
||||
}
|
||||
Reference in new issue
Block a user