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,474 @@
1
+ # Resource Objects Usage Guide
2
+
3
+ ## Overview
4
+
5
+ The Schwab Ruby SDK provides two response formats for API data:
6
+ - **Hash mode** (default): Returns plain Ruby hashes
7
+ - **Resource mode**: Returns rich objects with helper methods
8
+
9
+ ## Configuration
10
+
11
+ ### Setting Response Format
12
+
13
+ ```ruby
14
+ # Global configuration
15
+ Schwab.configure do |config|
16
+ config.response_format = :resource # or :hash (default)
17
+ end
18
+
19
+ # Per-client configuration
20
+ client = Schwab::Client.new(
21
+ access_token: token,
22
+ config: Schwab::Configuration.new.tap do |c|
23
+ c.response_format = :resource
24
+ end
25
+ )
26
+ ```
27
+
28
+ ## Working with Resource Objects
29
+
30
+ ### Basic Access Patterns
31
+
32
+ Resource objects provide multiple ways to access data:
33
+
34
+ ```ruby
35
+ # Assuming response_format = :resource
36
+ account = client.get_account("123456")
37
+
38
+ # Method access (preferred)
39
+ account.account_number # "123456"
40
+ account.total_value # 50000.00
41
+
42
+ # Hash-style access (still works)
43
+ account[:accountNumber] # "123456"
44
+ account["accountNumber"] # "123456"
45
+
46
+ # Checking for keys
47
+ account.key?(:status) # true
48
+ account.has_key?("status") # true
49
+
50
+ # Getting all keys
51
+ account.keys # [:accountNumber, :status, ...]
52
+
53
+ # Converting back to hash
54
+ account.to_h # { accountNumber: "123456", ... }
55
+ ```
56
+
57
+ ### Nested Resources
58
+
59
+ Nested hashes are automatically wrapped in resource objects:
60
+
61
+ ```ruby
62
+ account = client.get_account("123456")
63
+
64
+ # Nested objects are also resources
65
+ balances = account.current_balances
66
+ balances.cash_balance # 10000.00
67
+ balances.buying_power # 20000.00
68
+
69
+ # Deep nesting works automatically
70
+ position = account.positions.first
71
+ position.instrument.symbol # "AAPL"
72
+ ```
73
+
74
+ ### Type Coercion
75
+
76
+ Resource objects automatically coerce types for known fields:
77
+
78
+ ```ruby
79
+ # Dates and times are parsed
80
+ account.created_time # Returns Time/DateTime object
81
+ account.opened_date # Returns Date object
82
+
83
+ # Booleans are coerced
84
+ account.day_trader # true (from "true" string)
85
+ account.pdt_flag # false (from 0)
86
+
87
+ # Numbers are coerced
88
+ position.quantity # 100.0 (Float)
89
+ account.round_trips # 3 (Integer)
90
+ ```
91
+
92
+ ## Resource-Specific Features
93
+
94
+ ### Account Resources
95
+
96
+ ```ruby
97
+ account = client.get_account("123456")
98
+
99
+ # Account type checks
100
+ account.margin_account? # true/false
101
+ account.cash_account? # true/false
102
+
103
+ # Status checks
104
+ account.active? # true if status == "ACTIVE"
105
+
106
+ # Balance shortcuts
107
+ account.total_value # Net liquidation value
108
+ account.cash_balance # Available cash
109
+ account.buying_power # Buying power
110
+ account.day_trading_buying_power # Day trading BP
111
+
112
+ # Margin account features
113
+ account.margin_balance # Margin balance (nil for cash accounts)
114
+ account.margin_call? # true/false/nil
115
+
116
+ # Position management
117
+ account.positions # Array of Position resources
118
+ account.equity_positions # Only equity positions
119
+ account.option_positions # Only option positions
120
+ account.has_positions? # true/false
121
+ account.position_count # Number of positions
122
+
123
+ # P&L calculations
124
+ account.total_pnl # Total unrealized P&L
125
+ account.todays_pnl # Today's P&L
126
+ account.total_market_value # Sum of all position values
127
+ ```
128
+
129
+ ### Position Resources
130
+
131
+ ```ruby
132
+ position = account.positions.first
133
+
134
+ # Basic information
135
+ position.symbol # "AAPL"
136
+ position.quantity # 100.0
137
+ position.average_price # 150.00 (cost basis per share)
138
+ position.current_price # 155.00
139
+
140
+ # Calculations
141
+ position.market_value # 15500.00
142
+ position.cost_basis # 15000.00
143
+ position.unrealized_pnl # 500.00
144
+ position.unrealized_pnl_percentage # 3.33
145
+ position.day_pnl # 100.00
146
+
147
+ # Position type checks
148
+ position.long? # true if quantity > 0
149
+ position.short? # true if quantity < 0
150
+ position.equity? # true if equity position
151
+ position.option? # true if option position
152
+ position.profitable? # true if unrealized_pnl > 0
153
+
154
+ # Option-specific features
155
+ position.option_details # Hash with strike, expiration, etc.
156
+ position.underlying_symbol # "AAPL" (for options)
157
+ position.strike_price # 150.00
158
+ position.expiration_date # "2024-03-15"
159
+ position.call? # true if call option
160
+ position.put? # true if put option
161
+ ```
162
+
163
+ ### Order Resources
164
+
165
+ ```ruby
166
+ order = client.get_order("order123")
167
+
168
+ # Order identification
169
+ order.order_id # "order123"
170
+ order.account_id # "123456"
171
+ order.symbol # "AAPL" (for single-leg orders)
172
+
173
+ # Order details
174
+ order.status # "FILLED", "WORKING", etc.
175
+ order.order_type # "MARKET", "LIMIT", etc.
176
+ order.instruction # "BUY", "SELL", etc.
177
+ order.quantity # 100.0
178
+ order.price # 150.00 (for limit orders)
179
+
180
+ # Status checks
181
+ order.pending? # true if pending
182
+ order.active? # true if working/active
183
+ order.filled? # true if completely filled
184
+ order.partially_filled? # true if partially filled
185
+ order.cancelled? # true if cancelled
186
+ order.rejected? # true if rejected
187
+ order.complete? # true if terminal state
188
+
189
+ # Order type checks
190
+ order.market_order? # true if market order
191
+ order.limit_order? # true if limit order
192
+ order.stop_order? # true if stop order
193
+ order.day_order? # true if DAY duration
194
+ order.gtc? # true if GTC
195
+
196
+ # Instruction checks
197
+ order.buy? # true if buy order
198
+ order.sell? # true if sell order
199
+ order.opening? # true if opening position
200
+ order.closing? # true if closing position
201
+
202
+ # Complex orders
203
+ order.complex? # true if multi-leg
204
+ order.single_leg? # true if single-leg
205
+ order.option_order? # true if options order
206
+ order.equity_order? # true if equity order
207
+
208
+ # Fill information
209
+ order.filled_quantity # 50.0
210
+ order.remaining_quantity # 50.0
211
+ order.fill_percentage # 50.0 (%)
212
+ ```
213
+
214
+ ### Transaction Resources
215
+
216
+ ```ruby
217
+ transaction = client.get_transaction("trans123")
218
+
219
+ # Transaction identification
220
+ transaction.transaction_id # "trans123"
221
+ transaction.transaction_type # "TRADE", "DIVIDEND", etc.
222
+ transaction.date # Time/Date object
223
+
224
+ # Transaction details
225
+ transaction.symbol # "AAPL"
226
+ transaction.quantity # 100.0
227
+ transaction.price # 150.00
228
+ transaction.net_amount # 15000.00
229
+ transaction.fees # 0.65
230
+ transaction.commission # 0.00
231
+
232
+ # Transaction type checks
233
+ transaction.trade? # true if trade
234
+ transaction.buy? # true if buy
235
+ transaction.sell? # true if sell
236
+ transaction.dividend? # true if dividend
237
+ transaction.interest? # true if interest
238
+ transaction.deposit? # true if deposit
239
+ transaction.withdrawal? # true if withdrawal
240
+ transaction.option? # true if option transaction
241
+
242
+ # Status checks
243
+ transaction.pending? # true if pending
244
+ transaction.completed? # true if completed
245
+ transaction.cancelled? # true if cancelled
246
+ ```
247
+
248
+ ### Strategy Resources (Options Strategies)
249
+
250
+ ```ruby
251
+ strategy = client.get_strategy("strat123")
252
+
253
+ # Strategy identification
254
+ strategy.strategy_type # "VERTICAL", "IRON_CONDOR", etc.
255
+ strategy.underlying_symbol # "SPY"
256
+ strategy.legs # Array of strategy legs
257
+
258
+ # Strategy analysis
259
+ strategy.strike_prices # [420, 425] (all strikes)
260
+ strategy.expiration_dates # ["2024-03-15"] (all expirations)
261
+ strategy.net_premium # -250.00 (debit) or 150.00 (credit)
262
+
263
+ # Strategy type identification
264
+ strategy.vertical_spread? # true if vertical
265
+ strategy.calendar_spread? # true if calendar
266
+ strategy.iron_condor? # true if iron condor
267
+ strategy.iron_butterfly? # true if iron butterfly
268
+ strategy.straddle? # true if straddle
269
+ strategy.strangle? # true if strangle
270
+
271
+ # Credit/Debit checks
272
+ strategy.credit_strategy? # true if net credit
273
+ strategy.debit_strategy? # true if net debit
274
+
275
+ # Display
276
+ strategy.to_display_string # "Iron Condor - SPY - Strikes: 420/425/435/440"
277
+ ```
278
+
279
+ ## Iteration and Enumeration
280
+
281
+ Resource objects support enumeration methods:
282
+
283
+ ```ruby
284
+ account = client.get_account("123456")
285
+
286
+ # Iterate over data
287
+ account.each do |key, value|
288
+ puts "#{key}: #{value}"
289
+ end
290
+
291
+ # Check if empty
292
+ account.empty? # false
293
+
294
+ # Array-like operations on collections
295
+ positions = account.positions
296
+ positions.map(&:symbol) # ["AAPL", "GOOGL", "MSFT"]
297
+ positions.select(&:profitable?) # Only profitable positions
298
+ positions.sum(&:market_value) # Total market value
299
+ ```
300
+
301
+ ## Type Safety and Nil Handling
302
+
303
+ Resource objects handle nil values gracefully:
304
+
305
+ ```ruby
306
+ account = client.get_account("123456")
307
+
308
+ # Safe navigation
309
+ account.current_balances&.cash_balance # Returns nil if no balances
310
+
311
+ # Type coercion preserves nil
312
+ account.closed_date # nil (if not closed)
313
+
314
+ # Helper methods return appropriate defaults
315
+ empty_account = Schwab::Resources::Account.new({})
316
+ empty_account.has_positions? # false (not nil)
317
+ empty_account.position_count # 0 (not nil)
318
+ empty_account.total_pnl # 0.0 (not nil)
319
+ ```
320
+
321
+ ## Converting Between Formats
322
+
323
+ ### Resource to Hash
324
+
325
+ ```ruby
326
+ # Get resource object
327
+ account = client.get_account("123456") # Resource object
328
+
329
+ # Convert to hash when needed
330
+ hash = account.to_h
331
+ JSON.generate(hash) # For JSON serialization
332
+ Redis.set("account", hash) # For caching
333
+ ```
334
+
335
+ ### Working with Both Formats
336
+
337
+ ```ruby
338
+ # You can always use hash access on resources
339
+ account = client.get_account("123456") # Resource object
340
+ account[:accountNumber] # Works!
341
+ account.account_number # Also works!
342
+
343
+ # Equality works with hashes
344
+ account == account.to_h # true
345
+ ```
346
+
347
+ ## Performance Considerations
348
+
349
+ ### When to Use Hash Mode
350
+
351
+ - High-volume data processing
352
+ - Simple data pass-through
353
+ - Minimal data manipulation
354
+ - Memory-constrained environments
355
+
356
+ ```ruby
357
+ # For bulk processing, consider hash mode
358
+ client = Schwab::Client.new(token, config: { response_format: :hash })
359
+ accounts = client.get_all_accounts # Array of hashes (faster)
360
+ ```
361
+
362
+ ### When to Use Resource Mode
363
+
364
+ - Interactive development (REPL)
365
+ - Complex business logic
366
+ - Rich domain models
367
+ - Better code readability
368
+
369
+ ```ruby
370
+ # For application logic, resource mode is cleaner
371
+ client = Schwab::Client.new(token, config: { response_format: :resource })
372
+ account = client.get_account("123456")
373
+ if account.margin_call? && account.equity < account.maintenance_requirement
374
+ # Handle margin call
375
+ end
376
+ ```
377
+
378
+ ## Testing with Resources
379
+
380
+ ```ruby
381
+ # Create test resources easily
382
+ test_account = Schwab::Resources::Account.new(
383
+ accountNumber: "TEST123",
384
+ type: "MARGIN",
385
+ currentBalances: {
386
+ cashBalance: 10000.00,
387
+ buyingPower: 20000.00
388
+ }
389
+ )
390
+
391
+ # Assertions work naturally
392
+ expect(test_account.margin_account?).to be true
393
+ expect(test_account.cash_balance).to eq(10000.00)
394
+
395
+ # Mock responses
396
+ allow(client).to receive(:get_account).and_return(
397
+ Schwab::Resources::Account.new(mock_data)
398
+ )
399
+ ```
400
+
401
+ ## Common Patterns
402
+
403
+ ### Filtering and Searching
404
+
405
+ ```ruby
406
+ # Find all profitable positions
407
+ profitable = account.positions.select(&:profitable?)
408
+
409
+ # Find positions by symbol
410
+ aapl_positions = account.positions.select { |p| p.symbol == "AAPL" }
411
+
412
+ # Sum values
413
+ total_equity_value = account.equity_positions.sum(&:market_value)
414
+ ```
415
+
416
+ ### Conditional Logic
417
+
418
+ ```ruby
419
+ # Clean conditional checks
420
+ if account.margin_account? && account.margin_call?
421
+ notify_margin_call(account)
422
+ end
423
+
424
+ # Position management
425
+ account.positions.each do |position|
426
+ if position.option? && position.expiration_date < Date.today + 7
427
+ puts "Option expiring soon: #{position.symbol}"
428
+ end
429
+ end
430
+ ```
431
+
432
+ ### Data Transformation
433
+
434
+ ```ruby
435
+ # Transform to custom format
436
+ position_summary = account.positions.map do |pos|
437
+ {
438
+ symbol: pos.symbol,
439
+ value: pos.market_value,
440
+ pnl: pos.unrealized_pnl,
441
+ pnl_percent: pos.unrealized_pnl_percentage
442
+ }
443
+ end
444
+
445
+ # Group by type
446
+ positions_by_type = account.positions.group_by(&:asset_type)
447
+ ```
448
+
449
+ ## Migration Guide
450
+
451
+ ### From Hash to Resource Mode
452
+
453
+ ```ruby
454
+ # Before (hash mode)
455
+ account = client.get_account("123456")
456
+ balance = account[:currentBalances][:cashBalance]
457
+ is_active = account[:status] == "ACTIVE"
458
+
459
+ # After (resource mode)
460
+ account = client.get_account("123456")
461
+ balance = account.cash_balance # Cleaner!
462
+ is_active = account.active? # More intuitive!
463
+ ```
464
+
465
+ ### Gradual Migration
466
+
467
+ You can migrate gradually by using both access patterns:
468
+
469
+ ```ruby
470
+ # During migration, both work
471
+ account = client.get_account("123456") # Resource mode
472
+ legacy_code(account.to_h) # Pass hash to legacy code
473
+ new_code(account) # Use resource in new code
474
+ ```
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Schwab
4
+ # Resolves plain text account numbers to their encrypted hash values
5
+ # required by the Schwab API for URL path parameters
6
+ class AccountNumberResolver
7
+ # Initialize a new resolver for the given client
8
+ #
9
+ # @param client [Schwab::Client] The client instance to use for API calls
10
+ def initialize(client)
11
+ @client = client
12
+ @mappings = {}
13
+ @loaded = false
14
+ @mutex = Mutex.new
15
+ end
16
+
17
+ # Resolve an account number to its encrypted hash value
18
+ #
19
+ # @param account_number [String] Plain account number or encrypted hash value
20
+ # @return [String] The encrypted hash value to use in API calls
21
+ # @raise [Error] If the account number is not found
22
+ # @example Resolve account number
23
+ # resolver.resolve("123456789") # => "ABC123XYZ"
24
+ # resolver.resolve("ABC123XYZ") # => "ABC123XYZ" (already encrypted)
25
+ def resolve(account_number)
26
+ return account_number if looks_like_hash?(account_number)
27
+
28
+ @mutex.synchronize do
29
+ load_mappings unless @loaded
30
+
31
+ hash_value = @mappings[account_number.to_s]
32
+ return hash_value if hash_value
33
+
34
+ # Try refreshing mappings in case this is a new account
35
+ refresh_mappings
36
+ @mappings[account_number.to_s] || raise_account_not_found(account_number)
37
+ end
38
+ end
39
+
40
+ # Refresh the account number mappings from the API
41
+ #
42
+ # @return [void]
43
+ # @example Refresh mappings
44
+ # resolver.refresh!
45
+ def refresh!
46
+ @mutex.synchronize do
47
+ refresh_mappings
48
+ end
49
+ end
50
+
51
+ # Get all account numbers and their hash values
52
+ #
53
+ # @return [Hash<String, String>] Plain account number => hash value mapping
54
+ # @example Get mappings
55
+ # resolver.mappings # => {"123456789" => "ABC123XYZ"}
56
+ def mappings
57
+ @mutex.synchronize do
58
+ load_mappings unless @loaded
59
+ @mappings.dup
60
+ end
61
+ end
62
+
63
+ # Check if the account number mappings are loaded
64
+ #
65
+ # @return [Boolean] True if mappings are loaded
66
+ def loaded?
67
+ @loaded
68
+ end
69
+
70
+ private
71
+
72
+ # Check if a string looks like an encrypted hash value
73
+ # Hash values are typically alphanumeric and longer than account numbers
74
+ #
75
+ # @param value [String] The value to check
76
+ # @return [Boolean] True if it looks like a hash value
77
+ def looks_like_hash?(value)
78
+ # Schwab hash values are typically alphanumeric strings
79
+ # that contain both letters and numbers (not purely numeric)
80
+ str = value.to_s
81
+ str.match?(/^[A-Za-z0-9]+$/) && str.match?(/[A-Za-z]/) && !str.match?(/^\d+$/)
82
+ end
83
+
84
+ # Load account number mappings from the API
85
+ def load_mappings
86
+ response = @client.get("/trader/v1/accounts/accountNumbers")
87
+
88
+ @mappings.clear
89
+ case response
90
+ when Array
91
+ response.each do |account|
92
+ plain_number = account[:accountNumber] || account["accountNumber"]
93
+ hash_value = account[:hashValue] || account["hashValue"]
94
+ @mappings[plain_number.to_s] = hash_value.to_s if plain_number && hash_value
95
+ end
96
+ when Hash
97
+ # Handle case where API returns wrapped response
98
+ accounts = response[:accounts] || response["accounts"]
99
+ if accounts.is_a?(Array)
100
+ accounts.each do |account|
101
+ plain_number = account[:accountNumber] || account["accountNumber"]
102
+ hash_value = account[:hashValue] || account["hashValue"]
103
+ @mappings[plain_number.to_s] = hash_value.to_s if plain_number && hash_value
104
+ end
105
+ end
106
+ end
107
+
108
+ @loaded = true
109
+ end
110
+
111
+ # Refresh mappings (clear cache and reload)
112
+ def refresh_mappings
113
+ @loaded = false
114
+ load_mappings
115
+ end
116
+
117
+ # Raise an error for account not found
118
+ def raise_account_not_found(account_number)
119
+ raise Error, "Account number '#{account_number}' not found. " \
120
+ "Available accounts: #{@mappings.keys.join(", ")}"
121
+ end
122
+ end
123
+ end