From dcc16fec829d58b6232b1fda70fa6d8898c5f218 Mon Sep 17 00:00:00 2001 From: tinkle-community Date: Fri, 12 Dec 2025 18:59:09 +0800 Subject: [PATCH] feat: add Bitget futures trading support - Add BitgetTrader with full trading implementation - Support one-way position mode with proper API parameters - Add Bitget to all exchange switch statements - Update exchange icons (Bybit, OKX, Bitget, Lighter) - Add Bitget to frontend exchange config modal --- README.md | 3 +- api/server.go | 41 +- manager/trader_manager.go | 4 + store/exchange.go | 8 +- trader/auto_trader.go | 10 +- trader/bitget_trader.go | 1098 +++++++++++++++++ trader/position_sync.go | 3 + web/src/components/ExchangeIcons.tsx | 104 +- .../traders/ExchangeConfigModal.tsx | 17 +- web/src/i18n/translations.ts | 4 +- 10 files changed, 1253 insertions(+), 39 deletions(-) create mode 100644 trader/bitget_trader.go diff --git a/README.md b/README.md index 79a300d6..8d39b6e6 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ ### Core Features - **Multi-AI Support**: Run DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi - switch models anytime -- **Multi-Exchange**: Trade on Binance, Bybit, OKX, Hyperliquid, Aster DEX, Lighter from one platform +- **Multi-Exchange**: Trade on Binance, Bybit, OKX, Bitget, Hyperliquid, Aster DEX, Lighter from one platform - **Strategy Studio**: Visual strategy builder with coin sources, indicators, and risk controls - **AI Debate Arena**: Multiple AI models debate trading decisions with different roles (Bull, Bear, Analyst) - **AI Competition Mode**: Multiple AI traders compete in real-time, track performance side by side @@ -90,6 +90,7 @@ Join our Telegram developer community: **[NOFX Developer Community](https://t.me | **Binance** | ✅ Supported | [Register](https://www.binance.com/join?ref=NOFXENG) | | **Bybit** | ✅ Supported | [Register](https://partner.bybit.com/b/83856) | | **OKX** | ✅ Supported | [Register](https://www.okx.com/join/1865360) | +| **Bitget** | ✅ Supported | [Register](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) | ### Perp-DEX (Decentralized Perpetual Exchanges) diff --git a/api/server.go b/api/server.go index ee24e6f7..35decaea 100644 --- a/api/server.go +++ b/api/server.go @@ -581,6 +581,12 @@ func (s *Server) handleCreateTrader(c *gin.Context) { exchangeCfg.SecretKey, exchangeCfg.Passphrase, ) + case "bitget": + tempTrader = trader.NewBitgetTrader( + exchangeCfg.APIKey, + exchangeCfg.SecretKey, + exchangeCfg.Passphrase, + ) case "lighter": if exchangeCfg.LighterAPIKeyPrivateKey != "" { tempTrader, createErr = trader.NewLighterTraderV2( @@ -1086,6 +1092,33 @@ func (s *Server) handleSyncBalance(c *gin.Context) { exchangeCfg.APIKey, exchangeCfg.SecretKey, ) + case "okx": + tempTrader = trader.NewOKXTrader( + exchangeCfg.APIKey, + exchangeCfg.SecretKey, + exchangeCfg.Passphrase, + ) + case "bitget": + tempTrader = trader.NewBitgetTrader( + exchangeCfg.APIKey, + exchangeCfg.SecretKey, + exchangeCfg.Passphrase, + ) + case "lighter": + if exchangeCfg.LighterAPIKeyPrivateKey != "" { + tempTrader, createErr = trader.NewLighterTraderV2( + exchangeCfg.LighterPrivateKey, + exchangeCfg.LighterWalletAddr, + exchangeCfg.LighterAPIKeyPrivateKey, + exchangeCfg.Testnet, + ) + } else { + tempTrader, createErr = trader.NewLighterTrader( + exchangeCfg.LighterPrivateKey, + exchangeCfg.LighterWalletAddr, + exchangeCfg.Testnet, + ) + } default: c.JSON(http.StatusBadRequest, gin.H{"error": "Unsupported exchange type"}) return @@ -1219,6 +1252,12 @@ func (s *Server) handleClosePosition(c *gin.Context) { exchangeCfg.SecretKey, exchangeCfg.Passphrase, ) + case "bitget": + tempTrader = trader.NewBitgetTrader( + exchangeCfg.APIKey, + exchangeCfg.SecretKey, + exchangeCfg.Passphrase, + ) case "lighter": if exchangeCfg.LighterAPIKeyPrivateKey != "" { tempTrader, createErr = trader.NewLighterTraderV2( @@ -1590,7 +1629,7 @@ func (s *Server) handleCreateExchange(c *gin.Context) { // Validate exchange type validTypes := map[string]bool{ - "binance": true, "bybit": true, "okx": true, + "binance": true, "bybit": true, "okx": true, "bitget": true, "hyperliquid": true, "aster": true, "lighter": true, } if !validTypes[req.ExchangeType] { diff --git a/manager/trader_manager.go b/manager/trader_manager.go index d63d8b73..be7ed325 100644 --- a/manager/trader_manager.go +++ b/manager/trader_manager.go @@ -662,6 +662,10 @@ func (tm *TraderManager) addTraderFromStore(traderCfg *store.Trader, aiModelCfg traderConfig.OKXAPIKey = exchangeCfg.APIKey traderConfig.OKXSecretKey = exchangeCfg.SecretKey traderConfig.OKXPassphrase = exchangeCfg.Passphrase + case "bitget": + traderConfig.BitgetAPIKey = exchangeCfg.APIKey + traderConfig.BitgetSecretKey = exchangeCfg.SecretKey + traderConfig.BitgetPassphrase = exchangeCfg.Passphrase case "hyperliquid": traderConfig.HyperliquidPrivateKey = exchangeCfg.APIKey traderConfig.HyperliquidWalletAddr = exchangeCfg.HyperliquidWalletAddr diff --git a/store/exchange.go b/store/exchange.go index afc4a328..69ea50c7 100644 --- a/store/exchange.go +++ b/store/exchange.go @@ -102,7 +102,7 @@ func (s *ExchangeStore) migrateToMultiAccount() error { var count int err := s.db.QueryRow(` SELECT COUNT(*) FROM exchanges - WHERE exchange_type = '' AND id IN ('binance', 'bybit', 'okx', 'hyperliquid', 'aster', 'lighter') + WHERE exchange_type = '' AND id IN ('binance', 'bybit', 'okx', 'bitget', 'hyperliquid', 'aster', 'lighter') `).Scan(&count) if err != nil { return err @@ -127,7 +127,7 @@ func (s *ExchangeStore) migrateToMultiAccount() error { COALESCE(lighter_private_key, '') as lighter_private_key, COALESCE(lighter_api_key_private_key, '') as lighter_api_key_private_key FROM exchanges - WHERE exchange_type = '' AND id IN ('binance', 'bybit', 'okx', 'hyperliquid', 'aster', 'lighter') + WHERE exchange_type = '' AND id IN ('binance', 'bybit', 'okx', 'bitget', 'hyperliquid', 'aster', 'lighter') `) if err != nil { return err @@ -314,6 +314,8 @@ func getExchangeNameAndType(exchangeType string) (name string, typ string) { return "Bybit Futures", "cex" case "okx": return "OKX Futures", "cex" + case "bitget": + return "Bitget Futures", "cex" case "hyperliquid": return "Hyperliquid", "dex" case "aster": @@ -451,7 +453,7 @@ func (s *ExchangeStore) CreateLegacy(userID, id, name, typ string, enabled bool, hyperliquidWalletAddr, asterUser, asterSigner, asterPrivateKey string) error { // Check if this is an old-style ID (exchange type as ID) - if id == "binance" || id == "bybit" || id == "okx" || id == "hyperliquid" || id == "aster" || id == "lighter" { + if id == "binance" || id == "bybit" || id == "okx" || id == "bitget" || id == "hyperliquid" || id == "aster" || id == "lighter" { // Use new Create method with exchange type _, err := s.Create(userID, id, "Default", enabled, apiKey, secretKey, "", testnet, hyperliquidWalletAddr, asterUser, asterSigner, asterPrivateKey, "", "", "") diff --git a/trader/auto_trader.go b/trader/auto_trader.go index 9128a7ab..c7ec7ee9 100644 --- a/trader/auto_trader.go +++ b/trader/auto_trader.go @@ -22,7 +22,7 @@ type AutoTraderConfig struct { AIModel string // AI model: "qwen" or "deepseek" // Trading platform selection - Exchange string // Exchange type: "binance", "bybit", "okx", "hyperliquid", "aster" or "lighter" + Exchange string // Exchange type: "binance", "bybit", "okx", "bitget", "hyperliquid", "aster" or "lighter" ExchangeID string // Exchange account UUID (for multi-account support) // Binance API configuration @@ -38,6 +38,11 @@ type AutoTraderConfig struct { OKXSecretKey string OKXPassphrase string + // Bitget API configuration + BitgetAPIKey string + BitgetSecretKey string + BitgetPassphrase string + // Hyperliquid configuration HyperliquidPrivateKey string HyperliquidWalletAddr string @@ -222,6 +227,9 @@ func NewAutoTrader(config AutoTraderConfig, st *store.Store, userID string) (*Au case "okx": logger.Infof("🏦 [%s] Using OKX Futures trading", config.Name) trader = NewOKXTrader(config.OKXAPIKey, config.OKXSecretKey, config.OKXPassphrase) + case "bitget": + logger.Infof("🏦 [%s] Using Bitget Futures trading", config.Name) + trader = NewBitgetTrader(config.BitgetAPIKey, config.BitgetSecretKey, config.BitgetPassphrase) case "hyperliquid": logger.Infof("🏦 [%s] Using Hyperliquid trading", config.Name) trader, err = NewHyperliquidTrader(config.HyperliquidPrivateKey, config.HyperliquidWalletAddr, config.HyperliquidTestnet) diff --git a/trader/bitget_trader.go b/trader/bitget_trader.go new file mode 100644 index 00000000..6893a045 --- /dev/null +++ b/trader/bitget_trader.go @@ -0,0 +1,1098 @@ +package trader + +import ( + "bytes" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "nofx/logger" + "strconv" + "strings" + "sync" + "time" +) + +// Bitget API endpoints (V2) +const ( + bitgetBaseURL = "https://api.bitget.com" + bitgetAccountPath = "/api/v2/mix/account/accounts" + bitgetPositionPath = "/api/v2/mix/position/all-position" + bitgetOrderPath = "/api/v2/mix/order/place-order" + bitgetLeveragePath = "/api/v2/mix/account/set-leverage" + bitgetTickerPath = "/api/v2/mix/market/ticker" + bitgetContractsPath = "/api/v2/mix/market/contracts" + bitgetCancelOrderPath = "/api/v2/mix/order/cancel-order" + bitgetPendingPath = "/api/v2/mix/order/orders-pending" + bitgetHistoryPath = "/api/v2/mix/order/orders-history" + bitgetMarginModePath = "/api/v2/mix/account/set-margin-mode" + bitgetPositionModePath = "/api/v2/mix/account/set-position-mode" +) + +// BitgetTrader Bitget futures trader +type BitgetTrader 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]*BitgetContract + contractsCacheTime time.Time + contractsCacheMutex sync.RWMutex + + // Cache duration + cacheDuration time.Duration +} + +// BitgetContract Bitget contract info +type BitgetContract struct { + Symbol string // Symbol name + BaseCoin string // Base coin + QuoteCoin string // Quote coin + MinTradeNum float64 // Minimum trade amount + MaxTradeNum float64 // Maximum trade amount + SizeMultiplier float64 // Contract size multiplier + PricePlace int // Price decimal places + VolumePlace int // Volume decimal places +} + +// BitgetResponse Bitget API response +type BitgetResponse struct { + Code string `json:"code"` + Msg string `json:"msg"` + Data json.RawMessage `json:"data"` + RequestTime int64 `json:"requestTime"` +} + +// NewBitgetTrader creates a Bitget trader +func NewBitgetTrader(apiKey, secretKey, passphrase string) *BitgetTrader { + httpClient := &http.Client{ + Timeout: 30 * time.Second, + Transport: http.DefaultTransport, + } + + trader := &BitgetTrader{ + apiKey: apiKey, + secretKey: secretKey, + passphrase: passphrase, + httpClient: httpClient, + cacheDuration: 15 * time.Second, + contractsCache: make(map[string]*BitgetContract), + } + + // Set one-way position mode (net mode) + if err := trader.setPositionMode(); err != nil { + logger.Infof("⚠️ Failed to set Bitget position mode: %v (ignore if already set)", err) + } + + logger.Infof("🟢 [Bitget] Trader initialized") + + return trader +} + +// setPositionMode sets one-way position mode +func (t *BitgetTrader) setPositionMode() error { + body := map[string]interface{}{ + "productType": "USDT-FUTURES", + "posMode": "one_way_mode", + } + + _, err := t.doRequest("POST", bitgetPositionModePath, body) + if err != nil { + if strings.Contains(err.Error(), "same") || strings.Contains(err.Error(), "already") { + return nil + } + return err + } + + logger.Infof(" ✓ Bitget account switched to one-way position mode") + return nil +} + +// sign generates Bitget API signature +func (t *BitgetTrader) sign(timestamp, method, requestPath, body string) string { + // Signature = BASE64(HMAC_SHA256(timestamp + method + requestPath + 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)) +} + +// doRequest executes HTTP request +func (t *BitgetTrader) doRequest(method, path string, body interface{}) ([]byte, error) { + var bodyBytes []byte + var err error + var queryString string + + if body != nil { + if method == "GET" { + // For GET requests, body is query parameters + if params, ok := body.(map[string]interface{}); ok { + var parts []string + for k, v := range params { + parts = append(parts, fmt.Sprintf("%s=%v", k, v)) + } + queryString = strings.Join(parts, "&") + if queryString != "" { + path = path + "?" + queryString + } + } + } else { + bodyBytes, err = json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("failed to serialize request body: %w", err) + } + } + } + + timestamp := fmt.Sprintf("%d", time.Now().UnixMilli()) + + // Signature includes body for POST, nothing for GET (query is in path) + signBody := "" + if method != "GET" && bodyBytes != nil { + signBody = string(bodyBytes) + } + signature := t.sign(timestamp, method, path, signBody) + + url := bitgetBaseURL + path + req, err := http.NewRequest(method, url, bytes.NewReader(bodyBytes)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("ACCESS-KEY", t.apiKey) + req.Header.Set("ACCESS-SIGN", signature) + req.Header.Set("ACCESS-TIMESTAMP", timestamp) + req.Header.Set("ACCESS-PASSPHRASE", t.passphrase) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("locale", "en-US") + // Channel code only for order endpoints + if strings.Contains(path, "/order/") { + req.Header.Set("X-CHANNEL-API-CODE", "7fygt") + } + + 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 bitgetResp BitgetResponse + if err := json.Unmarshal(respBody, &bitgetResp); err != nil { + return nil, fmt.Errorf("failed to parse response: %w, body: %s", err, string(respBody)) + } + + if bitgetResp.Code != "00000" { + return nil, fmt.Errorf("Bitget API error: code=%s, msg=%s", bitgetResp.Code, bitgetResp.Msg) + } + + return bitgetResp.Data, nil +} + +// convertSymbol converts generic symbol to Bitget format +// e.g., BTCUSDT -> BTCUSDT +func (t *BitgetTrader) convertSymbol(symbol string) string { + // Bitget uses same format as input, just ensure uppercase + return strings.ToUpper(symbol) +} + +// GetBalance gets account balance +func (t *BitgetTrader) 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() + + params := map[string]interface{}{ + "productType": "USDT-FUTURES", + } + + data, err := t.doRequest("GET", bitgetAccountPath, params) + if err != nil { + return nil, fmt.Errorf("failed to get account balance: %w", err) + } + + var accounts []struct { + MarginCoin string `json:"marginCoin"` + Available string `json:"available"` // Available balance + AccountEquity string `json:"accountEquity"` // Total equity + UsdtEquity string `json:"usdtEquity"` // USDT equity + UnrealizedPL string `json:"unrealizedPL"` // Unrealized P&L + } + + if err := json.Unmarshal(data, &accounts); err != nil { + return nil, fmt.Errorf("failed to parse balance data: %w, raw: %s", err, string(data)) + } + + var totalEquity, availableBalance, unrealizedPnL float64 + for _, acc := range accounts { + if acc.MarginCoin == "USDT" { + totalEquity, _ = strconv.ParseFloat(acc.AccountEquity, 64) + availableBalance, _ = strconv.ParseFloat(acc.Available, 64) + unrealizedPnL, _ = strconv.ParseFloat(acc.UnrealizedPL, 64) + logger.Infof("✓ [Bitget] Balance: equity=%.2f, available=%.2f", totalEquity, availableBalance) + break + } + } + + result := map[string]interface{}{ + "totalWalletBalance": totalEquity - unrealizedPnL, + "availableBalance": availableBalance, + "totalUnrealizedProfit": unrealizedPnL, + "total_equity": totalEquity, + } + + // Update cache + t.balanceCacheMutex.Lock() + t.cachedBalance = result + t.balanceCacheTime = time.Now() + t.balanceCacheMutex.Unlock() + + return result, nil +} + +// GetPositions gets all positions +func (t *BitgetTrader) 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() + + params := map[string]interface{}{ + "productType": "USDT-FUTURES", + "marginCoin": "USDT", + } + + data, err := t.doRequest("GET", bitgetPositionPath, params) + if err != nil { + return nil, fmt.Errorf("failed to get positions: %w", err) + } + + var positions []struct { + Symbol string `json:"symbol"` + HoldSide string `json:"holdSide"` // long, short + OpenPriceAvg string `json:"openPriceAvg"` // Average entry price + MarkPrice string `json:"markPrice"` // Mark price + Total string `json:"total"` // Total position size + Available string `json:"available"` // Available to close + UnrealizedPL string `json:"unrealizedPL"` // Unrealized P&L + Leverage string `json:"leverage"` // Leverage + LiquidationPrice string `json:"liquidationPrice"` // Liquidation price + MarginSize string `json:"marginSize"` // Position margin + CTime string `json:"cTime"` // Create time + UTime string `json:"uTime"` // Update time + } + + 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 { + total, _ := strconv.ParseFloat(pos.Total, 64) + if total == 0 { + continue + } + + entryPrice, _ := strconv.ParseFloat(pos.OpenPriceAvg, 64) + markPrice, _ := strconv.ParseFloat(pos.MarkPrice, 64) + unrealizedPnL, _ := strconv.ParseFloat(pos.UnrealizedPL, 64) + leverage, _ := strconv.ParseFloat(pos.Leverage, 64) + liqPrice, _ := strconv.ParseFloat(pos.LiquidationPrice, 64) + cTime, _ := strconv.ParseInt(pos.CTime, 10, 64) + uTime, _ := strconv.ParseInt(pos.UTime, 10, 64) + + // Normalize side + side := "long" + if pos.HoldSide == "short" { + side = "short" + } + + posMap := map[string]interface{}{ + "symbol": pos.Symbol, + "positionAmt": total, + "entryPrice": entryPrice, + "markPrice": markPrice, + "unRealizedProfit": unrealizedPnL, + "leverage": leverage, + "liquidationPrice": liqPrice, + "side": side, + "createdTime": cTime, + "updatedTime": uTime, + } + result = append(result, posMap) + } + + // Update cache + t.positionsCacheMutex.Lock() + t.cachedPositions = result + t.positionsCacheTime = time.Now() + t.positionsCacheMutex.Unlock() + + return result, nil +} + +// getContract gets contract info +func (t *BitgetTrader) getContract(symbol string) (*BitgetContract, error) { + symbol = t.convertSymbol(symbol) + + // Check cache + t.contractsCacheMutex.RLock() + if contract, ok := t.contractsCache[symbol]; ok && time.Since(t.contractsCacheTime) < 5*time.Minute { + t.contractsCacheMutex.RUnlock() + return contract, nil + } + t.contractsCacheMutex.RUnlock() + + params := map[string]interface{}{ + "productType": "USDT-FUTURES", + "symbol": symbol, + } + + data, err := t.doRequest("GET", bitgetContractsPath, params) + if err != nil { + return nil, err + } + + var contracts []struct { + Symbol string `json:"symbol"` + BaseCoin string `json:"baseCoin"` + QuoteCoin string `json:"quoteCoin"` + MinTradeNum string `json:"minTradeNum"` + MaxTradeNum string `json:"maxTradeNum"` + SizeMultiplier string `json:"sizeMultiplier"` + PricePlace string `json:"pricePlace"` + VolumePlace string `json:"volumePlace"` + } + + if err := json.Unmarshal(data, &contracts); err != nil { + return nil, err + } + + // Find matching contract + for _, c := range contracts { + if c.Symbol == symbol { + minTrade, _ := strconv.ParseFloat(c.MinTradeNum, 64) + maxTrade, _ := strconv.ParseFloat(c.MaxTradeNum, 64) + sizeMult, _ := strconv.ParseFloat(c.SizeMultiplier, 64) + pricePlace, _ := strconv.Atoi(c.PricePlace) + volumePlace, _ := strconv.Atoi(c.VolumePlace) + + contract := &BitgetContract{ + Symbol: c.Symbol, + BaseCoin: c.BaseCoin, + QuoteCoin: c.QuoteCoin, + MinTradeNum: minTrade, + MaxTradeNum: maxTrade, + SizeMultiplier: sizeMult, + PricePlace: pricePlace, + VolumePlace: volumePlace, + } + + // Update cache + t.contractsCacheMutex.Lock() + t.contractsCache[symbol] = contract + t.contractsCacheTime = time.Now() + t.contractsCacheMutex.Unlock() + + return contract, nil + } + } + + return nil, fmt.Errorf("contract info not found: %s", symbol) +} + +// SetMarginMode sets margin mode +func (t *BitgetTrader) SetMarginMode(symbol string, isCrossMargin bool) error { + symbol = t.convertSymbol(symbol) + + marginMode := "isolated" + if isCrossMargin { + marginMode = "crossed" + } + + body := map[string]interface{}{ + "symbol": symbol, + "productType": "USDT-FUTURES", + "marginCoin": "USDT", + "marginMode": marginMode, + } + + _, err := t.doRequest("POST", bitgetMarginModePath, body) + if err != nil { + if strings.Contains(err.Error(), "same") || strings.Contains(err.Error(), "already") { + return nil + } + if strings.Contains(err.Error(), "position") { + logger.Infof(" ⚠️ %s has positions, cannot change margin mode", symbol) + return nil + } + return err + } + + logger.Infof(" ✓ %s margin mode set to %s", symbol, marginMode) + return nil +} + +// SetLeverage sets leverage +func (t *BitgetTrader) SetLeverage(symbol string, leverage int) error { + symbol = t.convertSymbol(symbol) + + body := map[string]interface{}{ + "symbol": symbol, + "productType": "USDT-FUTURES", + "marginCoin": "USDT", + "leverage": fmt.Sprintf("%d", leverage), + } + + _, err := t.doRequest("POST", bitgetLeveragePath, body) + if err != nil { + if strings.Contains(err.Error(), "same") { + return nil + } + logger.Infof(" ⚠️ Failed to set %s leverage: %v", symbol, err) + return err + } + + logger.Infof(" ✓ %s leverage set to %dx", symbol, leverage) + return nil +} + +// OpenLong opens long position +func (t *BitgetTrader) 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.Infof(" ⚠️ Failed to set leverage: %v", err) + } + + // Format quantity + qtyStr, _ := t.FormatQuantity(symbol, quantity) + + body := map[string]interface{}{ + "symbol": symbol, + "productType": "USDT-FUTURES", + "marginMode": "crossed", + "marginCoin": "USDT", + "side": "buy", + "orderType": "market", + "size": qtyStr, + "clientOid": genBitgetClientOid(), + } + + logger.Infof(" 📊 Bitget OpenLong: symbol=%s, qty=%s, leverage=%d", symbol, qtyStr, leverage) + + data, err := t.doRequest("POST", bitgetOrderPath, body) + if err != nil { + return nil, fmt.Errorf("failed to open long position: %w", err) + } + + var order struct { + OrderId string `json:"orderId"` + ClientOid string `json:"clientOid"` + } + + if err := json.Unmarshal(data, &order); err != nil { + return nil, fmt.Errorf("failed to parse order response: %w", err) + } + + // Clear cache + t.clearCache() + + logger.Infof("✓ Bitget opened long position successfully: %s", symbol) + + return map[string]interface{}{ + "orderId": order.OrderId, + "symbol": symbol, + "status": "FILLED", + }, nil +} + +// OpenShort opens short position +func (t *BitgetTrader) 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.Infof(" ⚠️ Failed to set leverage: %v", err) + } + + // Format quantity + qtyStr, _ := t.FormatQuantity(symbol, quantity) + + body := map[string]interface{}{ + "symbol": symbol, + "productType": "USDT-FUTURES", + "marginMode": "crossed", + "marginCoin": "USDT", + "side": "sell", + "orderType": "market", + "size": qtyStr, + "clientOid": genBitgetClientOid(), + } + + logger.Infof(" 📊 Bitget OpenShort: symbol=%s, qty=%s, leverage=%d", symbol, qtyStr, leverage) + + data, err := t.doRequest("POST", bitgetOrderPath, body) + if err != nil { + return nil, fmt.Errorf("failed to open short position: %w", err) + } + + var order struct { + OrderId string `json:"orderId"` + ClientOid string `json:"clientOid"` + } + + if err := json.Unmarshal(data, &order); err != nil { + return nil, fmt.Errorf("failed to parse order response: %w", err) + } + + // Clear cache + t.clearCache() + + logger.Infof("✓ Bitget opened short position successfully: %s", symbol) + + return map[string]interface{}{ + "orderId": order.OrderId, + "symbol": symbol, + "status": "FILLED", + }, nil +} + +// CloseLong closes long position +func (t *BitgetTrader) 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 { + if pos["symbol"] == symbol && pos["side"] == "long" { + quantity = pos["positionAmt"].(float64) + break + } + } + if quantity == 0 { + return nil, fmt.Errorf("long position not found for %s", symbol) + } + } + + // Format quantity + qtyStr, _ := t.FormatQuantity(symbol, quantity) + + body := map[string]interface{}{ + "symbol": symbol, + "productType": "USDT-FUTURES", + "marginMode": "crossed", + "marginCoin": "USDT", + "side": "sell", + "orderType": "market", + "size": qtyStr, + "reduceOnly": "YES", + "clientOid": genBitgetClientOid(), + } + + logger.Infof(" 📊 Bitget CloseLong: symbol=%s, qty=%s", symbol, qtyStr) + + data, err := t.doRequest("POST", bitgetOrderPath, body) + if err != nil { + return nil, fmt.Errorf("failed to close long position: %w", err) + } + + var order struct { + OrderId string `json:"orderId"` + } + + if err := json.Unmarshal(data, &order); err != nil { + return nil, err + } + + // Clear cache + t.clearCache() + + logger.Infof("✓ Bitget closed long position successfully: %s", symbol) + + return map[string]interface{}{ + "orderId": order.OrderId, + "symbol": symbol, + "status": "FILLED", + }, nil +} + +// CloseShort closes short position +func (t *BitgetTrader) 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 { + if pos["symbol"] == 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 + } + + // Format quantity + qtyStr, _ := t.FormatQuantity(symbol, quantity) + + body := map[string]interface{}{ + "symbol": symbol, + "productType": "USDT-FUTURES", + "marginMode": "crossed", + "marginCoin": "USDT", + "side": "buy", + "orderType": "market", + "size": qtyStr, + "reduceOnly": "YES", + "clientOid": genBitgetClientOid(), + } + + logger.Infof(" 📊 Bitget CloseShort: symbol=%s, qty=%s", symbol, qtyStr) + + data, err := t.doRequest("POST", bitgetOrderPath, body) + if err != nil { + return nil, fmt.Errorf("failed to close short position: %w", err) + } + + var order struct { + OrderId string `json:"orderId"` + } + + if err := json.Unmarshal(data, &order); err != nil { + return nil, err + } + + // Clear cache + t.clearCache() + + logger.Infof("✓ Bitget closed short position successfully: %s", symbol) + + return map[string]interface{}{ + "orderId": order.OrderId, + "symbol": symbol, + "status": "FILLED", + }, nil +} + +// GetMarketPrice gets market price +func (t *BitgetTrader) GetMarketPrice(symbol string) (float64, error) { + symbol = t.convertSymbol(symbol) + + params := map[string]interface{}{ + "symbol": symbol, + "productType": "USDT-FUTURES", + } + + data, err := t.doRequest("GET", bitgetTickerPath, params) + if err != nil { + return 0, fmt.Errorf("failed to get price: %w", err) + } + + var tickers []struct { + LastPr string `json:"lastPr"` + } + + if err := json.Unmarshal(data, &tickers); err != nil { + return 0, err + } + + if len(tickers) == 0 { + return 0, fmt.Errorf("no price data received") + } + + price, err := strconv.ParseFloat(tickers[0].LastPr, 64) + if err != nil { + return 0, err + } + + return price, nil +} + +// SetStopLoss sets stop loss order +func (t *BitgetTrader) SetStopLoss(symbol string, positionSide string, quantity, stopPrice float64) error { + // Bitget V2 uses plan order for stop loss + symbol = t.convertSymbol(symbol) + + side := "sell" + holdSide := "long" + if strings.ToUpper(positionSide) == "SHORT" { + side = "buy" + holdSide = "short" + } + + qtyStr, _ := t.FormatQuantity(symbol, quantity) + + body := map[string]interface{}{ + "planType": "loss_plan", + "symbol": symbol, + "productType": "USDT-FUTURES", + "marginMode": "crossed", + "marginCoin": "USDT", + "triggerPrice": fmt.Sprintf("%.8f", stopPrice), + "triggerType": "mark_price", + "side": side, + "tradeSide": "close", + "orderType": "market", + "size": qtyStr, + "holdSide": holdSide, + "clientOid": genBitgetClientOid(), + } + + _, err := t.doRequest("POST", "/api/v2/mix/order/place-plan-order", body) + if err != nil { + return fmt.Errorf("failed to set stop loss: %w", err) + } + + logger.Infof(" ✓ [Bitget] Stop loss set: %s @ %.4f", symbol, stopPrice) + return nil +} + +// SetTakeProfit sets take profit order +func (t *BitgetTrader) SetTakeProfit(symbol string, positionSide string, quantity, takeProfitPrice float64) error { + // Bitget V2 uses plan order for take profit + symbol = t.convertSymbol(symbol) + + side := "sell" + holdSide := "long" + if strings.ToUpper(positionSide) == "SHORT" { + side = "buy" + holdSide = "short" + } + + qtyStr, _ := t.FormatQuantity(symbol, quantity) + + body := map[string]interface{}{ + "planType": "profit_plan", + "symbol": symbol, + "productType": "USDT-FUTURES", + "marginMode": "crossed", + "marginCoin": "USDT", + "triggerPrice": fmt.Sprintf("%.8f", takeProfitPrice), + "triggerType": "mark_price", + "side": side, + "tradeSide": "close", + "orderType": "market", + "size": qtyStr, + "holdSide": holdSide, + "clientOid": genBitgetClientOid(), + } + + _, err := t.doRequest("POST", "/api/v2/mix/order/place-plan-order", body) + if err != nil { + return fmt.Errorf("failed to set take profit: %w", err) + } + + logger.Infof(" ✓ [Bitget] Take profit set: %s @ %.4f", symbol, takeProfitPrice) + return nil +} + +// CancelStopLossOrders cancels stop loss orders +func (t *BitgetTrader) CancelStopLossOrders(symbol string) error { + return t.cancelPlanOrders(symbol, "loss_plan") +} + +// CancelTakeProfitOrders cancels take profit orders +func (t *BitgetTrader) CancelTakeProfitOrders(symbol string) error { + return t.cancelPlanOrders(symbol, "profit_plan") +} + +// cancelPlanOrders cancels plan orders +func (t *BitgetTrader) cancelPlanOrders(symbol string, planType string) error { + symbol = t.convertSymbol(symbol) + + // Get pending plan orders + params := map[string]interface{}{ + "symbol": symbol, + "productType": "USDT-FUTURES", + "planType": planType, + } + + data, err := t.doRequest("GET", "/api/v2/mix/order/orders-plan-pending", params) + if err != nil { + return err + } + + var orders struct { + EntrustedList []struct { + OrderId string `json:"orderId"` + } `json:"entrustedList"` + } + + if err := json.Unmarshal(data, &orders); err != nil { + return err + } + + // Cancel each order + for _, order := range orders.EntrustedList { + body := map[string]interface{}{ + "symbol": symbol, + "productType": "USDT-FUTURES", + "marginCoin": "USDT", + "orderId": order.OrderId, + } + t.doRequest("POST", "/api/v2/mix/order/cancel-plan-order", body) + } + + return nil +} + +// CancelAllOrders cancels all pending orders +func (t *BitgetTrader) CancelAllOrders(symbol string) error { + symbol = t.convertSymbol(symbol) + + // Get pending orders + params := map[string]interface{}{ + "symbol": symbol, + "productType": "USDT-FUTURES", + } + + data, err := t.doRequest("GET", bitgetPendingPath, params) + if err != nil { + return err + } + + var orders struct { + EntrustedList []struct { + OrderId string `json:"orderId"` + } `json:"entrustedList"` + } + + if err := json.Unmarshal(data, &orders); err != nil { + return err + } + + // Cancel each order + for _, order := range orders.EntrustedList { + body := map[string]interface{}{ + "symbol": symbol, + "productType": "USDT-FUTURES", + "marginCoin": "USDT", + "orderId": order.OrderId, + } + t.doRequest("POST", bitgetCancelOrderPath, body) + } + + // Also cancel plan orders + t.cancelPlanOrders(symbol, "loss_plan") + t.cancelPlanOrders(symbol, "profit_plan") + + return nil +} + +// CancelStopOrders cancels stop loss and take profit orders +func (t *BitgetTrader) CancelStopOrders(symbol string) error { + t.CancelStopLossOrders(symbol) + t.CancelTakeProfitOrders(symbol) + return nil +} + +// FormatQuantity formats quantity +func (t *BitgetTrader) FormatQuantity(symbol string, quantity float64) (string, error) { + contract, err := t.getContract(symbol) + if err != nil { + return fmt.Sprintf("%.4f", quantity), nil + } + + // Format according to volume precision + format := fmt.Sprintf("%%.%df", contract.VolumePlace) + return fmt.Sprintf(format, quantity), nil +} + +// GetOrderStatus gets order status +func (t *BitgetTrader) GetOrderStatus(symbol string, orderID string) (map[string]interface{}, error) { + symbol = t.convertSymbol(symbol) + + params := map[string]interface{}{ + "symbol": symbol, + "productType": "USDT-FUTURES", + "orderId": orderID, + } + + data, err := t.doRequest("GET", "/api/v2/mix/order/detail", params) + if err != nil { + return nil, fmt.Errorf("failed to get order status: %w", err) + } + + var order struct { + OrderId string `json:"orderId"` + State string `json:"state"` // filled, canceled, partially_filled, new + PriceAvg string `json:"priceAvg"` // Average fill price + BaseVolume string `json:"baseVolume"` // Filled quantity + Fee string `json:"fee"` // Fee + Side string `json:"side"` + OrderType string `json:"orderType"` + CTime string `json:"cTime"` + UTime string `json:"uTime"` + } + + if err := json.Unmarshal(data, &order); err != nil { + return nil, err + } + + avgPrice, _ := strconv.ParseFloat(order.PriceAvg, 64) + fillQty, _ := strconv.ParseFloat(order.BaseVolume, 64) + fee, _ := strconv.ParseFloat(order.Fee, 64) + cTime, _ := strconv.ParseInt(order.CTime, 10, 64) + uTime, _ := strconv.ParseInt(order.UTime, 10, 64) + + // Status mapping + statusMap := map[string]string{ + "filled": "FILLED", + "new": "NEW", + "partially_filled": "PARTIALLY_FILLED", + "canceled": "CANCELED", + } + + status := statusMap[order.State] + if status == "" { + status = order.State + } + + return map[string]interface{}{ + "orderId": order.OrderId, + "symbol": symbol, + "status": status, + "avgPrice": avgPrice, + "executedQty": fillQty, + "side": order.Side, + "type": order.OrderType, + "time": cTime, + "updateTime": uTime, + "commission": -fee, + }, nil +} + +// GetClosedPnL retrieves closed position PnL records +func (t *BitgetTrader) GetClosedPnL(startTime time.Time, limit int) ([]ClosedPnLRecord, error) { + if limit <= 0 { + limit = 100 + } + if limit > 100 { + limit = 100 + } + + params := map[string]interface{}{ + "productType": "USDT-FUTURES", + "startTime": fmt.Sprintf("%d", startTime.UnixMilli()), + "limit": fmt.Sprintf("%d", limit), + } + + data, err := t.doRequest("GET", "/api/v2/mix/position/history-position", params) + if err != nil { + return nil, fmt.Errorf("failed to get positions history: %w", err) + } + + var resp struct { + List []struct { + Symbol string `json:"symbol"` + HoldSide string `json:"holdSide"` + OpenPriceAvg string `json:"openPriceAvg"` + ClosePriceAvg string `json:"closePriceAvg"` + CloseVol string `json:"closeVol"` + AchievedProfits string `json:"achievedProfits"` + TotalFee string `json:"totalFee"` + Leverage string `json:"leverage"` + CTime string `json:"cTime"` + UTime string `json:"uTime"` + } `json:"list"` + } + + if err := json.Unmarshal(data, &resp); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + records := make([]ClosedPnLRecord, 0, len(resp.List)) + for _, pos := range resp.List { + record := ClosedPnLRecord{ + Symbol: pos.Symbol, + Side: pos.HoldSide, + } + + record.EntryPrice, _ = strconv.ParseFloat(pos.OpenPriceAvg, 64) + record.ExitPrice, _ = strconv.ParseFloat(pos.ClosePriceAvg, 64) + record.Quantity, _ = strconv.ParseFloat(pos.CloseVol, 64) + record.RealizedPnL, _ = strconv.ParseFloat(pos.AchievedProfits, 64) + fee, _ := strconv.ParseFloat(pos.TotalFee, 64) + record.Fee = -fee + lev, _ := strconv.ParseFloat(pos.Leverage, 64) + record.Leverage = int(lev) + + cTime, _ := strconv.ParseInt(pos.CTime, 10, 64) + uTime, _ := strconv.ParseInt(pos.UTime, 10, 64) + record.EntryTime = time.UnixMilli(cTime) + record.ExitTime = time.UnixMilli(uTime) + + record.CloseType = "unknown" + records = append(records, record) + } + + return records, nil +} + +// clearCache clears all caches +func (t *BitgetTrader) clearCache() { + t.balanceCacheMutex.Lock() + t.cachedBalance = nil + t.balanceCacheMutex.Unlock() + + t.positionsCacheMutex.Lock() + t.cachedPositions = nil + t.positionsCacheMutex.Unlock() +} + +// genBitgetClientOid generates unique client order ID +func genBitgetClientOid() string { + timestamp := time.Now().UnixNano() % 10000000000000 + rand := time.Now().Nanosecond() % 100000 + return fmt.Sprintf("nofx%d%05d", timestamp, rand) +} diff --git a/trader/position_sync.go b/trader/position_sync.go index cee40c04..95ae3ecd 100644 --- a/trader/position_sync.go +++ b/trader/position_sync.go @@ -500,6 +500,9 @@ func (m *PositionSyncManager) createTrader(config *store.TraderFullConfig) (Trad case "okx": return NewOKXTrader(exchange.APIKey, exchange.SecretKey, exchange.Passphrase), nil + case "bitget": + return NewBitgetTrader(exchange.APIKey, exchange.SecretKey, exchange.Passphrase), nil + case "hyperliquid": return NewHyperliquidTrader(exchange.SecretKey, exchange.HyperliquidWalletAddr, exchange.Testnet) diff --git a/web/src/components/ExchangeIcons.tsx b/web/src/components/ExchangeIcons.tsx index eee47afc..e4792339 100644 --- a/web/src/components/ExchangeIcons.tsx +++ b/web/src/components/ExchangeIcons.tsx @@ -47,7 +47,7 @@ const HyperliquidIcon: React.FC = ({ ) -// Bybit SVG 图标组件 +// Bybit SVG 图标组件 (Official from bybit-web3.github.io) const BybitIcon: React.FC = ({ width = 24, height = 24, @@ -56,27 +56,26 @@ const BybitIcon: React.FC = ({ - - - - + + + + + + + + + + + + + + + ) @@ -94,11 +93,32 @@ const OKXIcon: React.FC = ({ xmlns="http://www.w3.org/2000/svg" className={className} > - - - - - + + + + + + + +) + +// Bitget SVG 图标组件 +const BitgetIcon: React.FC = ({ + width = 24, + height = 24, + className, +}) => ( + + + + ) @@ -180,6 +200,26 @@ const AsterIcon: React.FC = ({ ) +// Lighter SVG 图标组件 +const LighterIcon: React.FC = ({ + width = 24, + height = 24, + className, +}) => ( + + + + + +) + // 获取交易所图标的函数 export const getExchangeIcon = ( exchangeType: string, @@ -192,11 +232,15 @@ export const getExchangeIcon = ( ? 'bybit' : exchangeType.toLowerCase().includes('okx') ? 'okx' - : exchangeType.toLowerCase().includes('hyperliquid') - ? 'hyperliquid' - : exchangeType.toLowerCase().includes('aster') - ? 'aster' - : exchangeType.toLowerCase() + : exchangeType.toLowerCase().includes('bitget') + ? 'bitget' + : exchangeType.toLowerCase().includes('hyperliquid') + ? 'hyperliquid' + : exchangeType.toLowerCase().includes('aster') + ? 'aster' + : exchangeType.toLowerCase().includes('lighter') + ? 'lighter' + : exchangeType.toLowerCase() const iconProps = { width: props.width || 24, @@ -211,11 +255,15 @@ export const getExchangeIcon = ( return case 'okx': return + case 'bitget': + return case 'hyperliquid': case 'dex': return case 'aster': return + case 'lighter': + return case 'cex': default: return ( diff --git a/web/src/components/traders/ExchangeConfigModal.tsx b/web/src/components/traders/ExchangeConfigModal.tsx index 964a54cd..4739d821 100644 --- a/web/src/components/traders/ExchangeConfigModal.tsx +++ b/web/src/components/traders/ExchangeConfigModal.tsx @@ -21,6 +21,7 @@ const SUPPORTED_EXCHANGE_TEMPLATES = [ { exchange_type: 'binance', name: 'Binance Futures', type: 'cex' as const }, { exchange_type: 'bybit', name: 'Bybit Futures', type: 'cex' as const }, { exchange_type: 'okx', name: 'OKX Futures', type: 'cex' as const }, + { exchange_type: 'bitget', name: 'Bitget Futures', type: 'cex' as const }, { exchange_type: 'hyperliquid', name: 'Hyperliquid', type: 'dex' as const }, { exchange_type: 'aster', name: 'Aster DEX', type: 'dex' as const }, { exchange_type: 'lighter', name: 'Lighter', type: 'dex' as const }, @@ -123,6 +124,7 @@ export function ExchangeConfigModal({ binance: { url: 'https://www.binance.com/join?ref=NOFXENG', hasReferral: true }, okx: { url: 'https://www.okx.com/join/1865360', hasReferral: true }, bybit: { url: 'https://partner.bybit.com/b/83856', hasReferral: true }, + bitget: { url: 'https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172', hasReferral: true }, hyperliquid: { url: 'https://app.hyperliquid.xyz/join/AITRADING', hasReferral: true }, aster: { url: 'https://www.asterdex.com/en/referral/fdfc0e', hasReferral: true }, lighter: { url: 'https://lighter.xyz', hasReferral: false }, @@ -282,6 +284,9 @@ export function ExchangeConfigModal({ } else if (currentExchangeType === 'okx') { if (!apiKey.trim() || !secretKey.trim() || !passphrase.trim()) return await onSave(exchangeId, exchangeType, trimmedAccountName, apiKey.trim(), secretKey.trim(), passphrase.trim(), testnet) + } else if (currentExchangeType === 'bitget') { + if (!apiKey.trim() || !secretKey.trim() || !passphrase.trim()) return + await onSave(exchangeId, exchangeType, trimmedAccountName, apiKey.trim(), secretKey.trim(), passphrase.trim(), testnet) } else if (currentExchangeType === 'hyperliquid') { if (!apiKey.trim() || !hyperliquidWalletAddr.trim()) return // 验证私钥和钱包地址 await onSave( @@ -533,10 +538,11 @@ export function ExchangeConfigModal({ {selectedTemplate && ( <> - {/* Binance/Bybit/OKX 的输入字段 */} + {/* Binance/Bybit/OKX/Bitget 的输入字段 */} {(currentExchangeType === 'binance' || currentExchangeType === 'bybit' || - currentExchangeType === 'okx') && ( + currentExchangeType === 'okx' || + currentExchangeType === 'bitget') && ( <> {/* 币安用户配置提示 (D1 方案) */} {currentExchangeType === 'binance' && ( @@ -681,7 +687,7 @@ export function ExchangeConfigModal({ /> - {currentExchangeType === 'okx' && ( + {(currentExchangeType === 'okx' || currentExchangeType === 'bitget') && (