From a1211153ee89459f99b70e340de241fe293f4cd2 Mon Sep 17 00:00:00 2001 From: tinkle-community Date: Sun, 7 Dec 2025 15:17:46 +0800 Subject: [PATCH] fix: Bybit order quantity precision and position fields - Get qtyStep from Bybit API for each symbol and cache it - Format quantity based on actual qtyStep (e.g., BEATUSDT requires integers) - Add missing fields to GetPositions: markPrice, unRealizedProfit, liquidationPrice - Fix panic in buildTradingContext due to nil interface conversion --- trader/bybit_trader.go | 164 ++++++++++++++++++++++++++++++++++++----- 1 file changed, 146 insertions(+), 18 deletions(-) diff --git a/trader/bybit_trader.go b/trader/bybit_trader.go index cdebd65a..4ec9b374 100644 --- a/trader/bybit_trader.go +++ b/trader/bybit_trader.go @@ -2,7 +2,10 @@ package trader import ( "context" + "encoding/json" "fmt" + "io" + "math" "nofx/logger" "net/http" "strconv" @@ -27,6 +30,10 @@ type BybitTrader struct { positionsCacheTime time.Time positionsCacheMutex sync.RWMutex + // 交易对精度缓存 (symbol -> qtyStep) + qtyStepCache map[string]float64 + qtyStepCacheMutex sync.RWMutex + // 缓存有效期(15秒) cacheDuration time.Duration } @@ -53,6 +60,7 @@ func NewBybitTrader(apiKey, secretKey string) *BybitTrader { trader := &BybitTrader{ client: client, cacheDuration: 15 * time.Second, + qtyStepCache: make(map[string]float64), } logger.Infof("🔵 [Bybit] 交易器已初始化") @@ -104,7 +112,7 @@ func (t *BybitTrader) GetBalance() (map[string]interface{}, error) { list, _ := resultData["list"].([]interface{}) - var totalEquity, availableBalance float64 = 0, 0 + var totalEquity, availableBalance, totalWalletBalance, totalPerpUPL float64 = 0, 0, 0, 0 if len(list) > 0 { account, _ := list[0].(map[string]interface{}) @@ -114,12 +122,27 @@ func (t *BybitTrader) GetBalance() (map[string]interface{}, error) { if availStr, ok := account["totalAvailableBalance"].(string); ok { availableBalance, _ = strconv.ParseFloat(availStr, 64) } + // Bybit UNIFIED 账户的钱包余额字段 + if walletStr, ok := account["totalWalletBalance"].(string); ok { + totalWalletBalance, _ = strconv.ParseFloat(walletStr, 64) + } + // Bybit 永续合约未实现盈亏 + if uplStr, ok := account["totalPerpUPL"].(string); ok { + totalPerpUPL, _ = strconv.ParseFloat(uplStr, 64) + } + } + + // 如果没有 totalWalletBalance,使用 totalEquity + if totalWalletBalance == 0 { + totalWalletBalance = totalEquity } balance := map[string]interface{}{ - "totalEquity": totalEquity, - "availableBalance": availableBalance, - "balance": totalEquity, // 兼容其他交易所格式 + "totalEquity": totalEquity, + "totalWalletBalance": totalWalletBalance, + "availableBalance": availableBalance, + "totalUnrealizedProfit": totalPerpUPL, + "balance": totalEquity, // 兼容其他交易所格式 } // 更新缓存 @@ -189,6 +212,14 @@ func (t *BybitTrader) GetPositions() ([]map[string]interface{}, error) { leverageStr, _ := pos["leverage"].(string) leverage, _ := strconv.ParseFloat(leverageStr, 64) + // 标记价格 + markPriceStr, _ := pos["markPrice"].(string) + markPrice, _ := strconv.ParseFloat(markPriceStr, 64) + + // 强平价格 + liqPriceStr, _ := pos["liqPrice"].(string) + liqPrice, _ := strconv.ParseFloat(liqPriceStr, 64) + positionSide, _ := pos["side"].(string) // Buy = LONG, Sell = SHORT // 转换为统一格式 @@ -200,12 +231,15 @@ func (t *BybitTrader) GetPositions() ([]map[string]interface{}, error) { } position := map[string]interface{}{ - "symbol": pos["symbol"], - "side": side, - "positionAmt": positionAmt, - "entryPrice": entryPrice, - "unrealizedPnL": unrealisedPnl, - "leverage": int(leverage), + "symbol": pos["symbol"], + "side": side, + "positionAmt": positionAmt, + "entryPrice": entryPrice, + "markPrice": markPrice, + "unRealizedProfit": unrealisedPnl, + "unrealizedPnL": unrealisedPnl, + "liquidationPrice": liqPrice, + "leverage": leverage, } positions = append(positions, position) @@ -227,12 +261,15 @@ func (t *BybitTrader) OpenLong(symbol string, quantity float64, leverage int) (m logger.Infof("⚠️ [Bybit] 设置杠杆失败: %v", err) } + // 使用 FormatQuantity 格式化数量 + qtyStr, _ := t.FormatQuantity(symbol, quantity) + params := map[string]interface{}{ "category": "linear", "symbol": symbol, "side": "Buy", "orderType": "Market", - "qty": fmt.Sprintf("%v", quantity), + "qty": qtyStr, "positionIdx": 0, // 单向持仓模式 } @@ -254,12 +291,15 @@ func (t *BybitTrader) OpenShort(symbol string, quantity float64, leverage int) ( logger.Infof("⚠️ [Bybit] 设置杠杆失败: %v", err) } + // 使用 FormatQuantity 格式化数量 + qtyStr, _ := t.FormatQuantity(symbol, quantity) + params := map[string]interface{}{ "category": "linear", "symbol": symbol, "side": "Sell", "orderType": "Market", - "qty": fmt.Sprintf("%v", quantity), + "qty": qtyStr, "positionIdx": 0, // 单向持仓模式 } @@ -294,12 +334,15 @@ func (t *BybitTrader) CloseLong(symbol string, quantity float64) (map[string]int return nil, fmt.Errorf("没有多仓可平") } + // 使用 FormatQuantity 格式化数量 + qtyStr, _ := t.FormatQuantity(symbol, quantity) + params := map[string]interface{}{ "category": "linear", "symbol": symbol, "side": "Sell", // 平多用 Sell "orderType": "Market", - "qty": fmt.Sprintf("%v", quantity), + "qty": qtyStr, "positionIdx": 0, "reduceOnly": true, } @@ -335,12 +378,15 @@ func (t *BybitTrader) CloseShort(symbol string, quantity float64) (map[string]in return nil, fmt.Errorf("没有空仓可平") } + // 使用 FormatQuantity 格式化数量 + qtyStr, _ := t.FormatQuantity(symbol, quantity) + params := map[string]interface{}{ "category": "linear", "symbol": symbol, "side": "Buy", // 平空用 Buy "orderType": "Market", - "qty": fmt.Sprintf("%v", quantity), + "qty": qtyStr, "positionIdx": 0, "reduceOnly": true, } @@ -464,12 +510,15 @@ func (t *BybitTrader) SetStopLoss(symbol string, positionSide string, quantity, triggerDirection = 1 // 价格上涨触发(空单止损) } + // 使用 FormatQuantity 格式化数量 + qtyStr, _ := t.FormatQuantity(symbol, quantity) + params := map[string]interface{}{ "category": "linear", "symbol": symbol, "side": side, "orderType": "Market", - "qty": fmt.Sprintf("%v", quantity), + "qty": qtyStr, "triggerPrice": fmt.Sprintf("%v", stopPrice), "triggerDirection": triggerDirection, "triggerBy": "LastPrice", @@ -507,12 +556,15 @@ func (t *BybitTrader) SetTakeProfit(symbol string, positionSide string, quantity triggerDirection = 2 // 价格下跌触发(空单止盈) } + // 使用 FormatQuantity 格式化数量 + qtyStr, _ := t.FormatQuantity(symbol, quantity) + params := map[string]interface{}{ "category": "linear", "symbol": symbol, "side": side, "orderType": "Market", - "qty": fmt.Sprintf("%v", quantity), + "qty": qtyStr, "triggerPrice": fmt.Sprintf("%v", takeProfitPrice), "triggerDirection": triggerDirection, "triggerBy": "LastPrice", @@ -568,10 +620,86 @@ func (t *BybitTrader) CancelStopOrders(symbol string) error { return nil } +// getQtyStep 获取交易对的数量步长 +func (t *BybitTrader) getQtyStep(symbol string) float64 { + // 先检查缓存 + t.qtyStepCacheMutex.RLock() + if step, ok := t.qtyStepCache[symbol]; ok { + t.qtyStepCacheMutex.RUnlock() + return step + } + t.qtyStepCacheMutex.RUnlock() + + // 直接调用公开 API 获取合约信息 + url := fmt.Sprintf("https://api.bybit.com/v5/market/instruments-info?category=linear&symbol=%s", symbol) + resp, err := http.Get(url) + if err != nil { + logger.Infof("⚠️ [Bybit] 获取 %s 精度信息失败: %v", symbol, err) + return 1 // 默认整数 + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return 1 + } + + var result struct { + RetCode int `json:"retCode"` + Result struct { + List []struct { + LotSizeFilter struct { + QtyStep string `json:"qtyStep"` + } `json:"lotSizeFilter"` + } `json:"list"` + } `json:"result"` + } + + if err := json.Unmarshal(body, &result); err != nil { + return 1 + } + + if result.RetCode != 0 || len(result.Result.List) == 0 { + return 1 + } + + qtyStep, _ := strconv.ParseFloat(result.Result.List[0].LotSizeFilter.QtyStep, 64) + if qtyStep <= 0 { + qtyStep = 1 + } + + // 缓存结果 + t.qtyStepCacheMutex.Lock() + t.qtyStepCache[symbol] = qtyStep + t.qtyStepCacheMutex.Unlock() + + logger.Infof("🔵 [Bybit] %s qtyStep: %v", symbol, qtyStep) + + return qtyStep +} + // FormatQuantity 格式化数量 func (t *BybitTrader) FormatQuantity(symbol string, quantity float64) (string, error) { - // Bybit 通常使用 3 位小数 - return fmt.Sprintf("%.3f", quantity), nil + // 获取该币种的 qtyStep + qtyStep := t.getQtyStep(symbol) + + // 根据 qtyStep 对齐数量(向下取整到最近的 step) + alignedQty := math.Floor(quantity/qtyStep) * qtyStep + + // 计算需要的小数位数 + decimals := 0 + if qtyStep < 1 { + stepStr := strconv.FormatFloat(qtyStep, 'f', -1, 64) + if idx := strings.Index(stepStr, "."); idx >= 0 { + decimals = len(stepStr) - idx - 1 + } + } + + // 格式化 + format := fmt.Sprintf("%%.%df", decimals) + formatted := fmt.Sprintf(format, alignedQty) + + return formatted, nil } // 辅助方法