mirror of
https://github.com/laoxong/nofx.git
synced 2026-06-04 09:58:22 +08:00
093d2a329d
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
472 lines
12 KiB
Go
472 lines
12 KiB
Go
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)
|
|
}
|