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,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "oauth2"
4
+ require "uri"
5
+ require "securerandom"
6
+
7
+ module Schwab
8
+ # OAuth 2.0 authentication helpers for Schwab API
9
+ module OAuth
10
+ class << self
11
+ # Generate the authorization URL for the OAuth 2.0 flow
12
+ #
13
+ # @param client_id [String] Your Schwab application's client ID
14
+ # @param redirect_uri [String] The redirect URI configured in your Schwab application
15
+ # @param state [String, nil] Optional state parameter for CSRF protection (will be generated if not provided)
16
+ # @param config [Configuration, nil] Optional configuration object (uses global config if not provided)
17
+ # @return [String] The authorization URL to redirect the user to
18
+ def authorization_url(client_id:, redirect_uri:, state: nil, config: nil)
19
+ config ||= Schwab.configuration || Configuration.new
20
+ state ||= SecureRandom.hex(16)
21
+
22
+ params = {
23
+ response_type: "code",
24
+ client_id: client_id,
25
+ redirect_uri: redirect_uri,
26
+ state: state,
27
+ }
28
+
29
+ uri = URI(config.oauth_authorize_url)
30
+ uri.query = URI.encode_www_form(params)
31
+ uri.to_s
32
+ end
33
+
34
+ # Exchange an authorization code for access and refresh tokens
35
+ #
36
+ # @param code [String] The authorization code from the OAuth callback
37
+ # @param client_id [String] Your Schwab application's client ID
38
+ # @param client_secret [String] Your Schwab application's client secret
39
+ # @param redirect_uri [String] The redirect URI used in the authorization request
40
+ # @param config [Configuration, nil] Optional configuration object (uses global config if not provided)
41
+ # @return [Hash] Token response with :access_token, :refresh_token, :expires_in, :expires_at
42
+ def get_token(code:, client_id:, client_secret:, redirect_uri:, config: nil)
43
+ config ||= Schwab.configuration || Configuration.new
44
+ client = oauth2_client(client_id: client_id, client_secret: client_secret, config: config)
45
+
46
+ token = client.auth_code.get_token(
47
+ code,
48
+ redirect_uri: redirect_uri,
49
+ headers: { "Content-Type" => "application/x-www-form-urlencoded" },
50
+ )
51
+
52
+ parse_token_response(token)
53
+ end
54
+
55
+ # Refresh an access token using a refresh token
56
+ #
57
+ # @param refresh_token [String] The refresh token
58
+ # @param client_id [String] Your Schwab application's client ID
59
+ # @param client_secret [String] Your Schwab application's client secret
60
+ # @param config [Configuration, nil] Optional configuration object (uses global config if not provided)
61
+ # @return [Hash] Token response with :access_token, :refresh_token, :expires_in, :expires_at
62
+ def refresh_token(refresh_token:, client_id:, client_secret:, config: nil)
63
+ config ||= Schwab.configuration || Configuration.new
64
+ client = oauth2_client(client_id: client_id, client_secret: client_secret, config: config)
65
+
66
+ token = OAuth2::AccessToken.new(client, nil, refresh_token: refresh_token)
67
+ new_token = token.refresh!
68
+
69
+ parse_token_response(new_token)
70
+ end
71
+
72
+ private
73
+
74
+ def oauth2_client(client_id:, client_secret:, config:)
75
+ OAuth2::Client.new(
76
+ client_id,
77
+ client_secret,
78
+ site: config.api_base_url,
79
+ authorize_url: config.oauth_authorize_url,
80
+ token_url: config.oauth_token_url,
81
+ )
82
+ end
83
+
84
+ def parse_token_response(token)
85
+ {
86
+ access_token: token.token,
87
+ refresh_token: token.refresh_token,
88
+ expires_in: token.expires_in,
89
+ expires_at: token.expires_at ? Time.at(token.expires_at) : nil,
90
+ token_type: token.params["token_type"] || "Bearer",
91
+ }
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,272 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module Schwab
6
+ module Resources
7
+ # Resource wrapper for account objects
8
+ # Provides account-specific helper methods and type coercions
9
+ class Account < Base
10
+ # Set up field type coercions for account fields
11
+ set_field_type :created_time, :datetime
12
+ set_field_type :opened_date, :date
13
+ set_field_type :closed_date, :date
14
+ set_field_type :last_updated, :datetime
15
+ set_field_type :day_trader, :boolean
16
+ set_field_type :closing_only_restricted, :boolean
17
+ set_field_type :pdt_flag, :boolean
18
+ set_field_type :round_trips, :integer
19
+
20
+ # Get the account number/ID (plain text)
21
+ #
22
+ # @return [String] The account number
23
+ def account_number
24
+ self[:accountNumber] || self[:account_number]
25
+ end
26
+ alias_method :id, :account_number
27
+
28
+ # Get the encrypted hash value for this account
29
+ #
30
+ # @return [String, nil] The encrypted hash value used in API calls
31
+ def hash_value
32
+ self[:hashValue] || self[:hash_value]
33
+ end
34
+ alias_method :encrypted_id, :hash_value
35
+
36
+ # Get the appropriate account identifier for API calls
37
+ # Returns hash_value if available, otherwise account_number
38
+ #
39
+ # @return [String] The account identifier to use in API calls
40
+ def api_identifier
41
+ hash_value || account_number
42
+ end
43
+
44
+ # Get the account type
45
+ #
46
+ # @return [String] The account type (e.g., "CASH", "MARGIN")
47
+ def account_type
48
+ self[:type] || self[:accountType] || self[:account_type]
49
+ end
50
+
51
+ # Check if this is a margin account
52
+ #
53
+ # @return [Boolean] True if margin account
54
+ def margin_account?
55
+ account_type == "MARGIN"
56
+ end
57
+
58
+ # Check if this is a cash account
59
+ #
60
+ # @return [Boolean] True if cash account
61
+ def cash_account?
62
+ account_type == "CASH"
63
+ end
64
+
65
+ # Get account status
66
+ #
67
+ # @return [String] The account status
68
+ def status
69
+ self[:status] || self[:accountStatus]
70
+ end
71
+
72
+ # Check if account is active
73
+ #
74
+ # @return [Boolean] True if account is active
75
+ def active?
76
+ status == "ACTIVE"
77
+ end
78
+
79
+ # Get the current balances
80
+ #
81
+ # @return [Schwab::Resources::Base] The current balances object
82
+ def current_balances
83
+ self[:currentBalances] || self[:current_balances]
84
+ end
85
+
86
+ # Get the initial balances
87
+ #
88
+ # @return [Schwab::Resources::Base] The initial balances object
89
+ def initial_balances
90
+ self[:initialBalances] || self[:initial_balances]
91
+ end
92
+
93
+ # Get projected balances
94
+ #
95
+ # @return [Schwab::Resources::Base] The projected balances object
96
+ def projected_balances
97
+ self[:projectedBalances] || self[:projected_balances]
98
+ end
99
+
100
+ # Get positions
101
+ #
102
+ # @return [Array<Schwab::Resources::Position>] Array of positions
103
+ def positions
104
+ positions_data = self[:positions] || []
105
+ positions_data.map do |position_data|
106
+ if position_data.is_a?(Position)
107
+ position_data
108
+ else
109
+ Position.new(position_data, client)
110
+ end
111
+ end
112
+ end
113
+
114
+ # Get account value (net liquidation value)
115
+ #
116
+ # @return [Float, nil] The total account value
117
+ def account_value
118
+ return unless current_balances
119
+
120
+ current_balances[:liquidationValue] ||
121
+ current_balances[:liquidation_value] ||
122
+ current_balances[:totalValue] ||
123
+ current_balances[:total_value]
124
+ end
125
+ alias_method :net_liquidation_value, :account_value
126
+ alias_method :total_value, :account_value
127
+
128
+ # Get cash balance
129
+ #
130
+ # @return [Float, nil] The cash balance
131
+ def cash_balance
132
+ return unless current_balances
133
+
134
+ current_balances[:cashBalance] ||
135
+ current_balances[:cash_balance] ||
136
+ current_balances[:availableFunds] ||
137
+ current_balances[:available_funds]
138
+ end
139
+
140
+ # Get buying power
141
+ #
142
+ # @return [Float, nil] The buying power
143
+ def buying_power
144
+ return unless current_balances
145
+
146
+ current_balances[:buyingPower] ||
147
+ current_balances[:buying_power] ||
148
+ current_balances[:availableFundsTrade] ||
149
+ current_balances[:available_funds_trade]
150
+ end
151
+
152
+ # Get day trading buying power
153
+ #
154
+ # @return [Float, nil] The day trading buying power
155
+ def day_trading_buying_power
156
+ return unless current_balances
157
+
158
+ current_balances[:dayTradingBuyingPower] ||
159
+ current_balances[:day_trading_buying_power]
160
+ end
161
+
162
+ # Get maintenance requirement
163
+ #
164
+ # @return [Float, nil] The maintenance requirement
165
+ def maintenance_requirement
166
+ return unless current_balances
167
+
168
+ current_balances[:maintenanceRequirement] ||
169
+ current_balances[:maintenance_requirement] ||
170
+ current_balances[:maintReq] ||
171
+ current_balances[:maint_req]
172
+ end
173
+
174
+ # Get margin balance if applicable
175
+ #
176
+ # @return [Float, nil] The margin balance
177
+ def margin_balance
178
+ return unless margin_account? && current_balances
179
+
180
+ current_balances[:marginBalance] ||
181
+ current_balances[:margin_balance]
182
+ end
183
+
184
+ # Check if account is in margin call
185
+ #
186
+ # @return [Boolean, nil] True if in margin call
187
+ def margin_call?
188
+ return false unless margin_account? && current_balances
189
+
190
+ in_call = current_balances[:isInCall] ||
191
+ current_balances[:is_in_call] ||
192
+ current_balances[:inCall] ||
193
+ current_balances[:in_call]
194
+
195
+ !!in_call
196
+ end
197
+
198
+ # Get account equity
199
+ #
200
+ # @return [Float, nil] The account equity
201
+ def equity
202
+ return unless current_balances
203
+
204
+ current_balances[:equity] ||
205
+ current_balances[:accountEquity] ||
206
+ current_balances[:account_equity]
207
+ end
208
+
209
+ # Calculate total P&L for all positions
210
+ #
211
+ # @return [Float] Total profit/loss
212
+ def total_pnl
213
+ positions.sum { |position| position.unrealized_pnl || 0 }
214
+ end
215
+
216
+ # Calculate today's P&L for all positions
217
+ #
218
+ # @return [Float] Today's profit/loss
219
+ def todays_pnl
220
+ positions.sum { |position| position.day_pnl || 0 }
221
+ end
222
+
223
+ # Get positions filtered by asset type
224
+ #
225
+ # @param asset_type [String, Symbol] The asset type (e.g., :equity, :option)
226
+ # @return [Array<Schwab::Resources::Position>] Filtered positions
227
+ def positions_by_type(asset_type)
228
+ type_str = asset_type.to_s.upcase
229
+ positions.select do |position|
230
+ position.asset_type == type_str ||
231
+ position[:assetType] == type_str ||
232
+ position[:asset_type] == type_str
233
+ end
234
+ end
235
+
236
+ # Get equity positions
237
+ #
238
+ # @return [Array<Schwab::Resources::Position>] Equity positions
239
+ def equity_positions
240
+ positions_by_type(:equity)
241
+ end
242
+
243
+ # Get option positions
244
+ #
245
+ # @return [Array<Schwab::Resources::Position>] Option positions
246
+ def option_positions
247
+ positions_by_type(:option)
248
+ end
249
+
250
+ # Calculate total market value of positions
251
+ #
252
+ # @return [Float] Total market value
253
+ def total_market_value
254
+ positions.sum { |position| position.market_value || 0 }
255
+ end
256
+
257
+ # Check if account has positions
258
+ #
259
+ # @return [Boolean] True if account has positions
260
+ def has_positions?
261
+ !positions.empty?
262
+ end
263
+
264
+ # Get number of positions
265
+ #
266
+ # @return [Integer] Number of positions
267
+ def position_count
268
+ positions.size
269
+ end
270
+ end
271
+ end
272
+ end
@@ -0,0 +1,300 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require "date"
5
+
6
+ module Schwab
7
+ # Resource objects for wrapping API responses with convenient access patterns
8
+ module Resources
9
+ # Base class for resource objects that wrap API response hashes
10
+ # Provides Sawyer::Resource-like functionality for method access to hash data
11
+ #
12
+ # @example Creating a resource
13
+ # data = { name: "John", age: 30, address: { city: "NYC" } }
14
+ # resource = Schwab::Resources::Base.new(data)
15
+ # resource.name # => "John"
16
+ # resource[:age] # => 30
17
+ # resource.address.city # => "NYC"
18
+ class Base
19
+ class << self
20
+ # Define fields that should be coerced to specific types
21
+ # Subclasses can override this to specify their field types
22
+ def field_types
23
+ @field_types ||= {}
24
+ end
25
+
26
+ # Set field types for automatic coercion
27
+ def set_field_type(field, type)
28
+ field_types[field.to_sym] = type
29
+ end
30
+ end
31
+
32
+ # Initialize a new resource with data
33
+ #
34
+ # @param data [Hash] The data to wrap
35
+ # @param client [Schwab::Client, nil] Optional client for API calls
36
+ def initialize(data = {}, client = nil)
37
+ @data = data || {}
38
+ @client = client
39
+ @_nested_resources = {}
40
+ end
41
+
42
+ # Access data via method calls
43
+ #
44
+ # @param method_name [Symbol] The method name
45
+ # @param args [Array] Method arguments
46
+ # @param block [Proc] Optional block
47
+ # @return [Object] The value from the data hash
48
+ def method_missing(method_name, *args, &block)
49
+ key = method_name.to_s
50
+
51
+ # Check for setter methods
52
+ if key.end_with?("=")
53
+ key = key[0...-1]
54
+ @data[key.to_sym] = args.first
55
+ @data[key] = args.first
56
+ return args.first
57
+ end
58
+
59
+ # Try symbol key first, then string key
60
+ if @data.key?(method_name)
61
+ wrap_value(@data[method_name], method_name)
62
+ elsif @data.key?(key)
63
+ wrap_value(@data[key], key.to_sym)
64
+ else
65
+ super
66
+ end
67
+ end
68
+
69
+ # Check if method exists
70
+ #
71
+ # @param method_name [Symbol] The method name
72
+ # @param include_private [Boolean] Include private methods
73
+ # @return [Boolean] True if method exists
74
+ def respond_to_missing?(method_name, include_private = false)
75
+ key = method_name.to_s
76
+ is_setter = key.end_with?("=")
77
+ key = key[0...-1] if is_setter
78
+
79
+ # Always respond to setters, or check if key exists for getters
80
+ is_setter || @data.key?(method_name) || @data.key?(key.to_sym) || @data.key?(key) || super
81
+ end
82
+
83
+ # Hash-style access to data
84
+ #
85
+ # @param key [Symbol, String] The key to access
86
+ # @return [Object] The value
87
+ def [](key)
88
+ value = @data[key.to_sym] || @data[key.to_s]
89
+ wrap_value(value, key.to_sym)
90
+ end
91
+
92
+ # Hash-style setter
93
+ #
94
+ # @param key [Symbol, String] The key to set
95
+ # @param value [Object] The value to set
96
+ def []=(key, value)
97
+ @data[key.to_sym] = value
98
+ @data[key.to_s] = value
99
+ end
100
+
101
+ # Check if key exists
102
+ #
103
+ # @param key [Symbol, String] The key to check
104
+ # @return [Boolean] True if key exists
105
+ def key?(key)
106
+ @data.key?(key.to_sym) || @data.key?(key.to_s)
107
+ end
108
+ alias_method :has_key?, :key?
109
+
110
+ # Get all keys
111
+ #
112
+ # @return [Array] Array of keys
113
+ def keys
114
+ @data.keys
115
+ end
116
+
117
+ # Convert to hash
118
+ #
119
+ # @return [Hash] The underlying data hash
120
+ def to_h
121
+ @data
122
+ end
123
+ alias_method :to_hash, :to_h
124
+
125
+ # Get attributes as hash
126
+ #
127
+ # @return [Hash] The underlying data hash
128
+ def attributes
129
+ @data
130
+ end
131
+
132
+ # Inspect the resource
133
+ #
134
+ # @return [String] String representation
135
+ def inspect
136
+ "#<#{self.class.name} #{@data.inspect}>"
137
+ end
138
+
139
+ # Convert to string
140
+ #
141
+ # @return [String] String representation
142
+ def to_s
143
+ @data.to_s
144
+ end
145
+
146
+ # Equality comparison
147
+ #
148
+ # @param other [Object] Object to compare
149
+ # @return [Boolean] True if equal
150
+ def ==(other)
151
+ case other
152
+ when self.class
153
+ @data == other.to_h
154
+ when Hash
155
+ @data == other
156
+ else
157
+ false
158
+ end
159
+ end
160
+
161
+ # Iterate over data
162
+ #
163
+ # @yield [key, value] Yields each key-value pair
164
+ def each(&block)
165
+ @data.each(&block)
166
+ end
167
+
168
+ # Check if resource has no data
169
+ #
170
+ # @return [Boolean] True if empty
171
+ def empty?
172
+ @data.empty?
173
+ end
174
+
175
+ protected
176
+
177
+ # Get the client instance
178
+ attr_reader :client
179
+
180
+ private
181
+
182
+ # Wrap nested hashes in resource objects and coerce types
183
+ #
184
+ # @param value [Object] The value to wrap
185
+ # @param field_name [Symbol, nil] The field name for type coercion
186
+ # @return [Object] The wrapped and coerced value
187
+ def wrap_value(value, field_name = nil)
188
+ # First apply type coercion if field type is defined
189
+ if field_name && self.class.field_types[field_name.to_sym]
190
+ value = coerce_value(value, self.class.field_types[field_name.to_sym])
191
+ end
192
+
193
+ case value
194
+ when Hash
195
+ # Cache nested resources to maintain object identity
196
+ @_nested_resources[value.object_id] ||= self.class.new(value, @client)
197
+ when Array
198
+ value.map { |item| wrap_value(item) }
199
+ else
200
+ value
201
+ end
202
+ end
203
+
204
+ # Coerce a value to a specific type
205
+ #
206
+ # @param value [Object] The value to coerce
207
+ # @param type [Symbol, Class] The target type
208
+ # @return [Object] The coerced value
209
+ def coerce_value(value, type)
210
+ return if value.nil?
211
+
212
+ case type
213
+ when :time, Time
214
+ coerce_to_time(value)
215
+ when :date, Date
216
+ coerce_to_date(value)
217
+ when :datetime, DateTime
218
+ coerce_to_datetime(value)
219
+ when :integer, Integer
220
+ value.to_i
221
+ when :float, Float
222
+ value.to_f
223
+ when :decimal, BigDecimal
224
+ require "bigdecimal"
225
+ BigDecimal(value.to_s)
226
+ when :boolean
227
+ coerce_to_boolean(value)
228
+ when :symbol, Symbol
229
+ value.to_sym
230
+ else
231
+ value
232
+ end
233
+ rescue StandardError
234
+ value # Return original value if coercion fails
235
+ end
236
+
237
+ # Coerce to Time
238
+ def coerce_to_time(value)
239
+ case value
240
+ when Time
241
+ value
242
+ when Date, DateTime
243
+ value.to_time
244
+ when String
245
+ Time.parse(value)
246
+ when Integer, Float
247
+ # Assume milliseconds timestamp if large number
248
+ value > 9999999999 ? Time.at(value / 1000.0) : Time.at(value)
249
+ else
250
+ value
251
+ end
252
+ end
253
+
254
+ # Coerce to Date
255
+ def coerce_to_date(value)
256
+ case value
257
+ when Date
258
+ value
259
+ when Time, DateTime
260
+ value.to_date
261
+ when String
262
+ Date.parse(value)
263
+ else
264
+ value
265
+ end
266
+ end
267
+
268
+ # Coerce to DateTime
269
+ def coerce_to_datetime(value)
270
+ case value
271
+ when DateTime, Time
272
+ value
273
+ when Date
274
+ value.to_time
275
+ when String
276
+ Time.parse(value)
277
+ when Integer, Float
278
+ # Assume milliseconds timestamp if large number
279
+ value > 9999999999 ? Time.at(value / 1000.0) : Time.at(value)
280
+ else
281
+ value
282
+ end
283
+ end
284
+
285
+ # Coerce to boolean
286
+ def coerce_to_boolean(value)
287
+ case value
288
+ when TrueClass, FalseClass
289
+ value
290
+ when String
291
+ value.downcase == "true" || value == "1"
292
+ when Integer
293
+ value == 1
294
+ else
295
+ !!value
296
+ end
297
+ end
298
+ end
299
+ end
300
+ end