intrinio-realtime 2.1.0 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. checksums.yaml +5 -5
  2. data/lib/intrinio-realtime.rb +566 -424
  3. metadata +28 -15
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
- SHA1:
3
- metadata.gz: a230dd8a59f925f023ddde64d91f7d6c33e40ecb
4
- data.tar.gz: d6fe3f0b65ececa7e939d036d9bdf7322b396737
2
+ SHA256:
3
+ metadata.gz: 5133d9c56da7e6d9e8c89dbdda4733dbe3240d996617fb6e1abadeeaabbd1451
4
+ data.tar.gz: e2e2ef77e3efd159eb3b321f78b93bfd6e84ec8bb59e7058fdc9eb3812375f81
5
5
  SHA512:
6
- metadata.gz: 194bf4c83eda268fcd8df8e522588e326027aeb23e9f769bf608bedcf4538414bd8e9849b669d9e94f84f18731c484fb146c2d678f802f2caccb2df92bdc9147
7
- data.tar.gz: e5170c7f944938e482f8a205c3694cd137faa277a07834b199df81a41ab760ab60e40b5859cde468adbc46458f760e6937f9a073f9933a585e9940a0d5791b39
6
+ metadata.gz: cd7bca2cb01f65c65414bb58ee45de03456b9c031cd617431410f206a98e9baed436fc968964a569e55007c98dc415cbd490df412679c3766ce1745b11b431d5
7
+ data.tar.gz: 4a6e3c53d9da10740e75b73aaf482fd9258abcd6fe04c3c983f73dc5ec69150383516fd9a12aeab0abd37a3a3209afe4645f49d57018e17aa57d94c419063bf0
@@ -1,424 +1,566 @@
1
- require 'logger'
2
- require 'uri'
3
- require 'http'
4
- require 'eventmachine'
5
- require 'websocket-client-simple'
6
-
7
- module Intrinio
8
- module Realtime
9
- HEARTBEAT_TIME = 3
10
- SELF_HEAL_BACKOFFS = [0, 100, 500, 1000, 2000, 5000].freeze
11
- IEX = "iex".freeze
12
- QUODD = "quodd".freeze
13
- CRYPTOQUOTE = "cryptoquote".freeze
14
- PROVIDERS = [IEX, QUODD, CRYPTOQUOTE].freeze
15
-
16
- def self.connect(options, &b)
17
- EM.run do
18
- client = ::Intrinio::Realtime::Client.new(options)
19
- client.on_quote(&b)
20
- client.connect()
21
- end
22
- end
23
-
24
- class Client
25
-
26
- def initialize(options)
27
- raise "Options parameter is required" if options.nil? || !options.is_a?(Hash)
28
-
29
- @api_key = options[:api_key]
30
- raise "API Key was formatted invalidly." if @api_key && !valid_api_key?(@api_key)
31
-
32
- unless @api_key
33
- @username = options[:username]
34
- @password = options[:password]
35
- raise "API Key or Username and password are required" if @username.nil? || @username.empty? || @password.nil? || @password.empty?
36
- end
37
-
38
- @provider = options[:provider]
39
- raise "Provider must be 'quodd' or 'iex'" unless PROVIDERS.include?(@provider)
40
-
41
- @channels = []
42
- @channels = parse_channels(options[:channels]) if options[:channels]
43
- bad_channels = @channels.select{|x| !x.is_a?(String)}
44
- raise "Invalid channels to join: #{bad_channels}" unless bad_channels.empty?
45
-
46
- if options[:logger] == false
47
- @logger = nil
48
- elsif !options[:logger].nil?
49
- @logger = options[:logger]
50
- else
51
- @logger = Logger.new($stdout)
52
- @logger.level = Logger::INFO
53
- end
54
-
55
- @quotes = EventMachine::Channel.new
56
- @ready = false
57
- @joined_channels = []
58
- @heartbeat_timer = nil
59
- @selfheal_timer = nil
60
- @selfheal_backoffs = Array.new(SELF_HEAL_BACKOFFS)
61
- @ws = nil
62
- end
63
-
64
- def provider
65
- @provider
66
- end
67
-
68
- def join(*channels)
69
- channels = parse_channels(channels)
70
- nonconforming = channels.select{|x| !x.is_a?(String)}
71
- return error("Invalid channels to join: #{nonconforming}") unless nonconforming.empty?
72
-
73
- @channels.concat(channels)
74
- @channels.uniq!
75
- debug "Joining channels #{channels}"
76
-
77
- refresh_channels()
78
- end
79
-
80
- def leave(*channels)
81
- channels = parse_channels(channels)
82
- nonconforming = channels.find{|x| !x.is_a?(String)}
83
- return error("Invalid channels to leave: #{nonconforming}") unless nonconforming.empty?
84
-
85
- channels.each{|c| @channels.delete(c)}
86
- debug "Leaving channels #{channels}"
87
-
88
- refresh_channels()
89
- end
90
-
91
- def leave_all
92
- @channels = []
93
- debug "Leaving all channels"
94
- refresh_channels()
95
- end
96
-
97
- def on_quote(&b)
98
- @quotes.subscribe(&b)
99
- end
100
-
101
- def connect
102
- raise "Must be run from within an EventMachine run loop" unless EM.reactor_running?
103
- return warn("Already connected!") if @ready
104
- debug "Connecting..."
105
-
106
- catch :fatal do
107
- begin
108
- @closing = false
109
- @ready = false
110
- refresh_token()
111
- refresh_websocket()
112
- rescue StandardError => e
113
- error("Connection error: #{e} \n#{e.backtrace.join("\n")}")
114
- try_self_heal()
115
- end
116
- end
117
- end
118
-
119
- def disconnect
120
- EM.cancel_timer(@heartbeat_timer) if @heartbeat_timer
121
- EM.cancel_timer(@selfheal_timer) if @selfheal_timer
122
- @ready = false
123
- @closing = true
124
- @channels = []
125
- @joined_channels = []
126
- @ws.close() if @ws
127
- info "Connection closed"
128
- end
129
-
130
- private
131
-
132
- def refresh_token
133
- @token = nil
134
-
135
- if @api_key
136
- response = HTTP.get(auth_url)
137
- else
138
- response = HTTP.basic_auth(:user => @username, :pass => @password).get(auth_url)
139
- end
140
-
141
- return fatal("Unable to authorize") if response.status == 401
142
- return fatal("Could not get auth token") if response.status != 200
143
-
144
- @token = response.body
145
- debug "Token refreshed"
146
- end
147
-
148
- def auth_url
149
- url = ""
150
-
151
- case @provider
152
- when IEX then url = "https://realtime.intrinio.com/auth"
153
- when QUODD then url = "https://api.intrinio.com/token?type=QUODD"
154
- when CRYPTOQUOTE then url = "https://crypto.intrinio.com/auth"
155
- end
156
-
157
- url = api_auth_url(url) if @api_key
158
-
159
- url
160
- end
161
-
162
- def api_auth_url(url)
163
- if @api_key.include? "?"
164
- url = "#{url}&"
165
- else
166
- url = "#{url}?"
167
- end
168
-
169
- "#{url}api_key=#{@api_key}"
170
- end
171
-
172
- def socket_url
173
- case @provider
174
- when IEX then URI.escape("wss://realtime.intrinio.com/socket/websocket?vsn=1.0.0&token=#{@token}")
175
- when QUODD then URI.escape("wss://www5.quodd.com/websocket/webStreamer/intrinio/#{@token}")
176
- when CRYPTOQUOTE then URI.escape("wss://crypto.intrinio.com/socket/websocket?vsn=1.0.0&token=#{@token}")
177
- end
178
- end
179
-
180
- def refresh_websocket
181
- me = self
182
-
183
- @ws.close() unless @ws.nil?
184
- @ready = false
185
- @joined_channels = []
186
-
187
- @ws = ws = WebSocket::Client::Simple.connect(socket_url)
188
- me.send :info, "Connection opening"
189
-
190
- ws.on :open do
191
- me.send :info, "Connection established"
192
- me.send :ready, true
193
- if me.send(:provider) == IEX || me.send(:provider) == CRYPTOQUOTE
194
- me.send :refresh_channels
195
- end
196
- me.send :start_heartbeat
197
- me.send :stop_self_heal
198
- end
199
-
200
- ws.on :message do |frame|
201
- message = frame.data
202
- me.send :debug, "Message: #{message}"
203
-
204
- begin
205
- json = JSON.parse(message)
206
-
207
- quote = case me.send(:provider)
208
- when IEX
209
- if json["event"] == "quote"
210
- json["payload"]
211
- end
212
- when QUODD
213
- if json["event"] == "info" && json["data"]["message"] == "Connected"
214
- me.send :refresh_channels
215
- elsif json["event"] == "quote" || json["event"] == "trade"
216
- json["data"]
217
- end
218
- when CRYPTOQUOTE
219
- if json["event"] == "book_update" || json["event"] == "ticker" || json["event"] == "trade"
220
- json["payload"]
221
- end
222
- end
223
-
224
- if quote && quote.is_a?(Hash)
225
- me.send :process_quote, quote
226
- end
227
- rescue StandardError => e
228
- me.send :error, "Could not parse message: #{message} #{e}"
229
- end
230
- end
231
-
232
- ws.on :close do |e|
233
- me.send :disconnect
234
- end
235
-
236
- ws.on :error do |e|
237
- me.send :ready, false
238
- me.send :error, "Connection error: #{e}"
239
- me.send :try_self_heal
240
- end
241
- end
242
-
243
- def refresh_channels
244
- return unless @ready
245
- debug "Refreshing channels"
246
-
247
- # Join new channels
248
- new_channels = @channels - @joined_channels
249
- new_channels.each do |channel|
250
- msg = join_message(channel)
251
- @ws.send(msg.to_json)
252
- info "Joined #{channel}"
253
- end
254
-
255
- # Leave old channels
256
- old_channels = @joined_channels - @channels
257
- old_channels.each do |channel|
258
- msg = leave_message(channel)
259
- @ws.send(msg.to_json)
260
- info "Left #{channel}"
261
- end
262
-
263
- @channels.uniq!
264
- @joined_channels = Array.new(@channels)
265
- debug "Current channels: #{@channels}"
266
- end
267
-
268
- def start_heartbeat
269
- EM.cancel_timer(@heartbeat_timer) if @heartbeat_timer
270
- @heartbeat_timer = EM.add_periodic_timer(HEARTBEAT_TIME) do
271
- if msg = heartbeat_msg()
272
- @ws.send(msg)
273
- debug "Heartbeat #{msg}"
274
- end
275
- end
276
- end
277
-
278
- def heartbeat_msg
279
- case @provider
280
- when IEX then {topic: 'phoenix', event: 'heartbeat', payload: {}, ref: nil}.to_json
281
- when QUODD then {event: 'heartbeat', data: {action: 'heartbeat', ticker: (Time.now.to_f * 1000).to_i}}.to_json
282
- when CRYPTOQUOTE then {topic: 'phoenix', event: 'heartbeat', payload: {}, ref: nil}.to_json
283
- end
284
- end
285
-
286
- def stop_heartbeat
287
- EM.cancel_timer(@heartbeat_timer) if @heartbeat_timer
288
- end
289
-
290
- def try_self_heal
291
- return if @closing
292
- debug "Attempting to self-heal"
293
-
294
- time = @selfheal_backoffs.first
295
- @selfheal_backoffs.delete_at(0) if @selfheal_backoffs.count > 1
296
-
297
- EM.cancel_timer(@heartbeat_timer) if @heartbeat_timer
298
- EM.cancel_timer(@selfheal_timer) if @selfheal_timer
299
-
300
- @selfheal_timer = EM.add_timer(time/1000) do
301
- connect()
302
- end
303
- end
304
-
305
- def stop_self_heal
306
- EM.cancel_timer(@selfheal_timer) if @selfheal_timer
307
- @selfheal_backoffs = Array.new(SELF_HEAL_BACKOFFS)
308
- end
309
-
310
- def ready(val)
311
- @ready = val
312
- end
313
-
314
- def process_quote(quote)
315
- @quotes.push(quote)
316
- end
317
-
318
- def debug(message)
319
- message = "IntrinioRealtime | #{message}"
320
- @logger.debug(message) rescue
321
- nil
322
- end
323
-
324
- def info(message)
325
- message = "IntrinioRealtime | #{message}"
326
- @logger.info(message) rescue
327
- nil
328
- end
329
-
330
- def error(message)
331
- message = "IntrinioRealtime | #{message}"
332
- @logger.error(message) rescue
333
- nil
334
- end
335
-
336
- def fatal(message)
337
- message = "IntrinioRealtime | #{message}"
338
- @logger.fatal(message) rescue
339
- EM.stop_event_loop
340
- throw :fatal
341
- nil
342
- end
343
-
344
- def parse_channels(channels)
345
- channels.flatten!
346
- channels.uniq!
347
- channels.compact!
348
- channels
349
- end
350
-
351
- def parse_iex_topic(channel)
352
- case channel
353
- when "$lobby"
354
- "iex:lobby"
355
- when "$lobby_last_price"
356
- "iex:lobby:last_price"
357
- else
358
- "iex:securities:#{channel}"
359
- end
360
- end
361
-
362
- def join_message(channel)
363
- case @provider
364
- when IEX
365
- {
366
- topic: parse_iex_topic(channel),
367
- event: "phx_join",
368
- payload: {},
369
- ref: nil
370
- }
371
- when QUODD
372
- {
373
- event: "subscribe",
374
- data: {
375
- ticker: channel,
376
- action: "subscribe"
377
- }
378
- }
379
- when CRYPTOQUOTE
380
- {
381
- topic: channel,
382
- event: "phx_join",
383
- payload: {},
384
- ref: nil
385
- }
386
- end
387
- end
388
-
389
- def leave_message(channel)
390
- case @provider
391
- when IEX
392
- {
393
- topic: parse_iex_topic(channel),
394
- event: "phx_leave",
395
- payload: {},
396
- ref: nil
397
- }
398
- when QUODD
399
- {
400
- event: "unsubscribe",
401
- data: {
402
- ticker: channel,
403
- action: "unsubscribe"
404
- }
405
- }
406
- when CRYPTOQUOTE
407
- {
408
- topic: channel,
409
- event: "phx_leave",
410
- payload: {},
411
- ref: nil
412
- }
413
- end
414
- end
415
-
416
- def valid_api_key?(api_key)
417
- return false unless api_key.is_a?(String)
418
- return false if api_key.empty?
419
- true
420
- end
421
-
422
- end
423
- end
424
- 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.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
metadata CHANGED
@@ -1,60 +1,74 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: intrinio-realtime
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.1.0
4
+ version: 3.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Intrinio
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2018-11-29 00:00:00.000000000 Z
11
+ date: 2021-12-16 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
- name: http
14
+ name: eventmachine
15
15
  requirement: !ruby/object:Gem::Requirement
16
16
  requirements:
17
17
  - - "~>"
18
18
  - !ruby/object:Gem::Version
19
- version: '2.2'
19
+ version: '1.2'
20
20
  type: :runtime
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
24
  - - "~>"
25
25
  - !ruby/object:Gem::Version
26
- version: '2.2'
26
+ version: '1.2'
27
27
  - !ruby/object:Gem::Dependency
28
- name: eventmachine
28
+ name: websocket-client-simple
29
29
  requirement: !ruby/object:Gem::Requirement
30
30
  requirements:
31
31
  - - "~>"
32
32
  - !ruby/object:Gem::Version
33
- version: '1.2'
33
+ version: '0.3'
34
34
  type: :runtime
35
35
  prerelease: false
36
36
  version_requirements: !ruby/object:Gem::Requirement
37
37
  requirements:
38
38
  - - "~>"
39
39
  - !ruby/object:Gem::Version
40
- version: '1.2'
40
+ version: '0.3'
41
41
  - !ruby/object:Gem::Dependency
42
- name: websocket-client-simple
42
+ name: thread
43
43
  requirement: !ruby/object:Gem::Requirement
44
44
  requirements:
45
45
  - - "~>"
46
46
  - !ruby/object:Gem::Version
47
- version: '0.3'
47
+ version: 0.2.2
48
48
  type: :runtime
49
49
  prerelease: false
50
50
  version_requirements: !ruby/object:Gem::Requirement
51
51
  requirements:
52
52
  - - "~>"
53
53
  - !ruby/object:Gem::Version
54
- version: '0.3'
54
+ version: 0.2.2
55
+ - !ruby/object:Gem::Dependency
56
+ name: bigdecimal
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: 1.4.0
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: 1.4.0
55
69
  description: Intrinio Ruby SDK for Real-Time Stock Prices
56
70
  email:
57
- - asolo@intrinio.com
71
+ - ssnyder@intrinio.com
58
72
  executables: []
59
73
  extensions: []
60
74
  extra_rdoc_files: []
@@ -79,10 +93,9 @@ required_rubygems_version: !ruby/object:Gem::Requirement
79
93
  - !ruby/object:Gem::Version
80
94
  version: '0'
81
95
  requirements: []
82
- rubyforge_project:
83
- rubygems_version: 2.5.2.2
96
+ rubygems_version: 3.1.2
84
97
  signing_key:
85
98
  specification_version: 4
86
- summary: Intrinio provides real-time stock prices from the IEX stock exchange, via
99
+ summary: Intrinio provides real-time stock prices from its Multi-Exchange feed, via
87
100
  a two-way WebSocket connection.
88
101
  test_files: []