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.
- checksums.yaml +7 -0
- data/.brakeman.yml +75 -0
- data/.claude/commands/release-pr.md +120 -0
- data/.env.example +15 -0
- data/.rspec +3 -0
- data/.rubocop.yml +25 -0
- data/CHANGELOG.md +115 -0
- data/LICENSE.txt +21 -0
- data/README.md +133 -0
- data/Rakefile +12 -0
- data/docs/resource_objects.md +474 -0
- data/lib/schwab/account_number_resolver.rb +123 -0
- data/lib/schwab/accounts.rb +331 -0
- data/lib/schwab/client.rb +266 -0
- data/lib/schwab/configuration.rb +140 -0
- data/lib/schwab/connection.rb +81 -0
- data/lib/schwab/error.rb +51 -0
- data/lib/schwab/market_data.rb +179 -0
- data/lib/schwab/middleware/authentication.rb +100 -0
- data/lib/schwab/middleware/rate_limit.rb +119 -0
- data/lib/schwab/oauth.rb +95 -0
- data/lib/schwab/resources/account.rb +272 -0
- data/lib/schwab/resources/base.rb +300 -0
- data/lib/schwab/resources/order.rb +441 -0
- data/lib/schwab/resources/position.rb +318 -0
- data/lib/schwab/resources/strategy.rb +410 -0
- data/lib/schwab/resources/transaction.rb +333 -0
- data/lib/schwab/version.rb +6 -0
- data/lib/schwab.rb +46 -0
- data/sig/schwab.rbs +4 -0
- data/tasks/prd-accounts-trading-api.md +302 -0
- data/tasks/tasks-prd-accounts-trading-api-reordered.md +140 -0
- data/tasks/tasks-prd-accounts-trading-api.md +106 -0
- metadata +146 -0
@@ -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
|