Files
exchange-monitor-go/exchange/hyperliquid_trade.go
T
jackyu66git 915b316ca7 fix: HL size floor, random scan jitter 50-250ms
- GetHLSize: change rounding from Sprintf (round-to-nearest)
  to math.Floor (floor), consistent with GetBitgetSize
- Scan interval: fixed 200ms → random 50-250ms to avoid
  lock-step with HyperLiquid's ~200ms allMids push cycle
- README: update architecture diagram, trading logic, config note
2026-05-03 22:25:01 +08:00

201 lines
5.5 KiB
Go

package exchange
import (
"encoding/hex"
"encoding/json"
"fmt"
"io"
"math"
"math/big"
"net/http"
"strings"
"sync"
"time"
"crypto/ed25519"
"crypto/sha512"
)
// HLOrderAction represents a HyperLiquid order action.
type HLOrderAction struct {
Type string `json:"type"`
Order HLOrder `json:"order"`
Grouping string `json:"grouping"`
BrokerCode int `json:"brokerCode"`
}
type HLOrder struct {
Coin string `json:"coin"`
IsBuy bool `json:"isBuy"`
Sz string `json:"sz"`
LimitPx string `json:"limitPx"`
OrderType string `json:"orderType"`
ReduceOnly bool `json:"reduceOnly"`
}
// HLSignedAction wraps the action with signature.
type HLSignedAction struct {
Action HLOrderAction `json:"action"`
Nonce int64 `json:"nonce"`
Signature string `json:"signature"`
VaultAddress string `json:"vaultAddress,omitempty"`
}
type HLOrderResponse struct {
Response *json.RawMessage `json:"response"`
Data *json.RawMessage `json:"data"`
}
// HyperLiquidTrade handles order placement on HyperLiquid.
type HyperLiquidTrade struct {
PrivateKey ed25519.PrivateKey
Address string
client *http.Client
lastNonce int64
nonceMu sync.Mutex // protect lastNonce++ (Issue #4)
}
func NewHyperLiquidTrade(privateKeyHex, address string) (*HyperLiquidTrade, error) {
if privateKeyHex == "" {
return &HyperLiquidTrade{client: &http.Client{Timeout: 10 * time.Second}}, nil
}
keyBytes, err := hex.DecodeString(privateKeyHex)
if err != nil {
return nil, fmt.Errorf("decode private key: %w", err)
}
if len(keyBytes) != ed25519.PrivateKeySize {
return nil, fmt.Errorf("invalid private key length: %d (expected %d)", len(keyBytes), ed25519.PrivateKeySize)
}
return &HyperLiquidTrade{
PrivateKey: ed25519.PrivateKey(keyBytes),
Address: address,
client: &http.Client{Timeout: 10 * time.Second},
}, nil
}
func (h *HyperLiquidTrade) IsConfigured() bool {
return h.PrivateKey != nil && h.Address != ""
}
// PlaceMarketOrder places a market order on HyperLiquid.
// coin: "BTC", side: "buy" or "sell", sz: order size in coin units (e.g. "0.001")
func (h *HyperLiquidTrade) PlaceMarketOrder(coin, side, sz string) (string, error) {
if !h.IsConfigured() {
return "", fmt.Errorf("HyperLiquid not configured")
}
isBuy := side == "buy"
action := HLOrderAction{
Type: "order",
Order: HLOrder{
Coin: coin,
IsBuy: isBuy,
Sz: sz,
LimitPx: "1000000", // high limit price for market orders
OrderType: "IOC",
ReduceOnly: false,
},
Grouping: "na",
BrokerCode: 0,
}
// Generate nonce (thread-safe)
h.nonceMu.Lock()
h.lastNonce++
nonce := time.Now().UnixMilli()*1_000_000 + h.lastNonce%1_000_000
h.nonceMu.Unlock()
// Sign the action
sig, err := h.signAction(action, nonce)
if err != nil {
return "", fmt.Errorf("sign: %w", err)
}
signed := HLSignedAction{
Action: action,
Nonce: nonce,
Signature: sig,
}
bodyJSON, _ := json.Marshal(signed)
req, err := http.NewRequest("POST", "https://api.hyperliquid.xyz/exchange", strings.NewReader(string(bodyJSON)))
if err != nil {
return "", fmt.Errorf("create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := h.client.Do(req)
if err != nil {
return "", fmt.Errorf("http request: %w", err)
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
// Parse response
var response json.RawMessage
if err := json.Unmarshal(respBody, &response); err != nil {
return "", fmt.Errorf("parse response: %s", string(respBody))
}
// HL returns response as array, e.g. [{"response": {"type": "order", "data": ...}}]
var results []map[string]json.RawMessage
if err := json.Unmarshal(respBody, &results); err != nil {
// Maybe returns single object
return string(respBody), nil
}
if len(results) == 0 {
return "", fmt.Errorf("empty response: %s", string(respBody))
}
respJSON, _ := json.Marshal(results[0])
return string(respJSON), nil
}
// signAction generates an Ed25519 signature for a HyperLiquid action.
// The signing hash is SHA512(action_json + nonce).
func (h *HyperLiquidTrade) signAction(action HLOrderAction, nonce int64) (string, error) {
actionJSON, err := json.Marshal(action)
if err != nil {
return "", err
}
// HL signs: hash = sha512(action_json + nonce)
nonceBig := big.NewInt(nonce)
msg := string(actionJSON) + nonceBig.String()
hash := sha512.Sum512([]byte(msg))
sig := ed25519.Sign(h.PrivateKey, hash[:])
return "0x" + hex.EncodeToString(sig), nil
}
// GetHLSize calculates size for a given USD amount on HyperLiquid.
// Uses szDecimals precision from HL's contract universe.
// Returns size as a decimal string complying with HL precision.
// Uses math.Floor to round DOWN to the nearest valid step, consistent with GetBitgetSize.
func GetHLSize(coin string, amountUSD, price float64) string {
sz := amountUSD / price // raw coin count
switch coin {
case "DOGE":
sz = math.Floor(sz) // step=1, szDecimals=0
return fmt.Sprintf("%.0f", sz)
case "LINK":
sz = math.Floor(sz*10) / 10 // step=0.1, szDecimals=1
return fmt.Sprintf("%.1f", sz)
case "ONDO":
sz = math.Floor(sz) // step=1, szDecimals=0
return fmt.Sprintf("%.0f", sz)
case "OP":
sz = math.Floor(sz*10) / 10 // step=0.1, szDecimals=1
return fmt.Sprintf("%.1f", sz)
case "WIF":
sz = math.Floor(sz) // step=1, szDecimals=0
return fmt.Sprintf("%.0f", sz)
case "ARB":
sz = math.Floor(sz*10) / 10 // step=0.1, szDecimals=1
return fmt.Sprintf("%.1f", sz)
default:
return fmt.Sprintf("%.4f", sz)
}
}