- 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
201 lines
5.5 KiB
Go
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)
|
|
}
|
|
}
|