Files
nofx/trader/bybit/trader_account.go
tinkle-community 9ea9bd705f fix(trader): harden API calls with timeouts, strict balance parsing, error context
- binance/bybit/gate: SDK default http.DefaultClient has no timeout; use a
  dedicated 30s-timeout client so a hung connection cannot stall the loop
- bybit: stop mutating http.DefaultClient.Transport, which leaked the
  referer header into every other HTTP request in the process
- add types.ParseFloatField: empty exchange fields stay zero, but malformed
  numeric values now surface as errors instead of silently becoming zero
  balances (applied to GetBalance across 8 exchanges)
- wrap order/market-data errors in auto_trader_orders and okx cancel paths
  with symbol context; log per-order cancel failures in okx CancelAllOrders
2026-06-11 00:30:34 +08:00

246 lines
7.4 KiB
Go

package bybit
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"nofx/trader/types"
"strconv"
"time"
)
// GetBalance retrieves account balance
func (t *BybitTrader) GetBalance() (map[string]interface{}, error) {
// Check cache
t.balanceCacheMutex.RLock()
if t.cachedBalance != nil && time.Since(t.balanceCacheTime) < t.cacheDuration {
balance := t.cachedBalance
t.balanceCacheMutex.RUnlock()
return balance, nil
}
t.balanceCacheMutex.RUnlock()
// Call API
params := map[string]interface{}{
"accountType": "UNIFIED",
}
result, err := t.client.NewUtaBybitServiceWithParams(params).GetAccountWallet(context.Background())
if err != nil {
return nil, fmt.Errorf("failed to get Bybit balance: %w", err)
}
if result.RetCode != 0 {
return nil, fmt.Errorf("Bybit API error: %s", result.RetMsg)
}
// Extract balance information
resultData, ok := result.Result.(map[string]interface{})
if !ok {
return nil, fmt.Errorf("Bybit balance return format error")
}
list, _ := resultData["list"].([]interface{})
var totalEquity, availableBalance, totalWalletBalance, totalPerpUPL float64 = 0, 0, 0, 0
if len(list) > 0 {
account, _ := list[0].(map[string]interface{})
var parseErr error
if equityStr, ok := account["totalEquity"].(string); ok {
if totalEquity, parseErr = types.ParseFloatField("totalEquity", equityStr); parseErr != nil {
return nil, parseErr
}
}
if availStr, ok := account["totalAvailableBalance"].(string); ok {
if availableBalance, parseErr = types.ParseFloatField("totalAvailableBalance", availStr); parseErr != nil {
return nil, parseErr
}
}
// Bybit UNIFIED account wallet balance field
if walletStr, ok := account["totalWalletBalance"].(string); ok {
if totalWalletBalance, parseErr = types.ParseFloatField("totalWalletBalance", walletStr); parseErr != nil {
return nil, parseErr
}
}
// Bybit perpetual contract unrealized PnL
if uplStr, ok := account["totalPerpUPL"].(string); ok {
if totalPerpUPL, parseErr = types.ParseFloatField("totalPerpUPL", uplStr); parseErr != nil {
return nil, parseErr
}
}
}
// If no totalWalletBalance, use totalEquity
if totalWalletBalance == 0 {
totalWalletBalance = totalEquity
}
balance := map[string]interface{}{
"totalEquity": totalEquity,
"totalWalletBalance": totalWalletBalance,
"availableBalance": availableBalance,
"totalUnrealizedProfit": totalPerpUPL,
"balance": totalEquity, // Compatible with other exchange formats
}
// Update cache
t.balanceCacheMutex.Lock()
t.cachedBalance = balance
t.balanceCacheTime = time.Now()
t.balanceCacheMutex.Unlock()
return balance, nil
}
// GetClosedPnL retrieves closed position PnL records from Bybit via direct HTTP API
func (t *BybitTrader) GetClosedPnL(startTime time.Time, limit int) ([]types.ClosedPnLRecord, error) {
// The Bybit SDK doesn't expose the closed-pnl endpoint, use direct HTTP call
return t.getClosedPnLViaHTTP(startTime, limit)
}
// getClosedPnLViaHTTP makes direct HTTP call to Bybit API for closed PnL with proper signing
func (t *BybitTrader) getClosedPnLViaHTTP(startTime time.Time, limit int) ([]types.ClosedPnLRecord, error) {
// Build query string
queryParams := fmt.Sprintf("category=linear&startTime=%d&limit=%d", startTime.UnixMilli(), limit)
url := "https://api.bybit.com/v5/position/closed-pnl?" + queryParams
// Generate timestamp
timestamp := fmt.Sprintf("%d", time.Now().UnixMilli())
recvWindow := "5000"
// Build signature payload: timestamp + api_key + recv_window + queryString
signPayload := timestamp + t.apiKey + recvWindow + queryParams
// Generate HMAC-SHA256 signature
h := hmac.New(sha256.New, []byte(t.secretKey))
h.Write([]byte(signPayload))
signature := hex.EncodeToString(h.Sum(nil))
// Create request
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
// Add Bybit V5 API headers
req.Header.Set("X-BAPI-API-KEY", t.apiKey)
req.Header.Set("X-BAPI-SIGN", signature)
req.Header.Set("X-BAPI-SIGN-TYPE", "2")
req.Header.Set("X-BAPI-TIMESTAMP", timestamp)
req.Header.Set("X-BAPI-RECV-WINDOW", recvWindow)
req.Header.Set("Content-Type", "application/json")
// Use http.DefaultClient for the request
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to call Bybit API: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
var result struct {
RetCode int `json:"retCode"`
RetMsg string `json:"retMsg"`
Result map[string]interface{} `json:"result"`
}
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}
if result.RetCode != 0 {
return nil, fmt.Errorf("Bybit API error: %s", result.RetMsg)
}
return t.parseClosedPnLResult(result.Result)
}
// parseClosedPnLResult parses the closed PnL result from Bybit API
func (t *BybitTrader) parseClosedPnLResult(resultData interface{}) ([]types.ClosedPnLRecord, error) {
data, ok := resultData.(map[string]interface{})
if !ok {
return nil, fmt.Errorf("invalid result format")
}
list, _ := data["list"].([]interface{})
var records []types.ClosedPnLRecord
for _, item := range list {
pnl, ok := item.(map[string]interface{})
if !ok {
continue
}
// Parse fields
symbol, _ := pnl["symbol"].(string)
side, _ := pnl["side"].(string)
orderId, _ := pnl["orderId"].(string)
avgEntryPriceStr, _ := pnl["avgEntryPrice"].(string)
avgExitPriceStr, _ := pnl["avgExitPrice"].(string)
qtyStr, _ := pnl["qty"].(string)
closedPnLStr, _ := pnl["closedPnl"].(string)
cumEntryValueStr, _ := pnl["cumEntryValue"].(string)
cumExitValueStr, _ := pnl["cumExitValue"].(string)
leverageStr, _ := pnl["leverage"].(string)
createdTimeStr, _ := pnl["createdTime"].(string)
updatedTimeStr, _ := pnl["updatedTime"].(string)
avgEntryPrice, _ := strconv.ParseFloat(avgEntryPriceStr, 64)
avgExitPrice, _ := strconv.ParseFloat(avgExitPriceStr, 64)
qty, _ := strconv.ParseFloat(qtyStr, 64)
closedPnL, _ := strconv.ParseFloat(closedPnLStr, 64)
leverage, _ := strconv.ParseInt(leverageStr, 10, 64)
createdTime, _ := strconv.ParseInt(createdTimeStr, 10, 64)
updatedTime, _ := strconv.ParseInt(updatedTimeStr, 10, 64)
// Calculate approximate fee from value difference
cumEntryValue, _ := strconv.ParseFloat(cumEntryValueStr, 64)
cumExitValue, _ := strconv.ParseFloat(cumExitValueStr, 64)
expectedPnL := cumExitValue - cumEntryValue
if side == "Sell" {
expectedPnL = cumEntryValue - cumExitValue
}
fee := expectedPnL - closedPnL
if fee < 0 {
fee = 0
}
// Normalize side
normalizedSide := "long"
if side == "Sell" {
normalizedSide = "short"
}
record := types.ClosedPnLRecord{
Symbol: symbol,
Side: normalizedSide,
EntryPrice: avgEntryPrice,
ExitPrice: avgExitPrice,
Quantity: qty,
RealizedPnL: closedPnL,
Fee: fee,
Leverage: int(leverage),
EntryTime: time.UnixMilli(createdTime).UTC(),
ExitTime: time.UnixMilli(updatedTime).UTC(),
OrderID: orderId,
CloseType: "unknown", // Bybit doesn't provide close type directly
ExchangeID: orderId, // Use orderId as exchange ID
}
records = append(records, record)
}
return records, nil
}