Initial commit

This commit is contained in:
jackyu66git
2026-05-03 16:54:36 +08:00
commit 719f0a061d
22 changed files with 2436 additions and 0 deletions
+129
View File
@@ -0,0 +1,129 @@
package exchange
import (
"encoding/json"
"log"
"time"
)
// AevoWS connects to Aevo WebSocket for ticker data.
type AevoWS struct {
Conn *PriceConnector
Tracked []TrackedSymbol // coin + instrument name pairs
}
type TrackedSymbol struct {
Coin string // "BTC"
InstrumentID string // "BTC-PERP"
}
type aevoTickerMsg struct {
Op string `json:"op"`
Data json.RawMessage `json:"data"`
}
type aevoTickerData struct {
Timestamp string `json:"timestamp"`
Tickers []aevoInstrument `json:"tickers"`
}
type aevoInstrument struct {
InstrumentName string `json:"instrument_name"`
Mark *aevoPriceObj `json:"mark,omitempty"`
LastPrice string `json:"last_price,omitempty"`
}
type aevoPriceObj struct {
Price string `json:"price"`
}
func NewAevoWS(tracked []TrackedSymbol) *AevoWS {
ae := &AevoWS{
Tracked: tracked,
Conn: NewPriceConnector("wss://ws.aevo.xyz", "Aevo", 120*time.Second, 30*time.Second),
}
ae.Conn.PingInterval = 45 * time.Second
return ae
}
// Run connects to Aevo WS and streams ticker data.
func (a *AevoWS) Run(updateFn func(coin string, price float64)) error {
a.Conn.OnConnect = func() {
log.Printf("[Aevo WS] Connected, subscribing to %d tickers", len(a.Tracked))
for _, t := range a.Tracked {
sub := map[string]interface{}{
"op": "subscribe",
"data": []string{"ticker:" + t.Coin},
}
if err := a.Conn.SendJSON(sub); err != nil {
log.Printf("[Aevo WS] Subscribe %s error: %v", t.InstrumentID, err)
}
}
}
a.Conn.OnMessage = func(msg []byte) {
var raw map[string]json.RawMessage
if err := json.Unmarshal(msg, &raw); err != nil {
return
}
// Check for error
if errMsg, hasErr := raw["error"]; hasErr {
var errStr string
json.Unmarshal(errMsg, &errStr)
if errStr != "" {
// Log once, skip errors
return
}
}
// Parse ticker data
op, hasOp := raw["op"]
if !hasOp {
return
}
var opStr string
if err := json.Unmarshal(op, &opStr); err != nil || opStr != "ticker" {
return
}
dataRaw, hasData := raw["data"]
if !hasData {
return
}
var data aevoTickerData
if err := json.Unmarshal(dataRaw, &data); err != nil {
return
}
for _, ticker := range data.Tickers {
// Find the coin for this instrument
coin := ""
for _, t := range a.Tracked {
if t.InstrumentID == ticker.InstrumentName {
coin = t.Coin
break
}
}
if coin == "" {
continue
}
// Try mark price first, then last_price
var price float64
if ticker.Mark != nil && ticker.Mark.Price != "" {
price = parseFloat(ticker.Mark.Price)
} else if ticker.LastPrice != "" {
price = parseFloat(ticker.LastPrice)
}
if price > 0 {
updateFn(coin, price)
}
}
}
return a.Conn.Run()
}
+79
View File
@@ -0,0 +1,79 @@
package exchange
import (
"encoding/json"
"fmt"
"log"
"strconv"
"strings"
"time"
)
type BinanceWS struct {
Tracked []string
}
func NewBinanceWS(tracked []string) *BinanceWS {
return &BinanceWS{Tracked: tracked}
}
// Run connects to Binance WS and streams bookTicker data.
func (b *BinanceWS) Run(updateFn func(coin string, price, bid, ask float64)) error {
streams := ""
for i, sym := range b.Tracked {
if i > 0 {
streams += "/"
}
streams += fmt.Sprintf("%s@bookTicker", strings.ToLower(sym))
}
url := fmt.Sprintf("wss://fstream.binance.com/stream?streams=%s", streams)
conn := NewPriceConnector(url, "Binance", 120*time.Second, 30*time.Second)
conn.PingInterval = 45 * time.Second
conn.OnConnect = func() {
log.Printf("[Binance WS] Connected")
}
conn.OnMessage = func(msg []byte) {
// Combined stream: {"stream":"...","data":{...}}
// Navigate through "data" using map to avoid field name conflicts
var raw map[string]json.RawMessage
if err := json.Unmarshal(msg, &raw); err != nil {
return
}
dataRaw, ok := raw["data"]
if !ok {
return
}
// Parse data object as flat map to extract fields by exact name
var dataMap map[string]interface{}
if err := json.Unmarshal(dataRaw, &dataMap); err != nil {
return
}
symbol, _ := dataMap["s"].(string)
bidStr, _ := dataMap["b"].(string)
askStr, _ := dataMap["a"].(string)
if symbol == "" || bidStr == "" || askStr == "" {
return
}
bid, err1 := strconv.ParseFloat(bidStr, 64)
ask, err2 := strconv.ParseFloat(askStr, 64)
if err1 != nil || err2 != nil || bid <= 0 || ask <= 0 {
return
}
// Extract coin name (e.g., "BTCUSDT" -> "BTC")
coin := symbolToCoin(symbol, "USDT")
if coin == "" {
return
}
mid := (bid + ask) / 2.0
updateFn(coin, mid, bid, ask)
}
return conn.Run()
}
+95
View File
@@ -0,0 +1,95 @@
package exchange
import (
"encoding/json"
"log"
"time"
)
// BitgetWS connects to Bitget WebSocket for ticker channel.
type BitgetWS struct {
Conn *PriceConnector
Tracked []string // Bitget symbols like BTCUSDT
}
type bitgetSubscribeMsg struct {
Op string `json:"op"`
Args []bitgetChannel `json:"args"`
}
type bitgetChannel struct {
InstType string `json:"instType"`
Channel string `json:"channel"`
InstID string `json:"instId"`
}
type bitgetTickerMsg struct {
Action string `json:"action"`
Arg bitgetChannel `json:"arg"`
Data []bitgetTickerData `json:"data"`
}
type bitgetTickerData struct {
LastPr string `json:"lastPr"`
BidPr string `json:"bidPr"`
AskPr string `json:"askPr"`
}
func NewBitgetWS(tracked []string) *BitgetWS {
return &BitgetWS{
Tracked: tracked,
}
}
// Run connects to Bitget WS and streams ticker data.
func (b *BitgetWS) Run(updateFn func(coin string, price, bid, ask float64)) error {
url := "wss://ws.bitget.com/v2/ws/public"
b.Conn = NewPriceConnector(url, "Bitget", 120*time.Second, 30*time.Second)
b.Conn.PingInterval = 25 * time.Second // Bitget requires ping within 30s
b.Conn.OnConnect = func() {
log.Printf("[Bitget WS] Connected, subscribing")
args := make([]map[string]string, 0, len(b.Tracked))
for _, sym := range b.Tracked {
args = append(args, map[string]string{
"instType": "USDT-FUTURES",
"channel": "ticker",
"instId": sym,
})
}
sub := map[string]interface{}{
"op": "subscribe",
"args": args,
}
if err := b.Conn.SendJSON(sub); err != nil {
log.Printf("[Bitget WS] Subscribe error: %v", err)
}
}
b.Conn.OnMessage = func(msg []byte) {
var ticker bitgetTickerMsg
if err := json.Unmarshal(msg, &ticker); err != nil {
return
}
if len(ticker.Data) == 0 || ticker.Data[0].LastPr == "" {
return
}
// Convert BTCUSDT -> BTC
coin := symbolToCoin(ticker.Arg.InstID, "USDT")
if coin == "" {
return
}
price := parseFloat(ticker.Data[0].LastPr)
if price > 0 {
bid := parseFloat(ticker.Data[0].BidPr)
ask := parseFloat(ticker.Data[0].AskPr)
updateFn(coin, price, bid, ask)
}
}
return b.Conn.Run()
}
+138
View File
@@ -0,0 +1,138 @@
package exchange
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// BitgetTrade handles order placement on Bitget.
type BitgetTrade struct {
APIKey string
APISecret string
Passphrase string
client *http.Client
}
func NewBitgetTrade(apiKey, apiSecret, passphrase string) *BitgetTrade {
return &BitgetTrade{
APIKey: apiKey,
APISecret: apiSecret,
Passphrase: passphrase,
client: &http.Client{Timeout: 10 * time.Second},
}
}
// PlaceMarketOrder places a market order on Bitget.
// side: "buy" or "sell"
// symbol: "BTCUSDT" (we use UMCBL perpetual)
// size: contract size in coin units (e.g. 0.001 for BTC)
func (b *BitgetTrade) PlaceMarketOrder(side, symbol, size string) (string, error) {
ts := fmt.Sprintf("%d", time.Now().UnixMilli())
method := "POST"
requestPath := "/api/v2/mix/order/place"
body := map[string]interface{}{
"symbol": symbol + "_UMCBL",
"marginCoin": "USDT",
"side": side,
"orderType": "market",
"size": size,
"timeInForce": "IOC", // immediate-or-cancel for market orders
}
bodyJSON, _ := json.Marshal(body)
sign := b.sign(method, requestPath, ts, string(bodyJSON))
url := "https://api.bitget.com" + requestPath
req, err := http.NewRequest(method, url, strings.NewReader(string(bodyJSON)))
if err != nil {
return "", fmt.Errorf("create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("ACCESS-KEY", b.APIKey)
req.Header.Set("ACCESS-SIGN", sign)
req.Header.Set("ACCESS-TIMESTAMP", ts)
req.Header.Set("ACCESS-PASSPHRASE", b.Passphrase)
resp, err := b.client.Do(req)
if err != nil {
return "", fmt.Errorf("http request: %w", err)
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
var result struct {
Code string `json:"code"`
Msg string `json:"msg"`
Data struct {
OrderID string `json:"orderId"`
} `json:"data"`
}
if err := json.Unmarshal(respBody, &result); err != nil {
return "", fmt.Errorf("parse response: %s", string(respBody))
}
if result.Code != "00000" {
return "", fmt.Errorf("bitget error: %s - %s", result.Code, result.Msg)
}
return result.Data.OrderID, nil
}
func (b *BitgetTrade) sign(method, requestPath, timestamp, body string) string {
raw := timestamp + method + requestPath + body
mac := hmac.New(sha256.New, []byte(b.APISecret))
mac.Write([]byte(raw))
return base64.StdEncoding.EncodeToString(mac.Sum(nil))
}
// GetBitgetSize calculates the contract size for a given USD amount.
// Returns size as a decimal string complying with Bitget's USDT-FUTURES precision.
// Enforces the exchange's minimum: minTradeNum contracts AND $5 min notional.
func GetBitgetSize(symbol string, amountUSD, price float64) string {
if amountUSD < 5 {
amountUSD = 5 // Bitget minimum notional
}
sz := amountUSD / price // raw coin count
switch symbol {
case "DOGEUSDT":
if sz < 1 {
sz = 1
}
return fmt.Sprintf("%.0f", sz) // minTradeNum=1, sizeMultiplier=1
case "LINKUSDT":
if sz < 1 {
sz = 1
}
return fmt.Sprintf("%.0f", sz) // minTradeNum=1, sizeMultiplier=1
case "ONDOUSDT":
if sz < 0.1 {
sz = 0.1
}
return fmt.Sprintf("%.1f", sz) // minTradeNum=0.1, sizeMultiplier=0.1
case "OPUSDT":
if sz < 0.1 {
sz = 0.1
}
return fmt.Sprintf("%.1f", sz) // minTradeNum=0.1, sizeMultiplier=0.1
case "WIFUSDT":
if sz < 0.1 {
sz = 0.1
}
return fmt.Sprintf("%.1f", sz) // minTradeNum=0.1, sizeMultiplier=0.1
case "ARBUSDT":
if sz < 0.01 {
sz = 0.01
}
return fmt.Sprintf("%.2f", sz) // minTradeNum=0.01, sizeMultiplier=0.01
default:
return fmt.Sprintf("%.4f", sz)
}
}
+168
View File
@@ -0,0 +1,168 @@
package exchange
import (
"fmt"
"log"
"time"
"github.com/gorilla/websocket"
)
// PriceConnector is a reusable WebSocket reconnector with backoff and keepalive.
type PriceConnector struct {
URL string
Name string
ReadTimeout time.Duration
ReconnectBase time.Duration
PingInterval time.Duration // 0 = no client-side pings
OnConnect func()
OnMessage func([]byte)
OnError func(error)
conn *websocket.Conn
done chan struct{}
}
func NewPriceConnector(url, name string, readTimeout, reconnectBase time.Duration) *PriceConnector {
return &PriceConnector{
URL: url,
Name: name,
ReadTimeout: readTimeout,
ReconnectBase: reconnectBase,
done: make(chan struct{}),
}
}
func (pc *PriceConnector) Run() error {
backoff := time.Second
attempt := 0
for {
select {
case <-pc.done:
return nil
default:
}
log.Printf("[%s WS] Connecting... (attempt %d)", pc.Name, attempt+1)
c, _, err := websocket.DefaultDialer.Dial(pc.URL, nil)
if err != nil {
log.Printf("[%s WS] Dial error: %v (retry in %v)", pc.Name, err, backoff)
if pc.OnError != nil {
pc.OnError(err)
}
time.Sleep(backoff)
backoff *= 2
if backoff > 30*time.Second {
backoff = 30 * time.Second
}
attempt++
continue
}
backoff = time.Second
attempt = 0
pc.conn = c
// ── Ping/Pong keepalive ──
c.SetReadDeadline(time.Now().Add(pc.ReadTimeout))
// Respond to server pings with pongs (extend deadline)
c.SetPongHandler(func(appData string) error {
c.SetReadDeadline(time.Now().Add(pc.ReadTimeout))
return nil
})
// Handle server-initiated PING frames (used by Bitget etc.)
// Extend read deadline so the connection doesn't timeout
c.SetPingHandler(func(appData string) error {
c.SetReadDeadline(time.Now().Add(pc.ReadTimeout))
return nil
})
// Client-side ping sender
pingStop := make(chan struct{})
if pc.PingInterval > 0 {
go func() {
ticker := time.NewTicker(pc.PingInterval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
if err := c.WriteMessage(websocket.PingMessage, nil); err != nil {
return
}
case <-pingStop:
return
case <-pc.done:
return
}
}
}()
}
if pc.OnConnect != nil {
pc.OnConnect()
}
// Read loop
readLoop:
for {
_, msg, err := c.ReadMessage()
if err != nil {
log.Printf("[%s WS] Read error: %v", pc.Name, err)
break readLoop
}
c.SetReadDeadline(time.Now().Add(pc.ReadTimeout))
if pc.OnMessage != nil {
pc.OnMessage(msg)
}
}
close(pingStop)
c.Close()
}
}
func (pc *PriceConnector) Stop() {
close(pc.done)
if pc.conn != nil {
pc.conn.Close()
}
}
// Done returns a channel that's closed when the connector is stopped.
func (pc *PriceConnector) Done() <-chan struct{} {
return pc.done
}
// SendJSON sends a JSON message over the WebSocket.
func (pc *PriceConnector) SendJSON(v interface{}) error {
if pc.conn == nil {
return nil
}
return pc.conn.WriteJSON(v)
}
// Helper: parse float from string
func parseFloat(s string) float64 {
var f float64
if _, err := fmt.Sscanf(s, "%f", &f); err != nil {
return 0
}
return f
}
// Helper: convert "BTCUSDT" with suffix "USDT" to "BTC"
func symbolToCoin(symbol, suffix string) string {
if len(symbol) <= len(suffix) {
return ""
}
if symbol[len(symbol)-len(suffix):] == suffix {
return symbol[:len(symbol)-len(suffix)]
}
return ""
}
+174
View File
@@ -0,0 +1,174 @@
package exchange
import (
"encoding/json"
"log"
"time"
)
// DydxWS connects to dYdX v4 WebSocket for market data (oracle prices).
type DydxWS struct {
Conn *PriceConnector
Tracked []string // coin names like ["BTC", "ETH", ...]
}
type dydxSubscribeMsg struct {
Type string `json:"type"`
Channel string `json:"channel"`
ID string `json:"id,omitempty"`
}
type dydxMarketMsg struct {
Type string `json:"type"`
ID string `json:"id"`
Contents json.RawMessage `json:"contents"`
}
type dydxMarketContents struct {
OraclePrice string `json:"oraclePrice"`
MarkPrice string `json:"markPrice"`
NextFundingRate string `json:"nextFundingRate"`
}
func NewDydxWS(tracked []string) *DydxWS {
return &DydxWS{Tracked: tracked}
}
// Run connects to dYdX v4 WS and streams oracle/market prices.
func (d *DydxWS) Run(updateFn func(coin string, price, bid, ask float64)) error {
url := "wss://indexer.dydx.trade/v4/ws"
d.Conn = NewPriceConnector(url, "dYdX", 120*time.Second, 30*time.Second)
// dYdX v4 requires JSON {"type":"ping"} heartbeat
d.Conn.OnConnect = func() {
log.Printf("[dYdX WS] Connected, subscribing")
// Subscribe to all markets (gets all coins in one stream)
sub := dydxSubscribeMsg{
Type: "subscribe",
Channel: "v4_markets",
}
if err := d.Conn.SendJSON(sub); err != nil {
log.Printf("[dYdX WS] Subscribe error: %v", err)
}
// dYdX requires JSON {"type":"ping"} every ~30s
go func() {
heartbeat := time.NewTicker(15 * time.Second)
defer heartbeat.Stop()
// Send first ping after 10s (let subscription settle)
time.Sleep(10 * time.Second)
for {
select {
case <-heartbeat.C:
if err := d.Conn.SendJSON(map[string]string{"type": "ping"}); err != nil {
log.Printf("[dYdX WS] Heartbeat send error: %v", err)
}
case <-d.Conn.Done():
return
}
}
}()
}
d.Conn.OnMessage = func(msg []byte) {
var raw map[string]json.RawMessage
if err := json.Unmarshal(msg, &raw); err != nil {
return
}
// Check type
var msgType string
if err := json.Unmarshal(raw["type"], &msgType); err != nil {
return
}
if msgType != "channel_data" {
// Handle initial subscription response with all markets
if msgType == "subscribed" {
var contents struct {
Markets map[string]struct {
OraclePrice string `json:"oraclePrice"`
} `json:"markets"`
}
contentsRaw, ok := raw["contents"]
if !ok {
return
}
if err := json.Unmarshal(contentsRaw, &contents); err != nil {
return
}
for marketID, market := range contents.Markets {
if market.OraclePrice == "" {
continue
}
coin := dydxSymbolToCoin(marketID)
if coin == "" {
continue
}
if !isTracked(d.Tracked, coin) {
continue
}
price := parseFloat(market.OraclePrice)
if price > 0 {
updateFn(coin, price, 0, 0)
}
}
}
return
}
// Live updates - type "channel_data" with oraclePrices
contentsRaw, ok := raw["contents"]
if !ok {
return
}
var contents struct {
OraclePrices map[string]struct {
OraclePrice string `json:"oraclePrice"`
} `json:"oraclePrices"`
}
if err := json.Unmarshal(contentsRaw, &contents); err != nil {
return
}
for marketID, data := range contents.OraclePrices {
if data.OraclePrice == "" {
continue
}
coin := dydxSymbolToCoin(marketID)
if coin == "" {
continue
}
if !isTracked(d.Tracked, coin) {
continue
}
price := parseFloat(data.OraclePrice)
if price > 0 {
updateFn(coin, price, 0, 0)
}
}
}
return d.Conn.Run()
}
// dydxSymbolToCoin converts "BTC-USD" -> "BTC", "ETH-USD" -> "ETH"
func dydxSymbolToCoin(symbol string) string {
if len(symbol) < 4 {
return ""
}
// Remove "-USD" suffix
if len(symbol) > 4 && symbol[len(symbol)-4:] == "-USD" {
return symbol[:len(symbol)-4]
}
return symbol
}
func isTracked(list []string, coin string) bool {
for _, t := range list {
if t == coin {
return true
}
}
return false
}
+42
View File
@@ -0,0 +1,42 @@
package exchange
import "github.com/gorilla/websocket"
// These are needed for compilation of the exchange package.
// PriceConnector is defined in connector.go.
var _ = websocket.ErrCloseSent // keep gorilla/websocket import
// CalcNetProfit calculates net profit % for a complete round trip (entry + exit) between two exchanges.
// buyPrice: price on the buy exchange
// sellPrice: price on the sell exchange
// buyFee: fee rate on buy exchange (e.g. 0.03 for 0.03%)
// sellFee: fee rate on sell exchange
// buyFee2: buy fee on the other exchange
// sellFee2: sell fee on the other exchange
// Returns net profit in percentage.
func CalcNetProfit(price1, price2, fee1Buy, fee1Sell, fee2Buy, fee2Sell float64) float64 {
// price1 = Bitget, price2 = HyperLiquid
// Try: buy cheap (min), sell expensive (max)
buyPrice := price1
sellPrice := price2
buyFee := fee1Buy
sellFee := fee2Sell
if price2 < price1 {
buyPrice = price2
sellPrice = price1
buyFee = fee2Buy
sellFee = fee1Sell
}
// Entry: buy at buyPrice (pay buyFee), sell short at sellPrice (pay sellFee)
if buyPrice <= 0 || sellPrice <= 0 {
return 0
}
cost := buyPrice * (1 + buyFee/100)
revenue := sellPrice * (1 - sellFee/100)
// Exit: sell long (pay sellFee), buy back short (pay buyFee)
// Total fees = 2 * (buyFee + sellFee), first round already in formula above
return (revenue/cost-1)*100 - (buyFee + sellFee)
}
+66
View File
@@ -0,0 +1,66 @@
package exchange
import (
"encoding/json"
"log"
"strconv"
"time"
)
type HyperLiquidWS struct {
Tracked []string
}
type hlAllMidsMsg struct {
Channel string `json:"channel"`
Data json.RawMessage `json:"data"`
}
type hlMidsData struct {
Mids map[string]string `json:"mids"`
}
func NewHyperLiquidWS(tracked []string) *HyperLiquidWS {
return &HyperLiquidWS{Tracked: tracked}
}
// Run connects to HyperLiquid WS and streams mid prices.
func (h *HyperLiquidWS) Run(updateFn func(coin string, price, bid, ask float64)) error {
conn := NewPriceConnector("wss://api.hyperliquid.xyz/ws", "HyperLiquid", 120*time.Second, 30*time.Second)
conn.PingInterval = 45 * time.Second
conn.OnConnect = func() {
log.Printf("[HL WS] Connected")
sub := map[string]interface{}{
"method": "subscribe",
"subscription": map[string]string{
"type": "allMids",
},
}
if err := conn.SendJSON(sub); err != nil {
log.Printf("[HL WS] Subscribe error: %v", err)
}
}
conn.OnMessage = func(msg []byte) {
var raw hlAllMidsMsg
if err := json.Unmarshal(msg, &raw); err != nil {
return
}
if raw.Channel != "allMids" {
return
}
var data hlMidsData
if err := json.Unmarshal(raw.Data, &data); err != nil {
return
}
for coin, priceStr := range data.Mids {
price, err := strconv.ParseFloat(priceStr, 64)
if err != nil || price <= 0 {
continue
}
updateFn(coin, price, 0, 0)
}
}
return conn.Run()
}
+188
View File
@@ -0,0 +1,188 @@
package exchange
import (
"encoding/hex"
"encoding/json"
"fmt"
"io"
"math/big"
"net/http"
"strings"
"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
}
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
h.lastNonce++
nonce := time.Now().UnixMilli()*1_000_000 + h.lastNonce%1_000_000
// 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.
func GetHLSize(coin string, amountUSD, price float64) string {
sz := amountUSD / price // raw coin count
switch coin {
case "DOGE":
return fmt.Sprintf("%.0f", sz) // szDecimals=0
case "LINK":
return fmt.Sprintf("%.1f", sz) // szDecimals=1
case "ONDO":
return fmt.Sprintf("%.0f", sz) // szDecimals=0
case "OP":
return fmt.Sprintf("%.1f", sz) // szDecimals=1
case "WIF":
return fmt.Sprintf("%.0f", sz) // szDecimals=0
case "ARB":
return fmt.Sprintf("%.1f", sz) // szDecimals=1
default:
return fmt.Sprintf("%.4f", sz)
}
}
+16
View File
@@ -0,0 +1,16 @@
package exchange
import "time"
// WithPing returns a copy of pc with PingInterval set.
// Use this in each exchange's Run() before calling b.Conn.Run().
func WithPing(pc *PriceConnector, interval time.Duration) *PriceConnector {
pc.PingInterval = interval
return pc
}
// Standard ping intervals per exchange
const (
PingBitget = 25 * time.Second // Bitget requires ping within 30s
PingNormal = 45 * time.Second // General keepalive
)