Files
nofx/trader/bybit/trader.go
tinkle-community 9ea9bd705f fix(trader): harden API calls with timeouts, strict balance parsing, error context
- binance/bybit/gate: SDK default http.DefaultClient has no timeout; use a
  dedicated 30s-timeout client so a hung connection cannot stall the loop
- bybit: stop mutating http.DefaultClient.Transport, which leaked the
  referer header into every other HTTP request in the process
- add types.ParseFloatField: empty exchange fields stay zero, but malformed
  numeric values now surface as errors instead of silently becoming zero
  balances (applied to GetBalance across 8 exchanges)
- wrap order/market-data errors in auto_trader_orders and okx cancel paths
  with symbol context; log per-order cancel failures in okx CancelAllOrders
2026-06-11 00:30:34 +08:00

202 lines
4.9 KiB
Go

package bybit
import (
"encoding/json"
"fmt"
"io"
"math"
"net/http"
"nofx/logger"
"strconv"
"strings"
"sync"
"time"
bybit "github.com/bybit-exchange/bybit.go.api"
)
// BybitTrader Bybit USDT Perpetual Futures Trader
type BybitTrader struct {
client *bybit.Client
apiKey string
secretKey string
// Balance cache
cachedBalance map[string]interface{}
balanceCacheTime time.Time
balanceCacheMutex sync.RWMutex
// Position cache
cachedPositions []map[string]interface{}
positionsCacheTime time.Time
positionsCacheMutex sync.RWMutex
// Trading pair precision cache (symbol -> qtyStep)
qtyStepCache map[string]float64
qtyStepCacheMutex sync.RWMutex
// Cache duration (15 seconds)
cacheDuration time.Duration
}
// NewBybitTrader creates a Bybit trader
func NewBybitTrader(apiKey, secretKey string) *BybitTrader {
const src = "Up000938"
client := bybit.NewBybitHttpClient(apiKey, secretKey, bybit.WithBaseURL(bybit.MAINNET))
// Set HTTP transport. Use a dedicated client instead of mutating the
// SDK default (http.DefaultClient): mutating it would leak the referer
// header to every other request in the process, and the default client
// has no timeout, so a hung connection would stall the trading loop.
if client != nil {
defaultTransport := http.DefaultTransport
if client.HTTPClient != nil && client.HTTPClient != http.DefaultClient && client.HTTPClient.Transport != nil {
defaultTransport = client.HTTPClient.Transport
}
client.HTTPClient = &http.Client{
Timeout: 30 * time.Second,
Transport: &headerRoundTripper{
base: defaultTransport,
refererID: src,
},
}
}
trader := &BybitTrader{
client: client,
apiKey: apiKey,
secretKey: secretKey,
cacheDuration: 15 * time.Second,
qtyStepCache: make(map[string]float64),
}
logger.Infof("🔵 [Bybit] Trader initialized")
return trader
}
// headerRoundTripper HTTP RoundTripper for adding custom headers
type headerRoundTripper struct {
base http.RoundTripper
refererID string
}
func (h *headerRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
req.Header.Set("Referer", h.refererID)
return h.base.RoundTrip(req)
}
// getQtyStep retrieves the quantity step for a trading pair
func (t *BybitTrader) getQtyStep(symbol string) float64 {
// Check cache first
t.qtyStepCacheMutex.RLock()
if step, ok := t.qtyStepCache[symbol]; ok {
t.qtyStepCacheMutex.RUnlock()
return step
}
t.qtyStepCacheMutex.RUnlock()
// Call public API directly to get contract information
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] Failed to get precision info for %s: %v", symbol, err)
return 1 // Default to integer
}
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
}
// Cache result
t.qtyStepCacheMutex.Lock()
t.qtyStepCache[symbol] = qtyStep
t.qtyStepCacheMutex.Unlock()
logger.Infof("🔵 [Bybit] %s qtyStep: %v", symbol, qtyStep)
return qtyStep
}
// FormatQuantity formats quantity
func (t *BybitTrader) FormatQuantity(symbol string, quantity float64) (string, error) {
// Get qtyStep for this symbol
qtyStep := t.getQtyStep(symbol)
// Align quantity according to qtyStep (round down to nearest step)
alignedQty := math.Floor(quantity/qtyStep) * qtyStep
// Calculate required decimal places
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
format := fmt.Sprintf("%%.%df", decimals)
formatted := fmt.Sprintf(format, alignedQty)
return formatted, nil
}
// Helper methods
func (t *BybitTrader) clearCache() {
t.balanceCacheMutex.Lock()
t.cachedBalance = nil
t.balanceCacheMutex.Unlock()
t.positionsCacheMutex.Lock()
t.cachedPositions = nil
t.positionsCacheMutex.Unlock()
}
func (t *BybitTrader) parseOrderResult(result *bybit.ServerResponse) (map[string]interface{}, error) {
if result.RetCode != 0 {
return nil, fmt.Errorf("order placement failed: %s", result.RetMsg)
}
resultData, ok := result.Result.(map[string]interface{})
if !ok {
return nil, fmt.Errorf("return format error")
}
orderId, _ := resultData["orderId"].(string)
return map[string]interface{}{
"orderId": orderId,
"status": "NEW",
}, nil
}