intrinio-realtime 2.2.1 → 3.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (3) hide show
  1. checksums.yaml +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: []