Files
nofx/trader/hyperliquid_trader.go
T
tinkle-community 47bff87966 feat: add xyz dex balance calculation, market data providers, and UI improvements
- Fix xyz dex balance calculation (use marginSummary for isolated margin)
- Add Alpaca provider for US stocks market data
- Add TwelveData provider for forex & metals market data
- Add Hyperliquid kline provider
- Centralize API keys in config system
- Add builder fee for order routing
- Improve chart UI with compact design
- Fix position history fee display precision
- Add comprehensive balance calculation tests
2025-12-29 22:16:48 +08:00

1748 lines
57 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package trader
import (
"bytes"
"context"
"crypto/ecdsa"
"encoding/json"
"fmt"
"io"
"net/http"
"nofx/logger"
"strconv"
"strings"
"sync"
"time"
"github.com/ethereum/go-ethereum/crypto"
"github.com/sonirico/go-hyperliquid"
)
// HyperliquidTrader Hyperliquid trader
type HyperliquidTrader struct {
exchange *hyperliquid.Exchange
ctx context.Context
walletAddr string
meta *hyperliquid.Meta // Cache meta information (including precision)
metaMutex sync.RWMutex // Protect concurrent access to meta field
isCrossMargin bool // Whether to use cross margin mode
// xyz dex support (stocks, forex, commodities)
xyzMeta *xyzDexMeta
xyzMetaMutex sync.RWMutex
privateKey *ecdsa.PrivateKey // For xyz dex signing
isTestnet bool
}
// xyzDexMeta represents metadata for xyz dex assets
type xyzDexMeta struct {
Universe []xyzAssetInfo `json:"universe"`
}
// xyzAssetInfo represents info for a single xyz dex asset
type xyzAssetInfo struct {
Name string `json:"name"`
SzDecimals int `json:"szDecimals"`
MaxLeverage int `json:"maxLeverage"`
}
// xyz dex assets (stocks, forex, commodities, index)
// Updated based on actual available assets from xyz dex API
var xyzDexAssets = map[string]bool{
// Stocks (US equities perpetuals)
"TSLA": true, "NVDA": true, "AAPL": true, "MSFT": true, "META": true,
"AMZN": true, "GOOGL": true, "AMD": true, "COIN": true, "NFLX": true,
"PLTR": true, "HOOD": true, "INTC": true, "MSTR": true, "TSM": true,
"ORCL": true, "MU": true, "RIVN": true, "COST": true, "LLY": true,
"CRCL": true, "SKHX": true, "SNDK": true,
// Forex (currency pairs)
"EUR": true, "JPY": true,
// Commodities (precious metals)
"GOLD": true, "SILVER": true,
// Index
"XYZ100": true,
}
// isXyzDexAsset checks if a symbol is an xyz dex asset
func isXyzDexAsset(symbol string) bool {
// Remove common suffixes to get base symbol
base := strings.ToUpper(symbol) // Convert to uppercase for case-insensitive matching
for _, suffix := range []string{"USDT", "USD", "-USDC", "-USD"} {
if strings.HasSuffix(base, suffix) {
base = strings.TrimSuffix(base, suffix)
break
}
}
// Remove xyz: prefix if present (case-insensitive)
base = strings.TrimPrefix(base, "XYZ:")
base = strings.TrimPrefix(base, "xyz:")
return xyzDexAssets[base]
}
// NewHyperliquidTrader creates a Hyperliquid trader
func NewHyperliquidTrader(privateKeyHex string, walletAddr string, testnet bool) (*HyperliquidTrader, error) {
// Remove 0x prefix from private key (if present, case-insensitive)
privateKeyHex = strings.TrimPrefix(strings.ToLower(privateKeyHex), "0x")
// Parse private key
privateKey, err := crypto.HexToECDSA(privateKeyHex)
if err != nil {
return nil, fmt.Errorf("failed to parse private key: %w", err)
}
// Select API URL
apiURL := hyperliquid.MainnetAPIURL
if testnet {
apiURL = hyperliquid.TestnetAPIURL
}
// Security enhancement: Implement Agent Wallet best practices
// Reference: https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/nonces-and-api-wallets
agentAddr := crypto.PubkeyToAddress(*privateKey.Public().(*ecdsa.PublicKey)).Hex()
if walletAddr == "" {
return nil, fmt.Errorf("❌ Configuration error: Main wallet address (hyperliquid_wallet_addr) not provided\n" +
"🔐 Correct configuration pattern:\n" +
" 1. hyperliquid_private_key = Agent Private Key (for signing only, balance should be ~0)\n" +
" 2. hyperliquid_wallet_addr = Main Wallet Address (holds funds, never expose private key)\n" +
"💡 Please create an Agent Wallet on Hyperliquid official website and authorize it before configuration:\n" +
" https://app.hyperliquid.xyz/ → Settings → API Wallets")
}
// Check if user accidentally uses main wallet private key (security risk)
if strings.EqualFold(walletAddr, agentAddr) {
logger.Infof("⚠️⚠️⚠️ WARNING: Main wallet address (%s) matches Agent wallet address!", walletAddr)
logger.Infof(" This indicates you may be using your main wallet private key, which poses extremely high security risks!")
logger.Infof(" Recommendation: Immediately create a separate Agent Wallet on Hyperliquid official website")
logger.Infof(" Reference: https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/nonces-and-api-wallets")
} else {
logger.Infof("✓ Using Agent Wallet mode (secure)")
logger.Infof(" └─ Agent wallet address: %s (for signing)", agentAddr)
logger.Infof(" └─ Main wallet address: %s (holds funds)", walletAddr)
}
ctx := context.Background()
// Create Exchange client (Exchange includes Info functionality)
exchange := hyperliquid.NewExchange(
ctx,
privateKey,
apiURL,
nil, // Meta will be fetched automatically
"", // vault address (empty for personal account)
walletAddr, // wallet address
nil, // SpotMeta will be fetched automatically
)
logger.Infof("✓ Hyperliquid trader initialized successfully (testnet=%v, wallet=%s)", testnet, walletAddr)
// Get meta information (including precision and other configurations)
meta, err := exchange.Info().Meta(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get meta information: %w", err)
}
// 🔍 Security check: Validate Agent wallet balance (should be close to 0)
// Only check if using separate Agent wallet (not when main wallet is used as agent)
if !strings.EqualFold(walletAddr, agentAddr) {
agentState, err := exchange.Info().UserState(ctx, agentAddr)
if err == nil && agentState != nil && agentState.CrossMarginSummary.AccountValue != "" {
// Parse Agent wallet balance
agentBalance, _ := strconv.ParseFloat(agentState.CrossMarginSummary.AccountValue, 64)
if agentBalance > 100 {
// Critical: Agent wallet holds too much funds
logger.Infof("🚨🚨🚨 CRITICAL SECURITY WARNING 🚨🚨🚨")
logger.Infof(" Agent wallet balance: %.2f USDC (exceeds safe threshold of 100 USDC)", agentBalance)
logger.Infof(" Agent wallet address: %s", agentAddr)
logger.Infof(" ⚠️ Agent wallets should only be used for signing and hold minimal/zero balance")
logger.Infof(" ⚠️ High balance in Agent wallet poses security risks")
logger.Infof(" 📖 Reference: https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/nonces-and-api-wallets")
logger.Infof(" 💡 Recommendation: Transfer funds to main wallet and keep Agent wallet balance near 0")
return nil, fmt.Errorf("security check failed: Agent wallet balance too high (%.2f USDC), exceeds 100 USDC threshold", agentBalance)
} else if agentBalance > 10 {
// Warning: Agent wallet has some balance (acceptable but not ideal)
logger.Infof("⚠️ Notice: Agent wallet address (%s) has some balance: %.2f USDC", agentAddr, agentBalance)
logger.Infof(" While not critical, it's recommended to keep Agent wallet balance near 0 for security")
} else {
// OK: Agent wallet balance is safe
logger.Infof("✓ Agent wallet balance is safe: %.2f USDC (near zero as recommended)", agentBalance)
}
} else if err != nil {
// Failed to query agent balance - log warning but don't block initialization
logger.Infof("⚠️ Could not verify Agent wallet balance (query failed): %v", err)
logger.Infof(" Proceeding with initialization, but please manually verify Agent wallet balance is near 0")
}
}
return &HyperliquidTrader{
exchange: exchange,
ctx: ctx,
walletAddr: walletAddr,
meta: meta,
isCrossMargin: true, // Use cross margin mode by default
privateKey: privateKey,
isTestnet: testnet,
}, nil
}
// 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_trader.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
result["totalWalletBalance"] = totalWalletBalance // Total assets (Perp + Spot + xyz) - unrealized
result["totalEquity"] = totalEquityCalculated // Total equity = Perp AV + Spot + xyz AV
result["availableBalance"] = availableBalance // Available balance (Perpetuals only)
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
}
// 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
}
// 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
}
// 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
}
// OpenLong opens a long position (supports both crypto and xyz dex)
func (t *HyperliquidTrader) OpenLong(symbol string, quantity float64, leverage int) (map[string]interface{}, error) {
// First cancel all pending orders for this coin
if err := t.CancelAllOrders(symbol); err != nil {
logger.Infof(" ⚠ Failed to cancel old pending orders: %v", err)
}
// Hyperliquid symbol format
coin := convertSymbolToHyperliquid(symbol)
// Check if this is an xyz dex asset
isXyz := strings.HasPrefix(coin, "xyz:")
// Set leverage (skip for xyz dex as it may not support leverage adjustment)
if !isXyz {
if err := t.SetLeverage(symbol, leverage); err != nil {
return nil, err
}
} else {
logger.Infof(" xyz dex asset %s - using default leverage", coin)
}
// Get current price (for market order)
price, err := t.GetMarketPrice(symbol)
if err != nil {
return nil, err
}
// ⚠️ Critical: Price needs to be processed to 5 significant figures
aggressivePrice := t.roundPriceToSigfigs(price * 1.01)
logger.Infof(" 💰 Price precision handling: %.8f -> %.8f (5 significant figures)", price*1.01, aggressivePrice)
// Handle xyz dex assets differently
if isXyz {
// xyz dex order
if err := t.placeXyzOrder(coin, true, quantity, aggressivePrice, false); err != nil {
return nil, fmt.Errorf("failed to open long position on xyz dex: %w", err)
}
} else {
// Standard crypto order
roundedQuantity := t.roundToSzDecimals(coin, quantity)
logger.Infof(" 📏 Quantity precision handling: %.8f -> %.8f (szDecimals=%d)", quantity, roundedQuantity, t.getSzDecimals(coin))
order := hyperliquid.CreateOrderRequest{
Coin: coin,
IsBuy: true,
Size: roundedQuantity,
Price: aggressivePrice,
OrderType: hyperliquid.OrderType{
Limit: &hyperliquid.LimitOrderType{
Tif: hyperliquid.TifIoc,
},
},
ReduceOnly: false,
}
_, err = t.exchange.Order(t.ctx, order, defaultBuilder)
if err != nil {
return nil, fmt.Errorf("failed to open long position: %w", err)
}
}
logger.Infof("✓ Long position opened successfully: %s quantity: %.4f", symbol, quantity)
result := make(map[string]interface{})
result["orderId"] = 0
result["symbol"] = symbol
result["status"] = "FILLED"
return result, nil
}
// OpenShort opens a short position (supports both crypto and xyz dex)
func (t *HyperliquidTrader) OpenShort(symbol string, quantity float64, leverage int) (map[string]interface{}, error) {
// First cancel all pending orders for this coin
if err := t.CancelAllOrders(symbol); err != nil {
logger.Infof(" ⚠ Failed to cancel old pending orders: %v", err)
}
// Hyperliquid symbol format
coin := convertSymbolToHyperliquid(symbol)
// Check if this is an xyz dex asset
isXyz := strings.HasPrefix(coin, "xyz:")
// Set leverage (skip for xyz dex)
if !isXyz {
if err := t.SetLeverage(symbol, leverage); err != nil {
return nil, err
}
} else {
logger.Infof(" xyz dex asset %s - using default leverage", coin)
}
// Get current price
price, err := t.GetMarketPrice(symbol)
if err != nil {
return nil, err
}
// ⚠️ Critical: Price needs to be processed to 5 significant figures
aggressivePrice := t.roundPriceToSigfigs(price * 0.99)
logger.Infof(" 💰 Price precision handling: %.8f -> %.8f (5 significant figures)", price*0.99, aggressivePrice)
// Handle xyz dex assets differently
if isXyz {
// xyz dex order
if err := t.placeXyzOrder(coin, false, quantity, aggressivePrice, false); err != nil {
return nil, fmt.Errorf("failed to open short position on xyz dex: %w", err)
}
} else {
// Standard crypto order
roundedQuantity := t.roundToSzDecimals(coin, quantity)
logger.Infof(" 📏 Quantity precision handling: %.8f -> %.8f (szDecimals=%d)", quantity, roundedQuantity, t.getSzDecimals(coin))
order := hyperliquid.CreateOrderRequest{
Coin: coin,
IsBuy: false,
Size: roundedQuantity,
Price: aggressivePrice,
OrderType: hyperliquid.OrderType{
Limit: &hyperliquid.LimitOrderType{
Tif: hyperliquid.TifIoc,
},
},
ReduceOnly: false,
}
_, err = t.exchange.Order(t.ctx, order, defaultBuilder)
if err != nil {
return nil, fmt.Errorf("failed to open short position: %w", err)
}
}
logger.Infof("✓ Short position opened successfully: %s quantity: %.4f", symbol, quantity)
result := make(map[string]interface{})
result["orderId"] = 0
result["symbol"] = symbol
result["status"] = "FILLED"
return result, nil
}
// CloseLong closes a long position (supports both crypto and xyz dex)
func (t *HyperliquidTrader) CloseLong(symbol string, quantity float64) (map[string]interface{}, error) {
// Hyperliquid symbol format
coin := convertSymbolToHyperliquid(symbol)
isXyz := strings.HasPrefix(coin, "xyz:")
// If quantity is 0, get current position quantity
if quantity == 0 {
positions, err := t.GetPositions()
if err != nil {
return nil, err
}
// For xyz dex, also check xyz: prefixed symbols
searchSymbol := symbol
if isXyz {
searchSymbol = coin // Use xyz:SYMBOL format for comparison
}
for _, pos := range positions {
posSymbol := pos["symbol"].(string)
if (posSymbol == symbol || posSymbol == searchSymbol) && pos["side"] == "long" {
quantity = pos["positionAmt"].(float64)
break
}
}
if quantity == 0 {
return nil, fmt.Errorf("no long position found for %s", symbol)
}
}
// Get current price
price, err := t.GetMarketPrice(symbol)
if err != nil {
return nil, err
}
// ⚠️ Critical: Price needs to be processed to 5 significant figures
aggressivePrice := t.roundPriceToSigfigs(price * 0.99)
logger.Infof(" 💰 Price precision handling: %.8f -> %.8f (5 significant figures)", price*0.99, aggressivePrice)
// Handle xyz dex assets differently
if isXyz {
// xyz dex close order
if err := t.placeXyzOrder(coin, false, quantity, aggressivePrice, true); err != nil {
return nil, fmt.Errorf("failed to close long position on xyz dex: %w", err)
}
} else {
// Standard crypto close order
roundedQuantity := t.roundToSzDecimals(coin, quantity)
logger.Infof(" 📏 Quantity precision handling: %.8f -> %.8f (szDecimals=%d)", quantity, roundedQuantity, t.getSzDecimals(coin))
order := hyperliquid.CreateOrderRequest{
Coin: coin,
IsBuy: false,
Size: roundedQuantity,
Price: aggressivePrice,
OrderType: hyperliquid.OrderType{
Limit: &hyperliquid.LimitOrderType{
Tif: hyperliquid.TifIoc,
},
},
ReduceOnly: true,
}
_, err = t.exchange.Order(t.ctx, order, defaultBuilder)
if err != nil {
return nil, fmt.Errorf("failed to close long position: %w", err)
}
}
logger.Infof("✓ Long position closed successfully: %s quantity: %.4f", symbol, quantity)
// Cancel all pending orders for this coin after closing position
if err := t.CancelAllOrders(symbol); err != nil {
logger.Infof(" ⚠ Failed to cancel pending orders: %v", err)
}
result := make(map[string]interface{})
result["orderId"] = 0
result["symbol"] = symbol
result["status"] = "FILLED"
return result, nil
}
// CloseShort closes a short position (supports both crypto and xyz dex)
func (t *HyperliquidTrader) CloseShort(symbol string, quantity float64) (map[string]interface{}, error) {
// Hyperliquid symbol format
coin := convertSymbolToHyperliquid(symbol)
isXyz := strings.HasPrefix(coin, "xyz:")
// If quantity is 0, get current position quantity
if quantity == 0 {
positions, err := t.GetPositions()
if err != nil {
return nil, err
}
// For xyz dex, also check xyz: prefixed symbols
searchSymbol := symbol
if isXyz {
searchSymbol = coin
}
for _, pos := range positions {
posSymbol := pos["symbol"].(string)
if (posSymbol == symbol || posSymbol == searchSymbol) && pos["side"] == "short" {
quantity = pos["positionAmt"].(float64)
break
}
}
if quantity == 0 {
return nil, fmt.Errorf("no short position found for %s", symbol)
}
}
// Get current price
price, err := t.GetMarketPrice(symbol)
if err != nil {
return nil, err
}
// ⚠️ Critical: Price needs to be processed to 5 significant figures
aggressivePrice := t.roundPriceToSigfigs(price * 1.01)
logger.Infof(" 💰 Price precision handling: %.8f -> %.8f (5 significant figures)", price*1.01, aggressivePrice)
// Handle xyz dex assets differently
if isXyz {
// xyz dex close order
if err := t.placeXyzOrder(coin, true, quantity, aggressivePrice, true); err != nil {
return nil, fmt.Errorf("failed to close short position on xyz dex: %w", err)
}
} else {
// Standard crypto close order
roundedQuantity := t.roundToSzDecimals(coin, quantity)
logger.Infof(" 📏 Quantity precision handling: %.8f -> %.8f (szDecimals=%d)", quantity, roundedQuantity, t.getSzDecimals(coin))
order := hyperliquid.CreateOrderRequest{
Coin: coin,
IsBuy: true,
Size: roundedQuantity,
Price: aggressivePrice,
OrderType: hyperliquid.OrderType{
Limit: &hyperliquid.LimitOrderType{
Tif: hyperliquid.TifIoc,
},
},
ReduceOnly: true,
}
_, err = t.exchange.Order(t.ctx, order, defaultBuilder)
if err != nil {
return nil, fmt.Errorf("failed to close short position: %w", err)
}
}
logger.Infof("✓ Short position closed successfully: %s quantity: %.4f", symbol, quantity)
// Cancel all pending orders for this coin after closing position
if err := t.CancelAllOrders(symbol); err != nil {
logger.Infof(" ⚠ Failed to cancel pending orders: %v", err)
}
result := make(map[string]interface{})
result["orderId"] = 0
result["symbol"] = symbol
result["status"] = "FILLED"
return result, nil
}
// CancelStopLossOrders only cancels stop loss orders (Hyperliquid cannot distinguish stop loss and take profit, cancel all)
func (t *HyperliquidTrader) CancelStopLossOrders(symbol string) error {
// Hyperliquid SDK's OpenOrder structure does not expose trigger field
// Cannot distinguish stop loss and take profit orders, so cancel all pending orders for this coin
logger.Infof(" ⚠️ Hyperliquid cannot distinguish stop loss/take profit orders, will cancel all pending orders")
return t.CancelStopOrders(symbol)
}
// CancelTakeProfitOrders only cancels take profit orders (Hyperliquid cannot distinguish stop loss and take profit, cancel all)
func (t *HyperliquidTrader) CancelTakeProfitOrders(symbol string) error {
// Hyperliquid SDK's OpenOrder structure does not expose trigger field
// Cannot distinguish stop loss and take profit orders, so cancel all pending orders for this coin
logger.Infof(" ⚠️ Hyperliquid cannot distinguish stop loss/take profit orders, will cancel all pending orders")
return t.CancelStopOrders(symbol)
}
// CancelAllOrders cancels all pending orders for this coin
func (t *HyperliquidTrader) CancelAllOrders(symbol string) error {
coin := convertSymbolToHyperliquid(symbol)
// Get all pending orders
openOrders, err := t.exchange.Info().OpenOrders(t.ctx, t.walletAddr)
if err != nil {
return fmt.Errorf("failed to get pending orders: %w", err)
}
// Cancel all pending orders for this coin
for _, order := range openOrders {
if order.Coin == coin {
_, err := t.exchange.Cancel(t.ctx, coin, order.Oid)
if err != nil {
logger.Infof(" ⚠ Failed to cancel order (oid=%d): %v", order.Oid, err)
}
}
}
logger.Infof(" ✓ Cancelled all pending orders for %s", symbol)
return nil
}
// CancelStopOrders cancels take profit/stop loss orders for this coin (used to adjust TP/SL positions)
func (t *HyperliquidTrader) CancelStopOrders(symbol string) error {
coin := convertSymbolToHyperliquid(symbol)
// Get all pending orders
openOrders, err := t.exchange.Info().OpenOrders(t.ctx, t.walletAddr)
if err != nil {
return fmt.Errorf("failed to get pending orders: %w", err)
}
// Note: Hyperliquid SDK's OpenOrder structure does not expose trigger field
// Therefore temporarily cancel all pending orders for this coin (including TP/SL orders)
// This is safe because all old orders should be cleaned up before setting new TP/SL
canceledCount := 0
for _, order := range openOrders {
if order.Coin == coin {
_, err := t.exchange.Cancel(t.ctx, coin, order.Oid)
if err != nil {
logger.Infof(" ⚠ Failed to cancel order (oid=%d): %v", order.Oid, err)
continue
}
canceledCount++
}
}
if canceledCount == 0 {
logger.Infof(" No pending orders to cancel for %s", symbol)
} else {
logger.Infof(" ✓ Cancelled %d pending orders for %s (including TP/SL orders)", canceledCount, symbol)
}
return 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)
}
// floatToWireStr converts a float to wire format string (8 decimal places, trimmed zeros)
// This matches the SDK's floatToWire function
func floatToWireStr(x float64) string {
// Format to 8 decimal places
result := fmt.Sprintf("%.8f", x)
// Remove trailing zeros
result = strings.TrimRight(result, "0")
// Remove trailing decimal point if no decimals left
result = strings.TrimRight(result, ".")
return result
}
// placeXyzOrder places an order on the xyz dex (stocks, forex, commodities)
// Note: xyz dex orders use builder-deployed perpetuals and require different handling
// xyz dex asset indices start from 10000 (10000 + meta_index)
// This implementation bypasses the SDK's NameToAsset lookup and directly constructs the order
func (t *HyperliquidTrader) placeXyzOrder(coin string, isBuy bool, size float64, price float64, reduceOnly bool) error {
// Fetch xyz meta if not cached
t.xyzMetaMutex.RLock()
hasMeta := t.xyzMeta != nil
t.xyzMetaMutex.RUnlock()
if !hasMeta {
if err := t.fetchXyzMeta(); err != nil {
return fmt.Errorf("failed to fetch xyz meta: %w", err)
}
}
// Get asset index from xyz meta (returns 0-based index)
metaIndex := t.getXyzAssetIndex(coin)
if metaIndex < 0 {
return fmt.Errorf("xyz asset %s not found in meta", coin)
}
// HIP-3 perp dex asset index formula: 100000 + perp_dex_index * 10000 + index_in_meta
// xyz dex is at perp_dex_index = 1 (verified from perpDexs API: [null, {name:"xyz",...}])
// So xyz asset index = 100000 + 1 * 10000 + metaIndex = 110000 + metaIndex
const xyzPerpDexIndex = 1
assetIndex := 100000 + xyzPerpDexIndex*10000 + metaIndex
// Round size to correct precision
szDecimals := t.getXyzSzDecimals(coin)
multiplier := 1.0
for i := 0; i < szDecimals; i++ {
multiplier *= 10.0
}
roundedSize := float64(int(size*multiplier+0.5)) / multiplier
// Round price to 5 significant figures
roundedPrice := t.roundPriceToSigfigs(price)
logger.Infof("📝 Placing xyz dex order (direct): %s %s size=%.4f price=%.4f metaIndex=%d assetIndex=%d (formula: 100000 + 1*10000 + %d) reduceOnly=%v",
map[bool]string{true: "BUY", false: "SELL"}[isBuy],
coin, roundedSize, roundedPrice, metaIndex, assetIndex, metaIndex, reduceOnly)
// Construct OrderWire directly with correct asset index (bypassing SDK's NameToAsset)
orderWire := hyperliquid.OrderWire{
Asset: assetIndex,
IsBuy: isBuy,
LimitPx: floatToWireStr(roundedPrice),
Size: floatToWireStr(roundedSize),
ReduceOnly: reduceOnly,
OrderType: hyperliquid.OrderWireType{
Limit: &hyperliquid.OrderWireTypeLimit{
Tif: hyperliquid.TifIoc,
},
},
}
// Create OrderAction with builder (xyz dex requires builder info for order routing)
action := hyperliquid.OrderAction{
Type: "order",
Orders: []hyperliquid.OrderWire{orderWire},
Grouping: "na",
Builder: &hyperliquid.BuilderInfo{
Builder: "0x891dc6f05ad47a3c1a05da55e7a7517971faaf0d",
Fee: 10,
},
}
// Sign the action
nonce := time.Now().UnixMilli()
isMainnet := !t.isTestnet
vaultAddress := "" // No vault for personal account
sig, err := hyperliquid.SignL1Action(t.privateKey, action, vaultAddress, nonce, nil, isMainnet)
if err != nil {
return fmt.Errorf("failed to sign xyz dex order: %w", err)
}
// Construct payload for /exchange endpoint
payload := map[string]any{
"action": action,
"nonce": nonce,
"signature": sig,
}
// Determine API URL
apiURL := hyperliquid.MainnetAPIURL
if t.isTestnet {
apiURL = hyperliquid.TestnetAPIURL
}
// POST to /exchange
jsonData, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("failed to marshal payload: %w", err)
}
logger.Infof("📤 Sending xyz dex order to %s/exchange", apiURL)
req, err := http.NewRequestWithContext(t.ctx, http.MethodPost, apiURL+"/exchange", bytes.NewBuffer(jsonData))
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("request failed: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("failed to read response body: %w", err)
}
// Parse response
var result struct {
Status string `json:"status"`
Response struct {
Type string `json:"type"`
Data struct {
Statuses []struct {
Resting *struct {
Oid int64 `json:"oid"`
} `json:"resting,omitempty"`
Filled *struct {
TotalSz string `json:"totalSz"`
AvgPx string `json:"avgPx"`
Oid int `json:"oid"`
} `json:"filled,omitempty"`
Error *string `json:"error,omitempty"`
} `json:"statuses"`
} `json:"data"`
} `json:"response"`
}
if err := json.Unmarshal(body, &result); err != nil {
// Try to parse as error response
logger.Infof("⚠️ Failed to parse response as success, raw body: %s", string(body))
return fmt.Errorf("xyz dex order failed, status=%d, body=%s", resp.StatusCode, string(body))
}
// Check for errors in response
if result.Status != "ok" {
return fmt.Errorf("xyz dex order failed: status=%s, body=%s", result.Status, string(body))
}
// Check order statuses
if len(result.Response.Data.Statuses) > 0 {
status := result.Response.Data.Statuses[0]
if status.Error != nil {
return fmt.Errorf("xyz dex order error (coin=%s, assetIndex=%d, size=%.4f, price=%.4f): %s", coin, assetIndex, roundedSize, roundedPrice, *status.Error)
}
if status.Filled != nil {
logger.Infof("✅ xyz dex order filled: totalSz=%s avgPx=%s oid=%d",
status.Filled.TotalSz, status.Filled.AvgPx, status.Filled.Oid)
} else if status.Resting != nil {
logger.Infof("✅ xyz dex order resting: oid=%d", status.Resting.Oid)
}
}
logger.Infof("✅ xyz dex order placed successfully: %s (response: %s)", coin, string(body))
return nil
}
// 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
}
// SetStopLoss sets stop loss order
func (t *HyperliquidTrader) SetStopLoss(symbol string, positionSide string, quantity, stopPrice float64) error {
coin := convertSymbolToHyperliquid(symbol)
isBuy := positionSide == "SHORT" // Short position stop loss = buy, long position stop loss = sell
// ⚠️ Critical: Round quantity according to coin precision requirements
roundedQuantity := t.roundToSzDecimals(coin, quantity)
// ⚠️ Critical: Price also needs to be processed to 5 significant figures
roundedStopPrice := t.roundPriceToSigfigs(stopPrice)
// Create stop loss order (Trigger Order)
order := hyperliquid.CreateOrderRequest{
Coin: coin,
IsBuy: isBuy,
Size: roundedQuantity, // Use rounded quantity
Price: roundedStopPrice, // Use processed price
OrderType: hyperliquid.OrderType{
Trigger: &hyperliquid.TriggerOrderType{
TriggerPx: roundedStopPrice,
IsMarket: true,
Tpsl: "sl", // stop loss
},
},
ReduceOnly: true,
}
_, err := t.exchange.Order(t.ctx, order, defaultBuilder)
if err != nil {
return fmt.Errorf("failed to set stop loss: %w", err)
}
logger.Infof(" Stop loss price set: %.4f", roundedStopPrice)
return nil
}
// SetTakeProfit sets take profit order
func (t *HyperliquidTrader) SetTakeProfit(symbol string, positionSide string, quantity, takeProfitPrice float64) error {
coin := convertSymbolToHyperliquid(symbol)
isBuy := positionSide == "SHORT" // Short position take profit = buy, long position take profit = sell
// ⚠️ Critical: Round quantity according to coin precision requirements
roundedQuantity := t.roundToSzDecimals(coin, quantity)
// ⚠️ Critical: Price also needs to be processed to 5 significant figures
roundedTakeProfitPrice := t.roundPriceToSigfigs(takeProfitPrice)
// Create take profit order (Trigger Order)
order := hyperliquid.CreateOrderRequest{
Coin: coin,
IsBuy: isBuy,
Size: roundedQuantity, // Use rounded quantity
Price: roundedTakeProfitPrice, // Use processed price
OrderType: hyperliquid.OrderType{
Trigger: &hyperliquid.TriggerOrderType{
TriggerPx: roundedTakeProfitPrice,
IsMarket: true,
Tpsl: "tp", // take profit
},
},
ReduceOnly: true,
}
_, err := t.exchange.Order(t.ctx, order, defaultBuilder)
if err != nil {
return fmt.Errorf("failed to set take profit: %w", err)
}
logger.Infof(" Take profit price set: %.4f", roundedTakeProfitPrice)
return nil
}
// FormatQuantity formats quantity to correct precision
func (t *HyperliquidTrader) FormatQuantity(symbol string, quantity float64) (string, error) {
coin := convertSymbolToHyperliquid(symbol)
szDecimals := t.getSzDecimals(coin)
// Format quantity using szDecimals
formatStr := fmt.Sprintf("%%.%df", szDecimals)
return fmt.Sprintf(formatStr, quantity), nil
}
// getSzDecimals gets quantity precision for coin
func (t *HyperliquidTrader) getSzDecimals(coin string) int {
// ✅ Concurrency safe: Use read lock to protect meta field access
t.metaMutex.RLock()
defer t.metaMutex.RUnlock()
if t.meta == nil {
logger.Infof("⚠️ meta information is empty, using default precision 4")
return 4 // Default precision
}
// Find corresponding coin in meta.Universe
for _, asset := range t.meta.Universe {
if asset.Name == coin {
return asset.SzDecimals
}
}
logger.Infof("⚠️ Precision information not found for %s, using default precision 4", coin)
return 4 // Default precision
}
// roundToSzDecimals rounds quantity to correct precision
func (t *HyperliquidTrader) roundToSzDecimals(coin string, quantity float64) float64 {
szDecimals := t.getSzDecimals(coin)
// Calculate multiplier (10^szDecimals)
multiplier := 1.0
for i := 0; i < szDecimals; i++ {
multiplier *= 10.0
}
// Round
return float64(int(quantity*multiplier+0.5)) / multiplier
}
// roundPriceToSigfigs rounds price to 5 significant figures
// Hyperliquid requires prices to use 5 significant figures
func (t *HyperliquidTrader) roundPriceToSigfigs(price float64) float64 {
if price == 0 {
return 0
}
const sigfigs = 5 // Hyperliquid standard: 5 significant figures
// Calculate price magnitude
var magnitude float64
if price < 0 {
magnitude = -price
} else {
magnitude = price
}
// Calculate required multiplier
multiplier := 1.0
for magnitude >= 10 {
magnitude /= 10
multiplier /= 10
}
for magnitude < 1 {
magnitude *= 10
multiplier *= 10
}
// Apply significant figures precision
for i := 0; i < sigfigs-1; i++ {
multiplier *= 10
}
// Round
rounded := float64(int(price*multiplier+0.5)) / multiplier
return rounded
}
// convertSymbolToHyperliquid converts standard symbol to Hyperliquid format
// Example: "BTCUSDT" -> "BTC", "TSLA" -> "xyz:TSLA", "silver" -> "xyz:SILVER"
func convertSymbolToHyperliquid(symbol string) string {
// Convert to uppercase for consistent handling
base := strings.ToUpper(symbol)
// Remove common suffixes to get base symbol
for _, suffix := range []string{"USDT", "USD", "-USDC", "-USD"} {
if strings.HasSuffix(base, suffix) {
base = strings.TrimSuffix(base, suffix)
break
}
}
// Remove xyz: prefix if present (case-insensitive, will be re-added if needed)
if strings.HasPrefix(strings.ToLower(base), "xyz:") {
base = base[4:] // Remove first 4 characters
}
// Check if this is an xyz dex asset (stocks, forex, commodities)
if isXyzDexAsset(base) {
return "xyz:" + base
}
return base
}
// 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
}
// absFloat returns absolute value of float
func absFloat(x float64) float64 {
if x < 0 {
return -x
}
return x
}
// 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) ([]ClosedPnLRecord, error) {
trades, err := t.GetTrades(startTime, limit)
if err != nil {
return nil, err
}
// Filter only closing trades (realizedPnl != 0)
var records []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, 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) ([]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 []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 := 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),
}
trades = append(trades, trade)
}
return trades, nil
}
// defaultBuilder is the builder info for order routing
var defaultBuilder = &hyperliquid.BuilderInfo{
Builder: "0x891dc6f05ad47a3c1a05da55e7a7517971faaf0d",
Fee: 10,
}