schwab 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.
@@ -0,0 +1,331 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Schwab
6
+ # Account Management API endpoints for retrieving account information,
7
+ # positions, transactions, and orders
8
+ module Accounts
9
+ class << self
10
+ # Get all accounts for the authenticated user
11
+ #
12
+ # @param fields [String, Array<String>, nil] Fields to include (e.g., "positions", "orders")
13
+ # @param client [Schwab::Client, nil] Optional client instance (uses default if not provided)
14
+ # @return [Array<Hash>, Array<Resources::Account>] List of accounts
15
+ # @example Get all accounts
16
+ # Schwab::Accounts.get_accounts
17
+ # @example Get accounts with positions
18
+ # Schwab::Accounts.get_accounts(fields: "positions")
19
+ def get_accounts(fields: nil, client: nil)
20
+ client ||= default_client
21
+ params = {}
22
+ params[:fields] = normalize_fields(fields) if fields
23
+
24
+ response = client.get("/trader/v1/accounts", params, Resources::Account)
25
+ # API returns accounts in a wrapper, extract the array
26
+ response.is_a?(Hash) && response[:accounts] ? response[:accounts] : response
27
+ end
28
+ alias_method :list_accounts, :get_accounts
29
+
30
+ # Get a specific account by account number
31
+ #
32
+ # @param account_number [String] The account number
33
+ # @param fields [String, Array<String>, nil] Fields to include
34
+ # @param client [Schwab::Client, nil] Optional client instance
35
+ # @return [Hash, Resources::Account] Account details
36
+ # @example Get account with positions and orders
37
+ # Schwab::Accounts.get_account("123456", fields: ["positions", "orders"])
38
+ def get_account(account_number, fields: nil, client: nil)
39
+ client ||= default_client
40
+ path = "/trader/v1/accounts/#{encode_account_number(account_number, client)}"
41
+ params = {}
42
+ params[:fields] = normalize_fields(fields) if fields
43
+
44
+ client.get(path, params, Resources::Account)
45
+ end
46
+
47
+ # Get positions for a specific account
48
+ #
49
+ # @param account_number [String] The account number
50
+ # @param client [Schwab::Client, nil] Optional client instance
51
+ # @return [Array<Hash>, Array<Resources::Position>] List of positions
52
+ # @example Get all positions
53
+ # Schwab::Accounts.get_positions("123456")
54
+ def get_positions(account_number, client: nil)
55
+ account_data = get_account(account_number, fields: "positions", client: client)
56
+
57
+ if account_data.is_a?(Hash)
58
+ # Positions are nested under securitiesAccount
59
+ securities_account = account_data["securitiesAccount"] || account_data[:securitiesAccount]
60
+ positions = securities_account ? (securities_account["positions"] || securities_account[:positions]) : nil
61
+ elsif account_data.respond_to?(:positions)
62
+ positions = account_data.positions
63
+ end
64
+
65
+ positions || []
66
+ end
67
+
68
+ # Get transactions for a specific account
69
+ #
70
+ # @param account_number [String] The account number
71
+ # @param types [String, Array<String>] Transaction types to filter (REQUIRED). Valid values:
72
+ # TRADE, RECEIVE_AND_DELIVER, DIVIDEND_OR_INTEREST, ACH_RECEIPT, ACH_DISBURSEMENT,
73
+ # CASH_RECEIPT, CASH_DISBURSEMENT, ELECTRONIC_FUND, WIRE_OUT, WIRE_IN, JOURNAL,
74
+ # MEMORANDUM, MARGIN_CALL, MONEY_MARKET, SMA_ADJUSTMENT
75
+ # @param start_date [Date, Time, String] Start date for transactions (ISO-8601 format, REQUIRED)
76
+ # @param end_date [Date, Time, String] End date for transactions (ISO-8601 format, REQUIRED)
77
+ # @param symbol [String, nil] Filter by symbol
78
+ # @param client [Schwab::Client, nil] Optional client instance
79
+ # @return [Array<Hash>, Array<Resources::Transaction>] List of transactions
80
+ # @example Get all trade transactions
81
+ # Schwab::Accounts.get_transactions("123456",
82
+ # types: "TRADE",
83
+ # start_date: "2024-01-01",
84
+ # end_date: "2024-01-31"
85
+ # )
86
+ # @example Get trades for AAPL in date range
87
+ # Schwab::Accounts.get_transactions("123456",
88
+ # types: "TRADE",
89
+ # symbol: "AAPL",
90
+ # start_date: "2024-01-01",
91
+ # end_date: "2024-01-31"
92
+ # )
93
+ def get_transactions(account_number, types: nil, start_date: nil, end_date: nil, symbol: nil, client: nil)
94
+ client ||= default_client
95
+ path = "/trader/v1/accounts/#{encode_account_number(account_number, client)}/transactions"
96
+
97
+ params = {}
98
+ params[:types] = normalize_transaction_types(types) if types
99
+ params[:startDate] = format_date(start_date) if start_date
100
+ params[:endDate] = format_date(end_date) if end_date
101
+ params[:symbol] = symbol.upcase if symbol
102
+
103
+ client.get(path, params, Resources::Transaction)
104
+ end
105
+
106
+ # Get a specific transaction
107
+ #
108
+ # @param account_number [String] The account number
109
+ # @param transaction_id [String] The transaction ID
110
+ # @param client [Schwab::Client, nil] Optional client instance
111
+ # @return [Hash, Resources::Transaction] Transaction details
112
+ # @example Get transaction details
113
+ # Schwab::Accounts.get_transaction("123456", "trans789")
114
+ def get_transaction(account_number, transaction_id, client: nil)
115
+ client ||= default_client
116
+ path = "/trader/v1/accounts/#{encode_account_number(account_number, client)}/transactions/#{transaction_id}"
117
+
118
+ client.get(path, {}, Resources::Transaction)
119
+ end
120
+
121
+ # Get orders for a specific account
122
+ #
123
+ # @param account_number [String] The account number
124
+ # @param from_entered_time [Time, DateTime, String, nil] Start time for orders (ISO-8601 format required)
125
+ # @param to_entered_time [Time, DateTime, String, nil] End time for orders (ISO-8601 format required)
126
+ # @param status [String, Array<String>, nil] Order status filter. Valid values:
127
+ # AWAITING_PARENT_ORDER, AWAITING_CONDITION, AWAITING_STOP_CONDITION, AWAITING_MANUAL_REVIEW,
128
+ # ACCEPTED, AWAITING_UR_OUT, PENDING_ACTIVATION, QUEUED, WORKING, REJECTED, PENDING_CANCEL,
129
+ # CANCELED, PENDING_REPLACE, REPLACED, FILLED, EXPIRED, NEW, AWAITING_RELEASE_TIME,
130
+ # PENDING_ACKNOWLEDGEMENT, PENDING_RECALL, UNKNOWN
131
+ # @param max_results [Integer, nil] Maximum number of results (defaults to 3000)
132
+ # @param client [Schwab::Client, nil] Optional client instance
133
+ # @return [Array<Hash>, Array<Resources::Order>] List of orders
134
+ # @example Get all orders with time range
135
+ # Schwab::Accounts.get_orders("123456",
136
+ # from_entered_time: "2024-03-29T00:00:00.000Z",
137
+ # to_entered_time: "2024-03-30T00:00:00.000Z"
138
+ # )
139
+ # @example Get working orders
140
+ # Schwab::Accounts.get_orders("123456", status: "WORKING")
141
+ def get_orders(account_number, from_entered_time: nil, to_entered_time: nil, status: nil, max_results: nil, client: nil)
142
+ client ||= default_client
143
+ path = "/trader/v1/accounts/#{encode_account_number(account_number, client)}/orders"
144
+
145
+ params = {}
146
+ params[:fromEnteredTime] = format_datetime(from_entered_time) if from_entered_time
147
+ params[:toEnteredTime] = format_datetime(to_entered_time) if to_entered_time
148
+ params[:status] = normalize_order_status(status) if status
149
+ params[:maxResults] = max_results if max_results
150
+
151
+ client.get(path, params, Resources::Order)
152
+ end
153
+
154
+ # Get all orders for all accounts
155
+ #
156
+ # @param from_entered_time [Time, DateTime, String, nil] Start time for orders
157
+ # @param to_entered_time [Time, DateTime, String, nil] End time for orders
158
+ # @param status [String, Array<String>, nil] Order status filter
159
+ # @param max_results [Integer, nil] Maximum number of results
160
+ # @param client [Schwab::Client, nil] Optional client instance
161
+ # @return [Array<Hash>, Array<Resources::Order>] List of orders
162
+ # @example Get all orders across all accounts
163
+ # Schwab::Accounts.get_all_orders
164
+ # @example Get all filled orders today
165
+ # Schwab::Accounts.get_all_orders(
166
+ # from_entered_time: Date.today,
167
+ # status: "FILLED"
168
+ # )
169
+ def get_all_orders(from_entered_time: nil, to_entered_time: nil, status: nil, max_results: nil, client: nil)
170
+ client ||= default_client
171
+ path = "/trader/v1/orders"
172
+
173
+ params = {}
174
+ params[:fromEnteredTime] = format_datetime(from_entered_time) if from_entered_time
175
+ params[:toEnteredTime] = format_datetime(to_entered_time) if to_entered_time
176
+ params[:status] = normalize_order_status(status) if status
177
+ params[:maxResults] = max_results if max_results
178
+
179
+ client.get(path, params, Resources::Order)
180
+ end
181
+
182
+ # Get a specific order
183
+ #
184
+ # @param account_number [String] The account number
185
+ # @param order_id [String] The order ID
186
+ # @param client [Schwab::Client, nil] Optional client instance
187
+ # @return [Hash, Resources::Order] Order details
188
+ # @example Get order details
189
+ # Schwab::Accounts.get_order("123456", "order789")
190
+ def get_order(account_number, order_id, client: nil)
191
+ client ||= default_client
192
+ path = "/trader/v1/accounts/#{encode_account_number(account_number, client)}/orders/#{order_id}"
193
+
194
+ client.get(path, {}, Resources::Order)
195
+ end
196
+
197
+ # Get account numbers and their encrypted hash values
198
+ #
199
+ # @param client [Schwab::Client, nil] Optional client instance
200
+ # @return [Array<Hash>] Array of account number/hash value pairs
201
+ # @example Get account numbers
202
+ # Schwab::Accounts.get_account_numbers
203
+ # # => [{accountNumber: "123456789", hashValue: "ABC123XYZ"}, ...]
204
+ def get_account_numbers(client: nil)
205
+ client ||= default_client
206
+ client.get("/trader/v1/accounts/accountNumbers")
207
+ end
208
+
209
+ # Get user preferences (across all accounts)
210
+ #
211
+ # @param client [Schwab::Client, nil] Optional client instance
212
+ # @return [Hash] User preferences
213
+ # @example Get user preferences
214
+ # Schwab::Accounts.get_user_preferences
215
+ def get_user_preferences(client: nil)
216
+ client ||= default_client
217
+ client.get("/trader/v1/userPreference")
218
+ end
219
+
220
+ # Preview an order before placing it
221
+ #
222
+ # @param account_number [String] The account number
223
+ # @param order_data [Hash] Order details to preview
224
+ # @param client [Schwab::Client, nil] Optional client instance
225
+ # @return [Hash] Order preview with estimated costs, commissions, and margin requirements
226
+ # @example Preview a buy order
227
+ # Schwab::Accounts.preview_order("123456", {
228
+ # orderType: "MARKET",
229
+ # session: "NORMAL",
230
+ # duration: "DAY",
231
+ # orderStrategyType: "SINGLE",
232
+ # orderLegCollection: [{
233
+ # instruction: "BUY",
234
+ # quantity: 10,
235
+ # instrument: {
236
+ # symbol: "AAPL",
237
+ # assetType: "EQUITY"
238
+ # }
239
+ # }]
240
+ # })
241
+ def preview_order(account_number, order_data, client: nil)
242
+ client ||= default_client
243
+ path = "/trader/v1/accounts/#{encode_account_number(account_number, client)}/previewOrder"
244
+
245
+ client.post(path, order_data)
246
+ end
247
+
248
+ private
249
+
250
+ def default_client
251
+ Schwab.client || raise(Error, "No client configured. Set Schwab.client or pass a client instance.")
252
+ end
253
+
254
+ def encode_account_number(account_number, client = nil)
255
+ client ||= default_client
256
+ encrypted_number = client.resolve_account_number(account_number)
257
+ URI.encode_www_form_component(encrypted_number)
258
+ end
259
+
260
+ def encode_symbol(symbol)
261
+ URI.encode_www_form_component(symbol.to_s.upcase)
262
+ end
263
+
264
+ def normalize_fields(fields)
265
+ case fields
266
+ when Array
267
+ fields.join(",")
268
+ when String
269
+ fields
270
+ else
271
+ fields.to_s
272
+ end
273
+ end
274
+
275
+ def normalize_transaction_types(types)
276
+ case types
277
+ when Array
278
+ types.map(&:to_s).map(&:upcase).join(",")
279
+ when String
280
+ types.upcase
281
+ else
282
+ types.to_s.upcase
283
+ end
284
+ end
285
+
286
+ def normalize_order_status(status)
287
+ case status
288
+ when Array
289
+ status.map(&:to_s).map(&:upcase).join(",")
290
+ when String
291
+ status.upcase
292
+ else
293
+ status.to_s.upcase
294
+ end
295
+ end
296
+
297
+ def format_date(date)
298
+ case date
299
+ when Date
300
+ date.iso8601
301
+ when Time, DateTime
302
+ date.to_date.iso8601
303
+ when String
304
+ # If string already contains time info, preserve it
305
+ if date.include?("T") && date.include?(":")
306
+ date
307
+ else
308
+ Date.parse(date).iso8601
309
+ end
310
+ else
311
+ date.to_s
312
+ end
313
+ end
314
+
315
+ def format_datetime(datetime)
316
+ case datetime
317
+ when Time
318
+ datetime.iso8601
319
+ when DateTime
320
+ datetime.to_time.iso8601
321
+ when Date
322
+ datetime.to_time.iso8601
323
+ when String
324
+ Time.parse(datetime).iso8601
325
+ else
326
+ datetime.to_s
327
+ end
328
+ end
329
+ end
330
+ end
331
+ end
@@ -0,0 +1,266 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "connection"
4
+ require_relative "middleware/authentication"
5
+ require_relative "middleware/rate_limit"
6
+ require_relative "account_number_resolver"
7
+ require_relative "resources/base"
8
+ require_relative "resources/account"
9
+ require_relative "resources/position"
10
+ require_relative "resources/transaction"
11
+ require_relative "resources/order"
12
+ require_relative "resources/strategy"
13
+
14
+ module Schwab
15
+ # Main client for interacting with the Schwab API
16
+ class Client
17
+ attr_reader :access_token, :refresh_token, :auto_refresh, :config
18
+
19
+ # Initialize a new Schwab API client
20
+ #
21
+ # @param access_token [String] OAuth access token
22
+ # @param refresh_token [String, nil] OAuth refresh token for auto-refresh
23
+ # @param auto_refresh [Boolean] Whether to automatically refresh expired tokens
24
+ # @param on_token_refresh [Proc, nil] Callback when token is refreshed
25
+ # @param config [Configuration, nil] Custom configuration (uses global if not provided)
26
+ def initialize(access_token:, refresh_token: nil, auto_refresh: false, on_token_refresh: nil, config: nil)
27
+ @access_token = access_token
28
+ @refresh_token = refresh_token
29
+ @auto_refresh = auto_refresh
30
+ @on_token_refresh = on_token_refresh
31
+ @config = config || Schwab.configuration || Configuration.new
32
+ @connection = nil
33
+ @account_resolver = nil
34
+ @mutex = Mutex.new
35
+ end
36
+
37
+ # Get the Faraday connection (lazily initialized)
38
+ #
39
+ # @return [Faraday::Connection] The configured HTTP connection
40
+ def connection
41
+ @mutex.synchronize do
42
+ @connection ||= build_connection
43
+ end
44
+ end
45
+
46
+ # Make a GET request to the API
47
+ #
48
+ # @param path [String] The API endpoint path
49
+ # @param params [Hash] Query parameters
50
+ # @param resource_class [Class, nil] Optional resource class for response wrapping
51
+ # @return [Hash, Resources::Base] The response (hash or resource based on config)
52
+ def get(path, params = {}, resource_class = nil)
53
+ request(:get, path, params, resource_class)
54
+ end
55
+
56
+ # Make a POST request to the API
57
+ #
58
+ # @param path [String] The API endpoint path
59
+ # @param body [Hash] Request body
60
+ # @param resource_class [Class, nil] Optional resource class for response wrapping
61
+ # @return [Hash, Resources::Base] The response (hash or resource based on config)
62
+ def post(path, body = {}, resource_class = nil)
63
+ request(:post, path, body, resource_class)
64
+ end
65
+
66
+ # Make a PUT request to the API
67
+ #
68
+ # @param path [String] The API endpoint path
69
+ # @param body [Hash] Request body
70
+ # @param resource_class [Class, nil] Optional resource class for response wrapping
71
+ # @return [Hash, Resources::Base] The response (hash or resource based on config)
72
+ def put(path, body = {}, resource_class = nil)
73
+ request(:put, path, body, resource_class)
74
+ end
75
+
76
+ # Make a DELETE request to the API
77
+ #
78
+ # @param path [String] The API endpoint path
79
+ # @param params [Hash] Query parameters
80
+ # @param resource_class [Class, nil] Optional resource class for response wrapping
81
+ # @return [Hash, Resources::Base] The response (hash or resource based on config)
82
+ def delete(path, params = {}, resource_class = nil)
83
+ request(:delete, path, params, resource_class)
84
+ end
85
+
86
+ # Make a PATCH request to the API
87
+ #
88
+ # @param path [String] The API endpoint path
89
+ # @param body [Hash] Request body
90
+ # @param resource_class [Class, nil] Optional resource class for response wrapping
91
+ # @return [Hash, Resources::Base] The response (hash or resource based on config)
92
+ def patch(path, body = {}, resource_class = nil)
93
+ request(:patch, path, body, resource_class)
94
+ end
95
+
96
+ # Update the access token (useful after manual refresh)
97
+ #
98
+ # @param new_token [String] The new access token
99
+ def update_access_token(new_token)
100
+ @mutex.synchronize do
101
+ @access_token = new_token
102
+ @connection = nil # Force rebuild of connection with new token
103
+ end
104
+ end
105
+
106
+ # Update both access and refresh tokens
107
+ #
108
+ # @param access_token [String] The new access token
109
+ # @param refresh_token [String, nil] The new refresh token
110
+ def update_tokens(access_token:, refresh_token: nil)
111
+ @mutex.synchronize do
112
+ @access_token = access_token
113
+ @refresh_token = refresh_token if refresh_token
114
+ @connection = nil # Force rebuild of connection
115
+ end
116
+ end
117
+
118
+ # Get the account number resolver (lazily initialized)
119
+ #
120
+ # @return [AccountNumberResolver] The account number resolver
121
+ def account_resolver
122
+ @mutex.synchronize do
123
+ @account_resolver ||= AccountNumberResolver.new(self)
124
+ end
125
+ end
126
+
127
+ # Resolve an account number to its encrypted hash value
128
+ #
129
+ # @param account_number [String] Plain account number or encrypted hash
130
+ # @return [String] The encrypted hash value for API calls
131
+ # @example Resolve account number
132
+ # client.resolve_account_number("123456789") # => "ABC123XYZ"
133
+ def resolve_account_number(account_number)
134
+ account_resolver.resolve(account_number)
135
+ end
136
+
137
+ # Refresh account number mappings
138
+ #
139
+ # @return [void]
140
+ # @example Refresh account mappings
141
+ # client.refresh_account_mappings!
142
+ def refresh_account_mappings!
143
+ account_resolver.refresh!
144
+ end
145
+
146
+ private
147
+
148
+ def build_connection
149
+ if @auto_refresh && @refresh_token
150
+ # Build connection with automatic token refresh
151
+ Connection.build_with_refresh(
152
+ access_token: @access_token,
153
+ refresh_token: @refresh_token,
154
+ on_token_refresh: method(:handle_token_refresh),
155
+ config: @config,
156
+ )
157
+ else
158
+ # Build standard connection
159
+ Connection.build(
160
+ access_token: @access_token,
161
+ config: @config,
162
+ )
163
+ end
164
+ end
165
+
166
+ def handle_token_refresh(token_data)
167
+ # Update our tokens
168
+ @access_token = token_data[:access_token]
169
+ @refresh_token = token_data[:refresh_token] if token_data[:refresh_token]
170
+
171
+ # Call user's callback if provided
172
+ @on_token_refresh&.call(token_data)
173
+ end
174
+
175
+ def request(method, path, params_or_body = {}, resource_class = nil)
176
+ # Remove leading slash if present to work with Faraday's URL joining
177
+ path = path.sub(%r{^/}, "")
178
+
179
+ response = case method
180
+ when :get, :delete
181
+ connection.send(method, path, params_or_body)
182
+ when :post, :put, :patch
183
+ connection.send(method, path, params_or_body)
184
+ else
185
+ raise ArgumentError, "Unsupported HTTP method: #{method}"
186
+ end
187
+
188
+ wrap_response(response.body, resource_class)
189
+ rescue Faraday::Error => e
190
+ handle_error(e)
191
+ end
192
+
193
+ # Wrap response data based on configured format
194
+ #
195
+ # @param data [Hash, Array] The response data
196
+ # @param resource_class [Class, nil] Optional resource class to use for wrapping
197
+ # @return [Hash, Array, Resources::Base] The wrapped response
198
+ def wrap_response(data, resource_class = nil)
199
+ return data if @config.response_format == :hash || data.nil?
200
+
201
+ # If response_format is :resource, wrap the response
202
+ if data.is_a?(Array)
203
+ data.map { |item| wrap_single_response(item, resource_class) }
204
+ else
205
+ wrap_single_response(data, resource_class)
206
+ end
207
+ end
208
+
209
+ # Wrap a single response item
210
+ #
211
+ # @param item [Hash] The response item
212
+ # @param resource_class [Class, nil] Optional resource class to use
213
+ # @return [Resources::Base] The wrapped response
214
+ def wrap_single_response(item, resource_class)
215
+ return item unless item.is_a?(Hash)
216
+
217
+ klass = resource_class || determine_resource_class(item)
218
+ klass.new(item, self)
219
+ end
220
+
221
+ # Determine the appropriate resource class based on response data
222
+ #
223
+ # @param data [Hash] The response data
224
+ # @return [Class] The resource class to use
225
+ def determine_resource_class(data)
226
+ # Check for specific identifiers in the response to determine type
227
+ if data.key?(:accountNumber) || data.key?(:account_number)
228
+ Resources::Account
229
+ elsif data.key?(:orderId) || data.key?(:order_id)
230
+ Resources::Order
231
+ elsif data.key?(:transactionId) || data.key?(:transaction_id)
232
+ Resources::Transaction
233
+ elsif data.key?(:instrument) && (data.key?(:longQuantity) || data.key?(:long_quantity))
234
+ Resources::Position
235
+ elsif data.key?(:strategyType) || data.key?(:strategy_type)
236
+ Resources::Strategy
237
+ else
238
+ Resources::Base
239
+ end
240
+ end
241
+
242
+ def handle_error(error)
243
+ case error
244
+ when Faraday::TimeoutError, Faraday::ConnectionFailed
245
+ raise Schwab::Error, "Request timeout: #{error.message}"
246
+ when Faraday::UnauthorizedError
247
+ raise Schwab::AuthenticationError, "Authentication failed: #{error.message}"
248
+ when Faraday::ForbiddenError
249
+ raise Schwab::AuthorizationError, "Access forbidden: #{error.message}"
250
+ when Faraday::ResourceNotFound
251
+ raise Schwab::NotFoundError, "Resource not found: #{error.message}"
252
+ when Faraday::TooManyRequestsError
253
+ raise Schwab::RateLimitError, "Rate limit exceeded: #{error.message}"
254
+ when Faraday::BadRequestError
255
+ # Preserve the response body for BadRequestError so we can parse JSON error details
256
+ bad_request_error = Schwab::BadRequestError.new("Bad request: #{error.message}")
257
+ bad_request_error.response_body = error.response[:body] if error.response && error.response[:body]
258
+ raise bad_request_error
259
+ when Faraday::ServerError
260
+ raise Schwab::ServerError, "Server error: #{error.message}"
261
+ else
262
+ raise Schwab::Error, "Request failed: #{error.message}"
263
+ end
264
+ end
265
+ end
266
+ end