melaya 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/lib/melaya/stream.rb CHANGED
@@ -1,336 +1,336 @@
1
- # frozen_string_literal: true
2
-
3
- require "socket"
4
- require "openssl"
5
- require "uri"
6
- require "base64"
7
- require "digest"
8
- require "json"
9
- require "securerandom"
10
-
11
- require_relative "errors"
12
-
13
- module Melaya
14
- # A minimal RFC 6455 WebSocket client built on stdlib TCP+TLS.
15
- # No external gem required. Supports text frames; yields parsed JSON frames.
16
- #
17
- # Usage (block form — closes automatically):
18
- # MelayaWebSocket.connect(url, verify_ssl: true) do |ws|
19
- # ws.each_frame { |frame| puts frame.inspect; break }
20
- # end
21
- #
22
- # Usage (manual):
23
- # ws = MelayaWebSocket.new(url, verify_ssl: true)
24
- # ws.connect
25
- # ws.each_frame { |f| ... }
26
- # ws.close
27
- class MelayaWebSocket
28
- GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
29
-
30
- def self.connect(url, verify_ssl: true, &block)
31
- ws = new(url, verify_ssl: verify_ssl)
32
- ws.connect
33
- if block_given?
34
- begin
35
- block.call(ws)
36
- ensure
37
- ws.close
38
- end
39
- else
40
- ws
41
- end
42
- end
43
-
44
- def initialize(url, verify_ssl: true)
45
- @uri = URI.parse(url)
46
- @verify_ssl = verify_ssl
47
- @socket = nil
48
- @closed = false
49
- @buf = String.new("", encoding: "BINARY")
50
- end
51
-
52
- def connect
53
- tcp = TCPSocket.new(@uri.host, @uri.port)
54
-
55
- @socket = if @uri.scheme == "wss"
56
- ctx = OpenSSL::SSL::SSLContext.new
57
- ctx.verify_mode = @verify_ssl ? OpenSSL::SSL::VERIFY_PEER : OpenSSL::SSL::VERIFY_NONE
58
- ssl = OpenSSL::SSL::SSLSocket.new(tcp, ctx)
59
- ssl.hostname = @uri.host
60
- ssl.connect
61
- ssl
62
- else
63
- tcp
64
- end
65
-
66
- handshake
67
- self
68
- end
69
-
70
- # Iterate over incoming JSON frames. Yields each parsed frame Hash.
71
- # Blocks until the socket closes or the block calls +break+.
72
- def each_frame
73
- loop do
74
- frame = read_frame
75
- break if frame.nil? # connection closed
76
-
77
- next if frame.empty? # ping/pong or non-text
78
-
79
- begin
80
- yield JSON.parse(frame)
81
- rescue JSON::ParserError
82
- next # ignore non-JSON keep-alive text
83
- end
84
- end
85
- rescue IOError, Errno::ECONNRESET, EOFError
86
- # socket closed by remote
87
- ensure
88
- close
89
- end
90
-
91
- def close
92
- return if @closed
93
- @closed = true
94
- begin
95
- # Send a close frame (opcode 0x8) with no body, masked
96
- send_frame(0x8, "")
97
- rescue StandardError
98
- # best-effort
99
- ensure
100
- @socket&.close rescue nil
101
- end
102
- end
103
-
104
- private
105
-
106
- def handshake
107
- key = Base64.strict_encode64(SecureRandom.bytes(16))
108
- path = @uri.request_uri
109
- host = @uri.host + (@uri.port && ![80, 443].include?(@uri.port) ? ":#{@uri.port}" : "")
110
-
111
- request = [
112
- "GET #{path} HTTP/1.1",
113
- "Host: #{host}",
114
- "Upgrade: websocket",
115
- "Connection: Upgrade",
116
- "Sec-WebSocket-Key: #{key}",
117
- "Sec-WebSocket-Version: 13",
118
- "\r\n",
119
- ].join("\r\n")
120
-
121
- @socket.write(request)
122
-
123
- # Read response headers
124
- response = String.new("", encoding: "BINARY")
125
- loop do
126
- line = @socket.readline
127
- response << line
128
- break if line == "\r\n"
129
- end
130
-
131
- unless response.include?("101")
132
- raise MelayaError.new("WebSocket handshake failed: #{response.lines.first.strip}", status: 0)
133
- end
134
-
135
- expected = Base64.strict_encode64(Digest::SHA1.digest("#{key}#{GUID}"))
136
- unless response.include?(expected)
137
- raise MelayaError.new("WebSocket Sec-WebSocket-Accept mismatch", status: 0)
138
- end
139
- end
140
-
141
- # Read one complete WebSocket frame. Returns the payload string (text) or nil on close.
142
- # Skips ping frames (sends pong) and continuation frames (not used by Melaya).
143
- def read_frame
144
- loop do
145
- # Need at least 2 bytes for header
146
- fill_buf(2)
147
- b0 = @buf.getbyte(0)
148
- b1 = @buf.getbyte(1)
149
- @buf = @buf[2..]
150
-
151
- # fin = (b0 & 0x80) != 0 # we accept single-frame messages
152
- opcode = b0 & 0x0F
153
- masked = (b1 & 0x80) != 0
154
- len = b1 & 0x7F
155
-
156
- len = case len
157
- when 126
158
- fill_buf(2)
159
- v = @buf[0, 2].unpack1("n")
160
- @buf = @buf[2..]
161
- v
162
- when 127
163
- fill_buf(8)
164
- v = @buf[0, 8].unpack1("Q>")
165
- @buf = @buf[8..]
166
- v
167
- else
168
- len
169
- end
170
-
171
- mask_key = nil
172
- if masked
173
- fill_buf(4)
174
- mask_key = @buf[0, 4]
175
- @buf = @buf[4..]
176
- end
177
-
178
- fill_buf(len)
179
- payload = @buf[0, len].dup
180
- @buf = @buf[len..]
181
-
182
- if masked && mask_key
183
- payload.bytes.each_with_index { |b, i| payload.setbyte(i, b ^ mask_key.getbyte(i % 4)) }
184
- end
185
-
186
- case opcode
187
- when 0x1 # text
188
- return payload.force_encoding("UTF-8")
189
- when 0x2 # binary — treat as text (Melaya sends JSON)
190
- return payload.force_encoding("UTF-8")
191
- when 0x8 # close
192
- return nil
193
- when 0x9 # ping — send pong
194
- send_frame(0xA, payload)
195
- next
196
- when 0xA # pong
197
- next
198
- else
199
- next # ignore unknown opcodes
200
- end
201
- end
202
- end
203
-
204
- # Ensure @buf has at least +n+ bytes.
205
- def fill_buf(n)
206
- while @buf.bytesize < n
207
- chunk = @socket.read(n - @buf.bytesize)
208
- raise EOFError, "connection closed" if chunk.nil? || chunk.empty?
209
- @buf = @buf + chunk
210
- end
211
- end
212
-
213
- # Send a WebSocket frame with masking (client->server MUST be masked).
214
- def send_frame(opcode, payload)
215
- payload = payload.dup.force_encoding("BINARY")
216
- len = payload.bytesize
217
-
218
- header = String.new("", encoding: "BINARY")
219
- header << (0x80 | opcode).chr(Encoding::BINARY)
220
-
221
- mask_flag = 0x80 # clients must mask
222
- if len <= 125
223
- header << (mask_flag | len).chr(Encoding::BINARY)
224
- elsif len <= 65535
225
- header << (mask_flag | 126).chr(Encoding::BINARY)
226
- header << [len].pack("n")
227
- else
228
- header << (mask_flag | 127).chr(Encoding::BINARY)
229
- header << [len].pack("Q>")
230
- end
231
-
232
- mask_key = SecureRandom.bytes(4)
233
- header << mask_key
234
-
235
- masked_payload = payload.bytes.each_with_index.map { |b, i| b ^ mask_key.getbyte(i % 4) }.pack("C*")
236
-
237
- @socket.write(header + masked_payload)
238
- end
239
- end
240
-
241
- # WebSocket streaming API.
242
- #
243
- # Each method yields parsed JSON frame hashes to a block, or returns an
244
- # Enumerator if no block is given. The connection is closed when the block
245
- # returns or the Enumerator is exhausted.
246
- #
247
- # Public stream example:
248
- # melaya.stream.ticker(exchange: "binance", symbol: "BTC/USDT", market: "spot") do |frame|
249
- # puts frame["last"]
250
- # break # close after first frame
251
- # end
252
- #
253
- # Private stream example (ticket minted automatically):
254
- # melaya.stream.strategies do |ev|
255
- # puts ev["type"], ev["strategyId"]
256
- # break
257
- # end
258
- class StreamAPI
259
- DEFAULT_WS_URL = "wss://wss.melaya.org"
260
-
261
- def initialize(api_key, ws_url, http, verify_ssl: true)
262
- @api_key = api_key
263
- @ws_url = ws_url.to_s.chomp("/")
264
- @http = http
265
- @verify_ssl = verify_ssl
266
- end
267
-
268
- # Live ticker frames.
269
- def ticker(exchange:, symbol:, market: nil, &block)
270
- open_public("/ws/ticker", compact(exchange: exchange, symbol: symbol, market: market), &block)
271
- end
272
-
273
- # Live order-book frames.
274
- def orderbook(exchange:, symbol:, limit: nil, market: nil, &block)
275
- open_public("/ws/orderbook", compact(exchange: exchange, symbol: symbol, limit: limit, market: market), &block)
276
- end
277
-
278
- # Live OHLCV candle frames.
279
- def ohlcv(exchange:, symbol:, timeframe:, market: nil, &block)
280
- open_public("/ws/ohlcv", compact(exchange: exchange, symbol: symbol, timeframe: timeframe, market: market), &block)
281
- end
282
-
283
- # Live public-trade frames.
284
- def trades(exchange:, symbol:, market: nil, &block)
285
- open_public("/ws/public-trades", compact(exchange: exchange, symbol: symbol, market: market), &block)
286
- end
287
-
288
- # Cross-exchange liquidation firehose. Omit +exchange+ for all venues.
289
- def liquidations(exchange: nil, &block)
290
- open_public("/ws/liquidations", compact(exchange: exchange), &block)
291
- end
292
-
293
- # Live strategy events for your account (cycle markers, agent messages,
294
- # approval requests, executions, status). Mints a ticket, opens /ws/strategies.
295
- def strategies(&block)
296
- open_private("/ws/strategies", "strategies", {}, &block)
297
- end
298
-
299
- # Live private account feed for one connected exchange key (balance,
300
- # positions, your orders/fills). Pass +api_key_id+ from +account.keys()+.
301
- def private(exchange:, market: nil, api_key_id: nil, key_id: nil, symbol: nil, &block)
302
- params = compact(
303
- exchange: exchange, market: market,
304
- apiKeyId: api_key_id, keyId: key_id, symbol: symbol
305
- )
306
- open_private("/ws/private", "private", params, &block)
307
- end
308
-
309
- private
310
-
311
- def compact(hash)
312
- hash.reject { |_, v| v.nil? }
313
- end
314
-
315
- def build_url(path, params)
316
- q = params.merge("apiKey" => @api_key)
317
- "#{@ws_url}#{path}?#{URI.encode_www_form(q)}"
318
- end
319
-
320
- def open_public(path, params, &block)
321
- url = build_url(path, params.transform_keys(&:to_s))
322
- MelayaWebSocket.connect(url, verify_ssl: @verify_ssl) do |ws|
323
- ws.each_frame(&block)
324
- end
325
- end
326
-
327
- def open_private(path, stream, params, &block)
328
- body = { "stream" => stream }.merge(params.transform_keys(&:to_s))
329
- ticket = @http.post("/api/v1/private/private-ticket", body)["wsTicket"]
330
- url = "#{@ws_url}#{path}?#{URI.encode_www_form('wsTicket' => ticket)}"
331
- MelayaWebSocket.connect(url, verify_ssl: @verify_ssl) do |ws|
332
- ws.each_frame(&block)
333
- end
334
- end
335
- end
336
- end
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+ require "openssl"
5
+ require "uri"
6
+ require "base64"
7
+ require "digest"
8
+ require "json"
9
+ require "securerandom"
10
+
11
+ require_relative "errors"
12
+
13
+ module Melaya
14
+ # A minimal RFC 6455 WebSocket client built on stdlib TCP+TLS.
15
+ # No external gem required. Supports text frames; yields parsed JSON frames.
16
+ #
17
+ # Usage (block form — closes automatically):
18
+ # MelayaWebSocket.connect(url, verify_ssl: true) do |ws|
19
+ # ws.each_frame { |frame| puts frame.inspect; break }
20
+ # end
21
+ #
22
+ # Usage (manual):
23
+ # ws = MelayaWebSocket.new(url, verify_ssl: true)
24
+ # ws.connect
25
+ # ws.each_frame { |f| ... }
26
+ # ws.close
27
+ class MelayaWebSocket
28
+ GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
29
+
30
+ def self.connect(url, verify_ssl: true, &block)
31
+ ws = new(url, verify_ssl: verify_ssl)
32
+ ws.connect
33
+ if block_given?
34
+ begin
35
+ block.call(ws)
36
+ ensure
37
+ ws.close
38
+ end
39
+ else
40
+ ws
41
+ end
42
+ end
43
+
44
+ def initialize(url, verify_ssl: true)
45
+ @uri = URI.parse(url)
46
+ @verify_ssl = verify_ssl
47
+ @socket = nil
48
+ @closed = false
49
+ @buf = String.new("", encoding: "BINARY")
50
+ end
51
+
52
+ def connect
53
+ tcp = TCPSocket.new(@uri.host, @uri.port)
54
+
55
+ @socket = if @uri.scheme == "wss"
56
+ ctx = OpenSSL::SSL::SSLContext.new
57
+ ctx.verify_mode = @verify_ssl ? OpenSSL::SSL::VERIFY_PEER : OpenSSL::SSL::VERIFY_NONE
58
+ ssl = OpenSSL::SSL::SSLSocket.new(tcp, ctx)
59
+ ssl.hostname = @uri.host
60
+ ssl.connect
61
+ ssl
62
+ else
63
+ tcp
64
+ end
65
+
66
+ handshake
67
+ self
68
+ end
69
+
70
+ # Iterate over incoming JSON frames. Yields each parsed frame Hash.
71
+ # Blocks until the socket closes or the block calls +break+.
72
+ def each_frame
73
+ loop do
74
+ frame = read_frame
75
+ break if frame.nil? # connection closed
76
+
77
+ next if frame.empty? # ping/pong or non-text
78
+
79
+ begin
80
+ yield JSON.parse(frame)
81
+ rescue JSON::ParserError
82
+ next # ignore non-JSON keep-alive text
83
+ end
84
+ end
85
+ rescue IOError, Errno::ECONNRESET, EOFError
86
+ # socket closed by remote
87
+ ensure
88
+ close
89
+ end
90
+
91
+ def close
92
+ return if @closed
93
+ @closed = true
94
+ begin
95
+ # Send a close frame (opcode 0x8) with no body, masked
96
+ send_frame(0x8, "")
97
+ rescue StandardError
98
+ # best-effort
99
+ ensure
100
+ @socket&.close rescue nil
101
+ end
102
+ end
103
+
104
+ private
105
+
106
+ def handshake
107
+ key = Base64.strict_encode64(SecureRandom.bytes(16))
108
+ path = @uri.request_uri
109
+ host = @uri.host + (@uri.port && ![80, 443].include?(@uri.port) ? ":#{@uri.port}" : "")
110
+
111
+ request = [
112
+ "GET #{path} HTTP/1.1",
113
+ "Host: #{host}",
114
+ "Upgrade: websocket",
115
+ "Connection: Upgrade",
116
+ "Sec-WebSocket-Key: #{key}",
117
+ "Sec-WebSocket-Version: 13",
118
+ "\r\n",
119
+ ].join("\r\n")
120
+
121
+ @socket.write(request)
122
+
123
+ # Read response headers
124
+ response = String.new("", encoding: "BINARY")
125
+ loop do
126
+ line = @socket.readline
127
+ response << line
128
+ break if line == "\r\n"
129
+ end
130
+
131
+ unless response.include?("101")
132
+ raise MelayaError.new("WebSocket handshake failed: #{response.lines.first.strip}", status: 0)
133
+ end
134
+
135
+ expected = Base64.strict_encode64(Digest::SHA1.digest("#{key}#{GUID}"))
136
+ unless response.include?(expected)
137
+ raise MelayaError.new("WebSocket Sec-WebSocket-Accept mismatch", status: 0)
138
+ end
139
+ end
140
+
141
+ # Read one complete WebSocket frame. Returns the payload string (text) or nil on close.
142
+ # Skips ping frames (sends pong) and continuation frames (not used by Melaya).
143
+ def read_frame
144
+ loop do
145
+ # Need at least 2 bytes for header
146
+ fill_buf(2)
147
+ b0 = @buf.getbyte(0)
148
+ b1 = @buf.getbyte(1)
149
+ @buf = @buf[2..]
150
+
151
+ # fin = (b0 & 0x80) != 0 # we accept single-frame messages
152
+ opcode = b0 & 0x0F
153
+ masked = (b1 & 0x80) != 0
154
+ len = b1 & 0x7F
155
+
156
+ len = case len
157
+ when 126
158
+ fill_buf(2)
159
+ v = @buf[0, 2].unpack1("n")
160
+ @buf = @buf[2..]
161
+ v
162
+ when 127
163
+ fill_buf(8)
164
+ v = @buf[0, 8].unpack1("Q>")
165
+ @buf = @buf[8..]
166
+ v
167
+ else
168
+ len
169
+ end
170
+
171
+ mask_key = nil
172
+ if masked
173
+ fill_buf(4)
174
+ mask_key = @buf[0, 4]
175
+ @buf = @buf[4..]
176
+ end
177
+
178
+ fill_buf(len)
179
+ payload = @buf[0, len].dup
180
+ @buf = @buf[len..]
181
+
182
+ if masked && mask_key
183
+ payload.bytes.each_with_index { |b, i| payload.setbyte(i, b ^ mask_key.getbyte(i % 4)) }
184
+ end
185
+
186
+ case opcode
187
+ when 0x1 # text
188
+ return payload.force_encoding("UTF-8")
189
+ when 0x2 # binary — treat as text (Melaya sends JSON)
190
+ return payload.force_encoding("UTF-8")
191
+ when 0x8 # close
192
+ return nil
193
+ when 0x9 # ping — send pong
194
+ send_frame(0xA, payload)
195
+ next
196
+ when 0xA # pong
197
+ next
198
+ else
199
+ next # ignore unknown opcodes
200
+ end
201
+ end
202
+ end
203
+
204
+ # Ensure @buf has at least +n+ bytes.
205
+ def fill_buf(n)
206
+ while @buf.bytesize < n
207
+ chunk = @socket.read(n - @buf.bytesize)
208
+ raise EOFError, "connection closed" if chunk.nil? || chunk.empty?
209
+ @buf = @buf + chunk
210
+ end
211
+ end
212
+
213
+ # Send a WebSocket frame with masking (client->server MUST be masked).
214
+ def send_frame(opcode, payload)
215
+ payload = payload.dup.force_encoding("BINARY")
216
+ len = payload.bytesize
217
+
218
+ header = String.new("", encoding: "BINARY")
219
+ header << (0x80 | opcode).chr(Encoding::BINARY)
220
+
221
+ mask_flag = 0x80 # clients must mask
222
+ if len <= 125
223
+ header << (mask_flag | len).chr(Encoding::BINARY)
224
+ elsif len <= 65535
225
+ header << (mask_flag | 126).chr(Encoding::BINARY)
226
+ header << [len].pack("n")
227
+ else
228
+ header << (mask_flag | 127).chr(Encoding::BINARY)
229
+ header << [len].pack("Q>")
230
+ end
231
+
232
+ mask_key = SecureRandom.bytes(4)
233
+ header << mask_key
234
+
235
+ masked_payload = payload.bytes.each_with_index.map { |b, i| b ^ mask_key.getbyte(i % 4) }.pack("C*")
236
+
237
+ @socket.write(header + masked_payload)
238
+ end
239
+ end
240
+
241
+ # WebSocket streaming API.
242
+ #
243
+ # Each method yields parsed JSON frame hashes to a block, or returns an
244
+ # Enumerator if no block is given. The connection is closed when the block
245
+ # returns or the Enumerator is exhausted.
246
+ #
247
+ # Public stream example:
248
+ # melaya.stream.ticker(exchange: "binance", symbol: "BTC/USDT", market: "spot") do |frame|
249
+ # puts frame["last"]
250
+ # break # close after first frame
251
+ # end
252
+ #
253
+ # Private stream example (ticket minted automatically):
254
+ # melaya.stream.strategies do |ev|
255
+ # puts ev["type"], ev["strategyId"]
256
+ # break
257
+ # end
258
+ class StreamAPI
259
+ DEFAULT_WS_URL = "wss://wss.melaya.org"
260
+
261
+ def initialize(api_key, ws_url, http, verify_ssl: true)
262
+ @api_key = api_key
263
+ @ws_url = ws_url.to_s.chomp("/")
264
+ @http = http
265
+ @verify_ssl = verify_ssl
266
+ end
267
+
268
+ # Live ticker frames.
269
+ def ticker(exchange:, symbol:, market: nil, &block)
270
+ open_public("/ws/ticker", compact(exchange: exchange, symbol: symbol, market: market), &block)
271
+ end
272
+
273
+ # Live order-book frames.
274
+ def orderbook(exchange:, symbol:, limit: nil, market: nil, &block)
275
+ open_public("/ws/orderbook", compact(exchange: exchange, symbol: symbol, limit: limit, market: market), &block)
276
+ end
277
+
278
+ # Live OHLCV candle frames.
279
+ def ohlcv(exchange:, symbol:, timeframe:, market: nil, &block)
280
+ open_public("/ws/ohlcv", compact(exchange: exchange, symbol: symbol, timeframe: timeframe, market: market), &block)
281
+ end
282
+
283
+ # Live public-trade frames.
284
+ def trades(exchange:, symbol:, market: nil, &block)
285
+ open_public("/ws/public-trades", compact(exchange: exchange, symbol: symbol, market: market), &block)
286
+ end
287
+
288
+ # Cross-exchange liquidation firehose. Omit +exchange+ for all venues.
289
+ def liquidations(exchange: nil, &block)
290
+ open_public("/ws/liquidations", compact(exchange: exchange), &block)
291
+ end
292
+
293
+ # Live strategy events for your account (cycle markers, agent messages,
294
+ # approval requests, executions, status). Mints a ticket, opens /ws/strategies.
295
+ def strategies(&block)
296
+ open_private("/ws/strategies", "strategies", {}, &block)
297
+ end
298
+
299
+ # Live private account feed for one connected exchange key (balance,
300
+ # positions, your orders/fills). Pass +api_key_id+ from +account.keys()+.
301
+ def private(exchange:, market: nil, api_key_id: nil, key_id: nil, symbol: nil, &block)
302
+ params = compact(
303
+ exchange: exchange, market: market,
304
+ apiKeyId: api_key_id, keyId: key_id, symbol: symbol
305
+ )
306
+ open_private("/ws/private", "private", params, &block)
307
+ end
308
+
309
+ private
310
+
311
+ def compact(hash)
312
+ hash.reject { |_, v| v.nil? }
313
+ end
314
+
315
+ def build_url(path, params)
316
+ q = params.merge("apiKey" => @api_key)
317
+ "#{@ws_url}#{path}?#{URI.encode_www_form(q)}"
318
+ end
319
+
320
+ def open_public(path, params, &block)
321
+ url = build_url(path, params.transform_keys(&:to_s))
322
+ MelayaWebSocket.connect(url, verify_ssl: @verify_ssl) do |ws|
323
+ ws.each_frame(&block)
324
+ end
325
+ end
326
+
327
+ def open_private(path, stream, params, &block)
328
+ body = { "stream" => stream }.merge(params.transform_keys(&:to_s))
329
+ ticket = @http.post("/api/v1/private/private-ticket", body)["wsTicket"]
330
+ url = "#{@ws_url}#{path}?#{URI.encode_www_form('wsTicket' => ticket)}"
331
+ MelayaWebSocket.connect(url, verify_ssl: @verify_ssl) do |ws|
332
+ ws.each_frame(&block)
333
+ end
334
+ end
335
+ end
336
+ end