intrinio-realtime 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- 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: []
|