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.
- checksums.yaml +7 -0
- data/lib/intrinio-realtime.rb +295 -0
- metadata +88 -0
checksums.yaml
ADDED
@@ -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: []
|