diff --git a/pkg/bbgo/maintenance_config_test.go b/pkg/bbgo/maintenance_config_test.go new file mode 100644 index 0000000000..8c6793d6bd --- /dev/null +++ b/pkg/bbgo/maintenance_config_test.go @@ -0,0 +1,34 @@ +package bbgo + +import ( + "encoding/json" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMaintenanceConfig_UnmarshalJSON(t *testing.T) { + jsonData := `{ + "startTime": "yesterday", + "endTime": "now", + "balanceQueryAvailable": true + }` + + var config MaintenanceConfig + err := json.Unmarshal([]byte(jsonData), &config) + require.NoError(t, err) + + assert.True(t, config.BalanceQueryAvailable) + require.NotNil(t, config.StartTime) + require.NotNil(t, config.EndTime) + + now := time.Now() + yesterday := now.AddDate(0, 0, -1) + + // Since "now" and "yesterday" are relative to when UnmarshalJSON was called, + // they should be very close to the times we just calculated. + assert.WithinDuration(t, yesterday, config.StartTime.Time(), 10*time.Second) + assert.WithinDuration(t, now, config.EndTime.Time(), 10*time.Second) +} diff --git a/pkg/bbgo/maintenance_test.go b/pkg/bbgo/maintenance_test.go new file mode 100644 index 0000000000..54918da81d --- /dev/null +++ b/pkg/bbgo/maintenance_test.go @@ -0,0 +1,70 @@ +package bbgo + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "github.com/c9s/bbgo/pkg/types" +) + +func TestMaintenanceConfig_IsInMaintenance(t *testing.T) { + now := time.Now() + past := types.LooseFormatTime(now.Add(-time.Hour)) + future := types.LooseFormatTime(now.Add(time.Hour)) + + tests := []struct { + name string + maintenance *MaintenanceConfig + expected bool + }{ + { + name: "In maintenance", + maintenance: &MaintenanceConfig{ + StartTime: &past, + EndTime: &future, + }, + expected: true, + }, + { + name: "Before maintenance", + maintenance: &MaintenanceConfig{ + StartTime: &future, + EndTime: &future, // Not realistic but fine for test + }, + expected: false, + }, + { + name: "After maintenance", + maintenance: &MaintenanceConfig{ + StartTime: &past, + EndTime: &past, + }, + expected: false, + }, + { + name: "Maintenance not configured (nil times)", + maintenance: &MaintenanceConfig{}, + expected: false, + }, + { + name: "Maintenance config is nil", + maintenance: nil, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, tt.maintenance.IsInMaintenance()) + + session := &ExchangeSession{ + ExchangeSessionConfig: ExchangeSessionConfig{ + Maintenance: tt.maintenance, + }, + } + assert.Equal(t, tt.expected, session.IsInMaintenance()) + }) + } +} diff --git a/pkg/indicator/v2/smoothing.go b/pkg/indicator/v2/smoothing.go new file mode 100644 index 0000000000..5e93f8f4a4 --- /dev/null +++ b/pkg/indicator/v2/smoothing.go @@ -0,0 +1,39 @@ +package indicatorv2 + +import ( + "github.com/c9s/bbgo/pkg/types" +) + +type SmoothingType string + +const ( + SmoothingTypeEMA SmoothingType = "ema" + SmoothingTypeEWMA SmoothingType = "ewma" + SmoothingTypeRMA SmoothingType = "rma" + SmoothingTypeSMA SmoothingType = "sma" +) + +// NewSmoothedIndicator creates a new indicator with optional smoothing. +// It first applies a MAX indicator with the given window, and then optionally applies +// a smoothing indicator (RMA or EMA/EWMA) if smoothingWindow is greater than zero. +func NewSmoothedIndicator(source types.Float64Source, window int, smoothingWindow int, smoothingType SmoothingType) types.Float64Calculator { + var base types.Float64Source = source + if window > 0 { + base = MAX(source, window) + } + + if smoothingWindow > 0 { + switch smoothingType { + case SmoothingTypeRMA: + return RMA2(base, smoothingWindow, true) + case SmoothingTypeSMA: + return SMA(base, smoothingWindow) + case SmoothingTypeEWMA, SmoothingTypeEMA: + return EWMA2(base, smoothingWindow) + default: + return EWMA2(base, smoothingWindow) + } + } + + return base.(types.Float64Calculator) +} diff --git a/pkg/strategy/xalign/strategy_maintenance_test.go b/pkg/strategy/xalign/strategy_maintenance_test.go new file mode 100644 index 0000000000..fa8f1e8ddb --- /dev/null +++ b/pkg/strategy/xalign/strategy_maintenance_test.go @@ -0,0 +1,125 @@ +package xalign + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" + + "github.com/c9s/bbgo/pkg/bbgo" + "github.com/c9s/bbgo/pkg/fixedpoint" + "github.com/c9s/bbgo/pkg/types" + "github.com/c9s/bbgo/pkg/types/mocks" +) + +func TestStrategy_align_MaintenanceSkip(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + s := &Strategy{ + PreferredSessions: []string{"binance"}, + ExpectedBalances: map[string]fixedpoint.Value{ + "BTC": fixedpoint.NewFromFloat(1.0), + }, + } + + // Setup mock session + mExchange := mocks.NewMockExchange(ctrl) + mExchange.EXPECT().Name().Return(types.ExchangeName("binance")).AnyTimes() + mExchange.EXPECT().CancelOrders(gomock.Any(), gomock.Any()).Return(nil).AnyTimes() + mExchange.EXPECT().QueryAccount(gomock.Any()).Return(&types.Account{}, nil).AnyTimes() + mStream := mocks.NewMockStream(ctrl) + mStream.EXPECT().SetPublicOnly().AnyTimes() + mStream.EXPECT().OnConnect(gomock.Any()).AnyTimes() + mStream.EXPECT().OnDisconnect(gomock.Any()).AnyTimes() + mStream.EXPECT().OnAuth(gomock.Any()).AnyTimes() + mExchange.EXPECT().NewStream().Return(mStream).AnyTimes() + session := bbgo.NewExchangeSession("binance", mExchange) + + // Set maintenance mode with BalanceQueryAvailable = false + now := time.Now() + startTime := types.LooseFormatTime(now.Add(-1 * time.Hour)) + endTime := types.LooseFormatTime(now.Add(1 * time.Hour)) + session.Maintenance = &bbgo.MaintenanceConfig{ + StartTime: &startTime, + EndTime: &endTime, + BalanceQueryAvailable: false, + } + + sessions := bbgo.ExchangeSessionMap{ + "binance": session, + } + s.sessions = sessions + s.orderBooks = map[string]*bbgo.ActiveOrderBook{ + "binance": bbgo.NewActiveOrderBook(""), + } + + exists := s.align(ctx, sessions) + assert.False(t, exists) +} + +func TestStrategy_selectSessionForCurrency_MaintenanceSkip(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + s := &Strategy{ + PreferredSessions: []string{"binance", "okx"}, + PreferredQuoteCurrencies: &QuoteCurrencyPreference{ + Buy: []string{"USDT"}, + Sell: []string{"USDT"}, + }, + } + + mExchangeBinance := mocks.NewMockExchange(ctrl) + mExchangeBinance.EXPECT().Name().Return(types.ExchangeName("binance")).AnyTimes() + mStreamBinance := mocks.NewMockStream(ctrl) + mStreamBinance.EXPECT().SetPublicOnly().AnyTimes() + mStreamBinance.EXPECT().OnConnect(gomock.Any()).AnyTimes() + mStreamBinance.EXPECT().OnDisconnect(gomock.Any()).AnyTimes() + mStreamBinance.EXPECT().OnAuth(gomock.Any()).AnyTimes() + mExchangeBinance.EXPECT().NewStream().Return(mStreamBinance).AnyTimes() + sessionBinance := bbgo.NewExchangeSession("binance", mExchangeBinance) + + // Set binance in maintenance + now := time.Now() + startTime := types.LooseFormatTime(now.Add(-1 * time.Hour)) + endTime := types.LooseFormatTime(now.Add(1 * time.Hour)) + sessionBinance.Maintenance = &bbgo.MaintenanceConfig{ + StartTime: &startTime, + EndTime: &endTime, + } + + mExchangeOkx := mocks.NewMockExchange(ctrl) + mExchangeOkx.EXPECT().Name().Return(types.ExchangeName("okx")).AnyTimes() + mExchangeOkx.EXPECT().QueryAccount(gomock.Any()).Return(&types.Account{}, nil).AnyTimes() + mStreamOkx := mocks.NewMockStream(ctrl) + mStreamOkx.EXPECT().SetPublicOnly().AnyTimes() + mStreamOkx.EXPECT().OnConnect(gomock.Any()).AnyTimes() + mStreamOkx.EXPECT().OnDisconnect(gomock.Any()).AnyTimes() + mStreamOkx.EXPECT().OnAuth(gomock.Any()).AnyTimes() + mExchangeOkx.EXPECT().NewStream().Return(mStreamOkx).AnyTimes() + sessionOkx := bbgo.NewExchangeSession("okx", mExchangeOkx) + // okx is NOT in maintenance + + sessions := map[string]*bbgo.ExchangeSession{ + "binance": sessionBinance, + "okx": sessionOkx, + } + + // Test selecting session when binance is in maintenance + // It should skip binance and try okx (but okx will fail account update or market check in this mock setup) + selectedSession, _ := s.selectSessionForCurrency(ctx, sessions, "", "BTC", fixedpoint.NewFromFloat(1.0)) + + // If it correctly skipped binance, selectedSession won't be binance. + if selectedSession != nil { + assert.NotEqual(t, "binance", selectedSession.Name) + } +} diff --git a/pkg/strategy/xmaker/signal/book.go b/pkg/strategy/xmaker/signal/book.go index 007016be8c..1d1dc055e1 100644 --- a/pkg/strategy/xmaker/signal/book.go +++ b/pkg/strategy/xmaker/signal/book.go @@ -39,9 +39,9 @@ type OrderBookBestPriceVolumeSignal struct { MinQuoteVolume fixedpoint.Value `json:"minQuoteVolume"` MinDelta fixedpoint.Value `json:"minDelta"` - Window int `json:"window"` - SmoothingWindow int `json:"smoothingWindow"` - SmoothingType string `json:"smoothingType"` + Window int `json:"window"` + SmoothingWindow int `json:"smoothingWindow"` + SmoothingType indicatorv2.SmoothingType `json:"smoothingType"` symbol string book *types.StreamOrderBook @@ -71,30 +71,15 @@ func (s *OrderBookBestPriceVolumeSignal) Bind(ctx context.Context, session *bbgo if s.Window > 0 { s.bidVolumeSeries = types.NewFloat64Series() - s.bidVolumeIndicator = s.createVolumeIndicator(s.bidVolumeSeries) + s.bidVolumeIndicator = indicatorv2.NewSmoothedIndicator(s.bidVolumeSeries, s.Window, s.SmoothingWindow, s.SmoothingType) s.askVolumeSeries = types.NewFloat64Series() - s.askVolumeIndicator = s.createVolumeIndicator(s.askVolumeSeries) + s.askVolumeIndicator = indicatorv2.NewSmoothedIndicator(s.askVolumeSeries, s.Window, s.SmoothingWindow, s.SmoothingType) } return nil } -func (s *OrderBookBestPriceVolumeSignal) createVolumeIndicator(source types.Float64Source) types.Float64Calculator { - maxStream := indicatorv2.MAX(source, s.Window) - if s.SmoothingWindow > 0 { - switch s.SmoothingType { - case "rma": - return indicatorv2.RMA2(maxStream, s.SmoothingWindow, true) - case "ewma", "ema": - return indicatorv2.EWMA2(maxStream, s.SmoothingWindow) - default: - return indicatorv2.EWMA2(maxStream, s.SmoothingWindow) - } - } - return maxStream -} - func (s *OrderBookBestPriceVolumeSignal) CalculateSignal(ctx context.Context) (float64, error) { bid, ask, ok := s.book.BestBidAndAsk() if !ok { diff --git a/pkg/strategy/xmaker/signal/book_test.go b/pkg/strategy/xmaker/signal/book_test.go new file mode 100644 index 0000000000..ec04d77e42 --- /dev/null +++ b/pkg/strategy/xmaker/signal/book_test.go @@ -0,0 +1,188 @@ +package signal + +import ( + "context" + "testing" + + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + + "github.com/c9s/bbgo/pkg/fixedpoint" + indicatorv2 "github.com/c9s/bbgo/pkg/indicator/v2" + "github.com/c9s/bbgo/pkg/types" +) + +func TestOrderBookBestPriceVolumeSignal_CalculateSignal(t *testing.T) { + ctx := context.Background() + symbol := "BTCUSDT" + book := types.NewStreamBook(symbol, types.ExchangeBinance) + + s := &OrderBookBestPriceVolumeSignal{ + RatioThreshold: fixedpoint.NewFromFloat(0.6), + MinVolume: fixedpoint.NewFromFloat(100.0), // Quote volume threshold + Window: 3, + } + s.SetLogger(logrus.New()) + s.SetStreamBook(book) + + err := s.Bind(ctx, nil, symbol) + assert.NoError(t, err) + + // Update 1: Bid Price 100, Vol 10 -> Quote 1000 + // Ask Price 101, Vol 5 -> Quote 505 + // Max Bid Quote: 1000, Max Ask Quote: 505 + // Sum Quote: 1505, Bid Ratio: 1000/1505 = 0.66445 + // Signal: (0.66445 - 0.6) / (1.0 - 0.6) = 0.06445 / 0.4 = 0.1611 + book.Load(types.SliceOrderBook{ + Symbol: symbol, + Bids: types.PriceVolumeSlice{{Price: fixedpoint.NewFromFloat(100.0), Volume: fixedpoint.NewFromFloat(10.0)}}, + Asks: types.PriceVolumeSlice{{Price: fixedpoint.NewFromFloat(101.0), Volume: fixedpoint.NewFromFloat(5.0)}}, + }) + sig, err := s.CalculateSignal(ctx) + assert.NoError(t, err) + assert.InDelta(t, 0.1611, sig, 0.001) + + // Update 2: Bid Price 100, Vol 2 -> Quote 200 + // Ask Price 101, Vol 20 -> Quote 2020 + // Max Bid Quote: 1000, Max Ask Quote: 2020 + // Sum Quote: 3020, Ask Ratio: 2020/3020 = 0.66887 + // Signal: -(0.66887 - 0.6) / 0.4 = -0.17218 + book.Load(types.SliceOrderBook{ + Symbol: symbol, + Bids: types.PriceVolumeSlice{{Price: fixedpoint.NewFromFloat(100.0), Volume: fixedpoint.NewFromFloat(2.0)}}, + Asks: types.PriceVolumeSlice{{Price: fixedpoint.NewFromFloat(101.0), Volume: fixedpoint.NewFromFloat(20.0)}}, + }) + sig, err = s.CalculateSignal(ctx) + assert.NoError(t, err) + assert.InDelta(t, -0.17218, sig, 0.001) +} + +func TestOrderBookBestPriceVolumeSignal_MinDelta(t *testing.T) { + ctx := context.Background() + symbol := "BTCUSDT" + book := types.NewStreamBook(symbol, types.ExchangeBinance) + + s := &OrderBookBestPriceVolumeSignal{ + RatioThreshold: fixedpoint.NewFromFloat(0.6), + MinDelta: fixedpoint.NewFromFloat(500.0), // Quote volume delta threshold + } + s.SetLogger(logrus.New()) + s.SetStreamBook(book) + + err := s.Bind(ctx, nil, symbol) + assert.NoError(t, err) + + // Update 1: Bid Quote 1000, Ask Quote 800 -> Delta 200 < 500 + // Signal should be 0.0 + book.Load(types.SliceOrderBook{ + Symbol: symbol, + Bids: types.PriceVolumeSlice{{Price: fixedpoint.NewFromFloat(100.0), Volume: fixedpoint.NewFromFloat(10.0)}}, + Asks: types.PriceVolumeSlice{{Price: fixedpoint.NewFromFloat(100.0), Volume: fixedpoint.NewFromFloat(8.0)}}, + }) + sig, err := s.CalculateSignal(ctx) + assert.NoError(t, err) + assert.Equal(t, 0.0, sig) + + // Update 2: Bid Quote 1200, Ask Quote 600 -> Delta 600 > 500 + // Sum 1800, Bid Ratio 1200/1800 = 0.666 + // Signal (0.666 - 0.6) / 0.4 = 0.166 + book.Load(types.SliceOrderBook{ + Symbol: symbol, + Bids: types.PriceVolumeSlice{{Price: fixedpoint.NewFromFloat(100.0), Volume: fixedpoint.NewFromFloat(12.0)}}, + Asks: types.PriceVolumeSlice{{Price: fixedpoint.NewFromFloat(100.0), Volume: fixedpoint.NewFromFloat(6.0)}}, + }) + sig, err = s.CalculateSignal(ctx) + assert.NoError(t, err) + assert.InDelta(t, 0.166, sig, 0.001) +} + +func TestOrderBookBestPriceVolumeSignal_Smoothing(t *testing.T) { + ctx := context.Background() + symbol := "BTCUSDT" + book := types.NewStreamBook(symbol, types.ExchangeBinance) + + s := &OrderBookBestPriceVolumeSignal{ + RatioThreshold: fixedpoint.NewFromFloat(0.5), + MinVolume: fixedpoint.NewFromFloat(0.0), + Window: 1, // Max of current + SmoothingWindow: 2, // EWMA window 2 -> multiplier = 2/(1+2) = 0.666 + SmoothingType: indicatorv2.SmoothingTypeEMA, + } + s.SetLogger(logrus.New()) + s.SetStreamBook(book) + + err := s.Bind(ctx, nil, symbol) + assert.NoError(t, err) + + // Update 1: Bid Quote 1000, Ask Quote 1000 + // EMA Bid: 1000, EMA Ask: 1000 + book.Load(types.SliceOrderBook{ + Symbol: symbol, + Bids: types.PriceVolumeSlice{{Price: fixedpoint.NewFromFloat(100.0), Volume: fixedpoint.NewFromFloat(10.0)}}, + Asks: types.PriceVolumeSlice{{Price: fixedpoint.NewFromFloat(100.0), Volume: fixedpoint.NewFromFloat(10.0)}}, + }) + sig, err := s.CalculateSignal(ctx) + assert.NoError(t, err) + assert.Equal(t, 0.0, sig) + + // Update 2: Bid Quote 2000, Ask Quote 1000 + // multiplier m = 0.666 + // EMA Bid: (1-m)*1000 + m*2000 = 0.333*1000 + 0.666*2000 = 333.33 + 1333.33 = 1666.66 + // EMA Ask: (1-m)*1000 + m*1000 = 1000 + // Sum: 2666.66 + // Bid Ratio: 1666.66 / 2666.66 = 0.625 + // Signal: (0.625 - 0.5) / (1.0 - 0.5) = 0.125 / 0.5 = 0.25 + book.Load(types.SliceOrderBook{ + Symbol: symbol, + Bids: types.PriceVolumeSlice{{Price: fixedpoint.NewFromFloat(100.0), Volume: fixedpoint.NewFromFloat(20.0)}}, + Asks: types.PriceVolumeSlice{{Price: fixedpoint.NewFromFloat(100.0), Volume: fixedpoint.NewFromFloat(10.0)}}, + }) + sig, err = s.CalculateSignal(ctx) + assert.NoError(t, err) + assert.InDelta(t, 0.25, sig, 0.01) +} + +func TestOrderBookBestPriceVolumeSignal_SmoothingSMA(t *testing.T) { + ctx := context.Background() + symbol := "BTCUSDT" + book := types.NewStreamBook(symbol, types.ExchangeBinance) + + s := &OrderBookBestPriceVolumeSignal{ + RatioThreshold: fixedpoint.NewFromFloat(0.5), + MinVolume: fixedpoint.NewFromFloat(0.0), + Window: 1, // Max of current + SmoothingWindow: 2, // SMA window 2 + SmoothingType: indicatorv2.SmoothingTypeSMA, + } + s.SetLogger(logrus.New()) + s.SetStreamBook(book) + + err := s.Bind(ctx, nil, symbol) + assert.NoError(t, err) + + // Update 1: Bid Quote 1000, Ask Quote 1000 + // SMA Bid: 1000, SMA Ask: 1000 + book.Load(types.SliceOrderBook{ + Symbol: symbol, + Bids: types.PriceVolumeSlice{{Price: fixedpoint.NewFromFloat(100.0), Volume: fixedpoint.NewFromFloat(10.0)}}, + Asks: types.PriceVolumeSlice{{Price: fixedpoint.NewFromFloat(100.0), Volume: fixedpoint.NewFromFloat(10.0)}}, + }) + sig, err := s.CalculateSignal(ctx) + assert.NoError(t, err) + assert.Equal(t, 0.0, sig) + + // Update 2: Bid Quote 2000, Ask Quote 1000 + // SMA Bid: (1000 + 2000) / 2 = 1500 + // SMA Ask: (1000 + 1000) / 2 = 1000 + // Sum: 2500 + // Bid Ratio: 1500 / 2500 = 0.6 + // Signal: (0.6 - 0.5) / (1.0 - 0.5) = 0.1 / 0.5 = 0.2 + book.Load(types.SliceOrderBook{ + Symbol: symbol, + Bids: types.PriceVolumeSlice{{Price: fixedpoint.NewFromFloat(100.0), Volume: fixedpoint.NewFromFloat(20.0)}}, + Asks: types.PriceVolumeSlice{{Price: fixedpoint.NewFromFloat(100.0), Volume: fixedpoint.NewFromFloat(10.0)}}, + }) + sig, err = s.CalculateSignal(ctx) + assert.NoError(t, err) + assert.InDelta(t, 0.2, sig, 0.01) +} diff --git a/pkg/strategy/xmaker/splithedge_maintenance_test.go b/pkg/strategy/xmaker/splithedge_maintenance_test.go new file mode 100644 index 0000000000..514c44254f --- /dev/null +++ b/pkg/strategy/xmaker/splithedge_maintenance_test.go @@ -0,0 +1,166 @@ +package xmaker + +import ( + "context" + "testing" + "time" + + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" + + "github.com/c9s/bbgo/pkg/bbgo" + . "github.com/c9s/bbgo/pkg/testing/testhelper" + "github.com/c9s/bbgo/pkg/types" +) + +func TestSplitHedge_MaintenanceSkip(t *testing.T) { + ctx := context.Background() + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + market := Market("BTCUSDT") + now := time.Now() + past := types.LooseFormatTime(now.Add(-time.Hour)) + future := types.LooseFormatTime(now.Add(time.Hour)) + + // session1: in maintenance + session1, md1, _ := newMockSession(mockCtrl, ctx, market.Symbol) + session1.Maintenance = &bbgo.MaintenanceConfig{ + StartTime: &past, + EndTime: &future, + } + + // session2: not in maintenance + session2, md2, _ := newMockSession(mockCtrl, ctx, market.Symbol) + + depth := Number(100.0) + hm1 := NewHedgeMarket(&HedgeMarketConfig{ + SymbolSelector: market.Symbol, + HedgeInterval: hedgeInterval, + QuotingDepth: depth, + }, session1, market) + assert.NoError(t, hm1.stream.Connect(ctx)) + + hm2 := NewHedgeMarket(&HedgeMarketConfig{ + SymbolSelector: market.Symbol, + HedgeInterval: hedgeInterval, + QuotingDepth: depth, + }, session2, market) + assert.NoError(t, hm2.stream.Connect(ctx)) + + orderBook := types.SliceOrderBook{ + Symbol: market.Symbol, + Bids: types.PriceVolumeSlice{{Price: Number(10000), Volume: Number(100)}}, + Asks: types.PriceVolumeSlice{{Price: Number(10010), Volume: Number(100)}}, + } + md1.EmitBookSnapshot(orderBook) + md2.EmitBookSnapshot(orderBook) + + strategy := &Strategy{} + strategy.positionExposure = bbgo.NewPositionExposure(market.Symbol) + strategy.makerMarket = market + strategy.logger = logrus.New() + + split := &SplitHedge{ + Enabled: true, + Algo: SplitHedgeAlgoProportion, + ProportionAlgo: &SplitHedgeProportionAlgo{ + ProportionMarkets: []*SplitHedgeProportionMarket{ + {Name: "m1", Ratio: Number(0.5)}, + {Name: "m2"}, + }, + }, + hedgeMarketInstances: map[string]*HedgeMarket{ + "m1": hm1, + "m2": hm2, + }, + strategy: strategy, + logger: logrus.New(), + } + + t.Run("hedgeWithProportionAlgo skips m1", func(t *testing.T) { + uncovered := Number(2.0) + hedgeDelta := uncovered.Neg() + err := split.hedgeWithProportionAlgo(ctx, uncovered, hedgeDelta) + assert.NoError(t, err) + + // Since m1 is skipped, m2 should take its share. + // Wait for channel write + time.Sleep(10 * time.Millisecond) + + select { + case <-hm1.positionDeltaC: + t.Errorf("hm1 should have been skipped") + default: + } + + select { + case d := <-hm2.positionDeltaC: + // RatioBase is Total by default. + // m1 is skipped. + // m2 is last, it takes remaining. Total was 2.0. Remaining is 2.0. + assert.Equal(t, Number(2.0), d) + case <-time.After(100 * time.Millisecond): + t.Errorf("hm2 should have received delta") + } + }) + + t.Run("GetBalanceWeightedQuotePrice skips m1", func(t *testing.T) { + // Set some balances + session1.GetAccount().UpdateBalances(types.BalanceMap{ + market.BaseCurrency: {Available: Number(1.0)}, + market.QuoteCurrency: {Available: Number(10000.0)}, + }) + session2.GetAccount().UpdateBalances(types.BalanceMap{ + market.BaseCurrency: {Available: Number(2.0)}, + market.QuoteCurrency: {Available: Number(20000.0)}, + }) + + bid, ask, ok := split.GetBalanceWeightedQuotePrice() + assert.True(t, ok) + // Prices should come only from m2 + assert.Equal(t, Number(10000), bid) + assert.Equal(t, Number(10010), ask) + }) + + t.Run("hedgeWithBestPriceAlgo skips m1", func(t *testing.T) { + // Enabled best price hedge + split.BestPriceHedge = &BestPriceHedge{ + Enabled: true, + BelowAmount: Number(1000000), // very high to ensure it doesn't fallback + } + + uncovered := Number(2.0) + hedgeDelta := uncovered.Neg() + + // session1 has better price but it's in maintenance + // bids: session1: 10001, session2: 10000 + md1.EmitBookSnapshot(types.SliceOrderBook{ + Symbol: market.Symbol, + Bids: types.PriceVolumeSlice{{Price: Number(10001), Volume: Number(100)}}, + Asks: types.PriceVolumeSlice{{Price: Number(10010), Volume: Number(100)}}, + }) + + handled, err := split.hedgeWithBestPriceAlgo(ctx, uncovered, hedgeDelta) + assert.NoError(t, err) + assert.True(t, handled) + + // Wait for channel write + time.Sleep(10 * time.Millisecond) + + select { + case <-hm1.positionDeltaC: + t.Errorf("hm1 should have been skipped") + default: + } + + select { + case d := <-hm2.positionDeltaC: + // hm2 was chosen because hm1 is in maintenance + assert.Equal(t, Number(2.0), d) + case <-time.After(100 * time.Millisecond): + t.Errorf("hm2 should have received delta") + } + }) +} diff --git a/pkg/strategy/xmaker/strategy.go b/pkg/strategy/xmaker/strategy.go index d7e6671abb..77b2ed20aa 100644 --- a/pkg/strategy/xmaker/strategy.go +++ b/pkg/strategy/xmaker/strategy.go @@ -23,6 +23,7 @@ import ( "github.com/c9s/bbgo/pkg/dynamic" "github.com/c9s/bbgo/pkg/exchange/sandbox" "github.com/c9s/bbgo/pkg/fixedpoint" + indicatorv2 "github.com/c9s/bbgo/pkg/indicator/v2" "github.com/c9s/bbgo/pkg/pricesolver" "github.com/c9s/bbgo/pkg/profile/timeprofile" "github.com/c9s/bbgo/pkg/risk/circuitbreaker" @@ -100,6 +101,11 @@ type SignalMargin struct { Threshold float64 `json:"threshold,omitempty"` } +type AggregatedSignalConfig struct { + SmoothingWindow int `json:"smoothingWindow"` + SmoothingType indicatorv2.SmoothingType `json:"smoothingType"` +} + type Strategy struct { common.StrategyProfitFixer @@ -142,6 +148,8 @@ type Strategy struct { SignalReverseSideMargin *SignalMargin `json:"signalReverseSideMargin,omitempty"` SignalTrendSideMarginDiscount *SignalMargin `json:"signalTrendSideMarginDiscount,omitempty"` + AggregatedSignal *AggregatedSignalConfig `json:"aggregatedSignal,omitempty"` + // Margin is the default margin for the quote Margin fixedpoint.Value `json:"margin"` BidMargin fixedpoint.Value `json:"bidMargin"` @@ -288,6 +296,12 @@ type Strategy struct { // TODO: use float64 series instead, so that we can store history signal values lastAggregatedSignal MutexFloat64 + // lastSmoothedAggregatedSignal stores the last smoothed aggregated signal with mutex + lastSmoothedAggregatedSignal MutexFloat64 + + aggregatedSignalSeries *types.Float64Series + aggregatedSignalIndicator types.Float64Calculator + // lastDirectionDivergence stores the latest D2 value lastDirectionDivergence MutexFloat64 lastDirectionMean MutexFloat64 @@ -498,6 +512,11 @@ func (s *Strategy) Initialize() error { } } + if s.AggregatedSignal != nil && s.AggregatedSignal.SmoothingWindow > 0 { + s.aggregatedSignalSeries = types.NewFloat64Series() + s.aggregatedSignalIndicator = indicatorv2.NewSmoothedIndicator(s.aggregatedSignalSeries, 0, s.AggregatedSignal.SmoothingWindow, s.AggregatedSignal.SmoothingType) + } + s.positionExposure = bbgo.NewPositionExposure(s.Symbol) s.positionExposure.SetLogger(s.logger) s.positionExposure.SetMetricsLabels(ID, s.InstanceID(), s.MakerExchange, s.Symbol) @@ -561,14 +580,7 @@ func (s *Strategy) getPositionHoldingPeriod(now time.Time) (time.Duration, bool) } func (s *Strategy) applySignalMargin(ctx context.Context, quote *Quote) error { - sig, err := s.AggregateSignal(ctx) - if err != nil { - return err - } - - s.lastAggregatedSignal.Set(sig) - s.logger.Infof("aggregated signal: %f", sig) - + sig := s.lastSmoothedAggregatedSignal.Get() if sig == 0.0 { return nil } @@ -991,8 +1003,23 @@ func (s *Strategy) updateQuote(ctx context.Context) error { } s.logger.Infof("aggregated signal: %f", sig) + s.lastAggregatedSignal.Set(sig) s.aggregatedSignalMetrics.Set(sig) + smoothedSig := sig + if s.aggregatedSignalIndicator != nil { + s.aggregatedSignalSeries.PushAndEmit(sig) + smoothedSig = s.aggregatedSignalIndicator.(types.Series).Index(0) + } + s.lastSmoothedAggregatedSignal.Set(smoothedSig) + + // apply signal margin + if s.EnableSignalMargin { + if err := s.applySignalMargin(ctx, nil); err != nil { + s.logger.WithError(err).Errorf("unable to apply signal margin") + } + } + now := time.Now() if s.CircuitBreaker != nil { if reason, halted := s.CircuitBreaker.IsHalted(now); halted { @@ -2016,7 +2043,7 @@ func (s *Strategy) hedge(ctx context.Context) { ) lastPrice := s.lastPrice.Get() - sig := s.lastAggregatedSignal.Get() + sig := s.lastSmoothedAggregatedSignal.Get() if lastPrice.IsZero() { s.logger.Warnf("last price is zero, skip hedging") @@ -2273,6 +2300,13 @@ func (s *Strategy) Defaults() error { s.HedgeInterval = types.Duration(10 * time.Second) } + if s.AggregatedSignal == nil { + s.AggregatedSignal = &AggregatedSignalConfig{ + SmoothingWindow: 0, + SmoothingType: indicatorv2.SmoothingTypeEMA, + } + } + if s.NumLayers == 0 { s.NumLayers = 1 } diff --git a/pkg/strategy/xmaker/strategy_maintenance_test.go b/pkg/strategy/xmaker/strategy_maintenance_test.go new file mode 100644 index 0000000000..3345c706a8 --- /dev/null +++ b/pkg/strategy/xmaker/strategy_maintenance_test.go @@ -0,0 +1,54 @@ +package xmaker + +import ( + "context" + "testing" + "time" + + "github.com/prometheus/client_golang/prometheus" + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + + "github.com/c9s/bbgo/pkg/bbgo" + . "github.com/c9s/bbgo/pkg/testing/testhelper" + "github.com/c9s/bbgo/pkg/types" +) + +func TestStrategy_updateQuote_MaintenanceSkip(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + symbol := "BTCUSDT" + market := Market(symbol) + + hedgeSession := &bbgo.ExchangeSession{} + s := &Strategy{ + Symbol: symbol, + hedgeSession: hedgeSession, + makerSession: hedgeSession, + makerMarket: market, + logger: logrus.New(), + activeMakerOrders: bbgo.NewActiveOrderBook(symbol), + cancelOrderDurationMetrics: prometheus.NewHistogram(prometheus.HistogramOpts{ + Name: "test_cancel_order_duration", + }), + } + + dummyConn := types.NewConnectivity() + dummyStream := &types.StandardStream{} + dummyConn.Bind(dummyStream) + dummyStream.EmitConnect() + + hedgeSession.Connectivity = types.NewConnectivityGroup(dummyConn) + + now := time.Now() + past := types.LooseFormatTime(now.Add(-10 * time.Minute)) + future := types.LooseFormatTime(now.Add(10 * time.Minute)) + hedgeSession.Maintenance = &bbgo.MaintenanceConfig{ + StartTime: &past, + EndTime: &future, + } + + err := s.updateQuote(ctx) + assert.NoError(t, err) +}