mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-12 15:26:55 +08:00
test(trader): add comprehensive unit tests and CI coverage reporting (#823)
* chore(config): add Python and uv support to project - Add comprehensive Python .gitignore rules (pycache, venv, pytest, etc.) - Add uv package manager specific ignores (.uv/, uv.lock) - Initialize pyproject.toml for Python tooling Co-authored-by: tinkle-community <tinklefund@gmail.com> * chore(deps): add testing dependencies - Add github.com/stretchr/testify v1.11.1 for test assertions - Add github.com/agiledragon/gomonkey/v2 v2.13.0 for mocking - Promote github.com/rs/zerolog to direct dependency Co-authored-by: tinkle-community <tinklefund@gmail.com> * ci(workflow): add PR test coverage reporting Add GitHub Actions workflow to run unit tests and report coverage on PRs: - Run Go tests with race detection and coverage profiling - Calculate coverage statistics and generate detailed reports - Post coverage results as PR comments with visual indicators - Fix Go version to 1.23 (was incorrectly set to 1.25.0) Coverage guidelines: - Green (>=80%): excellent - Yellow (>=60%): good - Orange (>=40%): fair - Red (<40%): needs improvement This workflow is advisory only and does not block PR merging. Co-authored-by: tinkle-community <tinklefund@gmail.com> * test(trader): add comprehensive unit tests for trader modules Add unit test suites for multiple trader implementations: - aster_trader_test.go: AsterTrader functionality tests - auto_trader_test.go: AutoTrader lifecycle and operations tests - binance_futures_test.go: Binance futures trader tests - hyperliquid_trader_test.go: Hyperliquid trader tests - trader_test_suite.go: Common test suite utilities and helpers Also fix minor formatting issue in auto_trader.go (trailing whitespace) Co-authored-by: tinkle-community <tinklefund@gmail.com> * test(trader): preserve existing calculatePnLPercentage unit tests Merge existing calculatePnLPercentage tests with incoming comprehensive test suite: - Preserve TestCalculatePnLPercentage with 9 test cases covering edge cases - Preserve TestCalculatePnLPercentage_RealWorldScenarios with 3 trading scenarios - Add math package import for floating-point precision comparison - All tests validate PnL percentage calculation with different leverage scenarios Co-authored-by: tinkle-community <tinklefund@gmail.com> --------- Co-authored-by: tinkle-community <tinklefund@gmail.com>
This commit is contained in:
299
trader/aster_trader_test.go
Normal file
299
trader/aster_trader_test.go
Normal file
@@ -0,0 +1,299 @@
|
||||
package trader
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/ethereum/go-ethereum/crypto"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// ============================================================
|
||||
// 一、AsterTraderTestSuite - 继承 base test suite
|
||||
// ============================================================
|
||||
|
||||
// AsterTraderTestSuite Aster交易器测试套件
|
||||
// 继承 TraderTestSuite 并添加 Aster 特定的 mock 逻辑
|
||||
type AsterTraderTestSuite struct {
|
||||
*TraderTestSuite // 嵌入基础测试套件
|
||||
mockServer *httptest.Server
|
||||
}
|
||||
|
||||
// NewAsterTraderTestSuite 创建 Aster 测试套件
|
||||
func NewAsterTraderTestSuite(t *testing.T) *AsterTraderTestSuite {
|
||||
// 创建 mock HTTP 服务器
|
||||
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// 根据不同的 URL 路径返回不同的 mock 响应
|
||||
path := r.URL.Path
|
||||
|
||||
var respBody interface{}
|
||||
|
||||
switch {
|
||||
// Mock GetBalance - /fapi/v3/balance (返回数组)
|
||||
case path == "/fapi/v3/balance":
|
||||
respBody = []map[string]interface{}{
|
||||
{
|
||||
"asset": "USDT",
|
||||
"walletBalance": "10000.00",
|
||||
"unrealizedProfit": "100.50",
|
||||
"marginBalance": "10100.50",
|
||||
"maintMargin": "200.00",
|
||||
"initialMargin": "2000.00",
|
||||
"maxWithdrawAmount": "8000.00",
|
||||
"crossWalletBalance": "10000.00",
|
||||
"crossUnPnl": "100.50",
|
||||
"availableBalance": "8000.00",
|
||||
},
|
||||
}
|
||||
|
||||
// Mock GetPositions - /fapi/v3/positionRisk
|
||||
case path == "/fapi/v3/positionRisk":
|
||||
respBody = []map[string]interface{}{
|
||||
{
|
||||
"symbol": "BTCUSDT",
|
||||
"positionAmt": "0.5",
|
||||
"entryPrice": "50000.00",
|
||||
"markPrice": "50500.00",
|
||||
"unRealizedProfit": "250.00",
|
||||
"liquidationPrice": "45000.00",
|
||||
"leverage": "10",
|
||||
"positionSide": "LONG",
|
||||
},
|
||||
}
|
||||
|
||||
// Mock GetMarketPrice - /fapi/v3/ticker/price (返回单个对象)
|
||||
case path == "/fapi/v3/ticker/price":
|
||||
// 从查询参数获取symbol
|
||||
symbol := r.URL.Query().Get("symbol")
|
||||
if symbol == "" {
|
||||
symbol = "BTCUSDT"
|
||||
}
|
||||
// 根据symbol返回不同价格
|
||||
price := "50000.00"
|
||||
if symbol == "ETHUSDT" {
|
||||
price = "3000.00"
|
||||
} else if symbol == "INVALIDUSDT" {
|
||||
// 返回错误响应
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"code": -1121,
|
||||
"msg": "Invalid symbol",
|
||||
})
|
||||
return
|
||||
}
|
||||
respBody = map[string]interface{}{
|
||||
"symbol": symbol,
|
||||
"price": price,
|
||||
}
|
||||
|
||||
// Mock ExchangeInfo - /fapi/v3/exchangeInfo
|
||||
case path == "/fapi/v3/exchangeInfo":
|
||||
respBody = map[string]interface{}{
|
||||
"symbols": []map[string]interface{}{
|
||||
{
|
||||
"symbol": "BTCUSDT",
|
||||
"pricePrecision": 1,
|
||||
"quantityPrecision": 3,
|
||||
"baseAssetPrecision": 8,
|
||||
"quotePrecision": 8,
|
||||
"filters": []map[string]interface{}{
|
||||
{
|
||||
"filterType": "PRICE_FILTER",
|
||||
"tickSize": "0.1",
|
||||
},
|
||||
{
|
||||
"filterType": "LOT_SIZE",
|
||||
"stepSize": "0.001",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"symbol": "ETHUSDT",
|
||||
"pricePrecision": 2,
|
||||
"quantityPrecision": 3,
|
||||
"baseAssetPrecision": 8,
|
||||
"quotePrecision": 8,
|
||||
"filters": []map[string]interface{}{
|
||||
{
|
||||
"filterType": "PRICE_FILTER",
|
||||
"tickSize": "0.01",
|
||||
},
|
||||
{
|
||||
"filterType": "LOT_SIZE",
|
||||
"stepSize": "0.001",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Mock CreateOrder - /fapi/v1/order and /fapi/v3/order
|
||||
case (path == "/fapi/v1/order" || path == "/fapi/v3/order") && r.Method == "POST":
|
||||
// 从请求中解析参数以确定symbol
|
||||
bodyBytes, _ := io.ReadAll(r.Body)
|
||||
var orderParams map[string]interface{}
|
||||
json.Unmarshal(bodyBytes, &orderParams)
|
||||
|
||||
symbol := "BTCUSDT"
|
||||
if s, ok := orderParams["symbol"].(string); ok {
|
||||
symbol = s
|
||||
}
|
||||
|
||||
respBody = map[string]interface{}{
|
||||
"orderId": 123456,
|
||||
"symbol": symbol,
|
||||
"status": "FILLED",
|
||||
"side": orderParams["side"],
|
||||
"type": orderParams["type"],
|
||||
}
|
||||
|
||||
// Mock CancelOrder - /fapi/v1/order (DELETE)
|
||||
case path == "/fapi/v1/order" && r.Method == "DELETE":
|
||||
respBody = map[string]interface{}{
|
||||
"orderId": 123456,
|
||||
"symbol": "BTCUSDT",
|
||||
"status": "CANCELED",
|
||||
}
|
||||
|
||||
// Mock ListOpenOrders - /fapi/v1/openOrders and /fapi/v3/openOrders
|
||||
case path == "/fapi/v1/openOrders" || path == "/fapi/v3/openOrders":
|
||||
respBody = []map[string]interface{}{}
|
||||
|
||||
// Mock SetLeverage - /fapi/v1/leverage
|
||||
case path == "/fapi/v1/leverage":
|
||||
respBody = map[string]interface{}{
|
||||
"leverage": 10,
|
||||
"symbol": "BTCUSDT",
|
||||
}
|
||||
|
||||
// Mock SetMarginMode - /fapi/v1/marginType
|
||||
case path == "/fapi/v1/marginType":
|
||||
respBody = map[string]interface{}{
|
||||
"code": 200,
|
||||
"msg": "success",
|
||||
}
|
||||
|
||||
// Default: empty response
|
||||
default:
|
||||
respBody = map[string]interface{}{}
|
||||
}
|
||||
|
||||
// 序列化响应
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(respBody)
|
||||
}))
|
||||
|
||||
// 生成一个测试用的私钥
|
||||
privateKey, _ := crypto.GenerateKey()
|
||||
|
||||
// 创建 mock trader,使用 mock server 的 URL
|
||||
trader := &AsterTrader{
|
||||
ctx: context.Background(),
|
||||
user: "0x1234567890123456789012345678901234567890",
|
||||
signer: "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd",
|
||||
privateKey: privateKey,
|
||||
client: mockServer.Client(),
|
||||
baseURL: mockServer.URL, // 使用 mock server 的 URL
|
||||
symbolPrecision: make(map[string]SymbolPrecision),
|
||||
}
|
||||
|
||||
// 创建基础套件
|
||||
baseSuite := NewTraderTestSuite(t, trader)
|
||||
|
||||
return &AsterTraderTestSuite{
|
||||
TraderTestSuite: baseSuite,
|
||||
mockServer: mockServer,
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup 清理资源
|
||||
func (s *AsterTraderTestSuite) Cleanup() {
|
||||
if s.mockServer != nil {
|
||||
s.mockServer.Close()
|
||||
}
|
||||
s.TraderTestSuite.Cleanup()
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 二、使用 AsterTraderTestSuite 运行通用测试
|
||||
// ============================================================
|
||||
|
||||
// TestAsterTrader_InterfaceCompliance 测试接口兼容性
|
||||
func TestAsterTrader_InterfaceCompliance(t *testing.T) {
|
||||
var _ Trader = (*AsterTrader)(nil)
|
||||
}
|
||||
|
||||
// TestAsterTrader_CommonInterface 使用测试套件运行所有通用接口测试
|
||||
func TestAsterTrader_CommonInterface(t *testing.T) {
|
||||
// 创建测试套件
|
||||
suite := NewAsterTraderTestSuite(t)
|
||||
defer suite.Cleanup()
|
||||
|
||||
// 运行所有通用接口测试
|
||||
suite.RunAllTests()
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 三、Aster 特定功能的单元测试
|
||||
// ============================================================
|
||||
|
||||
// TestNewAsterTrader 测试创建 Aster 交易器
|
||||
func TestNewAsterTrader(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
user string
|
||||
signer string
|
||||
privateKeyHex string
|
||||
wantError bool
|
||||
errorContains string
|
||||
}{
|
||||
{
|
||||
name: "成功创建",
|
||||
user: "0x1234567890123456789012345678901234567890",
|
||||
signer: "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd",
|
||||
privateKeyHex: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||
wantError: false,
|
||||
},
|
||||
{
|
||||
name: "无效私钥格式",
|
||||
user: "0x1234567890123456789012345678901234567890",
|
||||
signer: "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd",
|
||||
privateKeyHex: "invalid_key",
|
||||
wantError: true,
|
||||
errorContains: "解析私钥失败",
|
||||
},
|
||||
{
|
||||
name: "带0x前缀的私钥",
|
||||
user: "0x1234567890123456789012345678901234567890",
|
||||
signer: "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd",
|
||||
privateKeyHex: "0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||
wantError: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
trader, err := NewAsterTrader(tt.user, tt.signer, tt.privateKeyHex)
|
||||
|
||||
if tt.wantError {
|
||||
assert.Error(t, err)
|
||||
if tt.errorContains != "" {
|
||||
assert.Contains(t, err.Error(), tt.errorContains)
|
||||
}
|
||||
assert.Nil(t, trader)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, trader)
|
||||
if trader != nil {
|
||||
assert.Equal(t, tt.user, trader.user)
|
||||
assert.Equal(t, tt.signer, trader.signer)
|
||||
assert.NotNil(t, trader.privateKey)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -241,7 +241,7 @@ func (at *AutoTrader) Run() error {
|
||||
at.isRunning = true
|
||||
at.stopMonitorCh = make(chan struct{})
|
||||
at.startTime = time.Now()
|
||||
|
||||
|
||||
log.Println("🚀 AI驱动自动交易系统启动")
|
||||
log.Printf("💰 初始余额: %.2f USDT", at.initialBalance)
|
||||
log.Printf("⚙️ 扫描间隔: %v", at.config.ScanInterval)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
420
trader/binance_futures_test.go
Normal file
420
trader/binance_futures_test.go
Normal file
@@ -0,0 +1,420 @@
|
||||
package trader
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/adshao/go-binance/v2/futures"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// ============================================================
|
||||
// 一、BinanceFuturesTestSuite - 继承 base test suite
|
||||
// ============================================================
|
||||
|
||||
// BinanceFuturesTestSuite 币安合约交易器测试套件
|
||||
// 继承 TraderTestSuite 并添加 Binance Futures 特定的 mock 逻辑
|
||||
type BinanceFuturesTestSuite struct {
|
||||
*TraderTestSuite // 嵌入基础测试套件
|
||||
mockServer *httptest.Server
|
||||
}
|
||||
|
||||
// NewBinanceFuturesTestSuite 创建币安合约测试套件
|
||||
func NewBinanceFuturesTestSuite(t *testing.T) *BinanceFuturesTestSuite {
|
||||
// 创建 mock HTTP 服务器
|
||||
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// 根据不同的 URL 路径返回不同的 mock 响应
|
||||
path := r.URL.Path
|
||||
|
||||
var respBody interface{}
|
||||
|
||||
switch {
|
||||
// Mock GetBalance - /fapi/v2/balance
|
||||
case path == "/fapi/v2/balance":
|
||||
respBody = []map[string]interface{}{
|
||||
{
|
||||
"accountAlias": "test",
|
||||
"asset": "USDT",
|
||||
"balance": "10000.00",
|
||||
"crossWalletBalance": "10000.00",
|
||||
"crossUnPnl": "100.50",
|
||||
"availableBalance": "8000.00",
|
||||
"maxWithdrawAmount": "8000.00",
|
||||
},
|
||||
}
|
||||
|
||||
// Mock GetAccount - /fapi/v2/account
|
||||
case path == "/fapi/v2/account":
|
||||
respBody = map[string]interface{}{
|
||||
"totalWalletBalance": "10000.00",
|
||||
"availableBalance": "8000.00",
|
||||
"totalUnrealizedProfit": "100.50",
|
||||
"assets": []map[string]interface{}{
|
||||
{
|
||||
"asset": "USDT",
|
||||
"walletBalance": "10000.00",
|
||||
"unrealizedProfit": "100.50",
|
||||
"marginBalance": "10100.50",
|
||||
"maintMargin": "200.00",
|
||||
"initialMargin": "2000.00",
|
||||
"positionInitialMargin": "2000.00",
|
||||
"openOrderInitialMargin": "0.00",
|
||||
"crossWalletBalance": "10000.00",
|
||||
"crossUnPnl": "100.50",
|
||||
"availableBalance": "8000.00",
|
||||
"maxWithdrawAmount": "8000.00",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Mock GetPositions - /fapi/v2/positionRisk
|
||||
case path == "/fapi/v2/positionRisk":
|
||||
respBody = []map[string]interface{}{
|
||||
{
|
||||
"symbol": "BTCUSDT",
|
||||
"positionAmt": "0.5",
|
||||
"entryPrice": "50000.00",
|
||||
"markPrice": "50500.00",
|
||||
"unRealizedProfit": "250.00",
|
||||
"liquidationPrice": "45000.00",
|
||||
"leverage": "10",
|
||||
"positionSide": "LONG",
|
||||
},
|
||||
}
|
||||
|
||||
// Mock GetMarketPrice - /fapi/v1/ticker/price and /fapi/v2/ticker/price
|
||||
case path == "/fapi/v1/ticker/price" || path == "/fapi/v2/ticker/price":
|
||||
symbol := r.URL.Query().Get("symbol")
|
||||
if symbol == "" {
|
||||
// 返回所有价格
|
||||
respBody = []map[string]interface{}{
|
||||
{"Symbol": "BTCUSDT", "Price": "50000.00", "Time": 1234567890},
|
||||
{"Symbol": "ETHUSDT", "Price": "3000.00", "Time": 1234567890},
|
||||
}
|
||||
} else if symbol == "INVALIDUSDT" {
|
||||
// 返回错误
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"code": -1121,
|
||||
"msg": "Invalid symbol.",
|
||||
})
|
||||
return
|
||||
} else {
|
||||
// 返回单个价格(注意:即使有 symbol 参数,也要返回数组)
|
||||
price := "50000.00"
|
||||
if symbol == "ETHUSDT" {
|
||||
price = "3000.00"
|
||||
}
|
||||
respBody = []map[string]interface{}{
|
||||
{
|
||||
"Symbol": symbol,
|
||||
"Price": price,
|
||||
"Time": 1234567890,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Mock ExchangeInfo - /fapi/v1/exchangeInfo
|
||||
case path == "/fapi/v1/exchangeInfo":
|
||||
respBody = map[string]interface{}{
|
||||
"symbols": []map[string]interface{}{
|
||||
{
|
||||
"symbol": "BTCUSDT",
|
||||
"status": "TRADING",
|
||||
"baseAsset": "BTC",
|
||||
"quoteAsset": "USDT",
|
||||
"pricePrecision": 2,
|
||||
"quantityPrecision": 3,
|
||||
"baseAssetPrecision": 8,
|
||||
"quotePrecision": 8,
|
||||
"filters": []map[string]interface{}{
|
||||
{
|
||||
"filterType": "PRICE_FILTER",
|
||||
"minPrice": "0.01",
|
||||
"maxPrice": "1000000",
|
||||
"tickSize": "0.01",
|
||||
},
|
||||
{
|
||||
"filterType": "LOT_SIZE",
|
||||
"minQty": "0.001",
|
||||
"maxQty": "10000",
|
||||
"stepSize": "0.001",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"symbol": "ETHUSDT",
|
||||
"status": "TRADING",
|
||||
"baseAsset": "ETH",
|
||||
"quoteAsset": "USDT",
|
||||
"pricePrecision": 2,
|
||||
"quantityPrecision": 3,
|
||||
"baseAssetPrecision": 8,
|
||||
"quotePrecision": 8,
|
||||
"filters": []map[string]interface{}{
|
||||
{
|
||||
"filterType": "PRICE_FILTER",
|
||||
"minPrice": "0.01",
|
||||
"maxPrice": "100000",
|
||||
"tickSize": "0.01",
|
||||
},
|
||||
{
|
||||
"filterType": "LOT_SIZE",
|
||||
"minQty": "0.001",
|
||||
"maxQty": "10000",
|
||||
"stepSize": "0.001",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Mock CreateOrder - /fapi/v1/order (POST)
|
||||
case path == "/fapi/v1/order" && r.Method == "POST":
|
||||
symbol := r.FormValue("symbol")
|
||||
if symbol == "" {
|
||||
symbol = "BTCUSDT"
|
||||
}
|
||||
respBody = map[string]interface{}{
|
||||
"orderId": 123456,
|
||||
"symbol": symbol,
|
||||
"status": "FILLED",
|
||||
"clientOrderId": r.FormValue("newClientOrderId"),
|
||||
"price": r.FormValue("price"),
|
||||
"avgPrice": r.FormValue("price"),
|
||||
"origQty": r.FormValue("quantity"),
|
||||
"executedQty": r.FormValue("quantity"),
|
||||
"cumQty": r.FormValue("quantity"),
|
||||
"cumQuote": "1000.00",
|
||||
"timeInForce": r.FormValue("timeInForce"),
|
||||
"type": r.FormValue("type"),
|
||||
"reduceOnly": r.FormValue("reduceOnly") == "true",
|
||||
"side": r.FormValue("side"),
|
||||
"positionSide": r.FormValue("positionSide"),
|
||||
"stopPrice": r.FormValue("stopPrice"),
|
||||
"workingType": r.FormValue("workingType"),
|
||||
}
|
||||
|
||||
// Mock CancelOrder - /fapi/v1/order (DELETE)
|
||||
case path == "/fapi/v1/order" && r.Method == "DELETE":
|
||||
respBody = map[string]interface{}{
|
||||
"orderId": 123456,
|
||||
"symbol": r.URL.Query().Get("symbol"),
|
||||
"status": "CANCELED",
|
||||
}
|
||||
|
||||
// Mock ListOpenOrders - /fapi/v1/openOrders
|
||||
case path == "/fapi/v1/openOrders":
|
||||
respBody = []map[string]interface{}{}
|
||||
|
||||
// Mock CancelAllOrders - /fapi/v1/allOpenOrders (DELETE)
|
||||
case path == "/fapi/v1/allOpenOrders" && r.Method == "DELETE":
|
||||
respBody = map[string]interface{}{
|
||||
"code": 200,
|
||||
"msg": "The operation of cancel all open order is done.",
|
||||
}
|
||||
|
||||
// Mock SetLeverage - /fapi/v1/leverage
|
||||
case path == "/fapi/v1/leverage":
|
||||
// 将字符串转换为整数
|
||||
leverageStr := r.FormValue("leverage")
|
||||
leverage := 10 // 默认值
|
||||
if leverageStr != "" {
|
||||
// 注意:这里我们直接返回整数,而不是字符串
|
||||
fmt.Sscanf(leverageStr, "%d", &leverage)
|
||||
}
|
||||
respBody = map[string]interface{}{
|
||||
"leverage": leverage,
|
||||
"maxNotionalValue": "1000000",
|
||||
"symbol": r.FormValue("symbol"),
|
||||
}
|
||||
|
||||
// Mock SetMarginType - /fapi/v1/marginType
|
||||
case path == "/fapi/v1/marginType":
|
||||
respBody = map[string]interface{}{
|
||||
"code": 200,
|
||||
"msg": "success",
|
||||
}
|
||||
|
||||
// Mock ChangePositionMode - /fapi/v1/positionSide/dual
|
||||
case path == "/fapi/v1/positionSide/dual":
|
||||
respBody = map[string]interface{}{
|
||||
"code": 200,
|
||||
"msg": "success",
|
||||
}
|
||||
|
||||
// Mock ServerTime - /fapi/v1/time
|
||||
case path == "/fapi/v1/time":
|
||||
respBody = map[string]interface{}{
|
||||
"serverTime": 1234567890000,
|
||||
}
|
||||
|
||||
// Default: empty response
|
||||
default:
|
||||
respBody = map[string]interface{}{}
|
||||
}
|
||||
|
||||
// 序列化响应
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(respBody)
|
||||
}))
|
||||
|
||||
// 创建 futures.Client 并设置为使用 mock 服务器
|
||||
client := futures.NewClient("test_api_key", "test_secret_key")
|
||||
client.BaseURL = mockServer.URL
|
||||
client.HTTPClient = mockServer.Client()
|
||||
|
||||
// 创建 FuturesTrader
|
||||
trader := &FuturesTrader{
|
||||
client: client,
|
||||
cacheDuration: 0, // 禁用缓存以便测试
|
||||
}
|
||||
|
||||
// 创建基础套件
|
||||
baseSuite := NewTraderTestSuite(t, trader)
|
||||
|
||||
return &BinanceFuturesTestSuite{
|
||||
TraderTestSuite: baseSuite,
|
||||
mockServer: mockServer,
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup 清理资源
|
||||
func (s *BinanceFuturesTestSuite) Cleanup() {
|
||||
if s.mockServer != nil {
|
||||
s.mockServer.Close()
|
||||
}
|
||||
s.TraderTestSuite.Cleanup()
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 二、使用 BinanceFuturesTestSuite 运行通用测试
|
||||
// ============================================================
|
||||
|
||||
// TestFuturesTrader_InterfaceCompliance 测试接口兼容性
|
||||
func TestFuturesTrader_InterfaceCompliance(t *testing.T) {
|
||||
var _ Trader = (*FuturesTrader)(nil)
|
||||
}
|
||||
|
||||
// TestFuturesTrader_CommonInterface 使用测试套件运行所有通用接口测试
|
||||
func TestFuturesTrader_CommonInterface(t *testing.T) {
|
||||
// 创建测试套件
|
||||
suite := NewBinanceFuturesTestSuite(t)
|
||||
defer suite.Cleanup()
|
||||
|
||||
// 运行所有通用接口测试
|
||||
suite.RunAllTests()
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 三、币安合约特定功能的单元测试
|
||||
// ============================================================
|
||||
|
||||
// TestNewFuturesTrader 测试创建币安合约交易器
|
||||
func TestNewFuturesTrader(t *testing.T) {
|
||||
// 创建 mock HTTP 服务器
|
||||
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
path := r.URL.Path
|
||||
|
||||
var respBody interface{}
|
||||
|
||||
switch path {
|
||||
case "/fapi/v1/time":
|
||||
respBody = map[string]interface{}{
|
||||
"serverTime": 1234567890000,
|
||||
}
|
||||
case "/fapi/v1/positionSide/dual":
|
||||
respBody = map[string]interface{}{
|
||||
"code": 200,
|
||||
"msg": "success",
|
||||
}
|
||||
default:
|
||||
respBody = map[string]interface{}{}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(respBody)
|
||||
}))
|
||||
defer mockServer.Close()
|
||||
|
||||
// 测试成功创建
|
||||
trader := NewFuturesTrader("test_api_key", "test_secret_key", "test_user")
|
||||
|
||||
// 修改 client 使用 mock server
|
||||
trader.client.BaseURL = mockServer.URL
|
||||
trader.client.HTTPClient = mockServer.Client()
|
||||
|
||||
assert.NotNil(t, trader)
|
||||
assert.NotNil(t, trader.client)
|
||||
assert.Equal(t, 15*time.Second, trader.cacheDuration)
|
||||
}
|
||||
|
||||
// TestCalculatePositionSize 测试仓位计算
|
||||
func TestCalculatePositionSize(t *testing.T) {
|
||||
trader := &FuturesTrader{}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
balance float64
|
||||
riskPercent float64
|
||||
price float64
|
||||
leverage int
|
||||
wantQuantity float64
|
||||
}{
|
||||
{
|
||||
name: "正常计算",
|
||||
balance: 10000,
|
||||
riskPercent: 2,
|
||||
price: 50000,
|
||||
leverage: 10,
|
||||
wantQuantity: 0.04, // (10000 * 0.02 * 10) / 50000 = 0.04
|
||||
},
|
||||
{
|
||||
name: "高杠杆",
|
||||
balance: 10000,
|
||||
riskPercent: 1,
|
||||
price: 3000,
|
||||
leverage: 20,
|
||||
wantQuantity: 0.6667, // (10000 * 0.01 * 20) / 3000 = 0.6667
|
||||
},
|
||||
{
|
||||
name: "低风险",
|
||||
balance: 5000,
|
||||
riskPercent: 0.5,
|
||||
price: 50000,
|
||||
leverage: 5,
|
||||
wantQuantity: 0.0025, // (5000 * 0.005 * 5) / 50000 = 0.0025
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
quantity := trader.CalculatePositionSize(tt.balance, tt.riskPercent, tt.price, tt.leverage)
|
||||
assert.InDelta(t, tt.wantQuantity, quantity, 0.0001, "计算的仓位数量不正确")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetBrOrderID 测试订单ID生成
|
||||
func TestGetBrOrderID(t *testing.T) {
|
||||
// 测试3次,确保每次生成的ID都不同
|
||||
ids := make(map[string]bool)
|
||||
for i := 0; i < 3; i++ {
|
||||
id := getBrOrderID()
|
||||
|
||||
// 检查格式
|
||||
assert.True(t, strings.HasPrefix(id, "x-KzrpZaP9"), "订单ID应以x-KzrpZaP9开头")
|
||||
|
||||
// 检查长度(应该 <= 32)
|
||||
assert.LessOrEqual(t, len(id), 32, "订单ID长度不应超过32字符")
|
||||
|
||||
// 检查唯一性
|
||||
assert.False(t, ids[id], "订单ID应该唯一")
|
||||
ids[id] = true
|
||||
}
|
||||
}
|
||||
646
trader/hyperliquid_trader_test.go
Normal file
646
trader/hyperliquid_trader_test.go
Normal file
@@ -0,0 +1,646 @@
|
||||
package trader
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/ethereum/go-ethereum/crypto"
|
||||
"github.com/sonirico/go-hyperliquid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// ============================================================
|
||||
// 一、HyperliquidTestSuite - 继承 base test suite
|
||||
// ============================================================
|
||||
|
||||
// HyperliquidTestSuite Hyperliquid 交易器测试套件
|
||||
// 继承 TraderTestSuite 并添加 Hyperliquid 特定的 mock 逻辑
|
||||
type HyperliquidTestSuite struct {
|
||||
*TraderTestSuite // 嵌入基础测试套件
|
||||
mockServer *httptest.Server
|
||||
privateKey *ecdsa.PrivateKey
|
||||
}
|
||||
|
||||
// NewHyperliquidTestSuite 创建 Hyperliquid 测试套件
|
||||
func NewHyperliquidTestSuite(t *testing.T) *HyperliquidTestSuite {
|
||||
// 创建测试用私钥
|
||||
privateKey, err := crypto.HexToECDSA("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")
|
||||
if err != nil {
|
||||
t.Fatalf("创建测试私钥失败: %v", err)
|
||||
}
|
||||
|
||||
// 创建 mock HTTP 服务器
|
||||
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// 根据不同的请求路径返回不同的 mock 响应
|
||||
var respBody interface{}
|
||||
|
||||
// Hyperliquid API 使用 POST 请求,请求体是 JSON
|
||||
// 我们需要根据请求体中的 "type" 字段来区分不同的请求
|
||||
var reqBody map[string]interface{}
|
||||
if r.Method == "POST" {
|
||||
json.NewDecoder(r.Body).Decode(&reqBody)
|
||||
}
|
||||
|
||||
// Try to get type from top level first, then from action object
|
||||
reqType, _ := reqBody["type"].(string)
|
||||
if reqType == "" && reqBody["action"] != nil {
|
||||
if action, ok := reqBody["action"].(map[string]interface{}); ok {
|
||||
reqType, _ = action["type"].(string)
|
||||
}
|
||||
}
|
||||
|
||||
switch reqType {
|
||||
// Mock Meta - 获取市场元数据
|
||||
case "meta":
|
||||
respBody = map[string]interface{}{
|
||||
"universe": []map[string]interface{}{
|
||||
{
|
||||
"name": "BTC",
|
||||
"szDecimals": 4,
|
||||
"maxLeverage": 50,
|
||||
"onlyIsolated": false,
|
||||
"isDelisted": false,
|
||||
"marginTableId": 0,
|
||||
},
|
||||
{
|
||||
"name": "ETH",
|
||||
"szDecimals": 3,
|
||||
"maxLeverage": 50,
|
||||
"onlyIsolated": false,
|
||||
"isDelisted": false,
|
||||
"marginTableId": 0,
|
||||
},
|
||||
},
|
||||
"marginTables": []interface{}{},
|
||||
}
|
||||
|
||||
// Mock UserState - 获取用户账户状态(用于 GetBalance 和 GetPositions)
|
||||
case "clearinghouseState":
|
||||
user, _ := reqBody["user"].(string)
|
||||
|
||||
// 检查是否是查询 Agent 钱包余额(用于安全检查)
|
||||
agentAddr := crypto.PubkeyToAddress(privateKey.PublicKey).Hex()
|
||||
if user == agentAddr {
|
||||
// Agent 钱包余额应该很低
|
||||
respBody = map[string]interface{}{
|
||||
"crossMarginSummary": map[string]interface{}{
|
||||
"accountValue": "5.00",
|
||||
"totalMarginUsed": "0.00",
|
||||
},
|
||||
"withdrawable": "5.00",
|
||||
"assetPositions": []interface{}{},
|
||||
}
|
||||
} else {
|
||||
// 主钱包账户状态
|
||||
respBody = map[string]interface{}{
|
||||
"crossMarginSummary": map[string]interface{}{
|
||||
"accountValue": "10000.00",
|
||||
"totalMarginUsed": "2000.00",
|
||||
},
|
||||
"withdrawable": "8000.00",
|
||||
"assetPositions": []map[string]interface{}{
|
||||
{
|
||||
"position": map[string]interface{}{
|
||||
"coin": "BTC",
|
||||
"szi": "0.5",
|
||||
"entryPx": "50000.00",
|
||||
"liquidationPx": "45000.00",
|
||||
"positionValue": "25000.00",
|
||||
"unrealizedPnl": "100.50",
|
||||
"leverage": map[string]interface{}{
|
||||
"type": "cross",
|
||||
"value": 10,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Mock SpotUserState - 获取现货账户状态
|
||||
case "spotClearinghouseState":
|
||||
respBody = map[string]interface{}{
|
||||
"balances": []map[string]interface{}{
|
||||
{
|
||||
"coin": "USDC",
|
||||
"total": "500.00",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Mock SpotMeta - 获取现货市场元数据
|
||||
case "spotMeta":
|
||||
respBody = map[string]interface{}{
|
||||
"universe": []map[string]interface{}{},
|
||||
"tokens": []map[string]interface{}{},
|
||||
}
|
||||
|
||||
// Mock AllMids - 获取所有市场价格
|
||||
case "allMids":
|
||||
respBody = map[string]string{
|
||||
"BTC": "50000.00",
|
||||
"ETH": "3000.00",
|
||||
}
|
||||
|
||||
// Mock OpenOrders - 获取挂单列表
|
||||
case "openOrders":
|
||||
respBody = []interface{}{}
|
||||
|
||||
// Mock Order - 创建订单(开仓、平仓、止损、止盈)
|
||||
case "order":
|
||||
respBody = map[string]interface{}{
|
||||
"status": "ok",
|
||||
"response": map[string]interface{}{
|
||||
"type": "order",
|
||||
"data": map[string]interface{}{
|
||||
"statuses": []map[string]interface{}{
|
||||
{
|
||||
"filled": map[string]interface{}{
|
||||
"totalSz": "0.01",
|
||||
"avgPx": "50000.00",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Mock UpdateLeverage - 设置杠杆
|
||||
case "updateLeverage":
|
||||
respBody = map[string]interface{}{
|
||||
"status": "ok",
|
||||
}
|
||||
|
||||
// Mock Cancel - 取消订单
|
||||
case "cancel":
|
||||
respBody = map[string]interface{}{
|
||||
"status": "ok",
|
||||
}
|
||||
|
||||
default:
|
||||
// 默认返回成功响应
|
||||
respBody = map[string]interface{}{
|
||||
"status": "ok",
|
||||
}
|
||||
}
|
||||
|
||||
// 序列化响应
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(respBody)
|
||||
}))
|
||||
|
||||
// 创建 HyperliquidTrader,使用 mock 服务器 URL
|
||||
walletAddr := "0x9999999999999999999999999999999999999999"
|
||||
ctx := context.Background()
|
||||
|
||||
// 创建 Exchange 客户端,指向 mock 服务器
|
||||
exchange := hyperliquid.NewExchange(
|
||||
ctx,
|
||||
privateKey,
|
||||
mockServer.URL, // 使用 mock 服务器 URL
|
||||
nil,
|
||||
"",
|
||||
walletAddr,
|
||||
nil,
|
||||
)
|
||||
|
||||
// 创建 meta(模拟获取成功)
|
||||
meta := &hyperliquid.Meta{
|
||||
Universe: []hyperliquid.AssetInfo{
|
||||
{Name: "BTC", SzDecimals: 4},
|
||||
{Name: "ETH", SzDecimals: 3},
|
||||
},
|
||||
}
|
||||
|
||||
trader := &HyperliquidTrader{
|
||||
exchange: exchange,
|
||||
ctx: ctx,
|
||||
walletAddr: walletAddr,
|
||||
meta: meta,
|
||||
isCrossMargin: true,
|
||||
}
|
||||
|
||||
// 创建基础套件
|
||||
baseSuite := NewTraderTestSuite(t, trader)
|
||||
|
||||
return &HyperliquidTestSuite{
|
||||
TraderTestSuite: baseSuite,
|
||||
mockServer: mockServer,
|
||||
privateKey: privateKey,
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup 清理资源
|
||||
func (s *HyperliquidTestSuite) Cleanup() {
|
||||
if s.mockServer != nil {
|
||||
s.mockServer.Close()
|
||||
}
|
||||
s.TraderTestSuite.Cleanup()
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 二、使用 HyperliquidTestSuite 运行通用测试
|
||||
// ============================================================
|
||||
|
||||
// TestHyperliquidTrader_InterfaceCompliance 测试接口兼容性
|
||||
func TestHyperliquidTrader_InterfaceCompliance(t *testing.T) {
|
||||
var _ Trader = (*HyperliquidTrader)(nil)
|
||||
}
|
||||
|
||||
// TestHyperliquidTrader_CommonInterface 使用测试套件运行所有通用接口测试
|
||||
func TestHyperliquidTrader_CommonInterface(t *testing.T) {
|
||||
// 创建测试套件
|
||||
suite := NewHyperliquidTestSuite(t)
|
||||
defer suite.Cleanup()
|
||||
|
||||
// 运行所有通用接口测试
|
||||
suite.RunAllTests()
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 三、Hyperliquid 特定功能的单元测试
|
||||
// ============================================================
|
||||
|
||||
// TestNewHyperliquidTrader 测试创建 Hyperliquid 交易器
|
||||
func TestNewHyperliquidTrader(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
privateKeyHex string
|
||||
walletAddr string
|
||||
testnet bool
|
||||
wantError bool
|
||||
errorContains string
|
||||
}{
|
||||
{
|
||||
name: "无效私钥格式",
|
||||
privateKeyHex: "invalid_key",
|
||||
walletAddr: "0x1234567890123456789012345678901234567890",
|
||||
testnet: true,
|
||||
wantError: true,
|
||||
errorContains: "解析私钥失败",
|
||||
},
|
||||
{
|
||||
name: "钱包地址为空",
|
||||
privateKeyHex: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||
walletAddr: "",
|
||||
testnet: true,
|
||||
wantError: true,
|
||||
errorContains: "Configuration error",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
trader, err := NewHyperliquidTrader(tt.privateKeyHex, tt.walletAddr, tt.testnet)
|
||||
|
||||
if tt.wantError {
|
||||
assert.Error(t, err)
|
||||
if tt.errorContains != "" {
|
||||
assert.Contains(t, err.Error(), tt.errorContains)
|
||||
}
|
||||
assert.Nil(t, trader)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, trader)
|
||||
if trader != nil {
|
||||
assert.Equal(t, tt.walletAddr, trader.walletAddr)
|
||||
assert.NotNil(t, trader.exchange)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewHyperliquidTrader_Success 测试成功创建交易器(需要 mock HTTP)
|
||||
func TestNewHyperliquidTrader_Success(t *testing.T) {
|
||||
// 创建测试用私钥
|
||||
privateKey, _ := crypto.HexToECDSA("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")
|
||||
agentAddr := crypto.PubkeyToAddress(privateKey.PublicKey).Hex()
|
||||
|
||||
// 创建 mock HTTP 服务器
|
||||
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var reqBody map[string]interface{}
|
||||
json.NewDecoder(r.Body).Decode(&reqBody)
|
||||
reqType, _ := reqBody["type"].(string)
|
||||
|
||||
var respBody interface{}
|
||||
switch reqType {
|
||||
case "meta":
|
||||
respBody = map[string]interface{}{
|
||||
"universe": []map[string]interface{}{
|
||||
{
|
||||
"name": "BTC",
|
||||
"szDecimals": 4,
|
||||
"maxLeverage": 50,
|
||||
"onlyIsolated": false,
|
||||
"isDelisted": false,
|
||||
"marginTableId": 0,
|
||||
},
|
||||
},
|
||||
"marginTables": []interface{}{},
|
||||
}
|
||||
case "clearinghouseState":
|
||||
user, _ := reqBody["user"].(string)
|
||||
if user == agentAddr {
|
||||
// Agent 钱包余额低
|
||||
respBody = map[string]interface{}{
|
||||
"crossMarginSummary": map[string]interface{}{
|
||||
"accountValue": "5.00",
|
||||
},
|
||||
"assetPositions": []interface{}{},
|
||||
}
|
||||
} else {
|
||||
// 主钱包
|
||||
respBody = map[string]interface{}{
|
||||
"crossMarginSummary": map[string]interface{}{
|
||||
"accountValue": "10000.00",
|
||||
},
|
||||
"assetPositions": []interface{}{},
|
||||
}
|
||||
}
|
||||
default:
|
||||
respBody = map[string]interface{}{"status": "ok"}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(respBody)
|
||||
}))
|
||||
defer mockServer.Close()
|
||||
|
||||
// 注意:这个测试会真正调用 NewHyperliquidTrader,但会失败
|
||||
// 因为 hyperliquid SDK 不允许我们在构造函数中注入自定义 URL
|
||||
// 所以这个测试仅用于验证参数处理逻辑
|
||||
t.Skip("跳过此测试:hyperliquid SDK 在构造时会调用真实 API,无法注入 mock URL")
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 四、工具函数单元测试(Hyperliquid 特有)
|
||||
// ============================================================
|
||||
|
||||
// TestConvertSymbolToHyperliquid 测试 symbol 转换函数
|
||||
func TestConvertSymbolToHyperliquid(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
symbol string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "BTCUSDT转换",
|
||||
symbol: "BTCUSDT",
|
||||
expected: "BTC",
|
||||
},
|
||||
{
|
||||
name: "ETHUSDT转换",
|
||||
symbol: "ETHUSDT",
|
||||
expected: "ETH",
|
||||
},
|
||||
{
|
||||
name: "无USDT后缀",
|
||||
symbol: "BTC",
|
||||
expected: "BTC",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := convertSymbolToHyperliquid(tt.symbol)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAbsFloat 测试绝对值函数
|
||||
func TestAbsFloat(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input float64
|
||||
expected float64
|
||||
}{
|
||||
{
|
||||
name: "正数",
|
||||
input: 10.5,
|
||||
expected: 10.5,
|
||||
},
|
||||
{
|
||||
name: "负数",
|
||||
input: -10.5,
|
||||
expected: 10.5,
|
||||
},
|
||||
{
|
||||
name: "零",
|
||||
input: 0,
|
||||
expected: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := absFloat(tt.input)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestHyperliquidTrader_RoundToSzDecimals 测试数量精度处理
|
||||
func TestHyperliquidTrader_RoundToSzDecimals(t *testing.T) {
|
||||
trader := &HyperliquidTrader{
|
||||
meta: &hyperliquid.Meta{
|
||||
Universe: []hyperliquid.AssetInfo{
|
||||
{Name: "BTC", SzDecimals: 4},
|
||||
{Name: "ETH", SzDecimals: 3},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
coin string
|
||||
quantity float64
|
||||
expected float64
|
||||
}{
|
||||
{
|
||||
name: "BTC_四舍五入到4位",
|
||||
coin: "BTC",
|
||||
quantity: 1.23456789,
|
||||
expected: 1.2346,
|
||||
},
|
||||
{
|
||||
name: "ETH_四舍五入到3位",
|
||||
coin: "ETH",
|
||||
quantity: 10.12345,
|
||||
expected: 10.123,
|
||||
},
|
||||
{
|
||||
name: "未知币种_使用默认精度4位",
|
||||
coin: "UNKNOWN",
|
||||
quantity: 1.23456789,
|
||||
expected: 1.2346,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := trader.roundToSzDecimals(tt.coin, tt.quantity)
|
||||
assert.InDelta(t, tt.expected, result, 0.0001)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestHyperliquidTrader_RoundPriceToSigfigs 测试价格有效数字处理
|
||||
func TestHyperliquidTrader_RoundPriceToSigfigs(t *testing.T) {
|
||||
trader := &HyperliquidTrader{}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
price float64
|
||||
expected float64
|
||||
}{
|
||||
{
|
||||
name: "BTC价格_5位有效数字",
|
||||
price: 50123.456789,
|
||||
expected: 50123.0,
|
||||
},
|
||||
{
|
||||
name: "小数价格_5位有效数字",
|
||||
price: 0.0012345678,
|
||||
expected: 0.0012346,
|
||||
},
|
||||
{
|
||||
name: "零价格",
|
||||
price: 0,
|
||||
expected: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := trader.roundPriceToSigfigs(tt.price)
|
||||
assert.InDelta(t, tt.expected, result, tt.expected*0.001)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestHyperliquidTrader_GetSzDecimals 测试获取精度
|
||||
func TestHyperliquidTrader_GetSzDecimals(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
meta *hyperliquid.Meta
|
||||
coin string
|
||||
expected int
|
||||
}{
|
||||
{
|
||||
name: "meta为nil_返回默认精度",
|
||||
meta: nil,
|
||||
coin: "BTC",
|
||||
expected: 4,
|
||||
},
|
||||
{
|
||||
name: "找到BTC_返回正确精度",
|
||||
meta: &hyperliquid.Meta{
|
||||
Universe: []hyperliquid.AssetInfo{
|
||||
{Name: "BTC", SzDecimals: 5},
|
||||
},
|
||||
},
|
||||
coin: "BTC",
|
||||
expected: 5,
|
||||
},
|
||||
{
|
||||
name: "未找到币种_返回默认精度",
|
||||
meta: &hyperliquid.Meta{
|
||||
Universe: []hyperliquid.AssetInfo{
|
||||
{Name: "ETH", SzDecimals: 3},
|
||||
},
|
||||
},
|
||||
coin: "BTC",
|
||||
expected: 4,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
trader := &HyperliquidTrader{meta: tt.meta}
|
||||
result := trader.getSzDecimals(tt.coin)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestHyperliquidTrader_SetMarginMode 测试设置保证金模式
|
||||
func TestHyperliquidTrader_SetMarginMode(t *testing.T) {
|
||||
trader := &HyperliquidTrader{
|
||||
ctx: context.Background(),
|
||||
isCrossMargin: true,
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
symbol string
|
||||
isCrossMargin bool
|
||||
wantError bool
|
||||
}{
|
||||
{
|
||||
name: "设置为全仓模式",
|
||||
symbol: "BTCUSDT",
|
||||
isCrossMargin: true,
|
||||
wantError: false,
|
||||
},
|
||||
{
|
||||
name: "设置为逐仓模式",
|
||||
symbol: "ETHUSDT",
|
||||
isCrossMargin: false,
|
||||
wantError: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := trader.SetMarginMode(tt.symbol, tt.isCrossMargin)
|
||||
|
||||
if tt.wantError {
|
||||
assert.Error(t, err)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, tt.isCrossMargin, trader.isCrossMargin)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewHyperliquidTrader_PrivateKeyProcessing 测试私钥处理
|
||||
func TestNewHyperliquidTrader_PrivateKeyProcessing(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
privateKeyHex string
|
||||
shouldStripOx bool
|
||||
expectedLength int
|
||||
}{
|
||||
{
|
||||
name: "带0x前缀的私钥",
|
||||
privateKeyHex: "0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||
shouldStripOx: true,
|
||||
expectedLength: 64,
|
||||
},
|
||||
{
|
||||
name: "无前缀的私钥",
|
||||
privateKeyHex: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||
shouldStripOx: false,
|
||||
expectedLength: 64,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// 测试私钥前缀处理逻辑(不实际创建 trader)
|
||||
processed := tt.privateKeyHex
|
||||
if len(processed) > 2 && (processed[:2] == "0x" || processed[:2] == "0X") {
|
||||
processed = processed[2:]
|
||||
}
|
||||
|
||||
assert.Equal(t, tt.expectedLength, len(processed))
|
||||
})
|
||||
}
|
||||
}
|
||||
664
trader/trader_test_suite.go
Normal file
664
trader/trader_test_suite.go
Normal file
@@ -0,0 +1,664 @@
|
||||
package trader
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/agiledragon/gomonkey/v2"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// TraderTestSuite 通用的 Trader 接口测试套件(基础套件)
|
||||
// 用于黑盒测试任何实现了 Trader 接口的交易器
|
||||
//
|
||||
// 使用方式:
|
||||
// 1. 创建具体的测试套件结构体,嵌入 TraderTestSuite
|
||||
// 2. 实现 SetupMocks() 方法来配置 gomonkey mock
|
||||
// 3. 调用 RunAllTests() 运行所有通用测试
|
||||
type TraderTestSuite struct {
|
||||
T *testing.T
|
||||
Trader Trader
|
||||
Patches *gomonkey.Patches
|
||||
}
|
||||
|
||||
// NewTraderTestSuite 创建新的基础测试套件
|
||||
func NewTraderTestSuite(t *testing.T, trader Trader) *TraderTestSuite {
|
||||
return &TraderTestSuite{
|
||||
T: t,
|
||||
Trader: trader,
|
||||
Patches: gomonkey.NewPatches(),
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup 清理 mock patches
|
||||
func (s *TraderTestSuite) Cleanup() {
|
||||
if s.Patches != nil {
|
||||
s.Patches.Reset()
|
||||
}
|
||||
}
|
||||
|
||||
// RunAllTests 运行所有通用接口测试
|
||||
// 注意:调用此方法前,请先通过 SetupMocks 设置好所需的 mock
|
||||
func (s *TraderTestSuite) RunAllTests() {
|
||||
// 基础查询方法
|
||||
s.T.Run("GetBalance", func(t *testing.T) { s.TestGetBalance() })
|
||||
s.T.Run("GetPositions", func(t *testing.T) { s.TestGetPositions() })
|
||||
s.T.Run("GetMarketPrice", func(t *testing.T) { s.TestGetMarketPrice() })
|
||||
|
||||
// 配置方法
|
||||
s.T.Run("SetLeverage", func(t *testing.T) { s.TestSetLeverage() })
|
||||
s.T.Run("SetMarginMode", func(t *testing.T) { s.TestSetMarginMode() })
|
||||
s.T.Run("FormatQuantity", func(t *testing.T) { s.TestFormatQuantity() })
|
||||
|
||||
// 核心交易方法
|
||||
s.T.Run("OpenLong", func(t *testing.T) { s.TestOpenLong() })
|
||||
s.T.Run("OpenShort", func(t *testing.T) { s.TestOpenShort() })
|
||||
s.T.Run("CloseLong", func(t *testing.T) { s.TestCloseLong() })
|
||||
s.T.Run("CloseShort", func(t *testing.T) { s.TestCloseShort() })
|
||||
|
||||
// 止损止盈
|
||||
s.T.Run("SetStopLoss", func(t *testing.T) { s.TestSetStopLoss() })
|
||||
s.T.Run("SetTakeProfit", func(t *testing.T) { s.TestSetTakeProfit() })
|
||||
|
||||
// 订单管理
|
||||
s.T.Run("CancelAllOrders", func(t *testing.T) { s.TestCancelAllOrders() })
|
||||
s.T.Run("CancelStopOrders", func(t *testing.T) { s.TestCancelStopOrders() })
|
||||
s.T.Run("CancelStopLossOrders", func(t *testing.T) { s.TestCancelStopLossOrders() })
|
||||
s.T.Run("CancelTakeProfitOrders", func(t *testing.T) { s.TestCancelTakeProfitOrders() })
|
||||
}
|
||||
|
||||
// TestGetBalance 测试获取账户余额
|
||||
func (s *TraderTestSuite) TestGetBalance() {
|
||||
tests := []struct {
|
||||
name string
|
||||
wantError bool
|
||||
validate func(*testing.T, map[string]interface{})
|
||||
}{
|
||||
{
|
||||
name: "成功获取余额",
|
||||
wantError: false,
|
||||
validate: func(t *testing.T, result map[string]interface{}) {
|
||||
assert.NotNil(t, result)
|
||||
assert.Contains(t, result, "totalWalletBalance")
|
||||
assert.Contains(t, result, "availableBalance")
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
s.T.Run(tt.name, func(t *testing.T) {
|
||||
result, err := s.Trader.GetBalance()
|
||||
if tt.wantError {
|
||||
assert.Error(t, err)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
if tt.validate != nil {
|
||||
tt.validate(t, result)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetPositions 测试获取持仓
|
||||
func (s *TraderTestSuite) TestGetPositions() {
|
||||
tests := []struct {
|
||||
name string
|
||||
wantError bool
|
||||
validate func(*testing.T, []map[string]interface{})
|
||||
}{
|
||||
{
|
||||
name: "成功获取持仓列表",
|
||||
wantError: false,
|
||||
validate: func(t *testing.T, positions []map[string]interface{}) {
|
||||
assert.NotNil(t, positions)
|
||||
// 持仓可以为空数组
|
||||
for _, pos := range positions {
|
||||
assert.Contains(t, pos, "symbol")
|
||||
assert.Contains(t, pos, "side")
|
||||
assert.Contains(t, pos, "positionAmt")
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
s.T.Run(tt.name, func(t *testing.T) {
|
||||
result, err := s.Trader.GetPositions()
|
||||
if tt.wantError {
|
||||
assert.Error(t, err)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
if tt.validate != nil {
|
||||
tt.validate(t, result)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetMarketPrice 测试获取市场价格
|
||||
func (s *TraderTestSuite) TestGetMarketPrice() {
|
||||
tests := []struct {
|
||||
name string
|
||||
symbol string
|
||||
wantError bool
|
||||
validate func(*testing.T, float64)
|
||||
}{
|
||||
{
|
||||
name: "成功获取BTC价格",
|
||||
symbol: "BTCUSDT",
|
||||
wantError: false,
|
||||
validate: func(t *testing.T, price float64) {
|
||||
assert.Greater(t, price, 0.0)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "无效交易对返回错误",
|
||||
symbol: "INVALIDUSDT",
|
||||
wantError: true,
|
||||
validate: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
s.T.Run(tt.name, func(t *testing.T) {
|
||||
price, err := s.Trader.GetMarketPrice(tt.symbol)
|
||||
if tt.wantError {
|
||||
assert.Error(t, err)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
if tt.validate != nil {
|
||||
tt.validate(t, price)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetLeverage 测试设置杠杆
|
||||
func (s *TraderTestSuite) TestSetLeverage() {
|
||||
tests := []struct {
|
||||
name string
|
||||
symbol string
|
||||
leverage int
|
||||
wantError bool
|
||||
}{
|
||||
{
|
||||
name: "设置10倍杠杆",
|
||||
symbol: "BTCUSDT",
|
||||
leverage: 10,
|
||||
wantError: false,
|
||||
},
|
||||
{
|
||||
name: "设置1倍杠杆",
|
||||
symbol: "ETHUSDT",
|
||||
leverage: 1,
|
||||
wantError: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
s.T.Run(tt.name, func(t *testing.T) {
|
||||
err := s.Trader.SetLeverage(tt.symbol, tt.leverage)
|
||||
if tt.wantError {
|
||||
assert.Error(t, err)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetMarginMode 测试设置仓位模式
|
||||
func (s *TraderTestSuite) TestSetMarginMode() {
|
||||
tests := []struct {
|
||||
name string
|
||||
symbol string
|
||||
isCrossMargin bool
|
||||
wantError bool
|
||||
}{
|
||||
{
|
||||
name: "设置全仓模式",
|
||||
symbol: "BTCUSDT",
|
||||
isCrossMargin: true,
|
||||
wantError: false,
|
||||
},
|
||||
{
|
||||
name: "设置逐仓模式",
|
||||
symbol: "ETHUSDT",
|
||||
isCrossMargin: false,
|
||||
wantError: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
s.T.Run(tt.name, func(t *testing.T) {
|
||||
err := s.Trader.SetMarginMode(tt.symbol, tt.isCrossMargin)
|
||||
if tt.wantError {
|
||||
assert.Error(t, err)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFormatQuantity 测试数量格式化
|
||||
func (s *TraderTestSuite) TestFormatQuantity() {
|
||||
tests := []struct {
|
||||
name string
|
||||
symbol string
|
||||
quantity float64
|
||||
wantError bool
|
||||
validate func(*testing.T, string)
|
||||
}{
|
||||
{
|
||||
name: "格式化BTC数量",
|
||||
symbol: "BTCUSDT",
|
||||
quantity: 1.23456789,
|
||||
wantError: false,
|
||||
validate: func(t *testing.T, result string) {
|
||||
assert.NotEmpty(t, result)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "格式化小数量",
|
||||
symbol: "ETHUSDT",
|
||||
quantity: 0.001,
|
||||
wantError: false,
|
||||
validate: func(t *testing.T, result string) {
|
||||
assert.NotEmpty(t, result)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
s.T.Run(tt.name, func(t *testing.T) {
|
||||
result, err := s.Trader.FormatQuantity(tt.symbol, tt.quantity)
|
||||
if tt.wantError {
|
||||
assert.Error(t, err)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
if tt.validate != nil {
|
||||
tt.validate(t, result)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCancelAllOrders 测试取消所有订单
|
||||
func (s *TraderTestSuite) TestCancelAllOrders() {
|
||||
tests := []struct {
|
||||
name string
|
||||
symbol string
|
||||
wantError bool
|
||||
}{
|
||||
{
|
||||
name: "取消BTC所有订单",
|
||||
symbol: "BTCUSDT",
|
||||
wantError: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
s.T.Run(tt.name, func(t *testing.T) {
|
||||
err := s.Trader.CancelAllOrders(tt.symbol)
|
||||
if tt.wantError {
|
||||
assert.Error(t, err)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 核心交易方法测试
|
||||
// ============================================================
|
||||
|
||||
// TestOpenLong 测试开多仓
|
||||
func (s *TraderTestSuite) TestOpenLong() {
|
||||
tests := []struct {
|
||||
name string
|
||||
symbol string
|
||||
quantity float64
|
||||
leverage int
|
||||
wantError bool
|
||||
validate func(*testing.T, map[string]interface{})
|
||||
}{
|
||||
{
|
||||
name: "成功开多仓",
|
||||
symbol: "BTCUSDT",
|
||||
quantity: 0.01,
|
||||
leverage: 10,
|
||||
wantError: false,
|
||||
validate: func(t *testing.T, result map[string]interface{}) {
|
||||
assert.NotNil(t, result)
|
||||
assert.Contains(t, result, "symbol")
|
||||
assert.Equal(t, "BTCUSDT", result["symbol"])
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "小数量开仓",
|
||||
symbol: "ETHUSDT",
|
||||
quantity: 0.004, // 增加到 0.004 以满足 Binance Futures 的 10 USDT 最小订单金额要求 (0.004 * 3000 = 12 USDT)
|
||||
leverage: 5,
|
||||
wantError: false,
|
||||
validate: func(t *testing.T, result map[string]interface{}) {
|
||||
assert.NotNil(t, result)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
s.T.Run(tt.name, func(t *testing.T) {
|
||||
result, err := s.Trader.OpenLong(tt.symbol, tt.quantity, tt.leverage)
|
||||
if tt.wantError {
|
||||
assert.Error(t, err)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
if tt.validate != nil {
|
||||
tt.validate(t, result)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpenShort 测试开空仓
|
||||
func (s *TraderTestSuite) TestOpenShort() {
|
||||
tests := []struct {
|
||||
name string
|
||||
symbol string
|
||||
quantity float64
|
||||
leverage int
|
||||
wantError bool
|
||||
validate func(*testing.T, map[string]interface{})
|
||||
}{
|
||||
{
|
||||
name: "成功开空仓",
|
||||
symbol: "BTCUSDT",
|
||||
quantity: 0.01,
|
||||
leverage: 10,
|
||||
wantError: false,
|
||||
validate: func(t *testing.T, result map[string]interface{}) {
|
||||
assert.NotNil(t, result)
|
||||
assert.Contains(t, result, "symbol")
|
||||
assert.Equal(t, "BTCUSDT", result["symbol"])
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "小数量开空仓",
|
||||
symbol: "ETHUSDT",
|
||||
quantity: 0.004, // 增加到 0.004 以满足 Binance Futures 的 10 USDT 最小订单金额要求 (0.004 * 3000 = 12 USDT)
|
||||
leverage: 5,
|
||||
wantError: false,
|
||||
validate: func(t *testing.T, result map[string]interface{}) {
|
||||
assert.NotNil(t, result)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
s.T.Run(tt.name, func(t *testing.T) {
|
||||
result, err := s.Trader.OpenShort(tt.symbol, tt.quantity, tt.leverage)
|
||||
if tt.wantError {
|
||||
assert.Error(t, err)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
if tt.validate != nil {
|
||||
tt.validate(t, result)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCloseLong 测试平多仓
|
||||
func (s *TraderTestSuite) TestCloseLong() {
|
||||
tests := []struct {
|
||||
name string
|
||||
symbol string
|
||||
quantity float64
|
||||
wantError bool
|
||||
validate func(*testing.T, map[string]interface{})
|
||||
}{
|
||||
{
|
||||
name: "平指定数量",
|
||||
symbol: "BTCUSDT",
|
||||
quantity: 0.01,
|
||||
wantError: false,
|
||||
validate: func(t *testing.T, result map[string]interface{}) {
|
||||
assert.NotNil(t, result)
|
||||
assert.Contains(t, result, "symbol")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "全部平仓_quantity为0_无持仓返回错误",
|
||||
symbol: "ETHUSDT",
|
||||
quantity: 0,
|
||||
wantError: true, // 当没有持仓时,quantity=0 应该返回错误
|
||||
validate: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
s.T.Run(tt.name, func(t *testing.T) {
|
||||
result, err := s.Trader.CloseLong(tt.symbol, tt.quantity)
|
||||
if tt.wantError {
|
||||
assert.Error(t, err)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
if tt.validate != nil {
|
||||
tt.validate(t, result)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCloseShort 测试平空仓
|
||||
func (s *TraderTestSuite) TestCloseShort() {
|
||||
tests := []struct {
|
||||
name string
|
||||
symbol string
|
||||
quantity float64
|
||||
wantError bool
|
||||
validate func(*testing.T, map[string]interface{})
|
||||
}{
|
||||
{
|
||||
name: "平指定数量",
|
||||
symbol: "BTCUSDT",
|
||||
quantity: 0.01,
|
||||
wantError: false,
|
||||
validate: func(t *testing.T, result map[string]interface{}) {
|
||||
assert.NotNil(t, result)
|
||||
assert.Contains(t, result, "symbol")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "全部平仓_quantity为0_无持仓返回错误",
|
||||
symbol: "ETHUSDT",
|
||||
quantity: 0,
|
||||
wantError: true, // 当没有持仓时,quantity=0 应该返回错误
|
||||
validate: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
s.T.Run(tt.name, func(t *testing.T) {
|
||||
result, err := s.Trader.CloseShort(tt.symbol, tt.quantity)
|
||||
if tt.wantError {
|
||||
assert.Error(t, err)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
if tt.validate != nil {
|
||||
tt.validate(t, result)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 止损止盈测试
|
||||
// ============================================================
|
||||
|
||||
// TestSetStopLoss 测试设置止损
|
||||
func (s *TraderTestSuite) TestSetStopLoss() {
|
||||
tests := []struct {
|
||||
name string
|
||||
symbol string
|
||||
positionSide string
|
||||
quantity float64
|
||||
stopPrice float64
|
||||
wantError bool
|
||||
}{
|
||||
{
|
||||
name: "多头止损",
|
||||
symbol: "BTCUSDT",
|
||||
positionSide: "LONG",
|
||||
quantity: 0.01,
|
||||
stopPrice: 45000.0,
|
||||
wantError: false,
|
||||
},
|
||||
{
|
||||
name: "空头止损",
|
||||
symbol: "ETHUSDT",
|
||||
positionSide: "SHORT",
|
||||
quantity: 0.1,
|
||||
stopPrice: 3200.0,
|
||||
wantError: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
s.T.Run(tt.name, func(t *testing.T) {
|
||||
err := s.Trader.SetStopLoss(tt.symbol, tt.positionSide, tt.quantity, tt.stopPrice)
|
||||
if tt.wantError {
|
||||
assert.Error(t, err)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetTakeProfit 测试设置止盈
|
||||
func (s *TraderTestSuite) TestSetTakeProfit() {
|
||||
tests := []struct {
|
||||
name string
|
||||
symbol string
|
||||
positionSide string
|
||||
quantity float64
|
||||
takeProfitPrice float64
|
||||
wantError bool
|
||||
}{
|
||||
{
|
||||
name: "多头止盈",
|
||||
symbol: "BTCUSDT",
|
||||
positionSide: "LONG",
|
||||
quantity: 0.01,
|
||||
takeProfitPrice: 55000.0,
|
||||
wantError: false,
|
||||
},
|
||||
{
|
||||
name: "空头止盈",
|
||||
symbol: "ETHUSDT",
|
||||
positionSide: "SHORT",
|
||||
quantity: 0.1,
|
||||
takeProfitPrice: 2800.0,
|
||||
wantError: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
s.T.Run(tt.name, func(t *testing.T) {
|
||||
err := s.Trader.SetTakeProfit(tt.symbol, tt.positionSide, tt.quantity, tt.takeProfitPrice)
|
||||
if tt.wantError {
|
||||
assert.Error(t, err)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCancelStopOrders 测试取消止盈止损单
|
||||
func (s *TraderTestSuite) TestCancelStopOrders() {
|
||||
tests := []struct {
|
||||
name string
|
||||
symbol string
|
||||
wantError bool
|
||||
}{
|
||||
{
|
||||
name: "取消BTC止盈止损单",
|
||||
symbol: "BTCUSDT",
|
||||
wantError: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
s.T.Run(tt.name, func(t *testing.T) {
|
||||
err := s.Trader.CancelStopOrders(tt.symbol)
|
||||
if tt.wantError {
|
||||
assert.Error(t, err)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCancelStopLossOrders 测试取消止损单
|
||||
func (s *TraderTestSuite) TestCancelStopLossOrders() {
|
||||
tests := []struct {
|
||||
name string
|
||||
symbol string
|
||||
wantError bool
|
||||
}{
|
||||
{
|
||||
name: "取消BTC止损单",
|
||||
symbol: "BTCUSDT",
|
||||
wantError: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
s.T.Run(tt.name, func(t *testing.T) {
|
||||
err := s.Trader.CancelStopLossOrders(tt.symbol)
|
||||
if tt.wantError {
|
||||
assert.Error(t, err)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCancelTakeProfitOrders 测试取消止盈单
|
||||
func (s *TraderTestSuite) TestCancelTakeProfitOrders() {
|
||||
tests := []struct {
|
||||
name string
|
||||
symbol string
|
||||
wantError bool
|
||||
}{
|
||||
{
|
||||
name: "取消BTC止盈单",
|
||||
symbol: "BTCUSDT",
|
||||
wantError: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
s.T.Run(tt.name, func(t *testing.T) {
|
||||
err := s.Trader.CancelTakeProfitOrders(tt.symbol)
|
||||
if tt.wantError {
|
||||
assert.Error(t, err)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user