mirror of
https://github.com/laoxong/nofx.git
synced 2026-06-04 09:58:22 +08:00
b32a3566e6
- 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
1229 lines
34 KiB
Go
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
|
|
}
|