massiveclient 0.3.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/.gitignore +2 -0
- data/.rubocop.yml +49 -0
- data/CHANGELOG.md +34 -0
- data/Gemfile +6 -0
- data/Gemfile.lock +197 -0
- data/README.md +59 -0
- data/Rakefile +12 -0
- data/bin/console +15 -0
- data/lib/massiveclient/rest/api/crypto.rb +199 -0
- data/lib/massiveclient/rest/api/forex.rb +129 -0
- data/lib/massiveclient/rest/api/reference/locales.rb +22 -0
- data/lib/massiveclient/rest/api/reference/markets.rb +55 -0
- data/lib/massiveclient/rest/api/reference/stocks.rb +70 -0
- data/lib/massiveclient/rest/api/reference/tickers.rb +136 -0
- data/lib/massiveclient/rest/api/stocks.rb +329 -0
- data/lib/massiveclient/rest/api.rb +34 -0
- data/lib/massiveclient/rest/client.rb +71 -0
- data/lib/massiveclient/rest/errors.rb +43 -0
- data/lib/massiveclient/rest.rb +5 -0
- data/lib/massiveclient/types.rb +7 -0
- data/lib/massiveclient/version.rb +5 -0
- data/lib/massiveclient/websocket/client.rb +140 -0
- data/lib/massiveclient/websocket/errors.rb +21 -0
- data/lib/massiveclient/websocket/events.rb +101 -0
- data/lib/massiveclient/websocket.rb +5 -0
- data/lib/massiveclient.rb +15 -0
- data/massiveclient.gemspec +54 -0
- metadata +311 -0
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MassiveClient
|
|
4
|
+
module Rest
|
|
5
|
+
class Stocks < PolygonRestHandler
|
|
6
|
+
class StockExchange < PolygonResponse
|
|
7
|
+
attribute :id, Types::Integer
|
|
8
|
+
attribute :type, Types::String
|
|
9
|
+
attribute :market, Types::String
|
|
10
|
+
attribute? :mic, Types::String
|
|
11
|
+
attribute :name, Types::String
|
|
12
|
+
attribute? :tape, Types::String
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def list_exchanges
|
|
16
|
+
res = client.request.get("/v1/meta/exchanges")
|
|
17
|
+
Types::Array.of(StockExchange)[res.body]
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
class HistoricTradesResponse < PolygonResponse
|
|
21
|
+
attribute :results_count, Types::Integer
|
|
22
|
+
attribute :db_latency, Types::Integer
|
|
23
|
+
attribute :success, Types::Bool
|
|
24
|
+
attribute :ticker, Types::String
|
|
25
|
+
attribute :map, Types::Hash
|
|
26
|
+
attribute :results, Types::Array do
|
|
27
|
+
attribute? :T, Types::String # Not receiving for some reason
|
|
28
|
+
attribute :t, Types::Integer
|
|
29
|
+
attribute :y, Types::Integer
|
|
30
|
+
attribute? :f, Types::Integer
|
|
31
|
+
attribute :q, Types::Integer
|
|
32
|
+
attribute :i, Types::String
|
|
33
|
+
attribute :x, Types::Integer
|
|
34
|
+
attribute :s, Types::Integer
|
|
35
|
+
attribute :c, Types::Array.of(Types::Integer)
|
|
36
|
+
attribute :p, Types::JSON::Decimal
|
|
37
|
+
attribute :z, Types::Integer
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
class HistoricParams < Dry::Struct
|
|
42
|
+
attribute? :timestamp, Types::Integer
|
|
43
|
+
attribute? :timestampLimit, Types::Integer # TODO: change to underscore?
|
|
44
|
+
attribute? :reverse, Types::Bool
|
|
45
|
+
attribute? :limit, Types::Integer
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def historic_trades(ticker, date, params = {})
|
|
49
|
+
ticker = Types::String[ticker]
|
|
50
|
+
date = Types::JSON::Date[date]
|
|
51
|
+
params = HistoricParams[params]
|
|
52
|
+
|
|
53
|
+
res = client.request.get("/v2/ticks/stocks/trades/#{ticker}/#{date}", params.to_h)
|
|
54
|
+
HistoricTradesResponse[res.body]
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
class HistoricQuotesResponse < PolygonResponse
|
|
58
|
+
attribute :results_count, Types::Integer
|
|
59
|
+
attribute :db_latency, Types::Integer
|
|
60
|
+
attribute :success, Types::Bool
|
|
61
|
+
attribute :ticker, Types::String
|
|
62
|
+
attribute :map, Types::Hash
|
|
63
|
+
attribute :results, Types::Array do
|
|
64
|
+
attribute? :T, Types::String # Not receiving for some reason
|
|
65
|
+
attribute :t, Types::Integer
|
|
66
|
+
attribute :y, Types::Integer
|
|
67
|
+
attribute? :f, Types::Integer
|
|
68
|
+
attribute :q, Types::Integer
|
|
69
|
+
attribute? :i, Types::Array.of(Types::Integer)
|
|
70
|
+
attribute :p, Types::JSON::Decimal
|
|
71
|
+
attribute :x, Types::Integer
|
|
72
|
+
attribute :s, Types::Integer
|
|
73
|
+
attribute? :P, Types::JSON::Decimal
|
|
74
|
+
attribute? :X, Types::Integer
|
|
75
|
+
attribute? :S, Types::Integer
|
|
76
|
+
attribute :c, Types::Array.of(Types::Integer)
|
|
77
|
+
attribute :z, Types::Integer
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def historic_quotes(ticker, date, params = {})
|
|
82
|
+
ticker = Types::String[ticker]
|
|
83
|
+
date = Types::JSON::Date[date]
|
|
84
|
+
params = HistoricParams[params]
|
|
85
|
+
|
|
86
|
+
res = client.request.get("/v2/ticks/stocks/nbbo/#{ticker}/#{date}", params.to_h)
|
|
87
|
+
HistoricQuotesResponse[res.body]
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
class LastTradeResponse < PolygonResponse
|
|
91
|
+
attribute :status, Types::String
|
|
92
|
+
attribute :symbol, Types::String
|
|
93
|
+
attribute :last do
|
|
94
|
+
attribute :price, Types::JSON::Decimal
|
|
95
|
+
attribute :size, Types::Integer
|
|
96
|
+
attribute :exchange, Types::Integer
|
|
97
|
+
attribute :cond1, Types::Integer
|
|
98
|
+
attribute :cond2, Types::Integer
|
|
99
|
+
attribute :cond3, Types::Integer
|
|
100
|
+
attribute? :cond4, Types::Integer
|
|
101
|
+
attribute :timestamp, Types::Integer
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def last_trade(symbol)
|
|
106
|
+
symbol = Types::String[symbol]
|
|
107
|
+
|
|
108
|
+
res = client.request.get("/v1/last/stocks/#{symbol}")
|
|
109
|
+
LastTradeResponse[res.body]
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
class LastQuoteResponse < PolygonResponse
|
|
113
|
+
attribute :status, Types::String
|
|
114
|
+
attribute :symbol, Types::String
|
|
115
|
+
attribute :last do
|
|
116
|
+
attribute :askprice, Types::JSON::Decimal
|
|
117
|
+
attribute :asksize, Types::Integer
|
|
118
|
+
attribute :askexchange, Types::Integer
|
|
119
|
+
attribute :bidprice, Types::JSON::Decimal
|
|
120
|
+
attribute :bidsize, Types::Integer
|
|
121
|
+
attribute :bidexchange, Types::Integer
|
|
122
|
+
attribute :timestamp, Types::Integer
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def last_quote(symbol)
|
|
127
|
+
symbol = Types::String[symbol]
|
|
128
|
+
|
|
129
|
+
res = client.request.get("/v1/last_quote/stocks/#{symbol}")
|
|
130
|
+
LastQuoteResponse[res.body]
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
class DailyOpenCloseResponse < PolygonResponse
|
|
134
|
+
attribute :status, Types::String
|
|
135
|
+
attribute :symbol, Types::String
|
|
136
|
+
attribute :open, Types::JSON::Decimal
|
|
137
|
+
attribute :high, Types::JSON::Decimal
|
|
138
|
+
attribute :low, Types::JSON::Decimal
|
|
139
|
+
attribute :close, Types::JSON::Decimal
|
|
140
|
+
attribute :volume, Types::Integer
|
|
141
|
+
attribute :after_hours, Types::JSON::Decimal
|
|
142
|
+
attribute :from, Types::JSON::DateTime
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def daily_open_close(symbol, date)
|
|
146
|
+
symbol = Types::String[symbol]
|
|
147
|
+
date = Types::JSON::Date[date]
|
|
148
|
+
|
|
149
|
+
res = client.request.get("/v1/open-close/#{symbol}/#{date}")
|
|
150
|
+
DailyOpenCloseResponse[res.body]
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def condition_mappings(tick_type)
|
|
154
|
+
tick_type = Types::String.enum("trades", "quotes")[tick_type]
|
|
155
|
+
|
|
156
|
+
res = client.request.get("/v1/meta/conditions/#{tick_type}")
|
|
157
|
+
Types::Hash[res.body]
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
class SnapshotTicker < PolygonResponse
|
|
161
|
+
attribute :ticker, Types::String
|
|
162
|
+
attribute :day do
|
|
163
|
+
attribute :c, Types::JSON::Decimal
|
|
164
|
+
attribute :h, Types::JSON::Decimal
|
|
165
|
+
attribute :l, Types::JSON::Decimal
|
|
166
|
+
attribute :o, Types::JSON::Decimal
|
|
167
|
+
attribute :v, Types::JSON::Decimal
|
|
168
|
+
end
|
|
169
|
+
attribute? :last_trade do
|
|
170
|
+
attribute? :c1, Types::Integer
|
|
171
|
+
attribute? :c2, Types::Integer
|
|
172
|
+
attribute? :c3, Types::Integer
|
|
173
|
+
attribute? :c4, Types::Integer
|
|
174
|
+
attribute? :e, Types::Integer
|
|
175
|
+
attribute :p, Types::JSON::Decimal
|
|
176
|
+
attribute :s, Types::Integer
|
|
177
|
+
attribute :t, Types::Integer
|
|
178
|
+
end
|
|
179
|
+
attribute? :last_quote do
|
|
180
|
+
attribute :p, Types::JSON::Decimal
|
|
181
|
+
attribute :s, Types::Integer
|
|
182
|
+
attribute? :P, Types::JSON::Decimal
|
|
183
|
+
attribute? :S, Types::Integer
|
|
184
|
+
attribute :t, Types::Integer
|
|
185
|
+
end
|
|
186
|
+
attribute :min do
|
|
187
|
+
attribute :c, Types::JSON::Decimal
|
|
188
|
+
attribute :h, Types::JSON::Decimal
|
|
189
|
+
attribute :l, Types::JSON::Decimal
|
|
190
|
+
attribute :o, Types::JSON::Decimal
|
|
191
|
+
attribute :v, Types::JSON::Decimal
|
|
192
|
+
end
|
|
193
|
+
attribute :prev_day do
|
|
194
|
+
attribute :c, Types::JSON::Decimal
|
|
195
|
+
attribute :h, Types::JSON::Decimal
|
|
196
|
+
attribute :l, Types::JSON::Decimal
|
|
197
|
+
attribute :o, Types::JSON::Decimal
|
|
198
|
+
attribute :v, Types::JSON::Decimal
|
|
199
|
+
end
|
|
200
|
+
attribute :todays_change, Types::JSON::Decimal
|
|
201
|
+
attribute :todays_change_perc, Types::JSON::Decimal
|
|
202
|
+
attribute :updated, Types::Integer
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
class FullSnapshotResponse < PolygonResponse
|
|
206
|
+
attribute :status, Types::String
|
|
207
|
+
attribute :tickers, Types::Array.of(SnapshotTicker)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def full_snapshot
|
|
211
|
+
res = client.request.get("/v2/snapshot/locale/us/markets/stocks/tickers")
|
|
212
|
+
FullSnapshotResponse[res.body]
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
class SnapshotResponse < PolygonResponse
|
|
216
|
+
attribute :status, Types::String
|
|
217
|
+
attribute :ticker, SnapshotTicker
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def snapshot(ticker)
|
|
221
|
+
ticker = Types::String[ticker]
|
|
222
|
+
|
|
223
|
+
res = client.request.get("/v2/snapshot/locale/us/markets/stocks/tickers/#{ticker}")
|
|
224
|
+
SnapshotResponse[res.body]
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
class SnapshotGainersLosersResponse < PolygonResponse
|
|
228
|
+
attribute :status, Types::String
|
|
229
|
+
attribute :tickers, Types::Array.of(SnapshotTicker)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def snapshot_gainers_losers(direction)
|
|
233
|
+
direction = Types::String.enum("gainers", "losers")[direction]
|
|
234
|
+
|
|
235
|
+
res = client.request.get("/v2/snapshot/locale/us/markets/stocks/#{direction}")
|
|
236
|
+
SnapshotGainersLosersResponse[res.body]
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
class PreviousCloseResponse < PolygonResponse
|
|
240
|
+
attribute :ticker, Types::String
|
|
241
|
+
attribute :status, Types::String
|
|
242
|
+
attribute :adjusted, Types::Bool
|
|
243
|
+
attribute :query_count, Types::Integer
|
|
244
|
+
attribute :results_count, Types::Integer
|
|
245
|
+
attribute :results, Types::Array do
|
|
246
|
+
attribute :T, Types::String
|
|
247
|
+
attribute :v, Types::JSON::Decimal
|
|
248
|
+
attribute :vw, Types::JSON::Decimal
|
|
249
|
+
attribute :o, Types::JSON::Decimal
|
|
250
|
+
attribute :c, Types::JSON::Decimal
|
|
251
|
+
attribute :h, Types::JSON::Decimal
|
|
252
|
+
attribute :l, Types::JSON::Decimal
|
|
253
|
+
attribute :t, Types::Integer
|
|
254
|
+
attribute? :n, Types::Integer
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def previous_close(ticker, unadjusted = false)
|
|
259
|
+
ticker = Types::String[ticker]
|
|
260
|
+
unadjusted = Types::Bool[unadjusted]
|
|
261
|
+
|
|
262
|
+
res = client.request.get("/v2/aggs/ticker/#{ticker}/prev", { unadjusted: unadjusted })
|
|
263
|
+
PreviousCloseResponse[res.body]
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
class AggregatesResponse < PolygonResponse
|
|
267
|
+
attribute :ticker, Types::String
|
|
268
|
+
attribute :status, Types::String
|
|
269
|
+
attribute :adjusted, Types::Bool
|
|
270
|
+
attribute :query_count, Types::Integer
|
|
271
|
+
attribute :results_count, Types::Integer
|
|
272
|
+
attribute :results, Types::Array do
|
|
273
|
+
attribute? :T, Types::String # Not appearing
|
|
274
|
+
attribute :v, Types::JSON::Decimal
|
|
275
|
+
attribute? :vw, Types::JSON::Decimal
|
|
276
|
+
attribute :o, Types::JSON::Decimal
|
|
277
|
+
attribute :c, Types::JSON::Decimal
|
|
278
|
+
attribute :h, Types::JSON::Decimal
|
|
279
|
+
attribute :l, Types::JSON::Decimal
|
|
280
|
+
attribute :t, Types::Integer
|
|
281
|
+
attribute? :n, Types::Integer
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def aggregates(ticker, multiplier, timespan, from, to, unadjusted = false) # rubocop:disable Metrics/ParameterLists
|
|
286
|
+
ticker = Types::String[ticker]
|
|
287
|
+
multiplier = Types::Integer[multiplier]
|
|
288
|
+
timespan = Types::Coercible::String.enum("minute", "hour", "day", "week", "month", "quarter", "year")[timespan]
|
|
289
|
+
from = Types::JSON::Date[from]
|
|
290
|
+
to = Types::JSON::Date[to]
|
|
291
|
+
unadjusted = Types::Bool[unadjusted]
|
|
292
|
+
|
|
293
|
+
res = client.request.get("/v2/aggs/ticker/#{ticker}/range/#{multiplier}/#{timespan}/#{from}/#{to}",
|
|
294
|
+
{ unadjusted: unadjusted })
|
|
295
|
+
AggregatesResponse[res.body]
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
class GroupedDailyResponse < PolygonResponse
|
|
299
|
+
attribute? :ticker, Types::String
|
|
300
|
+
attribute :status, Types::String
|
|
301
|
+
attribute :adjusted, Types::Bool
|
|
302
|
+
attribute :query_count, Types::Integer
|
|
303
|
+
attribute :results_count, Types::Integer
|
|
304
|
+
attribute :results, Types::Array do
|
|
305
|
+
attribute :T, Types::String # Not appearing
|
|
306
|
+
attribute :v, Types::JSON::Decimal
|
|
307
|
+
attribute? :vw, Types::JSON::Decimal
|
|
308
|
+
attribute :o, Types::JSON::Decimal
|
|
309
|
+
attribute :c, Types::JSON::Decimal
|
|
310
|
+
attribute :h, Types::JSON::Decimal
|
|
311
|
+
attribute :l, Types::JSON::Decimal
|
|
312
|
+
attribute :t, Types::Integer
|
|
313
|
+
attribute? :n, Types::Integer
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def grouped_daily(locale, market, date, unadjusted = false)
|
|
318
|
+
locale = Types::String[locale]
|
|
319
|
+
market = Types::Coercible::String.enum("stocks", "crypto", "bonds", "mf", "mmf", "indices", "fx")[market]
|
|
320
|
+
date = Types::JSON::Date[date]
|
|
321
|
+
unadjusted = Types::Bool[unadjusted]
|
|
322
|
+
|
|
323
|
+
res = client.request.get("/v2/aggs/grouped/locale/#{locale}/market/#{market.upcase}/#{date}",
|
|
324
|
+
{ unadjusted: unadjusted })
|
|
325
|
+
GroupedDailyResponse[res.body]
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MassiveClient
|
|
4
|
+
module Rest
|
|
5
|
+
class PolygonResponse < Dry::Struct
|
|
6
|
+
NUMBERS_TO_WORDS = {
|
|
7
|
+
"10" => "ten",
|
|
8
|
+
"9" => "nine",
|
|
9
|
+
"8" => "eight",
|
|
10
|
+
"7" => "seven",
|
|
11
|
+
"6" => "six",
|
|
12
|
+
"5" => "five",
|
|
13
|
+
"4" => "four",
|
|
14
|
+
"3" => "three",
|
|
15
|
+
"2" => "two",
|
|
16
|
+
"1" => "one"
|
|
17
|
+
}.freeze
|
|
18
|
+
|
|
19
|
+
transform_keys do |k|
|
|
20
|
+
k = NUMBERS_TO_WORDS[k] if NUMBERS_TO_WORDS.key?(k)
|
|
21
|
+
k = k.underscore unless k.length == 1
|
|
22
|
+
k.to_sym
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
require_relative "api/crypto"
|
|
29
|
+
require_relative "api/forex"
|
|
30
|
+
require_relative "api/stocks"
|
|
31
|
+
require_relative "api/reference/locales"
|
|
32
|
+
require_relative "api/reference/markets"
|
|
33
|
+
require_relative "api/reference/stocks"
|
|
34
|
+
require_relative "api/reference/tickers"
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MassiveClient
|
|
4
|
+
module Rest
|
|
5
|
+
class Client
|
|
6
|
+
Struct.new("Reference", :locales, :markets, :stocks, :tickers)
|
|
7
|
+
|
|
8
|
+
BASE_URL = "https://api.massive.com/"
|
|
9
|
+
|
|
10
|
+
attr_reader :url, :api_key
|
|
11
|
+
|
|
12
|
+
def initialize(api_key, &block)
|
|
13
|
+
@url = BASE_URL
|
|
14
|
+
@api_key = Types::String[api_key]
|
|
15
|
+
@request_builder = block if block_given?
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
RETRY_OPTIONS = {
|
|
19
|
+
max: 2,
|
|
20
|
+
interval: 0.05,
|
|
21
|
+
interval_randomness: 0.5,
|
|
22
|
+
backoff_factor: 2
|
|
23
|
+
}.freeze
|
|
24
|
+
|
|
25
|
+
def request
|
|
26
|
+
Faraday.new(url: "#{url}?apiKey=#{api_key}") do |builder|
|
|
27
|
+
builder.request :retry, RETRY_OPTIONS
|
|
28
|
+
builder.use ErrorMiddleware
|
|
29
|
+
@request_builder&.call(builder)
|
|
30
|
+
builder.request :json
|
|
31
|
+
builder.response :json, content_type: /\bjson$/
|
|
32
|
+
builder.adapter Faraday.default_adapter
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def reference
|
|
37
|
+
Struct::Reference.new(
|
|
38
|
+
Rest::Reference::Locales.new(self),
|
|
39
|
+
Rest::Reference::Markets.new(self),
|
|
40
|
+
Rest::Reference::Stocks.new(self),
|
|
41
|
+
Rest::Reference::Tickers.new(self)
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def stocks
|
|
46
|
+
Rest::Stocks.new(self)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def forex
|
|
50
|
+
Rest::Forex.new(self)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def crypto
|
|
54
|
+
Rest::Crypto.new(self)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
class PolygonRestHandler
|
|
59
|
+
attr_reader :client
|
|
60
|
+
|
|
61
|
+
def initialize(client)
|
|
62
|
+
@client = client
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
class PagingParameters < Dry::Struct
|
|
67
|
+
attribute? :offset, Types::Integer.optional
|
|
68
|
+
attribute? :limit, Types::Integer.default(100)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MassiveClient
|
|
4
|
+
module Errors
|
|
5
|
+
class PolygonRestClientError < StandardError; end
|
|
6
|
+
|
|
7
|
+
class BadRequestError < PolygonRestClientError; end
|
|
8
|
+
class UnauthorizedError < PolygonRestClientError; end
|
|
9
|
+
class ForbiddenError < PolygonRestClientError; end
|
|
10
|
+
class ResourceNotFoundError < PolygonRestClientError; end
|
|
11
|
+
class ServerError < PolygonRestClientError; end
|
|
12
|
+
class UnknownError < PolygonRestClientError; end
|
|
13
|
+
class UnexpectedResponseError < PolygonRestClientError; end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
class ErrorMiddleware < Faraday::Middleware
|
|
17
|
+
CLIENT_ERROR_STATUSES = (400...500).freeze
|
|
18
|
+
SERVER_ERROR_STATUSES = (500...600).freeze
|
|
19
|
+
|
|
20
|
+
def on_complete(response_env) # rubocop:disable Metrics/MethodLength
|
|
21
|
+
status = response_env.status
|
|
22
|
+
|
|
23
|
+
case status
|
|
24
|
+
when 400
|
|
25
|
+
raise Errors::BadRequestError
|
|
26
|
+
when 401
|
|
27
|
+
raise Errors::UnauthorizedError
|
|
28
|
+
when 403
|
|
29
|
+
raise Errors::ForbiddenError
|
|
30
|
+
when 404
|
|
31
|
+
raise Errors::ResourceNotFoundError
|
|
32
|
+
when CLIENT_ERROR_STATUSES
|
|
33
|
+
raise Errors::UnknownError
|
|
34
|
+
when SERVER_ERROR_STATUSES
|
|
35
|
+
raise Errors::ServerError
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def call(request_env)
|
|
40
|
+
@app.call(request_env).on_complete(&method(:on_complete))
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MassiveClient
|
|
4
|
+
module Websocket
|
|
5
|
+
module Connection
|
|
6
|
+
attr_accessor :url
|
|
7
|
+
|
|
8
|
+
def self.connect(api_key, url, channels, args = {}, &block)
|
|
9
|
+
raise "no onmessage callback block given" unless block_given?
|
|
10
|
+
|
|
11
|
+
uri = URI.parse(url)
|
|
12
|
+
args[:api_key] = api_key
|
|
13
|
+
args[:channels] = channels
|
|
14
|
+
args[:on_message] = block
|
|
15
|
+
EM.connect(uri.host, 443, self, args) do |conn|
|
|
16
|
+
conn.url = url
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def initialize(args)
|
|
21
|
+
@api_key = args.fetch(:api_key)
|
|
22
|
+
@channels = args.fetch(:channels)
|
|
23
|
+
@on_message = args.fetch(:on_message)
|
|
24
|
+
@debug = args.fetch(:debug) { false }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def connection_completed
|
|
28
|
+
@driver = WebSocket::Driver.client(self)
|
|
29
|
+
@driver.add_extension(PermessageDeflate)
|
|
30
|
+
|
|
31
|
+
uri = URI.parse(@url)
|
|
32
|
+
start_tls(sni_hostname: uri.host)
|
|
33
|
+
|
|
34
|
+
@driver.on(:open) do |_event|
|
|
35
|
+
msg = dump({ action: "auth", params: @api_key })
|
|
36
|
+
@driver.text(msg)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
@driver.on(:message) do |msg|
|
|
40
|
+
p [:message, msg.data] if @debug
|
|
41
|
+
|
|
42
|
+
events = Oj.load(msg.data, symbol_keys: true).map do |event|
|
|
43
|
+
to_event(event)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
status_events = events.select { |e| e.is_a? WebsocketEvent }
|
|
47
|
+
status_events.each do |event|
|
|
48
|
+
msg = handle_status_event(event)
|
|
49
|
+
@driver.text(msg) if msg
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
@on_message.call(events) if status_events.length.zero?
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
@driver.on(:close) { |event| finalize(event) }
|
|
56
|
+
|
|
57
|
+
@driver.start
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def receive_data(data)
|
|
61
|
+
@driver.parse(data)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def write(data)
|
|
65
|
+
send_data(data)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def finalize(event)
|
|
69
|
+
p [:close, event.code, event.reason] if @debug
|
|
70
|
+
close_connection
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def subscribe(channels)
|
|
74
|
+
dump({ action: "subscribe", params: channels })
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def to_event(event)
|
|
80
|
+
case event.fetch(:ev)
|
|
81
|
+
when "status"
|
|
82
|
+
if event.fetch(:status) == "error" && event.fetch(:message) == "not authorized"
|
|
83
|
+
raise NotAuthorizedError, event.fetch(:message)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
WebsocketEvent.new(event)
|
|
87
|
+
when "C"
|
|
88
|
+
ForexQuoteEvent.new(event)
|
|
89
|
+
when "CA"
|
|
90
|
+
ForexAggregateEvent.new(event)
|
|
91
|
+
when "XQ"
|
|
92
|
+
CryptoQuoteEvent.new(event)
|
|
93
|
+
when "XT"
|
|
94
|
+
CryptoTradeEvent.new(event)
|
|
95
|
+
when "XA"
|
|
96
|
+
CryptoAggregateEvent.new(event)
|
|
97
|
+
when "XS"
|
|
98
|
+
CryptoSipEvent.new(event)
|
|
99
|
+
when "XL2"
|
|
100
|
+
CryptoLevel2Event.new(event)
|
|
101
|
+
else
|
|
102
|
+
raise UnrecognizedEventError.new(event), "Unrecognized event with type: #{event.ev}"
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# dump json
|
|
107
|
+
def dump(json)
|
|
108
|
+
Oj.dump(json, mode: :compat)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def handle_status_event(event)
|
|
112
|
+
case WebsocketEvent::Statuses[event.status]
|
|
113
|
+
when "auth_success"
|
|
114
|
+
subscribe(@channels)
|
|
115
|
+
when "auth_timeout"
|
|
116
|
+
raise AuthTimeoutError, event.message
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
class Client
|
|
122
|
+
BASE_URL = "wss://socket.massive.com/"
|
|
123
|
+
|
|
124
|
+
def initialize(path, api_key, opts = {})
|
|
125
|
+
path = Types::Coercible::String.enum("stocks", "forex", "crypto")[path]
|
|
126
|
+
|
|
127
|
+
@api_key = api_key
|
|
128
|
+
@ws = nil
|
|
129
|
+
@opts = opts
|
|
130
|
+
@url = "#{BASE_URL}#{path}"
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def subscribe(channels, &block)
|
|
134
|
+
EM.run do
|
|
135
|
+
Connection.connect(@api_key, @url, channels, @opts, &block)
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MassiveClient
|
|
4
|
+
module Websocket
|
|
5
|
+
module Errors
|
|
6
|
+
class PolygonWebsocketClientError < StandardError; end
|
|
7
|
+
|
|
8
|
+
class AuthTimeoutError < PolygonWebsocketClientError; end
|
|
9
|
+
class NotAuthorizedError < PolygonWebsocketClientError; end
|
|
10
|
+
|
|
11
|
+
class UnrecognizedEventError < PolygonWebsocketClientError
|
|
12
|
+
attr_reader :event
|
|
13
|
+
|
|
14
|
+
def initialize(message, event)
|
|
15
|
+
super(message)
|
|
16
|
+
@event = event
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|