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:
WquGuru
2025-11-09 17:43:28 +08:00
committed by GitHub
parent 8107667796
commit 295124c1fa
14 changed files with 3766 additions and 42 deletions

299
trader/aster_trader_test.go Normal file
View 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)
}
}
})
}
}

View File

@@ -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

View 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
}
}

View 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
View 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)
}
})
}
}