Initial commit
This commit is contained in:
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
Reference in New Issue
Block a user