intrinio-realtime 5.2.0 → 5.4.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.
- checksums.yaml +4 -4
- data/lib/intrinio-realtime.rb +461 -158
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4489672939eedb16281b3cafdf67b88e272017854716b482be7c2e4f8da705be
|
|
4
|
+
data.tar.gz: 9aca80494ec93e780e033edf64fbf4ef9ce87ab2fb0d86756748be8ca3d04344
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 33bed178619655282ae60f6a10bb2e48f0b448c2b3d43c5aef8df82c9dfefff76d6e54fcc6f74b347513b9b461de1ce2859e60e6691349dde957993334832619
|
|
7
|
+
data.tar.gz: 84eaa2fad299104bd9980ac49ef5303c068f3545f03bfbcf56303044be5ba0301bbbb3922618557efcec499eb052baaa8d60f9d82c3a03437472c0219bc86120
|
data/lib/intrinio-realtime.rb
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
require 'logger'
|
|
2
2
|
require 'uri'
|
|
3
|
+
require 'timeout'
|
|
3
4
|
#require 'http'
|
|
4
5
|
require 'net/http'
|
|
5
6
|
require 'eventmachine'
|
|
@@ -19,14 +20,19 @@ module Intrinio
|
|
|
19
20
|
OTC = "OTC".freeze
|
|
20
21
|
IEX = "IEX".freeze
|
|
21
22
|
CBOE_ONE = "CBOE_ONE".freeze
|
|
22
|
-
|
|
23
|
-
|
|
23
|
+
EQUITIES_EDGE = "EQUITIES_EDGE".freeze
|
|
24
|
+
PROVIDERS = [REALTIME, MANUAL, DELAYED_SIP, NASDAQ_BASIC, IEX, CBOE_ONE, EQUITIES_EDGE].freeze
|
|
25
|
+
SUBPROVIDERS = [NO_SUBPROVIDER, CTA_A, CTA_B, UTP, OTC, NASDAQ_BASIC, IEX, CBOE_ONE, EQUITIES_EDGE].freeze
|
|
24
26
|
ASK = "Ask".freeze
|
|
25
27
|
BID = "Bid".freeze
|
|
26
28
|
CLIENT_INFO_HEADER_KEY = "Client-Information".freeze
|
|
27
|
-
CLIENT_INFO_HEADER_VALUE = "IntrinioRealtimeRubySDKv5.
|
|
29
|
+
CLIENT_INFO_HEADER_VALUE = "IntrinioRealtimeRubySDKv5.4".freeze
|
|
28
30
|
MESSAGE_VERSION_HEADER_KEY = "UseNewEquitiesFormat".freeze
|
|
29
31
|
MESSAGE_VERSION_HEADER_VALUE = "v2".freeze
|
|
32
|
+
CONNECT_TIMEOUT_SECONDS = 30
|
|
33
|
+
HTTP_TIMEOUT_SECONDS = 30
|
|
34
|
+
CLOSE_TIMEOUT_SECONDS = 5
|
|
35
|
+
WORKER_JOIN_TIMEOUT_SECONDS = 5
|
|
30
36
|
|
|
31
37
|
def self.connect(options, on_trade, on_quote)
|
|
32
38
|
EM.run do
|
|
@@ -149,8 +155,10 @@ module Intrinio
|
|
|
149
155
|
def initialize(options, on_trade, on_quote)
|
|
150
156
|
raise "Options parameter is required" if options.nil? || !options.is_a?(Hash)
|
|
151
157
|
@stop = false
|
|
152
|
-
@
|
|
153
|
-
|
|
158
|
+
@trade_messages = Queue.new
|
|
159
|
+
@quote_messages = Queue.new
|
|
160
|
+
@message_available = Queue.new
|
|
161
|
+
@stop_message = Object.new
|
|
154
162
|
@on_trade = on_trade
|
|
155
163
|
@on_quote = on_quote
|
|
156
164
|
|
|
@@ -167,7 +175,7 @@ module Intrinio
|
|
|
167
175
|
unless @provider
|
|
168
176
|
@provider = REALTIME
|
|
169
177
|
end
|
|
170
|
-
raise "Provider must be 'IEX', 'REALTIME', 'DELAYED_SIP', 'NASDAQ_BASIC', 'CBOE_ONE', or 'MANUAL'" unless PROVIDERS.include?(@provider)
|
|
178
|
+
raise "Provider must be 'IEX', 'REALTIME', 'DELAYED_SIP', 'NASDAQ_BASIC', 'CBOE_ONE', or 'EQUITIES_EDGE', or 'MANUAL'" unless PROVIDERS.include?(@provider)
|
|
171
179
|
|
|
172
180
|
@ip_address = options[:ip_address]
|
|
173
181
|
raise "Missing option ip_address while in MANUAL mode." if @provider == MANUAL and (@ip_address.nil? || @ip_address.empty?)
|
|
@@ -188,11 +196,14 @@ module Intrinio
|
|
|
188
196
|
end
|
|
189
197
|
|
|
190
198
|
@threads = []
|
|
199
|
+
@worker_generation = 0
|
|
200
|
+
@worker_mutex = Mutex.new
|
|
191
201
|
|
|
192
202
|
@channels = []
|
|
193
203
|
@channels = parse_channels(options[:channels]) if options[:channels]
|
|
194
204
|
bad_channels = @channels.select{|x| !x.is_a?(String)}
|
|
195
205
|
raise "Invalid channels to join: #{bad_channels}" unless bad_channels.empty?
|
|
206
|
+
@channels_mutex = Mutex.new
|
|
196
207
|
|
|
197
208
|
if options[:logger] == false
|
|
198
209
|
@logger = nil
|
|
@@ -204,10 +215,19 @@ module Intrinio
|
|
|
204
215
|
end
|
|
205
216
|
|
|
206
217
|
@ready = false
|
|
218
|
+
@connecting = false
|
|
219
|
+
@closing = false
|
|
207
220
|
@joined_channels = []
|
|
208
221
|
@selfheal_timer = nil
|
|
222
|
+
@connect_timer = nil
|
|
209
223
|
@selfheal_backoffs = Array.new(SELF_HEAL_BACKOFFS)
|
|
210
224
|
@ws = nil
|
|
225
|
+
@connection_generation = 0
|
|
226
|
+
@state_mutex = Mutex.new
|
|
227
|
+
@lifecycle_mutex = Mutex.new
|
|
228
|
+
@connect_timeout_seconds = CONNECT_TIMEOUT_SECONDS
|
|
229
|
+
@http_timeout_seconds = HTTP_TIMEOUT_SECONDS
|
|
230
|
+
@close_timeout_seconds = CLOSE_TIMEOUT_SECONDS
|
|
211
231
|
end
|
|
212
232
|
|
|
213
233
|
def provider
|
|
@@ -218,9 +238,11 @@ module Intrinio
|
|
|
218
238
|
channels = parse_channels(channels)
|
|
219
239
|
nonconforming = channels.select{|x| !x.is_a?(String)}
|
|
220
240
|
return error("Invalid channels to join: #{nonconforming}") unless nonconforming.empty?
|
|
221
|
-
|
|
222
|
-
@
|
|
223
|
-
|
|
241
|
+
|
|
242
|
+
@channels_mutex.synchronize do
|
|
243
|
+
@channels.concat(channels)
|
|
244
|
+
@channels.uniq!
|
|
245
|
+
end
|
|
224
246
|
debug "Joining channels #{channels}"
|
|
225
247
|
|
|
226
248
|
refresh_channels()
|
|
@@ -231,54 +253,78 @@ module Intrinio
|
|
|
231
253
|
nonconforming = channels.find{|x| !x.is_a?(String)}
|
|
232
254
|
return error("Invalid channels to leave: #{nonconforming}") unless nonconforming.empty?
|
|
233
255
|
|
|
234
|
-
|
|
256
|
+
@channels_mutex.synchronize do
|
|
257
|
+
channels.each{|c| @channels.delete(c)}
|
|
258
|
+
end
|
|
235
259
|
debug "Leaving channels #{channels}"
|
|
236
260
|
|
|
237
261
|
refresh_channels()
|
|
238
262
|
end
|
|
239
263
|
|
|
240
264
|
def leave_all
|
|
241
|
-
@channels = []
|
|
265
|
+
@channels_mutex.synchronize {@channels = []}
|
|
242
266
|
debug "Leaving all channels"
|
|
243
267
|
refresh_channels()
|
|
244
268
|
end
|
|
245
269
|
|
|
246
270
|
def connect
|
|
247
271
|
raise "Must be run from within an EventMachine run loop" unless EM.reactor_running?
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
@
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
272
|
+
|
|
273
|
+
generation = nil
|
|
274
|
+
reconnect_timer = nil
|
|
275
|
+
already_connected = @lifecycle_mutex.synchronize do
|
|
276
|
+
connected = @state_mutex.synchronize do
|
|
277
|
+
if @ready || @connecting
|
|
278
|
+
true
|
|
279
|
+
else
|
|
280
|
+
@closing = false
|
|
281
|
+
@connecting = true
|
|
282
|
+
@connection_generation += 1
|
|
283
|
+
generation = @connection_generation
|
|
284
|
+
reconnect_timer = @selfheal_timer
|
|
285
|
+
@selfheal_timer = nil
|
|
286
|
+
false
|
|
287
|
+
end
|
|
260
288
|
end
|
|
289
|
+
start_workers unless connected
|
|
290
|
+
connected
|
|
261
291
|
end
|
|
292
|
+
return warn("Already connected!") if already_connected
|
|
293
|
+
|
|
294
|
+
cancel_timer(reconnect_timer)
|
|
295
|
+
debug "Connecting..."
|
|
296
|
+
|
|
297
|
+
EM.defer(
|
|
298
|
+
proc { refresh_token },
|
|
299
|
+
proc { |token| finish_authorization(generation, token) },
|
|
300
|
+
proc { |exception| connection_attempt_failed(generation, exception) }
|
|
301
|
+
)
|
|
262
302
|
end
|
|
263
303
|
|
|
264
304
|
def disconnect
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
305
|
+
@lifecycle_mutex.synchronize do
|
|
306
|
+
socket = nil
|
|
307
|
+
reconnect_timer = nil
|
|
308
|
+
connect_timer = nil
|
|
309
|
+
@state_mutex.synchronize do
|
|
310
|
+
@closing = true
|
|
311
|
+
@ready = false
|
|
312
|
+
@connecting = false
|
|
313
|
+
@connection_generation += 1
|
|
314
|
+
socket = @ws
|
|
315
|
+
@ws = nil
|
|
316
|
+
reconnect_timer = @selfheal_timer
|
|
317
|
+
connect_timer = @connect_timer
|
|
318
|
+
@selfheal_timer = nil
|
|
319
|
+
@connect_timer = nil
|
|
278
320
|
end
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
321
|
+
@channels_mutex.synchronize {@joined_channels = []}
|
|
322
|
+
|
|
323
|
+
cancel_timer(reconnect_timer)
|
|
324
|
+
cancel_timer(connect_timer)
|
|
325
|
+
close_websocket(socket, true)
|
|
326
|
+
stop_workers
|
|
327
|
+
end
|
|
282
328
|
info "Connection closed"
|
|
283
329
|
end
|
|
284
330
|
|
|
@@ -293,7 +339,37 @@ module Intrinio
|
|
|
293
339
|
private
|
|
294
340
|
|
|
295
341
|
def queue_message(message)
|
|
296
|
-
|
|
342
|
+
return if message.nil? || message.empty?
|
|
343
|
+
|
|
344
|
+
message_count = message.getbyte(0)
|
|
345
|
+
raise "Message does not contain an event count" if message_count.nil?
|
|
346
|
+
|
|
347
|
+
start_index = 1
|
|
348
|
+
trades = []
|
|
349
|
+
quotes = []
|
|
350
|
+
message_count.times do
|
|
351
|
+
raise "Message header is truncated at byte #{start_index}" if start_index + 2 > message.bytesize
|
|
352
|
+
|
|
353
|
+
message_type = message.getbyte(start_index)
|
|
354
|
+
message_length = message.getbyte(start_index + 1)
|
|
355
|
+
if message_length.nil? || message_length < 2 || start_index + message_length > message.bytesize
|
|
356
|
+
raise "Message body is truncated at byte #{start_index}"
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
chunk = message.byteslice(start_index, message_length)
|
|
360
|
+
if message_type == 0
|
|
361
|
+
trades << chunk
|
|
362
|
+
else
|
|
363
|
+
quotes << chunk
|
|
364
|
+
end
|
|
365
|
+
start_index += message_length
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
trades.each {|chunk| @trade_messages.enq(chunk)}
|
|
369
|
+
quotes.each {|chunk| @quote_messages.enq(chunk)}
|
|
370
|
+
(trades.length + quotes.length).times {@message_available.enq(true)}
|
|
371
|
+
rescue StandardError => e
|
|
372
|
+
error "Error adding message to queues: #{e}"
|
|
297
373
|
end
|
|
298
374
|
|
|
299
375
|
def parse_uint64(data)
|
|
@@ -330,6 +406,8 @@ module Intrinio
|
|
|
330
406
|
IEX
|
|
331
407
|
when 7
|
|
332
408
|
CBOE_ONE
|
|
409
|
+
when 8
|
|
410
|
+
EQUITIES_EDGE
|
|
333
411
|
else
|
|
334
412
|
IEX
|
|
335
413
|
end
|
|
@@ -373,7 +451,7 @@ module Intrinio
|
|
|
373
451
|
trade = parse_trade(data, start_index)
|
|
374
452
|
@on_trade.call(trade)
|
|
375
453
|
return start_index + msg_length
|
|
376
|
-
when 1
|
|
454
|
+
when 1, 2 then
|
|
377
455
|
quote = parse_quote(data, start_index, msg_type)
|
|
378
456
|
@on_quote.call(quote)
|
|
379
457
|
return start_index + msg_length
|
|
@@ -381,47 +459,38 @@ module Intrinio
|
|
|
381
459
|
return start_index + msg_length
|
|
382
460
|
end
|
|
383
461
|
|
|
384
|
-
def handle_data
|
|
385
|
-
Thread.current.priority -= 1
|
|
386
|
-
me = self
|
|
462
|
+
def handle_data(generation)
|
|
463
|
+
Thread.current.priority -= 1 rescue nil
|
|
387
464
|
pop = nil
|
|
388
|
-
until @stop do
|
|
465
|
+
until @stop || generation != @worker_generation do
|
|
389
466
|
begin
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
end
|
|
402
|
-
if !data then me.send :error, "Cannot process data. Data is nil. #{pop}" end
|
|
403
|
-
start_index = 1
|
|
404
|
-
count = data[0]
|
|
405
|
-
# These are grouped (many) messages.
|
|
406
|
-
# The first byte tells us how many there are.
|
|
407
|
-
# From there, check the type and symbol length at index 0 of each chunk to know how many bytes each message has.
|
|
408
|
-
count.times {start_index = handle_message(data, start_index)}
|
|
409
|
-
end
|
|
467
|
+
signal = @message_available.deq
|
|
468
|
+
break if signal.equal?(@stop_message) || @stop || generation != @worker_generation
|
|
469
|
+
|
|
470
|
+
# Always drain a trade first. Quotes can be extremely high volume, so a
|
|
471
|
+
# shared FIFO lets them delay trades even when processing capacity is free.
|
|
472
|
+
pop = nonblocking_dequeue(@trade_messages)
|
|
473
|
+
pop = nonblocking_dequeue(@quote_messages) if pop.nil?
|
|
474
|
+
next if pop.nil?
|
|
475
|
+
|
|
476
|
+
data = pop.unpack('C*')
|
|
477
|
+
handle_message(data, 0)
|
|
410
478
|
rescue StandardError => e
|
|
411
|
-
|
|
479
|
+
error "Error handling message from queue: #{e} #{pop}"
|
|
412
480
|
rescue Exception => e
|
|
413
|
-
|
|
481
|
+
break if @stop || generation != @worker_generation
|
|
482
|
+
error "General error handling message from queue: #{e} #{pop}"
|
|
414
483
|
end
|
|
415
484
|
end
|
|
416
485
|
end
|
|
417
486
|
|
|
418
487
|
def refresh_token
|
|
419
|
-
@token = nil
|
|
420
|
-
|
|
421
488
|
uri = URI.parse(auth_url)
|
|
422
489
|
http = Net::HTTP.new(uri.host, uri.port)
|
|
423
490
|
http.use_ssl = true if (auth_url.include?("https"))
|
|
424
|
-
http.
|
|
491
|
+
http.open_timeout = @http_timeout_seconds
|
|
492
|
+
http.read_timeout = @http_timeout_seconds
|
|
493
|
+
http.write_timeout = @http_timeout_seconds if http.respond_to?(:write_timeout=)
|
|
425
494
|
request = Net::HTTP::Get.new(uri.request_uri)
|
|
426
495
|
request.add_field(CLIENT_INFO_HEADER_KEY, CLIENT_INFO_HEADER_VALUE)
|
|
427
496
|
|
|
@@ -429,13 +498,13 @@ module Intrinio
|
|
|
429
498
|
request.basic_auth(@username, @password)
|
|
430
499
|
end
|
|
431
500
|
|
|
432
|
-
response = http.request(request)
|
|
501
|
+
response = http.start {|session| session.request(request)}
|
|
433
502
|
|
|
434
|
-
|
|
435
|
-
|
|
503
|
+
raise "Unable to authorize" if response.code == "401"
|
|
504
|
+
raise "Could not get auth token (HTTP #{response.code})" if response.code != "200"
|
|
436
505
|
|
|
437
|
-
@token = response.body
|
|
438
506
|
debug "Token refreshed"
|
|
507
|
+
response.body
|
|
439
508
|
end
|
|
440
509
|
|
|
441
510
|
def auth_url
|
|
@@ -447,6 +516,7 @@ module Intrinio
|
|
|
447
516
|
when DELAYED_SIP then url = "https://realtime-delayed-sip.intrinio.com/auth"
|
|
448
517
|
when NASDAQ_BASIC then url = "https://realtime-nasdaq-basic.intrinio.com/auth"
|
|
449
518
|
when CBOE_ONE then url = "https://cboe-one.intrinio.com/auth"
|
|
519
|
+
when EQUITIES_EDGE then url = "https://equities-edge.intrinio.com/auth"
|
|
450
520
|
when MANUAL then url = "http://" + @ip_address + "/auth"
|
|
451
521
|
end
|
|
452
522
|
|
|
@@ -474,123 +544,356 @@ module Intrinio
|
|
|
474
544
|
when DELAYED_SIP then "wss://realtime-delayed-sip.intrinio.com/socket/websocket?vsn=1.0.0&token=#{@token}&#{CLIENT_INFO_HEADER_KEY}=#{CLIENT_INFO_HEADER_VALUE}&#{MESSAGE_VERSION_HEADER_KEY}=#{MESSAGE_VERSION_HEADER_VALUE}#{delayed_part}"
|
|
475
545
|
when NASDAQ_BASIC then "wss://realtime-nasdaq-basic.intrinio.com/socket/websocket?vsn=1.0.0&token=#{@token}&#{CLIENT_INFO_HEADER_KEY}=#{CLIENT_INFO_HEADER_VALUE}&#{MESSAGE_VERSION_HEADER_KEY}=#{MESSAGE_VERSION_HEADER_VALUE}#{delayed_part}"
|
|
476
546
|
when CBOE_ONE then "wss://cboe-one.intrinio.com/socket/websocket?vsn=1.0.0&token=#{@token}&#{CLIENT_INFO_HEADER_KEY}=#{CLIENT_INFO_HEADER_VALUE}&#{MESSAGE_VERSION_HEADER_KEY}=#{MESSAGE_VERSION_HEADER_VALUE}#{delayed_part}"
|
|
547
|
+
when EQUITIES_EDGE then "wss://equities-edge.intrinio.com/socket/websocket?vsn=1.0.0&token=#{@token}&#{CLIENT_INFO_HEADER_KEY}=#{CLIENT_INFO_HEADER_VALUE}&#{MESSAGE_VERSION_HEADER_KEY}=#{MESSAGE_VERSION_HEADER_VALUE}#{delayed_part}"
|
|
477
548
|
when MANUAL then "ws://" + @ip_address + "/socket/websocket?vsn=1.0.0&token=#{@token}&#{CLIENT_INFO_HEADER_KEY}=#{CLIENT_INFO_HEADER_VALUE}&#{MESSAGE_VERSION_HEADER_KEY}=#{MESSAGE_VERSION_HEADER_VALUE}#{delayed_part}"
|
|
478
549
|
else raise "Unknown provider"
|
|
479
550
|
end
|
|
480
551
|
end
|
|
481
552
|
|
|
482
|
-
def refresh_websocket
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
553
|
+
def refresh_websocket(generation)
|
|
554
|
+
headers = {
|
|
555
|
+
:headers => {
|
|
556
|
+
CLIENT_INFO_HEADER_KEY => CLIENT_INFO_HEADER_VALUE,
|
|
557
|
+
MESSAGE_VERSION_HEADER_KEY => MESSAGE_VERSION_HEADER_VALUE
|
|
558
|
+
}
|
|
559
|
+
}
|
|
488
560
|
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
elsif !thread.nil?
|
|
495
|
-
then thread.kill
|
|
561
|
+
old_socket = @state_mutex.synchronize {@ws}
|
|
562
|
+
ws = open_websocket(socket_url, headers) do |socket|
|
|
563
|
+
install_websocket_handlers(socket, generation)
|
|
564
|
+
@state_mutex.synchronize do
|
|
565
|
+
@ws = socket if generation == @connection_generation && !@closing
|
|
496
566
|
end
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
567
|
+
end
|
|
568
|
+
|
|
569
|
+
unless current_connection?(generation, ws)
|
|
570
|
+
close_websocket(ws, false)
|
|
571
|
+
return
|
|
572
|
+
end
|
|
501
573
|
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
@ws = ws = WebSocket::Client::Simple.connect(socket_url, headers)
|
|
574
|
+
close_websocket(old_socket, false) if old_socket && !old_socket.equal?(ws)
|
|
575
|
+
schedule_connect_timeout(generation, ws)
|
|
576
|
+
info "Connection opening"
|
|
577
|
+
end
|
|
507
578
|
|
|
508
|
-
|
|
579
|
+
def open_websocket(url, headers, &block)
|
|
580
|
+
WebSocket::Client::Simple.connect(url, headers, &block)
|
|
581
|
+
end
|
|
509
582
|
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
end
|
|
516
|
-
me.send :stop_self_heal
|
|
583
|
+
def install_websocket_handlers(ws, generation)
|
|
584
|
+
# websocket-client-simple emits with instance_exec, so self inside a
|
|
585
|
+
# raw handler is the socket. Rebind before touching client methods.
|
|
586
|
+
bind_socket_event(ws, :open) do
|
|
587
|
+
schedule_on_reactor {websocket_opened(generation, ws)}
|
|
517
588
|
end
|
|
518
589
|
|
|
519
|
-
ws
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
then me.send :queue_message, data_message
|
|
525
|
-
end
|
|
526
|
-
rescue StandardError => e
|
|
527
|
-
me.send :error, "Error adding message to queue: #{data_message} #{e}"
|
|
590
|
+
bind_socket_event(ws, :message) do |frame|
|
|
591
|
+
next unless current_connection?(generation, ws)
|
|
592
|
+
if frame.respond_to?(:type) && frame.type != :binary
|
|
593
|
+
debug "Ignoring non-binary websocket message"
|
|
594
|
+
next
|
|
528
595
|
end
|
|
596
|
+
queue_message(frame.data) unless frame.data.nil?
|
|
529
597
|
end
|
|
530
|
-
|
|
531
|
-
ws
|
|
532
|
-
|
|
533
|
-
me.send :info, "Connection closing...: #{e}"
|
|
534
|
-
me.send :try_self_heal
|
|
598
|
+
|
|
599
|
+
bind_socket_event(ws, :close) do |exception|
|
|
600
|
+
schedule_on_reactor {websocket_closed(generation, ws, exception)}
|
|
535
601
|
end
|
|
536
602
|
|
|
537
|
-
ws
|
|
538
|
-
|
|
539
|
-
me.send :error, "Connection error: #{e}"
|
|
540
|
-
me.send :try_self_heal
|
|
603
|
+
bind_socket_event(ws, :error) do |exception|
|
|
604
|
+
schedule_on_reactor {websocket_errored(generation, ws, exception)}
|
|
541
605
|
end
|
|
542
606
|
end
|
|
607
|
+
|
|
608
|
+
def bind_socket_event(ws, event, &callback)
|
|
609
|
+
client = self
|
|
610
|
+
ws.on(event) do |*args|
|
|
611
|
+
client.instance_exec(*args, &callback)
|
|
612
|
+
end
|
|
613
|
+
end
|
|
614
|
+
|
|
615
|
+
def websocket_opened(generation, ws)
|
|
616
|
+
timer = nil
|
|
617
|
+
opened = @state_mutex.synchronize do
|
|
618
|
+
if generation == @connection_generation && @ws.equal?(ws) && !@closing
|
|
619
|
+
@ready = true
|
|
620
|
+
@connecting = false
|
|
621
|
+
timer = @connect_timer
|
|
622
|
+
@connect_timer = nil
|
|
623
|
+
true
|
|
624
|
+
else
|
|
625
|
+
false
|
|
626
|
+
end
|
|
627
|
+
end
|
|
628
|
+
return close_websocket(ws, false) unless opened
|
|
629
|
+
|
|
630
|
+
cancel_timer(timer)
|
|
631
|
+
@channels_mutex.synchronize {@joined_channels = []}
|
|
632
|
+
info "Connection established"
|
|
633
|
+
|
|
634
|
+
begin
|
|
635
|
+
channels_refreshed = !PROVIDERS.include?(provider) || refresh_channels
|
|
636
|
+
return unless channels_refreshed
|
|
637
|
+
stop_self_heal
|
|
638
|
+
rescue StandardError => e
|
|
639
|
+
connection_attempt_failed(generation, e, ws)
|
|
640
|
+
end
|
|
641
|
+
end
|
|
642
|
+
|
|
643
|
+
def websocket_closed(generation, ws, exception)
|
|
644
|
+
return unless current_connection?(generation, ws)
|
|
645
|
+
info "Connection closing...: #{exception}"
|
|
646
|
+
connection_attempt_failed(generation, exception || StandardError.new("Websocket closed"), ws)
|
|
647
|
+
end
|
|
648
|
+
|
|
649
|
+
def websocket_errored(generation, ws, exception)
|
|
650
|
+
return unless current_connection?(generation, ws)
|
|
651
|
+
error "Connection error: #{exception}"
|
|
652
|
+
connection_attempt_failed(generation, exception || StandardError.new("Websocket error"), ws)
|
|
653
|
+
end
|
|
543
654
|
|
|
544
655
|
def refresh_channels
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
# Join new channels
|
|
549
|
-
new_channels = @channels - @joined_channels
|
|
550
|
-
new_channels.each do |channel|
|
|
551
|
-
#msg = join_message(channel)
|
|
552
|
-
#@ws.send(msg.to_json)
|
|
553
|
-
msg = join_binary_message(channel)
|
|
554
|
-
@ws.send(msg)
|
|
555
|
-
info "Joined #{channel}"
|
|
656
|
+
state = @state_mutex.synchronize do
|
|
657
|
+
[@ready, @ws, @connection_generation]
|
|
556
658
|
end
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
659
|
+
return unless state[0] && state[1]
|
|
660
|
+
|
|
661
|
+
ws = state[1]
|
|
662
|
+
generation = state[2]
|
|
663
|
+
debug "Refreshing channels"
|
|
664
|
+
|
|
665
|
+
desired_channels = nil
|
|
666
|
+
@channels_mutex.synchronize do
|
|
667
|
+
desired_channels = Array.new(@channels)
|
|
668
|
+
joined_channels = Array.new(@joined_channels)
|
|
669
|
+
|
|
670
|
+
# Keep the desired and server-side subscription sets in lockstep. Calls
|
|
671
|
+
# to join/leave wait briefly while these sends complete, then perform
|
|
672
|
+
# another refresh with their newer desired set.
|
|
673
|
+
new_channels = desired_channels - joined_channels
|
|
674
|
+
new_channels.each do |channel|
|
|
675
|
+
msg = join_binary_message(channel)
|
|
676
|
+
ws.send(msg, :type => :binary)
|
|
677
|
+
raise "Websocket changed while joining channels" unless current_connection?(generation, ws)
|
|
678
|
+
@joined_channels << channel unless @joined_channels.include?(channel)
|
|
679
|
+
info "Joined #{channel}"
|
|
680
|
+
end
|
|
681
|
+
|
|
682
|
+
old_channels = joined_channels - desired_channels
|
|
683
|
+
old_channels.each do |channel|
|
|
684
|
+
msg = leave_binary_message(channel)
|
|
685
|
+
ws.send(msg, :type => :binary)
|
|
686
|
+
raise "Websocket changed while leaving channels" unless current_connection?(generation, ws)
|
|
687
|
+
@joined_channels.delete(channel)
|
|
688
|
+
info "Left #{channel}"
|
|
689
|
+
end
|
|
566
690
|
end
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
691
|
+
|
|
692
|
+
debug "Current channels: #{desired_channels}"
|
|
693
|
+
true
|
|
694
|
+
rescue StandardError => e
|
|
695
|
+
connection_attempt_failed(generation, e, ws) if generation && ws
|
|
696
|
+
false
|
|
571
697
|
end
|
|
572
698
|
|
|
573
699
|
def try_self_heal
|
|
574
|
-
|
|
700
|
+
delay = nil
|
|
701
|
+
@state_mutex.synchronize do
|
|
702
|
+
return if @closing || @ready || @connecting || @selfheal_timer
|
|
703
|
+
|
|
704
|
+
delay = @selfheal_backoffs.first
|
|
705
|
+
@selfheal_backoffs.delete_at(0) if @selfheal_backoffs.count > 1
|
|
706
|
+
# A marker prevents close and error callbacks for the same failed socket
|
|
707
|
+
# from scheduling two reconnect loops before EM.add_timer returns.
|
|
708
|
+
@selfheal_timer = :scheduling
|
|
709
|
+
end
|
|
575
710
|
debug "Attempting to self-heal"
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
711
|
+
|
|
712
|
+
timer = nil
|
|
713
|
+
timer = EM.add_timer(delay / 1000.0) do
|
|
714
|
+
should_connect = @state_mutex.synchronize do
|
|
715
|
+
active = @selfheal_timer.equal?(timer) || @selfheal_timer == :scheduling
|
|
716
|
+
@selfheal_timer = nil if active
|
|
717
|
+
active && !@closing && !@ready && !@connecting
|
|
718
|
+
end
|
|
719
|
+
connect if should_connect
|
|
720
|
+
end
|
|
721
|
+
@state_mutex.synchronize do
|
|
722
|
+
@selfheal_timer = timer if @selfheal_timer == :scheduling
|
|
584
723
|
end
|
|
585
724
|
end
|
|
586
725
|
|
|
587
726
|
def stop_self_heal
|
|
588
|
-
|
|
589
|
-
|
|
727
|
+
timer = @state_mutex.synchronize do
|
|
728
|
+
value = @selfheal_timer
|
|
729
|
+
@selfheal_timer = nil
|
|
730
|
+
@selfheal_backoffs = Array.new(SELF_HEAL_BACKOFFS)
|
|
731
|
+
value
|
|
732
|
+
end
|
|
733
|
+
cancel_timer(timer)
|
|
734
|
+
end
|
|
735
|
+
|
|
736
|
+
def finish_authorization(generation, token)
|
|
737
|
+
authorized = @state_mutex.synchronize do
|
|
738
|
+
if generation == @connection_generation && @connecting && !@closing
|
|
739
|
+
@token = token
|
|
740
|
+
true
|
|
741
|
+
else
|
|
742
|
+
false
|
|
743
|
+
end
|
|
744
|
+
end
|
|
745
|
+
return unless authorized
|
|
746
|
+
|
|
747
|
+
refresh_websocket(generation)
|
|
748
|
+
rescue StandardError => e
|
|
749
|
+
connection_attempt_failed(generation, e)
|
|
750
|
+
end
|
|
751
|
+
|
|
752
|
+
def connection_attempt_failed(generation, exception, socket = nil)
|
|
753
|
+
failed_socket = nil
|
|
754
|
+
connect_timer = nil
|
|
755
|
+
current = @state_mutex.synchronize do
|
|
756
|
+
socket_matches = socket.nil? || @ws.equal?(socket)
|
|
757
|
+
if generation == @connection_generation && socket_matches && !@closing
|
|
758
|
+
failed_socket = @ws
|
|
759
|
+
@ws = nil
|
|
760
|
+
@ready = false
|
|
761
|
+
@connecting = false
|
|
762
|
+
connect_timer = @connect_timer
|
|
763
|
+
@connect_timer = nil
|
|
764
|
+
true
|
|
765
|
+
else
|
|
766
|
+
false
|
|
767
|
+
end
|
|
768
|
+
end
|
|
769
|
+
return unless current
|
|
770
|
+
|
|
771
|
+
@channels_mutex.synchronize {@joined_channels = []}
|
|
772
|
+
cancel_timer(connect_timer)
|
|
773
|
+
close_websocket(failed_socket, false)
|
|
774
|
+
message = exception.respond_to?(:message) ? exception.message : exception.to_s
|
|
775
|
+
error "Connection attempt failed: #{message}"
|
|
776
|
+
try_self_heal
|
|
777
|
+
end
|
|
778
|
+
|
|
779
|
+
def schedule_connect_timeout(generation, ws)
|
|
780
|
+
timer = EM.add_timer(@connect_timeout_seconds) do
|
|
781
|
+
connection_attempt_failed(
|
|
782
|
+
generation,
|
|
783
|
+
Timeout::Error.new("Websocket connect timed out after #{@connect_timeout_seconds} seconds"),
|
|
784
|
+
ws
|
|
785
|
+
)
|
|
786
|
+
end
|
|
787
|
+
|
|
788
|
+
previous_timer = nil
|
|
789
|
+
keep_timer = @state_mutex.synchronize do
|
|
790
|
+
if generation == @connection_generation && @ws.equal?(ws) && @connecting && !@closing
|
|
791
|
+
previous_timer = @connect_timer
|
|
792
|
+
@connect_timer = timer
|
|
793
|
+
true
|
|
794
|
+
else
|
|
795
|
+
false
|
|
796
|
+
end
|
|
797
|
+
end
|
|
798
|
+
cancel_timer(previous_timer)
|
|
799
|
+
cancel_timer(timer) unless keep_timer
|
|
800
|
+
end
|
|
801
|
+
|
|
802
|
+
def current_connection?(generation, ws)
|
|
803
|
+
@state_mutex.synchronize do
|
|
804
|
+
generation == @connection_generation && @ws.equal?(ws) && !@closing
|
|
805
|
+
end
|
|
806
|
+
end
|
|
807
|
+
|
|
808
|
+
def start_workers
|
|
809
|
+
@worker_mutex.synchronize do
|
|
810
|
+
live_threads = @threads.select(&:alive?)
|
|
811
|
+
return if live_threads.length == @thread_quantity
|
|
812
|
+
|
|
813
|
+
live_threads.each(&:kill)
|
|
814
|
+
@threads.each {|thread| thread.join(0.1) if thread.alive?}
|
|
815
|
+
@threads = []
|
|
816
|
+
clear_message_queues
|
|
817
|
+
@stop = false
|
|
818
|
+
@worker_generation += 1
|
|
819
|
+
generation = @worker_generation
|
|
820
|
+
@thread_quantity.times do
|
|
821
|
+
@threads << Thread.new {handle_data(generation)}
|
|
822
|
+
end
|
|
823
|
+
end
|
|
824
|
+
end
|
|
825
|
+
|
|
826
|
+
def stop_workers
|
|
827
|
+
@worker_mutex.synchronize do
|
|
828
|
+
@stop = true
|
|
829
|
+
@worker_generation += 1
|
|
830
|
+
threads = Array.new(@threads)
|
|
831
|
+
threads.length.times {@message_available.enq(@stop_message)}
|
|
832
|
+
|
|
833
|
+
threads.each do |thread|
|
|
834
|
+
next unless thread.alive?
|
|
835
|
+
next if thread.equal?(Thread.current)
|
|
836
|
+
unless thread.join(WORKER_JOIN_TIMEOUT_SECONDS)
|
|
837
|
+
warn "Worker thread timed out while stopping"
|
|
838
|
+
thread.kill
|
|
839
|
+
thread.join(0.1)
|
|
840
|
+
end
|
|
841
|
+
end
|
|
842
|
+
|
|
843
|
+
@threads = []
|
|
844
|
+
clear_message_queues
|
|
845
|
+
@stop = false
|
|
846
|
+
end
|
|
847
|
+
end
|
|
848
|
+
|
|
849
|
+
def clear_message_queues
|
|
850
|
+
@trade_messages.clear
|
|
851
|
+
@quote_messages.clear
|
|
852
|
+
@message_available.clear
|
|
853
|
+
end
|
|
854
|
+
|
|
855
|
+
def nonblocking_dequeue(queue)
|
|
856
|
+
queue.deq(true)
|
|
857
|
+
rescue ThreadError
|
|
858
|
+
nil
|
|
859
|
+
end
|
|
860
|
+
|
|
861
|
+
def close_websocket(ws, wait_for_close)
|
|
862
|
+
return if ws.nil?
|
|
863
|
+
|
|
864
|
+
close_thread = Thread.new do
|
|
865
|
+
begin
|
|
866
|
+
ws.close
|
|
867
|
+
rescue StandardError => e
|
|
868
|
+
error "Error closing websocket: #{e.message}"
|
|
869
|
+
end
|
|
870
|
+
end
|
|
871
|
+
return unless wait_for_close
|
|
872
|
+
|
|
873
|
+
unless close_thread.join(@close_timeout_seconds)
|
|
874
|
+
warn "Websocket close timed out"
|
|
875
|
+
close_thread.kill
|
|
876
|
+
close_thread.join(0.1)
|
|
877
|
+
end
|
|
878
|
+
end
|
|
879
|
+
|
|
880
|
+
def cancel_timer(timer)
|
|
881
|
+
return if timer.nil? || timer == :scheduling
|
|
882
|
+
EM.cancel_timer(timer)
|
|
883
|
+
rescue StandardError
|
|
884
|
+
nil
|
|
885
|
+
end
|
|
886
|
+
|
|
887
|
+
def schedule_on_reactor(&block)
|
|
888
|
+
if EM.reactor_running?
|
|
889
|
+
EM.schedule(&block)
|
|
890
|
+
else
|
|
891
|
+
block.call
|
|
892
|
+
end
|
|
590
893
|
end
|
|
591
894
|
|
|
592
895
|
def ready(val)
|
|
593
|
-
@ready = val
|
|
896
|
+
@state_mutex.synchronize {@ready = val}
|
|
594
897
|
end
|
|
595
898
|
|
|
596
899
|
def debug(message)
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: intrinio-realtime
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 5.
|
|
4
|
+
version: 5.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Intrinio
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date:
|
|
11
|
+
date: 2026-10-22 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: eventmachine
|