package binance import ( "context" "crypto/rand" "encoding/hex" "fmt" "net/http" "nofx/hook" "nofx/logger" "strings" "sync" "time" "github.com/adshao/go-binance/v2/futures" ) // getBrOrderID generates unique order ID (for futures contracts) // Format: x-{BR_ID}{TIMESTAMP}{RANDOM} // Futures limit is 32 characters, use this limit consistently // Uses nanosecond timestamp + random number to ensure global uniqueness (collision probability < 10^-20) func getBrOrderID() string { brID := "KzrpZaP9" // Futures br ID // Calculate available space: 32 - len("x-KzrpZaP9") = 32 - 11 = 21 characters // Allocation: 13-digit timestamp + 8-digit random = 21 characters (perfect utilization) timestamp := time.Now().UnixNano() % 10000000000000 // 13-digit nanosecond timestamp // Generate 4-byte random number (8 hex digits) randomBytes := make([]byte, 4) rand.Read(randomBytes) randomHex := hex.EncodeToString(randomBytes) // Format: x-KzrpZaP9{13-digit timestamp}{8-digit random} // Example: x-KzrpZaP91234567890123abcdef12 (exactly 31 characters) orderID := fmt.Sprintf("x-%s%d%s", brID, timestamp, randomHex) // Ensure not exceeding 32-character limit (theoretically exactly 31 characters) if len(orderID) > 32 { orderID = orderID[:32] } return orderID } // FuturesTrader Binance futures trader type FuturesTrader struct { client *futures.Client // Balance cache cachedBalance map[string]interface{} balanceCacheTime time.Time balanceCacheMutex sync.RWMutex // Position cache cachedPositions []map[string]interface{} positionsCacheTime time.Time positionsCacheMutex sync.RWMutex // Cache validity period (15 seconds) cacheDuration time.Duration } // NewFuturesTrader creates futures trader func NewFuturesTrader(apiKey, secretKey string, userId string) *FuturesTrader { client := futures.NewClient(apiKey, secretKey) // The SDK defaults to http.DefaultClient, which has no timeout — a hung // connection would stall the trading loop indefinitely. client.HTTPClient = &http.Client{Timeout: 30 * time.Second} hookRes := hook.HookExec[hook.NewBinanceTraderResult](hook.NEW_BINANCE_TRADER, userId, client) if hookRes != nil && hookRes.GetResult() != nil { client = hookRes.GetResult() } // Sync time to avoid "Timestamp ahead" error syncBinanceServerTime(client) trader := &FuturesTrader{ client: client, cacheDuration: 15 * time.Second, // 15-second cache } // Set dual-side position mode (Hedge Mode) // This is required because the code uses PositionSide (LONG/SHORT) if err := trader.setDualSidePosition(); err != nil { logger.Infof("⚠️ Failed to set dual-side position mode: %v (ignore this warning if already in dual-side mode)", err) } return trader } // setDualSidePosition sets dual-side position mode (called during initialization) func (t *FuturesTrader) setDualSidePosition() error { // Try to set dual-side position mode err := t.client.NewChangePositionModeService(). DualSide(true). // true = dual-side position (Hedge Mode) Do(context.Background()) if err != nil { // If error message contains "No need to change", it means already in dual-side position mode if strings.Contains(err.Error(), "No need to change position side") { logger.Infof(" ✓ Account is already in dual-side position mode (Hedge Mode)") return nil } // Other errors are returned (but won't interrupt initialization in the caller) return err } logger.Infof(" ✓ Account has been switched to dual-side position mode (Hedge Mode)") logger.Infof(" ℹ️ Dual-side position mode allows holding both long and short positions simultaneously") return nil } // syncBinanceServerTime syncs Binance server time to ensure request timestamps are valid func syncBinanceServerTime(client *futures.Client) { serverTime, err := client.NewServerTimeService().Do(context.Background()) if err != nil { logger.Infof("⚠️ Failed to sync Binance server time: %v", err) return } now := time.Now().UnixMilli() offset := now - serverTime client.TimeOffset = offset logger.Infof("⏱ Binance server time synced, offset %dms", offset) } // Helper functions func contains(s, substr string) bool { return len(s) >= len(substr) && stringContains(s, substr) } func stringContains(s, substr string) bool { for i := 0; i <= len(s)-len(substr); i++ { if s[i:i+len(substr)] == substr { return true } } return false } // calculatePrecision calculates precision from stepSize func calculatePrecision(stepSize string) int { // Remove trailing zeros stepSize = trimTrailingZeros(stepSize) // Find decimal point dotIndex := -1 for i := 0; i < len(stepSize); i++ { if stepSize[i] == '.' { dotIndex = i break } } // If no decimal point or decimal point is at the end, precision is 0 if dotIndex == -1 || dotIndex == len(stepSize)-1 { return 0 } // Return number of digits after decimal point return len(stepSize) - dotIndex - 1 } // trimTrailingZeros removes trailing zeros func trimTrailingZeros(s string) string { // If no decimal point, return directly if !stringContains(s, ".") { return s } // Iterate backwards to remove trailing zeros for len(s) > 0 && s[len(s)-1] == '0' { s = s[:len(s)-1] } // If last character is decimal point, remove it too if len(s) > 0 && s[len(s)-1] == '.' { s = s[:len(s)-1] } return s }