diff --git a/trader/auto_trader.go b/trader/auto_trader.go index 4e104ba17d..a61b9feab6 100644 --- a/trader/auto_trader.go +++ b/trader/auto_trader.go @@ -763,14 +763,6 @@ func (at *AutoTrader) executeOpenLongWithRecord(decision *decision.Decision, act return err } - // 计算数量 - quantity := decision.PositionSizeUSD / marketData.CurrentPrice - actionRecord.Quantity = quantity - actionRecord.Price = marketData.CurrentPrice - - // ⚠️ 保证金验证:防止保证金不足错误(code=-2019) - requiredMargin := decision.PositionSizeUSD / float64(decision.Leverage) - balance, err := at.trader.GetBalance() if err != nil { return fmt.Errorf("获取账户余额失败: %w", err) @@ -780,15 +772,24 @@ func (at *AutoTrader) executeOpenLongWithRecord(decision *decision.Decision, act availableBalance = avail } - // 手续费估算(Taker费率 0.04%) - estimatedFee := decision.PositionSizeUSD * 0.0004 - totalRequired := requiredMargin + estimatedFee + feeRate := at.config.TakerFeeRate + if feeRate <= 0 { + feeRate = 0.0004 + } - if totalRequired > availableBalance { - return fmt.Errorf("❌ 保证金不足: 需要 %.2f USDT(保证金 %.2f + 手续费 %.2f),可用 %.2f USDT", - totalRequired, requiredMargin, estimatedFee, availableBalance) + positionSizeUSD, _, _, _, err := at.preparePositionSizing(decision, availableBalance, feeRate) + if err != nil { + return err + } + + quantity := positionSizeUSD / marketData.CurrentPrice + if quantity <= 0 { + return fmt.Errorf("无效的开仓数量: %.4f", quantity) } + actionRecord.Quantity = quantity + actionRecord.Price = marketData.CurrentPrice + // 设置仓位模式 if err := at.trader.SetMarginMode(decision.Symbol, at.config.IsCrossMargin); err != nil { log.Printf(" ⚠️ 设置仓位模式失败: %v", err) @@ -847,14 +848,6 @@ func (at *AutoTrader) executeOpenShortWithRecord(decision *decision.Decision, ac return err } - // 计算数量 - quantity := decision.PositionSizeUSD / marketData.CurrentPrice - actionRecord.Quantity = quantity - actionRecord.Price = marketData.CurrentPrice - - // ⚠️ 保证金验证:防止保证金不足错误(code=-2019) - requiredMargin := decision.PositionSizeUSD / float64(decision.Leverage) - balance, err := at.trader.GetBalance() if err != nil { return fmt.Errorf("获取账户余额失败: %w", err) @@ -864,15 +857,24 @@ func (at *AutoTrader) executeOpenShortWithRecord(decision *decision.Decision, ac availableBalance = avail } - // 手续费估算(Taker费率 0.04%) - estimatedFee := decision.PositionSizeUSD * 0.0004 - totalRequired := requiredMargin + estimatedFee + feeRate := at.config.TakerFeeRate + if feeRate <= 0 { + feeRate = 0.0004 + } - if totalRequired > availableBalance { - return fmt.Errorf("❌ 保证金不足: 需要 %.2f USDT(保证金 %.2f + 手续费 %.2f),可用 %.2f USDT", - totalRequired, requiredMargin, estimatedFee, availableBalance) + positionSizeUSD, _, _, _, err := at.preparePositionSizing(decision, availableBalance, feeRate) + if err != nil { + return err + } + + quantity := positionSizeUSD / marketData.CurrentPrice + if quantity <= 0 { + return fmt.Errorf("无效的开仓数量: %.4f", quantity) } + actionRecord.Quantity = quantity + actionRecord.Price = marketData.CurrentPrice + // 设置仓位模式 if err := at.trader.SetMarginMode(decision.Symbol, at.config.IsCrossMargin); err != nil { log.Printf(" ⚠️ 设置仓位模式失败: %v", err) @@ -911,6 +913,74 @@ func (at *AutoTrader) executeOpenShortWithRecord(decision *decision.Decision, ac return nil } +func (at *AutoTrader) preparePositionSizing(decision *decision.Decision, availableBalance, feeRate float64) (positionSizeUSD, requiredMargin, estimatedFee, totalRequired float64, err error) { + leverage := float64(decision.Leverage) + if leverage <= 0 { + return 0, 0, 0, 0, fmt.Errorf("无效的杠杆倍数: %d", decision.Leverage) + } + + positionSizeUSD = decision.PositionSizeUSD + requiredMargin = positionSizeUSD / leverage + estimatedFee = positionSizeUSD * feeRate + totalRequired = requiredMargin + estimatedFee + + if totalRequired <= availableBalance { + return positionSizeUSD, requiredMargin, estimatedFee, totalRequired, nil + } + + denominator := (1.0 / leverage) + feeRate + if denominator <= 0 { + return 0, 0, 0, 0, fmt.Errorf("无效的保证金或手续费配置(杠杆=%d, 手续费率=%.6f)", decision.Leverage, feeRate) + } + + maxAffordableUSD := availableBalance / denominator + // 留出 0.5% 缓冲,避免实盘下单因手续费或价格轻微波动再次失败 + adjustedPositionUSD := maxAffordableUSD * 0.995 + + if adjustedPositionUSD <= 0 { + return 0, requiredMargin, estimatedFee, totalRequired, + fmt.Errorf("❌ 保证金不足: 需要 %.2f USDT(保证金 %.2f + 手续费 %.2f),可用 %.2f USDT", + totalRequired, requiredMargin, estimatedFee, availableBalance) + } + + if adjustedPositionUSD < positionSizeUSD { + log.Printf(" ⚠️ 可用余额 %.2f USDT 不足以支撑计划开仓 %.2f USDT,将自动降额至 %.2f USDT(包含手续费缓冲)", + availableBalance, positionSizeUSD, adjustedPositionUSD) + positionSizeUSD = adjustedPositionUSD + requiredMargin = positionSizeUSD / leverage + estimatedFee = positionSizeUSD * feeRate + totalRequired = requiredMargin + estimatedFee + } + + if totalRequired > availableBalance { + safetyAvailable := availableBalance * 0.995 + if safetyAvailable <= 0 { + return 0, requiredMargin, estimatedFee, totalRequired, + fmt.Errorf("❌ 保证金不足: 需要 %.2f USDT(保证金 %.2f + 手续费 %.2f),可用 %.2f USDT", + totalRequired, requiredMargin, estimatedFee, availableBalance) + } + + positionSizeUSD = math.Min(positionSizeUSD, safetyAvailable/denominator) + if positionSizeUSD <= 0 { + return 0, requiredMargin, estimatedFee, totalRequired, + fmt.Errorf("❌ 保证金不足: 需要 %.2f USDT(保证金 %.2f + 手续费 %.2f),可用 %.2f USDT", + totalRequired, requiredMargin, estimatedFee, availableBalance) + } + + requiredMargin = positionSizeUSD / leverage + estimatedFee = positionSizeUSD * feeRate + totalRequired = requiredMargin + estimatedFee + + if totalRequired > availableBalance { + return 0, requiredMargin, estimatedFee, totalRequired, + fmt.Errorf("❌ 保证金不足: 需要 %.2f USDT(保证金 %.2f + 手续费 %.2f),可用 %.2f USDT", + totalRequired, requiredMargin, estimatedFee, availableBalance) + } + } + + return positionSizeUSD, requiredMargin, estimatedFee, totalRequired, nil +} + // executeCloseLongWithRecord 执行平多仓并记录详细信息 func (at *AutoTrader) executeCloseLongWithRecord(decision *decision.Decision, actionRecord *logger.DecisionAction) error { log.Printf(" 🔄 平多仓: %s", decision.Symbol) diff --git a/trader/auto_trader_test.go b/trader/auto_trader_test.go index a556b7f160..192d723497 100644 --- a/trader/auto_trader_test.go +++ b/trader/auto_trader_test.go @@ -82,6 +82,8 @@ func (s *AutoTraderTestSuite) SetupTest() { BTCETHLeverage: 10, AltcoinLeverage: 5, IsCrossMargin: true, + TakerFeeRate: 0.0004, + MakerFeeRate: 0.0002, } // 创建 AutoTrader 实例(直接构造,不调用 NewAutoTrader 以避免外部依赖) @@ -410,13 +412,14 @@ func (s *AutoTraderTestSuite) TestBuildTradingContext() { // TestExecuteOpenPosition 测试开仓操作(多空通用) func (s *AutoTraderTestSuite) TestExecuteOpenPosition() { tests := []struct { - name string - action string - expectedOrder int64 - existingSide string - availBalance float64 - expectedErr string - executeFn func(*decision.Decision, *logger.DecisionAction) error + name string + action string + expectedOrder int64 + existingSide string + availBalance float64 + expectedErr string + expectAdjusted bool + executeFn func(*decision.Decision, *logger.DecisionAction) error }{ { name: "成功开多仓", @@ -454,6 +457,26 @@ func (s *AutoTraderTestSuite) TestExecuteOpenPosition() { return s.autoTrader.executeOpenShortWithRecord(d, a) }, }, + { + name: "多仓_余额略不足自动降额", + action: "open_long", + expectedOrder: 123456, + availBalance: 100.3, + expectAdjusted: true, + executeFn: func(d *decision.Decision, a *logger.DecisionAction) error { + return s.autoTrader.executeOpenLongWithRecord(d, a) + }, + }, + { + name: "空仓_余额略不足自动降额", + action: "open_short", + expectedOrder: 123457, + availBalance: 100.3, + expectAdjusted: true, + executeFn: func(d *decision.Decision, a *logger.DecisionAction) error { + return s.autoTrader.executeOpenShortWithRecord(d, a) + }, + }, { name: "多仓_已有同方向持仓", action: "open_long", @@ -492,6 +515,7 @@ func (s *AutoTraderTestSuite) TestExecuteOpenPosition() { decision := &decision.Decision{Action: tt.action, Symbol: "BTCUSDT", PositionSizeUSD: 1000.0, Leverage: 10} actionRecord := &logger.DecisionAction{Action: tt.action, Symbol: "BTCUSDT"} + originalQty := decision.PositionSizeUSD / 50000.0 err := tt.executeFn(decision, actionRecord) @@ -502,6 +526,11 @@ func (s *AutoTraderTestSuite) TestExecuteOpenPosition() { s.NoError(err) s.Equal(tt.expectedOrder, actionRecord.OrderID) s.Greater(actionRecord.Quantity, 0.0) + if tt.expectAdjusted { + s.Less(actionRecord.Quantity, originalQty) + } else { + s.InEpsilon(actionRecord.Quantity, originalQty, 1e-9) + } s.Equal(50000.0, actionRecord.Price) } diff --git a/trader/binance_futures.go b/trader/binance_futures.go index c22dcb37bd..6d0d0151e6 100644 --- a/trader/binance_futures.go +++ b/trader/binance_futures.go @@ -65,6 +65,9 @@ type FuturesTrader struct { orderStrategy string // Order strategy: "market_only", "conservative_hybrid", "limit_only" limitPriceOffset float64 // Limit order price offset percentage (e.g., -0.03 for -0.03%) limitTimeoutSeconds int // Timeout in seconds before converting to market order + + minNotionalCache map[string]float64 + minNotionalMutex sync.RWMutex } // NewFuturesTrader 创建合约交易器 @@ -89,6 +92,7 @@ func newFuturesTraderWithClient(client *futures.Client, orderStrategy string, li orderStrategy: orderStrategy, limitPriceOffset: limitPriceOffset, limitTimeoutSeconds: limitTimeoutSeconds, + minNotionalCache: make(map[string]float64), } // 设置双向持仓模式(Hedge Mode) @@ -1173,8 +1177,62 @@ func (t *FuturesTrader) SetTakeProfit(symbol string, positionSide string, quanti // GetMinNotional 获取最小名义价值(Binance要求) func (t *FuturesTrader) GetMinNotional(symbol string) float64 { - // 使用保守的默认值 10 USDT,确保订单能够通过交易所验证 - return 10.0 + t.minNotionalMutex.RLock() + if value, ok := t.minNotionalCache[symbol]; ok { + t.minNotionalMutex.RUnlock() + return value + } + t.minNotionalMutex.RUnlock() + + t.minNotionalMutex.Lock() + defer t.minNotionalMutex.Unlock() + + // Double-check in case another goroutine populated the cache + if value, ok := t.minNotionalCache[symbol]; ok { + return value + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + exchangeInfo, err := t.client.NewExchangeInfoService().Symbol(symbol).Do(ctx) + if err != nil { + log.Printf("⚠️ 获取 %s 最小名义价值失败,使用默认值: %v", symbol, err) + t.minNotionalCache[symbol] = 100.0 + return t.minNotionalCache[symbol] + } + + for _, s := range exchangeInfo.Symbols { + if s.Symbol != symbol { + continue + } + + for _, filter := range s.Filters { + filterType, ok := filter["filterType"].(string) + if !ok || filterType != "MIN_NOTIONAL" { + continue + } + + // Futures MIN_NOTIONAL filter provides the value under the "notional" key + if notionalStr, ok := filter["notional"].(string); ok { + if notional, err := strconv.ParseFloat(notionalStr, 64); err == nil { + t.minNotionalCache[symbol] = notional + return notional + } + } + + // Some responses use "notional" as float64 already + if notionalFloat, ok := filter["notional"].(float64); ok { + t.minNotionalCache[symbol] = notionalFloat + return notionalFloat + } + } + } + + // Binance 主流合约(如 BTCUSDT、ETHUSDT)的下限通常是 100 USDT + t.minNotionalCache[symbol] = 100.0 + log.Printf("⚠️ 未在交易规则中找到 %s 的最小名义价值,使用默认值 100 USDT", symbol) + return t.minNotionalCache[symbol] } // CheckMinNotional 检查订单是否满足最小名义价值要求