rh-console 1.0.5

Sign up to get free protection for your applications and to get access to all the features.
@@ -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