diff --git a/README.md b/README.md index 9d71af6..85e9a12 100644 --- a/README.md +++ b/README.md @@ -142,9 +142,10 @@ Twitter [@rarirureluis](https://twitter.com/rarirureluis) TODO ----- -- [x] [core/bybit] USDC/Perp -- [x] [core/bybit] USDT/Deriv -- [x] [cron/wallet/bybit] USDC -- [ ] [cron/wallet/bybit] Spot -- [ ] [cron/wallet/bybit] Futures +**There are no plans to support bybit/options/spot trading and other DEX.** + +- [x] [bybit/core] Futures +- [x] [bybit/core] USDC +- [x] [bybit/wallet] Deriv +- [x] [bybit/wallet] USDC - [ ] [core/binance] diff --git a/pkg/adapter/gateway/bybit_repository.go b/pkg/adapter/gateway/bybit_repository.go index 2d0dc3f..4128a0f 100644 --- a/pkg/adapter/gateway/bybit_repository.go +++ b/pkg/adapter/gateway/bybit_repository.go @@ -10,6 +10,8 @@ import ( "strings" "time" + "github.com/shopspring/decimal" + "github.com/frankrap/bybit-api/rest" "github.com/rluisr/tvbit-bot/pkg/domain" "github.com/rluisr/tvbit-bot/pkg/external/bybit" @@ -31,89 +33,56 @@ func (r *BybitRepository) Set(req domain.TV) { r.APISecretKey = req.APISecretKey } -func (r *BybitRepository) CreateOrder(req domain.TV) (*domain.TVOrder, error) { - orderHistory := &domain.TVOrder{ - Type: req.Order.Type, - Symbol: req.Order.Symbol, - Side: req.Order.Side, - QTY: req.Order.QTY, - TP: req.Order.TP, - SL: req.Order.SL, - } - - if !strings.Contains(req.Order.Symbol, "PERP") { - _, _, order, err := r.Client.LinearCreateOrder(req.Order.Side, req.Order.Type, req.Order.Price, req.Order.QTY, "ImmediateOrCancel", req.Order.TP.(float64), req.Order.SL.(float64), false, false, "", req.Order.Symbol) +func (r *BybitRepository) CreateOrder(req domain.TV) (string, error) { + var orderID string + + if strings.Contains(req.Order.Symbol, "PERP") { + params := map[string]interface{}{} + params["symbol"] = req.Order.Symbol + params["orderType"] = req.Order.Type + params["orderFilter"] = "Order" + params["orderQty"] = req.Order.QTY + params["side"] = req.Order.Side + params["timeInForce"] = "ImmediateOrCancel" + params["takeProfit"] = strconv.FormatFloat(req.Order.TP.(float64), 'f', -1, 64) + params["stopLoss"] = strconv.FormatFloat(req.Order.SL.(float64), 'f', -1, 64) + + var order domain.BybitPerpResponseOrder + _, resp, err := r.Client.SignedRequest(http.MethodPost, "perpetual/usdc/openapi/private/v1/place-order", params, &order) if err != nil { - return nil, err + return "", fmt.Errorf("SignedRequest err: %w, body: %s", err, string(resp)) } - - entryPrice, err := order.Price.Float64() + orderID = order.Result.OrderID + } else { + _, _, order, err := r.Client.LinearCreateOrder(req.Order.Side, req.Order.Type, req.Order.Price, req.Order.QTY, "ImmediateOrCancel", req.Order.TP.(float64), req.Order.SL.(float64), false, false, "", req.Order.Symbol) if err != nil { - return nil, err + return "", err } - orderHistory.OrderID = order.OrderId - orderHistory.Price = entryPrice - return orderHistory, nil - } - - params := map[string]interface{}{} - params["symbol"] = req.Order.Symbol - params["orderType"] = req.Order.Type - params["orderFilter"] = "Order" - params["orderQty"] = req.Order.QTY - params["side"] = req.Order.Side - params["timeInForce"] = "ImmediateOrCancel" - params["takeProfit"] = strconv.FormatFloat(req.Order.TP.(float64), 'f', -1, 64) - params["stopLoss"] = strconv.FormatFloat(req.Order.SL.(float64), 'f', -1, 64) - - var order domain.BybitPerpResponseOrder - _, resp, err := r.Client.SignedRequest(http.MethodPost, "perpetual/usdc/openapi/private/v1/place-order", params, &order) - if err != nil { - return nil, fmt.Errorf("SignedRequest err: %w, body: %s", err, string(resp)) - } - - orderPrice, err := strconv.ParseFloat(order.Result.OrderPrice, 64) - if err != nil { - // bybit returns fucking error response sometimes but order is success. - return nil, err + orderID = order.OrderId } - orderHistory.OrderID = order.Result.OrderID - orderHistory.Price = orderPrice - return orderHistory, nil + return orderID, nil } -// GetCurrentPrice returns mark price -func (r *BybitRepository) GetCurrentPrice(symbol string) (float64, error) { - var isPerp bool - - var tickersURL string - if strings.Contains(symbol, "PERP") { - isPerp = true - tickersURL = fmt.Sprintf("perpetual/usdc/openapi/public/v1/tick?symbol=%s", symbol) - } else { - tickersURL = fmt.Sprintf("derivatives/v3/public/tickers?category=linear&symbol=%s", symbol) +// FetchOrder is set entry price +func (r *BybitRepository) FetchOrder(req *domain.TV, orderID string) error { + req.Order = domain.TVOrder{ + OrderID: orderID, + Type: req.Order.Type, + Symbol: req.Order.Symbol, + Side: req.Order.Side, + QTY: req.Order.QTY, + TP: req.Order.TP, + SL: req.Order.SL, } - var markPriceStr string - - if !isPerp { - var ticker domain.BybitDerivTicker - _, resp, err := r.Client.PublicRequest(http.MethodGet, tickersURL, nil, &ticker) - if err != nil { - return 0, fmt.Errorf("PublicRequest err: %w, body: %s", err, string(resp)) - } - markPriceStr = ticker.Result.List[0].MarkPrice - } else { - var ticker domain.BybitPerpTicker - _, resp, err := r.Client.PublicRequest(http.MethodGet, tickersURL, nil, &ticker) - if err != nil { - return 0, fmt.Errorf("PublicRequest err: %w, body: %s", err, string(resp)) - } - markPriceStr = ticker.Result.MarkPrice + entryPrice, err := r.getEntryPrice(req.Order.Symbol, req.Order.Side) + if err != nil { + return err } + req.Order.EntryPrice = *entryPrice - return strconv.ParseFloat(markPriceStr, 64) + return nil } func (r *BybitRepository) CalculateTPSL(req domain.TV, value interface{}, isType string) (float64, error) { @@ -128,7 +97,7 @@ func (r *BybitRepository) CalculateTPSL(req domain.TV, value interface{}, isType return 0, nil } - currentPrice, err := r.GetCurrentPrice(req.Order.Symbol) + currentPrice, err := r.getMarkPrice(req.Order.Symbol) if err != nil { return 0, err } @@ -196,22 +165,107 @@ func (r *BybitRepository) CalculateTPSL(req domain.TV, value interface{}, isType return f64, nil } +// getMarkPrice returns mark price +func (r *BybitRepository) getMarkPrice(symbol string) (float64, error) { + var isPerp bool + + var tickersURL string + if strings.Contains(symbol, "PERP") { + isPerp = true + tickersURL = fmt.Sprintf("perpetual/usdc/openapi/public/v1/tick?symbol=%s", symbol) + } else { + tickersURL = fmt.Sprintf("derivatives/v3/public/tickers?category=linear&symbol=%s", symbol) + } + + var markPriceStr string + + if !isPerp { + var ticker domain.BybitDerivTicker + _, resp, err := r.Client.PublicRequest(http.MethodGet, tickersURL, nil, &ticker) + if err != nil { + return 0, fmt.Errorf("PublicRequest err: %w, body: %s", err, string(resp)) + } + markPriceStr = ticker.Result.List[0].MarkPrice + } else { + var ticker domain.BybitPerpTicker + _, resp, err := r.Client.PublicRequest(http.MethodGet, tickersURL, nil, &ticker) + if err != nil { + return 0, fmt.Errorf("PublicRequest err: %w, body: %s", err, string(resp)) + } + markPriceStr = ticker.Result.MarkPrice + } + + return strconv.ParseFloat(markPriceStr, 64) +} + +func (r *BybitRepository) getEntryPrice(symbol, side string) (*decimal.Decimal, error) { + var entryPrice decimal.Decimal + var err error + + if strings.Contains(symbol, "PERP") { + var positions domain.BybitUSDCPositions + + resp, err := r.signedRequestWithHeader(http.MethodPost, "option/usdc/openapi/private/v1/query-position", []byte("{\"category\":\"PERPETUAL\"}"), &positions) + if err != nil { + return nil, fmt.Errorf("signedRequest err: %w, body: %s", err, resp) + } + + for _, position := range positions.Result.DataList { + fmt.Printf("%+v\n", position) + entryPrice, err = decimal.NewFromString(position.EntryPrice) + if err != nil { + return nil, err + } + } + } else { + _, _, positions, err := r.Client.LinearGetPosition(symbol) + if err != nil { + return nil, err + } + + for _, position := range positions { + if position.Side == side { + entryPrice = decimal.NewFromFloat(position.EntryPrice) + } + } + } + + return &entryPrice, err +} + func (r *BybitRepository) GetWalletInfoUSDC() (*domain.BybitWallet, error) { var wallet domain.BybitWallet - resp, err := r.signedRequestWithHeaderWithoutBody(http.MethodPost, "option/usdc/openapi/private/v1/query-wallet-balance", &wallet) + resp, err := r.signedRequestWithHeader(http.MethodPost, "option/usdc/openapi/private/v1/query-wallet-balance", []byte("{}"), &wallet) if err != nil { - return nil, fmt.Errorf("signedRequestWithHeaderWithoutBody err: %w, body: %s", err, string(resp)) + return nil, fmt.Errorf("signedRequest err: %w, body: %s", err, resp) } return &wallet, nil } -func (r *BybitRepository) signedRequestWithHeaderWithoutBody(method, path string, result interface{}) (string, error) { +func (r *BybitRepository) GetWalletInfoDeriv() (*rest.Balance, error) { + _, _, wallet, err := r.Client.GetWalletBalance("USDT") + if err != nil { + return nil, err + } + + return &wallet, nil +} + +func (r *BybitRepository) signedRequestWithHeader(method, path string, body []byte, result interface{}) (string, error) { + var payload io.Reader + nowTimeInMilli := strconv.FormatInt(time.Now().UnixMilli(), 10) - var hmacSigned []byte - payload := strings.NewReader(`{}`) - signInput := nowTimeInMilli + r.APIKey + "5000" + "{}" + + if body == nil { + // TODO have to using query params. it's not working when query params are passed. + payload = strings.NewReader("") + } else { + payload = strings.NewReader(string(body)) + } + + signInput := nowTimeInMilli + r.APIKey + "5000" + string(body) hmacSigned, err := crypto.GetHMAC(crypto.HashSHA256, []byte(signInput), []byte(r.APISecretKey)) if err != nil { return "", err diff --git a/pkg/domain/bybit.go b/pkg/domain/bybit.go index 5d5122b..bca36ca 100644 --- a/pkg/domain/bybit.go +++ b/pkg/domain/bybit.go @@ -96,6 +96,44 @@ type BybitPerpTicker struct { } `json:"result"` } +type BybitUSDCPositions struct { + Result struct { + Cursor string `json:"cursor"` + ResultTotalSize int `json:"resultTotalSize"` + DataList []struct { + Symbol string `json:"symbol"` + Leverage string `json:"leverage"` + OccClosingFee string `json:"occClosingFee"` + LiqPrice string `json:"liqPrice"` + PositionValue string `json:"positionValue"` + TakeProfit string `json:"takeProfit"` + RiskID string `json:"riskId"` + TrailingStop string `json:"trailingStop"` + UnrealisedPnl string `json:"unrealisedPnl"` + CreatedAt string `json:"createdAt"` + MarkPrice string `json:"markPrice"` + CumRealisedPnl string `json:"cumRealisedPnl"` + PositionMM string `json:"positionMM"` + PositionIM string `json:"positionIM"` + UpdatedAt string `json:"updatedAt"` + TpSLMode string `json:"tpSLMode"` + Side string `json:"side"` + BustPrice string `json:"bustPrice"` + DeleverageIndicator int `json:"deleverageIndicator"` + EntryPrice string `json:"entryPrice"` + Size string `json:"size"` + SessionRPL string `json:"sessionRPL"` + PositionStatus string `json:"positionStatus"` + SessionUPL string `json:"sessionUPL"` + StopLoss string `json:"stopLoss"` + OrderMargin string `json:"orderMargin"` + SessionAvgPrice string `json:"sessionAvgPrice"` + } `json:"dataList"` + } `json:"result"` + RetCode int `json:"retCode"` + RetMsg string `json:"retMsg"` +} + type BybitWallet struct { Result struct { WalletBalance string `json:"walletBalance"` diff --git a/pkg/domain/tv.go b/pkg/domain/tv.go index b397834..d7e7205 100644 --- a/pkg/domain/tv.go +++ b/pkg/domain/tv.go @@ -19,6 +19,7 @@ along with this program. If not, see . package domain import ( + "github.com/shopspring/decimal" "gorm.io/gorm" ) @@ -31,16 +32,17 @@ type TV struct { type TVOrder struct { gorm.Model - DEX string `gorm:"type:varchar(255);not null" json:"-"` - SettingID uint64 `gorm:"type:uint;not null" json:"-"` - OrderID string `gorm:"type:varchar(255);uniqueIndex:order_id;not null"` - Type string `gorm:"type:varchar(255)" json:"type" binding:"required"` // "Market" or "Limit" - Symbol string `gorm:"type:varchar(255)" json:"symbol" binding:"required"` // eg: BTCUSDT - Side string `gorm:"type:varchar(255)" json:"side" binding:"required"` // "Buy" or "Sell" - Price float64 `gorm:"type:float" json:"price"` // Set 0 if order_type is Market - QTY float64 `gorm:"type:float" json:"qty" binding:"required"` - TP interface{} `gorm:"type:float" json:"tp"` - SL interface{} `gorm:"type:float" json:"sl"` + DEX string `gorm:"type:varchar(255);not null" json:"-"` + SettingID uint64 `gorm:"type:uint;not null" json:"-"` + OrderID string `gorm:"type:varchar(255);uniqueIndex:order_id;not null"` + Type string `gorm:"type:varchar(255)" json:"type" binding:"required"` // "Market" or "Limit" + Symbol string `gorm:"type:varchar(255)" json:"symbol" binding:"required"` // eg: BTCUSDT + Side string `gorm:"type:varchar(255)" json:"side" binding:"required"` // "Buy" or "Sell" + Price float64 `gorm:"-" json:"price"` // Set 0 if order_type is Market + EntryPrice decimal.Decimal `gorm:"type:decimal(10,4)" json:"-"` + QTY float64 `gorm:"type:float" json:"qty" binding:"required"` + TP interface{} `gorm:"type:float" json:"tp"` + SL interface{} `gorm:"type:float" json:"sl"` } type Tabler interface { diff --git a/pkg/external/cron.go b/pkg/external/cron.go index 0bf068e..2aba1c0 100644 --- a/pkg/external/cron.go +++ b/pkg/external/cron.go @@ -35,7 +35,7 @@ func cron() { Verbose: true, }) - task.Task("0 * * * *", func(ctx context.Context) (int, error) { + task.Task("* * * * *", func(ctx context.Context) (int, error) { settings, err := tvController.Interactor.TVRepository.GetSettings() if err != nil { return 0, err @@ -53,16 +53,16 @@ func cron() { }) // USDC - bybitWallet, err := tvController.Interactor.BybitRepository.GetWalletInfoUSDC() + bybitUSDCWallet, err := tvController.Interactor.BybitRepository.GetWalletInfoUSDC() if err != nil { return 1, err } - balance, err := decimal.NewFromString(bybitWallet.Result.WalletBalance) + balance, err := decimal.NewFromString(bybitUSDCWallet.Result.WalletBalance) if err != nil { return 1, err } - totalRPL, err := decimal.NewFromString(bybitWallet.Result.TotalRPL) + totalRPL, err := decimal.NewFromString(bybitUSDCWallet.Result.TotalRPL) if err != nil { return 1, err } @@ -73,8 +73,22 @@ func cron() { Balance: balance, TotalRPL: totalRPL, }) - // TODO spot - // TODO futures + + // Deriv USDT + bybitDerivWallet, err := tvController.Interactor.BybitRepository.GetWalletInfoDeriv() + if err != nil { + return 1, err + } + + balance = decimal.NewFromFloat(bybitDerivWallet.Equity) + totalRPL = decimal.NewFromFloat(bybitDerivWallet.CumRealisedPnl) + + walletHistories = append(walletHistories, domain.WalletHistory{ + SettingID: setting.ID, + Type: "usdt", + Balance: balance, + TotalRPL: totalRPL, + }) } } diff --git a/pkg/usecase/interfaces/repositories.go b/pkg/usecase/interfaces/repositories.go index a5fac7b..bbd1955 100644 --- a/pkg/usecase/interfaces/repositories.go +++ b/pkg/usecase/interfaces/repositories.go @@ -19,6 +19,7 @@ along with this program. If not, see . package interfaces import ( + "github.com/frankrap/bybit-api/rest" "github.com/rluisr/tvbit-bot/pkg/domain" ) @@ -31,10 +32,11 @@ type TVRepository interface { type BybitRepository interface { Set(domain.TV) - CreateOrder(domain.TV) (*domain.TVOrder, error) - GetCurrentPrice(string) (float64, error) + CreateOrder(domain.TV) (string, error) CalculateTPSL(domain.TV, interface{}, string) (float64, error) + FetchOrder(req *domain.TV, orderID string) error GetWalletInfoUSDC() (*domain.BybitWallet, error) + GetWalletInfoDeriv() (*rest.Balance, error) } type SettingRepository interface { diff --git a/pkg/usecase/tv_interactor.go b/pkg/usecase/tv_interactor.go index 7cbe49b..f5b79a0 100644 --- a/pkg/usecase/tv_interactor.go +++ b/pkg/usecase/tv_interactor.go @@ -19,7 +19,7 @@ along with this program. If not, see . package usecase import ( - "log" + "time" "github.com/rluisr/tvbit-bot/pkg/domain" "github.com/rluisr/tvbit-bot/pkg/usecase/interfaces" @@ -67,7 +67,7 @@ func (i *TVInteractor) CreateOrder(req domain.TV) (domain.TVOrderResponse, error }, nil } - order, err := i.BybitRepository.CreateOrder(req) + orderID, err := i.BybitRepository.CreateOrder(req) if err != nil { return domain.TVOrderResponse{ Success: false, @@ -76,15 +76,26 @@ func (i *TVInteractor) CreateOrder(req domain.TV) (domain.TVOrderResponse, error }, err } - if order == nil { - return domain.TVOrderResponse{Success: true, Reason: "order is cancelled by \"start_time\", \"stop_time\" setting", Order: nil}, nil + // Wait for ByBit server side reflection + time.Sleep(5 * time.Second) + + err = i.BybitRepository.FetchOrder(&req, orderID) + if err != nil { + return domain.TVOrderResponse{ + Success: false, + Reason: err.Error(), + Order: &req.Order, + }, err } - err = i.TVRepository.SaveOrder(req, order) + err = i.TVRepository.SaveOrder(req, &req.Order) if err != nil { - // order is created, do not return err here. - log.Printf("a order is created but failed to save order history to MySQL err: %s\n", err.Error()) + return domain.TVOrderResponse{ + Success: false, + Reason: err.Error(), + Order: &req.Order, + }, err } - return domain.TVOrderResponse{Success: true, Order: order}, nil + return domain.TVOrderResponse{Success: true, Order: &req.Order}, nil }