From ccef829832b24cbfa6d74dec3acfd38d3f5fe832 Mon Sep 17 00:00:00 2001 From: Natercio Moniz Date: Sun, 6 Sep 2026 18:44:22 +0100 Subject: [PATCH] Hanlde trading212 inconsistent date formats (#31) Reviewed-on: https://git.naterciomoniz.net/applications/any2anexoj/pulls/31 Co-authored-by: Natercio Moniz --- internal/trading212/record.go | 26 +++++++- internal/trading212/record_test.go | 104 +++++++++++++++++++++++++++++ 2 files changed, 129 insertions(+), 1 deletion(-) diff --git a/internal/trading212/record.go b/internal/trading212/record.go index b34f05b..1c3c5f6 100644 --- a/internal/trading212/record.go +++ b/internal/trading212/record.go @@ -114,7 +114,7 @@ func (rr RecordReader) ReadRecord(ctx context.Context) (internal.Record, error) return Record{}, fmt.Errorf("parse record price: %w", err) } - ts, err := time.Parse(time.DateTime, raw[1]) + ts, err := parseTimestamp(raw[1]) if err != nil { return Record{}, fmt.Errorf("parse record timestamp: %w", err) } @@ -225,6 +225,30 @@ func parseOptionalDecimal(s string) (decimal.Decimal, error) { return parseDecimal(s) } +// 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 diff --git a/internal/trading212/record_test.go b/internal/trading212/record_test.go index ba53cba..b0d4fd0 100644 --- a/internal/trading212/record_test.go +++ b/internal/trading212/record_test.go @@ -104,6 +104,39 @@ 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, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -219,6 +252,77 @@ 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