mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-14 08:16:56 +08:00
feat(gate): complete Gate.io exchange integration with trader refactoring
Gate.io Integration: - Add Gate trader with full Trader interface implementation - Add order_sync.go for background trade synchronization - Fix quantity display (convert contracts to actual tokens via quanto_multiplier) - Fix fill price return in OpenLong/OpenShort/CloseLong/CloseShort - Add Gate-specific CoinAnk K-line data source support - Add Gate to supported exchanges in frontend and backend - Add Gate/KuCoin logo SVG icons Trader Package Refactoring: - Move exchange-specific code into subdirectories (binance/, bybit/, okx/, bitget/, hyperliquid/, aster/, lighter/, gate/) - Create types/ package for shared types to avoid circular dependencies - Move TraderTestSuite to trader/testutil package to avoid import cycles - Update market.GetWithExchange to support exchange-specific data
This commit is contained in:
311
trader/bybit/order_sync.go
Normal file
311
trader/bybit/order_sync.go
Normal file
@@ -0,0 +1,311 @@
|
||||
package bybit
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"nofx/logger"
|
||||
"nofx/market"
|
||||
"nofx/store"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// BybitTrade represents a trade record from Bybit execution list
|
||||
type BybitTrade struct {
|
||||
Symbol string
|
||||
OrderID string
|
||||
ExecID string
|
||||
Side string // Buy or Sell
|
||||
ExecPrice float64
|
||||
ExecQty float64
|
||||
ExecFee float64
|
||||
ExecTime time.Time
|
||||
IsMaker bool
|
||||
OrderType string
|
||||
ClosedSize float64 // For close orders
|
||||
ClosedPnL float64
|
||||
OrderAction string // open_long, open_short, close_long, close_short
|
||||
}
|
||||
|
||||
// GetTrades retrieves trade/execution records from Bybit
|
||||
func (t *BybitTrader) GetTrades(startTime time.Time, limit int) ([]BybitTrade, error) {
|
||||
return t.getTradesViaHTTP(startTime, limit)
|
||||
}
|
||||
|
||||
// getTradesViaHTTP makes direct HTTP call to Bybit API for execution list
|
||||
func (t *BybitTrader) getTradesViaHTTP(startTime time.Time, limit int) ([]BybitTrade, error) {
|
||||
// Build query string
|
||||
queryParams := fmt.Sprintf("category=linear&startTime=%d&limit=%d", startTime.UnixMilli(), limit)
|
||||
url := "https://api.bybit.com/v5/execution/list?" + 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 struct {
|
||||
List []map[string]interface{} `json:"list"`
|
||||
} `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.parseTradesResult(result.Result.List)
|
||||
}
|
||||
|
||||
// parseTradesResult parses the execution list result from Bybit API
|
||||
func (t *BybitTrader) parseTradesResult(list []map[string]interface{}) ([]BybitTrade, error) {
|
||||
var trades []BybitTrade
|
||||
|
||||
for _, item := range list {
|
||||
symbol, _ := item["symbol"].(string)
|
||||
orderID, _ := item["orderId"].(string)
|
||||
execID, _ := item["execId"].(string)
|
||||
side, _ := item["side"].(string)
|
||||
orderType, _ := item["orderType"].(string)
|
||||
isMaker, _ := item["isMaker"].(bool)
|
||||
|
||||
execPriceStr, _ := item["execPrice"].(string)
|
||||
execQtyStr, _ := item["execQty"].(string)
|
||||
execFeeStr, _ := item["execFee"].(string)
|
||||
closedSizeStr, _ := item["closedSize"].(string)
|
||||
closedPnlStr, _ := item["closedPnl"].(string)
|
||||
execTimeStr, _ := item["execTime"].(string)
|
||||
|
||||
execPrice, _ := strconv.ParseFloat(execPriceStr, 64)
|
||||
execQty, _ := strconv.ParseFloat(execQtyStr, 64)
|
||||
execFee, _ := strconv.ParseFloat(execFeeStr, 64)
|
||||
closedSize, _ := strconv.ParseFloat(closedSizeStr, 64)
|
||||
closedPnl, _ := strconv.ParseFloat(closedPnlStr, 64)
|
||||
execTimeMs, _ := strconv.ParseInt(execTimeStr, 10, 64)
|
||||
execTime := time.UnixMilli(execTimeMs).UTC()
|
||||
|
||||
// Determine order action based on side and closedSize
|
||||
// If closedSize > 0, it's a close trade
|
||||
// Side: Buy = long direction, Sell = short direction
|
||||
orderAction := "open_long"
|
||||
if closedSize > 0 {
|
||||
// This is a close trade
|
||||
if strings.ToLower(side) == "sell" {
|
||||
orderAction = "close_long" // Selling to close a long
|
||||
} else {
|
||||
orderAction = "close_short" // Buying to close a short
|
||||
}
|
||||
} else {
|
||||
// This is an open trade
|
||||
if strings.ToLower(side) == "buy" {
|
||||
orderAction = "open_long"
|
||||
} else {
|
||||
orderAction = "open_short"
|
||||
}
|
||||
}
|
||||
|
||||
trade := BybitTrade{
|
||||
Symbol: symbol,
|
||||
OrderID: orderID,
|
||||
ExecID: execID,
|
||||
Side: side,
|
||||
ExecPrice: execPrice,
|
||||
ExecQty: execQty,
|
||||
ExecFee: execFee,
|
||||
ExecTime: execTime,
|
||||
IsMaker: isMaker,
|
||||
OrderType: orderType,
|
||||
ClosedSize: closedSize,
|
||||
ClosedPnL: closedPnl,
|
||||
OrderAction: orderAction,
|
||||
}
|
||||
|
||||
trades = append(trades, trade)
|
||||
}
|
||||
|
||||
return trades, nil
|
||||
}
|
||||
|
||||
// SyncOrdersFromBybit syncs Bybit exchange order history to local database
|
||||
// Also creates/updates position records to ensure orders/fills/positions data consistency
|
||||
// exchangeID: Exchange account UUID (from exchanges.id)
|
||||
// exchangeType: Exchange type ("bybit")
|
||||
func (t *BybitTrader) SyncOrdersFromBybit(traderID string, exchangeID string, exchangeType string, st *store.Store) error {
|
||||
if st == nil {
|
||||
return fmt.Errorf("store is nil")
|
||||
}
|
||||
|
||||
// Get recent trades (last 24 hours)
|
||||
startTime := time.Now().Add(-24 * time.Hour)
|
||||
|
||||
logger.Infof("🔄 Syncing Bybit trades from: %s", startTime.Format(time.RFC3339))
|
||||
|
||||
// Use GetTrades method to fetch trade records
|
||||
trades, err := t.GetTrades(startTime, 1000)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get trades: %w", err)
|
||||
}
|
||||
|
||||
logger.Infof("📥 Received %d trades from Bybit", len(trades))
|
||||
|
||||
// Sort trades by time ASC (oldest first) for proper position building
|
||||
sort.Slice(trades, func(i, j int) bool {
|
||||
return trades[i].ExecTime.UnixMilli() < trades[j].ExecTime.UnixMilli()
|
||||
})
|
||||
|
||||
// Process trades one by one (no transaction to avoid deadlock)
|
||||
orderStore := st.Order()
|
||||
positionStore := st.Position()
|
||||
posBuilder := store.NewPositionBuilder(positionStore)
|
||||
syncedCount := 0
|
||||
|
||||
for _, trade := range trades {
|
||||
// Check if trade already exists (use exchangeID which is UUID, not exchange type)
|
||||
existing, err := orderStore.GetOrderByExchangeID(exchangeID, trade.ExecID)
|
||||
if err == nil && existing != nil {
|
||||
continue // Order already exists, skip
|
||||
}
|
||||
|
||||
// Normalize symbol
|
||||
symbol := market.Normalize(trade.Symbol)
|
||||
|
||||
// Determine position side from order action
|
||||
positionSide := "LONG"
|
||||
if strings.Contains(trade.OrderAction, "short") {
|
||||
positionSide = "SHORT"
|
||||
}
|
||||
|
||||
// Normalize side for storage
|
||||
side := strings.ToUpper(trade.Side)
|
||||
|
||||
// Create order record - use UTC time in milliseconds to avoid timezone issues
|
||||
execTimeMs := trade.ExecTime.UTC().UnixMilli()
|
||||
orderRecord := &store.TraderOrder{
|
||||
TraderID: traderID,
|
||||
ExchangeID: exchangeID, // UUID
|
||||
ExchangeType: exchangeType, // Exchange type
|
||||
ExchangeOrderID: trade.ExecID, // Use ExecID as unique identifier
|
||||
Symbol: symbol,
|
||||
Side: side,
|
||||
PositionSide: "BOTH", // Bybit uses one-way position mode
|
||||
Type: trade.OrderType,
|
||||
OrderAction: trade.OrderAction,
|
||||
Quantity: trade.ExecQty,
|
||||
Price: trade.ExecPrice,
|
||||
Status: "FILLED",
|
||||
FilledQuantity: trade.ExecQty,
|
||||
AvgFillPrice: trade.ExecPrice,
|
||||
Commission: trade.ExecFee,
|
||||
FilledAt: execTimeMs,
|
||||
CreatedAt: execTimeMs,
|
||||
UpdatedAt: execTimeMs,
|
||||
}
|
||||
|
||||
// Insert order record
|
||||
if err := orderStore.CreateOrder(orderRecord); err != nil {
|
||||
logger.Infof(" ⚠️ Failed to sync trade %s: %v", trade.ExecID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Create fill record - use UTC time
|
||||
fillRecord := &store.TraderFill{
|
||||
TraderID: traderID,
|
||||
ExchangeID: exchangeID, // UUID
|
||||
ExchangeType: exchangeType, // Exchange type
|
||||
OrderID: orderRecord.ID,
|
||||
ExchangeOrderID: trade.OrderID,
|
||||
ExchangeTradeID: trade.ExecID,
|
||||
Symbol: symbol,
|
||||
Side: side,
|
||||
Price: trade.ExecPrice,
|
||||
Quantity: trade.ExecQty,
|
||||
QuoteQuantity: trade.ExecPrice * trade.ExecQty,
|
||||
Commission: trade.ExecFee,
|
||||
CommissionAsset: "USDT",
|
||||
RealizedPnL: trade.ClosedPnL,
|
||||
IsMaker: trade.IsMaker,
|
||||
CreatedAt: execTimeMs,
|
||||
}
|
||||
|
||||
if err := orderStore.CreateFill(fillRecord); err != nil {
|
||||
logger.Infof(" ⚠️ Failed to sync fill for trade %s: %v", trade.ExecID, err)
|
||||
}
|
||||
|
||||
// Create/update position record using PositionBuilder
|
||||
if err := posBuilder.ProcessTrade(
|
||||
traderID, exchangeID, exchangeType,
|
||||
symbol, positionSide, trade.OrderAction,
|
||||
trade.ExecQty, trade.ExecPrice, trade.ExecFee, trade.ClosedPnL,
|
||||
execTimeMs, trade.ExecID,
|
||||
); err != nil {
|
||||
logger.Infof(" ⚠️ Failed to sync position for trade %s: %v", trade.ExecID, err)
|
||||
} else {
|
||||
logger.Infof(" 📍 Position updated for trade: %s (action: %s, qty: %.6f)", trade.ExecID, trade.OrderAction, trade.ExecQty)
|
||||
}
|
||||
|
||||
syncedCount++
|
||||
logger.Infof(" ✅ Synced trade: %s %s %s qty=%.6f price=%.6f pnl=%.2f fee=%.6f action=%s",
|
||||
trade.ExecID, symbol, side, trade.ExecQty, trade.ExecPrice, trade.ClosedPnL, trade.ExecFee, trade.OrderAction)
|
||||
}
|
||||
|
||||
logger.Infof("✅ Bybit order sync completed: %d new trades synced", syncedCount)
|
||||
return nil
|
||||
}
|
||||
|
||||
// StartOrderSync starts background order sync task for Bybit
|
||||
func (t *BybitTrader) StartOrderSync(traderID string, exchangeID string, exchangeType string, st *store.Store, interval time.Duration) {
|
||||
ticker := time.NewTicker(interval)
|
||||
go func() {
|
||||
for range ticker.C {
|
||||
if err := t.SyncOrdersFromBybit(traderID, exchangeID, exchangeType, st); err != nil {
|
||||
logger.Infof("⚠️ Bybit order sync failed: %v", err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
logger.Infof("🔄 Bybit order sync started (interval: %v)", interval)
|
||||
}
|
||||
1264
trader/bybit/trader.go
Normal file
1264
trader/bybit/trader.go
Normal file
File diff suppressed because it is too large
Load Diff
471
trader/bybit/trader_test.go
Normal file
471
trader/bybit/trader_test.go
Normal file
@@ -0,0 +1,471 @@
|
||||
package bybit
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"nofx/trader/testutil"
|
||||
"nofx/trader/types"
|
||||
)
|
||||
|
||||
// ============================================================
|
||||
// Part 1: BybitTraderTestSuite - Inherits base test suite
|
||||
// ============================================================
|
||||
|
||||
// BybitTraderTestSuite Bybit trader test suite
|
||||
// Inherits TraderTestSuite and adds Bybit-specific mock logic
|
||||
type BybitTraderTestSuite struct {
|
||||
*testutil.TraderTestSuite // Embeds base test suite
|
||||
mockServer *httptest.Server
|
||||
}
|
||||
|
||||
// NewBybitTraderTestSuite Create Bybit test suite
|
||||
// Note: Due to Bybit SDK encapsulation design, cannot easily inject mock HTTP client
|
||||
// Therefore this test suite is mainly used for interface compliance verification, not API call testing
|
||||
func NewBybitTraderTestSuite(t *testing.T) *BybitTraderTestSuite {
|
||||
// Create mock HTTP server (for response format verification)
|
||||
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
path := r.URL.Path
|
||||
var respBody interface{}
|
||||
|
||||
switch {
|
||||
case path == "/v5/account/wallet-balance":
|
||||
respBody = map[string]interface{}{
|
||||
"retCode": 0,
|
||||
"retMsg": "OK",
|
||||
"result": map[string]interface{}{
|
||||
"list": []map[string]interface{}{
|
||||
{
|
||||
"accountType": "UNIFIED",
|
||||
"totalEquity": "10100.50",
|
||||
"coin": []map[string]interface{}{
|
||||
{
|
||||
"coin": "USDT",
|
||||
"walletBalance": "10000.00",
|
||||
"unrealisedPnl": "100.50",
|
||||
"availableToWithdraw": "8000.00",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
default:
|
||||
respBody = map[string]interface{}{
|
||||
"retCode": 0,
|
||||
"retMsg": "OK",
|
||||
"result": map[string]interface{}{},
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(respBody)
|
||||
}))
|
||||
|
||||
// Create real Bybit trader (for interface compliance testing)
|
||||
traderInstance := NewBybitTrader("test_api_key", "test_secret_key")
|
||||
|
||||
// Create base suite
|
||||
baseSuite := testutil.NewTraderTestSuite(t, traderInstance)
|
||||
|
||||
return &BybitTraderTestSuite{
|
||||
TraderTestSuite: baseSuite,
|
||||
mockServer: mockServer,
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup Clean up resources
|
||||
func (s *BybitTraderTestSuite) Cleanup() {
|
||||
if s.mockServer != nil {
|
||||
s.mockServer.Close()
|
||||
}
|
||||
s.TraderTestSuite.Cleanup()
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Part 2: Interface compliance tests
|
||||
// ============================================================
|
||||
|
||||
// TestBybitTrader_InterfaceCompliance Test interface compliance
|
||||
func TestBybitTrader_InterfaceCompliance(t *testing.T) {
|
||||
var _ types.Trader = (*BybitTrader)(nil)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Part 3: Bybit-specific feature unit tests
|
||||
// ============================================================
|
||||
|
||||
// TestNewBybitTrader Test creating Bybit trader
|
||||
func TestNewBybitTrader(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
apiKey string
|
||||
secretKey string
|
||||
wantNil bool
|
||||
}{
|
||||
{
|
||||
name: "Successfully create",
|
||||
apiKey: "test_api_key",
|
||||
secretKey: "test_secret_key",
|
||||
wantNil: false,
|
||||
},
|
||||
{
|
||||
name: "Empty API Key can still create",
|
||||
apiKey: "",
|
||||
secretKey: "test_secret_key",
|
||||
wantNil: false,
|
||||
},
|
||||
{
|
||||
name: "Empty Secret Key can still create",
|
||||
apiKey: "test_api_key",
|
||||
secretKey: "",
|
||||
wantNil: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
bt := NewBybitTrader(tt.apiKey, tt.secretKey)
|
||||
|
||||
if tt.wantNil {
|
||||
assert.Nil(t, bt)
|
||||
} else {
|
||||
assert.NotNil(t, bt)
|
||||
assert.NotNil(t, bt.client)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBybitTrader_SymbolFormat Test symbol format
|
||||
func TestBybitTrader_SymbolFormat(t *testing.T) {
|
||||
// Bybit uses uppercase symbol format (e.g. BTCUSDT)
|
||||
tests := []struct {
|
||||
name string
|
||||
symbol string
|
||||
isValid bool
|
||||
}{
|
||||
{
|
||||
name: "Standard USDT contract",
|
||||
symbol: "BTCUSDT",
|
||||
isValid: true,
|
||||
},
|
||||
{
|
||||
name: "ETH contract",
|
||||
symbol: "ETHUSDT",
|
||||
isValid: true,
|
||||
},
|
||||
{
|
||||
name: "SOL contract",
|
||||
symbol: "SOLUSDT",
|
||||
isValid: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Verify symbol format is correct (all uppercase, ends with USDT)
|
||||
assert.True(t, tt.symbol == strings.ToUpper(tt.symbol))
|
||||
assert.True(t, strings.HasSuffix(tt.symbol, "USDT"))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBybitTrader_FormatQuantity Test quantity formatting
|
||||
func TestBybitTrader_FormatQuantity(t *testing.T) {
|
||||
bt := NewBybitTrader("test", "test")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
symbol string
|
||||
quantity float64
|
||||
expected string
|
||||
hasError bool
|
||||
}{
|
||||
{
|
||||
name: "BTC quantity formatting",
|
||||
symbol: "BTCUSDT",
|
||||
quantity: 0.12345,
|
||||
expected: "0.123", // Bybit defaults to 3 decimal places
|
||||
hasError: false,
|
||||
},
|
||||
{
|
||||
name: "ETH quantity formatting",
|
||||
symbol: "ETHUSDT",
|
||||
quantity: 1.2345,
|
||||
expected: "1.234",
|
||||
hasError: false,
|
||||
},
|
||||
{
|
||||
name: "Integer quantity",
|
||||
symbol: "SOLUSDT",
|
||||
quantity: 10.0,
|
||||
expected: "10.000",
|
||||
hasError: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := bt.FormatQuantity(tt.symbol, tt.quantity)
|
||||
if tt.hasError {
|
||||
assert.Error(t, err)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBybitTrader_ParseResponse Test response parsing
|
||||
func TestBybitTrader_ParseResponse(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
retCode int
|
||||
retMsg string
|
||||
expectErr bool
|
||||
errContain string
|
||||
}{
|
||||
{
|
||||
name: "Success response",
|
||||
retCode: 0,
|
||||
retMsg: "OK",
|
||||
expectErr: false,
|
||||
},
|
||||
{
|
||||
name: "API error",
|
||||
retCode: 10001,
|
||||
retMsg: "Invalid symbol",
|
||||
expectErr: true,
|
||||
errContain: "Invalid symbol",
|
||||
},
|
||||
{
|
||||
name: "Permission error",
|
||||
retCode: 10003,
|
||||
retMsg: "Invalid API key",
|
||||
expectErr: true,
|
||||
errContain: "Invalid API key",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := checkBybitResponse(tt.retCode, tt.retMsg)
|
||||
if tt.expectErr {
|
||||
assert.Error(t, err)
|
||||
if tt.errContain != "" {
|
||||
assert.Contains(t, err.Error(), tt.errContain)
|
||||
}
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// checkBybitResponse Check if Bybit API response has errors
|
||||
func checkBybitResponse(retCode int, retMsg string) error {
|
||||
if retCode != 0 {
|
||||
return &BybitAPIError{
|
||||
Code: retCode,
|
||||
Message: retMsg,
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BybitAPIError Bybit API error type
|
||||
type BybitAPIError struct {
|
||||
Code int
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e *BybitAPIError) Error() string {
|
||||
return e.Message
|
||||
}
|
||||
|
||||
// TestBybitTrader_PositionSideConversion Test position side conversion
|
||||
func TestBybitTrader_PositionSideConversion(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
side string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "Buy to Long",
|
||||
side: "Buy",
|
||||
expected: "long",
|
||||
},
|
||||
{
|
||||
name: "Sell to Short",
|
||||
side: "Sell",
|
||||
expected: "short",
|
||||
},
|
||||
{
|
||||
name: "Other values remain unchanged",
|
||||
side: "Unknown",
|
||||
expected: "unknown",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := convertBybitSide(tt.side)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// convertBybitSide Convert Bybit position side
|
||||
func convertBybitSide(side string) string {
|
||||
switch side {
|
||||
case "Buy":
|
||||
return "long"
|
||||
case "Sell":
|
||||
return "short"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// TestBybitTrader_CategoryLinear Test using only linear category
|
||||
func TestBybitTrader_CategoryLinear(t *testing.T) {
|
||||
// Bybit trader should only use linear category (USDT perpetual contracts)
|
||||
bt := NewBybitTrader("test", "test")
|
||||
assert.NotNil(t, bt)
|
||||
|
||||
// Verify default configuration
|
||||
assert.NotNil(t, bt.client)
|
||||
}
|
||||
|
||||
// TestBybitTrader_CacheDuration Test cache duration
|
||||
func TestBybitTrader_CacheDuration(t *testing.T) {
|
||||
bt := NewBybitTrader("test", "test")
|
||||
|
||||
// Verify default cache time is 15 seconds
|
||||
assert.Equal(t, 15*time.Second, bt.cacheDuration)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Part 4: Mock server integration tests
|
||||
// ============================================================
|
||||
|
||||
// TestBybitTrader_MockServerGetBalance Test getting balance through Mock server
|
||||
func TestBybitTrader_MockServerGetBalance(t *testing.T) {
|
||||
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/v5/account/wallet-balance" {
|
||||
respBody := map[string]interface{}{
|
||||
"retCode": 0,
|
||||
"retMsg": "OK",
|
||||
"result": map[string]interface{}{
|
||||
"list": []map[string]interface{}{
|
||||
{
|
||||
"accountType": "UNIFIED",
|
||||
"totalEquity": "10100.50",
|
||||
"coin": []map[string]interface{}{
|
||||
{
|
||||
"coin": "USDT",
|
||||
"walletBalance": "10000.00",
|
||||
"unrealisedPnl": "100.50",
|
||||
"availableToWithdraw": "8000.00",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(respBody)
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
defer mockServer.Close()
|
||||
|
||||
// Due to Bybit SDK encapsulation, cannot directly inject mock URL
|
||||
// This test verifies mock server response format is correct
|
||||
assert.NotNil(t, mockServer)
|
||||
}
|
||||
|
||||
// TestBybitTrader_MockServerGetPositions Test getting positions through Mock server
|
||||
func TestBybitTrader_MockServerGetPositions(t *testing.T) {
|
||||
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/v5/position/list" {
|
||||
respBody := map[string]interface{}{
|
||||
"retCode": 0,
|
||||
"retMsg": "OK",
|
||||
"result": map[string]interface{}{
|
||||
"list": []map[string]interface{}{
|
||||
{
|
||||
"symbol": "BTCUSDT",
|
||||
"side": "Buy",
|
||||
"size": "0.5",
|
||||
"avgPrice": "50000.00",
|
||||
"markPrice": "50500.00",
|
||||
"unrealisedPnl": "250.00",
|
||||
"liqPrice": "45000.00",
|
||||
"leverage": "10",
|
||||
"positionIdx": 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(respBody)
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
defer mockServer.Close()
|
||||
|
||||
assert.NotNil(t, mockServer)
|
||||
}
|
||||
|
||||
// TestBybitTrader_MockServerPlaceOrder Test placing order through Mock server
|
||||
func TestBybitTrader_MockServerPlaceOrder(t *testing.T) {
|
||||
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/v5/order/create" && r.Method == "POST" {
|
||||
respBody := map[string]interface{}{
|
||||
"retCode": 0,
|
||||
"retMsg": "OK",
|
||||
"result": map[string]interface{}{
|
||||
"orderId": "1234567890",
|
||||
"orderLinkId": "test-order-id",
|
||||
},
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(respBody)
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
defer mockServer.Close()
|
||||
|
||||
assert.NotNil(t, mockServer)
|
||||
}
|
||||
|
||||
// TestBybitTrader_MockServerSetLeverage Test setting leverage through Mock server
|
||||
func TestBybitTrader_MockServerSetLeverage(t *testing.T) {
|
||||
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/v5/position/set-leverage" && r.Method == "POST" {
|
||||
respBody := map[string]interface{}{
|
||||
"retCode": 0,
|
||||
"retMsg": "OK",
|
||||
"result": map[string]interface{}{},
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(respBody)
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
defer mockServer.Close()
|
||||
|
||||
assert.NotNil(t, mockServer)
|
||||
}
|
||||
Reference in New Issue
Block a user