Hanlde trading212 inconsistent date formats
Generate check / check-changes (pull_request) Successful in 2s
Generate check / verify-generate (pull_request) Skipped
Quality / check-changes (pull_request) Successful in 3s
Quality / static-checks (pull_request) Successful in 21s
Quality / run-tests (pull_request) Successful in 52s

This commit is contained in:
2026-09-06 18:42:32 +01:00
parent e5961c0dff
commit 44f0351d81
2 changed files with 129 additions and 1 deletions
+25 -1
View File
@@ -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
+104
View File
@@ -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