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
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:
@@ -114,7 +114,7 @@ func (rr RecordReader) ReadRecord(ctx context.Context) (internal.Record, error)
|
|||||||
return Record{}, fmt.Errorf("parse record price: %w", err)
|
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 {
|
if err != nil {
|
||||||
return Record{}, fmt.Errorf("parse record timestamp: %w", err)
|
return Record{}, fmt.Errorf("parse record timestamp: %w", err)
|
||||||
}
|
}
|
||||||
@@ -225,6 +225,30 @@ func parseOptionalDecimal(s string) (decimal.Decimal, error) {
|
|||||||
return parseDecimal(s)
|
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 {
|
type splitRecord struct {
|
||||||
Record
|
Record
|
||||||
|
|
||||||
|
|||||||
@@ -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",,`),
|
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,
|
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 {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
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) {
|
func Test_figiNatureGetter(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string // description of this test case
|
name string // description of this test case
|
||||||
|
|||||||
Reference in New Issue
Block a user