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.
@@ -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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "rest/client"
4
+ require_relative "rest/api"
5
+ require_relative "rest/errors"
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MassiveClient
4
+ module Types
5
+ include Dry::Types(default: :strict)
6
+ end
7
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MassiveClient
4
+ VERSION = "0.3.0"
5
+ 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