Handle Spin-off events as buys #35
No files matched your search
@@ -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
-38
@@ -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,13 +105,89 @@ 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])
|
||||
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 {
|
||||
return Record{}, fmt.Errorf("parse record quantity: %w", err)
|
||||
}
|
||||
@@ -137,62 +217,81 @@ func (rr RecordReader) ReadRecord(ctx context.Context) (internal.Record, error)
|
||||
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
|
||||
case StockSplitOpen:
|
||||
if splitRec != nil {
|
||||
return nil, fmt.Errorf("split already open")
|
||||
}
|
||||
|
||||
splitRec = &splitRecord{
|
||||
Record: Record{
|
||||
symbol: symbol,
|
||||
kind: internal.KindSplit,
|
||||
return Record{
|
||||
symbol: raw[2],
|
||||
quantity: qant,
|
||||
price: price,
|
||||
fees: conversionFee,
|
||||
taxes: stampDutyTax.Add(frenchTxTax),
|
||||
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:
|
||||
if splitRec == nil {
|
||||
return nil, fmt.Errorf("missing split open")
|
||||
qant, err := parsePositiveDecimal(raw[6])
|
||||
if err != nil {
|
||||
return Record{}, fmt.Errorf("parse record quantity: %w", err)
|
||||
}
|
||||
splitRec.ratio = splitRec.Record.Quantity().Div(qant)
|
||||
return splitRec, nil
|
||||
|
||||
case StokDistribution:
|
||||
slog.Warn("Skipping 'stock distribution' event!",
|
||||
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),
|
||||
)
|
||||
continue
|
||||
|
||||
default:
|
||||
return Record{}, fmt.Errorf("parse record type: %s", raw[0])
|
||||
}
|
||||
|
||||
return Record{
|
||||
symbol: symbol,
|
||||
kind: kind,
|
||||
kind: internal.KindBuy,
|
||||
quantity: qant,
|
||||
price: price,
|
||||
fees: conversionFee,
|
||||
taxes: stampDutyTax.Add(frenchTxTax),
|
||||
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 {
|
||||
@@ -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.
|
||||
|
||||
@@ -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",,`,
|
||||
|
||||
Reference in new issue
Block a user