Files
exchange-monitor-go/exchange/hyperliquid_trade.go
T

205 lines
5.3 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
}
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.TestnetAPIURL, 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.TestnetAPIURL, meta, "", h.mainAddress, spotMeta, nil)
return nil
}
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()
mids, err := h.info.AllMids(ctx)
if err != nil {
return "", fmt.Errorf("mids: %w", err)
}
priceStr, ok := mids[coin]
if !ok {
return "", fmt.Errorf("coin %s not found", coin)
}
midPx, _ := strconv.ParseFloat(priceStr, 64)
limitPx := midPx * 2.0
if !isBuy {
limitPx = midPx * 0.5
}
result, err := h.exchange.MarketOpen(ctx, coin, isBuy, size, &limitPx, 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")
}
func GetHLSize(coin string, amountUSD, price float64) string {
sz := amountUSD / price
switch coin {
// integer sizes (szDecimals=0)
case "DOGE", "ONDO", "WIF", "ALGO", "PYTH", "SAND", "ADA", "HBAR",
"IOTA", "FET", "JUP", "MOVE":
sz = math.Floor(sz)
if sz < 1 {
sz = 1
}
return fmt.Sprintf("%.0f", sz)
// 1 decimal (szDecimals=1)
case "OP", "ARB", "DYDX", "WLD", "SUI", "TIA", "NEAR", "FIL":
sz = math.Floor(sz*10) / 10
if sz < 0.1 {
sz = 0.1
}
return fmt.Sprintf("%.1f", sz)
// 2 decimals (szDecimals=2)
case "APT":
sz = math.Floor(sz*100) / 100
if sz < 0.01 {
sz = 0.01
}
return fmt.Sprintf("%.2f", sz)
default:
return fmt.Sprintf("%.4f", sz)
}
}