mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-06-06 05:51:19 +08:00
Gate.io Integration: - Add Gate trader with full Trader interface implementation - Add order_sync.go for background trade synchronization - Fix quantity display (convert contracts to actual tokens via quanto_multiplier) - Fix fill price return in OpenLong/OpenShort/CloseLong/CloseShort - Add Gate-specific CoinAnk K-line data source support - Add Gate to supported exchanges in frontend and backend - Add Gate/KuCoin logo SVG icons Trader Package Refactoring: - Move exchange-specific code into subdirectories (binance/, bybit/, okx/, bitget/, hyperliquid/, aster/, lighter/, gate/) - Create types/ package for shared types to avoid circular dependencies - Move TraderTestSuite to trader/testutil package to avoid import cycles - Update market.GetWithExchange to support exchange-specific data
898 lines
24 KiB
Go
898 lines
24 KiB
Go
package gate
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"math"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/antihax/optional"
|
|
"github.com/gateio/gateapi-go/v6"
|
|
"nofx/logger"
|
|
"nofx/trader/types"
|
|
)
|
|
|
|
// GateTrader implements types.Trader interface for Gate.io Futures
|
|
type GateTrader struct {
|
|
apiKey string
|
|
secretKey string
|
|
client *gateapi.APIClient
|
|
ctx context.Context
|
|
|
|
// Cache fields
|
|
cachedBalance map[string]interface{}
|
|
balanceCacheTime time.Time
|
|
balanceCacheMutex sync.RWMutex
|
|
cachedPositions []map[string]interface{}
|
|
positionsCacheTime time.Time
|
|
positionsCacheMutex sync.RWMutex
|
|
contractsCache map[string]*gateapi.Contract
|
|
contractsCacheMutex sync.RWMutex
|
|
cacheDuration time.Duration
|
|
}
|
|
|
|
// NewGateTrader creates a new Gate trader instance
|
|
func NewGateTrader(apiKey, secretKey string) *GateTrader {
|
|
config := gateapi.NewConfiguration()
|
|
client := gateapi.NewAPIClient(config)
|
|
|
|
ctx := context.WithValue(context.Background(),
|
|
gateapi.ContextGateAPIV4,
|
|
gateapi.GateAPIV4{
|
|
Key: apiKey,
|
|
Secret: secretKey,
|
|
},
|
|
)
|
|
|
|
return &GateTrader{
|
|
apiKey: apiKey,
|
|
secretKey: secretKey,
|
|
client: client,
|
|
ctx: ctx,
|
|
contractsCache: make(map[string]*gateapi.Contract),
|
|
cacheDuration: 15 * time.Second,
|
|
}
|
|
}
|
|
|
|
// GetBalance retrieves account balance
|
|
func (t *GateTrader) GetBalance() (map[string]interface{}, error) {
|
|
// Check cache
|
|
t.balanceCacheMutex.RLock()
|
|
if t.cachedBalance != nil && time.Since(t.balanceCacheTime) < t.cacheDuration {
|
|
cached := t.cachedBalance
|
|
t.balanceCacheMutex.RUnlock()
|
|
return cached, nil
|
|
}
|
|
t.balanceCacheMutex.RUnlock()
|
|
|
|
// Fetch from API
|
|
accounts, _, err := t.client.FuturesApi.ListFuturesAccounts(t.ctx, "usdt")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get balance: %w", err)
|
|
}
|
|
|
|
total, _ := strconv.ParseFloat(accounts.Total, 64)
|
|
available, _ := strconv.ParseFloat(accounts.Available, 64)
|
|
unrealizedPnl, _ := strconv.ParseFloat(accounts.UnrealisedPnl, 64)
|
|
|
|
result := map[string]interface{}{
|
|
"totalWalletBalance": total,
|
|
"availableBalance": available,
|
|
"totalUnrealizedProfit": unrealizedPnl,
|
|
}
|
|
|
|
// Update cache
|
|
t.balanceCacheMutex.Lock()
|
|
t.cachedBalance = result
|
|
t.balanceCacheTime = time.Now()
|
|
t.balanceCacheMutex.Unlock()
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// GetPositions retrieves all open positions
|
|
func (t *GateTrader) GetPositions() ([]map[string]interface{}, error) {
|
|
// Check cache
|
|
t.positionsCacheMutex.RLock()
|
|
if t.cachedPositions != nil && time.Since(t.positionsCacheTime) < t.cacheDuration {
|
|
cached := t.cachedPositions
|
|
t.positionsCacheMutex.RUnlock()
|
|
return cached, nil
|
|
}
|
|
t.positionsCacheMutex.RUnlock()
|
|
|
|
// Fetch from API
|
|
positions, _, err := t.client.FuturesApi.ListPositions(t.ctx, "usdt", nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get positions: %w", err)
|
|
}
|
|
|
|
var result []map[string]interface{}
|
|
for _, pos := range positions {
|
|
if pos.Size == 0 {
|
|
continue // Skip empty positions
|
|
}
|
|
|
|
entryPrice, _ := strconv.ParseFloat(pos.EntryPrice, 64)
|
|
markPrice, _ := strconv.ParseFloat(pos.MarkPrice, 64)
|
|
liqPrice, _ := strconv.ParseFloat(pos.LiqPrice, 64)
|
|
unrealizedPnl, _ := strconv.ParseFloat(pos.UnrealisedPnl, 64)
|
|
leverage, _ := strconv.ParseFloat(pos.Leverage, 64)
|
|
|
|
// Gate returns position size in contracts, need to convert to base currency
|
|
// Each contract = quanto_multiplier base currency
|
|
contractSize := float64(pos.Size)
|
|
if pos.Size < 0 {
|
|
contractSize = float64(-pos.Size)
|
|
}
|
|
|
|
// Get quanto_multiplier from contract info to convert contracts to actual quantity
|
|
quantoMultiplier := 1.0
|
|
contract, err := t.getContract(pos.Contract)
|
|
if err == nil && contract != nil {
|
|
qm, _ := strconv.ParseFloat(contract.QuantoMultiplier, 64)
|
|
if qm > 0 {
|
|
quantoMultiplier = qm
|
|
}
|
|
}
|
|
|
|
// Convert contract count to actual token quantity
|
|
positionAmt := contractSize * quantoMultiplier
|
|
|
|
// Determine side based on position size
|
|
side := "long"
|
|
if pos.Size < 0 {
|
|
side = "short"
|
|
}
|
|
|
|
result = append(result, map[string]interface{}{
|
|
"symbol": pos.Contract,
|
|
"positionAmt": positionAmt,
|
|
"entryPrice": entryPrice,
|
|
"markPrice": markPrice,
|
|
"unRealizedProfit": unrealizedPnl,
|
|
"leverage": int(leverage),
|
|
"liquidationPrice": liqPrice,
|
|
"side": side,
|
|
})
|
|
}
|
|
|
|
// Update cache
|
|
t.positionsCacheMutex.Lock()
|
|
t.cachedPositions = result
|
|
t.positionsCacheTime = time.Now()
|
|
t.positionsCacheMutex.Unlock()
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// convertSymbol converts symbol format (e.g., BTCUSDT -> BTC_USDT)
|
|
func (t *GateTrader) convertSymbol(symbol string) string {
|
|
// If already in correct format
|
|
if strings.Contains(symbol, "_") {
|
|
return symbol
|
|
}
|
|
// Convert BTCUSDT to BTC_USDT
|
|
if strings.HasSuffix(symbol, "USDT") {
|
|
base := strings.TrimSuffix(symbol, "USDT")
|
|
return base + "_USDT"
|
|
}
|
|
return symbol
|
|
}
|
|
|
|
// revertSymbol converts symbol back to standard format (e.g., BTC_USDT -> BTCUSDT)
|
|
func (t *GateTrader) revertSymbol(symbol string) string {
|
|
return strings.ReplaceAll(symbol, "_", "")
|
|
}
|
|
|
|
// getContract fetches contract info with caching
|
|
func (t *GateTrader) getContract(symbol string) (*gateapi.Contract, error) {
|
|
symbol = t.convertSymbol(symbol)
|
|
|
|
// Check cache
|
|
t.contractsCacheMutex.RLock()
|
|
if contract, ok := t.contractsCache[symbol]; ok {
|
|
t.contractsCacheMutex.RUnlock()
|
|
return contract, nil
|
|
}
|
|
t.contractsCacheMutex.RUnlock()
|
|
|
|
// Fetch from API
|
|
contract, _, err := t.client.FuturesApi.GetFuturesContract(t.ctx, "usdt", symbol)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get contract info: %w", err)
|
|
}
|
|
|
|
// Update cache
|
|
t.contractsCacheMutex.Lock()
|
|
t.contractsCache[symbol] = &contract
|
|
t.contractsCacheMutex.Unlock()
|
|
|
|
return &contract, nil
|
|
}
|
|
|
|
// SetLeverage sets the leverage for a symbol
|
|
func (t *GateTrader) SetLeverage(symbol string, leverage int) error {
|
|
symbol = t.convertSymbol(symbol)
|
|
|
|
_, _, err := t.client.FuturesApi.UpdatePositionLeverage(t.ctx, "usdt", symbol, fmt.Sprintf("%d", leverage), nil)
|
|
if err != nil {
|
|
// Gate.io may return error if leverage is already set
|
|
if strings.Contains(err.Error(), "RISK_LIMIT_EXCEEDED") {
|
|
logger.Warnf(" [Gate] Leverage %d exceeds limit for %s", leverage, symbol)
|
|
return nil
|
|
}
|
|
return fmt.Errorf("failed to set leverage: %w", err)
|
|
}
|
|
|
|
logger.Infof(" [Gate] Leverage set to %dx for %s", leverage, symbol)
|
|
return nil
|
|
}
|
|
|
|
// SetMarginMode sets margin mode (cross or isolated)
|
|
func (t *GateTrader) SetMarginMode(symbol string, isCrossMargin bool) error {
|
|
// Gate.io uses leverage=0 for cross margin, positive number for isolated
|
|
// This is handled through UpdatePositionLeverage with cross_leverage_limit
|
|
// For now, we'll skip explicit margin mode setting as it's tied to leverage
|
|
logger.Infof(" [Gate] Margin mode is set through leverage (0=cross)")
|
|
return nil
|
|
}
|
|
|
|
// OpenLong opens a long position
|
|
func (t *GateTrader) OpenLong(symbol string, quantity float64, leverage int) (map[string]interface{}, error) {
|
|
symbol = t.convertSymbol(symbol)
|
|
|
|
// Cancel old orders first
|
|
t.CancelAllOrders(symbol)
|
|
|
|
// Set leverage
|
|
if err := t.SetLeverage(symbol, leverage); err != nil {
|
|
logger.Warnf(" [Gate] Failed to set leverage: %v", err)
|
|
}
|
|
|
|
// Get contract info for size calculation
|
|
contract, err := t.getContract(symbol)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Gate uses contract size units (each contract = quanto_multiplier base currency)
|
|
// size = quantity / quanto_multiplier
|
|
quantoMultiplier, _ := strconv.ParseFloat(contract.QuantoMultiplier, 64)
|
|
size := int64(quantity / quantoMultiplier)
|
|
if size <= 0 {
|
|
size = 1
|
|
}
|
|
|
|
order := gateapi.FuturesOrder{
|
|
Contract: symbol,
|
|
Size: size, // Positive for long
|
|
Price: "0", // Market order
|
|
Tif: "ioc",
|
|
Text: "t-nofx",
|
|
}
|
|
|
|
logger.Infof(" [Gate] OpenLong: symbol=%s, size=%d, leverage=%d", symbol, size, leverage)
|
|
|
|
result, _, err := t.client.FuturesApi.CreateFuturesOrder(t.ctx, "usdt", order, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to open long position: %w", err)
|
|
}
|
|
|
|
// Clear cache
|
|
t.clearCache()
|
|
|
|
// Parse fill price from result
|
|
fillPrice, _ := strconv.ParseFloat(result.FillPrice, 64)
|
|
|
|
logger.Infof(" [Gate] Opened long position: orderId=%d, fillPrice=%.4f", result.Id, fillPrice)
|
|
|
|
return map[string]interface{}{
|
|
"orderId": fmt.Sprintf("%d", result.Id),
|
|
"symbol": t.revertSymbol(symbol),
|
|
"status": "FILLED",
|
|
"fillPrice": fillPrice,
|
|
"avgPrice": fillPrice,
|
|
}, nil
|
|
}
|
|
|
|
// OpenShort opens a short position
|
|
func (t *GateTrader) OpenShort(symbol string, quantity float64, leverage int) (map[string]interface{}, error) {
|
|
symbol = t.convertSymbol(symbol)
|
|
|
|
// Cancel old orders first
|
|
t.CancelAllOrders(symbol)
|
|
|
|
// Set leverage
|
|
if err := t.SetLeverage(symbol, leverage); err != nil {
|
|
logger.Warnf(" [Gate] Failed to set leverage: %v", err)
|
|
}
|
|
|
|
// Get contract info for size calculation
|
|
contract, err := t.getContract(symbol)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Gate uses contract size units
|
|
quantoMultiplier, _ := strconv.ParseFloat(contract.QuantoMultiplier, 64)
|
|
size := int64(quantity / quantoMultiplier)
|
|
if size <= 0 {
|
|
size = 1
|
|
}
|
|
|
|
order := gateapi.FuturesOrder{
|
|
Contract: symbol,
|
|
Size: -size, // Negative for short
|
|
Price: "0", // Market order
|
|
Tif: "ioc",
|
|
Text: "t-nofx",
|
|
}
|
|
|
|
logger.Infof(" [Gate] OpenShort: symbol=%s, size=%d, leverage=%d", symbol, -size, leverage)
|
|
|
|
result, _, err := t.client.FuturesApi.CreateFuturesOrder(t.ctx, "usdt", order, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to open short position: %w", err)
|
|
}
|
|
|
|
// Clear cache
|
|
t.clearCache()
|
|
|
|
// Parse fill price from result
|
|
fillPrice, _ := strconv.ParseFloat(result.FillPrice, 64)
|
|
|
|
logger.Infof(" [Gate] Opened short position: orderId=%d, fillPrice=%.4f", result.Id, fillPrice)
|
|
|
|
return map[string]interface{}{
|
|
"orderId": fmt.Sprintf("%d", result.Id),
|
|
"symbol": t.revertSymbol(symbol),
|
|
"status": "FILLED",
|
|
"fillPrice": fillPrice,
|
|
"avgPrice": fillPrice,
|
|
}, nil
|
|
}
|
|
|
|
// CloseLong closes a long position
|
|
func (t *GateTrader) CloseLong(symbol string, quantity float64) (map[string]interface{}, error) {
|
|
symbol = t.convertSymbol(symbol)
|
|
|
|
// If quantity is 0, get current position
|
|
if quantity == 0 {
|
|
positions, err := t.GetPositions()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, pos := range positions {
|
|
posSymbol := t.convertSymbol(pos["symbol"].(string))
|
|
if posSymbol == symbol && pos["side"] == "long" {
|
|
quantity = pos["positionAmt"].(float64)
|
|
break
|
|
}
|
|
}
|
|
if quantity == 0 {
|
|
return nil, fmt.Errorf("long position not found for %s", symbol)
|
|
}
|
|
}
|
|
|
|
// Get contract info for size calculation
|
|
contract, err := t.getContract(symbol)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
quantoMultiplier, _ := strconv.ParseFloat(contract.QuantoMultiplier, 64)
|
|
size := int64(quantity / quantoMultiplier)
|
|
if size <= 0 {
|
|
size = 1
|
|
}
|
|
|
|
// Close long = sell (use ReduceOnly, not Close which requires Size=0)
|
|
order := gateapi.FuturesOrder{
|
|
Contract: symbol,
|
|
Size: -size, // Negative to close long
|
|
Price: "0",
|
|
Tif: "ioc",
|
|
ReduceOnly: true,
|
|
Text: "t-nofx-close",
|
|
}
|
|
|
|
logger.Infof(" [Gate] CloseLong: symbol=%s, size=%d", symbol, -size)
|
|
|
|
result, _, err := t.client.FuturesApi.CreateFuturesOrder(t.ctx, "usdt", order, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to close long position: %w", err)
|
|
}
|
|
|
|
// Clear cache
|
|
t.clearCache()
|
|
|
|
// Parse fill price from result
|
|
fillPrice, _ := strconv.ParseFloat(result.FillPrice, 64)
|
|
|
|
logger.Infof(" [Gate] Closed long position: orderId=%d, fillPrice=%.4f", result.Id, fillPrice)
|
|
|
|
return map[string]interface{}{
|
|
"orderId": fmt.Sprintf("%d", result.Id),
|
|
"symbol": t.revertSymbol(symbol),
|
|
"status": "FILLED",
|
|
"fillPrice": fillPrice,
|
|
"avgPrice": fillPrice,
|
|
}, nil
|
|
}
|
|
|
|
// CloseShort closes a short position
|
|
func (t *GateTrader) CloseShort(symbol string, quantity float64) (map[string]interface{}, error) {
|
|
symbol = t.convertSymbol(symbol)
|
|
|
|
// If quantity is 0, get current position
|
|
if quantity == 0 {
|
|
positions, err := t.GetPositions()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, pos := range positions {
|
|
posSymbol := t.convertSymbol(pos["symbol"].(string))
|
|
if posSymbol == symbol && pos["side"] == "short" {
|
|
quantity = pos["positionAmt"].(float64)
|
|
break
|
|
}
|
|
}
|
|
if quantity == 0 {
|
|
return nil, fmt.Errorf("short position not found for %s", symbol)
|
|
}
|
|
}
|
|
|
|
// Ensure quantity is positive
|
|
if quantity < 0 {
|
|
quantity = -quantity
|
|
}
|
|
|
|
// Get contract info for size calculation
|
|
contract, err := t.getContract(symbol)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
quantoMultiplier, _ := strconv.ParseFloat(contract.QuantoMultiplier, 64)
|
|
size := int64(quantity / quantoMultiplier)
|
|
if size <= 0 {
|
|
size = 1
|
|
}
|
|
|
|
// Close short = buy (use ReduceOnly, not Close which requires Size=0)
|
|
order := gateapi.FuturesOrder{
|
|
Contract: symbol,
|
|
Size: size, // Positive to close short
|
|
Price: "0",
|
|
Tif: "ioc",
|
|
ReduceOnly: true,
|
|
Text: "t-nofx-close",
|
|
}
|
|
|
|
logger.Infof(" [Gate] CloseShort: symbol=%s, size=%d", symbol, size)
|
|
|
|
result, _, err := t.client.FuturesApi.CreateFuturesOrder(t.ctx, "usdt", order, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to close short position: %w", err)
|
|
}
|
|
|
|
// Clear cache
|
|
t.clearCache()
|
|
|
|
// Parse fill price from result
|
|
fillPrice, _ := strconv.ParseFloat(result.FillPrice, 64)
|
|
|
|
logger.Infof(" [Gate] Closed short position: orderId=%d, fillPrice=%.4f", result.Id, fillPrice)
|
|
|
|
return map[string]interface{}{
|
|
"orderId": fmt.Sprintf("%d", result.Id),
|
|
"symbol": t.revertSymbol(symbol),
|
|
"status": "FILLED",
|
|
"fillPrice": fillPrice,
|
|
"avgPrice": fillPrice,
|
|
}, nil
|
|
}
|
|
|
|
// GetMarketPrice gets the current market price
|
|
func (t *GateTrader) GetMarketPrice(symbol string) (float64, error) {
|
|
symbol = t.convertSymbol(symbol)
|
|
|
|
opts := &gateapi.ListFuturesTickersOpts{
|
|
Contract: optional.NewString(symbol),
|
|
}
|
|
|
|
tickers, _, err := t.client.FuturesApi.ListFuturesTickers(t.ctx, "usdt", opts)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("failed to get market price: %w", err)
|
|
}
|
|
|
|
if len(tickers) == 0 {
|
|
return 0, fmt.Errorf("no ticker data for %s", symbol)
|
|
}
|
|
|
|
price, _ := strconv.ParseFloat(tickers[0].Last, 64)
|
|
return price, nil
|
|
}
|
|
|
|
// SetStopLoss sets a stop loss order
|
|
func (t *GateTrader) SetStopLoss(symbol string, positionSide string, quantity, stopPrice float64) error {
|
|
symbol = t.convertSymbol(symbol)
|
|
|
|
contract, err := t.getContract(symbol)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
quantoMultiplier, _ := strconv.ParseFloat(contract.QuantoMultiplier, 64)
|
|
size := int64(quantity / quantoMultiplier)
|
|
if size <= 0 {
|
|
size = 1
|
|
}
|
|
|
|
// For long position, stop loss means sell when price drops
|
|
// For short position, stop loss means buy when price rises
|
|
if strings.ToUpper(positionSide) == "LONG" {
|
|
size = -size
|
|
}
|
|
|
|
// Use price trigger order
|
|
trigger := gateapi.FuturesPriceTriggeredOrder{
|
|
Initial: gateapi.FuturesInitialOrder{
|
|
Contract: symbol,
|
|
Size: size,
|
|
Price: "0", // Market order
|
|
Tif: "ioc",
|
|
ReduceOnly: true,
|
|
Close: true,
|
|
},
|
|
Trigger: gateapi.FuturesPriceTrigger{
|
|
StrategyType: 0, // Close position
|
|
PriceType: 0, // Latest price
|
|
Price: fmt.Sprintf("%.8f", stopPrice),
|
|
Rule: 1, // Price <= trigger price
|
|
},
|
|
}
|
|
|
|
if strings.ToUpper(positionSide) == "SHORT" {
|
|
trigger.Trigger.Rule = 2 // Price >= trigger price for short stop loss
|
|
}
|
|
|
|
_, _, err = t.client.FuturesApi.CreatePriceTriggeredOrder(t.ctx, "usdt", trigger)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to set stop loss: %w", err)
|
|
}
|
|
|
|
logger.Infof(" [Gate] Stop loss set: %s @ %.4f", symbol, stopPrice)
|
|
return nil
|
|
}
|
|
|
|
// SetTakeProfit sets a take profit order
|
|
func (t *GateTrader) SetTakeProfit(symbol string, positionSide string, quantity, takeProfitPrice float64) error {
|
|
symbol = t.convertSymbol(symbol)
|
|
|
|
contract, err := t.getContract(symbol)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
quantoMultiplier, _ := strconv.ParseFloat(contract.QuantoMultiplier, 64)
|
|
size := int64(quantity / quantoMultiplier)
|
|
if size <= 0 {
|
|
size = 1
|
|
}
|
|
|
|
// For long position, take profit means sell when price rises
|
|
// For short position, take profit means buy when price drops
|
|
if strings.ToUpper(positionSide) == "LONG" {
|
|
size = -size
|
|
}
|
|
|
|
trigger := gateapi.FuturesPriceTriggeredOrder{
|
|
Initial: gateapi.FuturesInitialOrder{
|
|
Contract: symbol,
|
|
Size: size,
|
|
Price: "0", // Market order
|
|
Tif: "ioc",
|
|
ReduceOnly: true,
|
|
Close: true,
|
|
},
|
|
Trigger: gateapi.FuturesPriceTrigger{
|
|
StrategyType: 0, // Close position
|
|
PriceType: 0, // Latest price
|
|
Price: fmt.Sprintf("%.8f", takeProfitPrice),
|
|
Rule: 2, // Price >= trigger price for long take profit
|
|
},
|
|
}
|
|
|
|
if strings.ToUpper(positionSide) == "SHORT" {
|
|
trigger.Trigger.Rule = 1 // Price <= trigger price for short take profit
|
|
}
|
|
|
|
_, _, err = t.client.FuturesApi.CreatePriceTriggeredOrder(t.ctx, "usdt", trigger)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to set take profit: %w", err)
|
|
}
|
|
|
|
logger.Infof(" [Gate] Take profit set: %s @ %.4f", symbol, takeProfitPrice)
|
|
return nil
|
|
}
|
|
|
|
// CancelStopLossOrders cancels stop loss orders
|
|
func (t *GateTrader) CancelStopLossOrders(symbol string) error {
|
|
return t.cancelTriggerOrders(symbol, "stop_loss")
|
|
}
|
|
|
|
// CancelTakeProfitOrders cancels take profit orders
|
|
func (t *GateTrader) CancelTakeProfitOrders(symbol string) error {
|
|
return t.cancelTriggerOrders(symbol, "take_profit")
|
|
}
|
|
|
|
// cancelTriggerOrders cancels trigger orders of a specific type
|
|
func (t *GateTrader) cancelTriggerOrders(symbol string, orderType string) error {
|
|
symbol = t.convertSymbol(symbol)
|
|
|
|
opts := &gateapi.ListPriceTriggeredOrdersOpts{
|
|
Contract: optional.NewString(symbol),
|
|
}
|
|
|
|
orders, _, err := t.client.FuturesApi.ListPriceTriggeredOrders(t.ctx, "usdt", "open", opts)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, order := range orders {
|
|
// Determine if it's stop loss or take profit based on trigger rule and position
|
|
// For simplicity, cancel all matching symbol orders
|
|
_, _, err := t.client.FuturesApi.CancelPriceTriggeredOrder(t.ctx, "usdt", fmt.Sprintf("%d", order.Id))
|
|
if err != nil {
|
|
logger.Warnf(" [Gate] Failed to cancel trigger order %d: %v", order.Id, err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// CancelAllOrders cancels all pending orders for a symbol
|
|
func (t *GateTrader) CancelAllOrders(symbol string) error {
|
|
symbol = t.convertSymbol(symbol)
|
|
|
|
// Cancel regular orders
|
|
_, _, err := t.client.FuturesApi.CancelFuturesOrders(t.ctx, "usdt", symbol, nil)
|
|
if err != nil {
|
|
// Ignore if no orders to cancel
|
|
if !strings.Contains(err.Error(), "ORDER_NOT_FOUND") {
|
|
logger.Warnf(" [Gate] Error canceling orders: %v", err)
|
|
}
|
|
}
|
|
|
|
// Cancel trigger orders
|
|
t.cancelTriggerOrders(symbol, "")
|
|
|
|
return nil
|
|
}
|
|
|
|
// CancelStopOrders cancels all stop orders (stop loss and take profit)
|
|
func (t *GateTrader) CancelStopOrders(symbol string) error {
|
|
t.CancelStopLossOrders(symbol)
|
|
t.CancelTakeProfitOrders(symbol)
|
|
return nil
|
|
}
|
|
|
|
// FormatQuantity formats quantity to correct precision
|
|
func (t *GateTrader) FormatQuantity(symbol string, quantity float64) (string, error) {
|
|
contract, err := t.getContract(symbol)
|
|
if err != nil {
|
|
return fmt.Sprintf("%.4f", quantity), nil
|
|
}
|
|
|
|
// Gate uses quanto_multiplier for contract size
|
|
quantoMultiplier, _ := strconv.ParseFloat(contract.QuantoMultiplier, 64)
|
|
if quantoMultiplier > 0 {
|
|
// Calculate number of contracts
|
|
numContracts := quantity / quantoMultiplier
|
|
return fmt.Sprintf("%.0f", math.Floor(numContracts)), nil
|
|
}
|
|
|
|
return fmt.Sprintf("%.4f", quantity), nil
|
|
}
|
|
|
|
// GetOrderStatus gets the status of an order
|
|
func (t *GateTrader) GetOrderStatus(symbol string, orderID string) (map[string]interface{}, error) {
|
|
symbol = t.convertSymbol(symbol)
|
|
|
|
order, _, err := t.client.FuturesApi.GetFuturesOrder(t.ctx, "usdt", orderID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get order status: %w", err)
|
|
}
|
|
|
|
fillPrice, _ := strconv.ParseFloat(order.FillPrice, 64)
|
|
tkFee, _ := strconv.ParseFloat(order.Tkfr, 64)
|
|
mkFee, _ := strconv.ParseFloat(order.Mkfr, 64)
|
|
totalFee := tkFee + mkFee
|
|
|
|
// Get quanto_multiplier to convert contracts to actual quantity
|
|
quantoMultiplier := 1.0
|
|
contract, contractErr := t.getContract(symbol)
|
|
if contractErr == nil && contract != nil {
|
|
qm, _ := strconv.ParseFloat(contract.QuantoMultiplier, 64)
|
|
if qm > 0 {
|
|
quantoMultiplier = qm
|
|
}
|
|
}
|
|
|
|
// Map status
|
|
status := "NEW"
|
|
switch order.Status {
|
|
case "finished":
|
|
if order.FinishAs == "filled" {
|
|
status = "FILLED"
|
|
} else if order.FinishAs == "cancelled" {
|
|
status = "CANCELED"
|
|
} else {
|
|
status = "CLOSED"
|
|
}
|
|
case "open":
|
|
status = "NEW"
|
|
}
|
|
|
|
side := "BUY"
|
|
if order.Size < 0 {
|
|
side = "SELL"
|
|
}
|
|
|
|
// Convert contract count to actual token quantity
|
|
executedQty := math.Abs(float64(order.Size-order.Left)) * quantoMultiplier
|
|
|
|
return map[string]interface{}{
|
|
"orderId": orderID,
|
|
"symbol": t.revertSymbol(symbol),
|
|
"status": status,
|
|
"avgPrice": fillPrice,
|
|
"executedQty": executedQty,
|
|
"side": side,
|
|
"type": order.Tif,
|
|
"time": int64(order.CreateTime * 1000),
|
|
"updateTime": int64(order.FinishTime * 1000),
|
|
"commission": totalFee,
|
|
}, nil
|
|
}
|
|
|
|
// GetClosedPnL retrieves closed position PnL records
|
|
func (t *GateTrader) GetClosedPnL(startTime time.Time, limit int) ([]types.ClosedPnLRecord, error) {
|
|
if limit <= 0 {
|
|
limit = 100
|
|
}
|
|
if limit > 100 {
|
|
limit = 100
|
|
}
|
|
|
|
opts := &gateapi.ListPositionCloseOpts{
|
|
Limit: optional.NewInt32(int32(limit)),
|
|
From: optional.NewInt64(startTime.Unix()),
|
|
}
|
|
|
|
closedPositions, _, err := t.client.FuturesApi.ListPositionClose(t.ctx, "usdt", opts)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get closed positions: %w", err)
|
|
}
|
|
|
|
records := make([]types.ClosedPnLRecord, 0, len(closedPositions))
|
|
for _, pos := range closedPositions {
|
|
pnl, _ := strconv.ParseFloat(pos.Pnl, 64)
|
|
|
|
record := types.ClosedPnLRecord{
|
|
Symbol: t.revertSymbol(pos.Contract),
|
|
Side: pos.Side,
|
|
RealizedPnL: pnl,
|
|
ExitTime: time.Unix(int64(pos.Time), 0).UTC(),
|
|
CloseType: "unknown",
|
|
}
|
|
|
|
records = append(records, record)
|
|
}
|
|
|
|
return records, nil
|
|
}
|
|
|
|
// GetOpenOrders gets open/pending orders
|
|
func (t *GateTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, error) {
|
|
symbol = t.convertSymbol(symbol)
|
|
|
|
opts := &gateapi.ListFuturesOrdersOpts{
|
|
Contract: optional.NewString(symbol),
|
|
}
|
|
|
|
orders, _, err := t.client.FuturesApi.ListFuturesOrders(t.ctx, "usdt", "open", opts)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get open orders: %w", err)
|
|
}
|
|
|
|
// Get quanto_multiplier to convert contracts to actual quantity
|
|
quantoMultiplier := 1.0
|
|
contract, err := t.getContract(symbol)
|
|
if err == nil && contract != nil {
|
|
qm, _ := strconv.ParseFloat(contract.QuantoMultiplier, 64)
|
|
if qm > 0 {
|
|
quantoMultiplier = qm
|
|
}
|
|
}
|
|
|
|
var result []types.OpenOrder
|
|
for _, order := range orders {
|
|
price, _ := strconv.ParseFloat(order.Price, 64)
|
|
|
|
side := "BUY"
|
|
if order.Size < 0 {
|
|
side = "SELL"
|
|
}
|
|
|
|
// Convert contract count to actual token quantity
|
|
quantity := math.Abs(float64(order.Size)) * quantoMultiplier
|
|
|
|
result = append(result, types.OpenOrder{
|
|
OrderID: fmt.Sprintf("%d", order.Id),
|
|
Symbol: t.revertSymbol(order.Contract),
|
|
Side: side,
|
|
Type: "LIMIT",
|
|
Price: price,
|
|
Quantity: quantity,
|
|
Status: "NEW",
|
|
})
|
|
}
|
|
|
|
// Also get trigger orders
|
|
triggerOpts := &gateapi.ListPriceTriggeredOrdersOpts{
|
|
Contract: optional.NewString(symbol),
|
|
}
|
|
|
|
triggerOrders, _, err := t.client.FuturesApi.ListPriceTriggeredOrders(t.ctx, "usdt", "open", triggerOpts)
|
|
if err == nil {
|
|
for _, order := range triggerOrders {
|
|
triggerPrice, _ := strconv.ParseFloat(order.Trigger.Price, 64)
|
|
|
|
side := "BUY"
|
|
if order.Initial.Size < 0 {
|
|
side = "SELL"
|
|
}
|
|
|
|
orderType := "STOP_MARKET"
|
|
if order.Trigger.Rule == 2 {
|
|
orderType = "TAKE_PROFIT_MARKET"
|
|
}
|
|
|
|
// Convert contract count to actual token quantity
|
|
quantity := math.Abs(float64(order.Initial.Size)) * quantoMultiplier
|
|
|
|
result = append(result, types.OpenOrder{
|
|
OrderID: fmt.Sprintf("%d", order.Id),
|
|
Symbol: t.revertSymbol(order.Initial.Contract),
|
|
Side: side,
|
|
Type: orderType,
|
|
StopPrice: triggerPrice,
|
|
Quantity: quantity,
|
|
Status: "NEW",
|
|
})
|
|
}
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// clearCache clears all caches
|
|
func (t *GateTrader) clearCache() {
|
|
t.balanceCacheMutex.Lock()
|
|
t.cachedBalance = nil
|
|
t.balanceCacheMutex.Unlock()
|
|
|
|
t.positionsCacheMutex.Lock()
|
|
t.cachedPositions = nil
|
|
t.positionsCacheMutex.Unlock()
|
|
}
|
|
|
|
// Ensure GateTrader implements Trader interface
|
|
var _ types.Trader = (*GateTrader)(nil)
|