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.
- checksums.yaml +7 -0
- data/README.md +78 -0
- data/bin/rh-console +8 -0
- data/initializers/string.rb +10 -0
- data/lib/helpers/format_helpers.rb +18 -0
- data/lib/helpers/http_helpers.rb +43 -0
- data/lib/helpers/table.rb +83 -0
- data/lib/robinhood_client.rb +785 -0
- data/lib/robinhood_console.rb +686 -0
- metadata +51 -0
@@ -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
|