package exchange import ( "crypto/hmac" "crypto/sha256" "encoding/base64" "encoding/json" "fmt" "io" "math" "net/http" "strconv" "strings" "time" ) type BitgetTrade struct { APIKey string APISecret string Passphrase string client *http.Client } func NewBitgetTrade(apiKey, apiSecret, passphrase string) *BitgetTrade { return &BitgetTrade{ APIKey: apiKey, APISecret: apiSecret, Passphrase: passphrase, client: &http.Client{Timeout: 10 * time.Second}, } } func (b *BitgetTrade) PlaceMarketOrder(side, symbol, size, tradeSide, holdSide string) (string, error) { ts := fmt.Sprintf("%d", time.Now().UnixMilli()) method := "POST" requestPath := "/api/v2/mix/order/place-order" host := "https://api.bitget.com" body := map[string]interface{}{ "marginCoin": "USDT", "symbol": symbol, "productType": "USDT-FUTURES", "side": side, "orderType": "market", "timeInForce": "IOC", "marginMode": "crossed", "tradeSide": tradeSide, "size": size, } // Close orders require holdSide to identify which position to close if tradeSide == "close" && holdSide != "" { body["holdSide"] = holdSide } bodyJSON, _ := json.Marshal(body) sign := b.sign(method, requestPath, ts, string(bodyJSON)) url := host + requestPath req, _ := http.NewRequest(method, url, strings.NewReader(string(bodyJSON))) req.Header.Set("Content-Type", "application/json") req.Header.Set("ACCESS-KEY", b.APIKey) req.Header.Set("ACCESS-SIGN", sign) req.Header.Set("ACCESS-TIMESTAMP", ts) req.Header.Set("ACCESS-PASSPHRASE", b.Passphrase) resp, err := b.client.Do(req) if err != nil { return "", fmt.Errorf("http request: %w", err) } defer resp.Body.Close() respBody, _ := io.ReadAll(resp.Body) var result struct { Code string `json:"code"` Msg string `json:"msg"` Data struct { OrderID string `json:"orderId"` } `json:"data"` } if err := json.Unmarshal(respBody, &result); err != nil { return "", fmt.Errorf("parse: %s", string(respBody)) } if result.Code != "00000" { return "", fmt.Errorf("bitget error: %s - %s", result.Code, result.Msg) } return result.Data.OrderID, nil } // GetTradeFee queries the fills endpoint for actual fee charged and average fill price. // Retries up to 5 times with 500ms intervals because Bitget's fills API may lag. // Returns (average fill price, fee in USD, error). avgPrice=0 on any fills issue. func (b *BitgetTrade) GetTradeFee(symbol, orderID string) (avgPrice, feeUSD float64, err error) { for i := 0; i < 5; i++ { if i > 0 { time.Sleep(500 * time.Millisecond) } ts := fmt.Sprintf("%d", time.Now().UnixMilli()) method := "GET" requestPath := "/api/v2/mix/order/fills?symbol=" + symbol + "&orderId=" + orderID + "&productType=USDT-FUTURES" host := "https://api.bitget.com" sign := b.sign(method, requestPath, ts, "") url := host + requestPath req, _ := http.NewRequest(method, url, nil) req.Header.Set("Content-Type", "application/json") req.Header.Set("ACCESS-KEY", b.APIKey) req.Header.Set("ACCESS-SIGN", sign) req.Header.Set("ACCESS-TIMESTAMP", ts) req.Header.Set("ACCESS-PASSPHRASE", b.Passphrase) resp, err := b.client.Do(req) if err != nil { return 0, 0, fmt.Errorf("http: %w", err) } respBody, _ := io.ReadAll(resp.Body) resp.Body.Close() var raw struct { Code string `json:"code"` Msg string `json:"msg"` Data struct { FillList []json.RawMessage `json:"fillList"` } `json:"data"` } if err := json.Unmarshal(respBody, &raw); err != nil { return 0, 0, fmt.Errorf("parse: %s", string(respBody)) } if raw.Code != "00000" { return 0, 0, fmt.Errorf("bitget error: %s - %s", raw.Code, raw.Msg) } var totalFee, totalQty, totalCost float64 for _, item := range raw.Data.FillList { var fill struct { FillPrice string `json:"fillPrice"` FillSize string `json:"fillBaseSize"` FillFee string `json:"fillFee"` } if err := json.Unmarshal(item, &fill); err != nil { continue } f, _ := strconv.ParseFloat(fill.FillFee, 64) p, _ := strconv.ParseFloat(fill.FillPrice, 64) q, _ := strconv.ParseFloat(fill.FillSize, 64) totalFee += math.Abs(f) totalCost += p * q totalQty += q } if totalQty > 0 { return totalCost / totalQty, totalFee, nil } } return 0, 0, fmt.Errorf("no fill data after 5 attempts") } // CheckPosition returns the available position size for a coin, or 0 if no position. // Returns (total as float64, raw total string from API) — the raw string can be used // for close orders to ensure correct precision. func (b *BitgetTrade) CheckPosition(symbol string) (float64, string) { ts := fmt.Sprintf("%d", time.Now().UnixMilli()) method := "GET" requestPath := "/api/v2/mix/position/single-position?symbol=" + symbol + "&productType=USDT-FUTURES&marginCoin=USDT" host := "https://api.bitget.com" sign := b.sign(method, requestPath, ts, "") url := host + requestPath req, _ := http.NewRequest(method, url, nil) req.Header.Set("ACCESS-KEY", b.APIKey) req.Header.Set("ACCESS-SIGN", sign) req.Header.Set("ACCESS-TIMESTAMP", ts) req.Header.Set("ACCESS-PASSPHRASE", b.Passphrase) resp, err := b.client.Do(req) if err != nil { return 0, "" } defer resp.Body.Close() respBody, _ := io.ReadAll(resp.Body) var raw struct { Code string `json:"code"` Data []struct { Total string `json:"total"` } `json:"data"` } json.Unmarshal(respBody, &raw) if raw.Code != "00000" || len(raw.Data) == 0 { return 0, "" } total, _ := strconv.ParseFloat(raw.Data[0].Total, 64) return total, raw.Data[0].Total } func (b *BitgetTrade) sign(method, requestPath, timestamp, body string) string { raw := timestamp + method + requestPath + body mac := hmac.New(sha256.New, []byte(b.APISecret)) mac.Write([]byte(raw)) return base64.StdEncoding.EncodeToString(mac.Sum(nil)) } func (b *BitgetTrade) GetBalance() (float64, error) { ts := fmt.Sprintf("%d", time.Now().UnixMilli()) method := "GET" host := "https://api.bitget.com" requestPath := "/api/v2/mix/account/accounts?productType=USDT-FUTURES" sign := b.sign(method, requestPath, ts, "") url := host + requestPath req, _ := http.NewRequest(method, url, nil) req.Header.Set("ACCESS-KEY", b.APIKey) req.Header.Set("ACCESS-SIGN", sign) req.Header.Set("ACCESS-TIMESTAMP", ts) req.Header.Set("ACCESS-PASSPHRASE", b.Passphrase) resp, err := b.client.Do(req) if err != nil { return 0, fmt.Errorf("http: %w", err) } defer resp.Body.Close() respBody, _ := io.ReadAll(resp.Body) var raw map[string]interface{} if err := json.Unmarshal(respBody, &raw); err != nil { return 0, fmt.Errorf("parse: %s", string(respBody)) } code, _ := raw["code"].(string) if code != "00000" && code != "" { msg, _ := raw["msg"].(string) return 0, fmt.Errorf("bitget error: %s - %s", code, msg) } dataRaw, ok := raw["data"] if !ok || dataRaw == nil { return 0, fmt.Errorf("no data in response") } dataArr, ok := dataRaw.([]interface{}) if !ok { return 0, fmt.Errorf("unexpected data format") } for _, item := range dataArr { acct, ok := item.(map[string]interface{}) if !ok { continue } if acct["marginCoin"] == "USDT" { bal, _ := strconv.ParseFloat(fmt.Sprint(acct["available"]), 64) return bal, nil } } return 0, fmt.Errorf("no USDT account found") } func GetBitgetSize(symbol string, amountUSD, price float64) string { if amountUSD < 5 { amountUSD = 5 } sz := amountUSD / price switch symbol { case "DOGEUSDT": if sz < 1 { sz = 1 } return fmt.Sprintf("%.0f", math.Floor(sz)) case "ONDOUSDT": sz = math.Floor(sz*10)/10 if sz < 0.1 { sz = 0.1 } return fmt.Sprintf("%.1f", sz) case "OPUSDT": sz = math.Floor(sz*10)/10 if sz < 0.1 { sz = 0.1 } return fmt.Sprintf("%.1f", sz) case "WIFUSDT": sz = math.Floor(sz*10)/10 if sz < 0.1 { sz = 0.1 } return fmt.Sprintf("%.1f", sz) case "ARBUSDT": sz = math.Floor(sz*100)/100 if sz < 0.01 { sz = 0.01 } return fmt.Sprintf("%.2f", sz) default: return fmt.Sprintf("%.4f", sz) } }