schwab_rb 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. checksums.yaml +7 -0
  2. data/.copilotignore +4 -0
  3. data/.rspec +2 -0
  4. data/.rspec_status +292 -0
  5. data/.rubocop.yml +41 -0
  6. data/.rubocop_todo.yml +105 -0
  7. data/CHANGELOG.md +28 -0
  8. data/LICENSE.txt +23 -0
  9. data/README.md +271 -0
  10. data/Rakefile +12 -0
  11. data/doc/notes/data_objects_analysis.md +223 -0
  12. data/doc/notes/data_objects_refactoring_plan.md +82 -0
  13. data/examples/fetch_account_numbers.rb +49 -0
  14. data/examples/fetch_user_preferences.rb +49 -0
  15. data/lib/schwab_rb/account.rb +9 -0
  16. data/lib/schwab_rb/auth/auth_context.rb +23 -0
  17. data/lib/schwab_rb/auth/init_client_easy.rb +45 -0
  18. data/lib/schwab_rb/auth/init_client_login.rb +201 -0
  19. data/lib/schwab_rb/auth/init_client_token_file.rb +30 -0
  20. data/lib/schwab_rb/auth/login_flow_server.rb +55 -0
  21. data/lib/schwab_rb/auth/token.rb +24 -0
  22. data/lib/schwab_rb/auth/token_manager.rb +105 -0
  23. data/lib/schwab_rb/clients/async_client.rb +122 -0
  24. data/lib/schwab_rb/clients/base_client.rb +887 -0
  25. data/lib/schwab_rb/clients/client.rb +97 -0
  26. data/lib/schwab_rb/configuration.rb +39 -0
  27. data/lib/schwab_rb/constants.rb +7 -0
  28. data/lib/schwab_rb/data_objects/account.rb +281 -0
  29. data/lib/schwab_rb/data_objects/account_numbers.rb +68 -0
  30. data/lib/schwab_rb/data_objects/instrument.rb +156 -0
  31. data/lib/schwab_rb/data_objects/market_hours.rb +275 -0
  32. data/lib/schwab_rb/data_objects/option.rb +147 -0
  33. data/lib/schwab_rb/data_objects/option_chain.rb +95 -0
  34. data/lib/schwab_rb/data_objects/option_expiration_chain.rb +134 -0
  35. data/lib/schwab_rb/data_objects/order.rb +186 -0
  36. data/lib/schwab_rb/data_objects/order_leg.rb +68 -0
  37. data/lib/schwab_rb/data_objects/order_preview.rb +237 -0
  38. data/lib/schwab_rb/data_objects/position.rb +100 -0
  39. data/lib/schwab_rb/data_objects/price_history.rb +187 -0
  40. data/lib/schwab_rb/data_objects/quote.rb +276 -0
  41. data/lib/schwab_rb/data_objects/transaction.rb +132 -0
  42. data/lib/schwab_rb/data_objects/user_preferences.rb +129 -0
  43. data/lib/schwab_rb/market_hours.rb +13 -0
  44. data/lib/schwab_rb/movers.rb +35 -0
  45. data/lib/schwab_rb/option.rb +64 -0
  46. data/lib/schwab_rb/orders/builder.rb +202 -0
  47. data/lib/schwab_rb/orders/destination.rb +19 -0
  48. data/lib/schwab_rb/orders/duration.rb +9 -0
  49. data/lib/schwab_rb/orders/equity_instructions.rb +10 -0
  50. data/lib/schwab_rb/orders/errors.rb +5 -0
  51. data/lib/schwab_rb/orders/instruments.rb +35 -0
  52. data/lib/schwab_rb/orders/option_instructions.rb +10 -0
  53. data/lib/schwab_rb/orders/order.rb +77 -0
  54. data/lib/schwab_rb/orders/price_link_basis.rb +15 -0
  55. data/lib/schwab_rb/orders/price_link_type.rb +9 -0
  56. data/lib/schwab_rb/orders/session.rb +14 -0
  57. data/lib/schwab_rb/orders/special_instruction.rb +10 -0
  58. data/lib/schwab_rb/orders/stop_price_link_basis.rb +15 -0
  59. data/lib/schwab_rb/orders/stop_price_link_type.rb +9 -0
  60. data/lib/schwab_rb/orders/stop_type.rb +11 -0
  61. data/lib/schwab_rb/orders/tax_lot_method.rb +13 -0
  62. data/lib/schwab_rb/price_history.rb +55 -0
  63. data/lib/schwab_rb/quote.rb +13 -0
  64. data/lib/schwab_rb/transaction.rb +23 -0
  65. data/lib/schwab_rb/utils/enum_enforcer.rb +73 -0
  66. data/lib/schwab_rb/utils/logger.rb +70 -0
  67. data/lib/schwab_rb/utils/redactor.rb +104 -0
  68. data/lib/schwab_rb/version.rb +5 -0
  69. data/lib/schwab_rb.rb +48 -0
  70. data/sig/schwab_rb.rbs +4 -0
  71. metadata +289 -0
@@ -0,0 +1,887 @@
1
+ require "date"
2
+ require "json"
3
+ require_relative "../utils/enum_enforcer"
4
+ require_relative "../data_objects/account"
5
+ require_relative "../data_objects/account_numbers"
6
+ require_relative "../data_objects/user_preferences"
7
+ require_relative "../data_objects/option_expiration_chain"
8
+ require_relative "../data_objects/price_history"
9
+ require_relative "../data_objects/market_hours"
10
+ require_relative "../data_objects/quote"
11
+ require_relative "../data_objects/transaction"
12
+ require_relative "../data_objects/order"
13
+
14
+ module SchwabRb
15
+ class BaseClient
16
+ include EnumEnforcer
17
+
18
+ attr_reader :api_key, :app_secret, :session, :token_manager, :enforce_enums
19
+
20
+ def initialize(api_key, app_secret, session, token_manager:, enforce_enums: true)
21
+ @api_key = api_key
22
+ @app_secret = app_secret
23
+ @session = session
24
+ @token_manager = token_manager
25
+ @enforce_enums = enforce_enums
26
+ end
27
+
28
+ def refresh!
29
+ refresh_token_if_needed
30
+ end
31
+
32
+ def timeout
33
+ @session.options[:connection_opts][:request][:timeout]
34
+ end
35
+
36
+ def set_timeout(timeout)
37
+ # Sets the timeout for the client session.
38
+ #
39
+ # @param timeout [Integer] The timeout value in seconds.
40
+ @session.options[:connection_opts] ||= {}
41
+ @session.options[:connection_opts][:request] ||= {}
42
+ @session.options[:connection_opts][:request][:timeout] = timeout
43
+ end
44
+
45
+ def token_age
46
+ @token_manager.token_age
47
+ end
48
+
49
+ def get_account(account_hash, fields: nil, return_data_objects: true)
50
+ # Account balances, positions, and orders for a given account hash.
51
+ #
52
+ # @param fields [Array] Balances displayed by default, additional fields can be
53
+ # added here by adding values from Account.fields.
54
+ # @param return_data_objects [Boolean] Whether to return data objects or raw JSON
55
+ refresh_token_if_needed
56
+
57
+ fields = convert_enum_iterable(fields, SchwabRb::Account::Statuses) if fields
58
+
59
+ params = {}
60
+ params[:fields] = fields.join(",") if fields
61
+
62
+ path = "/trader/v1/accounts/#{account_hash}"
63
+ response = get(path, params)
64
+
65
+ if return_data_objects
66
+ account_data = JSON.parse(response.body, symbolize_names: true)
67
+ SchwabRb::DataObjects::Account.build(account_data)
68
+ else
69
+ response
70
+ end
71
+ end
72
+
73
+ def get_accounts(fields: nil, return_data_objects: true)
74
+ # Account balances, positions, and orders for all linked accounts.
75
+ #
76
+ # Note: This method does not return account hashes.
77
+ #
78
+ # @param fields [Array] Balances displayed by default, additional fields can be
79
+ # added here by adding values from Account.fields.
80
+ # @param return_data_objects [Boolean] Whether to return data objects or raw JSON
81
+ refresh_token_if_needed
82
+
83
+ fields = convert_enum_iterable(fields, SchwabRb::Account::Statuses) if fields
84
+
85
+ params = {}
86
+ params[:fields] = fields.join(",") if fields
87
+
88
+ path = "/trader/v1/accounts"
89
+ response = get(path, params)
90
+
91
+ if return_data_objects
92
+ accounts_data = JSON.parse(response.body, symbolize_names: true)
93
+ accounts_data.map { |account_data| SchwabRb::DataObjects::Account.build(account_data) }
94
+ else
95
+ response
96
+ end
97
+ end
98
+
99
+ def get_account_numbers(return_data_objects: true)
100
+ # Returns a mapping from account IDs available to this token to the
101
+ # account hash that should be passed whenever referring to that account
102
+ # in API calls.
103
+ # @param return_data_objects [Boolean] Whether to return data objects or raw JSON
104
+ refresh_token_if_needed
105
+
106
+ path = "/trader/v1/accounts/accountNumbers"
107
+ response = get(path, {})
108
+
109
+ if return_data_objects
110
+ account_numbers_data = JSON.parse(response.body, symbolize_names: true)
111
+ SchwabRb::DataObjects::AccountNumbers.build(account_numbers_data)
112
+ else
113
+ response
114
+ end
115
+ end
116
+
117
+ def get_order(order_id, account_hash, return_data_objects: true)
118
+ # Get a specific order for a specific account by its order ID.
119
+ #
120
+ # @param order_id [String] The order ID.
121
+ # @param account_hash [String] The account hash.
122
+ # @param return_data_objects [Boolean] Whether to return data objects or raw JSON
123
+ refresh_token_if_needed
124
+
125
+ path = "/trader/v1/accounts/#{account_hash}/orders/#{order_id}"
126
+ response = get(path, {})
127
+
128
+ if return_data_objects
129
+ order_data = JSON.parse(response.body, symbolize_names: true)
130
+ SchwabRb::DataObjects::Order.build(order_data)
131
+ else
132
+ response
133
+ end
134
+ end
135
+
136
+ def cancel_order(order_id, account_hash)
137
+ # Cancel a specific order for a specific account.
138
+ #
139
+ # @param order_id [String] The order ID.
140
+ # @param account_hash [String] The account hash.
141
+ refresh_token_if_needed
142
+
143
+ path = "/trader/v1/accounts/#{account_hash}/orders/#{order_id}"
144
+ delete(path)
145
+ end
146
+
147
+ def get_account_orders(
148
+ account_hash,
149
+ max_results: nil,
150
+ from_entered_datetime: nil,
151
+ to_entered_datetime: nil,
152
+ status: nil,
153
+ return_data_objects: true
154
+ )
155
+ # Orders for a specific account. Optionally specify a single status on which to filter.
156
+ #
157
+ # @param max_results [Integer] The maximum number of orders to retrieve.
158
+ # @param from_entered_datetime [DateTime] Start of the query date range (default: 60 days ago).
159
+ # @param to_entered_datetime [DateTime] End of the query date range (default: now).
160
+ # @param status [String] Restrict query to orders with this status.
161
+ # @param return_data_objects [Boolean] Whether to return data objects or raw JSON
162
+ refresh_token_if_needed
163
+
164
+ if from_entered_datetime.nil?
165
+ from_entered_datetime = DateTime.now.new_offset(0) - 60
166
+ end
167
+
168
+ if to_entered_datetime.nil?
169
+ to_entered_datetime = DateTime.now
170
+ end
171
+
172
+ status = convert_enum(status, SchwabRb::Order::Statuses) if status
173
+
174
+ path = "/trader/v1/accounts/#{account_hash}/orders"
175
+ params = make_order_query(
176
+ max_results: max_results,
177
+ from_entered_datetime: from_entered_datetime,
178
+ to_entered_datetime: to_entered_datetime,
179
+ status: status
180
+ )
181
+
182
+ response = get(path, params)
183
+
184
+ if return_data_objects
185
+ orders_data = JSON.parse(response.body, symbolize_names: true)
186
+ orders_data.map { |order_data| SchwabRb::DataObjects::Order.build(order_data) }
187
+ else
188
+ response
189
+ end
190
+ end
191
+
192
+ def get_all_linked_account_orders(
193
+ max_results: nil,
194
+ from_entered_datetime: nil,
195
+ to_entered_datetime: nil,
196
+ status: nil,
197
+ return_data_objects: true
198
+ )
199
+ # Orders for all linked accounts. Optionally specify a single status on which to filter.
200
+ #
201
+ # @param max_results [Integer] The maximum number of orders to retrieve.
202
+ # @param from_entered_datetime [DateTime] Start of the query date range (default: 60 days ago).
203
+ # @param to_entered_datetime [DateTime] End of the query date range (default: now).
204
+ # @param status [String] Restrict query to orders with this status.
205
+ # @param return_data_objects [Boolean] Whether to return data objects or raw JSON
206
+ refresh_token_if_needed
207
+
208
+ path = "/trader/v1/orders"
209
+ params = make_order_query(
210
+ max_results: max_results,
211
+ from_entered_datetime: from_entered_datetime,
212
+ to_entered_datetime: to_entered_datetime,
213
+ status: status
214
+ )
215
+
216
+ response = get(path, params)
217
+
218
+ if return_data_objects
219
+ orders_data = JSON.parse(response.body, symbolize_names: true)
220
+ orders_data.map { |order_data| SchwabRb::DataObjects::Order.build(order_data) }
221
+ else
222
+ response
223
+ end
224
+ end
225
+
226
+ def place_order(account_hash, order_spec)
227
+ # Place an order for a specific account. If order creation is successful,
228
+ # the response will contain the ID of the generated order.
229
+ #
230
+ # Note: Unlike most methods in this library, successful responses typically
231
+ # do not contain JSON data, and attempting to extract it may raise an exception.
232
+ refresh_token_if_needed
233
+
234
+ order_spec = order_spec.build if order_spec.is_a?(SchwabRb::Orders::Builder)
235
+
236
+ path = "/trader/v1/accounts/#{account_hash}/orders"
237
+ post(path, order_spec)
238
+ end
239
+
240
+ def replace_order(account_hash, order_id, order_spec)
241
+ # Replace an existing order for an account.
242
+ # The existing order will be replaced by the new order.
243
+ # Once replaced, the old order will be canceled and a new order will be created.
244
+ refresh_token_if_needed
245
+
246
+ order_spec = order_spec.build if order_spec.is_a?(SchwabRb::Orders::Builder)
247
+
248
+ path = "/trader/v1/accounts/#{account_hash}/orders/#{order_id}"
249
+ put(path, order_spec)
250
+ end
251
+
252
+ def preview_order(account_hash, order_spec, return_data_objects: true)
253
+ # Preview an order, i.e., test whether an order would be accepted by the
254
+ # API and see the structure it would result in.
255
+ # @param return_data_objects [Boolean] Whether to return data objects or raw JSON
256
+ refresh_token_if_needed
257
+
258
+ order_spec = order_spec.build if order_spec.is_a?(SchwabRb::Orders::Builder)
259
+
260
+ path = "/trader/v1/accounts/#{account_hash}/previewOrder"
261
+ response = post(path, order_spec)
262
+
263
+ if return_data_objects
264
+ preview_data = JSON.parse(response.body, symbolize_names: true)
265
+ SchwabRb::DataObjects::OrderPreview.build(preview_data)
266
+ else
267
+ response
268
+ end
269
+ end
270
+
271
+ def get_transactions(
272
+ account_hash,
273
+ start_date: nil,
274
+ end_date: nil,
275
+ transaction_types: nil,
276
+ symbol: nil,
277
+ return_data_objects: true
278
+ )
279
+ # Transactions for a specific account.
280
+ #
281
+ # @param account_hash [String] Account hash corresponding to the account.
282
+ # @param start_date [Date, DateTime] Start date for transactions (default: 60 days ago).
283
+ # @param end_date [Date, DateTime] End date for transactions (default: now).
284
+ # @param transaction_types [Array] List of transaction types to filter by.
285
+ # @param symbol [String] Filter transactions by the specified symbol.
286
+ # @param return_data_objects [Boolean] Whether to return data objects or raw JSON
287
+ refresh_token_if_needed
288
+
289
+ transaction_types = if transaction_types
290
+ convert_enum_iterable(transaction_types, SchwabRb::Transaction::Types)
291
+ else
292
+ get_valid_enum_values(SchwabRb::Transaction::Types)
293
+ end
294
+
295
+ start_date = if start_date.nil?
296
+ format_date_as_iso("start_date", DateTime.now.new_offset(0) - 60)
297
+ else
298
+ format_date_as_iso("start_date", start_date)
299
+ end
300
+
301
+ end_date = if end_date.nil?
302
+ format_date_as_iso("end_date", DateTime.now.new_offset(0))
303
+ else
304
+ format_date_as_iso("end_date", end_date)
305
+ end
306
+
307
+ params = {
308
+ "types" => transaction_types.sort.join(","),
309
+ "startDate" => start_date,
310
+ "endDate" => end_date
311
+ }
312
+ params["symbol"] = symbol unless symbol.nil?
313
+
314
+ path = "/trader/v1/accounts/#{account_hash}/transactions"
315
+ response = get(path, params)
316
+
317
+ if return_data_objects
318
+ transactions_data = JSON.parse(response.body, symbolize_names: true)
319
+ transactions_data.map do |transaction_data|
320
+ SchwabRb::DataObjects::Transaction.build(transaction_data)
321
+ end
322
+ else
323
+ response
324
+ end
325
+ end
326
+
327
+ def get_transaction(account_hash, activity_id, return_data_objects: true)
328
+ # Transaction for a specific account.
329
+ #
330
+ # @param account_hash [String] Account hash corresponding to the account whose
331
+ # transactions should be returned.
332
+ # @param activity_id [String] ID of the order for which to return data.
333
+ # @param return_data_objects [Boolean] Whether to return data objects or raw JSON
334
+ refresh_token_if_needed
335
+
336
+ path = "/trader/v1/accounts/#{account_hash}/transactions/#{activity_id}"
337
+ response = get(path, {})
338
+
339
+ if return_data_objects
340
+ transaction_data = JSON.parse(response.body, symbolize_names: true)
341
+ SchwabRb::DataObjects::Transaction.build(transaction_data)
342
+ else
343
+ response
344
+ end
345
+ end
346
+
347
+ def get_user_preferences(return_data_objects: true)
348
+ # Get user preferences for the authenticated user.
349
+ # @param return_data_objects [Boolean] Whether to return data objects or raw JSON
350
+ refresh_token_if_needed
351
+ path = "/trader/v1/userPreference"
352
+ response = get(path, {})
353
+
354
+ if return_data_objects
355
+ preferences_data = JSON.parse(response.body, symbolize_names: true)
356
+ SchwabRb::DataObjects::UserPreferences.build(preferences_data)
357
+ else
358
+ response
359
+ end
360
+ end
361
+
362
+ def get_quote(symbol, fields: nil, return_data_objects: true)
363
+ # Get quote for a symbol.
364
+ #
365
+ # @param symbol [String] Single symbol to fetch.
366
+ # @param fields [Array] Fields to request. If unset, return all available
367
+ # data (i.e., all fields). See `GetQuote::Field` for options.
368
+ # @param return_data_objects [Boolean] Whether to return data objects or raw JSON
369
+ refresh_token_if_needed
370
+
371
+ fields = convert_enum_iterable(fields, SchwabRb::Quote::Types) if fields
372
+ params = fields ? { "fields" => fields.join(",") } : {}
373
+ path = "/marketdata/v1/#{symbol}/quotes"
374
+ response = get(path, params)
375
+
376
+ if return_data_objects
377
+ quote_data = JSON.parse(response.body, symbolize_names: true)
378
+ SchwabRb::DataObjects::QuoteFactory.build(quote_data)
379
+ else
380
+ response
381
+ end
382
+ end
383
+
384
+ def get_quotes(symbols, fields: nil, indicative: nil, return_data_objects: true)
385
+ # Get quotes for symbols. This method supports all symbols, including those
386
+ # containing non-alphanumeric characters like `/ES`.
387
+ #
388
+ # @param symbols [Array, String] Symbols to fetch. Can be a single symbol or an array of symbols.
389
+ # @param fields [Array] Fields to request. If unset, return all available data.
390
+ # See `GetQuote::Field` for options.
391
+ # @param indicative [Boolean] If set, fetch indicative quotes. Must be true or false.
392
+ # @param return_data_objects [Boolean] Whether to return data objects or raw JSON
393
+ refresh_token_if_needed
394
+
395
+ symbols = [symbols] if symbols.is_a?(String)
396
+ params = { "symbols" => symbols.join(",") }
397
+ fields = convert_enum_iterable(fields, SchwabRb::Quote::Types) if fields
398
+ params["fields"] = fields.join(",") if fields
399
+
400
+ unless indicative.nil?
401
+ unless [true, false].include?(indicative)
402
+ raise ArgumentError, "value of 'indicative' must be either true or false"
403
+ end
404
+
405
+ params["indicative"] = indicative ? "true" : "false"
406
+ end
407
+
408
+ path = "/marketdata/v1/quotes"
409
+ response = get(path, params)
410
+
411
+ if return_data_objects
412
+ quotes_data = JSON.parse(response.body, symbolize_names: true)
413
+ quotes_data.map do |symbol, quote_data|
414
+ SchwabRb::DataObjects::QuoteFactory.build({symbol => quote_data})
415
+ end
416
+ else
417
+ response
418
+ end
419
+ end
420
+
421
+ def get_option_chain(
422
+ symbol,
423
+ contract_type: nil,
424
+ strike_count: nil,
425
+ include_underlying_quote: nil,
426
+ strategy: nil,
427
+ interval: nil,
428
+ strike: nil,
429
+ strike_range: nil,
430
+ from_date: nil,
431
+ to_date: nil,
432
+ volatility: nil,
433
+ underlying_price: nil,
434
+ interest_rate: nil,
435
+ days_to_expiration: nil,
436
+ exp_month: nil,
437
+ option_type: nil,
438
+ entitlement: nil,
439
+ return_data_objects: true
440
+ )
441
+ # Get option chain for an optionable symbol.
442
+ #
443
+ # @param symbol [String] The symbol for the option chain.
444
+ # @param contract_type [String] Type of contracts to return in the chain.
445
+ # @param strike_count [Integer] Number of strikes above and below the ATM price.
446
+ # @param include_underlying_quote [Boolean] Include a quote for the underlying.
447
+ # @param strategy [String] Strategy type for the option chain.
448
+ # @param interval [Float] Strike interval for spread strategy chains.
449
+ # @param strike [Float] Specific strike price for the option chain.
450
+ # @param strike_range [String] Range of strikes to include.
451
+ # @param from_date [Date] Filter expirations after this date.
452
+ # @param to_date [Date] Filter expirations before this date.
453
+ # @param volatility [Float] Volatility for analytical calculations.
454
+ # @param underlying_price [Float] Underlying price for analytical calculations.
455
+ # @param interest_rate [Float] Interest rate for analytical calculations.
456
+ # @param days_to_expiration [Integer] Days to expiration for analytical calculations.
457
+ # @param exp_month [String] Filter options by expiration month.
458
+ # @param option_type [String] Type of options to include in the chain.
459
+ # @param entitlement [String] Client entitlement.
460
+ # @param return_data_objects [Boolean] Whether to return data objects or raw JSON
461
+
462
+ refresh_token_if_needed
463
+
464
+ contract_type = convert_enum(contract_type, SchwabRb::Option::ContractTypes)
465
+ strategy = convert_enum(strategy, SchwabRb::Option::Strategies)
466
+ strike_range = convert_enum(strike_range, SchwabRb::Option::StrikeRanges)
467
+ option_type = convert_enum(option_type, SchwabRb::Option::Types)
468
+ exp_month = convert_enum(exp_month, SchwabRb::Option::ExpirationMonths)
469
+ entitlement = convert_enum(entitlement, SchwabRb::Option::Entitlements)
470
+
471
+ params = { "symbol" => symbol }
472
+ params["contractType"] = contract_type if contract_type
473
+ params["strikeCount"] = strike_count if strike_count
474
+ params["includeUnderlyingQuote"] = include_underlying_quote if include_underlying_quote
475
+ params["strategy"] = strategy if strategy
476
+ params["interval"] = interval if interval
477
+ params["strike"] = strike if strike
478
+ params["range"] = strike_range if strike_range
479
+ params["fromDate"] = format_date_as_day("from_date", from_date) if from_date
480
+ params["toDate"] = format_date_as_day("to_date", to_date) if to_date
481
+ params["volatility"] = volatility if volatility
482
+ params["underlyingPrice"] = underlying_price if underlying_price
483
+ params["interestRate"] = interest_rate if interest_rate
484
+ params["daysToExpiration"] = days_to_expiration if days_to_expiration
485
+ params["expMonth"] = exp_month if exp_month
486
+ params["optionType"] = option_type if option_type
487
+ params["entitlement"] = entitlement if entitlement
488
+
489
+ path = "/marketdata/v1/chains"
490
+ response = get(path, params)
491
+
492
+ if return_data_objects
493
+ option_chain_data = JSON.parse(response.body, symbolize_names: true)
494
+ SchwabRb::DataObjects::OptionChain.build(option_chain_data)
495
+ else
496
+ response
497
+ end
498
+ end
499
+
500
+ def get_option_expiration_chain(symbol, return_data_objects: true)
501
+ # Get option expiration chain for a symbol.
502
+ # @param symbol [String] The symbol for which to get option expiration dates.
503
+ # @param return_data_objects [Boolean] Whether to return data objects or raw JSON
504
+ refresh_token_if_needed
505
+ path = "/marketdata/v1/expirationchain"
506
+ response = get(path, { symbol: symbol })
507
+
508
+ if return_data_objects
509
+ expiration_data = JSON.parse(response.body, symbolize_names: true)
510
+ SchwabRb::DataObjects::OptionExpirationChain.build(expiration_data)
511
+ else
512
+ response
513
+ end
514
+ end
515
+
516
+ def get_price_history(
517
+ symbol,
518
+ period_type: nil,
519
+ period: nil,
520
+ frequency_type: nil,
521
+ frequency: nil,
522
+ start_datetime: nil,
523
+ end_datetime: nil,
524
+ need_extended_hours_data: nil,
525
+ need_previous_close: nil,
526
+ return_data_objects: true
527
+ )
528
+ # Get price history for a symbol.
529
+ # @param return_data_objects [Boolean] Whether to return data objects or raw JSON
530
+ refresh_token_if_needed
531
+
532
+ period_type = convert_enum(period_type, SchwabRb::PriceHistory::PeriodTypes) if period_type
533
+ period = convert_enum(period, SchwabRb::PriceHistory::Periods) if period
534
+ frequency_type = convert_enum(frequency_type, SchwabRb::PriceHistory::FrequencyTypes) if frequency_type
535
+ frequency = convert_enum(frequency, SchwabRb::PriceHistory::Frequencies) if frequency
536
+
537
+ params = { "symbol" => symbol }
538
+
539
+ params["periodType"] = period_type if period_type
540
+ params["period"] = period if period
541
+ params["frequencyType"] = frequency_type if frequency_type
542
+ params["frequency"] = frequency if frequency
543
+ params["startDate"] = format_date_as_millis("start_datetime", start_datetime) if start_datetime
544
+ params["endDate"] = format_date_as_millis("end_datetime", end_datetime) if end_datetime
545
+ params["needExtendedHoursData"] = need_extended_hours_data unless need_extended_hours_data.nil?
546
+ params["needPreviousClose"] = need_previous_close unless need_previous_close.nil?
547
+ path = "/marketdata/v1/pricehistory"
548
+
549
+ response = get(path, params)
550
+
551
+ if return_data_objects
552
+ price_history_data = JSON.parse(response.body, symbolize_names: true)
553
+ SchwabRb::DataObjects::PriceHistory.build(price_history_data)
554
+ else
555
+ response
556
+ end
557
+ end
558
+
559
+ def get_price_history_every_minute(symbol,
560
+ start_datetime: nil,
561
+ end_datetime: nil,
562
+ need_extended_hours_data: nil,
563
+ need_previous_close: nil)
564
+ refresh_token_if_needed
565
+
566
+ start_datetime, end_datetime = normalize_start_and_end_datetimes(
567
+ start_datetime, end_datetime
568
+ )
569
+
570
+ get_price_history(
571
+ symbol,
572
+ period_type: SchwabRb::PriceHistory::PeriodType::DAY,
573
+ period: SchwabRb::PriceHistory::Period::ONE_DAY,
574
+ frequency_type: SchwabRb::PriceHistory::FrequencyType::MINUTE,
575
+ frequency: SchwabRb::PriceHistory::Frequency::EVERY_MINUTE,
576
+ start_datetime: start_datetime,
577
+ end_datetime: end_datetime,
578
+ need_extended_hours_data: need_extended_hours_data,
579
+ need_previous_close: need_previous_close
580
+ )
581
+ end
582
+
583
+ def get_price_history_every_five_minutes(symbol,
584
+ start_datetime: nil,
585
+ end_datetime: nil,
586
+ need_extended_hours_data: nil,
587
+ need_previous_close: nil)
588
+ refresh_token_if_needed
589
+
590
+ start_datetime, end_datetime = normalize_start_and_end_datetimes(
591
+ start_datetime, end_datetime
592
+ )
593
+
594
+ get_price_history(
595
+ symbol,
596
+ period_type: SchwabRb::PriceHistory::PeriodType::DAY,
597
+ period: SchwabRb::PriceHistory::Period::ONE_DAY,
598
+ frequency_type: SchwabRb::PriceHistory::FrequencyType::MINUTE,
599
+ frequency: SchwabRb::PriceHistory::Frequency::EVERY_FIVE_MINUTES,
600
+ start_datetime: start_datetime,
601
+ end_datetime: end_datetime,
602
+ need_extended_hours_data: need_extended_hours_data,
603
+ need_previous_close: need_previous_close
604
+ )
605
+ end
606
+
607
+ def get_price_history_every_ten_minutes(symbol,
608
+ start_datetime: nil,
609
+ end_datetime: nil,
610
+ need_extended_hours_data: nil,
611
+ need_previous_close: nil)
612
+ refresh_token_if_needed
613
+
614
+ start_datetime, end_datetime = normalize_start_and_end_datetimes(
615
+ start_datetime, end_datetime
616
+ )
617
+
618
+ get_price_history(
619
+ symbol,
620
+ period_type: SchwabRb::PriceHistory::PeriodType::DAY,
621
+ period: SchwabRb::PriceHistory::Period::ONE_DAY,
622
+ frequency_type: SchwabRb::PriceHistory::FrequencyType::MINUTE,
623
+ frequency: SchwabRb::PriceHistory::Frequency::EVERY_TEN_MINUTES,
624
+ start_datetime: start_datetime,
625
+ end_datetime: end_datetime,
626
+ need_extended_hours_data: need_extended_hours_data,
627
+ need_previous_close: need_previous_close
628
+ )
629
+ end
630
+
631
+ def get_price_history_every_fifteen_minutes(symbol,
632
+ start_datetime: nil,
633
+ end_datetime: nil,
634
+ need_extended_hours_data: nil,
635
+ need_previous_close: nil)
636
+ refresh_token_if_needed
637
+
638
+ start_datetime, end_datetime = normalize_start_and_end_datetimes(
639
+ start_datetime, end_datetime
640
+ )
641
+
642
+ get_price_history(
643
+ symbol,
644
+ period_type: SchwabRb::PriceHistory::PeriodType::DAY,
645
+ period: SchwabRb::PriceHistory::Period::ONE_DAY,
646
+ frequency_type: SchwabRb::PriceHistory::FrequencyType::MINUTE,
647
+ frequency: SchwabRb::PriceHistory::Frequency::EVERY_FIFTEEN_MINUTES,
648
+ start_datetime: start_datetime,
649
+ end_datetime: end_datetime,
650
+ need_extended_hours_data: need_extended_hours_data,
651
+ need_previous_close: need_previous_close
652
+ )
653
+ end
654
+
655
+ def get_price_history_every_thirty_minutes(symbol,
656
+ start_datetime: nil,
657
+ end_datetime: nil,
658
+ need_extended_hours_data: nil,
659
+ need_previous_close: nil)
660
+ refresh_token_if_needed
661
+
662
+ start_datetime, end_datetime = normalize_start_and_end_datetimes(
663
+ start_datetime, end_datetime
664
+ )
665
+
666
+ get_price_history(
667
+ symbol,
668
+ period_type: SchwabRb::PriceHistory::PeriodType::DAY,
669
+ period: SchwabRb::PriceHistory::Period::ONE_DAY,
670
+ frequency_type: SchwabRb::PriceHistory::FrequencyType::MINUTE,
671
+ frequency: SchwabRb::PriceHistory::Frequency::EVERY_THIRTY_MINUTES,
672
+ start_datetime: start_datetime,
673
+ end_datetime: end_datetime,
674
+ need_extended_hours_data: need_extended_hours_data,
675
+ need_previous_close: need_previous_close
676
+ )
677
+ end
678
+
679
+ def get_price_history_every_day(symbol,
680
+ start_datetime: nil,
681
+ end_datetime: nil,
682
+ need_extended_hours_data: nil,
683
+ need_previous_close: nil)
684
+ refresh_token_if_needed
685
+
686
+ start_datetime, end_datetime = normalize_start_and_end_datetimes(
687
+ start_datetime, end_datetime
688
+ )
689
+
690
+ get_price_history(
691
+ symbol,
692
+ period_type: SchwabRb::PriceHistory::PeriodType::YEAR,
693
+ period: SchwabRb::PriceHistory::Period::TWENTY_YEARS,
694
+ frequency_type: SchwabRb::PriceHistory::FrequencyType::DAILY,
695
+ frequency: SchwabRb::PriceHistory::Frequency::EVERY_MINUTE,
696
+ start_datetime: start_datetime,
697
+ end_datetime: end_datetime,
698
+ need_extended_hours_data: need_extended_hours_data,
699
+ need_previous_close: need_previous_close
700
+ )
701
+ end
702
+
703
+ def get_price_history_every_week(symbol,
704
+ start_datetime: nil,
705
+ end_datetime: nil,
706
+ need_extended_hours_data: nil,
707
+ need_previous_close: nil)
708
+ refresh_token_if_needed
709
+
710
+ start_datetime, end_datetime = normalize_start_and_end_datetimes(
711
+ start_datetime, end_datetime
712
+ )
713
+
714
+ get_price_history(
715
+ symbol,
716
+ period_type: SchwabRb::PriceHistory::PeriodType::YEAR,
717
+ period: SchwabRb::PriceHistory::Period::TWENTY_YEARS,
718
+ frequency_type: SchwabRb::PriceHistory::FrequencyType::WEEKLY,
719
+ frequency: SchwabRb::PriceHistory::Frequency::EVERY_MINUTE,
720
+ start_datetime: start_datetime,
721
+ end_datetime: end_datetime,
722
+ need_extended_hours_data: need_extended_hours_data,
723
+ need_previous_close: need_previous_close
724
+ )
725
+ end
726
+
727
+ def get_movers(index, sort_order: nil, frequency: nil)
728
+ # Get a list of the top ten movers for a given index.
729
+ #
730
+ # @param index [String] Category of mover. See Movers::Index for valid values.
731
+ # @param sort_order [String] Order in which to return values. See Movers::SortOrder for valid values.
732
+ # @param frequency [String] Only return movers that saw this magnitude or greater. See Movers::Frequency for valid values.
733
+ refresh_token_if_needed
734
+
735
+ index = convert_enum(index, SchwabRb::Movers::Indexes)
736
+ sort_order = convert_enum(sort_order, SchwabRb::Movers::SortOrders) if sort_order
737
+ frequency = convert_enum(frequency, SchwabRb::Movers::Frequencies) if frequency
738
+
739
+ path = "/marketdata/v1/movers/#{index}"
740
+
741
+ params = {}
742
+ params["sort"] = sort_order if sort_order
743
+ params["frequency"] = frequency.to_s if frequency
744
+
745
+ get(path, params)
746
+ end
747
+
748
+ def get_market_hours(markets, date: nil, return_data_objects: true)
749
+ # Retrieve market hours for specified markets.
750
+ #
751
+ # @param markets [Array, String] Markets for which to return trading hours.
752
+ # @param date [Date] Date for which to return market hours. Accepts values up to
753
+ # one year from today.
754
+ # @param return_data_objects [Boolean] Whether to return data objects or raw JSON
755
+ refresh_token_if_needed
756
+
757
+ markets = convert_enum_iterable(markets, SchwabRb::MarketHours::Markets)
758
+
759
+ params = { "markets" => markets.join(",") }
760
+ params["date"] = format_date_as_day("date", date) if date
761
+
762
+ response = get("/marketdata/v1/markets", params)
763
+
764
+ if return_data_objects
765
+ market_hours_data = JSON.parse(response.body, symbolize_names: true)
766
+ SchwabRb::DataObjects::MarketHours.build(market_hours_data)
767
+ else
768
+ response
769
+ end
770
+ end
771
+
772
+ def get_instruments(symbols, projection, return_data_objects: true)
773
+ # Get instrument details by using different search methods. Also used
774
+ # to get fundamental instrument data using the "FUNDAMENTAL" projection.
775
+ #
776
+ # @param symbols [String, Array] For "FUNDAMENTAL" projection, the symbols to fetch.
777
+ # For other projections, a search term.
778
+ # @param projection [String] Search mode or "FUNDAMENTAL" for instrument fundamentals.
779
+ # @param return_data_objects [Boolean] Whether to return data objects or raw JSON
780
+ refresh_token_if_needed
781
+
782
+ symbols = [symbols] unless symbols.is_a?(Array)
783
+ projection = convert_enum(projection, SchwabRb::Orders::Instrument::Projections)
784
+ params = {
785
+ "symbol" => symbols.join(","),
786
+ "projection" => projection
787
+ }
788
+
789
+ response = get("/marketdata/v1/instruments", params)
790
+
791
+ if return_data_objects
792
+ instruments_data = JSON.parse(response.body, symbolize_names: true)
793
+ instruments_data.map do |instrument_data|
794
+ SchwabRb::DataObjects::Instrument.build(instrument_data)
795
+ end
796
+ else
797
+ response
798
+ end
799
+ end
800
+
801
+ def get_instrument_by_cusip(cusip, return_data_objects: true)
802
+ # Get instrument information for a single instrument by CUSIP.
803
+ #
804
+ # @param cusip [String] CUSIP of the instrument to fetch. Leading zeroes must be preserved.
805
+ # @param return_data_objects [Boolean] Whether to return data objects or raw JSON
806
+ refresh_token_if_needed
807
+
808
+ raise ArgumentError, "cusip must be passed as a string" unless cusip.is_a?(String)
809
+
810
+ response = get("/marketdata/v1/instruments/#{cusip}", {})
811
+
812
+ if return_data_objects
813
+ instrument_data = JSON.parse(response.body, symbolize_names: true)
814
+ SchwabRb::DataObjects::Instrument.build(instrument_data)
815
+ else
816
+ response
817
+ end
818
+ end
819
+
820
+ private
821
+
822
+ def make_order_query(
823
+ max_results: nil,
824
+ from_entered_datetime: nil,
825
+ to_entered_datetime: nil,
826
+ status: nil
827
+ )
828
+ status = convert_enum(status, SchwabRb::Order::Statuses) if status
829
+
830
+ from_entered_datetime ||= (DateTime.now.new_offset(0) - 60) # 60 days ago (UTC)
831
+ to_entered_datetime ||= DateTime.now.new_offset(0) # Current UTC time
832
+
833
+ params = {
834
+ "fromEnteredTime" => format_date_as_iso("from_entered_datetime", from_entered_datetime),
835
+ "toEnteredTime" => format_date_as_iso("to_entered_datetime", to_entered_datetime)
836
+ }
837
+
838
+ params["maxResults"] = max_results if max_results
839
+ params["status"] = status if status
840
+
841
+ params
842
+ end
843
+
844
+ def format_date_as_iso(var_name, dt)
845
+ assert_type(var_name, dt, [Date, DateTime])
846
+ dt = DateTime.new(dt.year, dt.month, dt.day) unless dt.is_a?(DateTime)
847
+ dt.strftime("%Y-%m-%dT%H:%M:%S.%LZ")
848
+ end
849
+
850
+ def format_date_as_day(var_name, date)
851
+ assert_type(var_name, date, [Date, DateTime])
852
+ date = Date.new(date.year, date.month, date.day) unless date.is_a?(Date)
853
+ date.strftime("%Y-%m-%d")
854
+ end
855
+
856
+ def format_date_as_millis(var_name, dt)
857
+ assert_type(var_name, dt, [Date, DateTime])
858
+ dt = DateTime.new(dt.year, dt.month, dt.day) unless dt.is_a?(DateTime)
859
+ (dt.to_time.to_f * 1000).to_i
860
+ end
861
+
862
+ def normalize_start_and_end_datetimes(start_datetime, end_datetime)
863
+ start_datetime ||= DateTime.new(1971, 1, 1)
864
+ end_datetime ||= DateTime.now + 7
865
+
866
+ [start_datetime, end_datetime]
867
+ end
868
+
869
+ def authorize_request(request)
870
+ request["Authorization"] = "Bearer #{@session.token}"
871
+ request
872
+ end
873
+
874
+ def refresh_token_if_needed
875
+ return unless session.expired?
876
+
877
+ new_session = token_manager.refresh_token(self)
878
+ @session = new_session
879
+ end
880
+
881
+ def assert_type(var_name, value, types)
882
+ return if types.any? { |type| value.is_a?(type) }
883
+
884
+ raise ArgumentError, "#{var_name} must be one of #{types.join(', ')}"
885
+ end
886
+ end
887
+ end