Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa7aef44f7 | ||
|
|
ede5dfafe2 | ||
|
|
ae0e77cecb | ||
|
|
76502bc8d4 | ||
|
|
6a399b7c56 | ||
|
|
ff791955d4 | ||
|
|
5011174bdd | ||
|
|
c42a852938 | ||
|
|
f35cf1d624 | ||
|
|
16ae9975ee |
No files matched your search
@@ -4,23 +4,23 @@ go 1.26.4
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/biter777/countries v1.7.5
|
github.com/biter777/countries v1.7.5
|
||||||
github.com/jedib0t/go-pretty/v6 v6.7.2
|
github.com/jedib0t/go-pretty/v6 v6.8.3
|
||||||
github.com/shopspring/decimal v1.4.0
|
github.com/shopspring/decimal v1.6.0
|
||||||
github.com/spf13/pflag v1.0.10
|
github.com/spf13/pflag v1.1.0
|
||||||
go.uber.org/mock v0.6.0
|
go.uber.org/mock v0.6.0
|
||||||
golang.org/x/time v0.14.0
|
golang.org/x/time v0.16.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require golang.org/x/sync v0.18.0 // indirect
|
require golang.org/x/sync v0.24.0 // indirect
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||||
github.com/nicksnyder/go-i18n/v2 v2.6.0
|
github.com/nicksnyder/go-i18n/v2 v2.7.0
|
||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
golang.org/x/mod v0.27.0 // indirect
|
golang.org/x/mod v0.41.0 // indirect
|
||||||
golang.org/x/sys v0.35.0 // indirect
|
golang.org/x/sys v0.48.0 // indirect
|
||||||
golang.org/x/text v0.23.0
|
golang.org/x/text v0.43.0
|
||||||
golang.org/x/tools v0.36.0 // indirect
|
golang.org/x/tools v0.51.0 // indirect
|
||||||
)
|
)
|
||||||
|
|
||||||
tool go.uber.org/mock/mockgen
|
tool go.uber.org/mock/mockgen
|
||||||
@@ -1,41 +1,43 @@
|
|||||||
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
|
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
||||||
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
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 h1:MJ+n3+rSxWQdqVJU8eBy9RqcdH6ePPn4PJHocVWUa+Q=
|
||||||
github.com/biter777/countries v1.7.5/go.mod h1:1HSpZ526mYqKJcpT5Ti1kcGQ0L0SrXWIaptUWjFfv2E=
|
github.com/biter777/countries v1.7.5/go.mod h1:1HSpZ526mYqKJcpT5Ti1kcGQ0L0SrXWIaptUWjFfv2E=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
github.com/jedib0t/go-pretty/v6 v6.7.2 h1:EYWgQNIH/+JsyHki7ns9OHyBKuHPkzrBo02uYjran7w=
|
github.com/jedib0t/go-pretty/v6 v6.8.3 h1:yVSk5aemoYHCvcrtqyXklwqcgHQIQzmy/oUzFlmffSQ=
|
||||||
github.com/jedib0t/go-pretty/v6 v6.7.2/go.mod h1:YwC5CE4fJ1HFUDeivSV1r//AmANFHyqczZk+U6BDALU=
|
github.com/jedib0t/go-pretty/v6 v6.8.3/go.mod h1:YwC5CE4fJ1HFUDeivSV1r//AmANFHyqczZk+U6BDALU=
|
||||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
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/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||||
github.com/nicksnyder/go-i18n/v2 v2.6.0 h1:C/m2NNWNiTB6SK4Ao8df5EWm3JETSTIGNXBpMJTxzxQ=
|
github.com/nicksnyder/go-i18n/v2 v2.7.0 h1:oiVQUmuCeNggvm/fUVb6/R0aP82BDVphZnmVHwut+J0=
|
||||||
github.com/nicksnyder/go-i18n/v2 v2.6.0/go.mod h1:88sRqr0C6OPyJn0/KRNaEz1uWorjxIKP7rUUcvycecE=
|
github.com/nicksnyder/go-i18n/v2 v2.7.0/go.mod h1:ZMYiWg8ndQSGNk4zbzbyJjrrVjQjuQ8pERFrnBik+c8=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
|
github.com/shopspring/decimal v1.6.0 h1:DcTwjCj4tJPLuGZYsR9W0hyX9pQwMIkdo26Ig1T9xX4=
|
||||||
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
|
github.com/shopspring/decimal v1.6.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
|
||||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
github.com/spf13/pflag v1.1.0 h1:6W6MbVUrnMb8PEhjkqwsVLY7ryuzRfFHF4mQd7L94ag=
|
||||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
github.com/spf13/pflag v1.1.0/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
github.com/stretchr/testify v1.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 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||||
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
|
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||||
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
|
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
|
||||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
|
||||||
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
|
||||||
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||||
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||||
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc=
|
||||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s=
|
||||||
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
|
golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE=
|
||||||
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
|
golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s=
|
||||||
|
golang.org/x/tools v0.51.0 h1:k4Xc/1Om9jwkBJBo4NVLMSARBoWtK10mx+W5BnXCeAI=
|
||||||
|
golang.org/x/tools v0.51.0/go.mod h1:9eEncMayCV6zRMGhR5eZEC2iBx98qWcF1HZ9Z7wJOoA=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# 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.
|
||||||
+151
-45
@@ -92,6 +92,10 @@ const (
|
|||||||
SpinOff = "spin off"
|
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) {
|
func (rr RecordReader) ReadRecord(ctx context.Context) (internal.Record, error) {
|
||||||
var splitRec *splitRecord
|
var splitRec *splitRecord
|
||||||
|
|
||||||
@@ -101,13 +105,89 @@ func (rr RecordReader) ReadRecord(ctx context.Context) (internal.Record, error)
|
|||||||
return Record{}, fmt.Errorf("read record: %w", err)
|
return Record{}, fmt.Errorf("read record: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.ToLower(raw[0]) == "action" {
|
action := strings.Trim(strings.ToLower(raw[0]), " ")
|
||||||
|
if action == "action" { // header row
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
symbol := raw[2]
|
switch action {
|
||||||
|
case MarketBuy, LimitBuy:
|
||||||
|
return rr.parseBuyRecord(ctx, raw)
|
||||||
|
|
||||||
qant, err := parseDecimal(raw[6])
|
case MarketSell, LimitSell:
|
||||||
|
return rr.parseSellRecord(ctx, raw)
|
||||||
|
|
||||||
|
case SpinOff:
|
||||||
|
return rr.parseSpinOff(ctx, raw)
|
||||||
|
|
||||||
|
case StockSplitOpen:
|
||||||
|
if splitRec != nil {
|
||||||
|
return nil, fmt.Errorf("split already open")
|
||||||
|
}
|
||||||
|
|
||||||
|
rec, err := rr.parseTradeRecord(ctx, raw)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
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),
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
default:
|
||||||
|
return Record{}, fmt.Errorf("parse record type: %s", raw[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
if err != nil {
|
||||||
return Record{}, fmt.Errorf("parse record quantity: %w", err)
|
return Record{}, fmt.Errorf("parse record quantity: %w", err)
|
||||||
}
|
}
|
||||||
@@ -137,69 +217,81 @@ func (rr RecordReader) ReadRecord(ctx context.Context) (internal.Record, error)
|
|||||||
return Record{}, fmt.Errorf("parse record french transaction tax: %w", err)
|
return Record{}, fmt.Errorf("parse record french transaction tax: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var kind internal.Kind
|
return Record{
|
||||||
switch strings.ToLower(raw[0]) {
|
symbol: raw[2],
|
||||||
case MarketBuy, LimitBuy:
|
|
||||||
kind = internal.KindBuy
|
|
||||||
case MarketSell, LimitSell:
|
|
||||||
kind = internal.KindSell
|
|
||||||
case StockSplitOpen:
|
|
||||||
if splitRec != nil {
|
|
||||||
return nil, fmt.Errorf("split already open")
|
|
||||||
}
|
|
||||||
|
|
||||||
splitRec = &splitRecord{
|
|
||||||
Record: Record{
|
|
||||||
symbol: symbol,
|
|
||||||
kind: internal.KindSplit,
|
|
||||||
quantity: qant,
|
quantity: qant,
|
||||||
price: price,
|
price: price,
|
||||||
fees: conversionFee,
|
fees: conversionFee,
|
||||||
taxes: stampDutyTax.Add(frenchTxTax),
|
taxes: stampDutyTax.Add(frenchTxTax),
|
||||||
timestamp: ts,
|
timestamp: ts,
|
||||||
natureGetter: figiNatureGetter(ctx, rr.figi, symbol),
|
natureGetter: figiNatureGetter(ctx, rr.figi, raw[2]),
|
||||||
},
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
continue
|
// 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]
|
||||||
|
|
||||||
case StockSplitClose:
|
qant, err := parsePositiveDecimal(raw[6])
|
||||||
if splitRec == nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("missing split open")
|
return Record{}, fmt.Errorf("parse record quantity: %w", err)
|
||||||
}
|
}
|
||||||
splitRec.ratio = splitRec.Record.Quantity().Div(qant)
|
|
||||||
return splitRec, nil
|
|
||||||
|
|
||||||
case StokDistribution:
|
ts, err := parseTimestamp(raw[1])
|
||||||
slog.Warn("Skipping 'stock distribution' event!",
|
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("ISIN", symbol),
|
||||||
slog.Any("Quantity", qant),
|
slog.Any("Quantity", qant),
|
||||||
)
|
)
|
||||||
continue
|
|
||||||
|
|
||||||
case SpinOff:
|
|
||||||
slog.Warn("Skipping 'spin off' event!",
|
|
||||||
slog.Any("ISIN", symbol),
|
|
||||||
slog.Any("Quantity", qant),
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
default:
|
|
||||||
return Record{}, fmt.Errorf("parse record type: %s", raw[0])
|
|
||||||
}
|
|
||||||
|
|
||||||
return Record{
|
return Record{
|
||||||
symbol: symbol,
|
symbol: symbol,
|
||||||
kind: kind,
|
kind: internal.KindBuy,
|
||||||
quantity: qant,
|
quantity: qant,
|
||||||
price: price,
|
price: decimal.Zero,
|
||||||
fees: conversionFee,
|
fees: decimal.Zero,
|
||||||
taxes: stampDutyTax.Add(frenchTxTax),
|
taxes: decimal.Zero,
|
||||||
timestamp: ts,
|
timestamp: ts,
|
||||||
natureGetter: figiNatureGetter(ctx, rr.figi, symbol),
|
natureGetter: figiNatureGetter(ctx, rr.figi, symbol),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
func figiNatureGetter(ctx context.Context, of *ofigi.Client, isin string) func() internal.Nature {
|
func figiNatureGetter(ctx context.Context, of *ofigi.Client, isin string) func() internal.Nature {
|
||||||
return sync.OnceValue(func() internal.Nature {
|
return sync.OnceValue(func() internal.Nature {
|
||||||
@@ -238,6 +330,20 @@ func parseOptionalDecimal(s string) (decimal.Decimal, error) {
|
|||||||
return parseDecimal(s)
|
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
|
// parseTimestamp parses a Trading212 timestamp string. Trading212 has shipped
|
||||||
// rows both without a timezone (treated here as UTC) and with an explicit
|
// 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.
|
// numeric offset (e.g. "+00:00", "-05:00", "+01:00") or a "Z" UTC marker.
|
||||||
|
|||||||
@@ -138,6 +138,69 @@ func TestRecordReader_ReadRecord(t *testing.T) {
|
|||||||
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",,`),
|
r: bytes.NewBufferString(`Market buy,not-a-timestamp,IE000GA3D489,ABXY,"Aspargus Broccoli",EOF987654321,2.4387014200,7.3690000000,USD,1.17995999,,"EUR",15.25,"EUR",0.25,"EUR",0.02,"EUR",,`),
|
||||||
wantErr: true,
|
wantErr: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
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 {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
@@ -377,10 +440,6 @@ func TestRecordReader_ReadRecord_SkipsCorporateActions(t *testing.T) {
|
|||||||
name string
|
name string
|
||||||
row string
|
row string
|
||||||
}{
|
}{
|
||||||
{
|
|
||||||
name: "spin off",
|
|
||||||
row: `Spin off,2026-02-11 13:41:43+00:00,US9418481035,WAT,"Waters",EOF46451921527,0.0406450000,0E-10,USD,,,,0.00,"EUR",,,,,,`,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: "stock distribution",
|
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",,`,
|
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",,`,
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"extends": [
|
||||||
|
"config:base",
|
||||||
|
":disableDependencyDashboard"
|
||||||
|
],
|
||||||
|
"packageRules": [
|
||||||
|
{
|
||||||
|
"updateTypes": [
|
||||||
|
"minor",
|
||||||
|
"patch",
|
||||||
|
"pin",
|
||||||
|
"digest"
|
||||||
|
],
|
||||||
|
"automerge": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"postUpdateOptions": [
|
||||||
|
"gomodTidy"
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in new issue
Block a user