refactor: split large files and clean up project structure

- Rename experience/ to telemetry/ for clarity
- Split 15+ large Go files (800-2200 lines) into focused modules:
  kernel/engine.go, backtest/runner.go, market/data.go, store/position.go,
  api/handler_trader.go, trader/auto_trader_grid.go, and 9 exchange traders
- Split frontend monoliths: types.ts, api.ts, AITradersPage.tsx, BacktestPage.tsx
  into domain-specific modules with barrel re-exports
- Remove stale files: screenshots, .yml.old, pyproject.toml
- Remove unused scripts/ and cmd/ directories
- Remove broken/outdated test files (network-dependent, stale expectations)
This commit is contained in:
tinkle-community
2026-03-12 12:53:57 +08:00
parent 8e294a5eed
commit cb31782be4
113 changed files with 20423 additions and 25733 deletions

View File

@@ -1,295 +0,0 @@
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
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,592 @@
package hyperliquid
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"nofx/logger"
"nofx/trader/types"
"strconv"
"strings"
"time"
)
// GetBalance gets account balance
func (t *HyperliquidTrader) GetBalance() (map[string]interface{}, error) {
logger.Infof("🔄 Calling Hyperliquid API to get account balance...")
// Step 1: Query Spot account balance
spotState, err := t.exchange.Info().SpotUserState(t.ctx, t.walletAddr)
var spotUSDCBalance float64 = 0.0
if err != nil {
logger.Infof("⚠️ Failed to query Spot balance (may have no spot assets): %v", err)
} else if spotState != nil && len(spotState.Balances) > 0 {
for _, balance := range spotState.Balances {
if balance.Coin == "USDC" {
spotUSDCBalance, _ = strconv.ParseFloat(balance.Total, 64)
logger.Infof("✓ Found Spot balance: %.2f USDC", spotUSDCBalance)
break
}
}
}
// Step 2: Query Perpetuals contract account status
accountState, err := t.exchange.Info().UserState(t.ctx, t.walletAddr)
if err != nil {
logger.Infof("❌ Hyperliquid Perpetuals API call failed: %v", err)
return nil, fmt.Errorf("failed to get account information: %w", err)
}
// Parse balance information (MarginSummary fields are all strings)
result := make(map[string]interface{})
// Step 3: Dynamically select correct summary based on margin mode (CrossMarginSummary or MarginSummary)
var accountValue, totalMarginUsed float64
var summaryType string
var summary interface{}
if t.isCrossMargin {
// Cross margin mode: use CrossMarginSummary
accountValue, _ = strconv.ParseFloat(accountState.CrossMarginSummary.AccountValue, 64)
totalMarginUsed, _ = strconv.ParseFloat(accountState.CrossMarginSummary.TotalMarginUsed, 64)
summaryType = "CrossMarginSummary (cross margin)"
summary = accountState.CrossMarginSummary
} else {
// Isolated margin mode: use MarginSummary
accountValue, _ = strconv.ParseFloat(accountState.MarginSummary.AccountValue, 64)
totalMarginUsed, _ = strconv.ParseFloat(accountState.MarginSummary.TotalMarginUsed, 64)
summaryType = "MarginSummary (isolated margin)"
summary = accountState.MarginSummary
}
// Debug: Print complete summary structure returned by API
summaryJSON, _ := json.MarshalIndent(summary, " ", " ")
logger.Infof("🔍 [DEBUG] Hyperliquid API %s complete data:", summaryType)
logger.Infof("%s", string(summaryJSON))
// Critical fix: Accumulate actual unrealized PnL from all positions
totalUnrealizedPnl := 0.0
for _, assetPos := range accountState.AssetPositions {
unrealizedPnl, _ := strconv.ParseFloat(assetPos.Position.UnrealizedPnl, 64)
totalUnrealizedPnl += unrealizedPnl
}
// Correctly understand Hyperliquid fields:
// AccountValue = Total account equity (includes idle funds + position value + unrealized PnL)
// TotalMarginUsed = Margin used by positions (included in AccountValue, for display only)
//
// To be compatible with auto_types.go calculation logic (totalEquity = totalWalletBalance + totalUnrealizedProfit)
// Need to return "wallet balance without unrealized PnL"
walletBalanceWithoutUnrealized := accountValue - totalUnrealizedPnl
// Step 4: Use Withdrawable field (PR #443)
// Withdrawable is the official real withdrawable balance, more reliable than simple calculation
availableBalance := 0.0
if accountState.Withdrawable != "" {
withdrawable, err := strconv.ParseFloat(accountState.Withdrawable, 64)
if err == nil && withdrawable > 0 {
availableBalance = withdrawable
logger.Infof("✓ Using Withdrawable as available balance: %.2f", availableBalance)
}
}
// Fallback: If no Withdrawable, use simple calculation
if availableBalance == 0 && accountState.Withdrawable == "" {
availableBalance = accountValue - totalMarginUsed
if availableBalance < 0 {
logger.Infof("⚠️ Calculated available balance is negative (%.2f), reset to 0", availableBalance)
availableBalance = 0
}
}
// Step 5: Query xyz dex balance (stock perps, forex, commodities)
var xyzAccountValue, xyzUnrealizedPnl float64
var xyzPositions []xyzAssetPosition
xyzAccountValue, xyzUnrealizedPnl, xyzPositions, err = t.getXYZDexBalance()
if err != nil {
// xyz dex query failed - log warning but don't fail the entire balance query
logger.Infof("⚠️ Failed to query xyz dex balance: %v", err)
}
// Always log xyz dex state for debugging
logger.Infof("🔍 xyz dex state: accountValue=%.4f, unrealizedPnl=%.4f, positions=%d",
xyzAccountValue, xyzUnrealizedPnl, len(xyzPositions))
for _, pos := range xyzPositions {
entryPx := "nil"
if pos.Position.EntryPx != nil {
entryPx = *pos.Position.EntryPx
}
logger.Infof(" └─ %s: size=%s, entryPx=%s, posValue=%s, pnl=%s",
pos.Position.Coin, pos.Position.Szi, entryPx, pos.Position.PositionValue, pos.Position.UnrealizedPnl)
}
xyzWalletBalance := xyzAccountValue - xyzUnrealizedPnl
// Step 6: Correctly handle Spot + Perpetuals + xyz dex balance
// Important: Each account is independent, manual transfers required
totalWalletBalance := walletBalanceWithoutUnrealized + spotUSDCBalance + xyzWalletBalance
totalUnrealizedPnlAll := totalUnrealizedPnl + xyzUnrealizedPnl
// Calculate total equity properly: perpAccountValue + spotUSDCBalance + xyzAccountValue
// Note: totalWalletBalance + totalUnrealizedPnlAll should equal this
totalEquityCalculated := accountValue + spotUSDCBalance + xyzAccountValue
// Step 7: Unified Account mode - Spot USDC is used as collateral for Perps
// In this mode, available balance includes Spot USDC since it can be used for Perp margin
if t.isUnifiedAccount && spotUSDCBalance > 0 {
// Add Spot balance to available balance for trading
availableBalance = availableBalance + spotUSDCBalance
logger.Infof("✓ Unified Account: Spot %.2f USDC added to available balance (total: %.2f)",
spotUSDCBalance, availableBalance)
}
// Suppress unused variable warning
_ = totalUnrealizedPnlAll
result["totalWalletBalance"] = totalWalletBalance // Total assets (Perp + Spot + xyz) - unrealized
result["totalEquity"] = totalEquityCalculated // Total equity = Perp AV + Spot + xyz AV
result["availableBalance"] = availableBalance // Available balance (Perp + Spot if unified)
result["totalUnrealizedProfit"] = totalUnrealizedPnlAll // Unrealized PnL (Perpetuals + xyz)
result["spotBalance"] = spotUSDCBalance // Spot balance
result["xyzDexBalance"] = xyzAccountValue // xyz dex equity (stock perps, forex, commodities)
result["xyzDexUnrealizedPnl"] = xyzUnrealizedPnl // xyz dex unrealized PnL
result["perpAccountValue"] = accountValue // Perp account value for debugging
logger.Infof("✓ Hyperliquid complete account:")
logger.Infof(" • Spot balance: %.2f USDC", spotUSDCBalance)
logger.Infof(" • Perpetuals equity: %.2f USDC (wallet %.2f + unrealized %.2f)",
accountValue,
walletBalanceWithoutUnrealized,
totalUnrealizedPnl)
logger.Infof(" • Perpetuals available balance: %.2f USDC", availableBalance)
logger.Infof(" • Margin used: %.2f USDC", totalMarginUsed)
logger.Infof(" • xyz dex equity: %.2f USDC (wallet %.2f + unrealized %.2f)",
xyzAccountValue,
xyzWalletBalance,
xyzUnrealizedPnl)
logger.Infof(" • Total assets (Perp+Spot+xyz): %.2f USDC", totalWalletBalance)
logger.Infof(" ⭐ Total: %.2f USDC | Perp: %.2f | Spot: %.2f | xyz: %.2f",
totalWalletBalance, availableBalance, spotUSDCBalance, xyzAccountValue)
return result, nil
}
// xyzDexState represents the clearinghouse state for xyz dex
type xyzDexState struct {
MarginSummary *xyzMarginSummary `json:"marginSummary,omitempty"`
CrossMarginSummary *xyzMarginSummary `json:"crossMarginSummary,omitempty"`
Withdrawable string `json:"withdrawable,omitempty"`
AssetPositions []xyzAssetPosition `json:"assetPositions,omitempty"`
}
type xyzMarginSummary struct {
AccountValue string `json:"accountValue"`
TotalMarginUsed string `json:"totalMarginUsed"`
}
type xyzAssetPosition struct {
Position struct {
Coin string `json:"coin"`
Szi string `json:"szi"`
EntryPx *string `json:"entryPx"`
PositionValue string `json:"positionValue"`
UnrealizedPnl string `json:"unrealizedPnl"`
LiquidationPx *string `json:"liquidationPx"`
Leverage struct {
Type string `json:"type"`
Value int `json:"value"`
} `json:"leverage"`
} `json:"position"`
}
// getXYZDexBalance queries the xyz dex balance (stock perps, forex, commodities)
func (t *HyperliquidTrader) getXYZDexBalance() (accountValue float64, unrealizedPnl float64, positions []xyzAssetPosition, err error) {
// Build request for xyz dex clearinghouse state
reqBody := map[string]interface{}{
"type": "clearinghouseState",
"user": t.walletAddr,
"dex": "xyz",
}
jsonBody, err := json.Marshal(reqBody)
if err != nil {
return 0, 0, nil, fmt.Errorf("failed to marshal request: %w", err)
}
// Determine API URL
apiURL := "https://api.hyperliquid.xyz/info"
// Note: xyz dex may not be available on testnet
req, err := http.NewRequestWithContext(t.ctx, "POST", apiURL, bytes.NewBuffer(jsonBody))
if err != nil {
return 0, 0, nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return 0, 0, nil, fmt.Errorf("failed to execute request: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return 0, 0, nil, fmt.Errorf("failed to read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return 0, 0, nil, fmt.Errorf("xyz dex API error (status %d): %s", resp.StatusCode, string(body))
}
var state xyzDexState
if err := json.Unmarshal(body, &state); err != nil {
return 0, 0, nil, fmt.Errorf("failed to parse response: %w", err)
}
// Parse account value - xyz dex uses MarginSummary for isolated margin mode
// CrossMarginSummary may exist but with 0 values, so check MarginSummary first
if state.MarginSummary != nil && state.MarginSummary.AccountValue != "" {
av, _ := strconv.ParseFloat(state.MarginSummary.AccountValue, 64)
if av > 0 {
accountValue = av
}
}
// Fallback to CrossMarginSummary if MarginSummary is 0
if accountValue == 0 && state.CrossMarginSummary != nil && state.CrossMarginSummary.AccountValue != "" {
accountValue, _ = strconv.ParseFloat(state.CrossMarginSummary.AccountValue, 64)
}
// Calculate total unrealized PnL from positions
for _, pos := range state.AssetPositions {
pnl, _ := strconv.ParseFloat(pos.Position.UnrealizedPnl, 64)
unrealizedPnl += pnl
}
return accountValue, unrealizedPnl, state.AssetPositions, nil
}
// GetMarketPrice gets market price (supports both crypto and xyz dex assets)
func (t *HyperliquidTrader) GetMarketPrice(symbol string) (float64, error) {
coin := convertSymbolToHyperliquid(symbol)
// Check if this is an xyz dex asset
if strings.HasPrefix(coin, "xyz:") {
return t.getXyzMarketPrice(coin)
}
// Get all market prices for crypto
allMids, err := t.exchange.Info().AllMids(t.ctx)
if err != nil {
return 0, fmt.Errorf("failed to get price: %w", err)
}
// Find price for corresponding coin (allMids is map[string]string)
if priceStr, ok := allMids[coin]; ok {
priceFloat, err := strconv.ParseFloat(priceStr, 64)
if err == nil {
return priceFloat, nil
}
return 0, fmt.Errorf("price format error: %v", err)
}
return 0, fmt.Errorf("price not found for %s", symbol)
}
// getXyzMarketPrice gets market price for xyz dex assets
func (t *HyperliquidTrader) getXyzMarketPrice(coin string) (float64, error) {
// Build request for xyz dex allMids
reqBody := map[string]string{
"type": "allMids",
"dex": "xyz",
}
jsonBody, err := json.Marshal(reqBody)
if err != nil {
return 0, fmt.Errorf("failed to marshal request: %w", err)
}
apiURL := "https://api.hyperliquid.xyz/info"
req, err := http.NewRequestWithContext(t.ctx, "POST", apiURL, bytes.NewBuffer(jsonBody))
if err != nil {
return 0, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return 0, fmt.Errorf("failed to execute request: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return 0, fmt.Errorf("failed to read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return 0, fmt.Errorf("xyz dex allMids API error (status %d): %s", resp.StatusCode, string(body))
}
var mids map[string]string
if err := json.Unmarshal(body, &mids); err != nil {
return 0, fmt.Errorf("failed to parse response: %w", err)
}
// The API returns keys with xyz: prefix, so ensure the coin has it
lookupKey := coin
if !strings.HasPrefix(lookupKey, "xyz:") {
lookupKey = "xyz:" + lookupKey
}
if priceStr, ok := mids[lookupKey]; ok {
priceFloat, err := strconv.ParseFloat(priceStr, 64)
if err == nil {
return priceFloat, nil
}
return 0, fmt.Errorf("price format error: %v", err)
}
return 0, fmt.Errorf("xyz dex price not found for %s (lookup key: %s)", coin, lookupKey)
}
// GetOrderStatus gets order status
// Hyperliquid uses IOC orders, usually filled or cancelled immediately
// For completed orders, need to query historical records
func (t *HyperliquidTrader) GetOrderStatus(symbol string, orderID string) (map[string]interface{}, error) {
// Hyperliquid's IOC orders are completed almost immediately
// If order was placed through this system, returned status will be FILLED
// Try to query open orders to determine if still pending
coin := convertSymbolToHyperliquid(symbol)
// First check if in open orders
openOrders, err := t.exchange.Info().OpenOrders(t.ctx, t.walletAddr)
if err != nil {
// If query fails, assume order is completed
return map[string]interface{}{
"orderId": orderID,
"status": "FILLED",
"avgPrice": 0.0,
"executedQty": 0.0,
"commission": 0.0,
}, nil
}
// Check if order is in open orders list
for _, order := range openOrders {
if order.Coin == coin && fmt.Sprintf("%d", order.Oid) == orderID {
// Order is still pending
return map[string]interface{}{
"orderId": orderID,
"status": "NEW",
"avgPrice": 0.0,
"executedQty": 0.0,
"commission": 0.0,
}, nil
}
}
// Order not in open list, meaning completed or cancelled
// Hyperliquid IOC orders not in open list are usually filled
return map[string]interface{}{
"orderId": orderID,
"status": "FILLED",
"avgPrice": 0.0, // Hyperliquid does not directly return execution price, need to get from position info
"executedQty": 0.0,
"commission": 0.0,
}, nil
}
// GetClosedPnL gets recent closing trades from Hyperliquid
// Note: Hyperliquid does NOT have a position history API, only fill history.
// This returns individual closing trades for real-time position closure detection.
func (t *HyperliquidTrader) GetClosedPnL(startTime time.Time, limit int) ([]types.ClosedPnLRecord, error) {
trades, err := t.GetTrades(startTime, limit)
if err != nil {
return nil, err
}
// Filter only closing trades (realizedPnl != 0)
var records []types.ClosedPnLRecord
for _, trade := range trades {
if trade.RealizedPnL == 0 {
continue
}
// Determine side (Hyperliquid uses one-way mode)
side := "long"
if trade.Side == "SELL" || trade.Side == "Sell" {
side = "long" // Selling closes long
} else {
side = "short" // Buying closes short
}
// Calculate entry price from PnL
var entryPrice float64
if trade.Quantity > 0 {
if side == "long" {
entryPrice = trade.Price - trade.RealizedPnL/trade.Quantity
} else {
entryPrice = trade.Price + trade.RealizedPnL/trade.Quantity
}
}
records = append(records, types.ClosedPnLRecord{
Symbol: trade.Symbol,
Side: side,
EntryPrice: entryPrice,
ExitPrice: trade.Price,
Quantity: trade.Quantity,
RealizedPnL: trade.RealizedPnL,
Fee: trade.Fee,
ExitTime: trade.Time,
EntryTime: trade.Time,
OrderID: trade.TradeID,
ExchangeID: trade.TradeID,
CloseType: "unknown",
})
}
return records, nil
}
// GetTrades retrieves trade history from Hyperliquid
func (t *HyperliquidTrader) GetTrades(startTime time.Time, limit int) ([]types.TradeRecord, error) {
// Use UserFillsByTime API
startTimeMs := startTime.UnixMilli()
fills, err := t.exchange.Info().UserFillsByTime(t.ctx, t.walletAddr, startTimeMs, nil, nil)
if err != nil {
return nil, fmt.Errorf("failed to get user fills: %w", err)
}
var trades []types.TradeRecord
for _, fill := range fills {
price, _ := strconv.ParseFloat(fill.Price, 64)
qty, _ := strconv.ParseFloat(fill.Size, 64)
fee, _ := strconv.ParseFloat(fill.Fee, 64)
pnl, _ := strconv.ParseFloat(fill.ClosedPnl, 64)
// Determine side: "B" = Buy, "S" = Sell (or "A" = Ask, "B" = Bid)
var side string
if fill.Side == "B" || fill.Side == "Buy" || fill.Side == "bid" {
side = "BUY"
} else {
side = "SELL"
}
// Parse Dir field to get order action
// Hyperliquid Dir values: "Open Long", "Open Short", "Close Long", "Close Short"
var orderAction string
switch strings.ToLower(fill.Dir) {
case "open long":
orderAction = "open_long"
case "open short":
orderAction = "open_short"
case "close long":
orderAction = "close_long"
case "close short":
orderAction = "close_short"
default:
// Fallback: use RealizedPnL if Dir is missing/unknown
if pnl != 0 {
if side == "BUY" {
orderAction = "close_short"
} else {
orderAction = "close_long"
}
} else {
if side == "BUY" {
orderAction = "open_long"
} else {
orderAction = "open_short"
}
}
}
// Hyperliquid uses one-way mode, so PositionSide is "BOTH"
trade := types.TradeRecord{
TradeID: strconv.FormatInt(fill.Tid, 10),
Symbol: fill.Coin,
Side: side,
PositionSide: "BOTH", // Hyperliquid doesn't have hedge mode
OrderAction: orderAction,
Price: price,
Quantity: qty,
RealizedPnL: pnl,
Fee: fee,
Time: time.UnixMilli(fill.Time).UTC(),
}
trades = append(trades, trade)
}
return trades, nil
}
// GetOpenOrders gets all open/pending orders for a symbol
func (t *HyperliquidTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, error) {
openOrders, err := t.exchange.Info().OpenOrders(t.ctx, t.walletAddr)
if err != nil {
return nil, fmt.Errorf("failed to get open orders: %w", err)
}
var result []types.OpenOrder
for _, order := range openOrders {
if order.Coin != symbol {
continue
}
side := "BUY"
if order.Side == "A" {
side = "SELL"
}
result = append(result, types.OpenOrder{
OrderID: fmt.Sprintf("%d", order.Oid),
Symbol: order.Coin,
Side: side,
PositionSide: "",
Type: "LIMIT",
Price: order.LimitPx,
StopPrice: 0,
Quantity: order.Size,
Status: "NEW",
})
}
return result, nil
}
// GetOrderBook gets the order book for a symbol
// Implements GridTrader interface
func (t *HyperliquidTrader) GetOrderBook(symbol string, depth int) (bids, asks [][]float64, err error) {
coin := convertSymbolToHyperliquid(symbol)
l2Book, err := t.exchange.Info().L2Snapshot(t.ctx, coin)
if err != nil {
return nil, nil, fmt.Errorf("failed to get order book: %w", err)
}
if l2Book == nil || len(l2Book.Levels) < 2 {
return nil, nil, fmt.Errorf("invalid order book data")
}
// Parse bids (first level array)
for i, level := range l2Book.Levels[0] {
if i >= depth {
break
}
bids = append(bids, []float64{level.Px, level.Sz})
}
// Parse asks (second level array)
for i, level := range l2Book.Levels[1] {
if i >= depth {
break
}
asks = append(asks, []float64{level.Px, level.Sz})
}
return bids, asks, nil
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,167 @@
package hyperliquid
import (
"fmt"
"nofx/logger"
"strconv"
"strings"
)
// GetPositions gets all positions (including xyz dex positions)
func (t *HyperliquidTrader) GetPositions() ([]map[string]interface{}, error) {
// Get account status
accountState, err := t.exchange.Info().UserState(t.ctx, t.walletAddr)
if err != nil {
return nil, fmt.Errorf("failed to get positions: %w", err)
}
var result []map[string]interface{}
// Iterate through all perp positions
for _, assetPos := range accountState.AssetPositions {
position := assetPos.Position
// Position amount (string type)
posAmt, _ := strconv.ParseFloat(position.Szi, 64)
if posAmt == 0 {
continue // Skip positions with zero amount
}
posMap := make(map[string]interface{})
// Normalize symbol format (Hyperliquid uses "BTC", we convert to "BTCUSDT")
symbol := position.Coin + "USDT"
posMap["symbol"] = symbol
// Position amount and direction
if posAmt > 0 {
posMap["side"] = "long"
posMap["positionAmt"] = posAmt
} else {
posMap["side"] = "short"
posMap["positionAmt"] = -posAmt // Convert to positive number
}
// Price information (EntryPx and LiquidationPx are pointer types)
var entryPrice, liquidationPx float64
if position.EntryPx != nil {
entryPrice, _ = strconv.ParseFloat(*position.EntryPx, 64)
}
if position.LiquidationPx != nil {
liquidationPx, _ = strconv.ParseFloat(*position.LiquidationPx, 64)
}
positionValue, _ := strconv.ParseFloat(position.PositionValue, 64)
unrealizedPnl, _ := strconv.ParseFloat(position.UnrealizedPnl, 64)
// Calculate mark price (positionValue / abs(posAmt))
var markPrice float64
if posAmt != 0 {
markPrice = positionValue / absFloat(posAmt)
}
posMap["entryPrice"] = entryPrice
posMap["markPrice"] = markPrice
posMap["unRealizedProfit"] = unrealizedPnl
posMap["leverage"] = float64(position.Leverage.Value)
posMap["liquidationPrice"] = liquidationPx
result = append(result, posMap)
}
// Also get xyz dex positions (stocks, forex, commodities)
_, _, xyzPositions, err := t.getXYZDexBalance()
if err != nil {
// xyz dex query failed - log warning but don't fail
logger.Infof("⚠️ Failed to get xyz dex positions: %v", err)
} else {
for _, pos := range xyzPositions {
posAmt, _ := strconv.ParseFloat(pos.Position.Szi, 64)
if posAmt == 0 {
continue
}
posMap := make(map[string]interface{})
// xyz dex positions - the API returns coin names with xyz: prefix (e.g., "xyz:SILVER")
// Only add prefix if not already present
symbol := pos.Position.Coin
if !strings.HasPrefix(symbol, "xyz:") {
symbol = "xyz:" + symbol
}
posMap["symbol"] = symbol
if posAmt > 0 {
posMap["side"] = "long"
posMap["positionAmt"] = posAmt
} else {
posMap["side"] = "short"
posMap["positionAmt"] = -posAmt
}
// Parse price information
var entryPrice, liquidationPx float64
if pos.Position.EntryPx != nil {
entryPrice, _ = strconv.ParseFloat(*pos.Position.EntryPx, 64)
}
if pos.Position.LiquidationPx != nil {
liquidationPx, _ = strconv.ParseFloat(*pos.Position.LiquidationPx, 64)
}
positionValue, _ := strconv.ParseFloat(pos.Position.PositionValue, 64)
unrealizedPnl, _ := strconv.ParseFloat(pos.Position.UnrealizedPnl, 64)
// Calculate mark price from position value
var markPrice float64
if posAmt != 0 {
markPrice = positionValue / absFloat(posAmt)
}
// Get leverage (default to 1 if not available)
leverage := float64(pos.Position.Leverage.Value)
if leverage == 0 {
leverage = 1.0
}
posMap["entryPrice"] = entryPrice
posMap["markPrice"] = markPrice
posMap["unRealizedProfit"] = unrealizedPnl
posMap["leverage"] = leverage
posMap["liquidationPrice"] = liquidationPx
posMap["isXyzDex"] = true // Mark as xyz dex position
result = append(result, posMap)
}
}
return result, nil
}
// SetMarginMode sets margin mode (set together with SetLeverage)
func (t *HyperliquidTrader) SetMarginMode(symbol string, isCrossMargin bool) error {
// Hyperliquid's margin mode is set in SetLeverage, only record here
t.isCrossMargin = isCrossMargin
marginModeStr := "cross margin"
if !isCrossMargin {
marginModeStr = "isolated margin"
}
logger.Infof(" ✓ %s will use %s mode", symbol, marginModeStr)
return nil
}
// SetLeverage sets leverage
func (t *HyperliquidTrader) SetLeverage(symbol string, leverage int) error {
// Hyperliquid symbol format (remove USDT suffix)
coin := convertSymbolToHyperliquid(symbol)
// Call UpdateLeverage (leverage int, name string, isCross bool)
// Third parameter: true=cross margin mode, false=isolated margin mode
_, err := t.exchange.UpdateLeverage(t.ctx, leverage, coin, t.isCrossMargin)
if err != nil {
return fmt.Errorf("failed to set leverage: %w", err)
}
logger.Infof(" ✓ %s leverage switched to %dx", symbol, leverage)
return nil
}

View File

@@ -0,0 +1,147 @@
package hyperliquid
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"nofx/logger"
"strings"
"time"
)
// refreshMetaIfNeeded refreshes meta information when invalid (triggered when Asset ID is 0)
func (t *HyperliquidTrader) refreshMetaIfNeeded(coin string) error {
assetID := t.exchange.Info().NameToAsset(coin)
if assetID != 0 {
return nil // Meta is normal, no refresh needed
}
logger.Infof("⚠️ Asset ID for %s is 0, attempting to refresh Meta information...", coin)
// Refresh Meta information
meta, err := t.exchange.Info().Meta(t.ctx)
if err != nil {
return fmt.Errorf("failed to refresh Meta information: %w", err)
}
// Concurrency safe: Use write lock to protect meta field update
t.metaMutex.Lock()
t.meta = meta
t.metaMutex.Unlock()
logger.Infof("✅ Meta information refreshed, contains %d assets", len(meta.Universe))
// Verify Asset ID after refresh
assetID = t.exchange.Info().NameToAsset(coin)
if assetID == 0 {
return fmt.Errorf("❌ Even after refreshing Meta, Asset ID for %s is still 0. Possible reasons:\n"+
" 1. This coin is not listed on Hyperliquid\n"+
" 2. Coin name is incorrect (should be BTC not BTCUSDT)\n"+
" 3. API connection issue", coin)
}
logger.Infof("✅ Asset ID check passed after refresh: %s -> %d", coin, assetID)
return nil
}
// fetchXyzMeta fetches metadata for xyz dex assets (stocks, forex, commodities)
func (t *HyperliquidTrader) fetchXyzMeta() error {
// Build request for xyz dex meta
reqBody := map[string]string{
"type": "meta",
"dex": "xyz",
}
jsonBody, err := json.Marshal(reqBody)
if err != nil {
return fmt.Errorf("failed to marshal request: %w", err)
}
apiURL := "https://api.hyperliquid.xyz/info"
req, err := http.NewRequestWithContext(t.ctx, "POST", apiURL, bytes.NewBuffer(jsonBody))
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("failed to execute request: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("failed to read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("xyz dex meta API error (status %d): %s", resp.StatusCode, string(body))
}
var meta xyzDexMeta
if err := json.Unmarshal(body, &meta); err != nil {
return fmt.Errorf("failed to parse response: %w", err)
}
t.xyzMetaMutex.Lock()
t.xyzMeta = &meta
t.xyzMetaMutex.Unlock()
logger.Infof("✅ xyz dex meta fetched, contains %d assets", len(meta.Universe))
return nil
}
// getXyzSzDecimals gets quantity precision for xyz dex asset
func (t *HyperliquidTrader) getXyzSzDecimals(coin string) int {
t.xyzMetaMutex.RLock()
defer t.xyzMetaMutex.RUnlock()
if t.xyzMeta == nil {
logger.Infof("⚠️ xyz meta information is empty, using default precision 2")
return 2 // Default precision for stocks/forex
}
// The meta API returns names with xyz: prefix, so ensure we match correctly
lookupName := coin
if !strings.HasPrefix(lookupName, "xyz:") {
lookupName = "xyz:" + lookupName
}
// Find corresponding asset in xyzMeta.Universe
for _, asset := range t.xyzMeta.Universe {
if asset.Name == lookupName {
return asset.SzDecimals
}
}
logger.Infof("⚠️ Precision information not found for %s, using default precision 2", lookupName)
return 2 // Default precision for stocks/forex
}
// getXyzAssetIndex gets the asset index for an xyz dex asset
func (t *HyperliquidTrader) getXyzAssetIndex(baseCoin string) int {
t.xyzMetaMutex.RLock()
defer t.xyzMetaMutex.RUnlock()
if t.xyzMeta == nil {
return -1
}
// The meta API returns names with xyz: prefix, so ensure we match correctly
lookupName := baseCoin
if !strings.HasPrefix(lookupName, "xyz:") {
lookupName = "xyz:" + lookupName
}
for i, asset := range t.xyzMeta.Universe {
if asset.Name == lookupName {
return i
}
}
return -1
}

View File

@@ -1,648 +0,0 @@
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))
})
}
}

View File

@@ -1,669 +0,0 @@
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)
}
}