package kucoin import ( "encoding/json" "fmt" "math" "nofx/logger" "nofx/trader/types" "strconv" "strings" "time" ) // OpenLong opens long position func (t *KuCoinTrader) OpenLong(symbol string, quantity float64, leverage int) (map[string]interface{}, error) { // Cancel old 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 { logger.Infof("⚠️ Failed to set leverage: %v", err) } kcSymbol := t.convertSymbol(symbol) // Convert quantity to lots lots, err := t.quantityToLots(symbol, quantity) if err != nil { return nil, fmt.Errorf("failed to calculate lots: %w", err) } body := map[string]interface{}{ "clientOid": fmt.Sprintf("nfx%d", time.Now().UnixNano()), "symbol": kcSymbol, "side": "buy", "type": "market", "size": lots, "leverage": fmt.Sprintf("%d", leverage), "reduceOnly": false, "marginMode": "CROSS", // Use cross margin mode } data, err := t.doRequest("POST", kucoinOrderPath, body) if err != nil { return nil, fmt.Errorf("failed to open long position: %w", err) } var result struct { OrderId string `json:"orderId"` } if err := json.Unmarshal(data, &result); err != nil { return nil, fmt.Errorf("failed to parse order response: %w", err) } logger.Infof("✓ KuCoin opened long position: %s, lots=%d, orderId=%s", symbol, lots, result.OrderId) // Query order to get fill price fillPrice := t.queryOrderFillPrice(result.OrderId) return map[string]interface{}{ "orderId": result.OrderId, "symbol": symbol, "status": "FILLED", "fillPrice": fillPrice, }, nil } // OpenShort opens short position func (t *KuCoinTrader) OpenShort(symbol string, quantity float64, leverage int) (map[string]interface{}, error) { // Cancel old 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 { logger.Infof("⚠️ Failed to set leverage: %v", err) } kcSymbol := t.convertSymbol(symbol) // Convert quantity to lots lots, err := t.quantityToLots(symbol, quantity) if err != nil { return nil, fmt.Errorf("failed to calculate lots: %w", err) } body := map[string]interface{}{ "clientOid": fmt.Sprintf("nfx%d", time.Now().UnixNano()), "symbol": kcSymbol, "side": "sell", "type": "market", "size": lots, "leverage": fmt.Sprintf("%d", leverage), "reduceOnly": false, "marginMode": "CROSS", // Use cross margin mode } data, err := t.doRequest("POST", kucoinOrderPath, body) if err != nil { return nil, fmt.Errorf("failed to open short position: %w", err) } var result struct { OrderId string `json:"orderId"` } if err := json.Unmarshal(data, &result); err != nil { return nil, fmt.Errorf("failed to parse order response: %w", err) } logger.Infof("✓ KuCoin opened short position: %s, lots=%d, orderId=%s", symbol, lots, result.OrderId) // Query order to get fill price fillPrice := t.queryOrderFillPrice(result.OrderId) return map[string]interface{}{ "orderId": result.OrderId, "symbol": symbol, "status": "FILLED", "fillPrice": fillPrice, }, nil } // queryOrderFillPrice queries order status and returns fill price func (t *KuCoinTrader) queryOrderFillPrice(orderId string) float64 { // Wait a bit for order to fill time.Sleep(500 * time.Millisecond) path := fmt.Sprintf("%s/%s", kucoinOrderPath, orderId) data, err := t.doRequest("GET", path, nil) if err != nil { logger.Warnf("Failed to query order %s: %v", orderId, err) return 0 } var order struct { DealAvgPrice float64 `json:"dealAvgPrice"` Status string `json:"status"` DealSize int64 `json:"dealSize"` } if err := json.Unmarshal(data, &order); err != nil { return 0 } return order.DealAvgPrice } // CloseLong closes long position func (t *KuCoinTrader) CloseLong(symbol string, quantity float64) (map[string]interface{}, error) { // Invalidate position cache and get fresh positions t.InvalidatePositionCache() positions, err := t.GetPositions() if err != nil { return nil, fmt.Errorf("failed to get positions: %w", err) } // Find actual position and get margin mode var actualQty float64 var posFound bool var marginMode string = "CROSS" // Default to CROSS for _, pos := range positions { if pos["symbol"] == symbol && pos["side"] == "long" { actualQty = pos["positionAmt"].(float64) posFound = true // Get margin mode from position if mgnMode, ok := pos["mgnMode"].(string); ok { marginMode = strings.ToUpper(mgnMode) } break } } if !posFound || actualQty == 0 { return map[string]interface{}{ "status": "NO_POSITION", "message": fmt.Sprintf("No long position found for %s on KuCoin", symbol), }, nil } // Use actual quantity from exchange if quantity == 0 || quantity > actualQty { quantity = actualQty } kcSymbol := t.convertSymbol(symbol) // Convert quantity to lots lots, err := t.quantityToLots(symbol, quantity) if err != nil { return nil, fmt.Errorf("failed to calculate lots: %w", err) } body := map[string]interface{}{ "clientOid": fmt.Sprintf("nfx%d", time.Now().UnixNano()), "symbol": kcSymbol, "side": "sell", "type": "market", "size": lots, "reduceOnly": true, "closeOrder": true, "marginMode": marginMode, // Use position's margin mode } data, err := t.doRequest("POST", kucoinOrderPath, body) if err != nil { return nil, fmt.Errorf("failed to close long position: %w", err) } var result struct { OrderId string `json:"orderId"` } if err := json.Unmarshal(data, &result); err != nil { return nil, fmt.Errorf("failed to parse order response: %w", err) } logger.Infof("✓ KuCoin closed long position: %s", symbol) // Cancel pending orders if err := t.CancelAllOrders(symbol); err != nil { logger.Infof(" ⚠ Failed to cancel pending orders: %v", err) } return map[string]interface{}{ "orderId": result.OrderId, "symbol": symbol, "status": "FILLED", }, nil } // CloseShort closes short position func (t *KuCoinTrader) CloseShort(symbol string, quantity float64) (map[string]interface{}, error) { // Invalidate position cache and get fresh positions t.InvalidatePositionCache() positions, err := t.GetPositions() if err != nil { return nil, fmt.Errorf("failed to get positions: %w", err) } // Find actual position and get margin mode var actualQty float64 var posFound bool var marginMode string = "CROSS" // Default to CROSS for _, pos := range positions { if pos["symbol"] == symbol && pos["side"] == "short" { actualQty = pos["positionAmt"].(float64) posFound = true // Get margin mode from position if mgnMode, ok := pos["mgnMode"].(string); ok { marginMode = strings.ToUpper(mgnMode) } break } } if !posFound || actualQty == 0 { return map[string]interface{}{ "status": "NO_POSITION", "message": fmt.Sprintf("No short position found for %s on KuCoin", symbol), }, nil } // Use actual quantity from exchange if quantity == 0 || quantity > actualQty { quantity = actualQty } kcSymbol := t.convertSymbol(symbol) // Convert quantity to lots lots, err := t.quantityToLots(symbol, quantity) if err != nil { return nil, fmt.Errorf("failed to calculate lots: %w", err) } body := map[string]interface{}{ "clientOid": fmt.Sprintf("nfx%d", time.Now().UnixNano()), "symbol": kcSymbol, "side": "buy", "type": "market", "size": lots, "reduceOnly": true, "closeOrder": true, "marginMode": marginMode, // Use position's margin mode } data, err := t.doRequest("POST", kucoinOrderPath, body) if err != nil { return nil, fmt.Errorf("failed to close short position: %w", err) } var result struct { OrderId string `json:"orderId"` } if err := json.Unmarshal(data, &result); err != nil { return nil, fmt.Errorf("failed to parse order response: %w", err) } logger.Infof("✓ KuCoin closed short position: %s", symbol) // Cancel pending orders if err := t.CancelAllOrders(symbol); err != nil { logger.Infof(" ⚠ Failed to cancel pending orders: %v", err) } return map[string]interface{}{ "orderId": result.OrderId, "symbol": symbol, "status": "FILLED", }, nil } // GetMarketPrice gets market price func (t *KuCoinTrader) GetMarketPrice(symbol string) (float64, error) { kcSymbol := t.convertSymbol(symbol) path := fmt.Sprintf("%s?symbol=%s", kucoinTickerPath, kcSymbol) data, err := t.doRequest("GET", path, nil) if err != nil { return 0, fmt.Errorf("failed to get price: %w", err) } var ticker struct { Price string `json:"price"` } if err := json.Unmarshal(data, &ticker); err != nil { return 0, err } price, _ := strconv.ParseFloat(ticker.Price, 64) return price, nil } // SetStopLoss sets stop loss order func (t *KuCoinTrader) SetStopLoss(symbol string, positionSide string, quantity, stopPrice float64) error { kcSymbol := t.convertSymbol(symbol) // Convert quantity to lots lots, err := t.quantityToLots(symbol, quantity) if err != nil { return fmt.Errorf("failed to calculate lots: %w", err) } // Determine side: close long = sell, close short = buy side := "sell" stop := "down" // Long position: stop loss triggers when price goes down if strings.ToUpper(positionSide) == "SHORT" { side = "buy" stop = "up" // Short position: stop loss triggers when price goes up } body := map[string]interface{}{ "clientOid": fmt.Sprintf("nfxsl%d", time.Now().UnixNano()), "symbol": kcSymbol, "side": side, "type": "market", "size": lots, "stop": stop, "stopPriceType": "MP", // Mark Price "stopPrice": fmt.Sprintf("%.8f", stopPrice), "reduceOnly": true, "closeOrder": true, } _, err = t.doRequest("POST", kucoinStopOrderPath, body) if err != nil { return fmt.Errorf("failed to set stop loss: %w", err) } logger.Infof("✓ Stop loss set: %.4f", stopPrice) return nil } // SetTakeProfit sets take profit order func (t *KuCoinTrader) SetTakeProfit(symbol string, positionSide string, quantity, takeProfitPrice float64) error { kcSymbol := t.convertSymbol(symbol) // Convert quantity to lots lots, err := t.quantityToLots(symbol, quantity) if err != nil { return fmt.Errorf("failed to calculate lots: %w", err) } // Determine side: close long = sell, close short = buy side := "sell" stop := "up" // Long position: take profit triggers when price goes up if strings.ToUpper(positionSide) == "SHORT" { side = "buy" stop = "down" // Short position: take profit triggers when price goes down } body := map[string]interface{}{ "clientOid": fmt.Sprintf("nfxtp%d", time.Now().UnixNano()), "symbol": kcSymbol, "side": side, "type": "market", "size": lots, "stop": stop, "stopPriceType": "MP", // Mark Price "stopPrice": fmt.Sprintf("%.8f", takeProfitPrice), "reduceOnly": true, "closeOrder": true, } _, err = t.doRequest("POST", kucoinStopOrderPath, body) if err != nil { return fmt.Errorf("failed to set take profit: %w", err) } logger.Infof("✓ Take profit set: %.4f", takeProfitPrice) return nil } // CancelStopLossOrders cancels stop loss orders func (t *KuCoinTrader) CancelStopLossOrders(symbol string) error { return t.cancelStopOrdersByType(symbol, "sl") } // CancelTakeProfitOrders cancels take profit orders func (t *KuCoinTrader) CancelTakeProfitOrders(symbol string) error { return t.cancelStopOrdersByType(symbol, "tp") } // cancelStopOrdersByType cancels stop orders by type func (t *KuCoinTrader) cancelStopOrdersByType(symbol string, orderType string) error { kcSymbol := t.convertSymbol(symbol) // Get pending stop orders path := fmt.Sprintf("%s?symbol=%s", kucoinStopOrderPath, kcSymbol) data, err := t.doRequest("GET", path, nil) if err != nil { return err } var response struct { Items []struct { Id string `json:"id"` ClientOid string `json:"clientOid"` Stop string `json:"stop"` } `json:"items"` } if err := json.Unmarshal(data, &response); err != nil { // Try alternate format (direct array) var items []struct { Id string `json:"id"` ClientOid string `json:"clientOid"` Stop string `json:"stop"` } if err := json.Unmarshal(data, &items); err != nil { return err } response.Items = items } // Cancel matching orders for _, order := range response.Items { // Check if order matches type based on clientOid prefix if orderType == "sl" && !strings.Contains(order.ClientOid, "sl") { continue } if orderType == "tp" && !strings.Contains(order.ClientOid, "tp") { continue } cancelPath := fmt.Sprintf("%s/%s", kucoinCancelStopPath, order.Id) _, err := t.doRequest("DELETE", cancelPath, nil) if err != nil { logger.Warnf("Failed to cancel stop order %s: %v", order.Id, err) } } return nil } // CancelStopOrders cancels all stop orders for symbol func (t *KuCoinTrader) CancelStopOrders(symbol string) error { kcSymbol := t.convertSymbol(symbol) path := fmt.Sprintf("%s?symbol=%s", kucoinCancelStopPath, kcSymbol) _, err := t.doRequest("DELETE", path, nil) if err != nil { // Ignore if no orders to cancel if strings.Contains(err.Error(), "not found") || strings.Contains(err.Error(), "400100") { return nil } return err } logger.Infof("✓ Cancelled stop orders for %s", symbol) return nil } // CancelAllOrders cancels all pending orders for symbol func (t *KuCoinTrader) CancelAllOrders(symbol string) error { kcSymbol := t.convertSymbol(symbol) // Cancel regular orders path := fmt.Sprintf("%s?symbol=%s", kucoinCancelOrderPath, kcSymbol) _, err := t.doRequest("DELETE", path, nil) if err != nil && !strings.Contains(err.Error(), "not found") { logger.Warnf("Failed to cancel regular orders: %v", err) } // Cancel stop orders t.CancelStopOrders(symbol) return nil } // SetMarginMode sets margin mode func (t *KuCoinTrader) SetMarginMode(symbol string, isCrossMargin bool) error { // KuCoin sets margin mode per position, handled automatically logger.Infof("✓ KuCoin margin mode: %v (handled per position)", isCrossMargin) return nil } // SetLeverage sets leverage for a symbol func (t *KuCoinTrader) SetLeverage(symbol string, leverage int) error { kcSymbol := t.convertSymbol(symbol) body := map[string]interface{}{ "symbol": kcSymbol, "leverage": fmt.Sprintf("%d", leverage), } _, err := t.doRequest("POST", kucoinLeveragePath, body) if err != nil { // Ignore if already at target leverage if strings.Contains(err.Error(), "same") || strings.Contains(err.Error(), "already") { logger.Infof("✓ %s leverage is already %dx", symbol, leverage) return nil } return fmt.Errorf("failed to set leverage: %w", err) } logger.Infof("✓ %s leverage set to %dx", symbol, leverage) return nil } // FormatQuantity formats quantity to correct precision func (t *KuCoinTrader) FormatQuantity(symbol string, quantity float64) (string, error) { contract, err := t.getContract(symbol) if err != nil { return "", err } // Calculate lots lots := quantity / contract.Multiplier // Round to integer lotsInt := int64(math.Round(lots)) return strconv.FormatInt(lotsInt, 10), nil } // GetOrderStatus gets order status func (t *KuCoinTrader) GetOrderStatus(symbol string, orderID string) (map[string]interface{}, error) { path := fmt.Sprintf("%s/%s", kucoinOrderPath, orderID) data, err := t.doRequest("GET", path, nil) if err != nil { return nil, fmt.Errorf("failed to get order status: %w", err) } var order struct { Id string `json:"id"` Symbol string `json:"symbol"` Status string `json:"status"` DealAvgPrice float64 `json:"dealAvgPrice"` DealSize int64 `json:"dealSize"` Fee float64 `json:"fee"` Side string `json:"side"` } if err := json.Unmarshal(data, &order); err != nil { return nil, err } // Convert status status := "NEW" if order.Status == "done" { status = "FILLED" } else if order.Status == "cancelled" || order.Status == "canceled" { status = "CANCELED" } return map[string]interface{}{ "orderId": order.Id, "symbol": t.convertSymbolBack(order.Symbol), "status": status, "avgPrice": order.DealAvgPrice, "executedQty": order.DealSize, "commission": order.Fee, }, nil } // GetClosedPnL gets closed position PnL records func (t *KuCoinTrader) GetClosedPnL(startTime time.Time, limit int) ([]types.ClosedPnLRecord, error) { if limit <= 0 { limit = 100 } if limit > 100 { limit = 100 } // KuCoin closed positions API path := fmt.Sprintf("/api/v1/history-positions?status=CLOSE&limit=%d", limit) if !startTime.IsZero() { path += fmt.Sprintf("&from=%d", startTime.UnixMilli()) } data, err := t.doRequest("GET", path, nil) if err != nil { return nil, fmt.Errorf("failed to get closed PnL: %w", err) } var response struct { HasMore bool `json:"hasMore"` DataList []struct { Symbol string `json:"symbol"` OpenPrice float64 `json:"avgEntryPrice"` ClosePrice float64 `json:"avgClosePrice"` Qty int64 `json:"qty"` RealisedPnl float64 `json:"realisedGrossCost"` CloseTime int64 `json:"closeTime"` OpenTime int64 `json:"openTime"` PositionId string `json:"id"` CloseType string `json:"type"` Leverage int `json:"leverage"` SettleCurrency string `json:"settleCurrency"` } `json:"dataList"` } if err := json.Unmarshal(data, &response); err != nil { return nil, fmt.Errorf("failed to parse closed PnL: %w", err) } var records []types.ClosedPnLRecord for _, item := range response.DataList { side := "long" qty := item.Qty if qty < 0 { side = "short" qty = -qty } // Map close type closeType := "unknown" switch strings.ToUpper(item.CloseType) { case "CLOSE", "MANUAL": closeType = "manual" case "STOP", "STOPLOSS": closeType = "stop_loss" case "TAKEPROFIT", "TP": closeType = "take_profit" case "LIQUIDATION", "LIQ", "ADL": closeType = "liquidation" } records = append(records, types.ClosedPnLRecord{ Symbol: t.convertSymbolBack(item.Symbol), Side: side, EntryPrice: item.OpenPrice, ExitPrice: item.ClosePrice, Quantity: float64(qty), RealizedPnL: item.RealisedPnl, Leverage: item.Leverage, EntryTime: time.UnixMilli(item.OpenTime), ExitTime: time.UnixMilli(item.CloseTime), ExchangeID: item.PositionId, CloseType: closeType, }) } return records, nil } // GetOpenOrders gets open/pending orders func (t *KuCoinTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, error) { kcSymbol := t.convertSymbol(symbol) // Get regular orders path := fmt.Sprintf("%s?symbol=%s&status=active", kucoinOrderPath, kcSymbol) data, err := t.doRequest("GET", path, nil) if err != nil { return nil, fmt.Errorf("failed to get open orders: %w", err) } var response struct { Items []struct { Id string `json:"id"` Symbol string `json:"symbol"` Side string `json:"side"` Type string `json:"type"` Price string `json:"price"` Size int64 `json:"size"` StopType string `json:"stopType"` } `json:"items"` } if err := json.Unmarshal(data, &response); err != nil { // Try alternate format var items []struct { Id string `json:"id"` Symbol string `json:"symbol"` Side string `json:"side"` Type string `json:"type"` Price string `json:"price"` Size int64 `json:"size"` StopType string `json:"stopType"` } if err := json.Unmarshal(data, &items); err != nil { return nil, err } response.Items = items } var orders []types.OpenOrder for _, item := range response.Items { // Determine position side based on order side positionSide := "LONG" if item.Side == "sell" { positionSide = "SHORT" } price, _ := strconv.ParseFloat(item.Price, 64) orders = append(orders, types.OpenOrder{ OrderID: item.Id, Symbol: t.convertSymbolBack(item.Symbol), Side: strings.ToUpper(item.Side), PositionSide: positionSide, Type: strings.ToUpper(item.Type), Price: price, Quantity: float64(item.Size), Status: "NEW", }) } // Get stop orders stopPath := fmt.Sprintf("%s?symbol=%s", kucoinStopOrderPath, kcSymbol) stopData, err := t.doRequest("GET", stopPath, nil) if err == nil { var stopResponse struct { Items []struct { Id string `json:"id"` Symbol string `json:"symbol"` Side string `json:"side"` StopPrice string `json:"stopPrice"` Size int64 `json:"size"` } `json:"items"` } if json.Unmarshal(stopData, &stopResponse) == nil { for _, item := range stopResponse.Items { positionSide := "LONG" if item.Side == "sell" { positionSide = "SHORT" } stopPrice, _ := strconv.ParseFloat(item.StopPrice, 64) orders = append(orders, types.OpenOrder{ OrderID: item.Id, Symbol: t.convertSymbolBack(item.Symbol), Side: strings.ToUpper(item.Side), PositionSide: positionSide, Type: "STOP_MARKET", StopPrice: stopPrice, Quantity: float64(item.Size), Status: "NEW", }) } } } return orders, nil }