diff --git a/config/bfxfunding.yaml b/config/bfxfunding.yaml new file mode 100644 index 0000000000..110c9d847f --- /dev/null +++ b/config/bfxfunding.yaml @@ -0,0 +1,20 @@ +--- +persistence: + redis: + host: 127.0.0.1 + port: 6379 + db: 0 + +sessions: + bitfinex: + exchange: bitfinex + envVarPrefix: BITFINEX + +exchangeStrategies: + +- on: bitfinex + bfxfunding: + currency: fUSD + + # interval is how long do you want to update your order price and quantity + interval: 1m diff --git a/pkg/cmd/strategy/builtin.go b/pkg/cmd/strategy/builtin.go index 7281777b7a..bfed64547e 100644 --- a/pkg/cmd/strategy/builtin.go +++ b/pkg/cmd/strategy/builtin.go @@ -6,6 +6,7 @@ import ( _ "github.com/c9s/bbgo/pkg/strategy/audacitymaker" _ "github.com/c9s/bbgo/pkg/strategy/autoborrow" _ "github.com/c9s/bbgo/pkg/strategy/autobuy" + _ "github.com/c9s/bbgo/pkg/strategy/bfxfunding" _ "github.com/c9s/bbgo/pkg/strategy/bollgrid" _ "github.com/c9s/bbgo/pkg/strategy/bollmaker" _ "github.com/c9s/bbgo/pkg/strategy/convert" diff --git a/pkg/exchange/bitfinex/bfxapi/websocket.go b/pkg/exchange/bitfinex/bfxapi/websocket.go index 706ea00f67..561476433f 100644 --- a/pkg/exchange/bitfinex/bfxapi/websocket.go +++ b/pkg/exchange/bitfinex/bfxapi/websocket.go @@ -1059,6 +1059,10 @@ func parseBalanceUpdateEvent(payload json.RawMessage) (interface{}, error) { return &event, nil } +type FundingOfferSnapshotEvent struct { + Offers []FundingOfferUpdateEvent +} + // FundingOfferUpdateEvent represents a Bitfinex funding offer update event. type FundingOfferUpdateEvent struct { OfferID int64 // [0] OFFER_ID @@ -1095,24 +1099,26 @@ func parseFundingOfferUpdate(arrJson json.RawMessage) (*FundingOfferUpdateEvent, } // parseFundingOfferSnapshot parses a funding offer snapshot array into []FundingOfferUpdateEvent. -func parseFundingOfferSnapshot(arrJson json.RawMessage) ([]FundingOfferUpdateEvent, error) { +func parseFundingOfferSnapshot(arrJson json.RawMessage) (*FundingOfferSnapshotEvent, error) { var offerArrays []json.RawMessage if err := json.Unmarshal(arrJson, &offerArrays); err != nil { return nil, fmt.Errorf("failed to unmarshal funding offer snapshot array: %w", err) } - events := make([]FundingOfferUpdateEvent, 0, len(offerArrays)) + snapshot := &FundingOfferSnapshotEvent{} + + snapshot.Offers = make([]FundingOfferUpdateEvent, 0, len(offerArrays)) for _, jsonArr := range offerArrays { event, err := parseFundingOfferUpdate(jsonArr) if err != nil { log.WithError(err).Warnf("failed to parse funding offer fields: %s", jsonArr) continue } else if event != nil { - events = append(events, *event) + snapshot.Offers = append(snapshot.Offers, *event) } } - return events, nil + return snapshot, nil } // FundingInfo represents the inner funding info array. diff --git a/pkg/exchange/bitfinex/bfxapi/websocket_test.go b/pkg/exchange/bitfinex/bfxapi/websocket_test.go index d36482cbfe..577b72ac9d 100644 --- a/pkg/exchange/bitfinex/bfxapi/websocket_test.go +++ b/pkg/exchange/bitfinex/bfxapi/websocket_test.go @@ -545,10 +545,11 @@ func TestParser_ParsePrivateMessages(t *testing.T) { msg, err := p.Parse([]byte(body)) assert.NoError(t, err) if assert.NotNil(t, msg) { - events, ok := msg.([]FundingOfferUpdateEvent) + event, ok := msg.(*FundingOfferSnapshotEvent) + offers := event.Offers assert.True(t, ok, "expected []FundingOfferUpdateEvent type") - assert.Len(t, events, 1) - fo := events[0] + assert.Len(t, offers, 1) + fo := offers[0] assert.Equal(t, int64(41237920), fo.OfferID) assert.Equal(t, "fETH", fo.Symbol) assert.Equal(t, int64(1573912039000), fo.MtsCreated.Time().UnixMilli()) diff --git a/pkg/exchange/bitfinex/exchange.go b/pkg/exchange/bitfinex/exchange.go index 7b8b3b1f36..969f4ddd82 100644 --- a/pkg/exchange/bitfinex/exchange.go +++ b/pkg/exchange/bitfinex/exchange.go @@ -443,8 +443,8 @@ func (e *Exchange) QueryOrderTrades(ctx context.Context, q types.OrderQuery) ([] func (e *Exchange) QueryTrades( ctx context.Context, symbol string, options *types.TradeQueryOptions, ) ([]types.Trade, error) { - - req := e.client.NewGetTradeHistoryBySymbolRequest().Symbol(symbol) + req := e.client.NewGetTradeHistoryBySymbolRequest(). + Symbol(toLocalSymbol(symbol)) if options != nil { if options.StartTime != nil { @@ -541,6 +541,10 @@ func (e *Exchange) QueryDepth( return convertDepth(response, symbol), 0, nil } +func (e *Exchange) GetApiClient() *bfxapi.Client { + return e.client +} + func MapSlice[T, M any](input []T, f func(T) M) []M { result := make([]M, len(input)) for i, v := range input { diff --git a/pkg/exchange/bitfinex/stream.go b/pkg/exchange/bitfinex/stream.go index bd67778b03..d856b76667 100644 --- a/pkg/exchange/bitfinex/stream.go +++ b/pkg/exchange/bitfinex/stream.go @@ -41,8 +41,10 @@ type Stream struct { bookUpdateEventCallbacks []func(e *bfxapi.BookUpdateEvent) bookSnapshotEventCallbacks []func(e *bfxapi.BookSnapshotEvent) - fundingBookEventCallbacks []func(e *bfxapi.FundingBookUpdateEvent) - fundingBookSnapshotEventCallbacks []func(e *bfxapi.FundingBookSnapshotEvent) + fundingBookEventCallbacks []func(e *bfxapi.FundingBookUpdateEvent) + fundingBookSnapshotEventCallbacks []func(e *bfxapi.FundingBookSnapshotEvent) + fundingOfferSnapshotEventCallbacks []func(e *bfxapi.FundingOfferSnapshotEvent) + fundingOfferUpdateEventCallbacks []func(e *bfxapi.FundingOfferUpdateEvent) walletSnapshotEventCallbacks []func(e *bfxapi.WalletSnapshotEvent) walletUpdateEventCallbacks []func(e *bfxapi.Wallet) @@ -342,6 +344,12 @@ func (s *Stream) dispatchEvent(e interface{}) { case *bfxapi.FundingBookUpdateEvent: s.EmitFundingBookEvent(evt) + case *bfxapi.FundingOfferSnapshotEvent: + s.EmitFundingOfferSnapshotEvent(evt) + + case *bfxapi.FundingOfferUpdateEvent: + s.EmitFundingOfferUpdateEvent(evt) + default: s.logger.Warnf("unhandled %T event: %+v", evt, evt) } diff --git a/pkg/exchange/bitfinex/stream_callbacks.go b/pkg/exchange/bitfinex/stream_callbacks.go index e8e9f0879e..72adf96490 100644 --- a/pkg/exchange/bitfinex/stream_callbacks.go +++ b/pkg/exchange/bitfinex/stream_callbacks.go @@ -146,6 +146,26 @@ func (s *Stream) EmitFundingBookSnapshotEvent(e *bfxapi.FundingBookSnapshotEvent } } +func (s *Stream) OnFundingOfferSnapshotEvent(cb func(e *bfxapi.FundingOfferSnapshotEvent)) { + s.fundingOfferSnapshotEventCallbacks = append(s.fundingOfferSnapshotEventCallbacks, cb) +} + +func (s *Stream) EmitFundingOfferSnapshotEvent(e *bfxapi.FundingOfferSnapshotEvent) { + for _, cb := range s.fundingOfferSnapshotEventCallbacks { + cb(e) + } +} + +func (s *Stream) OnFundingOfferUpdateEvent(cb func(e *bfxapi.FundingOfferUpdateEvent)) { + s.fundingOfferUpdateEventCallbacks = append(s.fundingOfferUpdateEventCallbacks, cb) +} + +func (s *Stream) EmitFundingOfferUpdateEvent(e *bfxapi.FundingOfferUpdateEvent) { + for _, cb := range s.fundingOfferUpdateEventCallbacks { + cb(e) + } +} + func (s *Stream) OnWalletSnapshotEvent(cb func(e *bfxapi.WalletSnapshotEvent)) { s.walletSnapshotEventCallbacks = append(s.walletSnapshotEventCallbacks, cb) } diff --git a/pkg/strategy/bfxfunding/strategy.go b/pkg/strategy/bfxfunding/strategy.go new file mode 100644 index 0000000000..5072d9e4a9 --- /dev/null +++ b/pkg/strategy/bfxfunding/strategy.go @@ -0,0 +1,123 @@ +package bfxfunding + +import ( + "context" + "fmt" + "sync" + + "github.com/sirupsen/logrus" + + "github.com/c9s/bbgo/pkg/bbgo" + "github.com/c9s/bbgo/pkg/exchange/bitfinex" + "github.com/c9s/bbgo/pkg/exchange/bitfinex/bfxapi" + "github.com/c9s/bbgo/pkg/fixedpoint" + "github.com/c9s/bbgo/pkg/types" +) + +const ID = "bfxfunding" + +func init() { + bbgo.RegisterStrategy(ID, &Strategy{}) +} + +type Strategy struct { + Currency string `json:"currency"` + Amount fixedpoint.Value `json:"amount"` + Period int `json:"period"` + + MinRate fixedpoint.Value `json:"minRate"` + + exchange *bitfinex.Exchange + stream *bitfinex.Stream + + client *bfxapi.Client + + logger logrus.FieldLogger +} + +func (s *Strategy) ID() string { + return ID +} + +func (s *Strategy) InstanceID() string { + return fmt.Sprintf("%s-%s", ID, s.Currency) +} + +func (s *Strategy) Defaults() error { + s.Currency = "fUSD" + s.MinRate = fixedpoint.NewFromFloat(0.01 * 0.01) + return nil +} + +func (s *Strategy) Validate() error { + if s.Currency == "" { + return fmt.Errorf("currency is required") + } + + if s.Amount.IsZero() { + return fmt.Errorf("amount must be greater than 0") + } + + if s.Period <= 0 { + return fmt.Errorf("period must be greater than 0") + } + + if s.MinRate.IsZero() { + return fmt.Errorf("minRate must be greater than 0") + } + + return nil +} + +func (s *Strategy) Initialize() error { + s.logger = logrus.WithFields(logrus.Fields{"strategy": s.InstanceID(), "currency": s.Currency}) + return nil +} + +func (s *Strategy) Subscribe(session *bbgo.ExchangeSession) { + // session.Subscribe(types.KLineChannel, s.Currency, types.SubscribeOptions{Interval: s.Interval}) +} + +func (s *Strategy) handleFundingOfferSnapshot(e *bfxapi.FundingOfferSnapshotEvent) { + s.logger.Infof("funding offer snapshot event: %+v", e) +} + +func (s *Strategy) handleFundingOfferUpdate(e *bfxapi.FundingOfferUpdateEvent) { + s.logger.Infof("funding offer update event: %+v", e) +} + +func (s *Strategy) bookFunding(rate fixedpoint.Value) { + req := s.client.Funding().NewSubmitFundingOfferRequest() + req.Symbol(s.Currency). + Amount(s.Amount.String()). + OfferType(bfxapi.FundingOfferTypeLimit). + Rate(rate.String()). + Period(s.Period).Notify(true). + Hidden(false). + AutoRenew(true) +} + +func (s *Strategy) Run(ctx context.Context, _ bbgo.OrderExecutor, session *bbgo.ExchangeSession) error { + if session.ExchangeName != types.ExchangeBitfinex { + return fmt.Errorf("bfxfunding strategy only works with bitfinex exchange") + } + + ex, ok := session.Exchange.(*bitfinex.Exchange) + if !ok { + return fmt.Errorf("exchange is not bitfinex exchange") + } + + s.exchange = ex + s.client = s.exchange.GetApiClient() + s.stream = session.UserDataStream.(*bitfinex.Stream) + + s.stream.OnFundingOfferSnapshotEvent(s.handleFundingOfferSnapshot) + s.stream.OnFundingOfferUpdateEvent(s.handleFundingOfferUpdate) + + bbgo.OnShutdown(ctx, func(ctx context.Context, wg *sync.WaitGroup) { + defer wg.Done() + bbgo.Sync(ctx, s) + }) + + return nil +}