Files
nofx/trader/binance/futures.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

185 lines
5.2 KiB
Go
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}