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