- BitgetTrade: added GetTradeFee() queries /mix/order/fills - HyperLiquidTrade: added GetTradeFee() parses MarketOpen response - trader.placeOrder() now returns (errMsg, actualFeeUSD) - executeEntry uses actual fee from exchange, falls back to estimate
204 lines
4.9 KiB
Go
204 lines
4.9 KiB
Go
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
|
|
}
|
|
|
|
// GetTradeFee parses the MarketOpen JSON response to extract filled size and
|
|
// estimates the actual fee from the exchange taker rate.
|
|
func (h *HyperLiquidTrade) GetTradeFee(orderResponseJSON string, takerFeePct float64) (feeUSD float64, err error) {
|
|
var resp struct {
|
|
Statuses []struct {
|
|
Filled *struct {
|
|
TotalSz string `json:"totalSz"`
|
|
AvgPx string `json:"avgPx"`
|
|
} `json:"filled,omitempty"`
|
|
Error *string `json:"error,omitempty"`
|
|
} `json:"statuses"`
|
|
}
|
|
if err := json.Unmarshal([]byte(orderResponseJSON), &resp); err != nil {
|
|
return 0, fmt.Errorf("parse order response: %w", err)
|
|
}
|
|
for _, st := range resp.Statuses {
|
|
if st.Filled != nil {
|
|
sz, _ := strconv.ParseFloat(st.Filled.TotalSz, 64)
|
|
px, _ := strconv.ParseFloat(st.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)
|
|
}
|
|
}
|