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