Files
nofx/trader/kucoin/trader.go
T
tinkle-community b32a3566e6 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
2026-02-04 02:10:26 +08:00

1229 lines
34 KiB
Go

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
}