mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-11 14:56:57 +08:00
feat(gate): complete Gate.io exchange integration with trader refactoring
Gate.io Integration: - Add Gate trader with full Trader interface implementation - Add order_sync.go for background trade synchronization - Fix quantity display (convert contracts to actual tokens via quanto_multiplier) - Fix fill price return in OpenLong/OpenShort/CloseLong/CloseShort - Add Gate-specific CoinAnk K-line data source support - Add Gate to supported exchanges in frontend and backend - Add Gate/KuCoin logo SVG icons Trader Package Refactoring: - Move exchange-specific code into subdirectories (binance/, bybit/, okx/, bitget/, hyperliquid/, aster/, lighter/, gate/) - Create types/ package for shared types to avoid circular dependencies - Move TraderTestSuite to trader/testutil package to avoid import cycles - Update market.GetWithExchange to support exchange-specific data
This commit is contained in:
295
trader/hyperliquid/balance_test.go
Normal file
295
trader/hyperliquid/balance_test.go
Normal file
@@ -0,0 +1,295 @@
|
||||
package hyperliquid
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestHyperliquidBalanceCalculation tests the balance calculation for Hyperliquid
|
||||
// including perp, spot, and xyz dex (stocks, forex, metals) accounts
|
||||
// Run with: TEST_PRIVATE_KEY=xxx TEST_WALLET_ADDR=xxx go test -v -run TestHyperliquidBalanceCalculation ./trader/
|
||||
func TestHyperliquidBalanceCalculation(t *testing.T) {
|
||||
// Get credentials from environment
|
||||
privateKeyHex := os.Getenv("TEST_PRIVATE_KEY")
|
||||
walletAddr := os.Getenv("TEST_WALLET_ADDR")
|
||||
|
||||
if privateKeyHex == "" || walletAddr == "" {
|
||||
t.Skip("TEST_PRIVATE_KEY and TEST_WALLET_ADDR env vars required")
|
||||
}
|
||||
|
||||
t.Logf("=== Testing Hyperliquid Balance Calculation ===")
|
||||
t.Logf("Wallet: %s", walletAddr)
|
||||
|
||||
// Create trader instance
|
||||
trader, err := NewHyperliquidTrader(privateKeyHex, walletAddr, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create trader: %v", err)
|
||||
}
|
||||
|
||||
// Test GetBalance
|
||||
t.Log("\n--- Testing GetBalance ---")
|
||||
balance, err := trader.GetBalance()
|
||||
if err != nil {
|
||||
t.Fatalf("GetBalance failed: %v", err)
|
||||
}
|
||||
|
||||
// Extract values
|
||||
totalWalletBalance, _ := balance["totalWalletBalance"].(float64)
|
||||
totalEquity, _ := balance["totalEquity"].(float64)
|
||||
totalUnrealizedProfit, _ := balance["totalUnrealizedProfit"].(float64)
|
||||
availableBalance, _ := balance["availableBalance"].(float64)
|
||||
spotBalance, _ := balance["spotBalance"].(float64)
|
||||
xyzDexBalance, _ := balance["xyzDexBalance"].(float64)
|
||||
xyzDexUnrealizedPnl, _ := balance["xyzDexUnrealizedPnl"].(float64)
|
||||
perpAccountValue, _ := balance["perpAccountValue"].(float64)
|
||||
|
||||
t.Logf("\n📊 Balance Results:")
|
||||
t.Logf(" Perp Account Value: %.4f USDC", perpAccountValue)
|
||||
t.Logf(" Spot Balance: %.4f USDC", spotBalance)
|
||||
t.Logf(" xyz Dex Balance: %.4f USDC", xyzDexBalance)
|
||||
t.Logf(" xyz Dex Unrealized PnL: %.4f USDC", xyzDexUnrealizedPnl)
|
||||
t.Logf(" ---")
|
||||
t.Logf(" Total Wallet Balance: %.4f USDC", totalWalletBalance)
|
||||
t.Logf(" Total Unrealized PnL: %.4f USDC", totalUnrealizedProfit)
|
||||
t.Logf(" Total Equity: %.4f USDC", totalEquity)
|
||||
t.Logf(" Available Balance: %.4f USDC", availableBalance)
|
||||
|
||||
// Verify calculation: totalEquity should equal perpAccountValue + spotBalance + xyzDexBalance
|
||||
expectedEquity := perpAccountValue + spotBalance + xyzDexBalance
|
||||
t.Logf("\n🔍 Verification:")
|
||||
t.Logf(" Expected Equity (Perp + Spot + xyz): %.4f", expectedEquity)
|
||||
t.Logf(" Actual Total Equity: %.4f", totalEquity)
|
||||
|
||||
if abs(totalEquity-expectedEquity) > 0.01 {
|
||||
t.Errorf("❌ Equity mismatch! Expected %.4f, got %.4f", expectedEquity, totalEquity)
|
||||
} else {
|
||||
t.Logf("✅ Equity calculation correct!")
|
||||
}
|
||||
|
||||
// Verify: totalWalletBalance + totalUnrealizedProfit should equal totalEquity
|
||||
calculatedEquity := totalWalletBalance + totalUnrealizedProfit
|
||||
t.Logf("\n🔍 Secondary Verification:")
|
||||
t.Logf(" Wallet + Unrealized = %.4f + %.4f = %.4f", totalWalletBalance, totalUnrealizedProfit, calculatedEquity)
|
||||
t.Logf(" Total Equity: %.4f", totalEquity)
|
||||
|
||||
if abs(calculatedEquity-totalEquity) > 0.01 {
|
||||
t.Errorf("❌ Secondary check failed! Wallet+Unrealized=%.4f != Equity=%.4f", calculatedEquity, totalEquity)
|
||||
} else {
|
||||
t.Logf("✅ Secondary verification passed!")
|
||||
}
|
||||
|
||||
// Test GetPositions
|
||||
t.Log("\n--- Testing GetPositions ---")
|
||||
positions, err := trader.GetPositions()
|
||||
if err != nil {
|
||||
t.Fatalf("GetPositions failed: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("Found %d positions:", len(positions))
|
||||
totalPositionValue := 0.0
|
||||
totalPositionPnL := 0.0
|
||||
|
||||
for i, pos := range positions {
|
||||
symbol, _ := pos["symbol"].(string)
|
||||
side, _ := pos["side"].(string)
|
||||
positionAmt, _ := pos["positionAmt"].(float64)
|
||||
entryPrice, _ := pos["entryPrice"].(float64)
|
||||
markPrice, _ := pos["markPrice"].(float64)
|
||||
unrealizedPnL, _ := pos["unRealizedProfit"].(float64)
|
||||
leverage, _ := pos["leverage"].(float64)
|
||||
isXyzDex, _ := pos["isXyzDex"].(bool)
|
||||
|
||||
posValue := positionAmt * markPrice
|
||||
totalPositionValue += posValue
|
||||
totalPositionPnL += unrealizedPnL
|
||||
|
||||
assetType := "Crypto"
|
||||
if isXyzDex {
|
||||
assetType = "xyz Dex"
|
||||
}
|
||||
|
||||
t.Logf(" [%d] %s (%s)", i+1, symbol, assetType)
|
||||
t.Logf(" Side: %s, Qty: %.4f, Leverage: %.0fx", side, positionAmt, leverage)
|
||||
t.Logf(" Entry: %.4f, Mark: %.4f", entryPrice, markPrice)
|
||||
t.Logf(" Value: %.4f, PnL: %.4f", posValue, unrealizedPnL)
|
||||
|
||||
// Verify xyz dex position has valid entry/mark prices
|
||||
if isXyzDex {
|
||||
if entryPrice == 0 {
|
||||
t.Errorf("❌ xyz dex position %s has zero entry price!", symbol)
|
||||
}
|
||||
if markPrice == 0 {
|
||||
t.Errorf("❌ xyz dex position %s has zero mark price!", symbol)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
t.Logf("\n📊 Position Summary:")
|
||||
t.Logf(" Total Position Value: %.4f USDC", totalPositionValue)
|
||||
t.Logf(" Total Position PnL: %.4f USDC", totalPositionPnL)
|
||||
|
||||
// Compare position PnL with balance unrealized PnL
|
||||
t.Logf("\n🔍 PnL Comparison:")
|
||||
t.Logf(" Balance Unrealized PnL: %.4f", totalUnrealizedProfit)
|
||||
t.Logf(" Position Sum PnL: %.4f", totalPositionPnL)
|
||||
|
||||
if abs(totalUnrealizedProfit-totalPositionPnL) > 0.1 {
|
||||
t.Logf("⚠️ PnL mismatch (may be due to funding fees or timing)")
|
||||
} else {
|
||||
t.Logf("✅ PnL values match!")
|
||||
}
|
||||
}
|
||||
|
||||
// TestXyzDexBalanceDirectQuery directly queries xyz dex balance for debugging
|
||||
func TestXyzDexBalanceDirectQuery(t *testing.T) {
|
||||
privateKeyHex := os.Getenv("TEST_PRIVATE_KEY")
|
||||
walletAddr := os.Getenv("TEST_WALLET_ADDR")
|
||||
|
||||
if privateKeyHex == "" || walletAddr == "" {
|
||||
t.Skip("TEST_PRIVATE_KEY and TEST_WALLET_ADDR env vars required")
|
||||
}
|
||||
|
||||
trader, err := NewHyperliquidTrader(privateKeyHex, walletAddr, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create trader: %v", err)
|
||||
}
|
||||
|
||||
t.Log("=== Direct xyz Dex Balance Query ===")
|
||||
|
||||
accountValue, unrealizedPnl, positions, err := trader.getXYZDexBalance()
|
||||
if err != nil {
|
||||
t.Fatalf("getXYZDexBalance failed: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("xyz Dex Account Value: %.4f", accountValue)
|
||||
t.Logf("xyz Dex Unrealized PnL: %.4f", unrealizedPnl)
|
||||
t.Logf("xyz Dex Wallet Balance: %.4f", accountValue-unrealizedPnl)
|
||||
t.Logf("xyz Dex Positions: %d", len(positions))
|
||||
|
||||
for i, pos := range positions {
|
||||
entryPx := "nil"
|
||||
if pos.Position.EntryPx != nil {
|
||||
entryPx = *pos.Position.EntryPx
|
||||
}
|
||||
liqPx := "nil"
|
||||
if pos.Position.LiquidationPx != nil {
|
||||
liqPx = *pos.Position.LiquidationPx
|
||||
}
|
||||
|
||||
t.Logf(" [%d] %s:", i+1, pos.Position.Coin)
|
||||
t.Logf(" Size: %s", pos.Position.Szi)
|
||||
t.Logf(" Entry Price: %s", entryPx)
|
||||
t.Logf(" Position Value: %s", pos.Position.PositionValue)
|
||||
t.Logf(" Unrealized PnL: %s", pos.Position.UnrealizedPnl)
|
||||
t.Logf(" Liquidation Price: %s", liqPx)
|
||||
t.Logf(" Leverage: %d (%s)", pos.Position.Leverage.Value, pos.Position.Leverage.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEquityAfterOpeningPosition simulates opening a position and verifies equity
|
||||
func TestEquityAfterOpeningPosition(t *testing.T) {
|
||||
privateKeyHex := os.Getenv("TEST_PRIVATE_KEY")
|
||||
walletAddr := os.Getenv("TEST_WALLET_ADDR")
|
||||
|
||||
if privateKeyHex == "" || walletAddr == "" {
|
||||
t.Skip("TEST_PRIVATE_KEY and TEST_WALLET_ADDR env vars required")
|
||||
}
|
||||
|
||||
if os.Getenv("XYZ_DEX_LIVE_TEST") != "1" {
|
||||
t.Skip("Set XYZ_DEX_LIVE_TEST=1 to run live position test")
|
||||
}
|
||||
|
||||
trader, err := NewHyperliquidTrader(privateKeyHex, walletAddr, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create trader: %v", err)
|
||||
}
|
||||
|
||||
// Step 1: Record initial balance
|
||||
t.Log("=== Step 1: Record Initial Balance ===")
|
||||
initialBalance, _ := trader.GetBalance()
|
||||
initialEquity, _ := initialBalance["totalEquity"].(float64)
|
||||
t.Logf("Initial Equity: %.4f", initialEquity)
|
||||
|
||||
// Step 2: Fetch xyz meta
|
||||
if err := trader.fetchXyzMeta(); err != nil {
|
||||
t.Fatalf("Failed to fetch xyz meta: %v", err)
|
||||
}
|
||||
|
||||
// Step 3: Get current price and place a small order
|
||||
price, err := trader.getXyzMarketPrice("xyz:SILVER")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get price: %v", err)
|
||||
}
|
||||
t.Logf("Current xyz:SILVER price: %.4f", price)
|
||||
|
||||
// Place a small buy order (minimum ~$10)
|
||||
testSize := 0.14
|
||||
testPrice := price * 1.05 // 5% above for IOC
|
||||
|
||||
t.Log("\n=== Step 2: Place Test Order ===")
|
||||
t.Logf("Opening position: xyz:SILVER BUY %.4f @ %.4f", testSize, testPrice)
|
||||
|
||||
err = trader.placeXyzOrder("xyz:SILVER", true, testSize, testPrice, false)
|
||||
if err != nil {
|
||||
t.Logf("Order result: %v", err)
|
||||
// Even if IOC doesn't fill, continue to check balance
|
||||
}
|
||||
|
||||
// Wait a moment for the order to process
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
// Step 3: Check balance after order
|
||||
t.Log("\n=== Step 3: Check Balance After Order ===")
|
||||
afterBalance, _ := trader.GetBalance()
|
||||
afterEquity, _ := afterBalance["totalEquity"].(float64)
|
||||
afterPerpAV, _ := afterBalance["perpAccountValue"].(float64)
|
||||
afterXyzAV, _ := afterBalance["xyzDexBalance"].(float64)
|
||||
|
||||
t.Logf("After Order:")
|
||||
t.Logf(" Perp Account Value: %.4f", afterPerpAV)
|
||||
t.Logf(" xyz Dex Balance: %.4f", afterXyzAV)
|
||||
t.Logf(" Total Equity: %.4f", afterEquity)
|
||||
|
||||
equityChange := afterEquity - initialEquity
|
||||
t.Logf("\nEquity Change: %.4f (%.2f%%)", equityChange, (equityChange/initialEquity)*100)
|
||||
|
||||
// Equity should not change significantly (only by trading fees/slippage)
|
||||
if abs(equityChange) > initialEquity*0.05 { // More than 5% change is suspicious
|
||||
t.Errorf("❌ Equity changed too much! Initial=%.4f, After=%.4f, Change=%.4f",
|
||||
initialEquity, afterEquity, equityChange)
|
||||
} else {
|
||||
t.Logf("✅ Equity change is within acceptable range")
|
||||
}
|
||||
|
||||
// Step 4: Close position if opened
|
||||
t.Log("\n=== Step 4: Close Position ===")
|
||||
positions, _ := trader.GetPositions()
|
||||
for _, pos := range positions {
|
||||
symbol, _ := pos["symbol"].(string)
|
||||
if symbol == "xyz:SILVER" {
|
||||
posAmt, _ := pos["positionAmt"].(float64)
|
||||
if posAmt > 0 {
|
||||
closePrice := price * 0.95 // 5% below for IOC sell
|
||||
t.Logf("Closing position: SELL %.4f @ %.4f", posAmt, closePrice)
|
||||
trader.placeXyzOrder("xyz:SILVER", false, posAmt, closePrice, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
// Final balance check
|
||||
t.Log("\n=== Step 5: Final Balance ===")
|
||||
finalBalance, _ := trader.GetBalance()
|
||||
finalEquity, _ := finalBalance["totalEquity"].(float64)
|
||||
t.Logf("Final Equity: %.4f", finalEquity)
|
||||
t.Logf("Net Change: %.4f", finalEquity-initialEquity)
|
||||
}
|
||||
|
||||
func abs(x float64) float64 {
|
||||
if x < 0 {
|
||||
return -x
|
||||
}
|
||||
return x
|
||||
}
|
||||
149
trader/hyperliquid/order_sync.go
Normal file
149
trader/hyperliquid/order_sync.go
Normal file
@@ -0,0 +1,149 @@
|
||||
package hyperliquid
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"nofx/logger"
|
||||
"nofx/market"
|
||||
"nofx/store"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SyncOrdersFromHyperliquid syncs Hyperliquid exchange order history to local database
|
||||
// Also creates/updates position records to ensure orders/fills/positions data consistency
|
||||
// exchangeID: Exchange account UUID (from exchanges.id)
|
||||
// exchangeType: Exchange type ("hyperliquid")
|
||||
func (t *HyperliquidTrader) SyncOrdersFromHyperliquid(traderID string, exchangeID string, exchangeType string, st *store.Store) error {
|
||||
if st == nil {
|
||||
return fmt.Errorf("store is nil")
|
||||
}
|
||||
|
||||
// Get recent trades (last 24 hours)
|
||||
startTime := time.Now().Add(-24 * time.Hour)
|
||||
|
||||
logger.Infof("🔄 Syncing Hyperliquid trades from: %s", startTime.Format(time.RFC3339))
|
||||
|
||||
// Use GetTrades method to fetch trade records
|
||||
trades, err := t.GetTrades(startTime, 1000)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get trades: %w", err)
|
||||
}
|
||||
|
||||
logger.Infof("📥 Received %d trades from Hyperliquid", len(trades))
|
||||
|
||||
// Sort trades by time ASC (oldest first) for proper position building
|
||||
sort.Slice(trades, func(i, j int) bool {
|
||||
return trades[i].Time.UnixMilli() < trades[j].Time.UnixMilli()
|
||||
})
|
||||
|
||||
// Process trades one by one (no transaction to avoid deadlock)
|
||||
orderStore := st.Order()
|
||||
positionStore := st.Position()
|
||||
posBuilder := store.NewPositionBuilder(positionStore)
|
||||
syncedCount := 0
|
||||
|
||||
for _, trade := range trades {
|
||||
// Check if trade already exists (use exchangeID which is UUID, not exchange type)
|
||||
existing, err := orderStore.GetOrderByExchangeID(exchangeID, trade.TradeID)
|
||||
if err == nil && existing != nil {
|
||||
continue // Order already exists, skip
|
||||
}
|
||||
|
||||
// Normalize symbol
|
||||
symbol := market.Normalize(trade.Symbol)
|
||||
|
||||
// Use order action from trade (parsed from Hyperliquid Dir field)
|
||||
// Dir field values: "Open Long", "Open Short", "Close Long", "Close Short"
|
||||
orderAction := trade.OrderAction
|
||||
positionSide := "LONG"
|
||||
if strings.Contains(orderAction, "short") {
|
||||
positionSide = "SHORT"
|
||||
}
|
||||
|
||||
// Create order record - use Unix milliseconds UTC
|
||||
tradeTimeMs := trade.Time.UTC().UnixMilli()
|
||||
orderRecord := &store.TraderOrder{
|
||||
TraderID: traderID,
|
||||
ExchangeID: exchangeID, // UUID
|
||||
ExchangeType: exchangeType, // Exchange type
|
||||
ExchangeOrderID: trade.TradeID,
|
||||
Symbol: symbol,
|
||||
Side: trade.Side,
|
||||
PositionSide: "BOTH", // Hyperliquid uses one-way position mode
|
||||
Type: "MARKET",
|
||||
OrderAction: orderAction,
|
||||
Quantity: trade.Quantity,
|
||||
Price: trade.Price,
|
||||
Status: "FILLED",
|
||||
FilledQuantity: trade.Quantity,
|
||||
AvgFillPrice: trade.Price,
|
||||
Commission: trade.Fee,
|
||||
FilledAt: tradeTimeMs,
|
||||
CreatedAt: tradeTimeMs,
|
||||
UpdatedAt: tradeTimeMs,
|
||||
}
|
||||
|
||||
// Insert order record
|
||||
if err := orderStore.CreateOrder(orderRecord); err != nil {
|
||||
logger.Infof(" ⚠️ Failed to sync trade %s: %v", trade.TradeID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Create fill record - use Unix milliseconds UTC
|
||||
fillRecord := &store.TraderFill{
|
||||
TraderID: traderID,
|
||||
ExchangeID: exchangeID, // UUID
|
||||
ExchangeType: exchangeType, // Exchange type
|
||||
OrderID: orderRecord.ID,
|
||||
ExchangeOrderID: trade.TradeID,
|
||||
ExchangeTradeID: trade.TradeID,
|
||||
Symbol: symbol,
|
||||
Side: trade.Side,
|
||||
Price: trade.Price,
|
||||
Quantity: trade.Quantity,
|
||||
QuoteQuantity: trade.Price * trade.Quantity,
|
||||
Commission: trade.Fee,
|
||||
CommissionAsset: "USDT",
|
||||
RealizedPnL: trade.RealizedPnL,
|
||||
IsMaker: false, // Hyperliquid GetTrades doesn't provide maker/taker info
|
||||
CreatedAt: tradeTimeMs,
|
||||
}
|
||||
|
||||
if err := orderStore.CreateFill(fillRecord); err != nil {
|
||||
logger.Infof(" ⚠️ Failed to sync fill for trade %s: %v", trade.TradeID, err)
|
||||
}
|
||||
|
||||
// Create/update position record using PositionBuilder
|
||||
if err := posBuilder.ProcessTrade(
|
||||
traderID, exchangeID, exchangeType,
|
||||
symbol, positionSide, orderAction,
|
||||
trade.Quantity, trade.Price, trade.Fee, trade.RealizedPnL,
|
||||
tradeTimeMs, trade.TradeID,
|
||||
); err != nil {
|
||||
logger.Infof(" ⚠️ Failed to sync position for trade %s: %v", trade.TradeID, err)
|
||||
} else {
|
||||
logger.Infof(" 📍 Position updated for trade: %s (action: %s, qty: %.6f)", trade.TradeID, orderAction, trade.Quantity)
|
||||
}
|
||||
|
||||
syncedCount++
|
||||
logger.Infof(" ✅ Synced trade: %s %s %s qty=%.6f price=%.6f pnl=%.2f fee=%.6f action=%s",
|
||||
trade.TradeID, symbol, trade.Side, trade.Quantity, trade.Price, trade.RealizedPnL, trade.Fee, orderAction)
|
||||
}
|
||||
|
||||
logger.Infof("✅ Order sync completed: %d new trades synced", syncedCount)
|
||||
return nil
|
||||
}
|
||||
|
||||
// StartOrderSync starts background order sync task
|
||||
func (t *HyperliquidTrader) StartOrderSync(traderID string, exchangeID string, exchangeType string, st *store.Store, interval time.Duration) {
|
||||
ticker := time.NewTicker(interval)
|
||||
go func() {
|
||||
for range ticker.C {
|
||||
if err := t.SyncOrdersFromHyperliquid(traderID, exchangeID, exchangeType, st); err != nil {
|
||||
logger.Infof("⚠️ Hyperliquid order sync failed: %v", err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
logger.Infof("🔄 Hyperliquid order sync started (interval: %v)", interval)
|
||||
}
|
||||
393
trader/hyperliquid/sync_test.go
Normal file
393
trader/hyperliquid/sync_test.go
Normal file
@@ -0,0 +1,393 @@
|
||||
package hyperliquid
|
||||
|
||||
import (
|
||||
"math"
|
||||
"nofx/store"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
// TestHyperliquidOrderDirectionParsing tests Dir field parsing
|
||||
func TestHyperliquidOrderDirectionParsing(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
dirField string
|
||||
side string
|
||||
expectedAction string
|
||||
expectedPosSide string
|
||||
}{
|
||||
{
|
||||
name: "Open Long",
|
||||
dirField: "Open Long",
|
||||
side: "BUY",
|
||||
expectedAction: "open_long",
|
||||
expectedPosSide: "LONG",
|
||||
},
|
||||
{
|
||||
name: "Open Short",
|
||||
dirField: "Open Short",
|
||||
side: "SELL",
|
||||
expectedAction: "open_short",
|
||||
expectedPosSide: "SHORT",
|
||||
},
|
||||
{
|
||||
name: "Close Long",
|
||||
dirField: "Close Long",
|
||||
side: "SELL",
|
||||
expectedAction: "close_long",
|
||||
expectedPosSide: "LONG",
|
||||
},
|
||||
{
|
||||
name: "Close Short",
|
||||
dirField: "Close Short",
|
||||
side: "BUY",
|
||||
expectedAction: "close_short",
|
||||
expectedPosSide: "SHORT",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Mock fill data structure from Hyperliquid SDK
|
||||
// We'll test the parsing logic directly
|
||||
var orderAction string
|
||||
switch tt.dirField {
|
||||
case "Open Long":
|
||||
orderAction = "open_long"
|
||||
case "Open Short":
|
||||
orderAction = "open_short"
|
||||
case "Close Long":
|
||||
orderAction = "close_long"
|
||||
case "Close Short":
|
||||
orderAction = "close_short"
|
||||
}
|
||||
|
||||
if orderAction != tt.expectedAction {
|
||||
t.Errorf("Expected action %s, got %s", tt.expectedAction, orderAction)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestHyperliquidPositionBuilding tests the complete flow of position building
|
||||
func TestHyperliquidPositionBuilding(t *testing.T) {
|
||||
// Setup in-memory database
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test database: %v", err)
|
||||
}
|
||||
|
||||
// Initialize stores
|
||||
positionStore := store.NewPositionStore(db)
|
||||
if err := positionStore.InitTables(); err != nil {
|
||||
t.Fatalf("Failed to initialize position tables: %v", err)
|
||||
}
|
||||
|
||||
posBuilder := store.NewPositionBuilder(positionStore)
|
||||
|
||||
traderID := "test-trader"
|
||||
exchangeID := "test-exchange"
|
||||
exchangeType := "hyperliquid"
|
||||
symbol := "ETHUSDT"
|
||||
|
||||
// Test Case 1: Open Long → Close Long (should result in 0 position)
|
||||
t.Run("Open and Close Long", func(t *testing.T) {
|
||||
// Open Long: BUY 0.1 ETH @ 3500
|
||||
err := posBuilder.ProcessTrade(
|
||||
traderID, exchangeID, exchangeType,
|
||||
symbol, "LONG", "open_long",
|
||||
0.1, 3500, 0.5, 0,
|
||||
time.Now().UnixMilli(), "order-1",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to process open long: %v", err)
|
||||
}
|
||||
|
||||
// Verify position created
|
||||
positions, err := positionStore.GetOpenPositions(traderID)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get positions: %v", err)
|
||||
}
|
||||
if len(positions) != 1 {
|
||||
t.Fatalf("Expected 1 open position, got %d", len(positions))
|
||||
}
|
||||
if positions[0].Quantity != 0.1 {
|
||||
t.Errorf("Expected quantity 0.1, got %f", positions[0].Quantity)
|
||||
}
|
||||
|
||||
// Close Long: SELL 0.1 ETH @ 3600
|
||||
err = posBuilder.ProcessTrade(
|
||||
traderID, exchangeID, exchangeType,
|
||||
symbol, "LONG", "close_long",
|
||||
0.1, 3600, 0.5, 10.0, // PnL = (3600-3500)*0.1 = 10
|
||||
time.Now().UnixMilli(), "order-2",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to process close long: %v", err)
|
||||
}
|
||||
|
||||
// Verify position closed
|
||||
positions, err = positionStore.GetOpenPositions(traderID)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get positions: %v", err)
|
||||
}
|
||||
if len(positions) != 0 {
|
||||
t.Errorf("Expected 0 open positions, got %d", len(positions))
|
||||
}
|
||||
})
|
||||
|
||||
// Clear positions for next test
|
||||
db.Exec("DELETE FROM trader_positions")
|
||||
|
||||
// Test Case 2: Open Short → Close Short with BUY (the bug scenario!)
|
||||
t.Run("Open Short then Close with BUY", func(t *testing.T) {
|
||||
// Open Short: SELL 0.05 ETH @ 3500
|
||||
err := posBuilder.ProcessTrade(
|
||||
traderID, exchangeID, exchangeType,
|
||||
symbol, "SHORT", "open_short",
|
||||
0.05, 3500, 0.25, 0,
|
||||
time.Now().UnixMilli(), "order-3",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to process open short: %v", err)
|
||||
}
|
||||
|
||||
// Verify SHORT position created
|
||||
positions, err := positionStore.GetOpenPositions(traderID)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get positions: %v", err)
|
||||
}
|
||||
if len(positions) != 1 {
|
||||
t.Fatalf("Expected 1 open position, got %d", len(positions))
|
||||
}
|
||||
if positions[0].Side != "SHORT" {
|
||||
t.Errorf("Expected SHORT position, got %s", positions[0].Side)
|
||||
}
|
||||
|
||||
// Close Short: BUY 0.05 ETH @ 3400
|
||||
// ⚠️ This is the critical test - BUY should close SHORT, not open LONG!
|
||||
err = posBuilder.ProcessTrade(
|
||||
traderID, exchangeID, exchangeType,
|
||||
symbol, "SHORT", "close_short",
|
||||
0.05, 3400, 0.25, 5.0, // PnL = (3500-3400)*0.05 = 5
|
||||
time.Now().UnixMilli(), "order-4",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to process close short: %v", err)
|
||||
}
|
||||
|
||||
// Verify position CLOSED (not opened a new LONG!)
|
||||
positions, err = positionStore.GetOpenPositions(traderID)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get positions: %v", err)
|
||||
}
|
||||
if len(positions) != 0 {
|
||||
t.Errorf("Expected 0 open positions after close, got %d", len(positions))
|
||||
if len(positions) > 0 {
|
||||
t.Errorf("Wrong position side: %s (should be closed!)", positions[0].Side)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Clear positions
|
||||
db.Exec("DELETE FROM trader_positions")
|
||||
|
||||
// Test Case 3: Position Averaging (Open → Add → Close)
|
||||
t.Run("Position Averaging", func(t *testing.T) {
|
||||
// Open Long: BUY 0.1 ETH @ 3500
|
||||
err := posBuilder.ProcessTrade(
|
||||
traderID, exchangeID, exchangeType,
|
||||
symbol, "LONG", "open_long",
|
||||
0.1, 3500, 0.5, 0,
|
||||
time.Now().UnixMilli(), "order-5",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to process first open: %v", err)
|
||||
}
|
||||
|
||||
// Add to Long: BUY 0.1 ETH @ 3600
|
||||
err = posBuilder.ProcessTrade(
|
||||
traderID, exchangeID, exchangeType,
|
||||
symbol, "LONG", "open_long",
|
||||
0.1, 3600, 0.5, 0,
|
||||
time.Now().UnixMilli(), "order-6",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to process add position: %v", err)
|
||||
}
|
||||
|
||||
// Verify averaged position
|
||||
positions, err := positionStore.GetOpenPositions(traderID)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get positions: %v", err)
|
||||
}
|
||||
if len(positions) != 1 {
|
||||
t.Fatalf("Expected 1 position (averaged), got %d", len(positions))
|
||||
}
|
||||
if positions[0].Quantity != 0.2 {
|
||||
t.Errorf("Expected quantity 0.2, got %f", positions[0].Quantity)
|
||||
}
|
||||
expectedAvgPrice := (3500*0.1 + 3600*0.1) / 0.2 // = 3550
|
||||
if positions[0].EntryPrice != expectedAvgPrice {
|
||||
t.Errorf("Expected avg price %f, got %f", expectedAvgPrice, positions[0].EntryPrice)
|
||||
}
|
||||
|
||||
// Close all: SELL 0.2 ETH @ 3700
|
||||
err = posBuilder.ProcessTrade(
|
||||
traderID, exchangeID, exchangeType,
|
||||
symbol, "LONG", "close_long",
|
||||
0.2, 3700, 1.0, 30.0,
|
||||
time.Now().UnixMilli(), "order-7",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to process close: %v", err)
|
||||
}
|
||||
|
||||
// Verify fully closed
|
||||
positions, err = positionStore.GetOpenPositions(traderID)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get positions: %v", err)
|
||||
}
|
||||
if len(positions) != 0 {
|
||||
t.Errorf("Expected 0 positions, got %d", len(positions))
|
||||
}
|
||||
})
|
||||
|
||||
// Clear positions
|
||||
db.Exec("DELETE FROM trader_positions")
|
||||
|
||||
// Test Case 4: Partial Close
|
||||
t.Run("Partial Close", func(t *testing.T) {
|
||||
// Open Long: BUY 1.0 ETH @ 3500
|
||||
err := posBuilder.ProcessTrade(
|
||||
traderID, exchangeID, exchangeType,
|
||||
symbol, "LONG", "open_long",
|
||||
1.0, 3500, 2.0, 0,
|
||||
time.Now().UnixMilli(), "order-8",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to process open: %v", err)
|
||||
}
|
||||
|
||||
// Partial Close: SELL 0.3 ETH @ 3600
|
||||
err = posBuilder.ProcessTrade(
|
||||
traderID, exchangeID, exchangeType,
|
||||
symbol, "LONG", "close_long",
|
||||
0.3, 3600, 0.6, 30.0,
|
||||
time.Now().UnixMilli(), "order-9",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to process partial close: %v", err)
|
||||
}
|
||||
|
||||
// Verify remaining position
|
||||
positions, err := positionStore.GetOpenPositions(traderID)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get positions: %v", err)
|
||||
}
|
||||
if len(positions) != 1 {
|
||||
t.Fatalf("Expected 1 position, got %d", len(positions))
|
||||
}
|
||||
if positions[0].Quantity != 0.7 {
|
||||
t.Errorf("Expected remaining quantity 0.7, got %f", positions[0].Quantity)
|
||||
}
|
||||
if positions[0].Status != "OPEN" {
|
||||
t.Errorf("Expected status OPEN, got %s", positions[0].Status)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestHyperliquidBugScenario tests the exact bug we fixed
|
||||
func TestHyperliquidBugScenario(t *testing.T) {
|
||||
// Setup database
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test database: %v", err)
|
||||
}
|
||||
|
||||
positionStore := store.NewPositionStore(db)
|
||||
if err := positionStore.InitTables(); err != nil {
|
||||
t.Fatalf("Failed to initialize position tables: %v", err)
|
||||
}
|
||||
|
||||
posBuilder := store.NewPositionBuilder(positionStore)
|
||||
|
||||
traderID := "test-trader"
|
||||
exchangeID := "test-exchange"
|
||||
exchangeType := "hyperliquid"
|
||||
|
||||
// Simulate the exact scenario from the bug report
|
||||
// Account has 30 USDT, should not be able to hold 1.7 ETH
|
||||
|
||||
trades := []struct {
|
||||
action string
|
||||
side string
|
||||
symbol string
|
||||
qty float64
|
||||
price float64
|
||||
fee float64
|
||||
pnl float64
|
||||
}{
|
||||
// Order 853: Open Short
|
||||
{"open_short", "SHORT", "ETHUSDT", 0.0472, 3500, 0.2, 0},
|
||||
// Order 854: Close Short (was incorrectly classified as open_long)
|
||||
{"close_short", "SHORT", "ETHUSDT", 0.0472, 3400, 0.2, 4.72},
|
||||
// Order 855: Open Long
|
||||
{"open_long", "LONG", "ETHUSDT", 0.05, 3450, 0.2, 0},
|
||||
// Order 856: Close Long
|
||||
{"close_long", "LONG", "ETHUSDT", 0.05, 3550, 0.2, 5.0},
|
||||
}
|
||||
|
||||
for i, trade := range trades {
|
||||
err := posBuilder.ProcessTrade(
|
||||
traderID, exchangeID, exchangeType,
|
||||
trade.symbol, trade.side, trade.action,
|
||||
trade.qty, trade.price, trade.fee, trade.pnl,
|
||||
time.Now().Add(time.Duration(i)*time.Second).UnixMilli(),
|
||||
"",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to process trade %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify: Should have 0 open positions
|
||||
positions, err := positionStore.GetOpenPositions(traderID)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get positions: %v", err)
|
||||
}
|
||||
|
||||
if len(positions) != 0 {
|
||||
t.Errorf("Expected 0 open positions, got %d", len(positions))
|
||||
for _, p := range positions {
|
||||
t.Errorf(" Unexpected position: %s %s qty=%.4f", p.Symbol, p.Side, p.Quantity)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify closed positions have correct PnL
|
||||
allPositions, err := positionStore.GetClosedPositions(traderID, 100)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get closed positions: %v", err)
|
||||
}
|
||||
|
||||
totalPnL := 0.0
|
||||
for _, p := range allPositions {
|
||||
if p.Status == "CLOSED" {
|
||||
totalPnL += p.RealizedPnL
|
||||
}
|
||||
}
|
||||
|
||||
expectedTotalPnL := 4.72 + 5.0 // Sum of both close trades
|
||||
// Use tolerance for floating point comparison
|
||||
if math.Abs(totalPnL-expectedTotalPnL) > 0.01 {
|
||||
t.Errorf("Expected total PnL %.2f, got %.2f", expectedTotalPnL, totalPnL)
|
||||
}
|
||||
}
|
||||
2232
trader/hyperliquid/trader.go
Normal file
2232
trader/hyperliquid/trader.go
Normal file
File diff suppressed because it is too large
Load Diff
192
trader/hyperliquid/trader_race_test.go
Normal file
192
trader/hyperliquid/trader_race_test.go
Normal file
@@ -0,0 +1,192 @@
|
||||
package hyperliquid
|
||||
|
||||
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
|
||||
ht := &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 := ht.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) {
|
||||
ht := &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()
|
||||
ht.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
|
||||
ht.metaMutex.Lock()
|
||||
ht.meta = &hyperliquid.Meta{
|
||||
Universe: []hyperliquid.AssetInfo{
|
||||
{Name: "BTC", SzDecimals: 5 + iteration%3},
|
||||
{Name: "ETH", SzDecimals: 4},
|
||||
},
|
||||
}
|
||||
ht.metaMutex.Unlock()
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// Verify meta is not nil after all operations
|
||||
ht.metaMutex.RLock()
|
||||
if ht.meta == nil {
|
||||
t.Error("Meta should not be nil after concurrent operations")
|
||||
}
|
||||
ht.metaMutex.RUnlock()
|
||||
}
|
||||
|
||||
// TestGetSzDecimals_NilMeta tests getSzDecimals with nil meta
|
||||
func TestGetSzDecimals_NilMeta(t *testing.T) {
|
||||
ht := &HyperliquidTrader{
|
||||
meta: nil,
|
||||
metaMutex: sync.RWMutex{},
|
||||
}
|
||||
|
||||
// Should return default value 4 when meta is nil
|
||||
decimals := ht.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) {
|
||||
ht := &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 := ht.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) {
|
||||
ht := &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()
|
||||
ht.getSzDecimals("BTC")
|
||||
ht.getSzDecimals("ETH")
|
||||
}()
|
||||
}
|
||||
|
||||
// Concurrent writes
|
||||
for i := 0; i < 100; i++ {
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
ht.metaMutex.Lock()
|
||||
ht.meta = &hyperliquid.Meta{
|
||||
Universe: []hyperliquid.AssetInfo{
|
||||
{Name: "BTC", SzDecimals: 5},
|
||||
{Name: "ETH", SzDecimals: 4},
|
||||
{Name: "SOL", SzDecimals: 3},
|
||||
},
|
||||
}
|
||||
ht.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")
|
||||
}
|
||||
648
trader/hyperliquid/trader_test.go
Normal file
648
trader/hyperliquid/trader_test.go
Normal file
@@ -0,0 +1,648 @@
|
||||
package hyperliquid
|
||||
|
||||
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"
|
||||
"nofx/trader/testutil"
|
||||
"nofx/trader/types"
|
||||
)
|
||||
|
||||
// ============================================================
|
||||
// Part 1: HyperliquidTestSuite - Inherits base test suite
|
||||
// ============================================================
|
||||
|
||||
// HyperliquidTestSuite Hyperliquid trader test suite
|
||||
// Inherits TraderTestSuite and adds Hyperliquid-specific mock logic
|
||||
type HyperliquidTestSuite struct {
|
||||
*testutil.TraderTestSuite // Embeds base test suite
|
||||
mockServer *httptest.Server
|
||||
privateKey *ecdsa.PrivateKey
|
||||
}
|
||||
|
||||
// NewHyperliquidTestSuite Create Hyperliquid test suite
|
||||
func NewHyperliquidTestSuite(t *testing.T) *HyperliquidTestSuite {
|
||||
// Create test private key
|
||||
privateKey, err := crypto.HexToECDSA("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test private key: %v", err)
|
||||
}
|
||||
|
||||
// Create mock HTTP server
|
||||
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Return different mock responses based on request path
|
||||
var respBody interface{}
|
||||
|
||||
// Hyperliquid API uses POST requests with JSON body
|
||||
// We need to distinguish different requests by the "type" field in request body
|
||||
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 - Get market metadata
|
||||
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 - Get user account state (for GetBalance and GetPositions)
|
||||
case "clearinghouseState":
|
||||
user, _ := reqBody["user"].(string)
|
||||
|
||||
// Check if querying Agent wallet balance (for security check)
|
||||
agentAddr := crypto.PubkeyToAddress(privateKey.PublicKey).Hex()
|
||||
if user == agentAddr {
|
||||
// Agent wallet balance should be low
|
||||
respBody = map[string]interface{}{
|
||||
"crossMarginSummary": map[string]interface{}{
|
||||
"accountValue": "5.00",
|
||||
"totalMarginUsed": "0.00",
|
||||
},
|
||||
"withdrawable": "5.00",
|
||||
"assetPositions": []interface{}{},
|
||||
}
|
||||
} else {
|
||||
// Main wallet account state
|
||||
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 - Get spot account state
|
||||
case "spotClearinghouseState":
|
||||
respBody = map[string]interface{}{
|
||||
"balances": []map[string]interface{}{
|
||||
{
|
||||
"coin": "USDC",
|
||||
"total": "500.00",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Mock SpotMeta - Get spot market metadata
|
||||
case "spotMeta":
|
||||
respBody = map[string]interface{}{
|
||||
"universe": []map[string]interface{}{},
|
||||
"tokens": []map[string]interface{}{},
|
||||
}
|
||||
|
||||
// Mock AllMids - Get all market prices
|
||||
case "allMids":
|
||||
respBody = map[string]string{
|
||||
"BTC": "50000.00",
|
||||
"ETH": "3000.00",
|
||||
}
|
||||
|
||||
// Mock OpenOrders - Get open orders list
|
||||
case "openOrders":
|
||||
respBody = []interface{}{}
|
||||
|
||||
// Mock Order - Create order (open, close, stop-loss, take-profit)
|
||||
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 - Set leverage
|
||||
case "updateLeverage":
|
||||
respBody = map[string]interface{}{
|
||||
"status": "ok",
|
||||
}
|
||||
|
||||
// Mock Cancel - Cancel order
|
||||
case "cancel":
|
||||
respBody = map[string]interface{}{
|
||||
"status": "ok",
|
||||
}
|
||||
|
||||
default:
|
||||
// Default return success response
|
||||
respBody = map[string]interface{}{
|
||||
"status": "ok",
|
||||
}
|
||||
}
|
||||
|
||||
// Serialize response
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(respBody)
|
||||
}))
|
||||
|
||||
// Create HyperliquidTrader, using mock server URL
|
||||
walletAddr := "0x9999999999999999999999999999999999999999"
|
||||
ctx := context.Background()
|
||||
|
||||
// Create Exchange client, pointing to mock server
|
||||
exchange := hyperliquid.NewExchange(
|
||||
ctx,
|
||||
privateKey,
|
||||
mockServer.URL, // Use mock server URL
|
||||
nil,
|
||||
"",
|
||||
walletAddr,
|
||||
nil,
|
||||
)
|
||||
|
||||
// Create meta (simulate successful fetch)
|
||||
meta := &hyperliquid.Meta{
|
||||
Universe: []hyperliquid.AssetInfo{
|
||||
{Name: "BTC", SzDecimals: 4},
|
||||
{Name: "ETH", SzDecimals: 3},
|
||||
},
|
||||
}
|
||||
|
||||
traderInstance := &HyperliquidTrader{
|
||||
exchange: exchange,
|
||||
ctx: ctx,
|
||||
walletAddr: walletAddr,
|
||||
meta: meta,
|
||||
isCrossMargin: true,
|
||||
}
|
||||
|
||||
// Create base suite
|
||||
baseSuite := testutil.NewTraderTestSuite(t, traderInstance)
|
||||
|
||||
return &HyperliquidTestSuite{
|
||||
TraderTestSuite: baseSuite,
|
||||
mockServer: mockServer,
|
||||
privateKey: privateKey,
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup Clean up resources
|
||||
func (s *HyperliquidTestSuite) Cleanup() {
|
||||
if s.mockServer != nil {
|
||||
s.mockServer.Close()
|
||||
}
|
||||
s.TraderTestSuite.Cleanup()
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Part 2: Run common tests using HyperliquidTestSuite
|
||||
// ============================================================
|
||||
|
||||
// TestHyperliquidTrader_InterfaceCompliance Test interface compliance
|
||||
func TestHyperliquidTrader_InterfaceCompliance(t *testing.T) {
|
||||
var _ types.Trader = (*HyperliquidTrader)(nil)
|
||||
}
|
||||
|
||||
// TestHyperliquidTrader_CommonInterface Run all common interface tests using test suite
|
||||
func TestHyperliquidTrader_CommonInterface(t *testing.T) {
|
||||
// Create test suite
|
||||
suite := NewHyperliquidTestSuite(t)
|
||||
defer suite.Cleanup()
|
||||
|
||||
// Run all common interface tests
|
||||
suite.RunAllTests()
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Part 3: Hyperliquid-specific feature unit tests
|
||||
// ============================================================
|
||||
|
||||
// TestNewHyperliquidTrader Test creating Hyperliquid trader
|
||||
func TestNewHyperliquidTrader(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
privateKeyHex string
|
||||
walletAddr string
|
||||
testnet bool
|
||||
wantError bool
|
||||
errorContains string
|
||||
}{
|
||||
{
|
||||
name: "Invalid private key format",
|
||||
privateKeyHex: "invalid_key",
|
||||
walletAddr: "0x1234567890123456789012345678901234567890",
|
||||
testnet: true,
|
||||
wantError: true,
|
||||
errorContains: "Failed to parse private key",
|
||||
},
|
||||
{
|
||||
name: "Empty wallet address",
|
||||
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 Test successfully creating trader (requires mock HTTP)
|
||||
func TestNewHyperliquidTrader_Success(t *testing.T) {
|
||||
// Create test private key
|
||||
privateKey, _ := crypto.HexToECDSA("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")
|
||||
agentAddr := crypto.PubkeyToAddress(privateKey.PublicKey).Hex()
|
||||
|
||||
// Create mock HTTP server
|
||||
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 wallet low balance
|
||||
respBody = map[string]interface{}{
|
||||
"crossMarginSummary": map[string]interface{}{
|
||||
"accountValue": "5.00",
|
||||
},
|
||||
"assetPositions": []interface{}{},
|
||||
}
|
||||
} else {
|
||||
// Main wallet
|
||||
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()
|
||||
|
||||
// Note: This test would actually call NewHyperliquidTrader, but will fail
|
||||
// Because hyperliquid SDK doesn't allow us to inject custom URL in constructor
|
||||
// So this test is only for verifying parameter handling logic
|
||||
t.Skip("Skip this test: hyperliquid SDK calls real API during construction, cannot inject mock URL")
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Part 4: Utility function unit tests (Hyperliquid-specific)
|
||||
// ============================================================
|
||||
|
||||
// TestConvertSymbolToHyperliquid Test symbol conversion function
|
||||
func TestConvertSymbolToHyperliquid(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
symbol string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "BTCUSDT conversion",
|
||||
symbol: "BTCUSDT",
|
||||
expected: "BTC",
|
||||
},
|
||||
{
|
||||
name: "ETHUSDT conversion",
|
||||
symbol: "ETHUSDT",
|
||||
expected: "ETH",
|
||||
},
|
||||
{
|
||||
name: "No USDT suffix",
|
||||
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 Test absolute value function
|
||||
func TestAbsFloat(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input float64
|
||||
expected float64
|
||||
}{
|
||||
{
|
||||
name: "Positive number",
|
||||
input: 10.5,
|
||||
expected: 10.5,
|
||||
},
|
||||
{
|
||||
name: "Negative number",
|
||||
input: -10.5,
|
||||
expected: 10.5,
|
||||
},
|
||||
{
|
||||
name: "Zero",
|
||||
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 Test quantity precision handling
|
||||
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 - round to 4 decimals",
|
||||
coin: "BTC",
|
||||
quantity: 1.23456789,
|
||||
expected: 1.2346,
|
||||
},
|
||||
{
|
||||
name: "ETH - round to 3 decimals",
|
||||
coin: "ETH",
|
||||
quantity: 10.12345,
|
||||
expected: 10.123,
|
||||
},
|
||||
{
|
||||
name: "Unknown coin - use default 4 decimals",
|
||||
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 Test price significant figures handling
|
||||
func TestHyperliquidTrader_RoundPriceToSigfigs(t *testing.T) {
|
||||
trader := &HyperliquidTrader{}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
price float64
|
||||
expected float64
|
||||
}{
|
||||
{
|
||||
name: "BTC price - 5 significant figures",
|
||||
price: 50123.456789,
|
||||
expected: 50123.0,
|
||||
},
|
||||
{
|
||||
name: "Decimal price - 5 significant figures",
|
||||
price: 0.0012345678,
|
||||
expected: 0.0012346,
|
||||
},
|
||||
{
|
||||
name: "Zero price",
|
||||
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 Test getting precision
|
||||
func TestHyperliquidTrader_GetSzDecimals(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
meta *hyperliquid.Meta
|
||||
coin string
|
||||
expected int
|
||||
}{
|
||||
{
|
||||
name: "meta is nil - return default precision",
|
||||
meta: nil,
|
||||
coin: "BTC",
|
||||
expected: 4,
|
||||
},
|
||||
{
|
||||
name: "Found BTC - return correct precision",
|
||||
meta: &hyperliquid.Meta{
|
||||
Universe: []hyperliquid.AssetInfo{
|
||||
{Name: "BTC", SzDecimals: 5},
|
||||
},
|
||||
},
|
||||
coin: "BTC",
|
||||
expected: 5,
|
||||
},
|
||||
{
|
||||
name: "Coin not found - return default precision",
|
||||
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) {
|
||||
ht := &HyperliquidTrader{meta: tt.meta}
|
||||
result := ht.getSzDecimals(tt.coin)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestHyperliquidTrader_SetMarginMode Test setting margin mode
|
||||
func TestHyperliquidTrader_SetMarginMode(t *testing.T) {
|
||||
trader := &HyperliquidTrader{
|
||||
ctx: context.Background(),
|
||||
isCrossMargin: true,
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
symbol string
|
||||
isCrossMargin bool
|
||||
wantError bool
|
||||
}{
|
||||
{
|
||||
name: "Set to cross margin mode",
|
||||
symbol: "BTCUSDT",
|
||||
isCrossMargin: true,
|
||||
wantError: false,
|
||||
},
|
||||
{
|
||||
name: "Set to isolated margin mode",
|
||||
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 Test private key processing
|
||||
func TestNewHyperliquidTrader_PrivateKeyProcessing(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
privateKeyHex string
|
||||
shouldStripOx bool
|
||||
expectedLength int
|
||||
}{
|
||||
{
|
||||
name: "Private key with 0x prefix",
|
||||
privateKeyHex: "0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||
shouldStripOx: true,
|
||||
expectedLength: 64,
|
||||
},
|
||||
{
|
||||
name: "Private key without prefix",
|
||||
privateKeyHex: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||
shouldStripOx: false,
|
||||
expectedLength: 64,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Test private key prefix handling logic (without actually creating trader)
|
||||
processed := tt.privateKeyHex
|
||||
if len(processed) > 2 && (processed[:2] == "0x" || processed[:2] == "0X") {
|
||||
processed = processed[2:]
|
||||
}
|
||||
|
||||
assert.Equal(t, tt.expectedLength, len(processed))
|
||||
})
|
||||
}
|
||||
}
|
||||
669
trader/hyperliquid/xyz_dex_test.go
Normal file
669
trader/hyperliquid/xyz_dex_test.go
Normal file
@@ -0,0 +1,669 @@
|
||||
package hyperliquid
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// testXyzDexAsset is a local copy of testXyzDexAsset for testing
|
||||
type testXyzDexAsset struct {
|
||||
Name string `json:"name"`
|
||||
SzDecimals int `json:"szDecimals"`
|
||||
MaxLeverage int `json:"maxLeverage"`
|
||||
}
|
||||
|
||||
// testXyzDexMeta is a local copy of xyzDexMeta for testing
|
||||
type testXyzDexMeta struct {
|
||||
Universe []testXyzDexAsset `json:"universe"`
|
||||
}
|
||||
|
||||
// TestXyzDexMetaFetch tests fetching xyz dex meta from Hyperliquid API
|
||||
func TestXyzDexMetaFetch(t *testing.T) {
|
||||
reqBody := map[string]string{
|
||||
"type": "meta",
|
||||
"dex": "xyz",
|
||||
}
|
||||
jsonBody, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to marshal request: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", "https://api.hyperliquid.xyz/info", bytes.NewBuffer(jsonBody))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to execute request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read response: %v", err)
|
||||
}
|
||||
|
||||
var meta testXyzDexMeta
|
||||
if err := json.Unmarshal(body, &meta); err != nil {
|
||||
t.Fatalf("Failed to parse response: %v", err)
|
||||
}
|
||||
|
||||
if len(meta.Universe) == 0 {
|
||||
t.Fatal("xyz meta universe is empty")
|
||||
}
|
||||
|
||||
t.Logf("✅ xyz dex meta contains %d assets", len(meta.Universe))
|
||||
|
||||
// Check that SILVER exists
|
||||
// HIP-3 perp dex asset index formula: 100000 + perp_dex_index * 10000 + index_in_meta
|
||||
// xyz dex is at perp_dex_index = 1
|
||||
found := false
|
||||
for i, asset := range meta.Universe {
|
||||
if asset.Name == "xyz:SILVER" {
|
||||
found = true
|
||||
assetIndex := 100000 + 1*10000 + i // xyz dex index = 1
|
||||
t.Logf("✅ Found xyz:SILVER at index %d (asset ID: %d)", i, assetIndex)
|
||||
t.Logf(" SzDecimals: %d, MaxLeverage: %d", asset.SzDecimals, asset.MaxLeverage)
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("xyz:SILVER not found in meta")
|
||||
}
|
||||
}
|
||||
|
||||
// TestXyzDexPriceFetch tests fetching xyz dex prices from Hyperliquid API
|
||||
func TestXyzDexPriceFetch(t *testing.T) {
|
||||
reqBody := map[string]string{
|
||||
"type": "allMids",
|
||||
"dex": "xyz",
|
||||
}
|
||||
jsonBody, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to marshal request: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", "https://api.hyperliquid.xyz/info", bytes.NewBuffer(jsonBody))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to execute request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read response: %v", err)
|
||||
}
|
||||
|
||||
var mids map[string]string
|
||||
if err := json.Unmarshal(body, &mids); err != nil {
|
||||
t.Fatalf("Failed to parse response: %v", err)
|
||||
}
|
||||
|
||||
// Check that prices have xyz: prefix
|
||||
silverPrice, ok := mids["xyz:SILVER"]
|
||||
if !ok {
|
||||
t.Fatal("xyz:SILVER price not found (key should include xyz: prefix)")
|
||||
}
|
||||
t.Logf("✅ xyz:SILVER price: %s", silverPrice)
|
||||
|
||||
// Verify a few more assets
|
||||
testAssets := []string{"xyz:GOLD", "xyz:TSLA", "xyz:NVDA"}
|
||||
for _, asset := range testAssets {
|
||||
if price, ok := mids[asset]; ok {
|
||||
t.Logf("✅ %s price: %s", asset, price)
|
||||
} else {
|
||||
t.Logf("⚠️ %s not found in prices", asset)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestXyzAssetIndexLookup tests the asset index lookup for xyz dex assets
|
||||
func TestXyzAssetIndexLookup(t *testing.T) {
|
||||
// Fetch xyz meta
|
||||
reqBody := map[string]string{
|
||||
"type": "meta",
|
||||
"dex": "xyz",
|
||||
}
|
||||
jsonBody, _ := json.Marshal(reqBody)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req, _ := http.NewRequestWithContext(ctx, "POST", "https://api.hyperliquid.xyz/info", bytes.NewBuffer(jsonBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to fetch meta: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
var meta testXyzDexMeta
|
||||
json.Unmarshal(body, &meta)
|
||||
|
||||
// Test lookup with different formats
|
||||
testCases := []struct {
|
||||
input string
|
||||
expected string // expected match in meta
|
||||
}{
|
||||
{"SILVER", "xyz:SILVER"},
|
||||
{"xyz:SILVER", "xyz:SILVER"},
|
||||
{"GOLD", "xyz:GOLD"},
|
||||
{"xyz:TSLA", "xyz:TSLA"},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
lookupName := tc.input
|
||||
if !strings.HasPrefix(lookupName, "xyz:") {
|
||||
lookupName = "xyz:" + lookupName
|
||||
}
|
||||
|
||||
found := false
|
||||
for i, asset := range meta.Universe {
|
||||
if asset.Name == lookupName {
|
||||
found = true
|
||||
assetIndex := 100000 + 1*10000 + i // HIP-3 formula: 100000 + xyz_dex_index(1) * 10000 + meta_index
|
||||
t.Logf("✅ Lookup '%s' -> found at index %d (asset ID: %d)", tc.input, i, assetIndex)
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("❌ Lookup '%s' -> NOT FOUND (expected to match %s)", tc.input, tc.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestXyzSzDecimalsLookup tests the szDecimals lookup for different xyz assets
|
||||
func TestXyzSzDecimalsLookup(t *testing.T) {
|
||||
reqBody := map[string]string{
|
||||
"type": "meta",
|
||||
"dex": "xyz",
|
||||
}
|
||||
jsonBody, _ := json.Marshal(reqBody)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req, _ := http.NewRequestWithContext(ctx, "POST", "https://api.hyperliquid.xyz/info", bytes.NewBuffer(jsonBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to fetch meta: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
var meta testXyzDexMeta
|
||||
json.Unmarshal(body, &meta)
|
||||
|
||||
// Check szDecimals for various assets
|
||||
expectedDecimals := map[string]int{
|
||||
"xyz:SILVER": 2,
|
||||
"xyz:GOLD": 4,
|
||||
"xyz:TSLA": 3,
|
||||
}
|
||||
|
||||
for name, expected := range expectedDecimals {
|
||||
for _, asset := range meta.Universe {
|
||||
if asset.Name == name {
|
||||
if asset.SzDecimals == expected {
|
||||
t.Logf("✅ %s szDecimals: %d (expected %d)", name, asset.SzDecimals, expected)
|
||||
} else {
|
||||
t.Logf("⚠️ %s szDecimals: %d (expected %d, may have changed)", name, asset.SzDecimals, expected)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestXyzOrderParameters tests order parameter calculation
|
||||
func TestXyzOrderParameters(t *testing.T) {
|
||||
// Simulate order parameter calculation
|
||||
testCases := []struct {
|
||||
price float64
|
||||
size float64
|
||||
szDecimals int
|
||||
expectedSz float64
|
||||
}{
|
||||
{75.33, 1.0, 2, 1.00},
|
||||
{75.33, 1.234, 2, 1.23},
|
||||
{75.33, 5.567, 2, 5.57},
|
||||
{188.15, 0.5, 3, 0.500},
|
||||
{188.15, 0.1234, 3, 0.123},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
multiplier := 1.0
|
||||
for i := 0; i < tc.szDecimals; i++ {
|
||||
multiplier *= 10.0
|
||||
}
|
||||
roundedSize := float64(int(tc.size*multiplier+0.5)) / multiplier
|
||||
|
||||
if roundedSize != tc.expectedSz {
|
||||
t.Errorf("Size rounding failed: input=%v, decimals=%d, got=%v, expected=%v",
|
||||
tc.size, tc.szDecimals, roundedSize, tc.expectedSz)
|
||||
} else {
|
||||
t.Logf("✅ Size rounding: %v (decimals=%d) -> %v", tc.size, tc.szDecimals, roundedSize)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestXyzAssetIndexCalculation tests the HIP-3 asset index calculation
|
||||
// Formula: 100000 + perp_dex_index * 10000 + meta_index
|
||||
// For xyz dex: perp_dex_index = 1, so asset_index = 110000 + meta_index
|
||||
func TestXyzAssetIndexCalculation(t *testing.T) {
|
||||
reqBody := map[string]string{
|
||||
"type": "meta",
|
||||
"dex": "xyz",
|
||||
}
|
||||
jsonBody, _ := json.Marshal(reqBody)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req, _ := http.NewRequestWithContext(ctx, "POST", "https://api.hyperliquid.xyz/info", bytes.NewBuffer(jsonBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to fetch meta: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
var meta testXyzDexMeta
|
||||
json.Unmarshal(body, &meta)
|
||||
|
||||
// Test asset index calculation for SILVER
|
||||
// HIP-3 perp dex asset index formula: 100000 + perp_dex_index * 10000 + index_in_meta
|
||||
// xyz dex is at perp_dex_index = 1
|
||||
const xyzPerpDexIndex = 1
|
||||
for i, asset := range meta.Universe {
|
||||
if asset.Name == "xyz:SILVER" {
|
||||
assetIndex := 100000 + xyzPerpDexIndex*10000 + i
|
||||
t.Logf("✅ xyz:SILVER: meta_index=%d, asset_index=%d", i, assetIndex)
|
||||
|
||||
if assetIndex < 110000 {
|
||||
t.Errorf("Asset index should be >= 110000, got %d", assetIndex)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Log first few assets for reference
|
||||
t.Log("\nFirst 5 xyz assets:")
|
||||
for i := 0; i < 5 && i < len(meta.Universe); i++ {
|
||||
asset := meta.Universe[i]
|
||||
assetIndex := 100000 + xyzPerpDexIndex*10000 + i
|
||||
t.Logf(" [%d] %s -> asset_index=%d, szDecimals=%d", i, asset.Name, assetIndex, asset.SzDecimals)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsXyzDexAsset tests the isXyzDexAsset function
|
||||
func TestIsXyzDexAsset(t *testing.T) {
|
||||
testCases := []struct {
|
||||
symbol string
|
||||
expected bool
|
||||
}{
|
||||
{"xyz:SILVER", true},
|
||||
{"SILVER", true},
|
||||
{"silver", true},
|
||||
{"xyz:GOLD", true},
|
||||
{"GOLD", true},
|
||||
{"xyz:TSLA", true},
|
||||
{"TSLA", true},
|
||||
{"BTCUSDT", false},
|
||||
{"BTC", false},
|
||||
{"ETHUSDT", false},
|
||||
{"SOLUSDT", false},
|
||||
{"xyz:BTC", false}, // BTC is not an xyz asset
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
result := isXyzDexAsset(tc.symbol)
|
||||
if result != tc.expected {
|
||||
t.Errorf("isXyzDexAsset(%q) = %v, expected %v", tc.symbol, result, tc.expected)
|
||||
} else {
|
||||
t.Logf("✅ isXyzDexAsset(%q) = %v", tc.symbol, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestConvertSymbolToHyperliquidXyz tests symbol conversion for xyz assets
|
||||
func TestConvertSymbolToHyperliquidXyz(t *testing.T) {
|
||||
testCases := []struct {
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{"SILVER", "xyz:SILVER"},
|
||||
{"silver", "xyz:SILVER"},
|
||||
{"xyz:SILVER", "xyz:SILVER"},
|
||||
{"GOLD", "xyz:GOLD"},
|
||||
{"TSLA", "xyz:TSLA"},
|
||||
{"BTC", "BTC"},
|
||||
{"BTCUSDT", "BTC"},
|
||||
{"ETH", "ETH"},
|
||||
{"ETHUSDT", "ETH"},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
result := convertSymbolToHyperliquid(tc.input)
|
||||
if result != tc.expected {
|
||||
t.Errorf("convertSymbolToHyperliquid(%q) = %q, expected %q", tc.input, result, tc.expected)
|
||||
} else {
|
||||
t.Logf("✅ convertSymbolToHyperliquid(%q) = %q", tc.input, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestXyzDexOrderFlow tests the complete order flow (without actually placing an order)
|
||||
func TestXyzDexOrderFlow(t *testing.T) {
|
||||
t.Log("=== Testing xyz Dex Order Flow ===")
|
||||
|
||||
// Step 1: Fetch meta
|
||||
t.Log("\nStep 1: Fetching xyz meta...")
|
||||
reqBody := map[string]string{"type": "meta", "dex": "xyz"}
|
||||
jsonBody, _ := json.Marshal(reqBody)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req, _ := http.NewRequestWithContext(ctx, "POST", "https://api.hyperliquid.xyz/info", bytes.NewBuffer(jsonBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to fetch meta: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
var meta testXyzDexMeta
|
||||
json.Unmarshal(body, &meta)
|
||||
t.Logf("✅ Fetched %d xyz assets", len(meta.Universe))
|
||||
|
||||
// Step 2: Find SILVER
|
||||
t.Log("\nStep 2: Looking up xyz:SILVER...")
|
||||
var silverIndex int = -1
|
||||
var silverAsset *testXyzDexAsset
|
||||
for i, asset := range meta.Universe {
|
||||
if asset.Name == "xyz:SILVER" {
|
||||
silverIndex = i
|
||||
silverAsset = &meta.Universe[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if silverIndex < 0 {
|
||||
t.Fatal("SILVER not found in xyz meta")
|
||||
}
|
||||
t.Logf("✅ Found at index %d", silverIndex)
|
||||
|
||||
// Step 3: Fetch price
|
||||
t.Log("\nStep 3: Fetching price...")
|
||||
priceReq := map[string]string{"type": "allMids", "dex": "xyz"}
|
||||
priceBody, _ := json.Marshal(priceReq)
|
||||
req2, _ := http.NewRequestWithContext(ctx, "POST", "https://api.hyperliquid.xyz/info", bytes.NewBuffer(priceBody))
|
||||
req2.Header.Set("Content-Type", "application/json")
|
||||
resp2, _ := client.Do(req2)
|
||||
body2, _ := io.ReadAll(resp2.Body)
|
||||
resp2.Body.Close()
|
||||
|
||||
var mids map[string]string
|
||||
json.Unmarshal(body2, &mids)
|
||||
priceStr := mids["xyz:SILVER"]
|
||||
var price float64
|
||||
fmt.Sscanf(priceStr, "%f", &price)
|
||||
t.Logf("✅ Price: %s", priceStr)
|
||||
|
||||
// Step 4: Calculate order parameters
|
||||
t.Log("\nStep 4: Calculating order parameters...")
|
||||
orderSize := 1.0
|
||||
multiplier := 1.0
|
||||
for i := 0; i < silverAsset.SzDecimals; i++ {
|
||||
multiplier *= 10.0
|
||||
}
|
||||
roundedSize := float64(int(orderSize*multiplier+0.5)) / multiplier
|
||||
roundedPrice := price * 1.001 // 0.1% slippage
|
||||
// HIP-3 perp dex asset index formula: 100000 + perp_dex_index * 10000 + index_in_meta
|
||||
// xyz dex is at perp_dex_index = 1
|
||||
assetIndex := 100000 + 1*10000 + silverIndex
|
||||
|
||||
t.Logf(" Asset Index: %d (110000 + %d)", assetIndex, silverIndex)
|
||||
t.Logf(" Size: %.4f (szDecimals=%d)", roundedSize, silverAsset.SzDecimals)
|
||||
t.Logf(" Price: %.4f (with slippage)", roundedPrice)
|
||||
|
||||
// Step 5: Summary
|
||||
t.Log("\n=== Order Flow Test Summary ===")
|
||||
t.Log("✅ Meta fetch: OK")
|
||||
t.Log("✅ Asset lookup: OK")
|
||||
t.Log("✅ Price fetch: OK")
|
||||
t.Log("✅ Parameter calculation: OK")
|
||||
t.Logf("\n📋 Order would be placed with:")
|
||||
t.Logf(" coin: xyz:SILVER")
|
||||
t.Logf(" assetIndex: %d", assetIndex)
|
||||
t.Logf(" isBuy: true")
|
||||
t.Logf(" size: %.4f", roundedSize)
|
||||
t.Logf(" price: %.4f", roundedPrice)
|
||||
}
|
||||
|
||||
// TestXyzDexLiveOrder tests placing a real order on xyz dex
|
||||
// This test requires:
|
||||
// - XYZ_DEX_LIVE_TEST=1 to enable
|
||||
// - TEST_PRIVATE_KEY - the private key for signing
|
||||
// - TEST_WALLET_ADDR - the wallet address with funds
|
||||
func TestXyzDexLiveOrder(t *testing.T) {
|
||||
// Skip unless explicitly enabled
|
||||
if os.Getenv("XYZ_DEX_LIVE_TEST") != "1" {
|
||||
t.Skip("Skipping live order test. Set XYZ_DEX_LIVE_TEST=1 to run")
|
||||
}
|
||||
|
||||
// Get credentials from environment variables
|
||||
privateKeyHex := os.Getenv("TEST_PRIVATE_KEY")
|
||||
walletAddr := os.Getenv("TEST_WALLET_ADDR")
|
||||
|
||||
if privateKeyHex == "" || walletAddr == "" {
|
||||
t.Skip("TEST_PRIVATE_KEY and TEST_WALLET_ADDR env vars required")
|
||||
}
|
||||
|
||||
t.Logf("=== Live xyz Dex Order Test ===")
|
||||
t.Logf("Wallet: %s", walletAddr)
|
||||
|
||||
// Create trader instance
|
||||
trader, err := NewHyperliquidTrader(privateKeyHex, walletAddr, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create trader: %v", err)
|
||||
}
|
||||
|
||||
// Check xyz dex balance first
|
||||
xyzState, _ := trader.exchange.Info().UserState(trader.ctx, walletAddr, "xyz")
|
||||
if xyzState != nil && xyzState.CrossMarginSummary.AccountValue == "0.0" {
|
||||
t.Logf("⚠️ xyz dex account has no funds (balance: %s)", xyzState.CrossMarginSummary.AccountValue)
|
||||
t.Logf(" To trade xyz dex, you need to transfer funds using perpDexClassTransfer")
|
||||
t.Logf(" The test will still verify order signing and submission...")
|
||||
}
|
||||
|
||||
// Fetch xyz meta first
|
||||
if err := trader.fetchXyzMeta(); err != nil {
|
||||
t.Fatalf("Failed to fetch xyz meta: %v", err)
|
||||
}
|
||||
|
||||
// Get current price for xyz:SILVER
|
||||
price, err := trader.getXyzMarketPrice("xyz:SILVER")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get price: %v", err)
|
||||
}
|
||||
t.Logf("Current xyz:SILVER price: %.4f", price)
|
||||
|
||||
// Place a test order (minimum $10 value = 0.14 SILVER at ~$75)
|
||||
// With 5% slippage for IOC (market order)
|
||||
testSize := 0.14 // ~$10.5 at current price
|
||||
testPrice := price * 1.05 // 5% above market for IOC buy (market order)
|
||||
|
||||
t.Logf("Attempting to place order:")
|
||||
t.Logf(" Symbol: xyz:SILVER")
|
||||
t.Logf(" Side: BUY")
|
||||
t.Logf(" Size: %.4f", testSize)
|
||||
t.Logf(" Price: %.4f", testPrice)
|
||||
|
||||
// Place the order using the new direct method
|
||||
err = trader.placeXyzOrder("xyz:SILVER", true, testSize, testPrice, false)
|
||||
if err != nil {
|
||||
t.Logf("⚠️ Order result: %v", err)
|
||||
// Check if this is an expected error (e.g., insufficient margin, no matching orders for IOC)
|
||||
if strings.Contains(err.Error(), "insufficient") || strings.Contains(err.Error(), "margin") || strings.Contains(err.Error(), "minimum value") {
|
||||
t.Logf("This may be expected if the test wallet has no margin in xyz dex")
|
||||
t.Logf("✅ Order was properly signed and submitted (API validated format/signature)")
|
||||
} else if strings.Contains(err.Error(), "could not immediately match") {
|
||||
// IOC order didn't fill - this is actually SUCCESS!
|
||||
// It means the order was properly signed, submitted, and processed
|
||||
t.Logf("✅ Order was properly submitted but didn't fill (IOC with no matching orders)")
|
||||
t.Logf(" This confirms the asset index (%d) and signing are correct!", 110026)
|
||||
} else if strings.Contains(err.Error(), "Order has invalid price") || strings.Contains(err.Error(), "95% away") {
|
||||
t.Errorf("FAILED: Order has invalid price - asset index issue")
|
||||
} else {
|
||||
t.Errorf("FAILED: Unexpected error: %v", err)
|
||||
}
|
||||
} else {
|
||||
t.Logf("✅ Order placed and filled successfully!")
|
||||
}
|
||||
}
|
||||
|
||||
// TestXyzDexClosePosition tests closing a position on xyz dex
|
||||
// This test requires the XYZ_DEX_LIVE_TEST environment variable to be set
|
||||
func TestXyzDexClosePosition(t *testing.T) {
|
||||
// Skip unless explicitly enabled
|
||||
if os.Getenv("XYZ_DEX_LIVE_TEST") != "1" {
|
||||
t.Skip("Skipping live close position test. Set XYZ_DEX_LIVE_TEST=1 to run")
|
||||
}
|
||||
|
||||
// Get credentials from environment variables
|
||||
privateKeyHex := os.Getenv("TEST_PRIVATE_KEY")
|
||||
walletAddr := os.Getenv("TEST_WALLET_ADDR")
|
||||
|
||||
if privateKeyHex == "" || walletAddr == "" {
|
||||
t.Skip("TEST_PRIVATE_KEY and TEST_WALLET_ADDR env vars required")
|
||||
}
|
||||
|
||||
t.Logf("=== Live xyz Dex Close Position Test ===")
|
||||
t.Logf("Wallet: %s", walletAddr)
|
||||
|
||||
// Create trader instance
|
||||
trader, err := NewHyperliquidTrader(privateKeyHex, walletAddr, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create trader: %v", err)
|
||||
}
|
||||
|
||||
// Check current xyz dex position
|
||||
xyzState, err := trader.exchange.Info().UserState(trader.ctx, walletAddr, "xyz")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get xyz state: %v", err)
|
||||
}
|
||||
|
||||
if len(xyzState.AssetPositions) == 0 {
|
||||
t.Logf("No xyz dex positions to close")
|
||||
return
|
||||
}
|
||||
|
||||
// Get the position details
|
||||
pos := xyzState.AssetPositions[0].Position
|
||||
entryPx := ""
|
||||
if pos.EntryPx != nil {
|
||||
entryPx = *pos.EntryPx
|
||||
}
|
||||
t.Logf("Current position: %s size=%s entryPx=%s", pos.Coin, pos.Szi, entryPx)
|
||||
|
||||
// Fetch xyz meta
|
||||
if err := trader.fetchXyzMeta(); err != nil {
|
||||
t.Fatalf("Failed to fetch xyz meta: %v", err)
|
||||
}
|
||||
|
||||
// Get current price
|
||||
price, err := trader.getXyzMarketPrice(pos.Coin)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get price: %v", err)
|
||||
}
|
||||
t.Logf("Current %s price: %.4f", pos.Coin, price)
|
||||
|
||||
// Parse position size
|
||||
var posSize float64
|
||||
fmt.Sscanf(pos.Szi, "%f", &posSize)
|
||||
|
||||
// Close position: if long (szi > 0), sell; if short (szi < 0), buy
|
||||
isBuy := posSize < 0
|
||||
closeSize := posSize
|
||||
if closeSize < 0 {
|
||||
closeSize = -closeSize
|
||||
}
|
||||
|
||||
// Use aggressive slippage for close
|
||||
closePrice := price * 0.95 // 5% below for sell
|
||||
if isBuy {
|
||||
closePrice = price * 1.05 // 5% above for buy
|
||||
}
|
||||
|
||||
t.Logf("Closing position:")
|
||||
t.Logf(" Side: %s", map[bool]string{true: "BUY", false: "SELL"}[isBuy])
|
||||
t.Logf(" Size: %.4f", closeSize)
|
||||
t.Logf(" Price: %.4f", closePrice)
|
||||
|
||||
// Place close order with reduceOnly=true
|
||||
err = trader.placeXyzOrder(pos.Coin, isBuy, closeSize, closePrice, true)
|
||||
if err != nil {
|
||||
t.Logf("⚠️ Close order result: %v", err)
|
||||
if strings.Contains(err.Error(), "could not immediately match") {
|
||||
t.Logf("✅ Close order submitted but didn't fill (IOC)")
|
||||
} else {
|
||||
t.Errorf("FAILED: %v", err)
|
||||
}
|
||||
} else {
|
||||
t.Logf("✅ Position closed successfully!")
|
||||
}
|
||||
|
||||
// Verify position is closed
|
||||
xyzState2, _ := trader.exchange.Info().UserState(trader.ctx, walletAddr, "xyz")
|
||||
if len(xyzState2.AssetPositions) == 0 {
|
||||
t.Logf("✅ Position confirmed closed (no positions remaining)")
|
||||
} else {
|
||||
newPos := xyzState2.AssetPositions[0].Position
|
||||
t.Logf("Position after close: %s size=%s", newPos.Coin, newPos.Szi)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user