From f1a7f777ef3242926ad680a248bc81b1ede9b347 Mon Sep 17 00:00:00 2001 From: Natercio Moniz Date: Tue, 27 Oct 2026 21:59:04 +0000 Subject: [PATCH] Handle spin-off events --- internal/trading212/README.md | 50 ++++++ internal/trading212/record.go | 237 +++++++++++++++++++++-------- internal/trading212/record_test.go | 67 +++++++- 3 files changed, 288 insertions(+), 66 deletions(-) create mode 100644 internal/trading212/README.md diff --git a/internal/trading212/README.md b/internal/trading212/README.md new file mode 100644 index 0000000..61c936f --- /dev/null +++ b/internal/trading212/README.md @@ -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. diff --git a/internal/trading212/record.go b/internal/trading212/record.go index 22892cd..04ab092 100644 --- a/internal/trading212/record.go +++ b/internal/trading212/record.go @@ -92,6 +92,10 @@ const ( 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 @@ -101,78 +105,53 @@ func (rr RecordReader) ReadRecord(ctx context.Context) (internal.Record, error) return Record{}, fmt.Errorf("read record: %w", err) } - if strings.ToLower(raw[0]) == "action" { + action := strings.ToLower(raw[0]) + if action == "action" { // header row continue } - symbol := raw[2] + switch action { + case MarketBuy, LimitBuy: + return rr.parseBuyRecord(ctx, raw) - 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 := 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) - } - - var kind internal.Kind - switch strings.ToLower(raw[0]) { - case MarketBuy, LimitBuy, SpinOff: - kind = internal.KindBuy case MarketSell, LimitSell: - kind = internal.KindSell + return rr.parseSellRecord(ctx, raw) + + case SpinOff: + return rr.parseSpinOff(ctx, raw) + case StockSplitOpen: if splitRec != nil { return nil, fmt.Errorf("split already open") } - splitRec = &splitRecord{ - Record: Record{ - symbol: symbol, - kind: internal.KindSplit, - quantity: qant, - price: price, - fees: conversionFee, - taxes: stampDutyTax.Add(frenchTxTax), - timestamp: ts, - natureGetter: figiNatureGetter(ctx, rr.figi, symbol), - }, + 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") } - splitRec.ratio = splitRec.Record.Quantity().Div(qant) + + 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", symbol), + slog.Any("ISIN", raw[2]), slog.Any("Quantity", qant), ) continue @@ -180,20 +159,140 @@ func (rr RecordReader) ReadRecord(ctx context.Context) (internal.Record, error) default: return Record{}, fmt.Errorf("parse record type: %s", raw[0]) } - - return Record{ - symbol: symbol, - kind: kind, - quantity: qant, - price: price, - fees: conversionFee, - taxes: stampDutyTax.Add(frenchTxTax), - timestamp: ts, - natureGetter: figiNatureGetter(ctx, rr.figi, symbol), - }, 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 { return sync.OnceValue(func() internal.Nature { secType, err := of.SecurityTypeByISIN(ctx, isin) @@ -231,6 +330,20 @@ 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. diff --git a/internal/trading212/record_test.go b/internal/trading212/record_test.go index 2f4f513..e84e99b 100644 --- a/internal/trading212/record_test.go +++ b/internal/trading212/record_test.go @@ -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",,`), 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) { @@ -377,10 +440,6 @@ func TestRecordReader_ReadRecord_SkipsCorporateActions(t *testing.T) { name string row string }{ - { - name: "spin off", - row: `Spin off,2026-02-11 13:41:43+00:00,US9418481035,WAT,"Waters",EOF46451921527,0.0406450000,0E-10,USD,,,,0.00,"EUR",,,,,,`, - }, { name: "stock distribution", row: `Stock distribution,2025-08-04 11:45:30,XX1234567890,ABXY,"Aspargus Broccoli",EOF987654321,2.4387014200,7.9999999999,USD,1.17995999,,"EUR",15.25,"EUR",,,0.02,"EUR",,`,