File size: 4,069 Bytes
66b7a93
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
/**
 * binanceWebSocket.js
 * Real-time kline streaming from Binance WebSocket API.
 * No API key required.
 */

const BINANCE_WS_BASE = 'wss://stream.binance.com:9443/ws';
const MAX_RECONNECT_DELAY = 30_000; // 30 seconds

export class BinanceStream {
  constructor() {
    /** @type {WebSocket|null} */
    this._ws = null;
    /** @type {string|null} */
    this._symbol = null;
    /** @type {string|null} */
    this._interval = null;
    /** @type {{ onCandleUpdate?: Function, onTick?: Function }|null} */
    this._callbacks = null;
    this._reconnectAttempts = 0;
    this._reconnectTimer = null;
    this._intentionallyClosed = false;
  }

  /**
   * Subscribe to a kline stream.
   *
   * @param {string} symbol   - Lowercase symbol, e.g. 'btcusdt'
   * @param {string} interval - Kline interval, e.g. '1m', '5m', '1h'
   * @param {{ onCandleUpdate?: (candle: object) => void, onTick?: (price: number) => void }} callbacks
   */
  subscribe(symbol, interval, callbacks) {
    // Clean up any existing connection first
    this.unsubscribe();

    this._symbol = symbol.toLowerCase();
    this._interval = interval;
    this._callbacks = callbacks;
    this._intentionallyClosed = false;
    this._reconnectAttempts = 0;

    this._connect();
  }

  /** Disconnect from the current stream. */
  unsubscribe() {
    this._intentionallyClosed = true;
    clearTimeout(this._reconnectTimer);
    this._reconnectTimer = null;

    if (this._ws) {
      this._ws.onopen = null;
      this._ws.onmessage = null;
      this._ws.onerror = null;
      this._ws.onclose = null;
      this._ws.close();
      this._ws = null;
    }

    this._symbol = null;
    this._interval = null;
    this._callbacks = null;
  }

  /* ------------------------------------------------------------------ */
  /*  Internal                                                          */
  /* ------------------------------------------------------------------ */

  _connect() {
    const streamName = `${this._symbol}@kline_${this._interval}`;
    const url = `${BINANCE_WS_BASE}/${streamName}`;

    console.log(`[BinanceStream] Connecting to ${url}`);

    this._ws = new WebSocket(url);

    this._ws.onopen = () => {
      console.log(`[BinanceStream] Connected – ${streamName}`);
      this._reconnectAttempts = 0;
    };

    this._ws.onmessage = (event) => {
      try {
        this._handleMessage(JSON.parse(event.data));
      } catch (err) {
        console.error('[BinanceStream] Error handling message:', err);
      }
    };

    this._ws.onerror = (err) => {
      console.error('[BinanceStream] WebSocket error:', err);
    };

    this._ws.onclose = (event) => {
      console.warn(`[BinanceStream] Connection closed (code=${event.code})`);
      if (!this._intentionallyClosed) {
        this._reconnect();
      }
    };
  }

  /**
   * Parse a Binance kline WebSocket event.
   *
   * Binance event shape:
   * {
   *   e: 'kline',
   *   k: { t, o, h, l, c, v, x, ... }
   * }
   */
  _handleMessage(msg) {
    if (msg.e !== 'kline' || !msg.k) return;

    const k = msg.k;

    const candle = {
      time:     Math.floor(k.t / 1000), // ms β†’ seconds
      open:     parseFloat(k.o),
      high:     parseFloat(k.h),
      low:      parseFloat(k.l),
      close:    parseFloat(k.c),
      volume:   parseFloat(k.v),
      isClosed: k.x,
    };

    // Always emit the latest close as a tick
    if (this._callbacks?.onTick) {
      this._callbacks.onTick(candle.close);
    }

    if (this._callbacks?.onCandleUpdate) {
      this._callbacks.onCandleUpdate(candle);
    }
  }

  /** Reconnect with exponential backoff: 1 s β†’ 2 s β†’ 4 s β†’ … β†’ 30 s max. */
  _reconnect() {
    const delay = Math.min(
      1000 * 2 ** this._reconnectAttempts,
      MAX_RECONNECT_DELAY,
    );
    this._reconnectAttempts += 1;

    console.log(`[BinanceStream] Reconnecting in ${delay}ms (attempt ${this._reconnectAttempts})`);

    this._reconnectTimer = setTimeout(() => {
      if (!this._intentionallyClosed) {
        this._connect();
      }
    }, delay);
  }
}