feat(monitor/collectors/coinbase): per-product minute aggregator with rollover-emit
This commit is contained in:
parent
23a4e2ded4
commit
f6192e9c04
|
|
@ -0,0 +1,70 @@
|
||||||
|
// ©AngelaMos | 2026
|
||||||
|
// aggregator.go
|
||||||
|
|
||||||
|
package coinbase
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type Aggregator struct {
|
||||||
|
open map[string]MinuteBar
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAggregator() *Aggregator {
|
||||||
|
return &Aggregator{open: make(map[string]MinuteBar)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Aggregator) Push(t Tick) (*MinuteBar, MinuteBar) {
|
||||||
|
minute := t.TS.UTC().Truncate(time.Minute)
|
||||||
|
cur, exists := a.open[t.Symbol]
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
cur = MinuteBar{
|
||||||
|
Symbol: t.Symbol,
|
||||||
|
Minute: minute,
|
||||||
|
Open: t.Price,
|
||||||
|
High: t.Price,
|
||||||
|
Low: t.Price,
|
||||||
|
Close: t.Price,
|
||||||
|
Volume: t.Volume24h,
|
||||||
|
}
|
||||||
|
a.open[t.Symbol] = cur
|
||||||
|
return nil, cur
|
||||||
|
}
|
||||||
|
|
||||||
|
if minute.After(cur.Minute) {
|
||||||
|
closed := cur
|
||||||
|
cur = MinuteBar{
|
||||||
|
Symbol: t.Symbol,
|
||||||
|
Minute: minute,
|
||||||
|
Open: t.Price,
|
||||||
|
High: t.Price,
|
||||||
|
Low: t.Price,
|
||||||
|
Close: t.Price,
|
||||||
|
Volume: t.Volume24h,
|
||||||
|
}
|
||||||
|
a.open[t.Symbol] = cur
|
||||||
|
return &closed, cur
|
||||||
|
}
|
||||||
|
|
||||||
|
if t.Price.GreaterThan(cur.High) {
|
||||||
|
cur.High = t.Price
|
||||||
|
}
|
||||||
|
if t.Price.LessThan(cur.Low) {
|
||||||
|
cur.Low = t.Price
|
||||||
|
}
|
||||||
|
cur.Close = t.Price
|
||||||
|
if t.Volume24h.GreaterThan(cur.Volume) {
|
||||||
|
cur.Volume = t.Volume24h
|
||||||
|
}
|
||||||
|
a.open[t.Symbol] = cur
|
||||||
|
return nil, cur
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Aggregator) Flush() []MinuteBar {
|
||||||
|
out := make([]MinuteBar, 0, len(a.open))
|
||||||
|
for _, bar := range a.open {
|
||||||
|
out = append(out, bar)
|
||||||
|
}
|
||||||
|
a.open = make(map[string]MinuteBar)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,88 @@
|
||||||
|
// ©AngelaMos | 2026
|
||||||
|
// aggregator_test.go
|
||||||
|
|
||||||
|
package coinbase_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/shopspring/decimal"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/coinbase"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAggregator_FirstTickOpensBar(t *testing.T) {
|
||||||
|
a := coinbase.NewAggregator()
|
||||||
|
ts := time.Date(2026, 5, 1, 12, 0, 30, 0, time.UTC)
|
||||||
|
closed, cur := a.Push(coinbase.Tick{
|
||||||
|
Symbol: "BTC-USD", TS: ts,
|
||||||
|
Price: decimal.RequireFromString("42000"),
|
||||||
|
Volume24h: decimal.RequireFromString("100"),
|
||||||
|
})
|
||||||
|
require.Nil(t, closed)
|
||||||
|
require.Equal(t, ts.Truncate(time.Minute), cur.Minute)
|
||||||
|
require.True(t, cur.Open.Equal(decimal.RequireFromString("42000")))
|
||||||
|
require.True(t, cur.Close.Equal(decimal.RequireFromString("42000")))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAggregator_FoldUpdatesHighLowClose(t *testing.T) {
|
||||||
|
a := coinbase.NewAggregator()
|
||||||
|
min := time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC)
|
||||||
|
a.Push(coinbase.Tick{Symbol: "BTC-USD", TS: min.Add(10 * time.Second), Price: decimal.RequireFromString("42000"), Volume24h: decimal.RequireFromString("100")})
|
||||||
|
a.Push(coinbase.Tick{Symbol: "BTC-USD", TS: min.Add(20 * time.Second), Price: decimal.RequireFromString("42050"), Volume24h: decimal.RequireFromString("110")})
|
||||||
|
closed, cur := a.Push(coinbase.Tick{
|
||||||
|
Symbol: "BTC-USD", TS: min.Add(30 * time.Second),
|
||||||
|
Price: decimal.RequireFromString("41950"), Volume24h: decimal.RequireFromString("120"),
|
||||||
|
})
|
||||||
|
require.Nil(t, closed)
|
||||||
|
require.True(t, cur.Open.Equal(decimal.RequireFromString("42000")))
|
||||||
|
require.True(t, cur.High.Equal(decimal.RequireFromString("42050")))
|
||||||
|
require.True(t, cur.Low.Equal(decimal.RequireFromString("41950")))
|
||||||
|
require.True(t, cur.Close.Equal(decimal.RequireFromString("41950")))
|
||||||
|
require.True(t, cur.Volume.Equal(decimal.RequireFromString("120")))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAggregator_MinuteRolloverEmitsClosedBar(t *testing.T) {
|
||||||
|
a := coinbase.NewAggregator()
|
||||||
|
min1 := time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC)
|
||||||
|
min2 := min1.Add(time.Minute)
|
||||||
|
|
||||||
|
a.Push(coinbase.Tick{Symbol: "BTC-USD", TS: min1.Add(10 * time.Second), Price: decimal.RequireFromString("42000"), Volume24h: decimal.RequireFromString("100")})
|
||||||
|
a.Push(coinbase.Tick{Symbol: "BTC-USD", TS: min1.Add(50 * time.Second), Price: decimal.RequireFromString("42100"), Volume24h: decimal.RequireFromString("105")})
|
||||||
|
|
||||||
|
closed, cur := a.Push(coinbase.Tick{
|
||||||
|
Symbol: "BTC-USD", TS: min2.Add(5 * time.Second),
|
||||||
|
Price: decimal.RequireFromString("42150"), Volume24h: decimal.RequireFromString("110"),
|
||||||
|
})
|
||||||
|
|
||||||
|
require.NotNil(t, closed)
|
||||||
|
require.Equal(t, min1, closed.Minute)
|
||||||
|
require.True(t, closed.Close.Equal(decimal.RequireFromString("42100")))
|
||||||
|
|
||||||
|
require.Equal(t, min2, cur.Minute)
|
||||||
|
require.True(t, cur.Open.Equal(decimal.RequireFromString("42150")))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAggregator_BarsArePerProduct(t *testing.T) {
|
||||||
|
a := coinbase.NewAggregator()
|
||||||
|
min := time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
_, btc := a.Push(coinbase.Tick{Symbol: "BTC-USD", TS: min.Add(10 * time.Second), Price: decimal.RequireFromString("42000"), Volume24h: decimal.RequireFromString("100")})
|
||||||
|
_, eth := a.Push(coinbase.Tick{Symbol: "ETH-USD", TS: min.Add(11 * time.Second), Price: decimal.RequireFromString("2300"), Volume24h: decimal.RequireFromString("80")})
|
||||||
|
|
||||||
|
require.Equal(t, "BTC-USD", btc.Symbol)
|
||||||
|
require.Equal(t, "ETH-USD", eth.Symbol)
|
||||||
|
require.True(t, btc.Open.Equal(decimal.RequireFromString("42000")))
|
||||||
|
require.True(t, eth.Open.Equal(decimal.RequireFromString("2300")))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAggregator_FlushReturnsAllOpenBars(t *testing.T) {
|
||||||
|
a := coinbase.NewAggregator()
|
||||||
|
min := time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC)
|
||||||
|
a.Push(coinbase.Tick{Symbol: "BTC-USD", TS: min.Add(10 * time.Second), Price: decimal.RequireFromString("42000"), Volume24h: decimal.RequireFromString("100")})
|
||||||
|
a.Push(coinbase.Tick{Symbol: "ETH-USD", TS: min.Add(15 * time.Second), Price: decimal.RequireFromString("2300"), Volume24h: decimal.RequireFromString("80")})
|
||||||
|
bars := a.Flush()
|
||||||
|
require.Len(t, bars, 2)
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue