intrinio-realtime 3.1.0 → 3.1.2

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 +567 -566
  3. metadata +6 -6
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5133d9c56da7e6d9e8c89dbdda4733dbe3240d996617fb6e1abadeeaabbd1451
4
- data.tar.gz: e2e2ef77e3efd159eb3b321f78b93bfd6e84ec8bb59e7058fdc9eb3812375f81
3
+ metadata.gz: 230f6b829df617dbc89057d3b413e8e72ab9f1ce54cbfd40962beba8649b7fc8
4
+ data.tar.gz: f4f9ddf1279d94b2533ff37badd573c99323db4ad03b513251a3c688ab196bee
5
5
  SHA512:
6
- metadata.gz: cd7bca2cb01f65c65414bb58ee45de03456b9c031cd617431410f206a98e9baed436fc968964a569e55007c98dc415cbd490df412679c3766ce1745b11b431d5
7
- data.tar.gz: 4a6e3c53d9da10740e75b73aaf482fd9258abcd6fe04c3c983f73dc5ec69150383516fd9a12aeab0abd37a3a3209afe4645f49d57018e17aa57d94c419063bf0
6
+ metadata.gz: b07b66332b3e1ed14c506db140e7d3bd87d239c9e8868663a53442b76ec34178ee57add42bc13549e085cfbfad2cdf86927aa00f92b619a532feda517fdebb49
7
+ data.tar.gz: e1696ccdc4a6e4c037c84dd7f9e0ac5def100d974cdf286dad45a098a6cdf0007eed911237ae698b975f37941eddffb2e59947d0bab698ac2fddd78fd230671d
@@ -1,566 +1,567 @@
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_trade(data, start_index, symbol_length)
255
- symbol = data[start_index + 2, symbol_length].map!{|c| c.chr}.join
256
- price = parse_int32(data[start_index + 2 + symbol_length, 4]).to_f / 10000.0
257
- size = parse_uint32(data[start_index + 6 + symbol_length, 4])
258
- timestamp = parse_uint64(data[start_index + 10 + symbol_length, 8])
259
- total_volume = parse_uint32(data[start_index + 18 + symbol_length, 4])
260
- return Trade.new(symbol, price, size, timestamp, total_volume)
261
- end
262
-
263
- def parse_quote(data, start_index, symbol_length, msg_type)
264
- type = case when msg_type == 1 then ASK when msg_type == 2 then BID end
265
- symbol = data[start_index + 2, symbol_length].map!{|c| c.chr}.join
266
- price = parse_int32(data[start_index + 2 + symbol_length, 4]).to_f / 10000.0
267
- size = parse_uint32(data[start_index + 6 + symbol_length, 4])
268
- timestamp = parse_uint64(data[start_index + 10 + symbol_length, 8])
269
- return Quote.new(type, symbol, price, size, timestamp)
270
- end
271
-
272
- def handle_message(data, start_index)
273
- msg_type = data[start_index]
274
- symbol_length = data[start_index + 1]
275
- case msg_type
276
- when 0 then
277
- trade = parse_trade(data, start_index, symbol_length)
278
- @on_trade.call(trade)
279
- return start_index + 22 + symbol_length
280
- when 1 || 2 then
281
- quote = parse_quote(data, start_index, symbol_length, msg_type)
282
- @on_quote.call(quote)
283
- return start_index + 18 + symbol_length
284
- end
285
- return start_index
286
- end
287
-
288
- def handle_data
289
- Thread.current.priority -= 1
290
- me = self
291
- pop = nil
292
- until @stop do
293
- begin
294
- pop = nil
295
- data = nil
296
- pop = @messages.deq
297
- unless pop.nil?
298
- begin
299
- data = pop.unpack('C*')
300
- rescue StandardError => ex
301
- me.send :error, "Error unpacking data from queue: #{ex} #{pop}"
302
- next
303
- end
304
- if !data then me.send :error, "Cannot process data. Data is nil. #{pop}" end
305
- start_index = 1
306
- count = data[0]
307
- # These are grouped (many) messages.
308
- # The first byte tells us how many there are.
309
- # From there, check the type and symbol length at index 0 of each chunk to know how many bytes each message has.
310
- count.times {start_index = handle_message(data, start_index)}
311
- end
312
- if pop.nil? then sleep(0.1) end
313
- rescue StandardError => e
314
- me.send :error, "Error handling message from queue: #{e} #{pop} : #{data} ; count: #{count} ; start index: #{start_index}"
315
- rescue Exception => e
316
- #me.send :error, "General error handling message from queue: #{e} #{pop} : #{data} ; count: #{count} ; start index: #{start_index}"
317
- end
318
- end
319
- end
320
-
321
- def refresh_token
322
- @token = nil
323
-
324
- uri = URI.parse(auth_url)
325
- http = Net::HTTP.new(uri.host, uri.port)
326
- http.start
327
- request = Net::HTTP::Get.new(uri.request_uri)
328
- request.add_field("Client-Information", "IntrinioRealtimeRubySDKv3.1")
329
-
330
- unless @api_key
331
- request.basic_auth(@username, @password)
332
- end
333
-
334
- response = http.request(request)
335
-
336
- return fatal("Unable to authorize") if response.code == "401"
337
- return fatal("Could not get auth token") if response.code != "200"
338
-
339
- @token = response.body
340
- debug "Token refreshed"
341
- end
342
-
343
- def auth_url
344
- url = ""
345
-
346
- case @provider
347
- when REALTIME then url = "https://realtime-mx.intrinio.com/auth"
348
- when MANUAL then url = "http://" + @ip_address + "/auth"
349
- end
350
-
351
- url = api_auth_url(url) if @api_key
352
-
353
- url
354
- end
355
-
356
- def api_auth_url(url)
357
- if url.include? "?"
358
- url = "#{url}&"
359
- else
360
- url = "#{url}?"
361
- end
362
-
363
- "#{url}api_key=#{@api_key}"
364
- end
365
-
366
- def socket_url
367
- case @provider
368
- when REALTIME then URI.escape("wss://realtime-mx.intrinio.com/socket/websocket?vsn=1.0.0&token=#{@token}")
369
- when MANUAL then URI.escape("ws://" + @ip_address + "/socket/websocket?vsn=1.0.0&token=#{@token}")
370
- end
371
- end
372
-
373
- def refresh_websocket
374
- me = self
375
-
376
- @ws.close() unless @ws.nil?
377
- @ready = false
378
- @joined_channels = []
379
-
380
- @stop = true
381
- sleep(2)
382
- @threads.each { |thread|
383
- if !thread.nil? && (!thread.pending_interrupt? || thread.status == "run" || thread.status == "Sleeping")
384
- then thread.join(7)
385
- elsif !thread.nil?
386
- then thread.kill
387
- end
388
- }
389
- @threads = []
390
- @stop = false
391
- @thread_quantity.times {@threads << Thread.new{handle_data}}
392
-
393
- @ws = ws = WebSocket::Client::Simple.connect(socket_url)
394
- me.send :info, "Connection opening"
395
-
396
- ws.on :open do
397
- me.send :info, "Connection established"
398
- me.send :ready, true
399
- if [REALTIME, MANUAL].include?(me.send(:provider))
400
- me.send :refresh_channels
401
- end
402
- me.send :start_heartbeat
403
- me.send :stop_self_heal
404
- end
405
-
406
- ws.on :message do |frame|
407
- data_message = frame.data
408
- #me.send :debug, "Message: #{data_message}"
409
- begin
410
- unless data_message.nil?
411
- then me.send :queue_message, data_message
412
- end
413
- rescue StandardError => e
414
- me.send :error, "Error adding message to queue: #{data_message} #{e}"
415
- end
416
- end
417
-
418
- ws.on :close do |e|
419
- me.send :ready, false
420
- me.send :info, "Connection closing...: #{e}"
421
- me.send :try_self_heal
422
- end
423
-
424
- ws.on :error do |e|
425
- me.send :ready, false
426
- me.send :error, "Connection error: #{e}"
427
- me.send :try_self_heal
428
- end
429
- end
430
-
431
- def refresh_channels
432
- return unless @ready
433
- debug "Refreshing channels"
434
-
435
- # Join new channels
436
- new_channels = @channels - @joined_channels
437
- new_channels.each do |channel|
438
- #msg = join_message(channel)
439
- #@ws.send(msg.to_json)
440
- msg = join_binary_message(channel)
441
- @ws.send(msg)
442
- info "Joined #{channel}"
443
- end
444
-
445
- # Leave old channels
446
- old_channels = @joined_channels - @channels
447
- old_channels.each do |channel|
448
- #msg = leave__message(channel)
449
- #@ws.send(msg.to_json)
450
- msg = leave_binary_message(channel)
451
- @ws.send(msg)
452
- info "Left #{channel}"
453
- end
454
-
455
- @channels.uniq!
456
- @joined_channels = Array.new(@channels)
457
- debug "Current channels: #{@channels}"
458
- end
459
-
460
- def start_heartbeat
461
- EM.cancel_timer(@heartbeat_timer) if @heartbeat_timer
462
- @heartbeat_timer = EM.add_periodic_timer(HEARTBEAT_TIME) do
463
- if msg = heartbeat_msg()
464
- @ws.send(msg)
465
- debug "Heartbeat #{msg}"
466
- end
467
- end
468
- end
469
-
470
- def heartbeat_msg
471
- ""
472
- end
473
-
474
- def stop_heartbeat
475
- EM.cancel_timer(@heartbeat_timer) if @heartbeat_timer
476
- end
477
-
478
- def try_self_heal
479
- return if @closing
480
- debug "Attempting to self-heal"
481
-
482
- time = @selfheal_backoffs.first
483
- @selfheal_backoffs.delete_at(0) if @selfheal_backoffs.count > 1
484
-
485
- EM.cancel_timer(@heartbeat_timer) if @heartbeat_timer
486
- EM.cancel_timer(@selfheal_timer) if @selfheal_timer
487
-
488
- @selfheal_timer = EM.add_timer(time/1000) do
489
- connect()
490
- end
491
- end
492
-
493
- def stop_self_heal
494
- EM.cancel_timer(@selfheal_timer) if @selfheal_timer
495
- @selfheal_backoffs = Array.new(SELF_HEAL_BACKOFFS)
496
- end
497
-
498
- def ready(val)
499
- @ready = val
500
- end
501
-
502
- def debug(message)
503
- message = "IntrinioRealtime | #{message}"
504
- @logger.debug(message) rescue
505
- nil
506
- end
507
-
508
- def info(message)
509
- message = "IntrinioRealtime | #{message}"
510
- @logger.info(message) rescue
511
- nil
512
- end
513
-
514
- def error(message)
515
- message = "IntrinioRealtime | #{message}"
516
- @logger.error(message) rescue
517
- nil
518
- end
519
-
520
- def fatal(message)
521
- message = "IntrinioRealtime | #{message}"
522
- @logger.fatal(message) rescue
523
- EM.stop_event_loop
524
- throw :fatal
525
- nil
526
- end
527
-
528
- def parse_channels(channels)
529
- channels.flatten!
530
- channels.uniq!
531
- channels.compact!
532
- channels
533
- end
534
-
535
- def join_binary_message(channel)
536
- if (channel == "lobby") && (@trades_only == false)
537
- return [74, 0, 36, 70, 73, 82, 69, 72, 79, 83, 69].pack('C*') #74, not trades only, "$FIREHOSE"
538
- elsif (channel == "lobby") && (@trades_only == true)
539
- return [74, 1, 36, 70, 73, 82, 69, 72, 79, 83, 69].pack('C*') #74, trades only, "$FIREHOSE"
540
- else
541
- bytes = [74, 0]
542
- if (@trades_only == true)
543
- bytes[1] = 1
544
- end
545
- return bytes.concat(channel.bytes).pack('C*')
546
- end
547
- end
548
-
549
- def leave_binary_message(channel)
550
- if channel == "lobby"
551
- return [76, 36, 70, 73, 82, 69, 72, 79, 83, 69].pack('C*') #74, not trades only, "$FIREHOSE"
552
- else
553
- bytes = [76]
554
- return bytes.concat(channel.bytes).pack('C*')
555
- end
556
- end
557
-
558
- def valid_api_key?(api_key)
559
- return false unless api_key.is_a?(String)
560
- return false if api_key.empty?
561
- true
562
- end
563
-
564
- end
565
- end
566
- 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_trade(data, start_index, symbol_length)
255
+ symbol = data[start_index + 2, symbol_length].map!{|c| c.chr}.join
256
+ price = parse_int32(data[start_index + 2 + symbol_length, 4]).to_f / 10000.0
257
+ size = parse_uint32(data[start_index + 6 + symbol_length, 4])
258
+ timestamp = parse_uint64(data[start_index + 10 + symbol_length, 8])
259
+ total_volume = parse_uint32(data[start_index + 18 + symbol_length, 4])
260
+ return Trade.new(symbol, price, size, timestamp, total_volume)
261
+ end
262
+
263
+ def parse_quote(data, start_index, symbol_length, msg_type)
264
+ type = case when msg_type == 1 then ASK when msg_type == 2 then BID end
265
+ symbol = data[start_index + 2, symbol_length].map!{|c| c.chr}.join
266
+ price = parse_int32(data[start_index + 2 + symbol_length, 4]).to_f / 10000.0
267
+ size = parse_uint32(data[start_index + 6 + symbol_length, 4])
268
+ timestamp = parse_uint64(data[start_index + 10 + symbol_length, 8])
269
+ return Quote.new(type, symbol, price, size, timestamp)
270
+ end
271
+
272
+ def handle_message(data, start_index)
273
+ msg_type = data[start_index]
274
+ symbol_length = data[start_index + 1]
275
+ case msg_type
276
+ when 0 then
277
+ trade = parse_trade(data, start_index, symbol_length)
278
+ @on_trade.call(trade)
279
+ return start_index + 22 + symbol_length
280
+ when 1 || 2 then
281
+ quote = parse_quote(data, start_index, symbol_length, msg_type)
282
+ @on_quote.call(quote)
283
+ return start_index + 18 + symbol_length
284
+ end
285
+ return start_index
286
+ end
287
+
288
+ def handle_data
289
+ Thread.current.priority -= 1
290
+ me = self
291
+ pop = nil
292
+ until @stop do
293
+ begin
294
+ pop = nil
295
+ data = nil
296
+ pop = @messages.deq
297
+ unless pop.nil?
298
+ begin
299
+ data = pop.unpack('C*')
300
+ rescue StandardError => ex
301
+ me.send :error, "Error unpacking data from queue: #{ex} #{pop}"
302
+ next
303
+ end
304
+ if !data then me.send :error, "Cannot process data. Data is nil. #{pop}" end
305
+ start_index = 1
306
+ count = data[0]
307
+ # These are grouped (many) messages.
308
+ # The first byte tells us how many there are.
309
+ # From there, check the type and symbol length at index 0 of each chunk to know how many bytes each message has.
310
+ count.times {start_index = handle_message(data, start_index)}
311
+ end
312
+ if pop.nil? then sleep(0.1) end
313
+ rescue StandardError => e
314
+ me.send :error, "Error handling message from queue: #{e} #{pop} : #{data} ; count: #{count} ; start index: #{start_index}"
315
+ rescue Exception => e
316
+ #me.send :error, "General error handling message from queue: #{e} #{pop} : #{data} ; count: #{count} ; start index: #{start_index}"
317
+ end
318
+ end
319
+ end
320
+
321
+ def refresh_token
322
+ @token = nil
323
+
324
+ uri = URI.parse(auth_url)
325
+ http = Net::HTTP.new(uri.host, uri.port)
326
+ http.use_ssl = true if (auth_url.include?("https"))
327
+ http.start
328
+ request = Net::HTTP::Get.new(uri.request_uri)
329
+ request.add_field("Client-Information", "IntrinioRealtimeRubySDKv3.1")
330
+
331
+ unless @api_key
332
+ request.basic_auth(@username, @password)
333
+ end
334
+
335
+ response = http.request(request)
336
+
337
+ return fatal("Unable to authorize") if response.code == "401"
338
+ return fatal("Could not get auth token") if response.code != "200"
339
+
340
+ @token = response.body
341
+ debug "Token refreshed"
342
+ end
343
+
344
+ def auth_url
345
+ url = ""
346
+
347
+ case @provider
348
+ when REALTIME then url = "https://realtime-mx.intrinio.com/auth"
349
+ when MANUAL then url = "http://" + @ip_address + "/auth"
350
+ end
351
+
352
+ url = api_auth_url(url) if @api_key
353
+
354
+ url
355
+ end
356
+
357
+ def api_auth_url(url)
358
+ if url.include? "?"
359
+ url = "#{url}&"
360
+ else
361
+ url = "#{url}?"
362
+ end
363
+
364
+ "#{url}api_key=#{@api_key}"
365
+ end
366
+
367
+ def socket_url
368
+ case @provider
369
+ when REALTIME then URI.escape("wss://realtime-mx.intrinio.com/socket/websocket?vsn=1.0.0&token=#{@token}")
370
+ when MANUAL then URI.escape("ws://" + @ip_address + "/socket/websocket?vsn=1.0.0&token=#{@token}")
371
+ end
372
+ end
373
+
374
+ def refresh_websocket
375
+ me = self
376
+
377
+ @ws.close() unless @ws.nil?
378
+ @ready = false
379
+ @joined_channels = []
380
+
381
+ @stop = true
382
+ sleep(2)
383
+ @threads.each { |thread|
384
+ if !thread.nil? && (!thread.pending_interrupt? || thread.status == "run" || thread.status == "Sleeping")
385
+ then thread.join(7)
386
+ elsif !thread.nil?
387
+ then thread.kill
388
+ end
389
+ }
390
+ @threads = []
391
+ @stop = false
392
+ @thread_quantity.times {@threads << Thread.new{handle_data}}
393
+
394
+ @ws = ws = WebSocket::Client::Simple.connect(socket_url)
395
+ me.send :info, "Connection opening"
396
+
397
+ ws.on :open do
398
+ me.send :info, "Connection established"
399
+ me.send :ready, true
400
+ if [REALTIME, MANUAL].include?(me.send(:provider))
401
+ me.send :refresh_channels
402
+ end
403
+ me.send :start_heartbeat
404
+ me.send :stop_self_heal
405
+ end
406
+
407
+ ws.on :message do |frame|
408
+ data_message = frame.data
409
+ #me.send :debug, "Message: #{data_message}"
410
+ begin
411
+ unless data_message.nil?
412
+ then me.send :queue_message, data_message
413
+ end
414
+ rescue StandardError => e
415
+ me.send :error, "Error adding message to queue: #{data_message} #{e}"
416
+ end
417
+ end
418
+
419
+ ws.on :close do |e|
420
+ me.send :ready, false
421
+ me.send :info, "Connection closing...: #{e}"
422
+ me.send :try_self_heal
423
+ end
424
+
425
+ ws.on :error do |e|
426
+ me.send :ready, false
427
+ me.send :error, "Connection error: #{e}"
428
+ me.send :try_self_heal
429
+ end
430
+ end
431
+
432
+ def refresh_channels
433
+ return unless @ready
434
+ debug "Refreshing channels"
435
+
436
+ # Join new channels
437
+ new_channels = @channels - @joined_channels
438
+ new_channels.each do |channel|
439
+ #msg = join_message(channel)
440
+ #@ws.send(msg.to_json)
441
+ msg = join_binary_message(channel)
442
+ @ws.send(msg)
443
+ info "Joined #{channel}"
444
+ end
445
+
446
+ # Leave old channels
447
+ old_channels = @joined_channels - @channels
448
+ old_channels.each do |channel|
449
+ #msg = leave__message(channel)
450
+ #@ws.send(msg.to_json)
451
+ msg = leave_binary_message(channel)
452
+ @ws.send(msg)
453
+ info "Left #{channel}"
454
+ end
455
+
456
+ @channels.uniq!
457
+ @joined_channels = Array.new(@channels)
458
+ debug "Current channels: #{@channels}"
459
+ end
460
+
461
+ def start_heartbeat
462
+ EM.cancel_timer(@heartbeat_timer) if @heartbeat_timer
463
+ @heartbeat_timer = EM.add_periodic_timer(HEARTBEAT_TIME) do
464
+ if msg = heartbeat_msg()
465
+ @ws.send(msg)
466
+ debug "Heartbeat #{msg}"
467
+ end
468
+ end
469
+ end
470
+
471
+ def heartbeat_msg
472
+ ""
473
+ end
474
+
475
+ def stop_heartbeat
476
+ EM.cancel_timer(@heartbeat_timer) if @heartbeat_timer
477
+ end
478
+
479
+ def try_self_heal
480
+ return if @closing
481
+ debug "Attempting to self-heal"
482
+
483
+ time = @selfheal_backoffs.first
484
+ @selfheal_backoffs.delete_at(0) if @selfheal_backoffs.count > 1
485
+
486
+ EM.cancel_timer(@heartbeat_timer) if @heartbeat_timer
487
+ EM.cancel_timer(@selfheal_timer) if @selfheal_timer
488
+
489
+ @selfheal_timer = EM.add_timer(time/1000) do
490
+ connect()
491
+ end
492
+ end
493
+
494
+ def stop_self_heal
495
+ EM.cancel_timer(@selfheal_timer) if @selfheal_timer
496
+ @selfheal_backoffs = Array.new(SELF_HEAL_BACKOFFS)
497
+ end
498
+
499
+ def ready(val)
500
+ @ready = val
501
+ end
502
+
503
+ def debug(message)
504
+ message = "IntrinioRealtime | #{message}"
505
+ @logger.debug(message) rescue
506
+ nil
507
+ end
508
+
509
+ def info(message)
510
+ message = "IntrinioRealtime | #{message}"
511
+ @logger.info(message) rescue
512
+ nil
513
+ end
514
+
515
+ def error(message)
516
+ message = "IntrinioRealtime | #{message}"
517
+ @logger.error(message) rescue
518
+ nil
519
+ end
520
+
521
+ def fatal(message)
522
+ message = "IntrinioRealtime | #{message}"
523
+ @logger.fatal(message) rescue
524
+ EM.stop_event_loop
525
+ throw :fatal
526
+ nil
527
+ end
528
+
529
+ def parse_channels(channels)
530
+ channels.flatten!
531
+ channels.uniq!
532
+ channels.compact!
533
+ channels
534
+ end
535
+
536
+ def join_binary_message(channel)
537
+ if (channel == "lobby") && (@trades_only == false)
538
+ return [74, 0, 36, 70, 73, 82, 69, 72, 79, 83, 69].pack('C*') #74, not trades only, "$FIREHOSE"
539
+ elsif (channel == "lobby") && (@trades_only == true)
540
+ return [74, 1, 36, 70, 73, 82, 69, 72, 79, 83, 69].pack('C*') #74, trades only, "$FIREHOSE"
541
+ else
542
+ bytes = [74, 0]
543
+ if (@trades_only == true)
544
+ bytes[1] = 1
545
+ end
546
+ return bytes.concat(channel.bytes).pack('C*')
547
+ end
548
+ end
549
+
550
+ def leave_binary_message(channel)
551
+ if channel == "lobby"
552
+ return [76, 36, 70, 73, 82, 69, 72, 79, 83, 69].pack('C*') #74, not trades only, "$FIREHOSE"
553
+ else
554
+ bytes = [76]
555
+ return bytes.concat(channel.bytes).pack('C*')
556
+ end
557
+ end
558
+
559
+ def valid_api_key?(api_key)
560
+ return false unless api_key.is_a?(String)
561
+ return false if api_key.empty?
562
+ true
563
+ end
564
+
565
+ end
566
+ end
567
+ 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: 3.1.0
4
+ version: 3.1.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Intrinio
8
- autorequire:
8
+ autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2021-12-16 00:00:00.000000000 Z
11
+ date: 2022-01-24 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: eventmachine
@@ -78,7 +78,7 @@ homepage: https://github.com/intrinio/intrinio-realtime-ruby-sdk
78
78
  licenses:
79
79
  - GPL-3.0
80
80
  metadata: {}
81
- post_install_message:
81
+ post_install_message:
82
82
  rdoc_options: []
83
83
  require_paths:
84
84
  - lib
@@ -93,8 +93,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
93
93
  - !ruby/object:Gem::Version
94
94
  version: '0'
95
95
  requirements: []
96
- rubygems_version: 3.1.2
97
- signing_key:
96
+ rubygems_version: 3.2.15
97
+ signing_key:
98
98
  specification_version: 4
99
99
  summary: Intrinio provides real-time stock prices from its Multi-Exchange feed, via
100
100
  a two-way WebSocket connection.