melaya 0.1.2 → 0.2.0

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