package exchange import ( "context" "crypto/ecdsa" "encoding/hex" "encoding/json" "fmt" "log" "math" "strconv" "strings" "sync" "time" "github.com/ethereum/go-ethereum/crypto" hl "github.com/sonirico/go-hyperliquid" ) type HyperLiquidTrade struct { exchange *hl.Exchange info *hl.Info privateKey *ecdsa.PrivateKey mainAddress string nonceMu sync.Mutex lastNonce int64 configured bool // szDecimals maps coin name -> decimal places for size formatting // Populated from HL Meta on initExchange() szDecimals map[string]int } func NewHyperLiquidTrade(privateKeyHex, mainAddress, apiAddress string) (*HyperLiquidTrade, error) { if privateKeyHex == "" { return &HyperLiquidTrade{}, nil } keyHex := strings.TrimPrefix(privateKeyHex, "0x") keyBytes, err := hex.DecodeString(keyHex) if err != nil { return nil, fmt.Errorf("decode private key: %w", err) } privKey, err := crypto.ToECDSA(keyBytes) if err != nil { return nil, fmt.Errorf("to ECDSA: %w", err) } ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() info := hl.NewInfo(ctx, hl.MainnetAPIURL, true, nil, nil, nil) return &HyperLiquidTrade{ privateKey: privKey, mainAddress: mainAddress, info: info, configured: true, }, nil } // InitExchange ensures the HL exchange is initialized (fetches metadata, szDecimals, etc.). // Safe to call multiple times — no-op after first initialization. // Must be called before GetSize or PlaceMarketOrder for accurate size formatting. func (h *HyperLiquidTrade) InitExchange() error { return h.initExchange() } func (h *HyperLiquidTrade) initExchange() error { if h.exchange != nil { return nil } if !h.configured { return fmt.Errorf("HL not configured") } ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() meta, err := h.info.Meta(ctx) if err != nil { return fmt.Errorf("meta: %w", err) } spotMeta, err := h.info.SpotMeta(ctx) if err != nil { return fmt.Errorf("spot meta: %w", err) } h.exchange = hl.NewExchange(ctx, h.privateKey, hl.MainnetAPIURL, meta, "", h.mainAddress, spotMeta, nil) // Build szDecimals map from HL Meta for correct size formatting h.szDecimals = make(map[string]int, len(meta.Universe)) for _, asset := range meta.Universe { h.szDecimals[asset.Name] = asset.SzDecimals } return nil } // GetSize returns a formatted size string for HL orders using the correct szDecimals. func (h *HyperLiquidTrade) GetSize(coin string, amountUSD, price float64) string { sz := amountUSD / price decimals, ok := h.szDecimals[coin] if !ok { // Fallback: 4 decimal places return fmt.Sprintf("%.4f", math.Floor(sz*10000)/10000) } switch decimals { case 0: sz = math.Floor(sz) if sz < 1 { sz = 1 } return fmt.Sprintf("%.0f", sz) case 1: sz = math.Floor(sz*10) / 10 if sz < 0.1 { sz = 0.1 } return fmt.Sprintf("%.1f", sz) case 2: sz = math.Floor(sz*100) / 100 if sz < 0.01 { sz = 0.01 } return fmt.Sprintf("%.2f", sz) default: mult := math.Pow10(decimals) sz = math.Floor(sz*mult) / mult if sz < 1/mult { sz = 1 / mult } return fmt.Sprintf("%."+strconv.Itoa(decimals)+"f", sz) } } func (h *HyperLiquidTrade) IsConfigured() bool { return h.configured } // PlaceMarketOrder places a market order and returns the raw JSON response. func (h *HyperLiquidTrade) PlaceMarketOrder(coin, side, sz string) (string, error) { if !h.configured { return "", fmt.Errorf("HL not configured") } if err := h.initExchange(); err != nil { return "", fmt.Errorf("init: %w", err) } isBuy := side == "buy" size, _ := strconv.ParseFloat(sz, 64) // Find szDecimals for this coin decimals := 4 if d, ok := h.szDecimals[coin]; ok { decimals = d } ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() log.Printf("[Order] HL MarketOpen | coin=%s isBuy=%v size=%.*f szDecimals=%d slippage=0.05 px=nil", coin, isBuy, decimals, size, decimals) result, err := h.exchange.MarketOpen(ctx, coin, isBuy, size, nil, 0.05, nil, nil) if err != nil { return "", fmt.Errorf("market open: %w", err) } respJSON, _ := json.Marshal(result) return string(respJSON), nil } // PlaceMarketCloseOrder closes a position on HL with reduceOnly protection. // Uses the SDK's MarketClose which sets ReduceOnly=true to prevent accidental reversals. // sz is the size string (same format as PlaceMarketOrder). Pass "0" or "" to close full position. func (h *HyperLiquidTrade) PlaceMarketCloseOrder(coin, sz string) (string, error) { if !h.configured { return "", fmt.Errorf("HL not configured") } if err := h.initExchange(); err != nil { return "", fmt.Errorf("init: %w", err) } var size *float64 if f, err := strconv.ParseFloat(sz, 64); err == nil && f > 0 { size = &f } ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() log.Printf("[Order] HL MarketClose | coin=%s size=%v reduceOnly=true slippage=0.05", coin, size) result, err := h.exchange.MarketClose(ctx, coin, size, nil, 0.05, nil, nil) if err != nil { return "", fmt.Errorf("market close: %w", err) } respJSON, _ := json.Marshal(result) return string(respJSON), nil } // EstimateFeeFromResponse calculates the fee using the response's filled size × price // × configured taker rate. This is NOT an actual fee from HL — HL does not return // fee amounts in the order response. The result is equivalent to estimating from // TradeAmountUSD, but more accurate for partial fills since it uses actual filled sz/px. func (h *HyperLiquidTrade) EstimateFeeFromResponse(orderResponseJSON string, takerFeePct float64) (feeUSD float64, err error) { var resp struct { Filled *struct { TotalSz string `json:"totalSz"` AvgPx string `json:"avgPx"` } `json:"filled,omitempty"` } if err := json.Unmarshal([]byte(orderResponseJSON), &resp); err != nil || resp.Filled == nil { return 0, fmt.Errorf("no filled data in response") } sz, _ := strconv.ParseFloat(resp.Filled.TotalSz, 64) px, _ := strconv.ParseFloat(resp.Filled.AvgPx, 64) if sz > 0 && px > 0 { return sz * px * takerFeePct / 100, nil } return 0, fmt.Errorf("no filled status in response") } // ParseFillFromResponse extracts the average fill price and total filled size // from an HL MarketOpen/MarketClose response. Returns (avgFillPrice, filledSize, error). func (h *HyperLiquidTrade) ParseFillFromResponse(orderResponseJSON string) (avgPrice, filledSize float64, err error) { var resp struct { Filled *struct { TotalSz string `json:"totalSz"` AvgPx string `json:"avgPx"` } `json:"filled,omitempty"` Error *string `json:"error,omitempty"` } if err := json.Unmarshal([]byte(orderResponseJSON), &resp); err != nil { return 0, 0, fmt.Errorf("parse: %w", err) } if resp.Filled != nil { sz, _ := strconv.ParseFloat(resp.Filled.TotalSz, 64) px, _ := strconv.ParseFloat(resp.Filled.AvgPx, 64) if sz > 0 && px > 0 { return px, sz, nil } } return 0, 0, fmt.Errorf("no filled data in response") } func (h *HyperLiquidTrade) GetBalance() (float64, error) { if !h.configured { return 0, fmt.Errorf("HL not configured") } ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() // HL testnet USDC is on spot, not perp. Use SpotUserState. state, err := h.info.SpotUserState(ctx, h.mainAddress) if err != nil { return 0, fmt.Errorf("spot user state: %w", err) } for _, b := range state.Balances { if b.Coin == "USDC" { total, _ := strconv.ParseFloat(b.Total, 64) hold, _ := strconv.ParseFloat(b.Hold, 64) return total - hold, nil } } return 0, fmt.Errorf("USDC balance not found in spot state") }