mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-07 04:50:57 +08:00
- 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
246 lines
7.4 KiB
Go
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
|
|
}
|