intrinio-realtime 1.0.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 +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: []