B#1 — sigCh shared across goroutines, SIGINT unreliable
→ context.WithCancel: main loop cancels ctx on SIGINT,
4 WS goroutines select on ctx.Done() instead of shared sigCh
B#3 — restoreOpenPositions missing LastScaleAt
→ Set LastScaleAt = tr.OpenedAt on restore so scale-in cooldown works
B#4 — dYdX heartbeat goroutine leaks on reconnect
→ Added stopHeartbeat chan + heartbeatMu mutex; close old channel
before spawning new heartbeat goroutine
B#5 — GetBitgetSize fmt.Sprintf rounds up, may exceed amountUSD
→ Added math.Floor(sz*multiplier)/multiplier before format to round
DOWN to nearest valid step size for every coin
B#6 — netProfit and CalcNetProfit duplicate formula
→ scanner.go netProfit now delegates to exchange.CalcNetProfit
B#7 — Aevo Run callback only 2 params, incompatible with startExchange
→ Changed to 4-arg callback func(coin, price, bid, ask) with bid=ask=0
B#8 — parseFloat uses fmt.Sscanf (slow, locale-sensitive)
→ Replaced with strconv.ParseFloat
B#9 — dYdX receives hlSymbols instead of its own symbol list
→ Added dydxSymbols var, built from c.HL like other exchanges
189 lines
4.3 KiB
Go
189 lines
4.3 KiB
Go
package exchange
|
|
|
|
import (
|
|
"encoding/json"
|
|
"log"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// DydxWS connects to dYdX v4 WebSocket for market data (oracle prices).
|
|
type DydxWS struct {
|
|
Conn *PriceConnector
|
|
Tracked []string // coin names like ["BTC", "ETH", ...]
|
|
stopHeartbeat chan struct{}
|
|
heartbeatMu sync.Mutex
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
// B#4: Stop any previous heartbeat goroutine before starting a new one
|
|
d.heartbeatMu.Lock()
|
|
if d.stopHeartbeat != nil {
|
|
close(d.stopHeartbeat)
|
|
}
|
|
d.stopHeartbeat = make(chan struct{})
|
|
hbStop := d.stopHeartbeat
|
|
d.heartbeatMu.Unlock()
|
|
|
|
// 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 <-hbStop:
|
|
return
|
|
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
|
|
}
|