mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-06-06 05:51:19 +08:00
- Add NowFunc to GORM config for UTC auto-generated timestamps - Add .UTC() to all time.UnixMilli() calls in trader files - Add .UTC() to all time.Now() calls in store and api files - Fix TypeScript unused imports in frontend
1099 lines
29 KiB
Go
1099 lines
29 KiB
Go
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).UTC()
|
|
record.ExitTime = time.UnixMilli(uTime).UTC()
|
|
|
|
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)
|
|
}
|