mirror of
https://github.com/laoxong/nofx.git
synced 2026-06-04 09:58:22 +08:00
47bff87966
- 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
1748 lines
57 KiB
Go
1748 lines
57 KiB
Go
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,
|
||
}
|