diff --git a/config/bbgo.yaml b/config/bbgo.yaml index 7763bcb78..400df0f73 100644 --- a/config/bbgo.yaml +++ b/config/bbgo.yaml @@ -1,6 +1,8 @@ --- imports: - github.com/c9s/bbgo/pkg/strategy/buyandhold +- github.com/c9s/bbgo/pkg/strategy/xpuremaker + notifications: slack: defaultChannel: "bbgo" @@ -39,3 +41,11 @@ exchangeStrategies: interval: "1m" baseQuantity: 0.01 minDropPercentage: -0.02 +- on: max + xpuremaker: + symbol: MAXUSDT + numOrders: 2 + side: both + behindVolume: 1000.0 + priceTick: 0.01 + baseQuantity: 100.0 diff --git a/config/xpuremaker.yaml b/config/xpuremaker.yaml new file mode 100644 index 000000000..c145959ae --- /dev/null +++ b/config/xpuremaker.yaml @@ -0,0 +1,42 @@ +--- +notifications: + slack: + defaultChannel: "bbgo" + errorChannel: "bbgo-error" + +reportTrades: + channelBySymbol: + "btcusdt": "bbgo-btcusdt" + "ethusdt": "bbgo-ethusdt" + "bnbusdt": "bbgo-bnbusdt" + "sxpusdt": "bbgo-sxpusdt" + "maxusdt": "max-maxusdt" + +reportPnL: +- averageCostBySymbols: + - "BTCUSDT" + - "BNBUSDT" + of: binance + when: + - "@daily" + - "@hourly" + +sessions: + max: + exchange: max + keyVar: MAX_API_KEY + secretVar: MAX_API_SECRET + binance: + exchange: binance + keyVar: BINANCE_API_KEY + secretVar: BINANCE_API_SECRET + +exchangeStrategies: +- on: max + xpuremaker: + symbol: MAXUSDT + numOrders: 2 + side: both + behindVolume: 1000.0 + priceTick: 0.001 + baseQuantity: 100.0 diff --git a/examples/max-eqmaker/main.go b/examples/max-eqmaker/main.go index b393b9693..c343839dc 100644 --- a/examples/max-eqmaker/main.go +++ b/examples/max-eqmaker/main.go @@ -216,7 +216,7 @@ func generateOrders(symbol, side string, price, priceTick, baseVolume fixedpoint orders = append(orders, maxapi.Order{ Side: side, - OrderType: string(maxapi.OrderTypeLimit), + OrderType: maxapi.OrderTypeLimit, Market: symbol, Price: util.FormatFloat(price.Float64(), 3), Volume: util.FormatFloat(volume, 2), diff --git a/go.mod b/go.mod index f9ab3dd48..b82c1c8e7 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.13 require ( github.com/DATA-DOG/go-sqlmock v1.5.0 - github.com/adshao/go-binance v0.0.0-20200604145522-bf563a35f17f + github.com/adshao/go-binance v0.0.0-20201015231210-37cee298310e github.com/c9s/goose v0.0.0-20200415105707-8da682162a5b github.com/fastly/go-utils v0.0.0-20180712184237-d95a45783239 // indirect github.com/fsnotify/fsnotify v1.4.7 diff --git a/go.sum b/go.sum index e6a306b4c..6335a56dd 100644 --- a/go.sum +++ b/go.sum @@ -19,6 +19,8 @@ github.com/DATA-DOG/go-sqlmock v1.5.0/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= github.com/adshao/go-binance v0.0.0-20200604145522-bf563a35f17f h1:lVxx5HSt/imprfR8v577N3gCQmKmRgkGNz30FlHISO4= github.com/adshao/go-binance v0.0.0-20200604145522-bf563a35f17f/go.mod h1:XlIpE7brbCEQxp6VRouG/ZgjLjygQWE1xnc1DtQNp6I= +github.com/adshao/go-binance v0.0.0-20201015231210-37cee298310e h1:+fOwsQnvjCIVXuiVbyfuzNbubHvUrx1saeRa9pd7Df8= +github.com/adshao/go-binance v0.0.0-20201015231210-37cee298310e/go.mod h1:XlIpE7brbCEQxp6VRouG/ZgjLjygQWE1xnc1DtQNp6I= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc h1:cAKDfWh5VpdgMhJosfJnn5/FoN2SRZ4p7fJNX58YPaU= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf h1:qet1QNfXsQxTZqLG4oE62mJzwPIB8+Tee4RNCL9ulrY= diff --git a/pkg/bbgo/environment.go b/pkg/bbgo/environment.go index cb661165c..d61105a41 100644 --- a/pkg/bbgo/environment.go +++ b/pkg/bbgo/environment.go @@ -73,7 +73,7 @@ func (reporter *TradeReporter) Report(trade types.Trade) { var text = util.Render(`:handshake: {{ .Symbol }} {{ .Side }} Trade Execution @ {{ .Price }}`, trade) if err := reporter.notifier.NotifyTo(channel, text, trade); err != nil { - log.WithError(err).Error("notifier error") + log.WithError(err).Errorf("notifier error, channel=%s", channel) } } diff --git a/pkg/bbgo/loader.go b/pkg/bbgo/loader.go deleted file mode 100644 index 920078f66..000000000 --- a/pkg/bbgo/loader.go +++ /dev/null @@ -1,2 +0,0 @@ -package bbgo - diff --git a/pkg/bbgo/loader_test.go b/pkg/bbgo/loader_test.go deleted file mode 100644 index 920078f66..000000000 --- a/pkg/bbgo/loader_test.go +++ /dev/null @@ -1,2 +0,0 @@ -package bbgo - diff --git a/pkg/bbgo/order_execution.go b/pkg/bbgo/order_execution.go new file mode 100644 index 000000000..44a206cd8 --- /dev/null +++ b/pkg/bbgo/order_execution.go @@ -0,0 +1,69 @@ +package bbgo + +import ( + "context" + + "github.com/pkg/errors" + + "github.com/c9s/bbgo/pkg/types" +) + +type ExchangeOrderExecutionRouter struct { + Notifiability + + sessions map[string]*ExchangeSession +} + +func (e *ExchangeOrderExecutionRouter) SubmitOrdersTo(ctx context.Context, session string, orders ...types.SubmitOrder) ([]types.Order, error) { + es, ok := e.sessions[session] + if !ok { + return nil, errors.Errorf("exchange session %s not found", session) + } + + var formattedOrders []types.SubmitOrder + for _, order := range orders { + market, ok := es.Market(order.Symbol) + if !ok { + return nil, errors.Errorf("market is not defined: %s", order.Symbol) + } + + order.Market = market + order.PriceString = market.FormatPrice(order.Price) + order.QuantityString = market.FormatVolume(order.Quantity) + formattedOrders = append(formattedOrders, order) + } + + // e.Notify(":memo: Submitting order to %s %s %s %s with quantity: %s", session, order.Symbol, order.Type, order.Side, order.QuantityString, order) + + return es.Exchange.SubmitOrders(ctx, formattedOrders...) +} + +// ExchangeOrderExecutor is an order executor wrapper for single exchange instance. +type ExchangeOrderExecutor struct { + Notifiability + + session *ExchangeSession +} + +func (e *ExchangeOrderExecutor) Session() *ExchangeSession { + return e.session +} + +func (e *ExchangeOrderExecutor) SubmitOrders(ctx context.Context, orders ...types.SubmitOrder) ([]types.Order, error) { + var formattedOrders []types.SubmitOrder + for _, order := range orders { + market, ok := e.session.Market(order.Symbol) + if !ok { + return nil, errors.Errorf("market is not defined: %s", order.Symbol) + } + + order.Market = market + order.PriceString = market.FormatPrice(order.Price) + order.QuantityString = market.FormatVolume(order.Quantity) + formattedOrders = append(formattedOrders, order) + + // e.Notify(":memo: Submitting %s %s %s order with quantity: %s", order.Symbol, order.Type, order.Side, order.QuantityString, order) + } + + return e.session.Exchange.SubmitOrders(ctx, formattedOrders...) +} diff --git a/pkg/bbgo/order_processor.go b/pkg/bbgo/order_processor.go index 753f2821a..871879020 100644 --- a/pkg/bbgo/order_processor.go +++ b/pkg/bbgo/order_processor.go @@ -126,7 +126,9 @@ func (p *OrderProcessor) Submit(ctx context.Context, order types.SubmitOrder) er order.QuantityString = market.FormatVolume(quantity) */ - return p.Exchange.SubmitOrder(ctx, order) + createdOrders, err := p.Exchange.SubmitOrders(ctx, order) + _ = createdOrders + return err } func adjustQuantityByMinAmount(quantity float64, currentPrice float64, minAmount float64) float64 { diff --git a/pkg/bbgo/trader.go b/pkg/bbgo/trader.go index 5a1e8ac21..0b09fc04e 100644 --- a/pkg/bbgo/trader.go +++ b/pkg/bbgo/trader.go @@ -3,7 +3,6 @@ package bbgo import ( "context" - "github.com/pkg/errors" "github.com/robfig/cron/v3" log "github.com/sirupsen/logrus" @@ -26,11 +25,11 @@ func PersistentFlags(flags *flag.FlagSet) { // SingleExchangeStrategy represents the single Exchange strategy type SingleExchangeStrategy interface { - Run(ctx context.Context, orderExecutor types.OrderExecutor, session *ExchangeSession) error + Run(ctx context.Context, orderExecutor OrderExecutor, session *ExchangeSession) error } type CrossExchangeStrategy interface { - Run(ctx context.Context, orderExecutionRouter types.OrderExecutionRouter, sessions map[string]*ExchangeSession) error + Run(ctx context.Context, orderExecutionRouter OrderExecutionRouter, sessions map[string]*ExchangeSession) error } type PnLReporter interface { @@ -81,7 +80,7 @@ func (reporter *AverageCostPnLReporter) Of(sessions ...string) *AverageCostPnLRe } func (reporter *AverageCostPnLReporter) When(specs ...string) *AverageCostPnLReporter { - for _,spec := range specs { + for _, spec := range specs { _, err := reporter.cron.AddJob(spec, reporter) if err != nil { panic(err) @@ -152,15 +151,16 @@ func (trader *Trader) Run(ctx context.Context) error { // load and run session strategies for sessionName, strategies := range trader.exchangeStrategies { + session := trader.environment.sessions[sessionName] // we can move this to the exchange session, // that way we can mount the notification on the exchange with DSL orderExecutor := &ExchangeOrderExecutor{ Notifiability: trader.Notifiability, - Exchange: nil, + session: session, } for _, strategy := range strategies { - err := strategy.Run(ctx, orderExecutor, trader.environment.sessions[sessionName]) + err := strategy.Run(ctx, orderExecutor, session) if err != nil { return err } @@ -319,36 +319,12 @@ func (trader *Trader) SubmitOrder(ctx context.Context, order types.SubmitOrder) } } -type ExchangeOrderExecutionRouter struct { - Notifiability - - sessions map[string]*ExchangeSession +type OrderExecutor interface { + Session() *ExchangeSession + SubmitOrders(ctx context.Context, orders ...types.SubmitOrder) (createdOrders []types.Order, err error) } -func (e *ExchangeOrderExecutionRouter) SubmitOrderTo(ctx context.Context, session string, order types.SubmitOrder) error { - es, ok := e.sessions[session] - if !ok { - return errors.Errorf("exchange session %s not found", session) - } - - e.Notify(":memo: Submitting order to %s %s %s %s with quantity: %s", session, order.Symbol, order.Type, order.Side, order.QuantityString, order) - - order.PriceString = order.Market.FormatVolume(order.Price) - order.QuantityString = order.Market.FormatVolume(order.Quantity) - return es.Exchange.SubmitOrder(ctx, order) -} - -// ExchangeOrderExecutor is an order executor wrapper for single exchange instance. -type ExchangeOrderExecutor struct { - Notifiability - - Exchange types.Exchange -} - -func (e *ExchangeOrderExecutor) SubmitOrder(ctx context.Context, order types.SubmitOrder) error { - e.Notify(":memo: Submitting %s %s %s order with quantity: %s", order.Symbol, order.Type, order.Side, order.QuantityString, order) - - order.PriceString = order.Market.FormatVolume(order.Price) - order.QuantityString = order.Market.FormatVolume(order.Quantity) - return e.Exchange.SubmitOrder(ctx, order) +type OrderExecutionRouter interface { + // SubmitOrderTo submit order to a specific exchange session + SubmitOrdersTo(ctx context.Context, session string, orders ...types.SubmitOrder) (createdOrders []types.Order, err error) } diff --git a/pkg/cmd/run.go b/pkg/cmd/run.go index 461d91dfb..0fade8b27 100644 --- a/pkg/cmd/run.go +++ b/pkg/cmd/run.go @@ -25,6 +25,7 @@ import ( // import built-in strategies _ "github.com/c9s/bbgo/pkg/strategy/buyandhold" + _ "github.com/c9s/bbgo/pkg/strategy/xpuremaker" ) var errSlackTokenUndefined = errors.New("slack token is not defined.") diff --git a/pkg/exchange/binance/convert.go b/pkg/exchange/binance/convert.go new file mode 100644 index 000000000..9ae077790 --- /dev/null +++ b/pkg/exchange/binance/convert.go @@ -0,0 +1,151 @@ +package binance + +import ( + "fmt" + "strconv" + "time" + + "github.com/adshao/go-binance" + + "github.com/c9s/bbgo/pkg/types" + "github.com/c9s/bbgo/pkg/util" +) + +func toLocalOrderType(orderType types.OrderType) (binance.OrderType, error) { + switch orderType { + case types.OrderTypeLimit: + return binance.OrderTypeLimit, nil + + case types.OrderTypeStopLimit: + return binance.OrderTypeStopLossLimit, nil + + case types.OrderTypeStopMarket: + return binance.OrderTypeStopLoss, nil + + case types.OrderTypeMarket: + return binance.OrderTypeMarket, nil + } + + return "", fmt.Errorf("order type %s not supported", orderType) +} + +func toGlobalOrder(binanceOrder *binance.Order) (*types.Order, error) { + return &types.Order{ + SubmitOrder: types.SubmitOrder{ + ClientOrderID: binanceOrder.ClientOrderID, + Symbol: binanceOrder.Symbol, + Side: toGlobalSideType(binanceOrder.Side), + Type: toGlobalOrderType(binanceOrder.Type), + Quantity: util.MustParseFloat(binanceOrder.OrigQuantity), + Price: util.MustParseFloat(binanceOrder.Price), + TimeInForce: string(binanceOrder.TimeInForce), + }, + OrderID: uint64(binanceOrder.OrderID), + Status: toGlobalOrderStatus(binanceOrder.Status), + ExecutedQuantity: util.MustParseFloat(binanceOrder.ExecutedQuantity), + }, nil +} + +func toGlobalTrade(t binance.TradeV3) (*types.Trade, error) { + // skip trade ID that is the same. however this should not happen + var side types.SideType + if t.IsBuyer { + side = types.SideTypeBuy + } else { + side = types.SideTypeSell + } + + // trade time + mts := time.Unix(0, t.Time*int64(time.Millisecond)) + + price, err := strconv.ParseFloat(t.Price, 64) + if err != nil { + return nil, err + } + + quantity, err := strconv.ParseFloat(t.Quantity, 64) + if err != nil { + return nil, err + } + + quoteQuantity, err := strconv.ParseFloat(t.QuoteQuantity, 64) + if err != nil { + return nil, err + } + + fee, err := strconv.ParseFloat(t.Commission, 64) + if err != nil { + return nil, err + } + + return &types.Trade{ + ID: t.ID, + Price: price, + Symbol: t.Symbol, + Exchange: "binance", + Quantity: quantity, + Side: side, + IsBuyer: t.IsBuyer, + IsMaker: t.IsMaker, + Fee: fee, + FeeCurrency: t.CommissionAsset, + QuoteQuantity: quoteQuantity, + Time: mts, + }, nil +} + +func toGlobalSideType(side binance.SideType) types.SideType { + switch side { + case binance.SideTypeBuy: + return types.SideTypeBuy + + case binance.SideTypeSell: + return types.SideTypeSell + + default: + log.Errorf("unknown side type: %v", side) + return "" + } +} + +func toGlobalOrderType(orderType binance.OrderType) types.OrderType { + switch orderType { + + case binance.OrderTypeLimit: + return types.OrderTypeLimit + + case binance.OrderTypeMarket: + return types.OrderTypeMarket + + case binance.OrderTypeStopLossLimit: + return types.OrderTypeStopLimit + + case binance.OrderTypeStopLoss: + return types.OrderTypeStopMarket + + default: + log.Errorf("unsupported order type: %v", orderType) + return "" + } +} + +func toGlobalOrderStatus(orderStatus binance.OrderStatusType) types.OrderStatus { + switch orderStatus { + case binance.OrderStatusTypeNew: + return types.OrderStatusNew + + case binance.OrderStatusTypeRejected: + return types.OrderStatusRejected + + case binance.OrderStatusTypeCanceled: + return types.OrderStatusCanceled + + case binance.OrderStatusTypePartiallyFilled: + return types.OrderStatusPartiallyFilled + + case binance.OrderStatusTypeFilled: + return types.OrderStatusFilled + } + + return types.OrderStatus(orderStatus) +} diff --git a/pkg/exchange/binance/exchange.go b/pkg/exchange/binance/exchange.go index 3d47b3d57..c51533797 100644 --- a/pkg/exchange/binance/exchange.go +++ b/pkg/exchange/binance/exchange.go @@ -3,10 +3,11 @@ package binance import ( "context" "fmt" - "strconv" "time" "github.com/adshao/go-binance" + "github.com/google/uuid" + "github.com/pkg/errors" "github.com/sirupsen/logrus" @@ -55,7 +56,7 @@ func (e *Exchange) QueryMarkets(ctx context.Context) (types.MarketMap, error) { BaseCurrency: symbol.BaseAsset, } - if f := symbol.MinNotionalFilter() ; f != nil { + if f := symbol.MinNotionalFilter(); f != nil { market.MinNotional = util.MustParseFloat(f.MinNotional) market.MinAmount = util.MustParseFloat(f.MinNotional) } @@ -65,14 +66,14 @@ func (e *Exchange) QueryMarkets(ctx context.Context) (types.MarketMap, error) { // minQty defines the minimum quantity/icebergQty allowed. // maxQty defines the maximum quantity/icebergQty allowed. // stepSize defines the intervals that a quantity/icebergQty can be increased/decreased by. - if f := symbol.LotSizeFilter() ; f != nil { + if f := symbol.LotSizeFilter(); f != nil { market.MinLot = util.MustParseFloat(f.MinQuantity) market.MinQuantity = util.MustParseFloat(f.MinQuantity) market.MaxQuantity = util.MustParseFloat(f.MaxQuantity) // market.StepSize = util.MustParseFloat(f.StepSize) } - if f := symbol.PriceFilter() ; f != nil { + if f := symbol.PriceFilter(); f != nil { market.MaxPrice = util.MustParseFloat(f.MaxPrice) market.MinPrice = util.MustParseFloat(f.MinPrice) market.TickSize = util.MustParseFloat(f.TickSize) @@ -268,53 +269,115 @@ func (e *Exchange) QueryAccount(ctx context.Context) (*types.Account, error) { return a, nil } -func (e *Exchange) SubmitOrder(ctx context.Context, order types.SubmitOrder) error { - /* - limit order example - - order, err := Client.NewCreateOrderService(). - Symbol(Symbol). - Side(side). - Type(binance.OrderTypeLimit). - TimeInForce(binance.TimeInForceTypeGTC). - Quantity(volumeString). - Price(priceString). - Do(ctx) - */ - - orderType, err := toLocalOrderType(order.Type) +func (e *Exchange) QueryOpenOrders(ctx context.Context, symbol string) (orders []types.Order, err error) { + remoteOrders, err := e.Client.NewListOpenOrdersService().Symbol(symbol).Do(ctx) if err != nil { - return err + return orders, err } - req := e.Client.NewCreateOrderService(). - Symbol(order.Symbol). - Side(binance.SideType(order.Side)). - Type(orderType). - Quantity(order.QuantityString) + for _, binanceOrder := range remoteOrders { + order, err := toGlobalOrder(binanceOrder) + if err != nil { + return orders, err + } - if len(order.PriceString) > 0 { - req.Price(order.PriceString) - } - if len(order.TimeInForce) > 0 { - req.TimeInForce(order.TimeInForce) + orders = append(orders, *order) } - retOrder, err := req.Do(ctx) - log.Infof("order created: %+v", retOrder) - return err + return orders, err } -func toLocalOrderType(orderType types.OrderType) (binance.OrderType, error) { - switch orderType { - case types.OrderTypeLimit: - return binance.OrderTypeLimit, nil +func (e *Exchange) CancelOrders(ctx context.Context, orders ...types.Order) (err2 error) { + for _, o := range orders { + var req = e.Client.NewCancelOrderService() - case types.OrderTypeMarket: - return binance.OrderTypeMarket, nil + // Mandatory + req.Symbol(o.Symbol) + + if o.OrderID > 0 { + req.OrderID(int64(o.OrderID)) + } else if len(o.ClientOrderID) > 0 { + req.NewClientOrderID(o.ClientOrderID) + } + + _, err := req.Do(ctx) + if err != nil { + log.WithError(err).Errorf("order cancel error") + err2 = err + } } - return "", fmt.Errorf("order type %s not supported", orderType) + return err2 +} + +func (e *Exchange) SubmitOrders(ctx context.Context, orders ...types.SubmitOrder) (createdOrders []types.Order, err error) { + for _, order := range orders { + orderType, err := toLocalOrderType(order.Type) + if err != nil { + return createdOrders, err + } + + clientOrderID := uuid.New().String() + if len(order.ClientOrderID) > 0 { + clientOrderID = order.ClientOrderID + } + + req := e.Client.NewCreateOrderService(). + Symbol(order.Symbol). + Side(binance.SideType(order.Side)). + NewClientOrderID(clientOrderID). + Type(orderType) + + req.Quantity(order.QuantityString) + + if len(order.PriceString) > 0 { + req.Price(order.PriceString) + } + + if len(order.TimeInForce) > 0 { + // TODO: check the TimeInForce value + req.TimeInForce(binance.TimeInForceType(order.TimeInForce)) + } + + response, err := req.Do(ctx) + if err != nil { + return createdOrders, err + } + + log.Infof("order creation response: %+v", response) + + retOrder := binance.Order{ + Symbol: response.Symbol, + OrderID: response.OrderID, + ClientOrderID: response.ClientOrderID, + Price: response.Price, + OrigQuantity: response.OrigQuantity, + ExecutedQuantity: response.ExecutedQuantity, + CummulativeQuoteQuantity: response.CummulativeQuoteQuantity, + Status: response.Status, + TimeInForce: response.TimeInForce, + Type: response.Type, + Side: response.Side, + // StopPrice: + // IcebergQuantity: + Time: response.TransactTime, + // UpdateTime: + // IsWorking: , + } + + createdOrder, err := toGlobalOrder(&retOrder) + if err != nil { + return createdOrders, err + } + + if createdOrder == nil { + return createdOrders, errors.New("nil converted order") + } + + createdOrders = append(createdOrders, *createdOrder) + } + + return createdOrders, err } func (e *Exchange) QueryKLines(ctx context.Context, symbol, interval string, options types.KLineQueryOptions) ([]types.KLine, error) { @@ -393,7 +456,7 @@ func (e *Exchange) QueryTrades(ctx context.Context, symbol string, options *type } for _, t := range remoteTrades { - localTrade, err := convertRemoteTrade(*t) + localTrade, err := toGlobalTrade(*t) if err != nil { log.WithError(err).Errorf("can not convert binance trade: %+v", t) continue @@ -406,54 +469,6 @@ func (e *Exchange) QueryTrades(ctx context.Context, symbol string, options *type return trades, nil } -func convertRemoteTrade(t binance.TradeV3) (*types.Trade, error) { - // skip trade ID that is the same. however this should not happen - var side string - if t.IsBuyer { - side = "BUY" - } else { - side = "SELL" - } - - // trade time - mts := time.Unix(0, t.Time*int64(time.Millisecond)) - - price, err := strconv.ParseFloat(t.Price, 64) - if err != nil { - return nil, err - } - - quantity, err := strconv.ParseFloat(t.Quantity, 64) - if err != nil { - return nil, err - } - - quoteQuantity, err := strconv.ParseFloat(t.QuoteQuantity, 64) - if err != nil { - return nil, err - } - - fee, err := strconv.ParseFloat(t.Commission, 64) - if err != nil { - return nil, err - } - - return &types.Trade{ - ID: t.ID, - Price: price, - Symbol: t.Symbol, - Exchange: "binance", - Quantity: quantity, - Side: side, - IsBuyer: t.IsBuyer, - IsMaker: t.IsMaker, - Fee: fee, - FeeCurrency: t.CommissionAsset, - QuoteQuantity: quoteQuantity, - Time: mts, - }, nil -} - func (e *Exchange) BatchQueryKLines(ctx context.Context, symbol, interval string, startTime, endTime time.Time) ([]types.KLine, error) { var allKLines []types.KLine diff --git a/pkg/exchange/binance/parse.go b/pkg/exchange/binance/parse.go index 9f4de6207..45d3ff299 100644 --- a/pkg/exchange/binance/parse.go +++ b/pkg/exchange/binance/parse.go @@ -6,6 +6,7 @@ import ( "fmt" "time" + "github.com/adshao/go-binance" "github.com/valyala/fastjson" "github.com/c9s/bbgo/pkg/fixedpoint" @@ -74,7 +75,7 @@ type ExecutionReportEvent struct { CurrentExecutionType string `json:"x"` CurrentOrderStatus string `json:"X"` - OrderID int `json:"i"` + OrderID uint64 `json:"i"` TradeID int64 `json:"t"` TransactionTime int64 `json:"T"` @@ -84,7 +85,34 @@ type ExecutionReportEvent struct { LastExecutedPrice string `json:"L"` LastQuoteAssetTransactedQuantity string `json:"Y"` - OrderCreationTime int `json:"O"` + OrderCreationTime int64 `json:"O"` +} + +func (e *ExecutionReportEvent) Order() (*types.Order, error) { + + switch e.CurrentExecutionType { + case "NEW", "CANCELED", "REJECTED", "EXPIRED": + case "REPLACED": + default: + return nil, errors.New("execution report type is not for order") + } + + orderCreationTime := time.Unix(0, e.OrderCreationTime*int64(time.Millisecond)) + return &types.Order{ + SubmitOrder: types.SubmitOrder{ + Symbol: e.Symbol, + ClientOrderID: e.ClientOrderID, + Side: toGlobalSideType(binance.SideType(e.Side)), + Type: toGlobalOrderType(binance.OrderType(e.OrderType)), + Quantity: util.MustParseFloat(e.OrderQuantity), + Price: util.MustParseFloat(e.OrderPrice), + TimeInForce: e.TimeInForce, + }, + OrderID: e.OrderID, + Status: toGlobalOrderStatus(binance.OrderStatusType(e.CurrentOrderStatus)), + ExecutedQuantity: util.MustParseFloat(e.CumulativeFilledQuantity), + CreationTime: orderCreationTime, + }, nil } func (e *ExecutionReportEvent) Trade() (*types.Trade, error) { @@ -92,14 +120,14 @@ func (e *ExecutionReportEvent) Trade() (*types.Trade, error) { return nil, errors.New("execution report is not a trade") } - tt := time.Unix(0, e.TransactionTime/1000000) + tt := time.Unix(0, e.TransactionTime*int64(time.Millisecond)) return &types.Trade{ ID: e.TradeID, Symbol: e.Symbol, + Side: toGlobalSideType(binance.SideType(e.Side)), Price: util.MustParseFloat(e.LastExecutedPrice), Quantity: util.MustParseFloat(e.LastExecutedQuantity), QuoteQuantity: util.MustParseFloat(e.LastQuoteAssetTransactedQuantity), - Side: e.Side, IsBuyer: e.Side == "BUY", IsMaker: e.IsMaker, Time: tt, diff --git a/pkg/exchange/binance/parse_test.go b/pkg/exchange/binance/parse_test.go new file mode 100644 index 000000000..298834a16 --- /dev/null +++ b/pkg/exchange/binance/parse_test.go @@ -0,0 +1,61 @@ +package binance + +import ( + "regexp" + "testing" + + "github.com/stretchr/testify/assert" +) + +var jsCommentTrimmer = regexp.MustCompile("(?m)//.*$") + +func TestParseOrderUpdate(t *testing.T) { + payload := `{ + "e": "executionReport", // Event type + "E": 1499405658658, // Event time + "s": "ETHBTC", // Symbol + "c": "mUvoqJxFIILMdfAW5iGSOW", // Client order ID + "S": "BUY", // Side + "o": "LIMIT", // Order type + "f": "GTC", // Time in force + "q": "1.00000000", // Order quantity + "p": "0.10264410", // Order price + "P": "0.00000000", // Stop price + "F": "0.00000000", // Iceberg quantity + "g": -1, // OrderListId + "C": null, // Original client order ID; This is the ID of the order being canceled + "x": "NEW", // Current execution type + "X": "NEW", // Current order status + "r": "NONE", // Order reject reason; will be an error code. + "i": 4293153, // Order ID + "l": "0.00000000", // Last executed quantity + "z": "0.00000000", // Cumulative filled quantity + "L": "0.00000000", // Last executed price + "n": "0", // Commission amount + "N": null, // Commission asset + "T": 1499405658657, // Transaction time + "t": -1, // Trade ID + "I": 8641984, // Ignore + "w": true, // Is the order on the book? + "m": false, // Is this trade the maker side? + "M": false, // Ignore + "O": 1499405658657, // Order creation time + "Z": "0.00000000", // Cumulative quote asset transacted quantity + "Y": "0.00000000", // Last quote asset transacted quantity (i.e. lastPrice * lastQty) + "Q": "0.00000000" // Quote Order Qty +}` + + payload = jsCommentTrimmer.ReplaceAllLiteralString(payload, "") + + event, err := ParseEvent(payload) + assert.NoError(t, err) + assert.NotNil(t, event) + + executionReport, ok := event.(*ExecutionReportEvent) + assert.True(t, ok) + assert.NotNil(t, executionReport) + + orderUpdate, err := executionReport.Order() + assert.NoError(t, err) + assert.NotNil(t, orderUpdate) +} diff --git a/pkg/exchange/max/convert.go b/pkg/exchange/max/convert.go new file mode 100644 index 000000000..92130b5a6 --- /dev/null +++ b/pkg/exchange/max/convert.go @@ -0,0 +1,218 @@ +package max + +import ( + "fmt" + "strconv" + "strings" + "time" + + "github.com/c9s/bbgo/pkg/exchange/max/maxapi" + "github.com/c9s/bbgo/pkg/fixedpoint" + "github.com/c9s/bbgo/pkg/types" + "github.com/c9s/bbgo/pkg/util" +) + +func toGlobalCurrency(currency string) string { + return strings.ToUpper(currency) +} + +func toLocalCurrency(currency string) string { + return strings.ToLower(currency) +} + +func toLocalSymbol(symbol string) string { + return strings.ToLower(symbol) +} + +func toGlobalSymbol(symbol string) string { + return strings.ToUpper(symbol) +} + +func toLocalSideType(side types.SideType) string { + return strings.ToLower(string(side)) +} + +func toGlobalSideType(v string) types.SideType { + switch strings.ToLower(v) { + case "bid", "buy": + return types.SideTypeBuy + + case "ask", "sell": + return types.SideTypeSell + + case "self-trade": + return types.SideTypeSelf + + } + + return types.SideType(v) +} + +func toGlobalOrderStatus(orderStatus max.OrderState, executedVolume, remainingVolume fixedpoint.Value) types.OrderStatus { + + switch orderStatus { + + case max.OrderStateCancel: + return types.OrderStatusCanceled + + case max.OrderStateFinalizing, max.OrderStateDone: + if executedVolume > 0 && remainingVolume > 0 { + return types.OrderStatusPartiallyFilled + } else if remainingVolume == 0 { + return types.OrderStatusFilled + } + + return types.OrderStatusFilled + + case max.OrderStateWait: + if executedVolume > 0 && remainingVolume > 0 { + return types.OrderStatusPartiallyFilled + } + + return types.OrderStatusNew + + case max.OrderStateConvert: + if executedVolume > 0 && remainingVolume > 0 { + return types.OrderStatusPartiallyFilled + } + + return types.OrderStatusNew + + case max.OrderStateFailed: + return types.OrderStatusRejected + + } + + logger.Errorf("unknown order status: %v", orderStatus) + return types.OrderStatus(orderStatus) +} + +func toGlobalOrderType(orderType max.OrderType) types.OrderType { + switch orderType { + case max.OrderTypeLimit: + return types.OrderTypeLimit + + case max.OrderTypeMarket: + return types.OrderTypeMarket + + case max.OrderTypeStopLimit: + return types.OrderTypeStopLimit + + case max.OrderTypeStopMarket: + return types.OrderTypeStopMarket + + } + + logger.Errorf("unknown order type: %v", orderType) + return types.OrderType(orderType) +} + +func toLocalOrderType(orderType types.OrderType) (max.OrderType, error) { + switch orderType { + + case types.OrderTypeStopLimit: + return max.OrderTypeStopLimit, nil + + case types.OrderTypeStopMarket: + return max.OrderTypeStopMarket, nil + + case types.OrderTypeLimit: + return max.OrderTypeLimit, nil + + case types.OrderTypeMarket: + return max.OrderTypeMarket, nil + } + + return "", fmt.Errorf("order type %s not supported", orderType) +} + +func toGlobalOrder(maxOrder max.Order) (*types.Order, error) { + executedVolume, err := fixedpoint.NewFromString(maxOrder.ExecutedVolume) + if err != nil { + return nil, err + } + + remainingVolume, err := fixedpoint.NewFromString(maxOrder.RemainingVolume) + if err != nil { + return nil, err + } + + return &types.Order{ + SubmitOrder: types.SubmitOrder{ + ClientOrderID: maxOrder.ClientOID, + Symbol: toGlobalSymbol(maxOrder.Market), + Side: toGlobalSideType(maxOrder.Side), + Type: toGlobalOrderType(maxOrder.OrderType), + Quantity: util.MustParseFloat(maxOrder.Volume), + Price: util.MustParseFloat(maxOrder.Price), + TimeInForce: "GTC", // MAX only supports GTC + }, + OrderID: maxOrder.ID, + Status: toGlobalOrderStatus(maxOrder.State, executedVolume, remainingVolume), + ExecutedQuantity: executedVolume.Float64(), + }, nil +} + +func toGlobalTrade(t max.Trade) (*types.Trade, error) { + // skip trade ID that is the same. however this should not happen + var side = toGlobalSideType(t.Side) + + // trade time + mts := time.Unix(0, t.CreatedAtMilliSeconds*int64(time.Millisecond)) + + price, err := strconv.ParseFloat(t.Price, 64) + if err != nil { + return nil, err + } + + quantity, err := strconv.ParseFloat(t.Volume, 64) + if err != nil { + return nil, err + } + + quoteQuantity, err := strconv.ParseFloat(t.Funds, 64) + if err != nil { + return nil, err + } + + fee, err := strconv.ParseFloat(t.Fee, 64) + if err != nil { + return nil, err + } + + return &types.Trade{ + ID: int64(t.ID), + Price: price, + Symbol: toGlobalSymbol(t.Market), + Exchange: "max", + Quantity: quantity, + Side: side, + IsBuyer: t.IsBuyer(), + IsMaker: t.IsMaker(), + Fee: fee, + FeeCurrency: toGlobalCurrency(t.FeeCurrency), + QuoteQuantity: quoteQuantity, + Time: mts, + }, nil +} + +func toGlobalDepositStatus(a string) types.DepositStatus { + switch a { + case "submitting", "submitted", "checking": + return types.DepositPending + + case "accepted": + return types.DepositSuccess + + case "rejected": + return types.DepositRejected + + case "canceled": + return types.DepositCancelled + + case "suspect", "refunded": + + } + + return types.DepositStatus(a) +} diff --git a/pkg/exchange/max/exchange.go b/pkg/exchange/max/exchange.go index ba30f9ac1..747bcc874 100644 --- a/pkg/exchange/max/exchange.go +++ b/pkg/exchange/max/exchange.go @@ -2,11 +2,9 @@ package max import ( "context" - "fmt" - "strconv" - "strings" "time" + "github.com/google/uuid" "github.com/pkg/errors" "github.com/sirupsen/logrus" @@ -46,8 +44,10 @@ func (e *Exchange) QueryMarkets(ctx context.Context) (types.MarketMap, error) { markets := types.MarketMap{} for _, m := range remoteMarkets { + symbol := toGlobalSymbol(m.ID) + market := types.Market{ - Symbol: toGlobalSymbol(m.ID), + Symbol: symbol, PricePrecision: m.QuoteUnitPrecision, VolumePrecision: m.BaseUnitPrecision, QuoteCurrency: toGlobalCurrency(m.QuoteUnit), @@ -62,7 +62,7 @@ func (e *Exchange) QueryMarkets(ctx context.Context) (types.MarketMap, error) { TickSize: 0.001, } - markets[m.ID] = market + markets[symbol] = market } return markets, nil @@ -72,26 +72,86 @@ func (e *Exchange) NewStream() types.Stream { return NewStream(e.key, e.secret) } -func (e *Exchange) SubmitOrder(ctx context.Context, order types.SubmitOrder) error { - orderType, err := toLocalOrderType(order.Type) +func (e *Exchange) QueryOpenOrders(ctx context.Context, symbol string) (orders []types.Order, err error) { + maxOrders, err := e.client.OrderService.Open(toLocalSymbol(symbol), maxapi.QueryOrderOptions{}) if err != nil { - return err + return orders, err } - req := e.client.OrderService.NewCreateOrderRequest(). - Market(toLocalSymbol(order.Symbol)). - OrderType(string(orderType)). - Side(toLocalSideType(order.Side)). - Volume(order.QuantityString). - Price(order.PriceString) + for _, maxOrder := range maxOrders { + order, err := toGlobalOrder(maxOrder) + if err != nil { + return orders, err + } - retOrder, err := req.Do(ctx) - if err != nil { - return err + orders = append(orders, *order) } - logger.Infof("order created: %+v", retOrder) - return err + return orders, err +} + +func (e *Exchange) CancelOrders(ctx context.Context, orders ...types.Order) (err2 error) { + for _, o := range orders { + var req = e.client.OrderService.NewOrderCancelRequest() + if o.OrderID > 0 { + req.ID(o.OrderID) + } else if len(o.ClientOrderID) > 0 { + req.ClientOrderID(o.ClientOrderID) + } else { + return errors.Errorf("order id or client order id is not defined, order=%+v", o) + } + + if err := req.Do(ctx); err != nil { + log.WithError(err).Errorf("order cancel error") + err2 = err + } + } + + return err2 +} + +func (e *Exchange) SubmitOrders(ctx context.Context, orders ...types.SubmitOrder) (createdOrders []types.Order, err error) { + for _, order := range orders { + orderType, err := toLocalOrderType(order.Type) + if err != nil { + return createdOrders, err + } + + req := e.client.OrderService.NewCreateOrderRequest(). + Market(toLocalSymbol(order.Symbol)). + OrderType(string(orderType)). + Side(toLocalSideType(order.Side)). + Volume(order.QuantityString) + + if len(order.ClientOrderID) > 0 { + req.ClientOrderID(order.ClientOrderID) + } else { + clientOrderID := uuid.New().String() + req.ClientOrderID(clientOrderID) + } + + if len(order.PriceString) > 0 { + req.Price(order.PriceString) + } + + retOrder, err := req.Do(ctx) + if err != nil { + return createdOrders, err + } + if retOrder == nil { + return createdOrders, errors.New("returned nil order") + } + + logger.Infof("order created: %+v", retOrder) + createdOrder, err := toGlobalOrder(*retOrder) + if err != nil { + return createdOrders, err + } + + createdOrders = append(createdOrders, *createdOrder) + } + + return createdOrders, err } // PlatformFeeCurrency @@ -230,7 +290,7 @@ func (e *Exchange) QueryDepositHistory(ctx context.Context, asset string, since, Address: "", // not supported AddressTag: "", // not supported TransactionID: d.TxID, - Status: convertDepositState(d.State), + Status: toGlobalDepositStatus(d.State), }) } @@ -240,27 +300,6 @@ func (e *Exchange) QueryDepositHistory(ctx context.Context, asset string, since, return allDeposits, err } -func convertDepositState(a string) types.DepositStatus { - switch a { - case "submitting", "submitted", "checking": - return types.DepositPending - - case "accepted": - return types.DepositSuccess - - case "rejected": - return types.DepositRejected - - case "canceled": - return types.DepositCancelled - - case "suspect", "refunded": - - } - - return types.DepositStatus(a) -} - func (e *Exchange) QueryAccountBalances(ctx context.Context) (types.BalanceMap, error) { accounts, err := e.client.AccountService.Accounts() if err != nil { @@ -301,7 +340,7 @@ func (e *Exchange) QueryTrades(ctx context.Context, symbol string, options *type } for _, t := range remoteTrades { - localTrade, err := convertRemoteTrade(t) + localTrade, err := toGlobalTrade(t) if err != nil { logger.WithError(err).Errorf("can not convert trade: %+v", t) continue @@ -356,94 +395,3 @@ func (e *Exchange) QueryAveragePrice(ctx context.Context, symbol string) (float6 return (util.MustParseFloat(ticker.Sell) + util.MustParseFloat(ticker.Buy)) / 2, nil } - -func toGlobalCurrency(currency string) string { - return strings.ToUpper(currency) -} - -func toLocalCurrency(currency string) string { - return strings.ToLower(currency) -} - -func toLocalSymbol(symbol string) string { - return strings.ToLower(symbol) -} - -func toGlobalSymbol(symbol string) string { - return strings.ToUpper(symbol) -} - -func toLocalSideType(side types.SideType) string { - return strings.ToLower(string(side)) -} - -func toGlobalSideType(v string) string { - switch strings.ToLower(v) { - case "bid": - return "BUY" - - case "ask": - return "SELL" - - case "self-trade": - return "SELF" - - } - - return strings.ToUpper(v) -} - -func toLocalOrderType(orderType types.OrderType) (maxapi.OrderType, error) { - switch orderType { - case types.OrderTypeLimit: - return maxapi.OrderTypeLimit, nil - - case types.OrderTypeMarket: - return maxapi.OrderTypeMarket, nil - } - - return "", fmt.Errorf("order type %s not supported", orderType) -} - -func convertRemoteTrade(t maxapi.Trade) (*types.Trade, error) { - // skip trade ID that is the same. however this should not happen - var side = toGlobalSideType(t.Side) - - // trade time - mts := time.Unix(0, t.CreatedAtMilliSeconds*int64(time.Millisecond)) - - price, err := strconv.ParseFloat(t.Price, 64) - if err != nil { - return nil, err - } - - quantity, err := strconv.ParseFloat(t.Volume, 64) - if err != nil { - return nil, err - } - - quoteQuantity, err := strconv.ParseFloat(t.Funds, 64) - if err != nil { - return nil, err - } - - fee, err := strconv.ParseFloat(t.Fee, 64) - if err != nil { - return nil, err - } - - return &types.Trade{ - ID: int64(t.ID), - Price: price, - Symbol: toGlobalSymbol(t.Market), - Exchange: "max", - Quantity: quantity, - Side: side, - IsBuyer: t.IsBuyer(), - IsMaker: t.IsMaker(), - Fee: fee, - FeeCurrency: toGlobalCurrency(t.FeeCurrency), - QuoteQuantity: quoteQuantity, - Time: mts, - }, nil -} diff --git a/pkg/exchange/max/maxapi/order.go b/pkg/exchange/max/maxapi/order.go index d672c126a..0b292c9a1 100644 --- a/pkg/exchange/max/maxapi/order.go +++ b/pkg/exchange/max/maxapi/order.go @@ -19,20 +19,28 @@ const ( type OrderState string const ( - OrderStateDone = OrderState("done") - OrderStateCancel = OrderState("cancel") - OrderStateWait = OrderState("wait") - OrderStateConvert = OrderState("convert") + OrderStateDone = OrderState("done") + OrderStateCancel = OrderState("cancel") + OrderStateWait = OrderState("wait") + OrderStateConvert = OrderState("convert") + OrderStateFinalizing = OrderState("finalizing") + OrderStateFailed = OrderState("failed") ) type OrderType string // Order types that the API can return. const ( - OrderTypeMarket = OrderType("market") - OrderTypeLimit = OrderType("limit") + OrderTypeMarket = OrderType("market") + OrderTypeLimit = OrderType("limit") + OrderTypeStopLimit = OrderType("stop_limit") + OrderTypeStopMarket = OrderType("stop_market") ) +type QueryOrderOptions struct { + GroupID int +} + // OrderService manages the Order endpoint. type OrderService struct { client *RestClient @@ -40,22 +48,52 @@ type OrderService struct { // Order represents one returned order (POST order/GET order/GET orders) on the max platform. type Order struct { - ID uint64 `json:"id,omitempty" db:"exchange_id"` - Side string `json:"side" db:"side"` - OrderType string `json:"ord_type,omitempty" db:"order_type"` - Price string `json:"price" db:"price"` - AveragePrice string `json:"avg_price,omitempty" db:"average_price"` - State string `json:"state,omitempty" db:"state"` - Market string `json:"market,omitempty" db:"market"` - Volume string `json:"volume" db:"volume"` - RemainingVolume string `json:"remaining_volume,omitempty" db:"remaining_volume"` - ExecutedVolume string `json:"executed_volume,omitempty" db:"executed_volume"` - TradesCount int64 `json:"trades_count,omitempty" db:"trades_count"` - GroupID int64 `json:"group_id,omitempty" db:"group_id"` - ClientOID string `json:"client_oid,omitempty" db:"client_oid"` - CreatedAt time.Time `json:"-" db:"created_at"` - CreatedAtMs int64 `json:"created_at_in_ms,omitempty"` - InsertedAt time.Time `json:"-" db:"inserted_at"` + ID uint64 `json:"id,omitempty" db:"exchange_id"` + Side string `json:"side" db:"side"` + OrderType OrderType `json:"ord_type,omitempty" db:"order_type"` + Price string `json:"price" db:"price"` + AveragePrice string `json:"avg_price,omitempty" db:"average_price"` + State OrderState `json:"state,omitempty" db:"state"` + Market string `json:"market,omitempty" db:"market"` + Volume string `json:"volume" db:"volume"` + RemainingVolume string `json:"remaining_volume,omitempty" db:"remaining_volume"` + ExecutedVolume string `json:"executed_volume,omitempty" db:"executed_volume"` + TradesCount int64 `json:"trades_count,omitempty" db:"trades_count"` + GroupID int64 `json:"group_id,omitempty" db:"group_id"` + ClientOID string `json:"client_oid,omitempty" db:"client_oid"` + CreatedAt time.Time `json:"-" db:"created_at"` + CreatedAtMs int64 `json:"created_at_in_ms,omitempty"` + InsertedAt time.Time `json:"-" db:"inserted_at"` +} + +// Open returns open orders +func (s *OrderService) Open(market string, options QueryOrderOptions) ([]Order, error) { + payload := map[string]interface{}{ + "market": market, + // "state": []OrderState{OrderStateWait, OrderStateConvert}, + "order_by": "desc", + } + + if options.GroupID > 0 { + payload["group_id"] = options.GroupID + } + + req, err := s.client.newAuthenticatedRequest("GET", "v2/orders", payload) + if err != nil { + return nil, err + } + + response, err := s.client.sendRequest(req) + if err != nil { + return nil, err + } + + var orders []Order + if err := response.DecodeJSON(&orders); err != nil { + return nil, err + } + + return orders, nil } // All returns all orders for the authenticated account. @@ -154,22 +192,10 @@ func (s *OrderService) Cancel(orderID uint64, clientOrderID string) error { } type OrderCancelRequestParams struct { - ID uint64 `json:"id"` - ClientOrderID string `json:"client_oid"` -} + *PrivateRequestParams -func (p OrderCancelRequestParams) Map() map[string]interface{} { - payload := make(map[string]interface{}) - - if p.ID > 0 { - payload["id"] = p.ID - } - - if len(p.ClientOrderID) > 0 { - payload["client_oid"] = p.ClientOrderID - } - - return payload + ID uint64 `json:"id,omitempty"` + ClientOrderID string `json:"client_oid,omitempty"` } type OrderCancelRequest struct { @@ -189,13 +215,21 @@ func (r *OrderCancelRequest) ClientOrderID(id string) *OrderCancelRequest { } func (r *OrderCancelRequest) Do(ctx context.Context) error { - payload := r.params.Map() - req, err := r.client.newAuthenticatedRequest("POST", "v2/order/delete", payload) + req, err := r.client.newAuthenticatedRequest("POST", "v2/order/delete", &r.params) if err != nil { return err } - _, err = r.client.sendRequest(req) + response, err := r.client.sendRequest(req) + if err != nil { + return err + } + + var order = Order{} + if err := response.DecodeJSON(&order); err != nil { + return err + } + return err } @@ -230,7 +264,7 @@ func (s *OrderService) Get(orderID uint64) (*Order, error) { } type MultiOrderRequestParams struct { - PrivateRequestParams + *PrivateRequestParams Market string `json:"market"` Orders []Order `json:"orders"` @@ -281,12 +315,12 @@ func (s *OrderService) NewCreateMultiOrderRequest() *CreateMultiOrderRequest { } type CreateOrderRequestParams struct { - PrivateRequestParams + *PrivateRequestParams Market string `json:"market"` Volume string `json:"volume"` - Price string `json:"price"` - StopPrice string `json:"stop_price"` + Price string `json:"price,omitempty"` + StopPrice string `json:"stop_price,omitempty"` Side string `json:"side"` OrderType string `json:"ord_type"` ClientOrderID string `json:"client_oid,omitempty"` @@ -335,7 +369,7 @@ func (r *CreateOrderRequest) ClientOrderID(clientOrderID string) *CreateOrderReq } func (r *CreateOrderRequest) Do(ctx context.Context) (order *Order, err error) { - req, err := r.client.newAuthenticatedRequest("POST", "v2/orders", r.params) + req, err := r.client.newAuthenticatedRequest("POST", "v2/orders", &r.params) if err != nil { return order, errors.Wrapf(err, "order create error") } @@ -346,8 +380,8 @@ func (r *CreateOrderRequest) Do(ctx context.Context) (order *Order, err error) { } order = &Order{} - if errJson := response.DecodeJSON(order); errJson != nil { - return order, errJson + if err := response.DecodeJSON(order); err != nil { + return nil, err } return order, err diff --git a/pkg/exchange/max/maxapi/public_parser.go b/pkg/exchange/max/maxapi/public_parser.go index 1a487654a..b78d6ad2d 100644 --- a/pkg/exchange/max/maxapi/public_parser.go +++ b/pkg/exchange/max/maxapi/public_parser.go @@ -174,6 +174,8 @@ func (e *BookEvent) Time() time.Time { } func (e *BookEvent) OrderBook() (snapshot types.OrderBook, err error) { + snapshot.Symbol = strings.ToUpper(e.Market) + for _, bid := range e.Bids { pv, err := bid.PriceVolumePair() if err != nil { diff --git a/pkg/exchange/max/maxapi/restapi.go b/pkg/exchange/max/maxapi/restapi.go index e275d5ac5..27d14e3f8 100644 --- a/pkg/exchange/max/maxapi/restapi.go +++ b/pkg/exchange/max/maxapi/restapi.go @@ -250,7 +250,6 @@ func getPrivateRequestParamsObject(v interface{}) (*PrivateRequestParams, error) vt = vt.Elem() } - if vt.Kind() != reflect.Struct { return nil, errors.New("reflect error: given object is not a struct" + vt.Kind().String()) } diff --git a/pkg/exchange/max/stream.go b/pkg/exchange/max/stream.go index 1f02067a1..5dbfcc07a 100644 --- a/pkg/exchange/max/stream.go +++ b/pkg/exchange/max/stream.go @@ -49,6 +49,8 @@ func NewStream(key, secret string) *Stream { return } + newbook.Symbol = toGlobalSymbol(e.Market) + switch e.Event { case "snapshot": stream.EmitBookSnapshot(newbook) @@ -89,7 +91,7 @@ func NewStream(key, secret string) *Stream { } func (s *Stream) Subscribe(channel types.Channel, symbol string, options types.SubscribeOptions) { - s.websocketService.Subscribe(string(channel), symbol) + s.websocketService.Subscribe(string(channel), toLocalSymbol(symbol)) } func (s *Stream) Connect(ctx context.Context) error { diff --git a/pkg/fixedpoint/convert.go b/pkg/fixedpoint/convert.go index b7fd3e9e7..8d065288c 100644 --- a/pkg/fixedpoint/convert.go +++ b/pkg/fixedpoint/convert.go @@ -1,8 +1,11 @@ package fixedpoint import ( + "encoding/json" "math" "strconv" + + "github.com/pkg/errors" ) const DefaultPrecision = 8 @@ -39,6 +42,33 @@ func (v Value) Add(v2 Value) Value { return Value(int64(v) + int64(v2)) } +func (v *Value) UnmarshalJSON(data []byte) error { + var a interface{} + var err = json.Unmarshal(data, &a) + if err != nil { + return err + } + + switch d := a.(type) { + case float64: + *v = NewFromFloat(d) + + case float32: + *v = NewFromFloat32(d) + + case int: + *v = NewFromInt(d) + case int64: + *v = NewFromInt64(d) + + default: + return errors.Errorf("unsupported type: %T %v", d, d) + + } + + return nil +} + func NewFromString(input string) (Value, error) { v, err := strconv.ParseFloat(input, 64) if err != nil { @@ -52,6 +82,10 @@ func NewFromFloat(val float64) Value { return Value(int64(math.Round(val * DefaultPow))) } +func NewFromFloat32(val float32) Value { + return Value(int64(math.Round(float64(val) * DefaultPow))) +} + func NewFromInt(val int) Value { return Value(int64(val * DefaultPow)) } diff --git a/pkg/strategy/buyandhold/strategy.go b/pkg/strategy/buyandhold/strategy.go index a26d35783..70ed576a4 100644 --- a/pkg/strategy/buyandhold/strategy.go +++ b/pkg/strategy/buyandhold/strategy.go @@ -42,7 +42,7 @@ func (s *Strategy) SetMaxAssetQuantity(q float64) *Strategy { return s } -func (s *Strategy) Run(ctx context.Context, orderExecutor types.OrderExecutor, session *bbgo.ExchangeSession) error { +func (s *Strategy) Run(ctx context.Context, orderExecutor bbgo.OrderExecutor, session *bbgo.ExchangeSession) error { session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{Interval: s.Interval}) session.Stream.OnKLineClosed(func(kline types.KLine) { @@ -63,7 +63,7 @@ func (s *Strategy) Run(ctx context.Context, orderExecutor types.OrderExecutor, s } } - err := orderExecutor.SubmitOrder(ctx, types.SubmitOrder{ + _, err := orderExecutor.SubmitOrders(ctx, types.SubmitOrder{ Symbol: kline.Symbol, Side: types.SideTypeBuy, Type: types.OrderTypeMarket, diff --git a/pkg/strategy/skeleton/strategy.go b/pkg/strategy/skeleton/strategy.go new file mode 100644 index 000000000..ac647a8f0 --- /dev/null +++ b/pkg/strategy/skeleton/strategy.go @@ -0,0 +1,53 @@ +package skeleton + +import ( + "context" + + log "github.com/sirupsen/logrus" + + "github.com/c9s/bbgo/pkg/bbgo" + "github.com/c9s/bbgo/pkg/types" +) + +func init() { + bbgo.RegisterExchangeStrategy("skeleton", &Strategy{}) +} + +type Strategy struct { + Symbol string `json:"symbol"` +} + +func New(symbol string) *Strategy { + return &Strategy{ + Symbol: symbol, + } +} + +func (s *Strategy) Run(ctx context.Context, orderExecutor bbgo.OrderExecutor, session *bbgo.ExchangeSession) error { + session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{Interval: "1m"}) + session.Stream.OnKLineClosed(func(kline types.KLine) { + market, ok := session.Market(s.Symbol) + if !ok { + return + } + + quoteBalance, ok := session.Account.Balance(market.QuoteCurrency) + if !ok { + return + } + _ = quoteBalance + + _, err := orderExecutor.SubmitOrders(ctx, types.SubmitOrder{ + Symbol: kline.Symbol, + Side: types.SideTypeBuy, + Type: types.OrderTypeMarket, + Quantity: 0.01, + }) + + if err != nil { + log.WithError(err).Error("submit order error") + } + }) + + return nil +} diff --git a/pkg/strategy/xpuremaker/strategy.go b/pkg/strategy/xpuremaker/strategy.go new file mode 100644 index 000000000..f9506f4a5 --- /dev/null +++ b/pkg/strategy/xpuremaker/strategy.go @@ -0,0 +1,182 @@ +package xpuremaker + +import ( + "context" + "math" + "time" + + log "github.com/sirupsen/logrus" + + "github.com/c9s/bbgo/pkg/bbgo" + "github.com/c9s/bbgo/pkg/fixedpoint" + "github.com/c9s/bbgo/pkg/types" +) + +func init() { + bbgo.RegisterExchangeStrategy("xpuremaker", &Strategy{}) +} + +type Strategy struct { + Symbol string `json:"symbol"` + Side string `json:"side"` + NumOrders int `json:"numOrders"` + BehindVolume fixedpoint.Value `json:"behindVolume"` + PriceTick fixedpoint.Value `json:"priceTick"` + BaseQuantity fixedpoint.Value `json:"baseQuantity"` + BuySellRatio float64 `json:"buySellRatio"` + + book *types.StreamOrderBook + activeOrders map[string]types.Order +} + +func (s *Strategy) Run(ctx context.Context, orderExecutor bbgo.OrderExecutor, session *bbgo.ExchangeSession) error { + session.Subscribe(types.BookChannel, s.Symbol, types.SubscribeOptions{}) + + s.book = types.NewStreamBook(s.Symbol) + s.book.BindStream(session.Stream) + + s.activeOrders = make(map[string]types.Order) + + // We can move the go routine to the parent level. + go func() { + ticker := time.NewTicker(1 * time.Minute) + defer ticker.Stop() + + s.update(orderExecutor, session) + + for { + select { + case <-ctx.Done(): + return + + case <-s.book.C: + s.update(orderExecutor, session) + + case <-ticker.C: + s.update(orderExecutor, session) + } + } + }() + + return nil +} + +func (s *Strategy) cancelOrders(session *bbgo.ExchangeSession) { + var deletedIDs []string + for clientOrderID, o := range s.activeOrders { + log.Infof("canceling order: %+v", o) + + if err := session.Exchange.CancelOrders(context.Background(), o); err != nil { + log.WithError(err).Error("cancel order error") + continue + } + + deletedIDs = append(deletedIDs, clientOrderID) + } + s.book.C.Drain(1*time.Second, 3*time.Second) + + for _, id := range deletedIDs { + delete(s.activeOrders, id) + } +} + +func (s *Strategy) update(orderExecutor bbgo.OrderExecutor, session *bbgo.ExchangeSession) { + s.cancelOrders(session) + + switch s.Side { + case "buy": + s.updateOrders(orderExecutor, session, types.SideTypeBuy) + case "sell": + s.updateOrders(orderExecutor, session, types.SideTypeSell) + case "both": + s.updateOrders(orderExecutor, session, types.SideTypeBuy) + s.updateOrders(orderExecutor, session, types.SideTypeSell) + + default: + log.Panicf("undefined side: %s", s.Side) + } + + s.book.C.Drain(1*time.Second, 3*time.Second) +} + +func (s *Strategy) updateOrders(orderExecutor bbgo.OrderExecutor, session *bbgo.ExchangeSession, side types.SideType) { + var book = s.book.Copy() + var pvs = book.PriceVolumesBySide(side) + if pvs == nil || len(pvs) == 0 { + log.Warnf("empty side: %s", side) + return + } + + log.Infof("placing order behind volume: %f", s.BehindVolume.Float64()) + + idx := pvs.IndexByVolumeDepth(s.BehindVolume) + if idx == -1 || idx > len(pvs)-1 { + // do not place orders + log.Warn("depth is not enough") + return + } + + var depthPrice = pvs[idx].Price + var orders = s.generateOrders(s.Symbol, side, depthPrice, s.PriceTick, s.BaseQuantity, s.NumOrders) + if len(orders) == 0 { + log.Warn("empty orders") + return + } + + createdOrders, err := orderExecutor.SubmitOrders(context.Background(), orders...) + if err != nil { + log.WithError(err).Errorf("order submit error") + return + } + + // add created orders to the list + for i, o := range createdOrders { + s.activeOrders[o.ClientOrderID] = createdOrders[i] + } +} + +func (s *Strategy) generateOrders(symbol string, side types.SideType, price, priceTick, baseQuantity fixedpoint.Value, numOrders int) (orders []types.SubmitOrder) { + var expBase = fixedpoint.NewFromFloat(0.0) + + switch side { + case types.SideTypeBuy: + if priceTick > 0 { + priceTick = -priceTick + } + + case types.SideTypeSell: + if priceTick < 0 { + priceTick = -priceTick + } + } + + for i := 0; i < numOrders; i++ { + volume := math.Exp(expBase.Float64()) * baseQuantity.Float64() + + // skip order less than 10usd + if volume*price.Float64() < 10.0 { + log.Warnf("amount too small (< 10usd). price=%f volume=%f amount=%f", price.Float64(), volume, volume*price.Float64()) + continue + } + + orders = append(orders, types.SubmitOrder{ + Symbol: symbol, + Side: side, + Type: types.OrderTypeLimit, + Price: price.Float64(), + Quantity: volume, + }) + + log.Infof("%s order: %.2f @ %f", side, volume, price.Float64()) + + if len(orders) >= numOrders { + break + } + + price = price + priceTick + declog := math.Log10(math.Abs(priceTick.Float64())) + expBase += fixedpoint.NewFromFloat(math.Pow10(-int(declog)) * math.Abs(priceTick.Float64())) + } + + return orders +} diff --git a/pkg/types/exchange.go b/pkg/types/exchange.go index b85e409c6..dc9cb7761 100644 --- a/pkg/types/exchange.go +++ b/pkg/types/exchange.go @@ -54,7 +54,11 @@ type Exchange interface { QueryWithdrawHistory(ctx context.Context, asset string, since, until time.Time) (allWithdraws []Withdraw, err error) - SubmitOrder(ctx context.Context, order SubmitOrder) error + SubmitOrders(ctx context.Context, orders ...SubmitOrder) (createdOrders []Order, err error) + + QueryOpenOrders(ctx context.Context, symbol string) (orders []Order, err error) + + CancelOrders(ctx context.Context, orders ...Order) error } type TradeQueryOptions struct { diff --git a/pkg/types/market.go b/pkg/types/market.go index 4af98d6ec..437a07235 100644 --- a/pkg/types/market.go +++ b/pkg/types/market.go @@ -26,8 +26,7 @@ type Market struct { TickSize float64 } -func (m Market) FormatPrice(val float64) string { - +func (m Market) FormatPriceCurrency(val float64) string { switch m.QuoteCurrency { case "USD", "USDT": @@ -41,10 +40,19 @@ func (m Market) FormatPrice(val float64) string { } + return m.FormatPrice(val) +} + +func (m Market) FormatPrice(val float64) string { + + p := math.Pow10(m.PricePrecision) + val = math.Trunc(val*p) / p return strconv.FormatFloat(val, 'f', m.PricePrecision, 64) } func (m Market) FormatVolume(val float64) string { + p := math.Pow10(m.PricePrecision) + val = math.Trunc(val*p) / p return strconv.FormatFloat(val, 'f', m.VolumePrecision, 64) } diff --git a/pkg/types/order.go b/pkg/types/order.go index 4b6033a7e..859a21ac8 100644 --- a/pkg/types/order.go +++ b/pkg/types/order.go @@ -1,7 +1,8 @@ package types import ( - "github.com/adshao/go-binance" + "time" + "github.com/slack-go/slack" ) @@ -9,23 +10,50 @@ import ( type OrderType string const ( - OrderTypeLimit OrderType = "LIMIT" - OrderTypeMarket OrderType = "MARKET" + OrderTypeLimit OrderType = "LIMIT" + OrderTypeMarket OrderType = "MARKET" + OrderTypeStopLimit OrderType = "STOP_LIMIT" + OrderTypeStopMarket OrderType = "STOP_MARKET" ) +type OrderStatus string + +const ( + OrderStatusNew OrderStatus = "NEW" + OrderStatusFilled OrderStatus = "FILLED" + OrderStatusPartiallyFilled OrderStatus = "PARTIALLY_FILLED" + OrderStatusCanceled OrderStatus = "CANCELED" + OrderStatusRejected OrderStatus = "REJECTED" +) + +type Order struct { + SubmitOrder + + OrderID uint64 `json:"orderID"` // order id + Status OrderStatus `json:"status"` + ExecutedQuantity float64 `json:"executedQuantity"` + CreationTime time.Time +} + type SubmitOrder struct { - Symbol string - Side SideType - Type OrderType + ClientOrderID string `json:"clientOrderID"` + + Symbol string + Side SideType + Type OrderType + Quantity float64 Price float64 + StopPrice float64 Market Market + // TODO: we can probably remove these field + StopPriceString string PriceString string QuantityString string - TimeInForce binance.TimeInForceType + TimeInForce string `json:"timeInForce"` // GTC, IOC, FOK } func (o *SubmitOrder) SlackAttachment() slack.Attachment { diff --git a/pkg/types/orderbook.go b/pkg/types/orderbook.go index 51cd1e3ff..6f47bf19e 100644 --- a/pkg/types/orderbook.go +++ b/pkg/types/orderbook.go @@ -116,6 +116,19 @@ type OrderBook struct { asksChangeCallbacks []func(pvs PriceVolumeSlice) } +func (b *OrderBook) PriceVolumesBySide(side SideType) PriceVolumeSlice { + switch side { + + case SideTypeBuy: + return b.Bids + + case SideTypeSell: + return b.Asks + } + + return nil +} + func (b *OrderBook) Copy() (book OrderBook) { book = *b book.Bids = b.Bids.Copy() diff --git a/pkg/types/side.go b/pkg/types/side.go index 7fd24785b..8d75f4893 100644 --- a/pkg/types/side.go +++ b/pkg/types/side.go @@ -6,12 +6,14 @@ type SideType string const ( SideTypeBuy = SideType("BUY") SideTypeSell = SideType("SELL") + SideTypeSelf = SideType("SELF") ) func (side SideType) Color() string { if side == SideTypeBuy { return Green } + if side == SideTypeSell { return Red } diff --git a/pkg/types/trade.go b/pkg/types/trade.go index 7637dbccf..f49a216d6 100644 --- a/pkg/types/trade.go +++ b/pkg/types/trade.go @@ -21,7 +21,7 @@ type Trade struct { QuoteQuantity float64 `json:"quoteQuantity" db:"quote_quantity"` Symbol string `json:"symbol" db:"symbol"` - Side string `json:"side" db:"side"` + Side SideType `json:"side" db:"side"` IsBuyer bool `json:"isBuyer" db:"is_buyer"` IsMaker bool `json:"isMaker" db:"is_maker"` Time time.Time `json:"tradedAt" db:"traded_at"` diff --git a/pkg/types/trader.go b/pkg/types/trader.go deleted file mode 100644 index 933941db1..000000000 --- a/pkg/types/trader.go +++ /dev/null @@ -1,12 +0,0 @@ -package types - -import "context" - -type OrderExecutor interface { - SubmitOrder(ctx context.Context, order SubmitOrder) error -} - -type OrderExecutionRouter interface { - // SubmitOrderTo submit order to a specific exchange session - SubmitOrderTo(ctx context.Context, session string, order SubmitOrder) error -}