Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 98 additions & 28 deletions trader/auto_trader.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
43 changes: 36 additions & 7 deletions trader/auto_trader_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@ func (s *AutoTraderTestSuite) SetupTest() {
BTCETHLeverage: 10,
AltcoinLeverage: 5,
IsCrossMargin: true,
TakerFeeRate: 0.0004,
MakerFeeRate: 0.0002,
}

// 创建 AutoTrader 实例(直接构造,不调用 NewAutoTrader 以避免外部依赖)
Expand Down Expand Up @@ -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: "成功开多仓",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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)

Expand All @@ -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)
}

Expand Down
62 changes: 60 additions & 2 deletions trader/binance_futures.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 创建合约交易器
Expand All @@ -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)
Expand Down Expand Up @@ -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 检查订单是否满足最小名义价值要求
Expand Down