intrinio-realtime 1.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 +7 -0
  2. data/lib/intrinio-realtime.rb +295 -0
  3. metadata +88 -0
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: d5bfe9d4ae794df6de84bdc415554e2562da3369
4
+ data.tar.gz: 43a51886bdf1fe2603b5af0420919945325a58d1
5
+ SHA512:
6
+ metadata.gz: 3d356ae56916d72fb0cc0243e58354ec925b08e598fd97aa121aaeb80fb07fdb1be61cc60e78e95a1c1c120635acaf70db0ea5d1ba6a46c546051d034d62950f
7
+ data.tar.gz: 2a210412771750e5ed2ac3cfc17fd210c498dd2a88537f15981ded4c3c7b4e73ad8d4e1d2740289ec6dda1aeb3a6a988f22f518f33c3f26bc868c3257452583d
@@ -0,0 +1,295 @@
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
+ AUTH_URL = "https://realtime.intrinio.com/auth"
10
+ SOCKET_URL = "wss://realtime.intrinio.com/socket/websocket"
11
+ HEARTBEAT_TIME = 1
12
+ HEARTBEAT_MSG = {topic: 'phoenix', event: 'heartbeat', payload: {}, ref: nil}.to_json
13
+ SELF_HEAL_BACKOFFS = [0,100,500,1000,2000,5000]
14
+ DEFAULT_POOL_SIZE = 100
15
+
16
+ def self.connect(options, &b)
17
+ EM.run do
18
+ client = ::Intrinio::Realtime::Client.new(options)
19
+ client.on_quote(&b)
20
+ client.connect()
21
+ end
22
+ end
23
+
24
+ class Client
25
+ def initialize(options)
26
+ raise "Options parameter is required" if options.nil? || !options.is_a?(Hash)
27
+
28
+ @username = options[:username]
29
+ @password = options[:password]
30
+ raise "Username and password are required" if @username.nil? || @username.empty? || @password.nil? || @password.empty?
31
+
32
+ @channels = []
33
+ @channels = parse_channels(options[:channels]) if options[:channels]
34
+ bad_channels = @channels.select{|x| !x.is_a?(String)}
35
+ raise "Invalid channels to join: #{bad_channels}" unless bad_channels.empty?
36
+
37
+ if options[:logger] == false
38
+ @logger = nil
39
+ elsif !options[:logger].nil?
40
+ @logger = options[:logger]
41
+ else
42
+ @logger = Logger.new($stdout)
43
+ @logger.level = Logger::INFO
44
+ end
45
+
46
+ @quotes = EventMachine::Channel.new
47
+ @ready = false
48
+ @joined_channels = []
49
+ @heartbeat_timer = nil
50
+ @selfheal_timer = nil
51
+ @selfheal_backoffs = Array.new(SELF_HEAL_BACKOFFS)
52
+ @ws = nil
53
+ end
54
+
55
+ def join(*channels)
56
+ channels = parse_channels(channels)
57
+ nonconforming = channels.select{|x| !x.is_a?(String)}
58
+ return error("Invalid channels to join: #{nonconforming}") unless nonconforming.empty?
59
+
60
+ @channels.concat(channels)
61
+ @channels.uniq!
62
+ debug "Joining channels #{channels}"
63
+
64
+ refresh_channels()
65
+ end
66
+
67
+ def leave(*channels)
68
+ channels = parse_channels(channels)
69
+ nonconforming = channels.find{|x| !x.is_a?(String)}
70
+ return error("Invalid channels to leave: #{nonconforming}") unless nonconforming.empty?
71
+
72
+ channels.each{|c| @channels.delete(c)}
73
+ debug "Leaving channels #{channels}"
74
+
75
+ refresh_channels()
76
+ end
77
+
78
+ def leave_all
79
+ @channels = []
80
+ debug "Leaving all channels"
81
+ refresh_channels()
82
+ end
83
+
84
+ def on_quote(&b)
85
+ @quotes.subscribe(&b)
86
+ end
87
+
88
+ def connect
89
+ raise "Must be run from within an EventMachine run loop" unless EM.reactor_running?
90
+ return warn("Already connected!") if @ready
91
+ debug "Connecting..."
92
+
93
+ catch :fatal do
94
+ begin
95
+ @closing = false
96
+ @ready = false
97
+ refresh_token()
98
+ refresh_websocket()
99
+ rescue StandardError => e
100
+ error("Connection error: #{e}")
101
+ try_self_heal()
102
+ end
103
+ end
104
+ end
105
+
106
+ def disconnect
107
+ EM.cancel_timer(@heartbeat_timer) if @heartbeat_timer
108
+ EM.cancel_timer(@selfheal_timer) if @selfheal_timer
109
+ @ready = false
110
+ @closing = true
111
+ @channels = []
112
+ @joined_channels = []
113
+ @ws.close() if @ws
114
+ info "Connection closed"
115
+ end
116
+
117
+ private
118
+
119
+ def refresh_token
120
+ @token = nil
121
+
122
+ response = HTTP.basic_auth(:user => @username, :pass => @password).get(AUTH_URL)
123
+ return fatal("Unable to authorize") if response.status == 401
124
+ return fatal("Could not get auth token") if response.status != 200
125
+
126
+ @token = response.body
127
+ debug "Token refreshed"
128
+ end
129
+
130
+ def socket_url
131
+ URI.escape(SOCKET_URL + "?vsn=1.0.0&token=#{@token}")
132
+ end
133
+
134
+ def refresh_websocket
135
+ me = self
136
+
137
+ @ws.close() unless @ws.nil?
138
+ @ready = false
139
+ @joined_channels = []
140
+
141
+ @ws = ws = WebSocket::Client::Simple.connect(socket_url)
142
+
143
+ ws.on :open do
144
+ me.send :ready, true
145
+ me.send :info, "Connection established"
146
+ me.send :start_heartbeat
147
+ me.send :refresh_channels
148
+ me.send :stop_self_heal
149
+ end
150
+
151
+ ws.on :message do |frame|
152
+ message = frame.data
153
+ me.send :debug, "Message: #{message}"
154
+
155
+ begin
156
+ json = JSON.parse(message)
157
+ if json["event"] == "quote"
158
+ quote = json["payload"]
159
+ me.send :process_quote, quote
160
+ end
161
+ rescue StandardError => e
162
+ me.send :error, "Could not parse message: #{message} #{e}"
163
+ end
164
+ end
165
+
166
+ ws.on :error do |e|
167
+ me.send :ready, false
168
+ me.send :error, "Connection error: #{e}"
169
+ me.send :try_self_heal
170
+ end
171
+ end
172
+
173
+ def refresh_channels
174
+ return unless @ready
175
+ debug "Refreshing channels"
176
+
177
+ # Join new channels
178
+ new_channels = @channels - @joined_channels
179
+ new_channels.each do |channel|
180
+ msg = {
181
+ topic: parse_topic(channel),
182
+ event: "phx_join",
183
+ payload: {},
184
+ ref: nil
185
+ }.to_json
186
+
187
+ @ws.send(msg)
188
+ info "Joined #{channel}"
189
+ end
190
+
191
+ # Leave old channels
192
+ old_channels = @joined_channels - @channels
193
+ old_channels.each do |channel|
194
+ msg = {
195
+ topic: parse_topic(channel),
196
+ event: 'phx_leave',
197
+ payload: {},
198
+ ref: nil
199
+ }.to_json
200
+
201
+ @ws.send(msg)
202
+ info "Left #{channel}"
203
+ end
204
+
205
+ @channels.uniq!
206
+ @joined_channels = Array.new(@channels)
207
+ debug "Current channels: #{@channels}"
208
+ end
209
+
210
+ def start_heartbeat
211
+ EM.cancel_timer(@heartbeat_timer) if @heartbeat_timer
212
+ @heartbeat_timer = EM.add_periodic_timer(HEARTBEAT_TIME) do
213
+ debug "Heartbeat"
214
+ @ws.send(HEARTBEAT_MSG)
215
+ end
216
+ end
217
+
218
+ def stop_heartbeat
219
+ EM.cancel_timer(@heartbeat_timer) if @heartbeat_timer
220
+ end
221
+
222
+ def try_self_heal
223
+ return if @closing
224
+ debug "Attempting to self-heal"
225
+
226
+ time = @selfheal_backoffs.first
227
+ @selfheal_backoffs.delete_at(0) if @selfheal_backoffs.count > 1
228
+
229
+ EM.cancel_timer(@heartbeat_timer) if @heartbeat_timer
230
+ EM.cancel_timer(@selfheal_timer) if @selfheal_timer
231
+
232
+ @selfheal_timer = EM.add_timer(time/1000) do
233
+ connect()
234
+ end
235
+ end
236
+
237
+ def stop_self_heal
238
+ EM.cancel_timer(@selfheal_timer) if @selfheal_timer
239
+ @selfheal_backoffs = Array.new(SELF_HEAL_BACKOFFS)
240
+ end
241
+
242
+ def ready(val)
243
+ @ready = val
244
+ end
245
+
246
+ def process_quote(quote)
247
+ @quotes.push(quote)
248
+ end
249
+
250
+ def debug(message)
251
+ message = "IntrinioRealtime | #{message}"
252
+ @logger.debug(message) rescue
253
+ nil
254
+ end
255
+
256
+ def info(message)
257
+ message = "IntrinioRealtime | #{message}"
258
+ @logger.info(message) rescue
259
+ nil
260
+ end
261
+
262
+ def error(message)
263
+ message = "IntrinioRealtime | #{message}"
264
+ @logger.error(message) rescue
265
+ nil
266
+ end
267
+
268
+ def fatal(message)
269
+ message = "IntrinioRealtime | #{message}"
270
+ @logger.fatal(message) rescue
271
+ EM.stop_event_loop
272
+ throw :fatal
273
+ nil
274
+ end
275
+
276
+ def parse_topic(channel)
277
+ case channel
278
+ when "$lobby"
279
+ "iex:lobby"
280
+ when "$lobby_last_price"
281
+ "iex:lobby:last_price"
282
+ else
283
+ "iex:securities:#{channel}"
284
+ end
285
+ end
286
+
287
+ def parse_channels(channels)
288
+ channels.flatten!
289
+ channels.uniq!
290
+ channels.compact!
291
+ channels
292
+ end
293
+ end
294
+ end
295
+ end
metadata ADDED
@@ -0,0 +1,88 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: intrinio-realtime
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Intrinio
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2017-05-11 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: http
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '2.2'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '2.2'
27
+ - !ruby/object:Gem::Dependency
28
+ name: eventmachine
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '1.2'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '1.2'
41
+ - !ruby/object:Gem::Dependency
42
+ name: websocket-client-simple
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '0.3'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '0.3'
55
+ description: Intrinio Ruby SDK for Real-Time Stock Prices
56
+ email:
57
+ - asolo@intrinio.com
58
+ executables: []
59
+ extensions: []
60
+ extra_rdoc_files: []
61
+ files:
62
+ - lib/intrinio-realtime.rb
63
+ homepage: https://github.com/intrinio/intrinio-realtime-ruby-sdk
64
+ licenses:
65
+ - GPL-3.0
66
+ metadata: {}
67
+ post_install_message:
68
+ rdoc_options: []
69
+ require_paths:
70
+ - lib
71
+ required_ruby_version: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ required_rubygems_version: !ruby/object:Gem::Requirement
77
+ requirements:
78
+ - - ">="
79
+ - !ruby/object:Gem::Version
80
+ version: '0'
81
+ requirements: []
82
+ rubyforge_project:
83
+ rubygems_version: 2.5.1
84
+ signing_key:
85
+ specification_version: 4
86
+ summary: Intrinio provides real-time stock prices from the IEX stock exchange, via
87
+ a two-way WebSocket connection.
88
+ test_files: []