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,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
|