From b32a3566e6faafd98b02c9eddd557b06cbce538e Mon Sep 17 00:00:00 2001 From: tinkle-community Date: Wed, 4 Feb 2026 02:10:26 +0800 Subject: [PATCH] feat(kucoin): add order sync and fix price precision - Add KuCoin order sync with proper API response parsing - Use openFeePay/closeFeePay to determine open/close trades - Get contract multiplier from API for accurate qty calculation - Fix price rounding: 2 decimals -> 8 decimals for low-price coins - Add comprehensive tests for trades, positions, and P&L --- store/position.go | 6 +- store/position_builder.go | 3 +- trader/kucoin/order_sync.go | 412 ++++++++++ trader/kucoin/order_sync_test.go | 628 +++++++++++++++ trader/kucoin/trader.go | 1228 ++++++++++++++++++++++++++++++ 5 files changed, 2274 insertions(+), 3 deletions(-) create mode 100644 trader/kucoin/order_sync.go create mode 100644 trader/kucoin/order_sync_test.go create mode 100644 trader/kucoin/trader.go diff --git a/store/position.go b/store/position.go index f8337929..61de4001 100644 --- a/store/position.go +++ b/store/position.go @@ -156,7 +156,8 @@ func (s *PositionStore) UpdatePositionQuantityAndPrice(id int64, addQty float64, newQty := math.Round((pos.Quantity+addQty)*10000) / 10000 newEntryQty := math.Round((currentEntryQty+addQty)*10000) / 10000 newEntryPrice := (pos.EntryPrice*pos.Quantity + addPrice*addQty) / newQty - newEntryPrice = math.Round(newEntryPrice*100) / 100 + // Use 8 decimal places for price precision (crypto standard) + newEntryPrice = math.Round(newEntryPrice*100000000) / 100000000 newFee := pos.Fee + addFee nowMs := time.Now().UTC().UnixMilli() @@ -187,7 +188,8 @@ func (s *PositionStore) ReducePositionQuantity(id int64, reduceQty float64, exit var newExitPrice float64 if newClosedQty > 0 { newExitPrice = (pos.ExitPrice*closedQty + exitPrice*reduceQty) / newClosedQty - newExitPrice = math.Round(newExitPrice*100) / 100 + // Use 8 decimal places for price precision (crypto standard) + newExitPrice = math.Round(newExitPrice*100000000) / 100000000 } nowMs := time.Now().UTC().UnixMilli() diff --git a/store/position_builder.go b/store/position_builder.go index d4b4a06e..bab885b9 100644 --- a/store/position_builder.go +++ b/store/position_builder.go @@ -147,7 +147,8 @@ func (pb *PositionBuilder) handleClose( var finalExitPrice float64 if totalClosed > 0 { finalExitPrice = (position.ExitPrice*closedBefore + price*closeQty) / totalClosed - finalExitPrice = math.Round(finalExitPrice*100) / 100 + // Use 8 decimal places for price precision (crypto standard) + finalExitPrice = math.Round(finalExitPrice*100000000) / 100000000 } else { finalExitPrice = price } diff --git a/trader/kucoin/order_sync.go b/trader/kucoin/order_sync.go new file mode 100644 index 00000000..a817075e --- /dev/null +++ b/trader/kucoin/order_sync.go @@ -0,0 +1,412 @@ +package kucoin + +import ( + "encoding/json" + "fmt" + "nofx/logger" + "nofx/store" + "nofx/trader/types" + "sort" + "strings" + "time" +) + +// KuCoinTrade represents a trade record from KuCoin fill history +type KuCoinTrade struct { + Symbol string + TradeID string + OrderID string + Side string // buy or sell + FillPrice float64 + FillQty float64 // In base currency (e.g., ETH), not lots + Fee float64 + FeeAsset string + ExecTime time.Time + ProfitLoss float64 + OrderAction string // open_long, open_short, close_long, close_short +} + +// GetTrades retrieves trade/fill records from KuCoin +func (t *KuCoinTrader) GetTrades(startTime time.Time, limit int) ([]KuCoinTrade, error) { + if limit <= 0 { + limit = 100 + } + if limit > 100 { + limit = 100 // KuCoin max limit + } + + // Build query path + path := fmt.Sprintf("%s?pageSize=%d", kucoinFillsPath, limit) + if !startTime.IsZero() { + path += fmt.Sprintf("&startAt=%d", startTime.UnixMilli()) + } + + data, err := t.doRequest("GET", path, nil) + if err != nil { + return nil, fmt.Errorf("failed to get trade history: %w", err) + } + + var response struct { + CurrentPage int `json:"currentPage"` + PageSize int `json:"pageSize"` + TotalNum int `json:"totalNum"` + TotalPage int `json:"totalPage"` + Items []struct { + Symbol string `json:"symbol"` + TradeId string `json:"tradeId"` + OrderId string `json:"orderId"` + Side string `json:"side"` + Price string `json:"price"` + Size int64 `json:"size"` + Value string `json:"value"` // Trade value in quote currency + Fee string `json:"fee"` // Total fee + FeeRate string `json:"feeRate"` // Fee rate + FeeCurrency string `json:"feeCurrency"` // Fee currency (USDT) + OpenFeePay string `json:"openFeePay"` // Fee for opening (>0 means opening trade) + CloseFeePay string `json:"closeFeePay"` // Fee for closing (>0 means closing trade) + TradeTime int64 `json:"tradeTime"` // Nanoseconds + MarginMode string `json:"marginMode"` // CROSS or ISOLATED + OrderType string `json:"orderType"` // market, limit + } `json:"items"` + } + + if err := json.Unmarshal(data, &response); err != nil { + return nil, fmt.Errorf("failed to parse trade history: %w", err) + } + + logger.Infof("šŸ“„ Received %d trades from KuCoin", len(response.Items)) + + result := make([]KuCoinTrade, 0, len(response.Items)) + + for _, trade := range response.Items { + // Parse numeric values from strings + var fillPrice, fee, openFeePay, closeFeePay float64 + fmt.Sscanf(trade.Price, "%f", &fillPrice) + fmt.Sscanf(trade.Fee, "%f", &fee) + fmt.Sscanf(trade.OpenFeePay, "%f", &openFeePay) + fmt.Sscanf(trade.CloseFeePay, "%f", &closeFeePay) + + // Get multiplier from contract info + symbol := t.convertSymbolBack(trade.Symbol) + var multiplier float64 + contract, err := t.getContract(symbol) + if err == nil && contract != nil { + multiplier = contract.Multiplier + } else { + // Default multipliers based on symbol + if strings.Contains(symbol, "BTC") { + multiplier = 0.001 + } else { + multiplier = 0.01 // Default for altcoins + } + } + + // Convert lots to actual quantity + absSize := trade.Size + if absSize < 0 { + absSize = -absSize + } + fillQty := float64(absSize) * multiplier + + // Determine side and order action + // KuCoin uses openFeePay/closeFeePay to indicate if trade is opening or closing + side := strings.ToUpper(trade.Side) // BUY or SELL + isClosing := closeFeePay > 0 + + var orderAction string + if trade.Side == "buy" { + if isClosing { + // Buying to close short + orderAction = "close_short" + } else { + // Buying to open long + orderAction = "open_long" + } + } else { // sell + if isClosing { + // Selling to close long + orderAction = "close_long" + } else { + // Selling to open short + orderAction = "open_short" + } + } + + // Trade time is in nanoseconds + execTime := time.Unix(0, trade.TradeTime) + + result = append(result, KuCoinTrade{ + Symbol: symbol, + TradeID: trade.TradeId, + OrderID: trade.OrderId, + Side: side, + FillPrice: fillPrice, + FillQty: fillQty, + Fee: fee, + FeeAsset: trade.FeeCurrency, + ExecTime: execTime, + ProfitLoss: 0, // KuCoin fills API doesn't return PnL per trade + OrderAction: orderAction, + }) + } + + // Sort by execution time (oldest first) + sort.Slice(result, func(i, j int) bool { + return result[i].ExecTime.Before(result[j].ExecTime) + }) + + return result, nil +} + +// GetRecentTrades retrieves recent trades (faster, no pagination) +func (t *KuCoinTrader) GetRecentTrades() ([]KuCoinTrade, error) { + data, err := t.doRequest("GET", kucoinRecentFillsPath, nil) + if err != nil { + return nil, fmt.Errorf("failed to get recent trades: %w", err) + } + + var trades []struct { + Symbol string `json:"symbol"` + TradeId string `json:"tradeId"` + OrderId string `json:"orderId"` + Side string `json:"side"` + Price string `json:"price"` + Size int64 `json:"size"` + Fee string `json:"fee"` + FeeCurrency string `json:"feeCurrency"` + OpenFeePay string `json:"openFeePay"` + CloseFeePay string `json:"closeFeePay"` + TradeTime int64 `json:"tradeTime"` + } + + if err := json.Unmarshal(data, &trades); err != nil { + return nil, fmt.Errorf("failed to parse recent trades: %w", err) + } + + result := make([]KuCoinTrade, 0, len(trades)) + + for _, trade := range trades { + var fillPrice, fee, openFeePay, closeFeePay float64 + fmt.Sscanf(trade.Price, "%f", &fillPrice) + fmt.Sscanf(trade.Fee, "%f", &fee) + fmt.Sscanf(trade.OpenFeePay, "%f", &openFeePay) + fmt.Sscanf(trade.CloseFeePay, "%f", &closeFeePay) + + // Get multiplier from contract info + symbol := t.convertSymbolBack(trade.Symbol) + var multiplier float64 + contract, err := t.getContract(symbol) + if err == nil && contract != nil { + multiplier = contract.Multiplier + } else { + if strings.Contains(symbol, "BTC") { + multiplier = 0.001 + } else { + multiplier = 0.01 + } + } + + absSize := trade.Size + if absSize < 0 { + absSize = -absSize + } + fillQty := float64(absSize) * multiplier + + side := strings.ToUpper(trade.Side) + isClosing := closeFeePay > 0 + + var orderAction string + if trade.Side == "buy" { + if isClosing { + orderAction = "close_short" + } else { + orderAction = "open_long" + } + } else { + if isClosing { + orderAction = "close_long" + } else { + orderAction = "open_short" + } + } + + execTime := time.Unix(0, trade.TradeTime) + + result = append(result, KuCoinTrade{ + Symbol: symbol, + TradeID: trade.TradeId, + OrderID: trade.OrderId, + Side: side, + FillPrice: fillPrice, + FillQty: fillQty, + Fee: fee, + FeeAsset: trade.FeeCurrency, + ExecTime: execTime, + ProfitLoss: 0, + OrderAction: orderAction, + }) + } + + return result, nil +} + +// ToTradeRecord converts KuCoinTrade to types.TradeRecord +func (t *KuCoinTrade) ToTradeRecord() types.TradeRecord { + // Determine position side from order action + positionSide := "LONG" + if strings.Contains(t.OrderAction, "short") { + positionSide = "SHORT" + } + + return types.TradeRecord{ + TradeID: t.TradeID, + Symbol: t.Symbol, + Side: t.Side, + PositionSide: positionSide, + OrderAction: t.OrderAction, + Price: t.FillPrice, + Quantity: t.FillQty, + RealizedPnL: t.ProfitLoss, + Fee: t.Fee, + Time: t.ExecTime, + } +} + +// SyncOrdersFromKuCoin syncs KuCoin exchange order history to local database +// Also creates/updates position records to ensure orders/fills/positions data consistency +// exchangeID: Exchange account UUID (from exchanges.id) +// exchangeType: Exchange type ("kucoin") +func (t *KuCoinTrader) SyncOrdersFromKuCoin(traderID string, exchangeID string, exchangeType string, st *store.Store) error { + if st == nil { + return fmt.Errorf("store is nil") + } + + // Get recent trades (last 24 hours) + startTime := time.Now().Add(-24 * time.Hour) + + logger.Infof("šŸ”„ Syncing KuCoin trades from: %s", startTime.Format(time.RFC3339)) + + // Use GetTrades method to fetch trade records + trades, err := t.GetTrades(startTime, 100) + if err != nil { + return fmt.Errorf("failed to get trades: %w", err) + } + + logger.Infof("šŸ“„ Received %d trades from KuCoin", len(trades)) + + // Sort trades by time ASC (oldest first) for proper position building + sort.Slice(trades, func(i, j int) bool { + return trades[i].ExecTime.UnixMilli() < trades[j].ExecTime.UnixMilli() + }) + + // Process trades one by one (no transaction to avoid deadlock) + orderStore := st.Order() + positionStore := st.Position() + posBuilder := store.NewPositionBuilder(positionStore) + syncedCount := 0 + + for _, trade := range trades { + // Check if trade already exists (use exchangeID which is UUID, not exchange type) + existing, err := orderStore.GetOrderByExchangeID(exchangeID, trade.TradeID) + if err == nil && existing != nil { + continue // Order already exists, skip + } + + // Symbol is already normalized in GetTrades + symbol := trade.Symbol + + // Determine position side from order action + positionSide := "LONG" + if strings.Contains(trade.OrderAction, "short") { + positionSide = "SHORT" + } + + // Normalize side for storage + side := strings.ToUpper(trade.Side) + + // Create order record - use UTC time in milliseconds to avoid timezone issues + execTimeMs := trade.ExecTime.UTC().UnixMilli() + orderRecord := &store.TraderOrder{ + TraderID: traderID, + ExchangeID: exchangeID, // UUID + ExchangeType: exchangeType, // Exchange type + ExchangeOrderID: trade.TradeID, + Symbol: symbol, + Side: side, + PositionSide: "BOTH", // KuCoin uses one-way position mode + Type: "MARKET", + OrderAction: trade.OrderAction, + Quantity: trade.FillQty, + Price: trade.FillPrice, + Status: "FILLED", + FilledQuantity: trade.FillQty, + AvgFillPrice: trade.FillPrice, + Commission: trade.Fee, + FilledAt: execTimeMs, + CreatedAt: execTimeMs, + UpdatedAt: execTimeMs, + } + + // Insert order record + if err := orderStore.CreateOrder(orderRecord); err != nil { + logger.Infof(" āš ļø Failed to sync trade %s: %v", trade.TradeID, err) + continue + } + + // Create fill record - use UTC time in milliseconds + fillRecord := &store.TraderFill{ + TraderID: traderID, + ExchangeID: exchangeID, // UUID + ExchangeType: exchangeType, // Exchange type + OrderID: orderRecord.ID, + ExchangeOrderID: trade.OrderID, + ExchangeTradeID: trade.TradeID, + Symbol: symbol, + Side: side, + Price: trade.FillPrice, + Quantity: trade.FillQty, + QuoteQuantity: trade.FillPrice * trade.FillQty, + Commission: trade.Fee, + CommissionAsset: trade.FeeAsset, + RealizedPnL: trade.ProfitLoss, + IsMaker: false, + CreatedAt: execTimeMs, + } + + if err := orderStore.CreateFill(fillRecord); err != nil { + logger.Infof(" āš ļø Failed to sync fill for trade %s: %v", trade.TradeID, err) + } + + // Create/update position record using PositionBuilder + if err := posBuilder.ProcessTrade( + traderID, exchangeID, exchangeType, + symbol, positionSide, trade.OrderAction, + trade.FillQty, trade.FillPrice, trade.Fee, trade.ProfitLoss, + execTimeMs, trade.TradeID, + ); err != nil { + logger.Infof(" āš ļø Failed to sync position for trade %s: %v", trade.TradeID, err) + } else { + logger.Infof(" šŸ“ Position updated for trade: %s (action: %s, qty: %.6f)", trade.TradeID, trade.OrderAction, trade.FillQty) + } + + syncedCount++ + logger.Infof(" āœ… Synced trade: %s %s %s qty=%.6f price=%.6f pnl=%.2f fee=%.6f action=%s", + trade.TradeID, symbol, side, trade.FillQty, trade.FillPrice, trade.ProfitLoss, trade.Fee, trade.OrderAction) + } + + logger.Infof("āœ… KuCoin order sync completed: %d new trades synced", syncedCount) + return nil +} + +// StartOrderSync starts background order sync task for KuCoin +func (t *KuCoinTrader) StartOrderSync(traderID string, exchangeID string, exchangeType string, st *store.Store, interval time.Duration) { + ticker := time.NewTicker(interval) + go func() { + for range ticker.C { + if err := t.SyncOrdersFromKuCoin(traderID, exchangeID, exchangeType, st); err != nil { + logger.Infof("āš ļø KuCoin order sync failed: %v", err) + } + } + }() + logger.Infof("šŸ”„ KuCoin order sync started (interval: %v)", interval) +} diff --git a/trader/kucoin/order_sync_test.go b/trader/kucoin/order_sync_test.go new file mode 100644 index 00000000..eae38a8d --- /dev/null +++ b/trader/kucoin/order_sync_test.go @@ -0,0 +1,628 @@ +package kucoin + +import ( + "encoding/json" + "fmt" + "os" + "testing" + "time" +) + +// Test credentials - set via environment variables +func getKuCoinTestCredentials(t *testing.T) (string, string, string) { + apiKey := os.Getenv("KUCOIN_TEST_API_KEY") + secretKey := os.Getenv("KUCOIN_TEST_SECRET_KEY") + passphrase := os.Getenv("KUCOIN_TEST_PASSPHRASE") + + if apiKey == "" || secretKey == "" || passphrase == "" { + t.Skip("KuCoin test credentials not set (KUCOIN_TEST_API_KEY, KUCOIN_TEST_SECRET_KEY, KUCOIN_TEST_PASSPHRASE)") + } + + return apiKey, secretKey, passphrase +} + +func createKuCoinTestTrader(t *testing.T) *KuCoinTrader { + apiKey, secretKey, passphrase := getKuCoinTestCredentials(t) + trader := NewKuCoinTrader(apiKey, secretKey, passphrase) + return trader +} + +// TestKuCoinConnection tests basic API connectivity +func TestKuCoinConnection(t *testing.T) { + trader := createKuCoinTestTrader(t) + + balance, err := trader.GetBalance() + if err != nil { + t.Fatalf("Failed to get balance: %v", err) + } + + t.Logf("āœ… Connection OK") + t.Logf(" totalWalletBalance: %v", balance["totalWalletBalance"]) + t.Logf(" availableBalance: %v", balance["availableBalance"]) + t.Logf(" totalUnrealizedProfit: %v", balance["totalUnrealizedProfit"]) + t.Logf(" totalEquity: %v", balance["totalEquity"]) +} + +// TestKuCoinGetPositions tests position retrieval +func TestKuCoinGetPositions(t *testing.T) { + trader := createKuCoinTestTrader(t) + + positions, err := trader.GetPositions() + if err != nil { + t.Fatalf("Failed to get positions: %v", err) + } + + t.Logf("šŸ“Š Found %d positions:", len(positions)) + for i, pos := range positions { + symbol := pos["symbol"].(string) + side := pos["side"].(string) + posAmt := pos["positionAmt"].(float64) + entryPrice := pos["entryPrice"].(float64) + markPrice := pos["markPrice"].(float64) + unrealizedPnl := pos["unRealizedProfit"].(float64) + leverage := pos["leverage"].(float64) + mgnMode := pos["mgnMode"].(string) + + t.Logf(" [%d] %s %s: qty=%.6f entry=%.4f mark=%.4f pnl=%.4f lev=%.0f mode=%s", + i+1, symbol, side, posAmt, entryPrice, markPrice, unrealizedPnl, leverage, mgnMode) + } +} + +// TestKuCoinGetTrades tests trade history retrieval with proper JSON parsing +func TestKuCoinGetTrades(t *testing.T) { + trader := createKuCoinTestTrader(t) + + // Get trades from last 24 hours (KuCoin API quirk: >24h startAt returns 0) + startTime := time.Now().Add(-24 * time.Hour) + + trades, err := trader.GetTrades(startTime, 100) + if err != nil { + t.Fatalf("Failed to get trades: %v", err) + } + + t.Logf("šŸ“‹ Retrieved %d trades from KuCoin:", len(trades)) + for i, trade := range trades { + t.Logf(" [%d] %s | TradeID: %s | OrderID: %s", i+1, trade.ExecTime.Format("2006-01-02 15:04:05"), trade.TradeID, trade.OrderID) + t.Logf(" Symbol: %s | Side: %s | Action: %s", trade.Symbol, trade.Side, trade.OrderAction) + t.Logf(" Price: %.4f | Qty: %.6f | Fee: %.6f %s", trade.FillPrice, trade.FillQty, trade.Fee, trade.FeeAsset) + t.Logf(" PnL: %.4f", trade.ProfitLoss) + } + + // Verify trade data integrity + for i, trade := range trades { + if trade.TradeID == "" { + t.Errorf("Trade %d has empty TradeID", i) + } + if trade.Symbol == "" { + t.Errorf("Trade %d has empty Symbol", i) + } + if trade.Side != "BUY" && trade.Side != "SELL" { + t.Errorf("Trade %d has invalid Side: %s (expected BUY or SELL)", i, trade.Side) + } + if trade.OrderAction != "open_long" && trade.OrderAction != "open_short" && + trade.OrderAction != "close_long" && trade.OrderAction != "close_short" { + t.Errorf("Trade %d has invalid OrderAction: %s", i, trade.OrderAction) + } + if trade.FillPrice <= 0 { + t.Errorf("Trade %d has invalid FillPrice: %.6f", i, trade.FillPrice) + } + if trade.FillQty <= 0 { + t.Errorf("Trade %d has invalid FillQty: %.6f", i, trade.FillQty) + } + } +} + +// TestKuCoinGetRecentTrades tests recent trades endpoint +func TestKuCoinGetRecentTrades(t *testing.T) { + trader := createKuCoinTestTrader(t) + + trades, err := trader.GetRecentTrades() + if err != nil { + t.Fatalf("Failed to get recent trades: %v", err) + } + + t.Logf("šŸ“‹ Retrieved %d recent trades from KuCoin:", len(trades)) + for i, trade := range trades { + t.Logf(" [%d] %s %s %s qty=%.6f price=%.4f pnl=%.4f action=%s", + i+1, trade.ExecTime.Format("01-02 15:04:05"), trade.Symbol, trade.Side, + trade.FillQty, trade.FillPrice, trade.ProfitLoss, trade.OrderAction) + } +} + +// TestKuCoinTradeToRecord tests conversion to TradeRecord +func TestKuCoinTradeToRecord(t *testing.T) { + // Test open_long + trade1 := KuCoinTrade{ + TradeID: "test-trade-1", + Symbol: "BTCUSDT", + Side: "BUY", + OrderAction: "open_long", + FillPrice: 50000.0, + FillQty: 0.01, + Fee: 0.5, + ProfitLoss: 0, + } + record1 := trade1.ToTradeRecord() + if record1.PositionSide != "LONG" { + t.Errorf("open_long should have PositionSide=LONG, got %s", record1.PositionSide) + } + + // Test close_long + trade2 := KuCoinTrade{ + TradeID: "test-trade-2", + Symbol: "BTCUSDT", + Side: "SELL", + OrderAction: "close_long", + FillPrice: 51000.0, + FillQty: 0.01, + Fee: 0.5, + ProfitLoss: 10.0, + } + record2 := trade2.ToTradeRecord() + if record2.PositionSide != "LONG" { + t.Errorf("close_long should have PositionSide=LONG, got %s", record2.PositionSide) + } + + // Test open_short + trade3 := KuCoinTrade{ + TradeID: "test-trade-3", + Symbol: "ETHUSDT", + Side: "SELL", + OrderAction: "open_short", + FillPrice: 3000.0, + FillQty: 0.1, + Fee: 0.3, + ProfitLoss: 0, + } + record3 := trade3.ToTradeRecord() + if record3.PositionSide != "SHORT" { + t.Errorf("open_short should have PositionSide=SHORT, got %s", record3.PositionSide) + } + + // Test close_short + trade4 := KuCoinTrade{ + TradeID: "test-trade-4", + Symbol: "ETHUSDT", + Side: "BUY", + OrderAction: "close_short", + FillPrice: 2900.0, + FillQty: 0.1, + Fee: 0.3, + ProfitLoss: 10.0, + } + record4 := trade4.ToTradeRecord() + if record4.PositionSide != "SHORT" { + t.Errorf("close_short should have PositionSide=SHORT, got %s", record4.PositionSide) + } + + t.Logf("āœ… TradeRecord conversion tests passed") +} + +// TestKuCoinOrderActionDetermination tests that order action is correctly determined +func TestKuCoinOrderActionDetermination(t *testing.T) { + trader := createKuCoinTestTrader(t) + + startTime := time.Now().Add(-24 * time.Hour) + trades, err := trader.GetTrades(startTime, 100) + if err != nil { + t.Fatalf("Failed to get trades: %v", err) + } + + // Analyze trade patterns + actionCounts := make(map[string]int) + for _, trade := range trades { + actionCounts[trade.OrderAction]++ + } + + t.Logf("šŸ“Š Order action distribution:") + for action, count := range actionCounts { + t.Logf(" %s: %d", action, count) + } + + // Verify logical consistency: + // - BUY + open_long: opening a long position + // - BUY + close_short: closing a short position + // - SELL + open_short: opening a short position + // - SELL + close_long: closing a long position + for i, trade := range trades { + switch trade.OrderAction { + case "open_long": + if trade.Side != "BUY" { + t.Errorf("Trade %d: open_long should have Side=BUY, got %s", i, trade.Side) + } + case "close_short": + if trade.Side != "BUY" { + t.Errorf("Trade %d: close_short should have Side=BUY, got %s", i, trade.Side) + } + case "open_short": + if trade.Side != "SELL" { + t.Errorf("Trade %d: open_short should have Side=SELL, got %s", i, trade.Side) + } + case "close_long": + if trade.Side != "SELL" { + t.Errorf("Trade %d: close_long should have Side=SELL, got %s", i, trade.Side) + } + } + } +} + +// TestKuCoinPositionBuilding tests that trades can be used to build position state +func TestKuCoinPositionBuilding(t *testing.T) { + trader := createKuCoinTestTrader(t) + + startTime := time.Now().Add(-24 * time.Hour) + trades, err := trader.GetTrades(startTime, 100) + if err != nil { + t.Fatalf("Failed to get trades: %v", err) + } + + // Group trades by symbol and build position state + type PositionState struct { + LongQty float64 + ShortQty float64 + LongPnL float64 + ShortPnL float64 + TradeCount int + } + positions := make(map[string]*PositionState) + + for _, trade := range trades { + if positions[trade.Symbol] == nil { + positions[trade.Symbol] = &PositionState{} + } + pos := positions[trade.Symbol] + pos.TradeCount++ + + switch trade.OrderAction { + case "open_long": + pos.LongQty += trade.FillQty + case "close_long": + pos.LongQty -= trade.FillQty + pos.LongPnL += trade.ProfitLoss + case "open_short": + pos.ShortQty += trade.FillQty + case "close_short": + pos.ShortQty -= trade.FillQty + pos.ShortPnL += trade.ProfitLoss + } + } + + t.Logf("šŸ“Š Calculated position states from %d trades:", len(trades)) + for symbol, pos := range positions { + t.Logf(" %s: trades=%d longQty=%.6f shortQty=%.6f longPnL=%.4f shortPnL=%.4f", + symbol, pos.TradeCount, pos.LongQty, pos.ShortQty, pos.LongPnL, pos.ShortPnL) + } + + // Now compare with actual positions from exchange + actualPositions, err := trader.GetPositions() + if err != nil { + t.Fatalf("Failed to get actual positions: %v", err) + } + + t.Logf("\nšŸ“Š Actual positions from exchange:") + for _, pos := range actualPositions { + symbol := pos["symbol"].(string) + side := pos["side"].(string) + qty := pos["positionAmt"].(float64) + t.Logf(" %s %s: qty=%.6f", symbol, side, qty) + } +} + +// TestKuCoinRawAPIResponse tests raw API response to verify field types +func TestKuCoinRawAPIResponse(t *testing.T) { + trader := createKuCoinTestTrader(t) + + // Make raw request to fills endpoint + startTime := time.Now().Add(-24 * time.Hour) + path := fmt.Sprintf("%s?pageSize=10&startAt=%d", kucoinFillsPath, startTime.UnixMilli()) + + data, err := trader.doRequest("GET", path, nil) + if err != nil { + t.Fatalf("Failed to get raw fills data: %v", err) + } + + t.Logf("šŸ“‹ Raw API response (first 2000 chars):") + response := string(data) + if len(response) > 2000 { + response = response[:2000] + "..." + } + t.Logf("%s", response) +} + +// TestKuCoinValueCalculation tests that calculated value (price * qty) matches API value +// This is the key test to verify multiplier and qty calculation is correct +func TestKuCoinValueCalculation(t *testing.T) { + trader := createKuCoinTestTrader(t) + + // Get raw API response to compare + path := fmt.Sprintf("%s?pageSize=20", kucoinFillsPath) + data, err := trader.doRequest("GET", path, nil) + if err != nil { + t.Fatalf("Failed to get raw fills: %v", err) + } + + var rawResponse struct { + Items []struct { + Symbol string `json:"symbol"` + TradeId string `json:"tradeId"` + Price string `json:"price"` + Size int64 `json:"size"` + Value string `json:"value"` // This is the actual USDT value from API + Side string `json:"side"` + } `json:"items"` + } + if err := json.Unmarshal(data, &rawResponse); err != nil { + t.Fatalf("Failed to parse raw response: %v", err) + } + + // Get trades via GetTrades + trades, err := trader.GetTrades(time.Time{}, 20) + if err != nil { + t.Fatalf("Failed to get trades: %v", err) + } + + // Build a map of tradeID -> calculated value + calculatedValues := make(map[string]float64) + for _, trade := range trades { + calculatedValues[trade.TradeID] = trade.FillPrice * trade.FillQty + } + + t.Logf("Comparing API value vs calculated value (price * qty):") + t.Logf("==========================================") + + errorCount := 0 + for i, raw := range rawResponse.Items { + if i >= 10 { + break + } + + var apiValue float64 + fmt.Sscanf(raw.Value, "%f", &apiValue) + + calculatedValue, exists := calculatedValues[raw.TradeId] + if !exists { + t.Errorf("Trade %s not found in GetTrades result", raw.TradeId) + continue + } + + // Allow 1% tolerance for rounding + tolerance := apiValue * 0.01 + diff := calculatedValue - apiValue + if diff < 0 { + diff = -diff + } + + status := "āœ…" + if diff > tolerance { + status = "āŒ" + errorCount++ + } + + t.Logf(" %s [%d] %s: API value=%.4f, Calculated=%.4f, Diff=%.4f", + status, i+1, raw.Symbol, apiValue, calculatedValue, diff) + } + + if errorCount > 0 { + t.Errorf("Found %d trades with incorrect value calculation", errorCount) + } +} + +// TestKuCoinEntryExitPrice tests that entry/exit prices are correctly captured +func TestKuCoinEntryExitPrice(t *testing.T) { + trader := createKuCoinTestTrader(t) + + trades, err := trader.GetTrades(time.Time{}, 50) + if err != nil { + t.Fatalf("Failed to get trades: %v", err) + } + + // Group trades by symbol to track entry/exit + type PositionTracker struct { + OpenTrades []KuCoinTrade + CloseTrades []KuCoinTrade + } + positions := make(map[string]*PositionTracker) + + for _, trade := range trades { + if positions[trade.Symbol] == nil { + positions[trade.Symbol] = &PositionTracker{} + } + if trade.OrderAction == "open_long" || trade.OrderAction == "open_short" { + positions[trade.Symbol].OpenTrades = append(positions[trade.Symbol].OpenTrades, trade) + } else { + positions[trade.Symbol].CloseTrades = append(positions[trade.Symbol].CloseTrades, trade) + } + } + + t.Logf("Entry/Exit price analysis:") + t.Logf("==========================") + + for symbol, pos := range positions { + if len(pos.OpenTrades) == 0 && len(pos.CloseTrades) == 0 { + continue + } + + // Calculate weighted average entry price + var totalEntryValue, totalEntryQty float64 + for _, trade := range pos.OpenTrades { + totalEntryValue += trade.FillPrice * trade.FillQty + totalEntryQty += trade.FillQty + } + avgEntryPrice := 0.0 + if totalEntryQty > 0 { + avgEntryPrice = totalEntryValue / totalEntryQty + } + + // Calculate weighted average exit price + var totalExitValue, totalExitQty float64 + for _, trade := range pos.CloseTrades { + totalExitValue += trade.FillPrice * trade.FillQty + totalExitQty += trade.FillQty + } + avgExitPrice := 0.0 + if totalExitQty > 0 { + avgExitPrice = totalExitValue / totalExitQty + } + + // Calculate P&L (simplified: (exit - entry) * qty for long) + pnl := 0.0 + if totalEntryQty > 0 && totalExitQty > 0 { + // Use the smaller qty for P&L calculation + closedQty := totalExitQty + if totalEntryQty < closedQty { + closedQty = totalEntryQty + } + pnl = (avgExitPrice - avgEntryPrice) * closedQty + } + + t.Logf(" %s:", symbol) + t.Logf(" Entry: %d trades, total qty=%.6f, avg price=%.6f, value=%.2f USDT", + len(pos.OpenTrades), totalEntryQty, avgEntryPrice, totalEntryValue) + t.Logf(" Exit: %d trades, total qty=%.6f, avg price=%.6f, value=%.2f USDT", + len(pos.CloseTrades), totalExitQty, avgExitPrice, totalExitValue) + t.Logf(" Calculated P&L: %.4f USDT", pnl) + + // Verify entry qty matches exit qty for closed positions + if len(pos.OpenTrades) > 0 && len(pos.CloseTrades) > 0 { + qtyDiff := totalEntryQty - totalExitQty + if qtyDiff < 0 { + qtyDiff = -qtyDiff + } + tolerance := totalEntryQty * 0.001 // 0.1% tolerance + if qtyDiff > tolerance { + t.Logf(" āš ļø Entry/Exit qty mismatch: %.6f", qtyDiff) + } + } + } +} + +// TestKuCoinPnLCalculation tests P&L calculation against actual exchange data +func TestKuCoinPnLCalculation(t *testing.T) { + trader := createKuCoinTestTrader(t) + + // Get current balance for reference + balance, err := trader.GetBalance() + if err != nil { + t.Logf("Warning: Could not get balance: %v", err) + } else { + t.Logf("Current account balance:") + t.Logf(" Total equity: %v", balance["totalEquity"]) + t.Logf(" Available: %v", balance["availableBalance"]) + } + + trades, err := trader.GetTrades(time.Time{}, 50) + if err != nil { + t.Fatalf("Failed to get trades: %v", err) + } + + // Group by symbol and calculate P&L + type SymbolPnL struct { + Symbol string + TotalFees float64 + GrossPnL float64 // From price difference + NetPnL float64 // Gross - fees + OpenQty float64 + CloseQty float64 + AvgOpenPrice float64 + AvgClosePrice float64 + } + pnlBySymbol := make(map[string]*SymbolPnL) + + for _, trade := range trades { + if pnlBySymbol[trade.Symbol] == nil { + pnlBySymbol[trade.Symbol] = &SymbolPnL{Symbol: trade.Symbol} + } + p := pnlBySymbol[trade.Symbol] + p.TotalFees += trade.Fee + + if trade.OrderAction == "open_long" || trade.OrderAction == "open_short" { + p.OpenQty += trade.FillQty + p.AvgOpenPrice = (p.AvgOpenPrice*(p.OpenQty-trade.FillQty) + trade.FillPrice*trade.FillQty) / p.OpenQty + } else { + p.CloseQty += trade.FillQty + p.AvgClosePrice = (p.AvgClosePrice*(p.CloseQty-trade.FillQty) + trade.FillPrice*trade.FillQty) / p.CloseQty + } + } + + t.Logf("\nP&L Summary by Symbol:") + t.Logf("======================") + + var totalGrossPnL, totalFees, totalNetPnL float64 + + for symbol, p := range pnlBySymbol { + closedQty := p.CloseQty + if p.OpenQty < closedQty { + closedQty = p.OpenQty + } + + // For LONG: P&L = (exitPrice - entryPrice) * qty + if closedQty > 0 && p.AvgOpenPrice > 0 && p.AvgClosePrice > 0 { + p.GrossPnL = (p.AvgClosePrice - p.AvgOpenPrice) * closedQty + p.NetPnL = p.GrossPnL - p.TotalFees + } + + totalGrossPnL += p.GrossPnL + totalFees += p.TotalFees + totalNetPnL += p.NetPnL + + t.Logf(" %s:", symbol) + t.Logf(" Open: qty=%.6f @ avg price=%.6f", p.OpenQty, p.AvgOpenPrice) + t.Logf(" Close: qty=%.6f @ avg price=%.6f", p.CloseQty, p.AvgClosePrice) + t.Logf(" Fees: %.4f USDT", p.TotalFees) + t.Logf(" Gross P&L: %.4f USDT", p.GrossPnL) + t.Logf(" Net P&L: %.4f USDT", p.NetPnL) + } + + t.Logf("\nTotal Summary:") + t.Logf(" Total Gross P&L: %.4f USDT", totalGrossPnL) + t.Logf(" Total Fees: %.4f USDT", totalFees) + t.Logf(" Total Net P&L: %.4f USDT", totalNetPnL) +} + +// TestKuCoinGetTradesDebug tests GetTrades with detailed debugging +func TestKuCoinGetTradesDebug(t *testing.T) { + trader := createKuCoinTestTrader(t) + + // Test with different time windows + timeWindows := []struct { + name string + duration time.Duration + }{ + {"1 hour", 1 * time.Hour}, + {"24 hours", 24 * time.Hour}, + {"7 days", 7 * 24 * time.Hour}, + {"no filter", 0}, + } + + for _, tw := range timeWindows { + var startTime time.Time + var path string + if tw.duration > 0 { + startTime = time.Now().Add(-tw.duration) + path = fmt.Sprintf("%s?pageSize=100&startAt=%d", kucoinFillsPath, startTime.UnixMilli()) + } else { + path = fmt.Sprintf("%s?pageSize=100", kucoinFillsPath) + } + + data, err := trader.doRequest("GET", path, nil) + if err != nil { + t.Errorf("Failed to get fills for %s: %v", tw.name, err) + continue + } + + // Parse to count items + var resp struct { + TotalNum int `json:"totalNum"` + Items []struct { + TradeTime int64 `json:"tradeTime"` + } `json:"items"` + } + json.Unmarshal(data, &resp) + + t.Logf("šŸ“‹ %s: totalNum=%d, items=%d", tw.name, resp.TotalNum, len(resp.Items)) + if len(resp.Items) > 0 { + firstTime := time.Unix(0, resp.Items[0].TradeTime) + t.Logf(" First trade time: %s", firstTime.Format(time.RFC3339)) + } + } +} diff --git a/trader/kucoin/trader.go b/trader/kucoin/trader.go new file mode 100644 index 00000000..525acf7c --- /dev/null +++ b/trader/kucoin/trader.go @@ -0,0 +1,1228 @@ +package kucoin + +import ( + "bytes" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "math" + "net/http" + "nofx/logger" + "nofx/trader/types" + "strconv" + "strings" + "sync" + "time" +) + +// KuCoin Futures API endpoints +const ( + kucoinBaseURL = "https://api-futures.kucoin.com" + kucoinAccountPath = "/api/v1/account-overview" + kucoinPositionPath = "/api/v1/positions" + kucoinOrderPath = "/api/v1/orders" + kucoinLeveragePath = "/api/v1/position/margin/leverage" + kucoinTickerPath = "/api/v1/ticker" + kucoinContractsPath = "/api/v1/contracts/active" + kucoinCancelOrderPath = "/api/v1/orders" + kucoinStopOrderPath = "/api/v1/stopOrders" + kucoinCancelStopPath = "/api/v1/stopOrders" + kucoinPositionModePath = "/api/v1/position/margin/auto-deposit-status" + kucoinFillsPath = "/api/v1/fills" + kucoinRecentFillsPath = "/api/v1/recentFills" +) + +// API channel configuration +const ( + kcPartnerID = "NoFxFutures" + kcPartnerKey = "d7c05b0c-c81b-4630-8fa8-ca6d049d3aae" +) + +// KuCoinTrader implements types.Trader interface for KuCoin Futures +type KuCoinTrader struct { + apiKey string + secretKey string + passphrase string + + // HTTP client + httpClient *http.Client + + // Balance cache + cachedBalance map[string]interface{} + balanceCacheTime time.Time + balanceCacheMutex sync.RWMutex + + // Positions cache + cachedPositions []map[string]interface{} + positionsCacheTime time.Time + positionsCacheMutex sync.RWMutex + + // Contract info cache + contractsCache map[string]*KuCoinContract + contractsCacheTime time.Time + contractsCacheMutex sync.RWMutex + + // Cache duration + cacheDuration time.Duration +} + +// KuCoinContract represents contract info +type KuCoinContract struct { + Symbol string // Symbol + BaseCurrency string // Base currency + Multiplier float64 // Contract multiplier + LotSize float64 // Minimum order quantity (lot size) + TickSize float64 // Minimum price increment + MaxOrderQty float64 // Maximum order quantity + MaxLeverage float64 // Maximum leverage + MarkPrice float64 // Current mark price + IsInverse bool // Is inverse contract + QuoteCurrency string // Quote currency + IndexPriceScale int // Index price decimal places +} + +// KuCoinResponse represents KuCoin API response +type KuCoinResponse struct { + Code string `json:"code"` + Msg string `json:"msg"` + Data json.RawMessage `json:"data"` +} + +// NewKuCoinTrader creates a new KuCoin trader instance +func NewKuCoinTrader(apiKey, secretKey, passphrase string) *KuCoinTrader { + httpClient := &http.Client{ + Timeout: 30 * time.Second, + Transport: http.DefaultTransport, + } + + trader := &KuCoinTrader{ + apiKey: apiKey, + secretKey: secretKey, + passphrase: passphrase, + httpClient: httpClient, + cacheDuration: 15 * time.Second, + contractsCache: make(map[string]*KuCoinContract), + } + + logger.Infof("āœ“ KuCoin Futures trader initialized") + return trader +} + +// sign generates KuCoin API signature +func (t *KuCoinTrader) sign(timestamp, method, requestPath, body string) string { + // KuCoin signature: base64(HMAC-SHA256(timestamp + method + endpoint + body, secretKey)) + preHash := timestamp + method + requestPath + body + h := hmac.New(sha256.New, []byte(t.secretKey)) + h.Write([]byte(preHash)) + return base64.StdEncoding.EncodeToString(h.Sum(nil)) +} + +// signPassphrase signs the passphrase with API v2 +func (t *KuCoinTrader) signPassphrase(passphrase string) string { + h := hmac.New(sha256.New, []byte(t.secretKey)) + h.Write([]byte(passphrase)) + return base64.StdEncoding.EncodeToString(h.Sum(nil)) +} + +// signPartner generates partner signature: base64(HMAC-SHA256(timestamp + partner + apiKey, partnerKey)) +func (t *KuCoinTrader) signPartner(timestamp string) string { + preHash := timestamp + kcPartnerID + t.apiKey + h := hmac.New(sha256.New, []byte(kcPartnerKey)) + h.Write([]byte(preHash)) + return base64.StdEncoding.EncodeToString(h.Sum(nil)) +} + +// doRequest executes HTTP request +func (t *KuCoinTrader) doRequest(method, path string, body interface{}) ([]byte, error) { + var bodyBytes []byte + var err error + + if body != nil { + bodyBytes, err = json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("failed to serialize request body: %w", err) + } + } + + timestamp := strconv.FormatInt(time.Now().UnixMilli(), 10) + signature := t.sign(timestamp, method, path, string(bodyBytes)) + signedPassphrase := t.signPassphrase(t.passphrase) + + req, err := http.NewRequest(method, kucoinBaseURL+path, bytes.NewReader(bodyBytes)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + // Authentication headers + req.Header.Set("KC-API-KEY", t.apiKey) + req.Header.Set("KC-API-SIGN", signature) + req.Header.Set("KC-API-TIMESTAMP", timestamp) + req.Header.Set("KC-API-PASSPHRASE", signedPassphrase) + req.Header.Set("KC-API-KEY-VERSION", "3") + req.Header.Set("Content-Type", "application/json") + + // Partner headers + req.Header.Set("KC-API-PARTNER", kcPartnerID) + req.Header.Set("KC-API-PARTNER-SIGN", t.signPartner(timestamp)) + req.Header.Set("KC-API-PARTNER-VERIFY", "true") + + resp, err := t.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + var kcResp KuCoinResponse + if err := json.Unmarshal(respBody, &kcResp); err != nil { + return nil, fmt.Errorf("failed to parse response: %w, body: %s", err, string(respBody)) + } + + if kcResp.Code != "200000" { + return nil, fmt.Errorf("KuCoin API error: code=%s, msg=%s", kcResp.Code, kcResp.Msg) + } + + return kcResp.Data, nil +} + +// convertSymbol converts generic symbol to KuCoin format +// e.g. BTCUSDT -> XBTUSDTM (KuCoin uses XBT for BTC) +func (t *KuCoinTrader) convertSymbol(symbol string) string { + // Remove USDT suffix + base := strings.TrimSuffix(symbol, "USDT") + // KuCoin uses XBT instead of BTC + if base == "BTC" { + base = "XBT" + } + return fmt.Sprintf("%sUSDTM", base) +} + +// convertSymbolBack converts KuCoin format back to generic symbol +// e.g. XBTUSDTM -> BTCUSDT +func (t *KuCoinTrader) convertSymbolBack(kcSymbol string) string { + // Remove M suffix + sym := strings.TrimSuffix(kcSymbol, "M") + // Convert XBT back to BTC + if strings.HasPrefix(sym, "XBT") { + sym = "BTC" + strings.TrimPrefix(sym, "XBT") + } + return sym +} + +// GetBalance gets account balance +func (t *KuCoinTrader) GetBalance() (map[string]interface{}, error) { + // Check cache + t.balanceCacheMutex.RLock() + if t.cachedBalance != nil && time.Since(t.balanceCacheTime) < t.cacheDuration { + t.balanceCacheMutex.RUnlock() + return t.cachedBalance, nil + } + t.balanceCacheMutex.RUnlock() + + data, err := t.doRequest("GET", kucoinAccountPath+"?currency=USDT", nil) + if err != nil { + return nil, fmt.Errorf("failed to get account balance: %w", err) + } + + var account struct { + AccountEquity float64 `json:"accountEquity"` + UnrealisedPNL float64 `json:"unrealisedPNL"` + MarginBalance float64 `json:"marginBalance"` + PositionMargin float64 `json:"positionMargin"` + OrderMargin float64 `json:"orderMargin"` + FrozenFunds float64 `json:"frozenFunds"` + AvailableBalance float64 `json:"availableBalance"` + Currency string `json:"currency"` + } + + if err := json.Unmarshal(data, &account); err != nil { + return nil, fmt.Errorf("failed to parse balance data: %w", err) + } + + result := map[string]interface{}{ + "totalWalletBalance": account.MarginBalance, // Wallet balance (without unrealized PnL) + "availableBalance": account.AvailableBalance, + "totalUnrealizedProfit": account.UnrealisedPNL, + "total_equity": account.AccountEquity, + "totalEquity": account.AccountEquity, // For GetAccountInfo compatibility + } + + logger.Infof("āœ“ KuCoin balance: Total equity=%.2f, Available=%.2f, Unrealized PnL=%.2f", + account.AccountEquity, account.AvailableBalance, account.UnrealisedPNL) + + // Update cache + t.balanceCacheMutex.Lock() + t.cachedBalance = result + t.balanceCacheTime = time.Now() + t.balanceCacheMutex.Unlock() + + return result, nil +} + +// GetPositions gets all positions +func (t *KuCoinTrader) GetPositions() ([]map[string]interface{}, error) { + // Check cache + t.positionsCacheMutex.RLock() + if t.cachedPositions != nil && time.Since(t.positionsCacheTime) < t.cacheDuration { + t.positionsCacheMutex.RUnlock() + return t.cachedPositions, nil + } + t.positionsCacheMutex.RUnlock() + + data, err := t.doRequest("GET", kucoinPositionPath, nil) + if err != nil { + return nil, fmt.Errorf("failed to get positions: %w", err) + } + + var positions []struct { + Symbol string `json:"symbol"` + CurrentQty int64 `json:"currentQty"` // Position quantity (in lots, integer) + AvgEntryPrice float64 `json:"avgEntryPrice"` // Average entry price (string in API) + MarkPrice float64 `json:"markPrice"` // Mark price + UnrealisedPnl float64 `json:"unrealisedPnl"` // Unrealized PnL + Leverage float64 `json:"leverage"` // Leverage setting + RealLeverage float64 `json:"realLeverage"` // Effective leverage (may be nil in cross mode) + LiquidationPrice float64 `json:"liquidationPrice"`// Liquidation price + Multiplier float64 `json:"multiplier"` // Contract multiplier + IsOpen bool `json:"isOpen"` + CrossMode bool `json:"crossMode"` + OpeningTimestamp int64 `json:"openingTimestamp"` + SettleCurrency string `json:"settleCurrency"` + } + + if err := json.Unmarshal(data, &positions); err != nil { + return nil, fmt.Errorf("failed to parse position data: %w", err) + } + + var result []map[string]interface{} + for _, pos := range positions { + if !pos.IsOpen || pos.CurrentQty == 0 { + continue + } + + // Convert symbol format + symbol := t.convertSymbolBack(pos.Symbol) + + // Determine side based on position quantity + // KuCoin: positive qty = long, negative qty = short + side := "long" + qty := pos.CurrentQty + if qty < 0 { + side = "short" + qty = -qty + } + + // Convert lots to actual quantity using multiplier + // Position quantity = lots * multiplier + multiplier := pos.Multiplier + if multiplier == 0 { + multiplier = 0.001 // Default for BTC + } + positionAmt := float64(qty) * multiplier + + // Determine margin mode + mgnMode := "isolated" + if pos.CrossMode { + mgnMode = "cross" + } + + // Use Leverage field (setting), fallback to RealLeverage (effective), default to 10 + leverage := pos.Leverage + if leverage == 0 { + leverage = pos.RealLeverage + } + if leverage == 0 { + leverage = 10 // Default leverage + } + + posMap := map[string]interface{}{ + "symbol": symbol, + "positionAmt": positionAmt, + "entryPrice": pos.AvgEntryPrice, + "markPrice": pos.MarkPrice, + "unRealizedProfit": pos.UnrealisedPnl, + "leverage": leverage, + "liquidationPrice": pos.LiquidationPrice, + "side": side, + "mgnMode": mgnMode, + "createdTime": pos.OpeningTimestamp, + } + result = append(result, posMap) + } + + // Update cache + t.positionsCacheMutex.Lock() + t.cachedPositions = result + t.positionsCacheTime = time.Now() + t.positionsCacheMutex.Unlock() + + return result, nil +} + +// InvalidatePositionCache clears the position cache +func (t *KuCoinTrader) InvalidatePositionCache() { + t.positionsCacheMutex.Lock() + t.cachedPositions = nil + t.positionsCacheTime = time.Time{} + t.positionsCacheMutex.Unlock() +} + +// getContract gets contract info +func (t *KuCoinTrader) getContract(symbol string) (*KuCoinContract, error) { + kcSymbol := t.convertSymbol(symbol) + + // Check cache + t.contractsCacheMutex.RLock() + if contract, ok := t.contractsCache[kcSymbol]; ok && time.Since(t.contractsCacheTime) < 5*time.Minute { + t.contractsCacheMutex.RUnlock() + return contract, nil + } + t.contractsCacheMutex.RUnlock() + + // Get contract info + data, err := t.doRequest("GET", kucoinContractsPath, nil) + if err != nil { + return nil, err + } + + var contracts []struct { + Symbol string `json:"symbol"` + BaseCurrency string `json:"baseCurrency"` + Multiplier float64 `json:"multiplier"` + LotSize int64 `json:"lotSize"` + TickSize float64 `json:"tickSize"` + MaxOrderQty int64 `json:"maxOrderQty"` + MaxLeverage int `json:"maxLeverage"` + MarkPrice float64 `json:"markPrice"` + IsInverse bool `json:"isInverse"` + QuoteCurrency string `json:"quoteCurrency"` + } + + if err := json.Unmarshal(data, &contracts); err != nil { + return nil, err + } + + // Update cache with all contracts + t.contractsCacheMutex.Lock() + for _, c := range contracts { + t.contractsCache[c.Symbol] = &KuCoinContract{ + Symbol: c.Symbol, + BaseCurrency: c.BaseCurrency, + Multiplier: c.Multiplier, + LotSize: float64(c.LotSize), + TickSize: c.TickSize, + MaxOrderQty: float64(c.MaxOrderQty), + MaxLeverage: float64(c.MaxLeverage), + MarkPrice: c.MarkPrice, + IsInverse: c.IsInverse, + QuoteCurrency: c.QuoteCurrency, + } + } + t.contractsCacheTime = time.Now() + t.contractsCacheMutex.Unlock() + + // Return requested contract + t.contractsCacheMutex.RLock() + contract, ok := t.contractsCache[kcSymbol] + t.contractsCacheMutex.RUnlock() + + if !ok { + return nil, fmt.Errorf("contract info not found: %s", kcSymbol) + } + + return contract, nil +} + +// quantityToLots converts quantity (in base asset) to lots +func (t *KuCoinTrader) quantityToLots(symbol string, quantity float64) (int64, error) { + contract, err := t.getContract(symbol) + if err != nil { + return 0, err + } + + // lots = quantity / multiplier + lots := quantity / contract.Multiplier + + // Round to integer (KuCoin uses integer lots) + lotsInt := int64(math.Round(lots)) + + // Check max order quantity + if contract.MaxOrderQty > 0 && float64(lotsInt) > contract.MaxOrderQty { + logger.Infof("āš ļø KuCoin order quantity %d exceeds max %d, reducing to max", lotsInt, int64(contract.MaxOrderQty)) + lotsInt = int64(contract.MaxOrderQty) + } + + return lotsInt, 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 +} + +// OpenLong opens long position +func (t *KuCoinTrader) OpenLong(symbol string, quantity float64, leverage int) (map[string]interface{}, error) { + // Cancel old orders + t.CancelAllOrders(symbol) + + // 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 + t.CancelAllOrders(symbol) + + // 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 + t.CancelAllOrders(symbol) + + 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 + t.CancelAllOrders(symbol) + + 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 +} + +// 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 +}