package trader import ( "fmt" "math" "nofx/kernel" "nofx/logger" "nofx/market" "nofx/store" "strconv" "time" ) // executeDecisionWithRecord executes AI decision and records detailed information func (at *AutoTrader) executeDecisionWithRecord(decision *kernel.Decision, actionRecord *store.DecisionAction) error { switch decision.Action { case "open_long": return at.executeOpenLongWithRecord(decision, actionRecord) case "open_short": return at.executeOpenShortWithRecord(decision, actionRecord) case "close_long": return at.executeCloseLongWithRecord(decision, actionRecord) case "close_short": return at.executeCloseShortWithRecord(decision, actionRecord) case "place_buy_limit": return at.executeLimitOrderWithRecord(decision, actionRecord, "BUY") case "place_sell_limit": return at.executeLimitOrderWithRecord(decision, actionRecord, "SELL") case "cancel_order": return at.executeCancelOrderWithRecord(decision, actionRecord) case "cancel_all_orders": return at.executeCancelAllOrdersWithRecord(decision, actionRecord) case "hold", "wait": // No execution needed, just record return nil default: return fmt.Errorf("unknown action: %s", decision.Action) } } func (at *AutoTrader) executeLimitOrderWithRecord(decision *kernel.Decision, actionRecord *store.DecisionAction, side string) error { if decision.Price <= 0 { return fmt.Errorf("limit order price must be greater than 0") } quantity := decision.Quantity if quantity <= 0 && decision.PositionSizeUSD > 0 { quantity = decision.PositionSizeUSD / decision.Price } if quantity <= 0 { return fmt.Errorf("limit order quantity must be greater than 0") } gridTrader, ok := at.trader.(GridTrader) if !ok { gridTrader = NewGridTraderAdapter(at.trader) } if duplicated, existingOrderID, err := at.hasSimilarOpenOrder(decision.Symbol, side, decision.Price); err != nil { logger.Warnf(" ⚠️ Failed to check existing open orders: %v", err) } else if duplicated { actionRecord.OrderID = parseOrderIDInt64(existingOrderID) actionRecord.Quantity = quantity actionRecord.Price = decision.Price logger.Infof(" ℹ️ Similar %s limit order already exists for %s at %.4f (orderID=%s), skipping duplicate", side, decision.Symbol, decision.Price, existingOrderID) return nil } leverage := decision.Leverage if leverage <= 0 { leverage = at.defaultLeverageForSymbol(decision.Symbol) } if err := at.trader.SetMarginMode(decision.Symbol, at.config.IsCrossMargin); err != nil { logger.Infof(" ⚠️ Failed to set margin mode: %v", err) } req := &LimitOrderRequest{ Symbol: decision.Symbol, Side: side, Price: decision.Price, Quantity: quantity, Leverage: leverage, PostOnly: true, ReduceOnly: false, ClientID: fmt.Sprintf("ai-limit-%d", time.Now().UnixNano()%1000000000), } result, err := gridTrader.PlaceLimitOrder(req) if err != nil { return err } actionRecord.OrderID = parseOrderIDInt64(result.OrderID) actionRecord.Quantity = result.Quantity actionRecord.Price = result.Price actionRecord.Leverage = leverage logger.Infof(" ✓ Limit order placed: %s %s %.6f @ %.4f orderID=%s", decision.Symbol, side, result.Quantity, result.Price, result.OrderID) return nil } func (at *AutoTrader) executeCancelOrderWithRecord(decision *kernel.Decision, actionRecord *store.DecisionAction) error { gridTrader, ok := at.trader.(GridTrader) if !ok { gridTrader = NewGridTraderAdapter(at.trader) } if err := gridTrader.CancelOrder(decision.Symbol, decision.OrderID); err != nil { return err } actionRecord.OrderID = parseOrderIDInt64(decision.OrderID) logger.Infof(" ✓ Cancelled order: %s %s", decision.Symbol, decision.OrderID) return nil } func (at *AutoTrader) executeCancelAllOrdersWithRecord(decision *kernel.Decision, actionRecord *store.DecisionAction) error { if err := at.trader.CancelAllOrders(decision.Symbol); err != nil { return err } logger.Infof(" ✓ Cancelled all open orders for %s", decision.Symbol) return nil } func (at *AutoTrader) hasSimilarOpenOrder(symbol, side string, price float64) (bool, string, error) { orders, err := at.trader.GetOpenOrders(symbol) if err != nil { return false, "", err } tolerance := price * 0.001 if tolerance < 0.01 { tolerance = 0.01 } for _, order := range orders { if order.Side == side && math.Abs(order.Price-price) <= tolerance { return true, order.OrderID, nil } } return false, "", nil } func (at *AutoTrader) defaultLeverageForSymbol(symbol string) int { if at.config.StrategyConfig == nil { return 3 } risk := at.config.StrategyConfig.RiskControl if symbol == "BTCUSDT" || symbol == "ETHUSDT" { if risk.BTCETHMaxLeverage > 0 { return risk.BTCETHMaxLeverage } return 3 } if risk.AltcoinMaxLeverage > 0 { return risk.AltcoinMaxLeverage } return 3 } func parseOrderIDInt64(orderID string) int64 { id, _ := strconv.ParseInt(orderID, 10, 64) return id } // executeOpenLongWithRecord executes open long position and records detailed information func (at *AutoTrader) executeOpenLongWithRecord(decision *kernel.Decision, actionRecord *store.DecisionAction) error { logger.Infof(" 📈 Open long: %s", decision.Symbol) // ⚠️ Get current positions for multiple checks positions, err := at.trader.GetPositions() if err != nil { return fmt.Errorf("failed to get positions: %w", err) } // [CODE ENFORCED] Check max positions limit if err := at.enforceMaxPositions(len(positions)); err != nil { return err } // Check if there's already a position in the same symbol and direction for _, pos := range positions { if pos["symbol"] == decision.Symbol && pos["side"] == "long" { return fmt.Errorf("❌ %s already has long position, close it first", decision.Symbol) } } // Get current price marketData, err := market.GetWithExchange(decision.Symbol, at.exchange) if err != nil { return err } // Get balance (needed for multiple checks) balance, err := at.trader.GetBalance() if err != nil { return fmt.Errorf("failed to get account balance: %w", err) } availableBalance := 0.0 if avail, ok := balance["availableBalance"].(float64); ok { availableBalance = avail } // Get equity for position value ratio check equity := 0.0 if eq, ok := balance["totalEquity"].(float64); ok && eq > 0 { equity = eq } else if eq, ok := balance["totalWalletBalance"].(float64); ok && eq > 0 { equity = eq } else { equity = availableBalance // Fallback to available balance } // [CODE ENFORCED] Position Value Ratio Check: position_value <= equity × ratio adjustedPositionSize, wasCapped := at.enforcePositionValueRatio(decision.PositionSizeUSD, equity, decision.Symbol) if wasCapped { decision.PositionSizeUSD = adjustedPositionSize } // ⚠️ Auto-adjust position size if insufficient margin // Formula: totalRequired = positionSize/leverage + positionSize*0.001 + positionSize/leverage*0.01 // = positionSize * (1.01/leverage + 0.001) marginFactor := 1.01/float64(decision.Leverage) + 0.001 maxAffordablePositionSize := availableBalance / marginFactor actualPositionSize := decision.PositionSizeUSD if actualPositionSize > maxAffordablePositionSize { // Use 98% of max to leave buffer for price fluctuation adjustedSize := maxAffordablePositionSize * 0.98 logger.Infof(" ⚠️ Position size %.2f exceeds max affordable %.2f, auto-reducing to %.2f", actualPositionSize, maxAffordablePositionSize, adjustedSize) actualPositionSize = adjustedSize decision.PositionSizeUSD = actualPositionSize } // [CODE ENFORCED] Minimum position size check if err := at.enforceMinPositionSize(decision.PositionSizeUSD); err != nil { return err } // Calculate quantity with adjusted position size quantity := actualPositionSize / marketData.CurrentPrice actionRecord.Quantity = quantity actionRecord.Price = marketData.CurrentPrice // Set margin mode if err := at.trader.SetMarginMode(decision.Symbol, at.config.IsCrossMargin); err != nil { logger.Infof(" ⚠️ Failed to set margin mode: %v", err) // Continue execution, doesn't affect trading } // Open position order, err := at.trader.OpenLong(decision.Symbol, quantity, decision.Leverage) if err != nil { return err } // Record order ID if orderID, ok := order["orderId"].(int64); ok { actionRecord.OrderID = orderID } logger.Infof(" ✓ Position opened successfully, order ID: %v, quantity: %.4f", order["orderId"], quantity) // Record order to database and poll for confirmation at.recordAndConfirmOrder(order, decision.Symbol, "open_long", quantity, marketData.CurrentPrice, decision.Leverage, 0) // Record position opening time posKey := decision.Symbol + "_long" at.positionFirstSeenTime[posKey] = time.Now().UnixMilli() // Set stop loss and take profit if err := at.trader.SetStopLoss(decision.Symbol, "LONG", quantity, decision.StopLoss); err != nil { logger.Infof(" ⚠ Failed to set stop loss: %v", err) } if err := at.trader.SetTakeProfit(decision.Symbol, "LONG", quantity, decision.TakeProfit); err != nil { logger.Infof(" ⚠ Failed to set take profit: %v", err) } return nil } // executeOpenShortWithRecord executes open short position and records detailed information func (at *AutoTrader) executeOpenShortWithRecord(decision *kernel.Decision, actionRecord *store.DecisionAction) error { logger.Infof(" 📉 Open short: %s", decision.Symbol) // ⚠️ Get current positions for multiple checks positions, err := at.trader.GetPositions() if err != nil { return fmt.Errorf("failed to get positions: %w", err) } // [CODE ENFORCED] Check max positions limit if err := at.enforceMaxPositions(len(positions)); err != nil { return err } // Check if there's already a position in the same symbol and direction for _, pos := range positions { if pos["symbol"] == decision.Symbol && pos["side"] == "short" { return fmt.Errorf("❌ %s already has short position, close it first", decision.Symbol) } } // Get current price marketData, err := market.GetWithExchange(decision.Symbol, at.exchange) if err != nil { return err } // Get balance (needed for multiple checks) balance, err := at.trader.GetBalance() if err != nil { return fmt.Errorf("failed to get account balance: %w", err) } availableBalance := 0.0 if avail, ok := balance["availableBalance"].(float64); ok { availableBalance = avail } // Get equity for position value ratio check equity := 0.0 if eq, ok := balance["totalEquity"].(float64); ok && eq > 0 { equity = eq } else if eq, ok := balance["totalWalletBalance"].(float64); ok && eq > 0 { equity = eq } else { equity = availableBalance // Fallback to available balance } // [CODE ENFORCED] Position Value Ratio Check: position_value <= equity × ratio adjustedPositionSize, wasCapped := at.enforcePositionValueRatio(decision.PositionSizeUSD, equity, decision.Symbol) if wasCapped { decision.PositionSizeUSD = adjustedPositionSize } // ⚠️ Auto-adjust position size if insufficient margin // Formula: totalRequired = positionSize/leverage + positionSize*0.001 + positionSize/leverage*0.01 // = positionSize * (1.01/leverage + 0.001) marginFactor := 1.01/float64(decision.Leverage) + 0.001 maxAffordablePositionSize := availableBalance / marginFactor actualPositionSize := decision.PositionSizeUSD if actualPositionSize > maxAffordablePositionSize { // Use 98% of max to leave buffer for price fluctuation adjustedSize := maxAffordablePositionSize * 0.98 logger.Infof(" ⚠️ Position size %.2f exceeds max affordable %.2f, auto-reducing to %.2f", actualPositionSize, maxAffordablePositionSize, adjustedSize) actualPositionSize = adjustedSize decision.PositionSizeUSD = actualPositionSize } // [CODE ENFORCED] Minimum position size check if err := at.enforceMinPositionSize(decision.PositionSizeUSD); err != nil { return err } // Calculate quantity with adjusted position size quantity := actualPositionSize / marketData.CurrentPrice actionRecord.Quantity = quantity actionRecord.Price = marketData.CurrentPrice // Set margin mode if err := at.trader.SetMarginMode(decision.Symbol, at.config.IsCrossMargin); err != nil { logger.Infof(" ⚠️ Failed to set margin mode: %v", err) // Continue execution, doesn't affect trading } // Open position order, err := at.trader.OpenShort(decision.Symbol, quantity, decision.Leverage) if err != nil { return err } // Record order ID if orderID, ok := order["orderId"].(int64); ok { actionRecord.OrderID = orderID } logger.Infof(" ✓ Position opened successfully, order ID: %v, quantity: %.4f", order["orderId"], quantity) // Record order to database and poll for confirmation at.recordAndConfirmOrder(order, decision.Symbol, "open_short", quantity, marketData.CurrentPrice, decision.Leverage, 0) // Record position opening time posKey := decision.Symbol + "_short" at.positionFirstSeenTime[posKey] = time.Now().UnixMilli() // Set stop loss and take profit if err := at.trader.SetStopLoss(decision.Symbol, "SHORT", quantity, decision.StopLoss); err != nil { logger.Infof(" ⚠ Failed to set stop loss: %v", err) } if err := at.trader.SetTakeProfit(decision.Symbol, "SHORT", quantity, decision.TakeProfit); err != nil { logger.Infof(" ⚠ Failed to set take profit: %v", err) } return nil } // executeCloseLongWithRecord executes close long position and records detailed information func (at *AutoTrader) executeCloseLongWithRecord(decision *kernel.Decision, actionRecord *store.DecisionAction) error { logger.Infof(" 🔄 Close long: %s", decision.Symbol) // Get current price marketData, err := market.GetWithExchange(decision.Symbol, at.exchange) if err != nil { return err } actionRecord.Price = marketData.CurrentPrice // Normalize symbol for database lookup normalizedSymbol := market.Normalize(decision.Symbol) // Get entry price and quantity - prioritize local database for accurate quantity var entryPrice float64 var quantity float64 // First try to get from local database (more accurate for quantity) if at.store != nil { if openPos, err := at.store.Position().GetOpenPositionBySymbol(at.id, normalizedSymbol, "LONG"); err == nil && openPos != nil { quantity = openPos.Quantity entryPrice = openPos.EntryPrice logger.Infof(" 📊 Using local position data: qty=%.8f, entry=%.2f", quantity, entryPrice) } } // Fallback to exchange API if local data not found if quantity == 0 { positions, err := at.trader.GetPositions() if err == nil { for _, pos := range positions { if pos["symbol"] == decision.Symbol && pos["side"] == "long" { if ep, ok := pos["entryPrice"].(float64); ok { entryPrice = ep } if amt, ok := pos["positionAmt"].(float64); ok && amt > 0 { quantity = amt } break } } } logger.Infof(" 📊 Using exchange position data: qty=%.8f, entry=%.2f", quantity, entryPrice) } // Close position order, err := at.trader.CloseLong(decision.Symbol, 0) // 0 = close all if err != nil { return err } // Record order ID if orderID, ok := order["orderId"].(int64); ok { actionRecord.OrderID = orderID } // Record order to database and poll for confirmation at.recordAndConfirmOrder(order, decision.Symbol, "close_long", quantity, marketData.CurrentPrice, 0, entryPrice) logger.Infof(" ✓ Position closed successfully") return nil } // executeCloseShortWithRecord executes close short position and records detailed information func (at *AutoTrader) executeCloseShortWithRecord(decision *kernel.Decision, actionRecord *store.DecisionAction) error { logger.Infof(" 🔄 Close short: %s", decision.Symbol) // Get current price marketData, err := market.GetWithExchange(decision.Symbol, at.exchange) if err != nil { return err } actionRecord.Price = marketData.CurrentPrice // Normalize symbol for database lookup normalizedSymbol := market.Normalize(decision.Symbol) // Get entry price and quantity - prioritize local database for accurate quantity var entryPrice float64 var quantity float64 // First try to get from local database (more accurate for quantity) if at.store != nil { if openPos, err := at.store.Position().GetOpenPositionBySymbol(at.id, normalizedSymbol, "SHORT"); err == nil && openPos != nil { quantity = openPos.Quantity entryPrice = openPos.EntryPrice logger.Infof(" 📊 Using local position data: qty=%.8f, entry=%.2f", quantity, entryPrice) } } // Fallback to exchange API if local data not found if quantity == 0 { positions, err := at.trader.GetPositions() if err == nil { for _, pos := range positions { if pos["symbol"] == decision.Symbol && pos["side"] == "short" { if ep, ok := pos["entryPrice"].(float64); ok { entryPrice = ep } if amt, ok := pos["positionAmt"].(float64); ok { quantity = -amt // positionAmt is negative for short } break } } } logger.Infof(" 📊 Using exchange position data: qty=%.8f, entry=%.2f", quantity, entryPrice) } // Close position order, err := at.trader.CloseShort(decision.Symbol, 0) // 0 = close all if err != nil { return err } // Record order ID if orderID, ok := order["orderId"].(int64); ok { actionRecord.OrderID = orderID } // Record order to database and poll for confirmation at.recordAndConfirmOrder(order, decision.Symbol, "close_short", quantity, marketData.CurrentPrice, 0, entryPrice) logger.Infof(" ✓ Position closed successfully") return nil }