package main import ( "encoding/csv" "os" "path/filepath" "reflect" "testing" "github.com/banbox/banbot/orm" "github.com/banbox/banexg/errs" ) func TestIsUnavailablePair(t *testing.T) { if !isUnavailablePair(errs.NewMsg(errs.CodeNoMarketForPair, "missing")) { t.Fatal("NoMarketForPair should be skipped") } if isUnavailablePair(errs.NewMsg(errs.CodeInvalidData, "bad data")) { t.Fatal("non-market errors must not be skipped") } } const testHourMS int64 = 3600000 func hourlyBars(startHour, count int) []*orm.SeriesOHLCV { rows := make([]*orm.SeriesOHLCV, 0, count) for i := startHour; i < startHour+count; i++ { rows = append(rows, &orm.SeriesOHLCV{ Time: int64(i) * testHourMS, EndMS: int64(i+1) * testHourMS, Open: float64(i + 10), High: float64(i + 20), Low: float64(i), Close: float64(i + 12), Volume: 2, Quote: 3, BuyVolume: 1, TradeNum: 4, }) } return rows } func TestAggregateHourlyDailyAndCSV(t *testing.T) { // Reversed inputs still produce UTC-aligned daily history in time order. rows := hourlyBars(0, 72) for i, j := 0, len(rows)-1; i < j; i, j = i+1, j-1 { rows[i], rows[j] = rows[j], rows[i] } bars := aggregateHourly(rows, "1d") if len(bars) != 3 { t.Fatalf("daily bars = %d, want 3", len(bars)) } for i, bar := range bars { if bar.Time != int64(i*24)*testHourMS || bar.EndMS != int64((i+1)*24)*testHourMS || bar.TimeFrame != "1d" || !bar.Closed { t.Fatalf("unexpected daily metadata: %+v", bar) } } path := filepath.Join(t.TempDir(), "daily.csv") if err := writeCSV(path, bars); err != nil { t.Fatal(err) } file, err := os.Open(path) if err != nil { t.Fatal(err) } defer file.Close() contents, err := csv.NewReader(file).ReadAll() if err != nil { t.Fatal(err) } if !reflect.DeepEqual(contents[0], columns) { t.Fatalf("unexpected CSV columns: %v", contents[0]) } want := []string{"0", "10", "43", "0", "35", "48", "72", "24", "96"} if !reflect.DeepEqual(contents[1], want) { t.Fatalf("CSV first bar = %v, want %v", contents[1], want) } } func TestAggregateHourlyEightHour(t *testing.T) { bars := aggregateHourly(hourlyBars(0, 24), "8h") if len(bars) != 3 { t.Fatalf("eight-hour bars = %d, want 3", len(bars)) } for i, bar := range bars { if bar.Time != int64(i*8)*testHourMS || bar.Open != float64(i*8+10) || bar.Close != float64(i*8+19) || bar.Volume != 16 || bar.Quote != 24 || bar.BuyVolume != 8 || bar.TradeNum != 32 { t.Fatalf("unexpected eight-hour bar: %+v", bar) } } } func TestAggregateHourlyRejectsIncompleteBuckets(t *testing.T) { missing := hourlyBars(0, 48) missing = append(missing[:12:12], missing[13:]...) gapWithDuplicate := hourlyBars(0, 48) gapWithDuplicate[12] = gapWithDuplicate[11] duplicate := hourlyBars(0, 48) duplicate = append(duplicate, duplicate[12]) for name, rows := range map[string][]*orm.SeriesOHLCV{ "missing hour": missing, "gap with duplicate": gapWithDuplicate, "extra duplicate": duplicate, "partial start and end": hourlyBars(1, 70), "misaligned hour": append(hourlyBars(0, 23), &orm.SeriesOHLCV{Time: 23*testHourMS + 1}), } { t.Run(name, func(t *testing.T) { bars := aggregateHourly(rows, "1d") want := 1 if name == "misaligned hour" { want = 0 } if len(bars) != want || (want == 1 && bars[0].Time != 24*testHourMS) { t.Fatalf("unexpected complete buckets: %+v", bars) } }) } }