intrinio-realtime 4.0.0 → 4.1.1

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