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