Files
exchange-monitor-go/exchange/hyperliquid_trade.go
T
jackyu66git 866f9906b7 fix: HL market order double slippage causing 'Order has invalid size'
Root cause: PlaceMarketOrder manually calculated limitPx=mid*0.5 (sell)
then passed &limitPx to SDK's MarketOpen. SDK treated this as reference
price and applied slippage AGAIN (0.95x), resulting in limitPx=mid*0.475.
Notional value fell below HL's 0 minimum → error.

Fix: pass nil instead of &limitPx, letting SDK get mid price and
apply standard 5% slippage itself.
2026-05-04 23:35:44 +08:00

207 lines
5.5 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 exchange
import (
"context"
"crypto/ecdsa"
"encoding/hex"
"encoding/json"
"fmt"
"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
}
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)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
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
}
// 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) {
// HL MarketOpen returns a single OrderStatus object (NOT wrapped in statuses array):
// {"resting":..., "filled":{"totalSz":"82.5","avgPx":"0.12153","oid":52463955193}, "error":...}
var resp struct {
Resting *json.RawMessage `json:"resting,omitempty"`
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, 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 sz * px * takerFeePct / 100, nil
}
}
return 0, fmt.Errorf("no filled status 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")
}