package api import ( "fmt" "net/http" "strings" "time" "nofx/logger" "nofx/store" "nofx/trader" "nofx/trader/aster" "nofx/trader/binance" "nofx/trader/bitget" "nofx/trader/bybit" "nofx/trader/gate" hyperliquidtrader "nofx/trader/hyperliquid" "nofx/trader/kucoin" "nofx/trader/lighter" "nofx/trader/okx" "github.com/gin-gonic/gin" ) // handleGetGridRiskInfo returns current risk information for a grid trader func (s *Server) handleGetGridRiskInfo(c *gin.Context) { traderID := c.Param("id") autoTrader, err := s.traderManager.GetTrader(traderID) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "trader not found"}) return } riskInfo := autoTrader.GetGridRiskInfo() c.JSON(http.StatusOK, riskInfo) } // handleSyncBalance Sync exchange balance to initial_balance (Option B: Manual Sync + Option C: Smart Detection) func (s *Server) handleSyncBalance(c *gin.Context) { userID := c.GetString("user_id") traderID := c.Param("id") logger.Infof("🔄 User %s requested balance sync for trader %s", userID, traderID) // Get trader configuration from database (including exchange info) fullConfig, err := s.store.Trader().GetFullConfig(userID, traderID) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Trader does not exist"}) return } traderConfig := fullConfig.Trader exchangeCfg := fullConfig.Exchange if exchangeCfg == nil || !exchangeCfg.Enabled { c.JSON(http.StatusBadRequest, gin.H{"error": "Exchange not configured or not enabled"}) return } // Create temporary trader to query balance var tempTrader trader.Trader var createErr error // Use ExchangeType (e.g., "binance") instead of ExchangeID (which is now UUID) // Convert EncryptedString fields to string switch exchangeCfg.ExchangeType { case "binance": tempTrader = binance.NewFuturesTrader(string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey), userID) case "hyperliquid": tempTrader, createErr = hyperliquidtrader.NewHyperliquidTrader( string(exchangeCfg.APIKey), exchangeCfg.HyperliquidWalletAddr, exchangeCfg.Testnet, exchangeCfg.HyperliquidUnifiedAcct, ) case "aster": tempTrader, createErr = aster.NewAsterTrader( exchangeCfg.AsterUser, exchangeCfg.AsterSigner, string(exchangeCfg.AsterPrivateKey), ) case "bybit": tempTrader = bybit.NewBybitTrader( string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey), ) case "okx": tempTrader = okx.NewOKXTrader( string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey), string(exchangeCfg.Passphrase), ) case "bitget": tempTrader = bitget.NewBitgetTrader( string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey), string(exchangeCfg.Passphrase), ) case "gate": tempTrader = gate.NewGateTrader( string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey), ) case "kucoin": tempTrader = kucoin.NewKuCoinTrader( string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey), string(exchangeCfg.Passphrase), ) case "lighter": if exchangeCfg.LighterWalletAddr != "" && string(exchangeCfg.LighterAPIKeyPrivateKey) != "" { // Lighter only supports mainnet tempTrader, createErr = lighter.NewLighterTraderV2( exchangeCfg.LighterWalletAddr, string(exchangeCfg.LighterAPIKeyPrivateKey), exchangeCfg.LighterAPIKeyIndex, false, // Always use mainnet for Lighter ) } else { createErr = fmt.Errorf("Lighter requires wallet address and API Key private key") } default: c.JSON(http.StatusBadRequest, gin.H{"error": "Unsupported exchange type"}) return } if createErr != nil { logger.Infof("âš ī¸ Failed to create temporary trader: %v", createErr) SafeInternalError(c, "Failed to connect to exchange", createErr) return } // Query actual balance balanceInfo, balanceErr := tempTrader.GetBalance() if balanceErr != nil { logger.Infof("âš ī¸ Failed to query exchange balance: %v", balanceErr) SafeInternalError(c, "Failed to query balance", balanceErr) return } // Extract total equity (for P&L calculation, we need total account value, not available balance) var actualBalance float64 // Priority: total_equity > totalWalletBalance > wallet_balance > totalEq > balance balanceKeys := []string{"total_equity", "totalWalletBalance", "wallet_balance", "totalEq", "balance"} for _, key := range balanceKeys { if balance, ok := balanceInfo[key].(float64); ok && balance > 0 { actualBalance = balance break } } if actualBalance <= 0 { c.JSON(http.StatusInternalServerError, gin.H{"error": "Unable to get total equity"}) return } oldBalance := traderConfig.InitialBalance // Smart balance change detection changePercent := ((actualBalance - oldBalance) / oldBalance) * 100 changeType := "increase" if changePercent < 0 { changeType = "decrease" } logger.Infof("✓ Queried actual exchange balance: %.2f USDT (current config: %.2f USDT, change: %.2f%%)", actualBalance, oldBalance, changePercent) // Update initial_balance in database err = s.store.Trader().UpdateInitialBalance(userID, traderID, actualBalance) if err != nil { logger.Infof("❌ Failed to update initial_balance: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update balance"}) return } // Reload traders into memory err = s.traderManager.LoadUserTradersFromStore(s.store, userID) if err != nil { logger.Infof("âš ī¸ Failed to reload user traders into memory: %v", err) } logger.Infof("✅ Synced balance: %.2f → %.2f USDT (%s %.2f%%)", oldBalance, actualBalance, changeType, changePercent) c.JSON(http.StatusOK, gin.H{ "message": "Balance synced successfully", "old_balance": oldBalance, "new_balance": actualBalance, "change_percent": changePercent, "change_type": changeType, }) } // handleClosePosition One-click close position func (s *Server) handleClosePosition(c *gin.Context) { userID := c.GetString("user_id") traderID := c.Param("id") var req struct { Symbol string `json:"symbol" binding:"required"` Side string `json:"side" binding:"required"` // "LONG" or "SHORT" } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Parameter error: symbol and side are required"}) return } logger.Infof("đŸ”ģ User %s requested position close: trader=%s, symbol=%s, side=%s", userID, traderID, req.Symbol, req.Side) // Get trader configuration from database (including exchange info) fullConfig, err := s.store.Trader().GetFullConfig(userID, traderID) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Trader does not exist"}) return } exchangeCfg := fullConfig.Exchange if exchangeCfg == nil || !exchangeCfg.Enabled { c.JSON(http.StatusBadRequest, gin.H{"error": "Exchange not configured or not enabled"}) return } // Create temporary trader to execute close position var tempTrader trader.Trader var createErr error // Use ExchangeType (e.g., "binance") instead of ExchangeID (which is now UUID) // Convert EncryptedString fields to string switch exchangeCfg.ExchangeType { case "binance": tempTrader = binance.NewFuturesTrader(string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey), userID) case "hyperliquid": tempTrader, createErr = hyperliquidtrader.NewHyperliquidTrader( string(exchangeCfg.APIKey), exchangeCfg.HyperliquidWalletAddr, exchangeCfg.Testnet, exchangeCfg.HyperliquidUnifiedAcct, ) case "aster": tempTrader, createErr = aster.NewAsterTrader( exchangeCfg.AsterUser, exchangeCfg.AsterSigner, string(exchangeCfg.AsterPrivateKey), ) case "bybit": tempTrader = bybit.NewBybitTrader( string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey), ) case "okx": tempTrader = okx.NewOKXTrader( string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey), string(exchangeCfg.Passphrase), ) case "bitget": tempTrader = bitget.NewBitgetTrader( string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey), string(exchangeCfg.Passphrase), ) case "gate": tempTrader = gate.NewGateTrader( string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey), ) case "kucoin": tempTrader = kucoin.NewKuCoinTrader( string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey), string(exchangeCfg.Passphrase), ) case "lighter": if exchangeCfg.LighterWalletAddr != "" && string(exchangeCfg.LighterAPIKeyPrivateKey) != "" { // Lighter only supports mainnet tempTrader, createErr = lighter.NewLighterTraderV2( exchangeCfg.LighterWalletAddr, string(exchangeCfg.LighterAPIKeyPrivateKey), exchangeCfg.LighterAPIKeyIndex, false, // Always use mainnet for Lighter ) } else { createErr = fmt.Errorf("Lighter requires wallet address and API Key private key") } default: c.JSON(http.StatusBadRequest, gin.H{"error": "Unsupported exchange type"}) return } if createErr != nil { logger.Infof("âš ī¸ Failed to create temporary trader: %v", createErr) SafeInternalError(c, "Failed to connect to exchange", createErr) return } // Get current position info BEFORE closing (to get quantity and price) positions, err := tempTrader.GetPositions() if err != nil { logger.Infof("âš ī¸ Failed to get positions: %v", err) } var posQty float64 var entryPrice float64 for _, pos := range positions { if pos["symbol"] == req.Symbol && pos["side"] == strings.ToLower(req.Side) { if amt, ok := pos["positionAmt"].(float64); ok { posQty = amt if posQty < 0 { posQty = -posQty // Make positive } } if price, ok := pos["entryPrice"].(float64); ok { entryPrice = price } break } } // Execute close position operation var result map[string]interface{} var closeErr error if req.Side == "LONG" { result, closeErr = tempTrader.CloseLong(req.Symbol, 0) // 0 means close all } else if req.Side == "SHORT" { result, closeErr = tempTrader.CloseShort(req.Symbol, 0) // 0 means close all } else { c.JSON(http.StatusBadRequest, gin.H{"error": "side must be LONG or SHORT"}) return } if closeErr != nil { logger.Infof("❌ Close position failed: symbol=%s, side=%s, error=%v", req.Symbol, req.Side, closeErr) SafeInternalError(c, "Close position", closeErr) return } logger.Infof("✅ Position closed successfully: symbol=%s, side=%s, qty=%.6f, result=%v", req.Symbol, req.Side, posQty, result) // Record order to database (for chart markers and history) s.recordClosePositionOrder(traderID, exchangeCfg.ID, exchangeCfg.ExchangeType, req.Symbol, req.Side, posQty, entryPrice, result) c.JSON(http.StatusOK, gin.H{ "message": "Position closed successfully", "symbol": req.Symbol, "side": req.Side, "result": result, }) } // recordClosePositionOrder Record close position order to database (Lighter version - direct FILLED status) func (s *Server) recordClosePositionOrder(traderID, exchangeID, exchangeType, symbol, side string, quantity, exitPrice float64, result map[string]interface{}) { // Skip for exchanges with OrderSync - let the background sync handle it to avoid duplicates switch exchangeType { case "binance", "lighter", "hyperliquid", "bybit", "okx", "bitget", "aster", "gate": logger.Infof(" 📝 Close order will be synced by OrderSync, skipping immediate record") return } // Check if order was placed (skip if NO_POSITION) status, _ := result["status"].(string) if status == "NO_POSITION" { logger.Infof(" âš ī¸ No position to close, skipping order record") return } // Get order ID from result var orderID string switch v := result["orderId"].(type) { case int64: orderID = fmt.Sprintf("%d", v) case float64: orderID = fmt.Sprintf("%.0f", v) case string: orderID = v default: orderID = fmt.Sprintf("%v", v) } if orderID == "" || orderID == "0" { logger.Infof(" âš ī¸ Order ID is empty, skipping record") return } // Determine order action based on side var orderAction string if side == "LONG" { orderAction = "close_long" } else { orderAction = "close_short" } // Use entry price if exit price not available if exitPrice == 0 { exitPrice = quantity * 100 // Rough estimate if we don't have price } // Estimate fee (0.04% for Lighter taker) fee := exitPrice * quantity * 0.0004 // Create order record - DIRECTLY as FILLED (Lighter market orders fill immediately) orderRecord := &store.TraderOrder{ TraderID: traderID, ExchangeID: exchangeID, ExchangeType: exchangeType, ExchangeOrderID: orderID, Symbol: symbol, PositionSide: side, OrderAction: orderAction, Type: "MARKET", Side: getSideFromAction(orderAction), Quantity: quantity, Price: 0, // Market order Status: "FILLED", FilledQuantity: quantity, AvgFillPrice: exitPrice, Commission: fee, FilledAt: time.Now().UTC().UnixMilli(), CreatedAt: time.Now().UTC().UnixMilli(), UpdatedAt: time.Now().UTC().UnixMilli(), } if err := s.store.Order().CreateOrder(orderRecord); err != nil { logger.Infof(" âš ī¸ Failed to record order: %v", err) return } logger.Infof(" ✅ Order recorded as FILLED: %s [%s] %s qty=%.6f price=%.6f", orderID, orderAction, symbol, quantity, exitPrice) // Create fill record immediately tradeID := fmt.Sprintf("%s-%d", orderID, time.Now().UnixNano()) fillRecord := &store.TraderFill{ TraderID: traderID, ExchangeID: exchangeID, ExchangeType: exchangeType, OrderID: orderRecord.ID, ExchangeOrderID: orderID, ExchangeTradeID: tradeID, Symbol: symbol, Side: getSideFromAction(orderAction), Price: exitPrice, Quantity: quantity, QuoteQuantity: exitPrice * quantity, Commission: fee, CommissionAsset: "USDT", RealizedPnL: 0, IsMaker: false, CreatedAt: time.Now().UTC().UnixMilli(), } if err := s.store.Order().CreateFill(fillRecord); err != nil { logger.Infof(" âš ī¸ Failed to record fill: %v", err) } else { logger.Infof(" ✅ Fill record created: price=%.6f qty=%.6f", exitPrice, quantity) } } // pollAndUpdateOrderStatus Poll order status and update with fill data func (s *Server) pollAndUpdateOrderStatus(orderRecordID int64, traderID, exchangeID, exchangeType, orderID, symbol, orderAction string, tempTrader trader.Trader) { var actualPrice float64 var actualQty float64 var fee float64 // Wait a bit for order to be filled time.Sleep(500 * time.Millisecond) // For Lighter, use GetTrades instead of GetOrderStatus (market orders are filled immediately) if exchangeType == "lighter" { s.pollLighterTradeHistory(orderRecordID, traderID, exchangeID, exchangeType, orderID, symbol, orderAction, tempTrader) return } // For other exchanges, poll GetOrderStatus for i := 0; i < 5; i++ { status, err := tempTrader.GetOrderStatus(symbol, orderID) if err != nil { logger.Infof(" âš ī¸ GetOrderStatus failed (attempt %d/5): %v", i+1, err) time.Sleep(500 * time.Millisecond) continue } if err == nil { statusStr, _ := status["status"].(string) if statusStr == "FILLED" { // Get actual fill price if avgPrice, ok := status["avgPrice"].(float64); ok && avgPrice > 0 { actualPrice = avgPrice } // Get actual executed quantity if execQty, ok := status["executedQty"].(float64); ok && execQty > 0 { actualQty = execQty } // Get commission/fee if commission, ok := status["commission"].(float64); ok { fee = commission } logger.Infof(" ✅ Order filled: avgPrice=%.6f, qty=%.6f, fee=%.6f", actualPrice, actualQty, fee) // Update order status to FILLED if err := s.store.Order().UpdateOrderStatus(orderRecordID, "FILLED", actualQty, actualPrice, fee); err != nil { logger.Infof(" âš ī¸ Failed to update order status: %v", err) return } // Record fill details tradeID := fmt.Sprintf("%s-%d", orderID, time.Now().UnixNano()) fillRecord := &store.TraderFill{ TraderID: traderID, ExchangeID: exchangeID, ExchangeType: exchangeType, OrderID: orderRecordID, ExchangeOrderID: orderID, ExchangeTradeID: tradeID, Symbol: symbol, Side: getSideFromAction(orderAction), Price: actualPrice, Quantity: actualQty, QuoteQuantity: actualPrice * actualQty, Commission: fee, CommissionAsset: "USDT", RealizedPnL: 0, IsMaker: false, CreatedAt: time.Now().UTC().UnixMilli(), } if err := s.store.Order().CreateFill(fillRecord); err != nil { logger.Infof(" âš ī¸ Failed to record fill: %v", err) } else { logger.Infof(" 📝 Fill recorded: price=%.6f, qty=%.6f", actualPrice, actualQty) } return } else if statusStr == "CANCELED" || statusStr == "EXPIRED" || statusStr == "REJECTED" { logger.Infof(" âš ī¸ Order %s, updating status", statusStr) s.store.Order().UpdateOrderStatus(orderRecordID, statusStr, 0, 0, 0) return } } time.Sleep(500 * time.Millisecond) } logger.Infof(" âš ī¸ Failed to confirm order fill after polling, order may still be pending") } // pollLighterTradeHistory No longer used - Lighter orders are marked as FILLED immediately // Keeping this function stub for compatibility with other exchanges func (s *Server) pollLighterTradeHistory(orderRecordID int64, traderID, exchangeID, exchangeType, orderID, symbol, orderAction string, tempTrader trader.Trader) { // For Lighter, orders are now recorded as FILLED immediately in recordClosePositionOrder // This function is no longer called for Lighter exchange logger.Infof(" â„šī¸ pollLighterTradeHistory called but not needed (order already marked FILLED)") } // getSideFromAction Get order side (BUY/SELL) from order action func getSideFromAction(action string) string { switch action { case "open_long", "close_short": return "BUY" case "open_short", "close_long": return "SELL" default: return "BUY" } }