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
170 lines
3.5 KiB
Go
170 lines
3.5 KiB
Go
package exchange
|
|
|
|
import (
|
|
"log"
|
|
"strconv"
|
|
"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
|
|
// B#8: use strconv.ParseFloat instead of fmt.Sscanf
|
|
func parseFloat(s string) float64 {
|
|
f, err := strconv.ParseFloat(s, 64)
|
|
if 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 ""
|
|
}
|