package trader import ( "context" "crypto/rand" "encoding/hex" "fmt" "nofx/hook" "nofx/logger" "strconv" "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) 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) } // GetBalance gets account balance (with cache) func (t *FuturesTrader) GetBalance() (map[string]interface{}, error) { // First check if cache is valid t.balanceCacheMutex.RLock() if t.cachedBalance != nil && time.Since(t.balanceCacheTime) < t.cacheDuration { cacheAge := time.Since(t.balanceCacheTime) t.balanceCacheMutex.RUnlock() logger.Infof("✓ Using cached account balance (cache age: %.1f seconds ago)", cacheAge.Seconds()) return t.cachedBalance, nil } t.balanceCacheMutex.RUnlock() // Cache expired or doesn't exist, call API logger.Infof("🔄 Cache expired, calling Binance API to get account balance...") account, err := t.client.NewGetAccountService().Do(context.Background()) if err != nil { logger.Infof("❌ Binance API call failed: %v", err) return nil, fmt.Errorf("failed to get account info: %w", err) } result := make(map[string]interface{}) result["totalWalletBalance"], _ = strconv.ParseFloat(account.TotalWalletBalance, 64) result["availableBalance"], _ = strconv.ParseFloat(account.AvailableBalance, 64) result["totalUnrealizedProfit"], _ = strconv.ParseFloat(account.TotalUnrealizedProfit, 64) logger.Infof("✓ Binance API returned: total balance=%s, available=%s, unrealized PnL=%s", account.TotalWalletBalance, account.AvailableBalance, account.TotalUnrealizedProfit) // Update cache t.balanceCacheMutex.Lock() t.cachedBalance = result t.balanceCacheTime = time.Now() t.balanceCacheMutex.Unlock() return result, nil } // GetPositions gets all positions (with cache) func (t *FuturesTrader) GetPositions() ([]map[string]interface{}, error) { // First check if cache is valid t.positionsCacheMutex.RLock() if t.cachedPositions != nil && time.Since(t.positionsCacheTime) < t.cacheDuration { cacheAge := time.Since(t.positionsCacheTime) t.positionsCacheMutex.RUnlock() logger.Infof("✓ Using cached position information (cache age: %.1f seconds ago)", cacheAge.Seconds()) return t.cachedPositions, nil } t.positionsCacheMutex.RUnlock() // Cache expired or doesn't exist, call API logger.Infof("🔄 Cache expired, calling Binance API to get position information...") positions, err := t.client.NewGetPositionRiskService().Do(context.Background()) if err != nil { return nil, fmt.Errorf("failed to get positions: %w", err) } var result []map[string]interface{} for _, pos := range positions { posAmt, _ := strconv.ParseFloat(pos.PositionAmt, 64) if posAmt == 0 { continue // Skip positions with zero amount } posMap := make(map[string]interface{}) posMap["symbol"] = pos.Symbol posMap["positionAmt"], _ = strconv.ParseFloat(pos.PositionAmt, 64) posMap["entryPrice"], _ = strconv.ParseFloat(pos.EntryPrice, 64) posMap["markPrice"], _ = strconv.ParseFloat(pos.MarkPrice, 64) posMap["unRealizedProfit"], _ = strconv.ParseFloat(pos.UnRealizedProfit, 64) posMap["leverage"], _ = strconv.ParseFloat(pos.Leverage, 64) posMap["liquidationPrice"], _ = strconv.ParseFloat(pos.LiquidationPrice, 64) // Note: Binance SDK doesn't expose updateTime field, will fallback to local tracking // Determine direction if posAmt > 0 { posMap["side"] = "long" } else { posMap["side"] = "short" } result = append(result, posMap) } // Update cache t.positionsCacheMutex.Lock() t.cachedPositions = result t.positionsCacheTime = time.Now() t.positionsCacheMutex.Unlock() return result, nil } // SetMarginMode sets margin mode func (t *FuturesTrader) SetMarginMode(symbol string, isCrossMargin bool) error { var marginType futures.MarginType if isCrossMargin { marginType = futures.MarginTypeCrossed } else { marginType = futures.MarginTypeIsolated } // Try to set margin mode err := t.client.NewChangeMarginTypeService(). Symbol(symbol). MarginType(marginType). Do(context.Background()) marginModeStr := "Cross Margin" if !isCrossMargin { marginModeStr = "Isolated Margin" } if err != nil { // If error message contains "No need to change", margin mode is already set to target value if contains(err.Error(), "No need to change margin type") { logger.Infof(" ✓ %s margin mode is already %s", symbol, marginModeStr) return nil } // If there is an open position, margin mode cannot be changed, but this doesn't affect trading if contains(err.Error(), "Margin type cannot be changed if there exists position") { logger.Infof(" ⚠️ %s has open positions, cannot change margin mode, continuing with current mode", symbol) return nil } // Detect Multi-Assets mode (error code -4168) if contains(err.Error(), "Multi-Assets mode") || contains(err.Error(), "-4168") || contains(err.Error(), "4168") { logger.Infof(" ⚠️ %s detected Multi-Assets mode, forcing Cross Margin mode", symbol) logger.Infof(" 💡 Tip: To use Isolated Margin mode, please disable Multi-Assets mode in Binance") return nil } // Detect Unified Account API (Portfolio Margin) if contains(err.Error(), "unified") || contains(err.Error(), "portfolio") || contains(err.Error(), "Portfolio") { logger.Infof(" ❌ %s detected Unified Account API, unable to trade futures", symbol) return fmt.Errorf("please use 'Spot & Futures Trading' API permission, do not use 'Unified Account API'") } logger.Infof(" ⚠️ Failed to set margin mode: %v", err) // Don't return error, let trading continue return nil } logger.Infof(" ✓ %s margin mode set to %s", symbol, marginModeStr) return nil } // SetLeverage sets leverage (with smart detection and cooldown period) func (t *FuturesTrader) SetLeverage(symbol string, leverage int) error { // First try to get current leverage (from position information) currentLeverage := 0 positions, err := t.GetPositions() if err == nil { for _, pos := range positions { if pos["symbol"] == symbol { if lev, ok := pos["leverage"].(float64); ok { currentLeverage = int(lev) break } } } } // If current leverage is already the target leverage, skip if currentLeverage == leverage && currentLeverage > 0 { logger.Infof(" ✓ %s leverage is already %dx, no need to change", symbol, leverage) return nil } // Change leverage _, err = t.client.NewChangeLeverageService(). Symbol(symbol). Leverage(leverage). Do(context.Background()) if err != nil { // If error message contains "No need to change", leverage is already the target value if contains(err.Error(), "No need to change") { logger.Infof(" ✓ %s leverage is already %dx", symbol, leverage) return nil } return fmt.Errorf("failed to set leverage: %w", err) } logger.Infof(" ✓ %s leverage changed to %dx", symbol, leverage) // Wait 5 seconds after changing leverage (to avoid cooldown period errors) logger.Infof(" ⏱ Waiting 5 seconds for cooldown period...") time.Sleep(5 * time.Second) return nil } // OpenLong opens a long position func (t *FuturesTrader) OpenLong(symbol string, quantity float64, leverage int) (map[string]interface{}, error) { // First cancel all pending orders for this symbol (clean up old stop-loss and take-profit orders) if err := t.CancelAllOrders(symbol); err != nil { logger.Infof(" ⚠ Failed to cancel old pending orders (may not have any): %v", err) } // Set leverage if err := t.SetLeverage(symbol, leverage); err != nil { return nil, err } // Note: Margin mode should be set by the caller (AutoTrader) before opening position via SetMarginMode // Format quantity to correct precision quantityStr, err := t.FormatQuantity(symbol, quantity) if err != nil { return nil, err } // Check if formatted quantity is 0 (prevent rounding errors) quantityFloat, parseErr := strconv.ParseFloat(quantityStr, 64) if parseErr != nil || quantityFloat <= 0 { return nil, fmt.Errorf("position size too small, rounded to 0 (original: %.8f → formatted: %s). Suggest increasing position amount or selecting a lower-priced coin", quantity, quantityStr) } // Check minimum notional value (Binance requires at least 10 USDT) if err := t.CheckMinNotional(symbol, quantityFloat); err != nil { return nil, err } // Create market buy order (using br ID) order, err := t.client.NewCreateOrderService(). Symbol(symbol). Side(futures.SideTypeBuy). PositionSide(futures.PositionSideTypeLong). Type(futures.OrderTypeMarket). Quantity(quantityStr). NewClientOrderID(getBrOrderID()). Do(context.Background()) if err != nil { return nil, fmt.Errorf("failed to open long position: %w", err) } logger.Infof("✓ Opened long position successfully: %s quantity: %s", symbol, quantityStr) logger.Infof(" Order ID: %d", order.OrderID) result := make(map[string]interface{}) result["orderId"] = order.OrderID result["symbol"] = order.Symbol result["status"] = order.Status return result, nil } // OpenShort opens a short position func (t *FuturesTrader) OpenShort(symbol string, quantity float64, leverage int) (map[string]interface{}, error) { // First cancel all pending orders for this symbol (clean up old stop-loss and take-profit orders) if err := t.CancelAllOrders(symbol); err != nil { logger.Infof(" ⚠ Failed to cancel old pending orders (may not have any): %v", err) } // Set leverage if err := t.SetLeverage(symbol, leverage); err != nil { return nil, err } // Note: Margin mode should be set by the caller (AutoTrader) before opening position via SetMarginMode // Format quantity to correct precision quantityStr, err := t.FormatQuantity(symbol, quantity) if err != nil { return nil, err } // Check if formatted quantity is 0 (prevent rounding errors) quantityFloat, parseErr := strconv.ParseFloat(quantityStr, 64) if parseErr != nil || quantityFloat <= 0 { return nil, fmt.Errorf("position size too small, rounded to 0 (original: %.8f → formatted: %s). Suggest increasing position amount or selecting a lower-priced coin", quantity, quantityStr) } // Check minimum notional value (Binance requires at least 10 USDT) if err := t.CheckMinNotional(symbol, quantityFloat); err != nil { return nil, err } // Create market sell order (using br ID) order, err := t.client.NewCreateOrderService(). Symbol(symbol). Side(futures.SideTypeSell). PositionSide(futures.PositionSideTypeShort). Type(futures.OrderTypeMarket). Quantity(quantityStr). NewClientOrderID(getBrOrderID()). Do(context.Background()) if err != nil { return nil, fmt.Errorf("failed to open short position: %w", err) } logger.Infof("✓ Opened short position successfully: %s quantity: %s", symbol, quantityStr) logger.Infof(" Order ID: %d", order.OrderID) result := make(map[string]interface{}) result["orderId"] = order.OrderID result["symbol"] = order.Symbol result["status"] = order.Status return result, nil } // CloseLong closes a long position func (t *FuturesTrader) CloseLong(symbol string, quantity float64) (map[string]interface{}, error) { // If quantity is 0, get current position quantity if quantity == 0 { positions, err := t.GetPositions() if err != nil { return nil, err } for _, pos := range positions { if pos["symbol"] == symbol && pos["side"] == "long" { quantity = pos["positionAmt"].(float64) break } } if quantity == 0 { return nil, fmt.Errorf("no long position found for %s", symbol) } } // Format quantity quantityStr, err := t.FormatQuantity(symbol, quantity) if err != nil { return nil, err } // Create market sell order (close long, using br ID) order, err := t.client.NewCreateOrderService(). Symbol(symbol). Side(futures.SideTypeSell). PositionSide(futures.PositionSideTypeLong). Type(futures.OrderTypeMarket). Quantity(quantityStr). NewClientOrderID(getBrOrderID()). Do(context.Background()) if err != nil { return nil, fmt.Errorf("failed to close long position: %w", err) } logger.Infof("✓ Closed long position successfully: %s quantity: %s", symbol, quantityStr) // After closing position, cancel all pending orders for this symbol (stop-loss and take-profit orders) if err := t.CancelAllOrders(symbol); err != nil { logger.Infof(" ⚠ Failed to cancel pending orders: %v", err) } result := make(map[string]interface{}) result["orderId"] = order.OrderID result["symbol"] = order.Symbol result["status"] = order.Status return result, nil } // CloseShort closes a short position func (t *FuturesTrader) CloseShort(symbol string, quantity float64) (map[string]interface{}, error) { // If quantity is 0, get current position quantity if quantity == 0 { positions, err := t.GetPositions() if err != nil { return nil, err } for _, pos := range positions { if pos["symbol"] == symbol && pos["side"] == "short" { quantity = -pos["positionAmt"].(float64) // Short position quantity is negative, take absolute value break } } if quantity == 0 { return nil, fmt.Errorf("no short position found for %s", symbol) } } // Format quantity quantityStr, err := t.FormatQuantity(symbol, quantity) if err != nil { return nil, err } // Create market buy order (close short, using br ID) order, err := t.client.NewCreateOrderService(). Symbol(symbol). Side(futures.SideTypeBuy). PositionSide(futures.PositionSideTypeShort). Type(futures.OrderTypeMarket). Quantity(quantityStr). NewClientOrderID(getBrOrderID()). Do(context.Background()) if err != nil { return nil, fmt.Errorf("failed to close short position: %w", err) } logger.Infof("✓ Closed short position successfully: %s quantity: %s", symbol, quantityStr) // After closing position, cancel all pending orders for this symbol (stop-loss and take-profit orders) if err := t.CancelAllOrders(symbol); err != nil { logger.Infof(" ⚠ Failed to cancel pending orders: %v", err) } result := make(map[string]interface{}) result["orderId"] = order.OrderID result["symbol"] = order.Symbol result["status"] = order.Status return result, nil } // CancelStopLossOrders cancels only stop-loss orders (doesn't affect take-profit orders) func (t *FuturesTrader) CancelStopLossOrders(symbol string) error { // Get all open orders for this symbol orders, err := t.client.NewListOpenOrdersService(). Symbol(symbol). Do(context.Background()) if err != nil { return fmt.Errorf("failed to get open orders: %w", err) } // Filter out stop-loss orders and cancel them (cancel all directions including LONG and SHORT) canceledCount := 0 var cancelErrors []error for _, order := range orders { orderType := order.Type // Only cancel stop-loss orders (don't cancel take-profit orders) if orderType == futures.OrderTypeStopMarket || orderType == futures.OrderTypeStop { _, err := t.client.NewCancelOrderService(). Symbol(symbol). OrderID(order.OrderID). Do(context.Background()) if err != nil { errMsg := fmt.Sprintf("Order ID %d: %v", order.OrderID, err) cancelErrors = append(cancelErrors, fmt.Errorf("%s", errMsg)) logger.Infof(" ⚠ Failed to cancel stop-loss order: %s", errMsg) continue } canceledCount++ logger.Infof(" ✓ Canceled stop-loss order (Order ID: %d, Type: %s, Side: %s)", order.OrderID, orderType, order.PositionSide) } } if canceledCount == 0 && len(cancelErrors) == 0 { logger.Infof(" ℹ %s has no stop-loss orders to cancel", symbol) } else if canceledCount > 0 { logger.Infof(" ✓ Canceled %d stop-loss order(s) for %s", canceledCount, symbol) } // If all cancellations failed, return error if len(cancelErrors) > 0 && canceledCount == 0 { return fmt.Errorf("failed to cancel stop-loss orders: %v", cancelErrors) } return nil } // CancelTakeProfitOrders cancels only take-profit orders (doesn't affect stop-loss orders) func (t *FuturesTrader) CancelTakeProfitOrders(symbol string) error { // Get all open orders for this symbol orders, err := t.client.NewListOpenOrdersService(). Symbol(symbol). Do(context.Background()) if err != nil { return fmt.Errorf("failed to get open orders: %w", err) } // Filter out take-profit orders and cancel them (cancel all directions including LONG and SHORT) canceledCount := 0 var cancelErrors []error for _, order := range orders { orderType := order.Type // Only cancel take-profit orders (don't cancel stop-loss orders) if orderType == futures.OrderTypeTakeProfitMarket || orderType == futures.OrderTypeTakeProfit { _, err := t.client.NewCancelOrderService(). Symbol(symbol). OrderID(order.OrderID). Do(context.Background()) if err != nil { errMsg := fmt.Sprintf("Order ID %d: %v", order.OrderID, err) cancelErrors = append(cancelErrors, fmt.Errorf("%s", errMsg)) logger.Infof(" ⚠ Failed to cancel take-profit order: %s", errMsg) continue } canceledCount++ logger.Infof(" ✓ Canceled take-profit order (Order ID: %d, Type: %s, Side: %s)", order.OrderID, orderType, order.PositionSide) } } if canceledCount == 0 && len(cancelErrors) == 0 { logger.Infof(" ℹ %s has no take-profit orders to cancel", symbol) } else if canceledCount > 0 { logger.Infof(" ✓ Canceled %d take-profit order(s) for %s", canceledCount, symbol) } // If all cancellations failed, return error if len(cancelErrors) > 0 && canceledCount == 0 { return fmt.Errorf("failed to cancel take-profit orders: %v", cancelErrors) } return nil } // CancelAllOrders cancels all pending orders for this symbol func (t *FuturesTrader) CancelAllOrders(symbol string) error { err := t.client.NewCancelAllOpenOrdersService(). Symbol(symbol). Do(context.Background()) if err != nil { return fmt.Errorf("failed to cancel pending orders: %w", err) } logger.Infof(" ✓ Canceled all pending orders for %s", symbol) return nil } // CancelStopOrders cancels take-profit/stop-loss orders for this symbol (used to adjust TP/SL positions) func (t *FuturesTrader) CancelStopOrders(symbol string) error { // Get all open orders for this symbol orders, err := t.client.NewListOpenOrdersService(). Symbol(symbol). Do(context.Background()) if err != nil { return fmt.Errorf("failed to get open orders: %w", err) } // Filter out take-profit and stop-loss orders and cancel them canceledCount := 0 for _, order := range orders { orderType := order.Type // Only cancel stop-loss and take-profit orders if orderType == futures.OrderTypeStopMarket || orderType == futures.OrderTypeTakeProfitMarket || orderType == futures.OrderTypeStop || orderType == futures.OrderTypeTakeProfit { _, err := t.client.NewCancelOrderService(). Symbol(symbol). OrderID(order.OrderID). Do(context.Background()) if err != nil { logger.Infof(" ⚠ Failed to cancel order %d: %v", order.OrderID, err) continue } canceledCount++ logger.Infof(" ✓ Canceled take-profit/stop-loss order for %s (Order ID: %d, Type: %s)", symbol, order.OrderID, orderType) } } if canceledCount == 0 { logger.Infof(" ℹ %s has no take-profit/stop-loss orders to cancel", symbol) } else { logger.Infof(" ✓ Canceled %d take-profit/stop-loss order(s) for %s", canceledCount, symbol) } return nil } // GetMarketPrice gets market price func (t *FuturesTrader) GetMarketPrice(symbol string) (float64, error) { prices, err := t.client.NewListPricesService().Symbol(symbol).Do(context.Background()) if err != nil { return 0, fmt.Errorf("failed to get price: %w", err) } if len(prices) == 0 { return 0, fmt.Errorf("price not found") } price, err := strconv.ParseFloat(prices[0].Price, 64) if err != nil { return 0, err } return price, nil } // CalculatePositionSize calculates position size func (t *FuturesTrader) CalculatePositionSize(balance, riskPercent, price float64, leverage int) float64 { riskAmount := balance * (riskPercent / 100.0) positionValue := riskAmount * float64(leverage) quantity := positionValue / price return quantity } // SetStopLoss sets stop-loss order func (t *FuturesTrader) SetStopLoss(symbol string, positionSide string, quantity, stopPrice float64) error { var side futures.SideType var posSide futures.PositionSideType if positionSide == "LONG" { side = futures.SideTypeSell posSide = futures.PositionSideTypeLong } else { side = futures.SideTypeBuy posSide = futures.PositionSideTypeShort } // Format quantity quantityStr, err := t.FormatQuantity(symbol, quantity) if err != nil { return err } _, err = t.client.NewCreateOrderService(). Symbol(symbol). Side(side). PositionSide(posSide). Type(futures.OrderTypeStopMarket). StopPrice(fmt.Sprintf("%.8f", stopPrice)). Quantity(quantityStr). WorkingType(futures.WorkingTypeContractPrice). ClosePosition(true). NewClientOrderID(getBrOrderID()). Do(context.Background()) if err != nil { return fmt.Errorf("failed to set stop-loss: %w", err) } logger.Infof(" Stop-loss price set: %.4f", stopPrice) return nil } // SetTakeProfit sets take-profit order func (t *FuturesTrader) SetTakeProfit(symbol string, positionSide string, quantity, takeProfitPrice float64) error { var side futures.SideType var posSide futures.PositionSideType if positionSide == "LONG" { side = futures.SideTypeSell posSide = futures.PositionSideTypeLong } else { side = futures.SideTypeBuy posSide = futures.PositionSideTypeShort } // Format quantity quantityStr, err := t.FormatQuantity(symbol, quantity) if err != nil { return err } _, err = t.client.NewCreateOrderService(). Symbol(symbol). Side(side). PositionSide(posSide). Type(futures.OrderTypeTakeProfitMarket). StopPrice(fmt.Sprintf("%.8f", takeProfitPrice)). Quantity(quantityStr). WorkingType(futures.WorkingTypeContractPrice). ClosePosition(true). NewClientOrderID(getBrOrderID()). Do(context.Background()) if err != nil { return fmt.Errorf("failed to set take-profit: %w", err) } logger.Infof(" Take-profit price set: %.4f", takeProfitPrice) return nil } // GetMinNotional gets minimum notional value (Binance requirement) func (t *FuturesTrader) GetMinNotional(symbol string) float64 { // Use conservative default value of 10 USDT to ensure order passes exchange validation return 10.0 } // CheckMinNotional checks if order meets minimum notional value requirement func (t *FuturesTrader) CheckMinNotional(symbol string, quantity float64) error { price, err := t.GetMarketPrice(symbol) if err != nil { return fmt.Errorf("failed to get market price: %w", err) } notionalValue := quantity * price minNotional := t.GetMinNotional(symbol) if notionalValue < minNotional { return fmt.Errorf( "order amount %.2f USDT is below minimum requirement %.2f USDT (quantity: %.4f, price: %.4f)", notionalValue, minNotional, quantity, price, ) } return nil } // GetSymbolPrecision gets the quantity precision for a trading pair func (t *FuturesTrader) GetSymbolPrecision(symbol string) (int, error) { exchangeInfo, err := t.client.NewExchangeInfoService().Do(context.Background()) if err != nil { return 0, fmt.Errorf("failed to get trading rules: %w", err) } for _, s := range exchangeInfo.Symbols { if s.Symbol == symbol { // Get precision from LOT_SIZE filter for _, filter := range s.Filters { if filter["filterType"] == "LOT_SIZE" { stepSize := filter["stepSize"].(string) precision := calculatePrecision(stepSize) logger.Infof(" %s quantity precision: %d (stepSize: %s)", symbol, precision, stepSize) return precision, nil } } } } logger.Infof(" ⚠ %s precision information not found, using default precision 3", symbol) return 3, nil // Default precision is 3 } // 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 } // FormatQuantity formats quantity to correct precision func (t *FuturesTrader) FormatQuantity(symbol string, quantity float64) (string, error) { precision, err := t.GetSymbolPrecision(symbol) if err != nil { // If retrieval fails, use default format return fmt.Sprintf("%.3f", quantity), nil } format := fmt.Sprintf("%%.%df", precision) return fmt.Sprintf(format, quantity), nil } // 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 } // GetOrderStatus gets order status func (t *FuturesTrader) GetOrderStatus(symbol string, orderID string) (map[string]interface{}, error) { // Convert orderID to int64 orderIDInt, err := strconv.ParseInt(orderID, 10, 64) if err != nil { return nil, fmt.Errorf("invalid order ID: %s", orderID) } order, err := t.client.NewGetOrderService(). Symbol(symbol). OrderID(orderIDInt). Do(context.Background()) if err != nil { return nil, fmt.Errorf("failed to get order status: %w", err) } // Parse execution price avgPrice, _ := strconv.ParseFloat(order.AvgPrice, 64) executedQty, _ := strconv.ParseFloat(order.ExecutedQuantity, 64) result := map[string]interface{}{ "orderId": order.OrderID, "symbol": order.Symbol, "status": string(order.Status), "avgPrice": avgPrice, "executedQty": executedQty, "side": string(order.Side), "type": string(order.Type), "time": order.Time, "updateTime": order.UpdateTime, } // Binance futures commission fee needs to be obtained through GetUserTrades, not retrieved here for now // Can be obtained later through WebSocket or separate query result["commission"] = 0.0 return result, nil } // GetClosedPnL retrieves closed position PnL records from Binance Futures // Binance API: /fapi/v1/income with incomeType=REALIZED_PNL func (t *FuturesTrader) GetClosedPnL(startTime time.Time, limit int) ([]ClosedPnLRecord, error) { if limit <= 0 { limit = 100 } if limit > 1000 { limit = 1000 } // Use income history API to get realized PnL incomes, err := t.client.NewGetIncomeHistoryService(). IncomeType("REALIZED_PNL"). StartTime(startTime.UnixMilli()). Limit(int64(limit)). Do(context.Background()) if err != nil { return nil, fmt.Errorf("failed to get income history: %w", err) } records := make([]ClosedPnLRecord, 0, len(incomes)) for _, income := range incomes { record := ClosedPnLRecord{ Symbol: income.Symbol, ExchangeID: fmt.Sprintf("%d", income.TranID), } // Parse realized PnL record.RealizedPnL, _ = strconv.ParseFloat(income.Income, 64) // Parse time record.ExitTime = time.UnixMilli(income.Time) // Income API doesn't provide entry/exit price directly // We need to get these from trade history if needed // For now, leave them as 0 (will be matched with local DB records) // Determine side from PnL sign (approximate) // Note: This is not 100% accurate; actual side comes from position tracking record.Side = "unknown" record.CloseType = "unknown" records = append(records, record) } // Enrich with trade history for more details (if needed) // This requires additional API calls per symbol, so we do it only for important records if len(records) > 0 { t.enrichClosedPnLWithTrades(records, startTime) } return records, nil } // enrichClosedPnLWithTrades adds entry/exit price details from trade history func (t *FuturesTrader) enrichClosedPnLWithTrades(records []ClosedPnLRecord, startTime time.Time) { // Group by symbol symbolSet := make(map[string]bool) for _, r := range records { symbolSet[r.Symbol] = true } // Get trade history for each symbol for symbol := range symbolSet { trades, err := t.client.NewListAccountTradeService(). Symbol(symbol). StartTime(startTime.UnixMilli()). Limit(100). Do(context.Background()) if err != nil { continue } // Build a map of trades by time for quick lookup for i := range records { if records[i].Symbol != symbol { continue } // Find matching trade(s) near the income time for _, trade := range trades { tradeTime := time.UnixMilli(trade.Time) // Match if within 1 second of the PnL record if tradeTime.Sub(records[i].ExitTime).Abs() < time.Second { // Found matching trade records[i].ExitPrice, _ = strconv.ParseFloat(trade.Price, 64) records[i].Quantity, _ = strconv.ParseFloat(trade.Quantity, 64) commission, _ := strconv.ParseFloat(trade.Commission, 64) records[i].Fee += commission // Determine side if trade.PositionSide == futures.PositionSideTypeLong { records[i].Side = "long" } else if trade.PositionSide == futures.PositionSideTypeShort { records[i].Side = "short" } // Determine close type from order type (approximate) if trade.Buyer && records[i].Side == "short" || !trade.Buyer && records[i].Side == "long" { // This is a close trade records[i].CloseType = "unknown" // Can't determine SL/TP from trade data } records[i].OrderID = strconv.FormatInt(trade.OrderID, 10) break } } } } }