diff --git a/trader/hyperliquid_trader.go b/trader/hyperliquid_trader.go index d0f80922..885ce0d8 100644 --- a/trader/hyperliquid_trader.go +++ b/trader/hyperliquid_trader.go @@ -8,6 +8,7 @@ import ( "log" "strconv" "strings" + "sync" "github.com/ethereum/go-ethereum/crypto" "github.com/sonirico/go-hyperliquid" @@ -19,6 +20,7 @@ type HyperliquidTrader struct { ctx context.Context walletAddr string meta *hyperliquid.Meta // 缓存meta信息(包含精度等) + metaMutex sync.RWMutex // 保护meta字段的并发访问 isCrossMargin bool // 是否为全仓模式 } @@ -334,6 +336,41 @@ func (t *HyperliquidTrader) SetLeverage(symbol string, leverage int) error { return nil } +// refreshMetaIfNeeded 当 Meta 信息失效时刷新(Asset ID 为 0 时触发) +func (t *HyperliquidTrader) refreshMetaIfNeeded(coin string) error { + assetID := t.exchange.Info().NameToAsset(coin) + if assetID != 0 { + return nil // Meta 正常,无需刷新 + } + + log.Printf("⚠️ %s 的 Asset ID 为 0,尝试刷新 Meta 信息...", coin) + + // 刷新 Meta 信息 + meta, err := t.exchange.Info().Meta(t.ctx) + if err != nil { + return fmt.Errorf("刷新 Meta 信息失败: %w", err) + } + + // ✅ 并发安全:使用写锁保护 meta 字段更新 + t.metaMutex.Lock() + t.meta = meta + t.metaMutex.Unlock() + + log.Printf("✅ Meta 信息已刷新,包含 %d 个资产", len(meta.Universe)) + + // 验证刷新后的 Asset ID + assetID = t.exchange.Info().NameToAsset(coin) + if assetID == 0 { + return fmt.Errorf("❌ 即使在刷新 Meta 后,资产 %s 的 Asset ID 仍为 0。可能原因:\n"+ + " 1. 该币种未在 Hyperliquid 上市\n"+ + " 2. 币种名称错误(应为 BTC 而非 BTCUSDT)\n"+ + " 3. API 连接问题", coin) + } + + log.Printf("✅ 刷新后 Asset ID 检查通过: %s -> %d", coin, assetID) + return nil +} + // OpenLong 开多仓 func (t *HyperliquidTrader) OpenLong(symbol string, quantity float64, leverage int) (map[string]interface{}, error) { // 先取消该币种的所有委托单 @@ -778,6 +815,10 @@ func (t *HyperliquidTrader) FormatQuantity(symbol string, quantity float64) (str // getSzDecimals 获取币种的数量精度 func (t *HyperliquidTrader) getSzDecimals(coin string) int { + // ✅ 并发安全:使用读锁保护 meta 字段访问 + t.metaMutex.RLock() + defer t.metaMutex.RUnlock() + if t.meta == nil { log.Printf("⚠️ meta信息为空,使用默认精度4") return 4 // 默认精度 diff --git a/trader/hyperliquid_trader_race_test.go b/trader/hyperliquid_trader_race_test.go new file mode 100644 index 00000000..f52b5036 --- /dev/null +++ b/trader/hyperliquid_trader_race_test.go @@ -0,0 +1,192 @@ +package trader + +import ( + "context" + "sync" + "testing" + + "github.com/sonirico/go-hyperliquid" +) + +// TestMetaConcurrentAccess tests that concurrent access to meta field is safe +func TestMetaConcurrentAccess(t *testing.T) { + // Create a HyperliquidTrader instance with meta initialized + trader := &HyperliquidTrader{ + ctx: context.Background(), + meta: &hyperliquid.Meta{ + Universe: []hyperliquid.AssetInfo{ + {Name: "BTC", SzDecimals: 5}, + {Name: "ETH", SzDecimals: 4}, + }, + }, + metaMutex: sync.RWMutex{}, + } + + // Number of concurrent goroutines + concurrency := 100 + var wg sync.WaitGroup + + // Test concurrent reads (getSzDecimals) + for i := 0; i < concurrency; i++ { + wg.Add(1) + go func() { + defer wg.Done() + // This should not cause race conditions + decimals := trader.getSzDecimals("BTC") + if decimals != 5 { + t.Errorf("Expected decimals 5, got %d", decimals) + } + }() + } + + wg.Wait() +} + +// TestMetaConcurrentReadWrite tests concurrent reads and writes to meta field +func TestMetaConcurrentReadWrite(t *testing.T) { + trader := &HyperliquidTrader{ + ctx: context.Background(), + meta: &hyperliquid.Meta{ + Universe: []hyperliquid.AssetInfo{ + {Name: "BTC", SzDecimals: 5}, + }, + }, + metaMutex: sync.RWMutex{}, + } + + var wg sync.WaitGroup + concurrency := 50 + + // Concurrent readers + for i := 0; i < concurrency; i++ { + wg.Add(1) + go func() { + defer wg.Done() + trader.getSzDecimals("BTC") + }() + } + + // Concurrent writers (simulating meta refresh) + for i := 0; i < 10; i++ { + wg.Add(1) + go func(iteration int) { + defer wg.Done() + // Simulate meta update + trader.metaMutex.Lock() + trader.meta = &hyperliquid.Meta{ + Universe: []hyperliquid.AssetInfo{ + {Name: "BTC", SzDecimals: 5 + iteration%3}, + {Name: "ETH", SzDecimals: 4}, + }, + } + trader.metaMutex.Unlock() + }(i) + } + + wg.Wait() + + // Verify meta is not nil after all operations + trader.metaMutex.RLock() + if trader.meta == nil { + t.Error("Meta should not be nil after concurrent operations") + } + trader.metaMutex.RUnlock() +} + +// TestGetSzDecimals_NilMeta tests getSzDecimals with nil meta +func TestGetSzDecimals_NilMeta(t *testing.T) { + trader := &HyperliquidTrader{ + meta: nil, + metaMutex: sync.RWMutex{}, + } + + // Should return default value 4 when meta is nil + decimals := trader.getSzDecimals("BTC") + expectedDecimals := 4 + + if decimals != expectedDecimals { + t.Errorf("Expected default decimals %d for nil meta, got %d", expectedDecimals, decimals) + } +} + +// TestGetSzDecimals_ValidMeta tests getSzDecimals with valid meta +func TestGetSzDecimals_ValidMeta(t *testing.T) { + trader := &HyperliquidTrader{ + meta: &hyperliquid.Meta{ + Universe: []hyperliquid.AssetInfo{ + {Name: "BTC", SzDecimals: 5}, + {Name: "ETH", SzDecimals: 4}, + {Name: "SOL", SzDecimals: 3}, + }, + }, + metaMutex: sync.RWMutex{}, + } + + tests := []struct { + coin string + expectedDecimals int + }{ + {"BTC", 5}, + {"ETH", 4}, + {"SOL", 3}, + } + + for _, tt := range tests { + t.Run(tt.coin, func(t *testing.T) { + decimals := trader.getSzDecimals(tt.coin) + if decimals != tt.expectedDecimals { + t.Errorf("For coin %s, expected decimals %d, got %d", tt.coin, tt.expectedDecimals, decimals) + } + }) + } +} + +// TestMetaMutex_NoRaceCondition tests that using -race detector finds no issues +// Run with: go test -race -run TestMetaMutex_NoRaceCondition +func TestMetaMutex_NoRaceCondition(t *testing.T) { + trader := &HyperliquidTrader{ + ctx: context.Background(), + meta: &hyperliquid.Meta{ + Universe: []hyperliquid.AssetInfo{ + {Name: "BTC", SzDecimals: 5}, + {Name: "ETH", SzDecimals: 4}, + }, + }, + metaMutex: sync.RWMutex{}, + } + + var wg sync.WaitGroup + iterations := 1000 + + // Massive concurrent reads + for i := 0; i < iterations; i++ { + wg.Add(1) + go func() { + defer wg.Done() + trader.getSzDecimals("BTC") + trader.getSzDecimals("ETH") + }() + } + + // Concurrent writes + for i := 0; i < 100; i++ { + wg.Add(1) + go func(idx int) { + defer wg.Done() + trader.metaMutex.Lock() + trader.meta = &hyperliquid.Meta{ + Universe: []hyperliquid.AssetInfo{ + {Name: "BTC", SzDecimals: 5}, + {Name: "ETH", SzDecimals: 4}, + {Name: "SOL", SzDecimals: 3}, + }, + } + trader.metaMutex.Unlock() + }(i) + } + + wg.Wait() + + // If we reach here without race detector errors, the test passes + t.Log("No race conditions detected in concurrent meta access") +}