rh-console 1.0.5

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,785 @@
1
+ require_relative "helpers/http_helpers"
2
+ require_relative "helpers/table"
3
+ require_relative "../initializers/string"
4
+
5
+ require "time"
6
+ require "fileutils"
7
+ require "cgi"
8
+ require "securerandom"
9
+ require "io/console"
10
+
11
+ class RobinhoodClient
12
+
13
+ # Route that takes credentials and returns a JWT and refresh token
14
+ ROBINHOOD_OAUTH_TOKEN_ROUTE = "https://api.robinhood.com/oauth2/token/".freeze
15
+ # Route to refresh a JWT
16
+ ROBINHOOD_TOKEN_REFRESH_ROUTE = "https://api.robinhood.com/oauth2/token/"
17
+ # Route that returns info about the authenticated user
18
+ ROBINHOOD_USER_ROUTE = "https://api.robinhood.com/user/".freeze
19
+ # Route that returns info about the authenticated user's account
20
+ ROBINHOOD_ACCOUNTS_ROUTE = "https://api.robinhood.com/accounts/".freeze
21
+ # Route that returns authenticated user's order history
22
+ ROBINHOOD_ORDERS_ROUTE = "https://api.robinhood.com/orders/".freeze
23
+ # Route to fetch the authenticated user's default watchlist
24
+ ROBINHOOD_DEFAULT_WATCHLIST = "https://api.robinhood.com/watchlists/Default/".freeze
25
+ # Route to fetch the authenticated user's option positions
26
+ ROBINHOOD_OPTIONS_POSITIONS_ROUTE = "https://api.robinhood.com/options/positions/".freeze
27
+ # Route to get a quote for an option ID
28
+ ROBINHOOD_OPTION_QUOTE_ROUTE = "https://api.robinhood.com/marketdata/options/".freeze
29
+ # Route to place option orders
30
+ ROBINHOOD_OPTION_ORDER_ROUTE = "https://api.robinhood.com/options/orders/".freeze
31
+
32
+ # Route to get a quote for a given symbol
33
+ ROBINHOOD_QUOTE_ROUTE = "https://api.robinhood.com/quotes/".freeze
34
+ # Route to get fundamentals for a given symbol
35
+ ROBINHOOD_FUNDAMENTALS_ROUTE = "https://api.robinhood.com/fundamentals/".freeze
36
+ # Route to get historical data for a given symbol
37
+ ROBINHOOD_HISTORICAL_QUOTE_ROUTE = "https://api.robinhood.com/quotes/historicals/".freeze
38
+ # Route to get top moving symbols for the day
39
+ ROBINHOOD_TOP_MOVERS_ROUTE = "https://api.robinhood.com/midlands/movers/sp500/".freeze
40
+ # Route to get news related to a given symbol
41
+ ROBINHOOD_NEWS_ROUTE = "https://api.robinhood.com/midlands/news/".freeze
42
+ # Route to get past and future earnings for a symbol
43
+ ROBINHOOD_EARNINGS_ROUTE = "https://api.robinhood.com/marketdata/earnings/".freeze
44
+ # Route to get an instrument
45
+ ROBINHOOD_INSTRUMENTS_ROUTE = "https://api.robinhood.com/instruments/".freeze
46
+ # Route to get option instruments
47
+ ROBINHOOD_OPTION_INSTRUMENT_ROUTE = "https://api.robinhood.com/options/instruments/".freeze
48
+ # Route to get an option chain by ID
49
+ ROBINHOOD_OPTION_CHAIN_ROUTE = "https://api.robinhood.com/options/chains/".freeze
50
+
51
+ # Status signifying credentials were invalid
52
+ INVALID = "INVALID".freeze
53
+ # Status signifying the credentials were correct but an MFA code is required
54
+ MFA_REQUIRED = "MFA_REQUIRED".freeze
55
+ # Status signifying valid
56
+ SUCCESS = "SUCCESS".freeze
57
+
58
+ # Constant signifying how long the JWT should last before expiring
59
+ DEFAULT_EXPIRES_IN = 3600.freeze
60
+ # The OAuth client ID to use. (the same one the Web client uses)
61
+ DEFAULT_CLIENT_ID = "c82SH0WZOsabOXGP2sxqcj34FxkvfnWRZBKlBjFS".freeze
62
+ # The scope of the JWT
63
+ DEFAULT_SCOPE = "internal".freeze
64
+
65
+ # Create a new RobinhoodClient instance
66
+ #
67
+ # @param username [String] Username of the account
68
+ # @param password [String] Password of the account
69
+ # @param mfa_code [String] MFA code (if applicable)
70
+ # @return [RobinhoodClient] New instance of the RobinhoodClient class
71
+ # @example
72
+ # RobinhoodClient.new(username: "username", password: "password", mfa_code: "mfa_code")
73
+ def initialize(username: nil, password: nil, mfa_code: nil, unauthenticated: false, jwt: nil)
74
+
75
+ return if unauthenticated
76
+
77
+ @access_token = jwt
78
+ return if jwt
79
+
80
+ body = {}
81
+ body["username"] = username
82
+ body["password"] = password
83
+ body["mfa_code"] = mfa_code if mfa_code
84
+ body["grant_type"] = "password"
85
+ body["scope"] = DEFAULT_SCOPE
86
+ body["client_id"] = DEFAULT_CLIENT_ID
87
+ body["expires_in"] = DEFAULT_EXPIRES_IN
88
+ body["device_token"] = SecureRandom.uuid
89
+
90
+ response = post(ROBINHOOD_OAUTH_TOKEN_ROUTE, body)
91
+ json_response = JSON.parse(response.body)
92
+
93
+ if response.code == "400"
94
+ @authentication_status = INVALID
95
+ elsif response.code == "200" && json_response["mfa_required"]
96
+ @authentication_status = MFA_REQUIRED
97
+ elsif response.code == "200"
98
+ @authentication_status = SUCCESS
99
+ @access_token = json_response["access_token"]
100
+ @refresh_token = json_response["refresh_token"]
101
+ @expires_in = json_response["expires_in"]
102
+ @last_refreshed_at = Time.now.to_i
103
+ Thread.abort_on_exception = true
104
+ Thread.new { token_refresh() }
105
+ else
106
+ raise "Received an unexpected response when logging in: #{response.code} - #{response.body}"
107
+ end
108
+
109
+ end
110
+
111
+ # Create a new RobinhoodClient instance by prompting for user input
112
+ #
113
+ # @return [RobinhoodClient] New instance of the RobinhoodClient class
114
+ # @example
115
+ # my_new_client = RobinhoodClient.interactively_create_client
116
+ def self.interactively_create_client
117
+ print "Enter your username: "
118
+ username = gets.chomp
119
+ print "Password: "
120
+ password = STDIN.noecho(&:gets).chomp
121
+
122
+ client = RobinhoodClient.new(username: username, password: password)
123
+ if client.authentication_status == RobinhoodClient::SUCCESS
124
+ return client
125
+ elsif client.authentication_status == RobinhoodClient::MFA_REQUIRED
126
+ print "\nMFA code: "
127
+ mfa_code = STDIN.noecho(&:gets).chomp
128
+ client = RobinhoodClient.new(username: username, password: password, mfa_code: mfa_code)
129
+ if client.authentication_status == RobinhoodClient::SUCCESS
130
+ return client
131
+ else
132
+ puts "\nInvalid credentials."
133
+ exit 1
134
+ end
135
+ else
136
+ puts "\nInvalid credentials."
137
+ exit 1
138
+ end
139
+ rescue Interrupt
140
+ puts "\nExiting..."
141
+ exit 1
142
+ end
143
+
144
+ # Checks if the JWT currently stored is close to expiring (< 30 seconds TTL) and fetches a new one with the refresh token if so
145
+ #
146
+ # @note This is spawned in its own thread whenever a new instance of RobinhoodClient is created.
147
+ # You shouldn't have to manually call this method.
148
+ #
149
+ # @example
150
+ # Thread.abort_on_exception = true
151
+ # Thread.new { token_refresh() }
152
+ def token_refresh
153
+ begin
154
+ loop do
155
+
156
+ # Sleep unless there's less than 60 seconds until expiration
157
+ time_left = (@last_refreshed_at + @expires_in) - Time.now.to_i
158
+ if time_left > 60
159
+ sleep 10
160
+ else
161
+ params = {}
162
+ params["grant_type"] = "refresh_token"
163
+ params["scope"] = DEFAULT_SCOPE
164
+ params["expires_in"] = DEFAULT_EXPIRES_IN
165
+ params["client_id"] = DEFAULT_CLIENT_ID
166
+ params["device_token"] = "caec6972-daf7-4d41-a1d7-56cc6b293bfb"
167
+ params["refresh_token"] = @refresh_token
168
+
169
+ response = post(ROBINHOOD_TOKEN_REFRESH_ROUTE, params)
170
+
171
+ if response.code == "200"
172
+ json_response = JSON.parse(response.body)
173
+ @access_token = json_response["access_token"]
174
+ @refresh_token = json_response["refresh_token"]
175
+ @expires_in = json_response["expires_in"]
176
+ @last_refreshed_at = Time.now.to_i
177
+ else
178
+ # This should never happen, let's raise an error
179
+ raise "Error refreshing JWT."
180
+ end
181
+ end
182
+ end
183
+ rescue SocketError
184
+ raise "Error refreshing token: Check your internet connection."
185
+ end
186
+ end
187
+
188
+ # Checks if the user is signed in with a valid auth token
189
+ #
190
+ # @return [Boolean] Whether or not the token was valid
191
+ #
192
+ # @example
193
+ # if @client.logged_in?
194
+ # // do something authenticated
195
+ def logged_in?
196
+ get(ROBINHOOD_USER_ROUTE).code == "200"
197
+ end
198
+
199
+ # Returns information about the currently authenticated user
200
+ #
201
+ # @return [String] User info in pretty JSON form
202
+ #
203
+ # @example
204
+ # @client.user
205
+ def user
206
+ get(ROBINHOOD_USER_ROUTE, return_as_json: true)
207
+ end
208
+
209
+ # Returns information about the currently authenticated user's account
210
+ #
211
+ # @return [String] Account info in pretty JSON form
212
+ #
213
+ # @example
214
+ # @client.accounts
215
+ def accounts
216
+ get(ROBINHOOD_ACCOUNTS_ROUTE, return_as_json: true)
217
+ end
218
+
219
+ # Get an order by ID
220
+ #
221
+ # @param id [String] The ID of the order to get
222
+ # @return [Hash] The order
223
+ def order(id)
224
+ get("#{ROBINHOOD_ORDERS_ROUTE}#{id}/", return_as_json: true)
225
+ end
226
+
227
+ # Get an option order by ID
228
+ #
229
+ # @param id [String] The ID of the option order to get
230
+ # @return [Hash] The order
231
+ def option_order(id)
232
+ get("#{ROBINHOOD_OPTION_ORDER_ROUTE}#{id}/", return_as_json: true)
233
+ end
234
+
235
+ # View past orders
236
+ #
237
+ # @param days [String] Limit to orders within the last N days
238
+ # @param symbol [String] Limit to orders for a certain symbol
239
+ # @param last [String] Limit to last N orders
240
+ # @return [String] Past orders in table form.
241
+ # @example
242
+ # @client.orders(days: "5", symbol: "FB")
243
+ def orders(days: nil, symbol: nil, last: nil)
244
+ params = {}
245
+ if days
246
+ days_ago = (Time.now - (days.to_i*24*60*60)).utc.iso8601
247
+ params["updated_at[gte]"] = days_ago
248
+ end
249
+ if symbol
250
+ params["instrument"] = quote(symbol)["instrument"]
251
+ end
252
+
253
+ orders = []
254
+ orders_response = get(ROBINHOOD_ORDERS_ROUTE, return_as_json: true, params: params)
255
+ orders.concat(orders_response["results"]) if orders_response["results"]
256
+
257
+ next_url = orders_response["next"]
258
+ while next_url
259
+ # No need to keep paginating if we're looking for the last N orders, and already have them
260
+ break if last && orders.length >= last.to_i
261
+ orders_response = get(next_url, return_as_json: true)
262
+ orders.concat(orders_response["results"])
263
+ next_url = orders_response["next"]
264
+ end
265
+
266
+ orders = orders.shift(last.to_i) if last
267
+ orders
268
+ end
269
+
270
+ # Get the option chain for a symbol
271
+ #
272
+ # @param symbol [String] The symbol to get the option chain for
273
+ # @return [String, Array<String>] Returns two values, the chain ID, and an array of valid expiration dates for this symbol
274
+ # @example
275
+ # chain_id, expirations = @client.get_chain_and_expirations("FB")
276
+ def get_chain_and_expirations(symbol)
277
+ instrument_id = get(quote(symbol)["instrument"], return_as_json: true)["id"]
278
+ params = {}
279
+ params["ids"] = instrument_id
280
+ instruments_response = get(ROBINHOOD_INSTRUMENTS_ROUTE, params: params, return_as_json: true)
281
+ chain_id = instruments_response["results"].first["tradable_chain_id"]
282
+
283
+ # Get valid expirations for the chain
284
+ expiration_dates = get("#{ROBINHOOD_OPTION_CHAIN_ROUTE}#{chain_id}/", return_as_json: true)["expiration_dates"]
285
+
286
+ return chain_id, expiration_dates
287
+ end
288
+
289
+ # Get all option instruments given an option type, expiration date, and chain_id
290
+ #
291
+ # @param type [String] The type to limit results by ("call" or "put")
292
+ # @param expiration_date [String] The expiration date to limit results by
293
+ # @param chain_id [String] The option chain ID for the symbol
294
+
295
+ # @return [String, Array<String>] Returns the instruments corresponding to the options passed in
296
+ def get_option_instruments(type, expiration_date, chain_id)
297
+ # Get all option instruments with the desired type and expiration
298
+ params = {}
299
+ params["chain_id"] = chain_id
300
+ params["expiration_dates"] = expiration_date
301
+ params["state"] = "active"
302
+ params["tradability"] = "tradable"
303
+ params["type"] = type
304
+ option_instruments = get(ROBINHOOD_OPTION_INSTRUMENT_ROUTE, params: params, return_as_json: true)
305
+ option_instruments["results"]
306
+ end
307
+
308
+ # Get an option quote by instrument URL
309
+ #
310
+ # @param instrument_url [String] The instrument URL
311
+ #
312
+ # @return [Hash] Returns quotes for the instrument passed in
313
+ def get_option_quote_by_instrument_url(instrument_url)
314
+ params = {}
315
+ params["instruments"] = instrument_url
316
+ quote = get(ROBINHOOD_OPTION_QUOTE_ROUTE, params: params, return_as_json: true)
317
+ quote["results"].first
318
+ end
319
+
320
+ # Get an option quote by instrument URLs
321
+ #
322
+ # @param instrument_url [Array] An array of instrument URLs
323
+ #
324
+ # @return [Array] Returns an array of quotes for the instruments passed in
325
+ def get_batch_option_quote_by_instrument_urls(instrument_urls)
326
+ params = {}
327
+ instruments_string = ""
328
+ instrument_urls.each do |instrument|
329
+ instruments_string += "#{instrument},"
330
+ end
331
+ params["instruments"] = instruments_string
332
+ quote = get(ROBINHOOD_OPTION_QUOTE_ROUTE, params: params, return_as_json: true)
333
+ quote["results"]
334
+ end
335
+
336
+ # Get multiple option quotes
337
+ #
338
+ # @param instrument_urls [Array<String>] The option instrument URLs
339
+ #
340
+ # @return [Hash] Returns quotes for the instruments passed in
341
+ def get_multiple_option_quotes(instrument_urls)
342
+ params = {}
343
+ instruments_string = ""
344
+ instrument_urls.each do |instrument|
345
+ instruments_string += "#{instrument},"
346
+ end
347
+ instruments_string.chop!
348
+ params["instruments"] = instruments_string
349
+ get(ROBINHOOD_OPTION_QUOTE_ROUTE, params: params, return_as_json: true)["results"]
350
+ end
351
+
352
+ # Get an option quote by instrument ID
353
+ #
354
+ # @param instrument_id [String] The instrument ID
355
+ #
356
+ # @return [Hash] Returns quotes for the instrument passed in
357
+ def get_option_quote_by_id(instrument_id)
358
+ get("#{ROBINHOOD_OPTION_QUOTE_ROUTE}#{instrument_id}/", return_as_json: true)
359
+ end
360
+
361
+ # Place an order
362
+ #
363
+ # @note Only limit orders are supported for now.
364
+ # @param side [String] "buy" or "sell"
365
+ # @param symbol [String] The symbol you want to place an order for
366
+ # @param quantity [String] The number of shares
367
+ # @param price [String] The (limit) price per share
368
+ # @param dry_run [Boolean] Whether or not this order should be executed, or if we should just return a summary of the order wanting to be placed
369
+ # @return [Boolean, String] Whether or not the trade was successfully placed. Or if it was a dry run, a string containing a summary of the order wanting to be placed
370
+ # @example
371
+ # @client.place_order("buy", "FB", "100", "167.55")
372
+ def place_order(side, symbol, quantity, price, dry_run: true)
373
+ return false unless side == "buy" || side == "sell"
374
+ return false unless symbol && quantity.to_i > 0 && price.to_f > 0
375
+ if dry_run
376
+ company_name = get(quote(symbol)["instrument"], return_as_json: true)["name"]
377
+ return "You are placing an order to #{side} #{quantity} shares of #{company_name} (#{symbol}) with a limit price of #{price}"
378
+ else
379
+ accounts = get(ROBINHOOD_ACCOUNTS_ROUTE, return_as_json: true)
380
+ raise "Error: Unexpected number of accounts" unless accounts && accounts["results"].length == 1
381
+ account = accounts["results"].first["url"]
382
+
383
+ instrument = quote(symbol)["instrument"]
384
+
385
+ params = {}
386
+ params["time_in_force"] = "gfd"
387
+ params["side"] = side
388
+ params["price"] = price.to_f.to_s
389
+ params["type"] = "limit"
390
+ params["trigger"] = "immediate"
391
+ params["quantity"] = quantity
392
+ params["account"] = account
393
+ params["instrument"] = instrument
394
+ params["symbol"] = symbol.upcase
395
+
396
+ response = post(ROBINHOOD_ORDERS_ROUTE, params)
397
+ response.code == "201"
398
+ end
399
+ end
400
+
401
+ # Place an option order
402
+ #
403
+ # @note Only limit orders are supported for now.
404
+ # @param instrument [String] The instrument URL of the option
405
+ # @param quantity [String] The number of contracts
406
+ # @param price [String] The (limit) price per share
407
+ # @param dry_run [Boolean] Whether or not this order should be executed, or if we should just return a summary of the order wanting to be placed
408
+ # @return [Boolean, String] Whether or not the trade was successfully placed. Or if it was a dry run, a string containing a summary of the order wanting to be placed
409
+ def place_option_order(instrument, quantity, price, dry_run: true)
410
+
411
+ if dry_run
412
+ instrument_response = get(instrument, return_as_json: true)
413
+ symbol = instrument_response["chain_symbol"]
414
+ type = instrument_response["type"]
415
+ strike_price = instrument_response["strike_price"]
416
+ expiration = instrument_response["expiration_date"]
417
+ company_name = get(quote(symbol)["instrument"], return_as_json: true)["name"]
418
+ response = "You are placing an order to buy #{quantity} contracts of the $#{strike_price} #{expiration} #{type} for #{company_name} (#{symbol}) with a limit price of #{price}"
419
+ response += "\nTotal cost: $#{quantity.to_i * price.to_f * 100.00}"
420
+ return response
421
+ else
422
+ accounts = get(ROBINHOOD_ACCOUNTS_ROUTE, return_as_json: true)
423
+ raise "Error: Unexpected number of accounts" unless accounts && accounts["results"].length == 1
424
+ account = accounts["results"].first["url"]
425
+
426
+ params = {}
427
+ params["quantity"] = quantity
428
+ params["direction"] = "debit"
429
+ params["price"] = price
430
+ params["type"] = "limit"
431
+ params["account"] = account
432
+ params["time_in_force"] = "gfd"
433
+ params["trigger"] = "immediate"
434
+ params["legs"] = []
435
+ params["legs"] <<
436
+ leg = {}
437
+ leg["side"] = "buy"
438
+ leg["option"] = instrument
439
+ leg["position_effect"] = "open"
440
+ leg["ratio_quantity"] = "1"
441
+ params["override_day_trade_checks"] = false
442
+ params["override_dtbp_checks"] = false
443
+ params["ref_id"] = SecureRandom.uuid
444
+
445
+ response = post(ROBINHOOD_OPTION_ORDER_ROUTE, params)
446
+ response.code == "201"
447
+ end
448
+ end
449
+
450
+ def option_orders(last: nil)
451
+
452
+ orders = []
453
+ orders_response = get(ROBINHOOD_OPTION_ORDER_ROUTE, return_as_json: true)
454
+ orders.concat(orders_response["results"])
455
+
456
+ next_url = orders_response["next"]
457
+ while next_url
458
+ # No need to keep paginating if we're looking for the last N orders, and already have them
459
+ break if last && orders.length >= last.to_i
460
+ orders_response = get(next_url, return_as_json: true)
461
+ orders.concat(orders_response["results"])
462
+ next_url = orders_response["next"]
463
+ end
464
+
465
+ orders = orders.shift(last.to_i) if last
466
+ orders
467
+ end
468
+
469
+ # Cancel an order
470
+ #
471
+ # @param id [String] the ID of the order to cancel
472
+ # @return [Boolean] Whether or not it was successfully cancelled
473
+ def cancel_stock_order(id)
474
+ response = post("#{ROBINHOOD_ORDERS_ROUTE}#{id}/cancel/", {})
475
+ response.code == "200"
476
+ end
477
+
478
+ # Cancel all open orders
479
+ #
480
+ # @return [String] A string specifying how many orders were cancelled
481
+ # @example
482
+ # @client.cancel_all_open_stock_orders
483
+ def cancel_all_open_stock_orders
484
+ number_cancelled = 0
485
+ self.orders.each do |order|
486
+ if order["cancel"]
487
+ cancelled = cancel_stock_order(order["id"])
488
+ number_cancelled += 1 if cancelled
489
+ end
490
+ end
491
+
492
+ number_cancelled
493
+ end
494
+
495
+ # Cancel an order
496
+ #
497
+ # @param id [String] the ID of the order to cancel
498
+ # @return [Boolean] Whether or not it was successfully cancelled
499
+ def cancel_option_order(id)
500
+ response = post("#{ROBINHOOD_OPTION_ORDER_ROUTE}#{id}/cancel/", {})
501
+ response.code == "200"
502
+ end
503
+
504
+ # Cancel all open option orders
505
+ #
506
+ # @return [String] A string specifying how many orders were cancelled
507
+ # @example
508
+ # @client.cancel_all_open_option_orders
509
+ def cancel_all_open_option_orders
510
+ number_cancelled = 0
511
+ self.option_orders.each do |order|
512
+ if order["cancel_url"]
513
+ cancelled = cancel_option_order(order["id"])
514
+ number_cancelled += 1 if cancelled
515
+ end
516
+ end
517
+
518
+ number_cancelled
519
+ end
520
+
521
+ def option_positions
522
+ position_params = {}
523
+ position_params["nonzero"] = true
524
+ get(ROBINHOOD_OPTIONS_POSITIONS_ROUTE, params: position_params, return_as_json: true)["results"]
525
+ end
526
+
527
+ def stock_positions
528
+ position_params = {}
529
+ position_params["nonzero"] = true
530
+ get(self.account["positions"], params: position_params, return_as_json: true)["results"]
531
+ end
532
+
533
+ def portfolio
534
+ get(self.account["portfolio"], return_as_json: true)
535
+ end
536
+
537
+ def account
538
+ user_accounts = self.accounts()
539
+ raise "Error: Unexpected number of accounts" unless user_accounts["results"].length == 1
540
+ user_accounts["results"].first
541
+ end
542
+
543
+ # Get the authentication status to see if the credentials passed in when creating the client were valid
544
+ #
545
+ # @return [RobinhoodClient::INVALID, RobinhoodClient::MFA_REQUIRED, RobinhoodClient::SUCCESS] The authentication status of the client
546
+ # @example
547
+ # @client.authentication_status
548
+ def authentication_status
549
+ @authentication_status
550
+ end
551
+
552
+ # Get the latest quote for a symbol
553
+ #
554
+ # @param symbol [String] The symbol to get a quote for
555
+ # @return [Hash] The stock quote
556
+ # @example
557
+ # @client.quote("FB")
558
+ def quote(symbol)
559
+ symbol.upcase!
560
+ get("#{ROBINHOOD_QUOTE_ROUTE}#{symbol}/", return_as_json: true)
561
+ end
562
+
563
+ # Get the fundamentals for a symbol
564
+ #
565
+ # @param symbol [String] The symbol to get the fundamentals for
566
+ # @return [Hash] The fundamentals
567
+ # @example
568
+ # @client.fundamentals("FB")
569
+ def fundamentals(symbol)
570
+ symbol.upcase!
571
+ get("#{ROBINHOOD_FUNDAMENTALS_ROUTE}#{symbol.upcase}/", return_as_json: true)
572
+ end
573
+
574
+ # Get historical data
575
+ #
576
+ # @param symbol [String] The symbol to get historical data for
577
+ # @param interval [String] "week" | "day" | "10minute" | "5minute"
578
+ # @param span [String] "day" | "week" | "year" | "5year" | "all"
579
+ # @param bounds [String] "extended" | "regular" | "trading"
580
+ #
581
+ # @return [Hash] The historical data
582
+ # @example
583
+ # @client.historical_quote("FB")
584
+ def historical_quote(symbol, interval = "day", span = "year", bounds = "regular")
585
+ params = {}
586
+ params["interval"] = interval
587
+ params["span"] = span
588
+ params["bounds"] = bounds
589
+
590
+ symbol.upcase!
591
+ get("#{ROBINHOOD_HISTORICAL_QUOTE_ROUTE}#{symbol}/", params: params, return_as_json: true)
592
+ end
593
+
594
+ # Finds the highest moving tickers for the
595
+ #
596
+ # @param direction [String] "up" | "down"
597
+ #
598
+ # @return [Hash] The top moving companies
599
+ # @example
600
+ # @client.top_movers("up")
601
+ def top_movers(direction)
602
+ params = {}
603
+ params["direction"] = direction
604
+ get(ROBINHOOD_TOP_MOVERS_ROUTE, params: params, return_as_json: true)
605
+ end
606
+
607
+ # Get recent news for a symbol
608
+ #
609
+ # @param symbol [String] The symbol to get news for
610
+ # @return [Hash] The news
611
+ # @example
612
+ # @client.news("FB")
613
+ def news(symbol)
614
+ symbol.upcase!
615
+ get("#{ROBINHOOD_NEWS_ROUTE}#{symbol}/")
616
+ end
617
+
618
+ # Get recent quarterly earnings
619
+ #
620
+ # @param symbol [String] The symbol to get news for
621
+ # @return [Hash] Earnings by quarter
622
+ # @example
623
+ # @client.earnings("FB")
624
+ def earnings(symbol)
625
+ symbol.upcase!
626
+ params = {}
627
+ params["symbol"] = symbol
628
+ get(ROBINHOOD_EARNINGS_ROUTE, params: params, return_as_json: true)
629
+ end
630
+
631
+ # Get upcoming earnings
632
+ #
633
+ # @param days [String, Integer] Limit to earnings within the next N days (1-21)
634
+ # @return [Hash] Upcoming companies releasing earnings
635
+ # @example
636
+ # @client.upcoming_earnings("FB")
637
+ def upcoming_earnings(days)
638
+ params = {}
639
+ params["range"] = "#{days}day"
640
+ get(ROBINHOOD_EARNINGS_ROUTE, params: params, return_as_json: true)
641
+ end
642
+
643
+ # Gets the default watchlist
644
+ #
645
+ # @return [Hash] Stocks on the default watchlist
646
+ def default_watchlist
647
+
648
+ watchlist_items = []
649
+ watchlist_response = get(ROBINHOOD_DEFAULT_WATCHLIST, return_as_json: true)
650
+ watchlist_items.concat(watchlist_response["results"])
651
+
652
+ next_url = watchlist_response["next"]
653
+ while next_url
654
+ watchlist_response = get(next_url, return_as_json: true)
655
+ watchlist_items.concat(watchlist_response["results"])
656
+ next_url = watchlist_response["next"]
657
+ end
658
+
659
+ watchlist_items
660
+ end
661
+
662
+ # Used to map an "instrument" to a stock symbol
663
+ #
664
+ # @note Internally on the API, stocks are represented by an instrument ID. Many APIs (e.g the recent orders API) don't return the symbol, only the instrument ID.
665
+ # These mappings don't change so we use a cache to quickly map an instrument ID to a symbol so that we don't have to make a separate API call each time.
666
+ # @param instrument [String] The API instrument URL
667
+ # @return [String] The symbol the insrument corresponds to
668
+ # @example
669
+ # instrument_to_symbol_lookup("https://api.robinhood.com/instruments/ebab2398-028d-4939-9f1d-13bf38f81c50/")
670
+ def instrument_to_symbol_lookup(instrument)
671
+ @instrument_to_symbol_cache ||= {}
672
+ return @instrument_to_symbol_cache[instrument] if @instrument_to_symbol_cache.key?(instrument)
673
+ stock = get(instrument, return_as_json: true)
674
+ @instrument_to_symbol_cache[instrument] = stock["symbol"]
675
+ return stock["symbol"]
676
+ end
677
+
678
+ def access_token
679
+ @access_token
680
+ end
681
+
682
+ # Make a GET request to the designated URL using the authentication token if one is stored
683
+ #
684
+ # @param url [String] The API route to hit
685
+ # @param params [Hash] Parameters to add to the request
686
+ # @param return_as_json [Boolean] Whether or not we should return a JSON Hash or the Net::HTTP response.
687
+ # @param authenticated [Boolean] Whether or not we should send the authentication token stored for the client
688
+ #
689
+ # @return [Hash, Net::HTTP] Either the response as a Hash, or a Net::HTTP object depending on the input of `return_as_json`
690
+ def get(url, params: {}, return_as_json: false, authenticated: true)
691
+
692
+ # If the URL already has query parameters in it, prefer those
693
+ params_from_url = URI.parse(url).query
694
+ parsed_params_from_url = CGI.parse(params_from_url) if params_from_url
695
+ params = parsed_params_from_url.merge(params) if parsed_params_from_url
696
+
697
+ unless url.start_with?("https://api.robinhood.com/")
698
+ raise "Error: Requests must be to the Robinhood API."
699
+ end
700
+
701
+ headers = {}
702
+ headers["User-Agent"] = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:68.0) Gecko/20100101 Firefox/68.0"
703
+ headers["Accept"] = "*/*"
704
+ headers["Accept-Language"] = "en-US,en;q=0.5"
705
+ headers["Accept-Encoding"] = "gzip, deflate"
706
+ headers["Referer"] = "https://robinhood.com/"
707
+ headers["X-Robinhood-API-Version"] = "1.280.0"
708
+ headers["Origin"] = "https://robinhood.com"
709
+
710
+
711
+ if @access_token && authenticated
712
+ headers["Authorization"] = "Bearer #{@access_token}"
713
+ end
714
+
715
+ response = HttpHelpers.get(url, headers: headers, params: params)
716
+
717
+ body = if response.header['content-encoding'] == 'gzip'
718
+ sio = StringIO.new( response.body )
719
+ gz = Zlib::GzipReader.new( sio )
720
+ gz.read()
721
+ else
722
+ response.body
723
+ end
724
+
725
+ if return_as_json
726
+ JSON.parse(body)
727
+ else
728
+ response_struct = OpenStruct.new
729
+ response_struct.code = response.code
730
+ response_struct.body = body
731
+ response_struct
732
+ end
733
+ end
734
+
735
+ # Make a POST request to the designated URL using the authentication token if one is stored
736
+ #
737
+ # @param url [String] The API route to hit
738
+ # @param body [Hash] Parameters to add to the request
739
+ # @param return_as_json [Boolean] Whether or not we should return a JSON Hash or the Net::HTTP response.
740
+ # @param authenticated [Boolean] Whether or not we should send the authentication token stored for the client
741
+ #
742
+ # @return [Hash, Net::HTTP] Either the response as a Hash, or a Net::HTTP object depending on the input of `return_as_json`
743
+ def post(url, body, return_as_json: false, authenticated: true)
744
+
745
+ unless url.start_with?("https://api.robinhood.com/")
746
+ raise "Error: Requests must be to the Robinhood API."
747
+ end
748
+
749
+ headers = {}
750
+ headers["User-Agent"] = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:68.0) Gecko/20100101 Firefox/68.0"
751
+ headers["Accept"] = "*/*"
752
+ headers["Accept-Language"] = "en-US,en;q=0.5"
753
+ headers["Accept-Encoding"] = "gzip, deflate"
754
+ headers["Referer"] = "https://robinhood.com/"
755
+ headers["X-Robinhood-API-Version"] = "1.280.0"
756
+ headers["Origin"] = "https://robinhood.com"
757
+ headers["Content-Type"] = "application/json"
758
+
759
+ if @access_token && authenticated
760
+ headers["Authorization"] = "Bearer #{@access_token}"
761
+ end
762
+
763
+ response = HttpHelpers.post(url, headers, body)
764
+
765
+ if return_as_json
766
+ JSON.parse(response.body)
767
+ else
768
+ response
769
+ end
770
+
771
+ end
772
+
773
+ # Add commas to a dollar amount
774
+ #
775
+ # @param value [String] a float dollar value
776
+ #
777
+ # @return [String] A string with commas added appropriately
778
+ #
779
+ # @example
780
+ # commarize(3901.5) => "3,901.5"
781
+ def commarize(value)
782
+ value.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse
783
+ end
784
+
785
+ end