intrinio-realtime 4.0.0 → 4.1.1

Sign up to get free protection for your applications and to get access to all the features.
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