intrinio-realtime 4.0.0 → 4.1.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.
Files changed (3) hide show
  1. checksums.yaml +4 -4
  2. data/lib/intrinio-realtime.rb +571 -571
  3. metadata +3 -3
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 406e143d185f62b73cc01284324409ac2160c1dcf527c478d58d00d66449cdaf
4
- data.tar.gz: 8879ac3c8866d649664e7e659c9340879c1a1c18264be3860fd9860f15be5d56
3
+ metadata.gz: df8c1b4a01075b66f59dd7f6fda2daa1f7b3cc1eb395f9016206d6cffe13d8bc
4
+ data.tar.gz: e1d255b939ed7fa3a27f7eea2cc35fccfaec8b82cb0265d9cab326017685525f
5
5
  SHA512:
6
- metadata.gz: e149912b30b5a00ceeb7296712840e7f2c1d09bbb97ab180944e01be23f40a2dcd06bbd73c76060c60f042b031234fd42f75dc3d6225719e13488f2755398259
7
- data.tar.gz: 3e8194d76da15107777542d3dbe2662bf69179b101b39615b04307ed0014880fdd5e697c4d608a32b722de93e71a1943d8dcd07061f47a4321b1c165277476f4
6
+ metadata.gz: fdc6916b86b3823144c2f9d1b1d4905d9c9af4022e229b0a9ece88a9b639fb5ac3bdb8ae44ebce9cec5aed8ab01e6932cd9ed683e2149724ec9b43893b86f02f
7
+ data.tar.gz: cfda64741414b456b33244dbcdfe67ad0ee9e604984dbee9f6c1f13bb08d0373714b7d94ef592e17db978092e10c40247a8dae9064f2745475c3f5a9b3aa5832
@@ -1,571 +1,571 @@
1
- require 'logger'
2
- require 'uri'
3
- #require 'http'
4
- require 'net/http'
5
- require 'eventmachine'
6
- require 'websocket-client-simple'
7
-
8
- module Intrinio
9
- module Realtime
10
- HEARTBEAT_TIME = 3
11
- SELF_HEAL_BACKOFFS = [0, 100, 500, 1000, 2000, 5000].freeze
12
- REALTIME = "REALTIME".freeze
13
- MANUAL = "MANUAL".freeze
14
- PROVIDERS = [REALTIME, MANUAL].freeze
15
- ASK = "Ask".freeze
16
- BID = "Bid".freeze
17
-
18
- def self.connect(options, on_trade, on_quote)
19
- EM.run do
20
- client = ::Intrinio::Realtime::Client.new(options, on_trade, on_quote)
21
- client.connect()
22
- end
23
- end
24
-
25
- class Trade
26
- def initialize(symbol, price, size, timestamp, total_volume)
27
- @symbol = symbol
28
- @price = price
29
- @size = size
30
- @timestamp = timestamp
31
- @total_volume = total_volume
32
- end
33
-
34
- def symbol
35
- @symbol
36
- end
37
-
38
- def price
39
- @price
40
- end
41
-
42
- def size
43
- @size
44
- end
45
-
46
- def timestamp
47
- @timestamp
48
- end
49
-
50
- def total_volume
51
- @total_volume
52
- end
53
-
54
- def to_s
55
- [@symbol, @price, @size, @timestamp, @total_volume].join(",")
56
- end
57
- end
58
-
59
- class Quote
60
- def initialize(type, symbol, price, size, timestamp)
61
- @type = type
62
- @symbol = symbol
63
- @price = price
64
- @size = size
65
- @timestamp = timestamp
66
- end
67
-
68
- def type
69
- @type
70
- end
71
-
72
- def symbol
73
- @symbol
74
- end
75
-
76
- def price
77
- @price
78
- end
79
-
80
- def size
81
- @size
82
- end
83
-
84
- def timestamp
85
- @timestamp
86
- end
87
-
88
- def to_s
89
- [@symbol, @type, @price, @size, @timestamp].join(",")
90
- end
91
- end
92
-
93
- class Client
94
-
95
- def initialize(options, on_trade, on_quote)
96
- raise "Options parameter is required" if options.nil? || !options.is_a?(Hash)
97
- @stop = false
98
- @messages = Queue.new
99
- raise "Unable to create queue." if @messages.nil?
100
- @on_trade = on_trade
101
- @on_quote = on_quote
102
-
103
- @api_key = options[:api_key]
104
- raise "API Key was formatted invalidly." if @api_key && !valid_api_key?(@api_key)
105
-
106
- unless @api_key
107
- @username = options[:username]
108
- @password = options[:password]
109
- raise "API Key or Username and password are required" if @username.nil? || @username.empty? || @password.nil? || @password.empty?
110
- end
111
-
112
- @provider = options[:provider]
113
- unless @provider
114
- @provider = REALTIME
115
- end
116
- raise "Provider must be 'REALTIME' or 'MANUAL'" unless PROVIDERS.include?(@provider)
117
-
118
- @ip_address = options[:ip_address]
119
- raise "Missing option ip_address while in MANUAL mode." if @provider == MANUAL and (@ip_address.nil? || @ip_address.empty?)
120
-
121
- @trades_only = options[:trades_only]
122
- if @trades_only.nil?
123
- @trades_only = false
124
- end
125
-
126
- @thread_quantity = options[:threads]
127
- unless @thread_quantity
128
- @thread_quantity = 4
129
- end
130
-
131
- @threads = []
132
-
133
- @channels = []
134
- @channels = parse_channels(options[:channels]) if options[:channels]
135
- bad_channels = @channels.select{|x| !x.is_a?(String)}
136
- raise "Invalid channels to join: #{bad_channels}" unless bad_channels.empty?
137
-
138
- if options[:logger] == false
139
- @logger = nil
140
- elsif !options[:logger].nil?
141
- @logger = options[:logger]
142
- else
143
- @logger = Logger.new($stdout)
144
- @logger.level = Logger::INFO
145
- end
146
-
147
- @ready = false
148
- @joined_channels = []
149
- @heartbeat_timer = nil
150
- @selfheal_timer = nil
151
- @selfheal_backoffs = Array.new(SELF_HEAL_BACKOFFS)
152
- @ws = nil
153
- end
154
-
155
- def provider
156
- @provider
157
- end
158
-
159
- def join(*channels)
160
- channels = parse_channels(channels)
161
- nonconforming = channels.select{|x| !x.is_a?(String)}
162
- return error("Invalid channels to join: #{nonconforming}") unless nonconforming.empty?
163
-
164
- @channels.concat(channels)
165
- @channels.uniq!
166
- debug "Joining channels #{channels}"
167
-
168
- refresh_channels()
169
- end
170
-
171
- def leave(*channels)
172
- channels = parse_channels(channels)
173
- nonconforming = channels.find{|x| !x.is_a?(String)}
174
- return error("Invalid channels to leave: #{nonconforming}") unless nonconforming.empty?
175
-
176
- channels.each{|c| @channels.delete(c)}
177
- debug "Leaving channels #{channels}"
178
-
179
- refresh_channels()
180
- end
181
-
182
- def leave_all
183
- @channels = []
184
- debug "Leaving all channels"
185
- refresh_channels()
186
- end
187
-
188
- def connect
189
- raise "Must be run from within an EventMachine run loop" unless EM.reactor_running?
190
- return warn("Already connected!") if @ready
191
- debug "Connecting..."
192
-
193
- catch :fatal do
194
- begin
195
- @closing = false
196
- @ready = false
197
- refresh_token()
198
- refresh_websocket()
199
- rescue StandardError => e
200
- error("Connection error: #{e} \n#{e.backtrace.join("\n")}")
201
- try_self_heal()
202
- end
203
- end
204
- end
205
-
206
- def disconnect
207
- EM.cancel_timer(@heartbeat_timer) if @heartbeat_timer
208
- EM.cancel_timer(@selfheal_timer) if @selfheal_timer
209
- @ready = false
210
- @closing = true
211
- @channels = []
212
- @joined_channels = []
213
- @ws.close() if @ws
214
- @stop = true
215
- sleep(2)
216
- @threads.each { |thread|
217
- if !thread.nil? && (!thread.pending_interrupt? || thread.status == "run" || thread.status == "Sleeping")
218
- then thread.join(7)
219
- elsif !thread.nil?
220
- then thread.kill
221
- end
222
- }
223
- @threads = []
224
- @stop = false
225
- info "Connection closed"
226
- end
227
-
228
- def on_trade(on_trade)
229
- @on_trade = on_trade
230
- end
231
-
232
- def on_quote(on_quote)
233
- @on_quote = on_quote
234
- end
235
-
236
- private
237
-
238
- def queue_message(message)
239
- @messages.enq(message)
240
- end
241
-
242
- def parse_uint64(data)
243
- data.map { |i| [sprintf('%02x',i)].pack('H2') }.join.unpack('Q<').first
244
- end
245
-
246
- def parse_int32(data)
247
- data.map { |i| [sprintf('%02x',i)].pack('H2') }.join.unpack('l<').first
248
- end
249
-
250
- def parse_uint32(data)
251
- data.map { |i| [sprintf('%02x',i)].pack('H2') }.join.unpack('V').first
252
- end
253
-
254
- def parse_float32(data)
255
- data.map { |i| [sprintf('%02x',i)].pack('H2') }.join.unpack('e').first
256
- end
257
-
258
- def parse_trade(data, start_index, symbol_length)
259
- symbol = data[start_index + 2, symbol_length].map!{|c| c.chr}.join
260
- price = parse_float32(data[start_index + 2 + symbol_length, 4])
261
- size = parse_uint32(data[start_index + 6 + symbol_length, 4])
262
- timestamp = parse_uint64(data[start_index + 10 + symbol_length, 8])
263
- total_volume = parse_uint32(data[start_index + 18 + symbol_length, 4])
264
- return Trade.new(symbol, price, size, timestamp, total_volume)
265
- end
266
-
267
- def parse_quote(data, start_index, symbol_length, msg_type)
268
- type = case when msg_type == 1 then ASK when msg_type == 2 then BID end
269
- symbol = data[start_index + 2, symbol_length].map!{|c| c.chr}.join
270
- price = parse_float32(data[start_index + 2 + symbol_length, 4])
271
- size = parse_uint32(data[start_index + 6 + symbol_length, 4])
272
- timestamp = parse_uint64(data[start_index + 10 + symbol_length, 8])
273
- return Quote.new(type, symbol, price, size, timestamp)
274
- end
275
-
276
- def handle_message(data, start_index)
277
- msg_type = data[start_index]
278
- symbol_length = data[start_index + 1]
279
- case msg_type
280
- when 0 then
281
- trade = parse_trade(data, start_index, symbol_length)
282
- @on_trade.call(trade)
283
- return start_index + 22 + symbol_length
284
- when 1 || 2 then
285
- quote = parse_quote(data, start_index, symbol_length, msg_type)
286
- @on_quote.call(quote)
287
- return start_index + 18 + symbol_length
288
- end
289
- return start_index
290
- end
291
-
292
- def handle_data
293
- Thread.current.priority -= 1
294
- me = self
295
- pop = nil
296
- until @stop do
297
- begin
298
- pop = nil
299
- data = nil
300
- pop = @messages.deq
301
- unless pop.nil?
302
- begin
303
- data = pop.unpack('C*')
304
- rescue StandardError => ex
305
- me.send :error, "Error unpacking data from queue: #{ex} #{pop}"
306
- next
307
- end
308
- if !data then me.send :error, "Cannot process data. Data is nil. #{pop}" end
309
- start_index = 1
310
- count = data[0]
311
- # These are grouped (many) messages.
312
- # The first byte tells us how many there are.
313
- # From there, check the type and symbol length at index 0 of each chunk to know how many bytes each message has.
314
- count.times {start_index = handle_message(data, start_index)}
315
- end
316
- if pop.nil? then sleep(0.1) end
317
- rescue StandardError => e
318
- me.send :error, "Error handling message from queue: #{e} #{pop} : #{data} ; count: #{count} ; start index: #{start_index}"
319
- rescue Exception => e
320
- #me.send :error, "General error handling message from queue: #{e} #{pop} : #{data} ; count: #{count} ; start index: #{start_index}"
321
- end
322
- end
323
- end
324
-
325
- def refresh_token
326
- @token = nil
327
-
328
- uri = URI.parse(auth_url)
329
- http = Net::HTTP.new(uri.host, uri.port)
330
- http.use_ssl = true if (auth_url.include?("https"))
331
- http.start
332
- request = Net::HTTP::Get.new(uri.request_uri)
333
- request.add_field("Client-Information", "IntrinioRealtimeRubySDKv4.0")
334
-
335
- unless @api_key
336
- request.basic_auth(@username, @password)
337
- end
338
-
339
- response = http.request(request)
340
-
341
- return fatal("Unable to authorize") if response.code == "401"
342
- return fatal("Could not get auth token") if response.code != "200"
343
-
344
- @token = response.body
345
- debug "Token refreshed"
346
- end
347
-
348
- def auth_url
349
- url = ""
350
-
351
- case @provider
352
- when REALTIME then url = "https://realtime-mx.intrinio.com/auth"
353
- when MANUAL then url = "http://" + @ip_address + "/auth"
354
- end
355
-
356
- url = api_auth_url(url) if @api_key
357
-
358
- url
359
- end
360
-
361
- def api_auth_url(url)
362
- if url.include? "?"
363
- url = "#{url}&"
364
- else
365
- url = "#{url}?"
366
- end
367
-
368
- "#{url}api_key=#{@api_key}"
369
- end
370
-
371
- def socket_url
372
- case @provider
373
- when REALTIME then "wss://realtime-mx.intrinio.com/socket/websocket?vsn=1.0.0&token=#{@token}"
374
- when MANUAL then "ws://" + @ip_address + "/socket/websocket?vsn=1.0.0&token=#{@token}"
375
- end
376
- end
377
-
378
- def refresh_websocket
379
- me = self
380
-
381
- @ws.close() unless @ws.nil?
382
- @ready = false
383
- @joined_channels = []
384
-
385
- @stop = true
386
- sleep(2)
387
- @threads.each { |thread|
388
- if !thread.nil? && (!thread.pending_interrupt? || thread.status == "run" || thread.status == "Sleeping")
389
- then thread.join(7)
390
- elsif !thread.nil?
391
- then thread.kill
392
- end
393
- }
394
- @threads = []
395
- @stop = false
396
- @thread_quantity.times {@threads << Thread.new{handle_data}}
397
-
398
- @ws = ws = WebSocket::Client::Simple.connect(socket_url)
399
- me.send :info, "Connection opening"
400
-
401
- ws.on :open do
402
- me.send :info, "Connection established"
403
- me.send :ready, true
404
- if [REALTIME, MANUAL].include?(me.send(:provider))
405
- me.send :refresh_channels
406
- end
407
- me.send :start_heartbeat
408
- me.send :stop_self_heal
409
- end
410
-
411
- ws.on :message do |frame|
412
- data_message = frame.data
413
- #me.send :debug, "Message: #{data_message}"
414
- begin
415
- unless data_message.nil?
416
- then me.send :queue_message, data_message
417
- end
418
- rescue StandardError => e
419
- me.send :error, "Error adding message to queue: #{data_message} #{e}"
420
- end
421
- end
422
-
423
- ws.on :close do |e|
424
- me.send :ready, false
425
- me.send :info, "Connection closing...: #{e}"
426
- me.send :try_self_heal
427
- end
428
-
429
- ws.on :error do |e|
430
- me.send :ready, false
431
- me.send :error, "Connection error: #{e}"
432
- me.send :try_self_heal
433
- end
434
- end
435
-
436
- def refresh_channels
437
- return unless @ready
438
- debug "Refreshing channels"
439
-
440
- # Join new channels
441
- new_channels = @channels - @joined_channels
442
- new_channels.each do |channel|
443
- #msg = join_message(channel)
444
- #@ws.send(msg.to_json)
445
- msg = join_binary_message(channel)
446
- @ws.send(msg)
447
- info "Joined #{channel}"
448
- end
449
-
450
- # Leave old channels
451
- old_channels = @joined_channels - @channels
452
- old_channels.each do |channel|
453
- #msg = leave__message(channel)
454
- #@ws.send(msg.to_json)
455
- msg = leave_binary_message(channel)
456
- @ws.send(msg)
457
- info "Left #{channel}"
458
- end
459
-
460
- @channels.uniq!
461
- @joined_channels = Array.new(@channels)
462
- debug "Current channels: #{@channels}"
463
- end
464
-
465
- def start_heartbeat
466
- EM.cancel_timer(@heartbeat_timer) if @heartbeat_timer
467
- @heartbeat_timer = EM.add_periodic_timer(HEARTBEAT_TIME) do
468
- if msg = heartbeat_msg()
469
- @ws.send(msg)
470
- debug "Heartbeat #{msg}"
471
- end
472
- end
473
- end
474
-
475
- def heartbeat_msg
476
- ""
477
- end
478
-
479
- def stop_heartbeat
480
- EM.cancel_timer(@heartbeat_timer) if @heartbeat_timer
481
- end
482
-
483
- def try_self_heal
484
- return if @closing
485
- debug "Attempting to self-heal"
486
-
487
- time = @selfheal_backoffs.first
488
- @selfheal_backoffs.delete_at(0) if @selfheal_backoffs.count > 1
489
-
490
- EM.cancel_timer(@heartbeat_timer) if @heartbeat_timer
491
- EM.cancel_timer(@selfheal_timer) if @selfheal_timer
492
-
493
- @selfheal_timer = EM.add_timer(time/1000) do
494
- connect()
495
- end
496
- end
497
-
498
- def stop_self_heal
499
- EM.cancel_timer(@selfheal_timer) if @selfheal_timer
500
- @selfheal_backoffs = Array.new(SELF_HEAL_BACKOFFS)
501
- end
502
-
503
- def ready(val)
504
- @ready = val
505
- end
506
-
507
- def debug(message)
508
- message = "IntrinioRealtime | #{message}"
509
- @logger.debug(message) rescue
510
- nil
511
- end
512
-
513
- def info(message)
514
- message = "IntrinioRealtime | #{message}"
515
- @logger.info(message) rescue
516
- nil
517
- end
518
-
519
- def error(message)
520
- message = "IntrinioRealtime | #{message}"
521
- @logger.error(message) rescue
522
- nil
523
- end
524
-
525
- def fatal(message)
526
- message = "IntrinioRealtime | #{message}"
527
- @logger.fatal(message) rescue
528
- EM.stop_event_loop
529
- throw :fatal
530
- nil
531
- end
532
-
533
- def parse_channels(channels)
534
- channels.flatten!
535
- channels.uniq!
536
- channels.compact!
537
- channels
538
- end
539
-
540
- def join_binary_message(channel)
541
- if (channel == "lobby") && (@trades_only == false)
542
- return [74, 0, 36, 70, 73, 82, 69, 72, 79, 83, 69].pack('C*') #74, not trades only, "$FIREHOSE"
543
- elsif (channel == "lobby") && (@trades_only == true)
544
- return [74, 1, 36, 70, 73, 82, 69, 72, 79, 83, 69].pack('C*') #74, trades only, "$FIREHOSE"
545
- else
546
- bytes = [74, 0]
547
- if (@trades_only == true)
548
- bytes[1] = 1
549
- end
550
- return bytes.concat(channel.bytes).pack('C*')
551
- end
552
- end
553
-
554
- def leave_binary_message(channel)
555
- if channel == "lobby"
556
- return [76, 36, 70, 73, 82, 69, 72, 79, 83, 69].pack('C*') #74, not trades only, "$FIREHOSE"
557
- else
558
- bytes = [76]
559
- return bytes.concat(channel.bytes).pack('C*')
560
- end
561
- end
562
-
563
- def valid_api_key?(api_key)
564
- return false unless api_key.is_a?(String)
565
- return false if api_key.empty?
566
- true
567
- end
568
-
569
- end
570
- end
571
- end
1
+ require 'logger'
2
+ require 'uri'
3
+ #require 'http'
4
+ require 'net/http'
5
+ require 'eventmachine'
6
+ require 'websocket-client-simple'
7
+
8
+ module Intrinio
9
+ module Realtime
10
+ HEARTBEAT_TIME = 3
11
+ SELF_HEAL_BACKOFFS = [0, 100, 500, 1000, 2000, 5000].freeze
12
+ REALTIME = "REALTIME".freeze
13
+ MANUAL = "MANUAL".freeze
14
+ PROVIDERS = [REALTIME, MANUAL].freeze
15
+ ASK = "Ask".freeze
16
+ BID = "Bid".freeze
17
+
18
+ def self.connect(options, on_trade, on_quote)
19
+ EM.run do
20
+ client = ::Intrinio::Realtime::Client.new(options, on_trade, on_quote)
21
+ client.connect()
22
+ end
23
+ end
24
+
25
+ class Trade
26
+ def initialize(symbol, price, size, timestamp, total_volume)
27
+ @symbol = symbol
28
+ @price = price
29
+ @size = size
30
+ @timestamp = timestamp
31
+ @total_volume = total_volume
32
+ end
33
+
34
+ def symbol
35
+ @symbol
36
+ end
37
+
38
+ def price
39
+ @price
40
+ end
41
+
42
+ def size
43
+ @size
44
+ end
45
+
46
+ def timestamp
47
+ @timestamp
48
+ end
49
+
50
+ def total_volume
51
+ @total_volume
52
+ end
53
+
54
+ def to_s
55
+ [@symbol, @price, @size, @timestamp, @total_volume].join(",")
56
+ end
57
+ end
58
+
59
+ class Quote
60
+ def initialize(type, symbol, price, size, timestamp)
61
+ @type = type
62
+ @symbol = symbol
63
+ @price = price
64
+ @size = size
65
+ @timestamp = timestamp
66
+ end
67
+
68
+ def type
69
+ @type
70
+ end
71
+
72
+ def symbol
73
+ @symbol
74
+ end
75
+
76
+ def price
77
+ @price
78
+ end
79
+
80
+ def size
81
+ @size
82
+ end
83
+
84
+ def timestamp
85
+ @timestamp
86
+ end
87
+
88
+ def to_s
89
+ [@symbol, @type, @price, @size, @timestamp].join(",")
90
+ end
91
+ end
92
+
93
+ class Client
94
+
95
+ def initialize(options, on_trade, on_quote)
96
+ raise "Options parameter is required" if options.nil? || !options.is_a?(Hash)
97
+ @stop = false
98
+ @messages = Queue.new
99
+ raise "Unable to create queue." if @messages.nil?
100
+ @on_trade = on_trade
101
+ @on_quote = on_quote
102
+
103
+ @api_key = options[:api_key]
104
+ raise "API Key was formatted invalidly." if @api_key && !valid_api_key?(@api_key)
105
+
106
+ unless @api_key
107
+ @username = options[:username]
108
+ @password = options[:password]
109
+ raise "API Key or Username and password are required" if @username.nil? || @username.empty? || @password.nil? || @password.empty?
110
+ end
111
+
112
+ @provider = options[:provider]
113
+ unless @provider
114
+ @provider = REALTIME
115
+ end
116
+ raise "Provider must be 'REALTIME' or 'MANUAL'" unless PROVIDERS.include?(@provider)
117
+
118
+ @ip_address = options[:ip_address]
119
+ raise "Missing option ip_address while in MANUAL mode." if @provider == MANUAL and (@ip_address.nil? || @ip_address.empty?)
120
+
121
+ @trades_only = options[:trades_only]
122
+ if @trades_only.nil?
123
+ @trades_only = false
124
+ end
125
+
126
+ @thread_quantity = options[:threads]
127
+ unless @thread_quantity
128
+ @thread_quantity = 4
129
+ end
130
+
131
+ @threads = []
132
+
133
+ @channels = []
134
+ @channels = parse_channels(options[:channels]) if options[:channels]
135
+ bad_channels = @channels.select{|x| !x.is_a?(String)}
136
+ raise "Invalid channels to join: #{bad_channels}" unless bad_channels.empty?
137
+
138
+ if options[:logger] == false
139
+ @logger = nil
140
+ elsif !options[:logger].nil?
141
+ @logger = options[:logger]
142
+ else
143
+ @logger = Logger.new($stdout)
144
+ @logger.level = Logger::INFO
145
+ end
146
+
147
+ @ready = false
148
+ @joined_channels = []
149
+ @heartbeat_timer = nil
150
+ @selfheal_timer = nil
151
+ @selfheal_backoffs = Array.new(SELF_HEAL_BACKOFFS)
152
+ @ws = nil
153
+ end
154
+
155
+ def provider
156
+ @provider
157
+ end
158
+
159
+ def join(*channels)
160
+ channels = parse_channels(channels)
161
+ nonconforming = channels.select{|x| !x.is_a?(String)}
162
+ return error("Invalid channels to join: #{nonconforming}") unless nonconforming.empty?
163
+
164
+ @channels.concat(channels)
165
+ @channels.uniq!
166
+ debug "Joining channels #{channels}"
167
+
168
+ refresh_channels()
169
+ end
170
+
171
+ def leave(*channels)
172
+ channels = parse_channels(channels)
173
+ nonconforming = channels.find{|x| !x.is_a?(String)}
174
+ return error("Invalid channels to leave: #{nonconforming}") unless nonconforming.empty?
175
+
176
+ channels.each{|c| @channels.delete(c)}
177
+ debug "Leaving channels #{channels}"
178
+
179
+ refresh_channels()
180
+ end
181
+
182
+ def leave_all
183
+ @channels = []
184
+ debug "Leaving all channels"
185
+ refresh_channels()
186
+ end
187
+
188
+ def connect
189
+ raise "Must be run from within an EventMachine run loop" unless EM.reactor_running?
190
+ return warn("Already connected!") if @ready
191
+ debug "Connecting..."
192
+
193
+ catch :fatal do
194
+ begin
195
+ @closing = false
196
+ @ready = false
197
+ refresh_token()
198
+ refresh_websocket()
199
+ rescue StandardError => e
200
+ error("Connection error: #{e} \n#{e.backtrace.join("\n")}")
201
+ try_self_heal()
202
+ end
203
+ end
204
+ end
205
+
206
+ def disconnect
207
+ EM.cancel_timer(@heartbeat_timer) if @heartbeat_timer
208
+ EM.cancel_timer(@selfheal_timer) if @selfheal_timer
209
+ @ready = false
210
+ @closing = true
211
+ @channels = []
212
+ @joined_channels = []
213
+ @ws.close() if @ws
214
+ @stop = true
215
+ sleep(2)
216
+ @threads.each { |thread|
217
+ if !thread.nil? && (!thread.pending_interrupt? || thread.status == "run" || thread.status == "Sleeping")
218
+ then thread.join(7)
219
+ elsif !thread.nil?
220
+ then thread.kill
221
+ end
222
+ }
223
+ @threads = []
224
+ @stop = false
225
+ info "Connection closed"
226
+ end
227
+
228
+ def on_trade(on_trade)
229
+ @on_trade = on_trade
230
+ end
231
+
232
+ def on_quote(on_quote)
233
+ @on_quote = on_quote
234
+ end
235
+
236
+ private
237
+
238
+ def queue_message(message)
239
+ @messages.enq(message)
240
+ end
241
+
242
+ def parse_uint64(data)
243
+ data.map { |i| [sprintf('%02x',i)].pack('H2') }.join.unpack('Q<').first
244
+ end
245
+
246
+ def parse_int32(data)
247
+ data.map { |i| [sprintf('%02x',i)].pack('H2') }.join.unpack('l<').first
248
+ end
249
+
250
+ def parse_uint32(data)
251
+ data.map { |i| [sprintf('%02x',i)].pack('H2') }.join.unpack('V').first
252
+ end
253
+
254
+ def parse_float32(data)
255
+ data.map { |i| [sprintf('%02x',i)].pack('H2') }.join.unpack('e').first
256
+ end
257
+
258
+ def parse_trade(data, start_index, symbol_length)
259
+ symbol = data[start_index + 2, symbol_length].map!{|c| c.chr}.join
260
+ price = parse_float32(data[start_index + 2 + symbol_length, 4])
261
+ size = parse_uint32(data[start_index + 6 + symbol_length, 4])
262
+ timestamp = parse_uint64(data[start_index + 10 + symbol_length, 8])
263
+ total_volume = parse_uint32(data[start_index + 18 + symbol_length, 4])
264
+ return Trade.new(symbol, price, size, timestamp, total_volume)
265
+ end
266
+
267
+ def parse_quote(data, start_index, symbol_length, msg_type)
268
+ type = case when msg_type == 1 then ASK when msg_type == 2 then BID end
269
+ symbol = data[start_index + 2, symbol_length].map!{|c| c.chr}.join
270
+ price = parse_float32(data[start_index + 2 + symbol_length, 4])
271
+ size = parse_uint32(data[start_index + 6 + symbol_length, 4])
272
+ timestamp = parse_uint64(data[start_index + 10 + symbol_length, 8])
273
+ return Quote.new(type, symbol, price, size, timestamp)
274
+ end
275
+
276
+ def handle_message(data, start_index)
277
+ msg_type = data[start_index]
278
+ symbol_length = data[start_index + 1]
279
+ case msg_type
280
+ when 0 then
281
+ trade = parse_trade(data, start_index, symbol_length)
282
+ @on_trade.call(trade)
283
+ return start_index + 22 + symbol_length
284
+ when 1 || 2 then
285
+ quote = parse_quote(data, start_index, symbol_length, msg_type)
286
+ @on_quote.call(quote)
287
+ return start_index + 18 + symbol_length
288
+ end
289
+ return start_index
290
+ end
291
+
292
+ def handle_data
293
+ Thread.current.priority -= 1
294
+ me = self
295
+ pop = nil
296
+ until @stop do
297
+ begin
298
+ pop = nil
299
+ data = nil
300
+ pop = @messages.deq
301
+ unless pop.nil?
302
+ begin
303
+ data = pop.unpack('C*')
304
+ rescue StandardError => ex
305
+ me.send :error, "Error unpacking data from queue: #{ex} #{pop}"
306
+ next
307
+ end
308
+ if !data then me.send :error, "Cannot process data. Data is nil. #{pop}" end
309
+ start_index = 1
310
+ count = data[0]
311
+ # These are grouped (many) messages.
312
+ # The first byte tells us how many there are.
313
+ # From there, check the type and symbol length at index 0 of each chunk to know how many bytes each message has.
314
+ count.times {start_index = handle_message(data, start_index)}
315
+ end
316
+ if pop.nil? then sleep(0.1) end
317
+ rescue StandardError => e
318
+ me.send :error, "Error handling message from queue: #{e} #{pop} : #{data} ; count: #{count} ; start index: #{start_index}"
319
+ rescue Exception => e
320
+ #me.send :error, "General error handling message from queue: #{e} #{pop} : #{data} ; count: #{count} ; start index: #{start_index}"
321
+ end
322
+ end
323
+ end
324
+
325
+ def refresh_token
326
+ @token = nil
327
+
328
+ uri = URI.parse(auth_url)
329
+ http = Net::HTTP.new(uri.host, uri.port)
330
+ http.use_ssl = true if (auth_url.include?("https"))
331
+ http.start
332
+ request = Net::HTTP::Get.new(uri.request_uri)
333
+ request.add_field("Client-Information", "IntrinioRealtimeRubySDKv4.0")
334
+
335
+ unless @api_key
336
+ request.basic_auth(@username, @password)
337
+ end
338
+
339
+ response = http.request(request)
340
+
341
+ return fatal("Unable to authorize") if response.code == "401"
342
+ return fatal("Could not get auth token") if response.code != "200"
343
+
344
+ @token = response.body
345
+ debug "Token refreshed"
346
+ end
347
+
348
+ def auth_url
349
+ url = ""
350
+
351
+ case @provider
352
+ when REALTIME then url = "https://realtime-mx.intrinio.com/auth"
353
+ when MANUAL then url = "http://" + @ip_address + "/auth"
354
+ end
355
+
356
+ url = api_auth_url(url) if @api_key
357
+
358
+ url
359
+ end
360
+
361
+ def api_auth_url(url)
362
+ if url.include? "?"
363
+ url = "#{url}&"
364
+ else
365
+ url = "#{url}?"
366
+ end
367
+
368
+ "#{url}api_key=#{@api_key}"
369
+ end
370
+
371
+ def socket_url
372
+ case @provider
373
+ when REALTIME then "wss://realtime-mx.intrinio.com/socket/websocket?vsn=1.0.0&token=#{@token}"
374
+ when MANUAL then "ws://" + @ip_address + "/socket/websocket?vsn=1.0.0&token=#{@token}"
375
+ end
376
+ end
377
+
378
+ def refresh_websocket
379
+ me = self
380
+
381
+ @ws.close() unless @ws.nil?
382
+ @ready = false
383
+ @joined_channels = []
384
+
385
+ @stop = true
386
+ sleep(2)
387
+ @threads.each { |thread|
388
+ if !thread.nil? && (!thread.pending_interrupt? || thread.status == "run" || thread.status == "Sleeping")
389
+ then thread.join(7)
390
+ elsif !thread.nil?
391
+ then thread.kill
392
+ end
393
+ }
394
+ @threads = []
395
+ @stop = false
396
+ @thread_quantity.times {@threads << Thread.new{handle_data}}
397
+
398
+ @ws = ws = WebSocket::Client::Simple.connect(socket_url)
399
+ me.send :info, "Connection opening"
400
+
401
+ ws.on :open do
402
+ me.send :info, "Connection established"
403
+ me.send :ready, true
404
+ if [REALTIME, MANUAL].include?(me.send(:provider))
405
+ me.send :refresh_channels
406
+ end
407
+ me.send :start_heartbeat
408
+ me.send :stop_self_heal
409
+ end
410
+
411
+ ws.on :message do |frame|
412
+ data_message = frame.data
413
+ #me.send :debug, "Message: #{data_message}"
414
+ begin
415
+ unless data_message.nil?
416
+ then me.send :queue_message, data_message
417
+ end
418
+ rescue StandardError => e
419
+ me.send :error, "Error adding message to queue: #{data_message} #{e}"
420
+ end
421
+ end
422
+
423
+ ws.on :close do |e|
424
+ me.send :ready, false
425
+ me.send :info, "Connection closing...: #{e}"
426
+ me.send :try_self_heal
427
+ end
428
+
429
+ ws.on :error do |e|
430
+ me.send :ready, false
431
+ me.send :error, "Connection error: #{e}"
432
+ me.send :try_self_heal
433
+ end
434
+ end
435
+
436
+ def refresh_channels
437
+ return unless @ready
438
+ debug "Refreshing channels"
439
+
440
+ # Join new channels
441
+ new_channels = @channels - @joined_channels
442
+ new_channels.each do |channel|
443
+ #msg = join_message(channel)
444
+ #@ws.send(msg.to_json)
445
+ msg = join_binary_message(channel)
446
+ @ws.send(msg)
447
+ info "Joined #{channel}"
448
+ end
449
+
450
+ # Leave old channels
451
+ old_channels = @joined_channels - @channels
452
+ old_channels.each do |channel|
453
+ #msg = leave__message(channel)
454
+ #@ws.send(msg.to_json)
455
+ msg = leave_binary_message(channel)
456
+ @ws.send(msg)
457
+ info "Left #{channel}"
458
+ end
459
+
460
+ @channels.uniq!
461
+ @joined_channels = Array.new(@channels)
462
+ debug "Current channels: #{@channels}"
463
+ end
464
+
465
+ def start_heartbeat
466
+ EM.cancel_timer(@heartbeat_timer) if @heartbeat_timer
467
+ @heartbeat_timer = EM.add_periodic_timer(HEARTBEAT_TIME) do
468
+ if msg = heartbeat_msg()
469
+ @ws.send(msg)
470
+ debug "Heartbeat #{msg}"
471
+ end
472
+ end
473
+ end
474
+
475
+ def heartbeat_msg
476
+ ""
477
+ end
478
+
479
+ def stop_heartbeat
480
+ EM.cancel_timer(@heartbeat_timer) if @heartbeat_timer
481
+ end
482
+
483
+ def try_self_heal
484
+ return if @closing
485
+ debug "Attempting to self-heal"
486
+
487
+ time = @selfheal_backoffs.first
488
+ @selfheal_backoffs.delete_at(0) if @selfheal_backoffs.count > 1
489
+
490
+ EM.cancel_timer(@heartbeat_timer) if @heartbeat_timer
491
+ EM.cancel_timer(@selfheal_timer) if @selfheal_timer
492
+
493
+ @selfheal_timer = EM.add_timer(time/1000) do
494
+ connect()
495
+ end
496
+ end
497
+
498
+ def stop_self_heal
499
+ EM.cancel_timer(@selfheal_timer) if @selfheal_timer
500
+ @selfheal_backoffs = Array.new(SELF_HEAL_BACKOFFS)
501
+ end
502
+
503
+ def ready(val)
504
+ @ready = val
505
+ end
506
+
507
+ def debug(message)
508
+ message = "IntrinioRealtime | #{message}"
509
+ @logger.debug(message) rescue
510
+ nil
511
+ end
512
+
513
+ def info(message)
514
+ message = "IntrinioRealtime | #{message}"
515
+ @logger.info(message) rescue
516
+ nil
517
+ end
518
+
519
+ def error(message)
520
+ message = "IntrinioRealtime | #{message}"
521
+ @logger.error(message) rescue
522
+ nil
523
+ end
524
+
525
+ def fatal(message)
526
+ message = "IntrinioRealtime | #{message}"
527
+ @logger.fatal(message) rescue
528
+ EM.stop_event_loop
529
+ throw :fatal
530
+ nil
531
+ end
532
+
533
+ def parse_channels(channels)
534
+ channels.flatten!
535
+ channels.uniq!
536
+ channels.compact!
537
+ channels
538
+ end
539
+
540
+ def join_binary_message(channel)
541
+ if (channel == "lobby") && (@trades_only == false)
542
+ return [74, 0, 36, 70, 73, 82, 69, 72, 79, 83, 69].pack('C*') #74, not trades only, "$FIREHOSE"
543
+ elsif (channel == "lobby") && (@trades_only == true)
544
+ return [74, 1, 36, 70, 73, 82, 69, 72, 79, 83, 69].pack('C*') #74, trades only, "$FIREHOSE"
545
+ else
546
+ bytes = [74, 0]
547
+ if (@trades_only == true)
548
+ bytes[1] = 1
549
+ end
550
+ return bytes.concat(channel.bytes).pack('C*')
551
+ end
552
+ end
553
+
554
+ def leave_binary_message(channel)
555
+ if channel == "lobby"
556
+ return [76, 36, 70, 73, 82, 69, 72, 79, 83, 69].pack('C*') #74, not trades only, "$FIREHOSE"
557
+ else
558
+ bytes = [76]
559
+ return bytes.concat(channel.bytes).pack('C*')
560
+ end
561
+ end
562
+
563
+ def valid_api_key?(api_key)
564
+ return false unless api_key.is_a?(String)
565
+ return false if api_key.empty?
566
+ true
567
+ end
568
+
569
+ end
570
+ end
571
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: intrinio-realtime
3
3
  version: !ruby/object:Gem::Version
4
- version: 4.0.0
4
+ version: 4.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Intrinio
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2022-04-21 00:00:00.000000000 Z
11
+ date: 2023-01-24 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: eventmachine
@@ -93,7 +93,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
93
93
  - !ruby/object:Gem::Version
94
94
  version: '0'
95
95
  requirements: []
96
- rubygems_version: 3.3.7
96
+ rubygems_version: 3.1.6
97
97
  signing_key:
98
98
  specification_version: 4
99
99
  summary: Intrinio provides real-time stock prices from its Multi-Exchange feed, via