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,318 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module Schwab
6
+ module Resources
7
+ # Resource wrapper for position objects
8
+ # Provides position-specific calculations and helper methods
9
+ class Position < Base
10
+ # Set up field type coercions for position fields
11
+ set_field_type :average_price, :float
12
+ set_field_type :current_day_cost, :float
13
+ set_field_type :current_day_profit_loss, :float
14
+ set_field_type :current_day_profit_loss_percentage, :float
15
+ set_field_type :long_quantity, :float
16
+ set_field_type :short_quantity, :float
17
+ set_field_type :settled_long_quantity, :float
18
+ set_field_type :settled_short_quantity, :float
19
+ set_field_type :market_value, :float
20
+ set_field_type :maintenance_requirement, :float
21
+ set_field_type :previous_session_long_quantity, :float
22
+ set_field_type :previous_session_short_quantity, :float
23
+
24
+ # Get the symbol
25
+ #
26
+ # @return [String] The position symbol
27
+ def symbol
28
+ if self[:instrument]
29
+ self[:instrument][:symbol] || self[:instrument][:underlying_symbol]
30
+ else
31
+ self[:symbol]
32
+ end
33
+ end
34
+
35
+ # Get the asset type
36
+ #
37
+ # @return [String] The asset type (e.g., "EQUITY", "OPTION")
38
+ def asset_type
39
+ if self[:instrument]
40
+ self[:instrument][:assetType] || self[:instrument][:asset_type]
41
+ else
42
+ self[:assetType] || self[:asset_type]
43
+ end
44
+ end
45
+
46
+ # Get the CUSIP
47
+ #
48
+ # @return [String] The CUSIP identifier
49
+ def cusip
50
+ if self[:instrument]
51
+ self[:instrument][:cusip]
52
+ else
53
+ self[:cusip]
54
+ end
55
+ end
56
+
57
+ # Get the quantity (net of long and short)
58
+ #
59
+ # @return [Float] The net quantity
60
+ def quantity
61
+ long_qty = (self[:longQuantity] || self[:long_quantity] || 0).to_f
62
+ short_qty = (self[:shortQuantity] || self[:short_quantity] || 0).to_f
63
+ long_qty - short_qty
64
+ end
65
+ alias_method :net_quantity, :quantity
66
+
67
+ # Get the average price (cost basis per share)
68
+ #
69
+ # @return [Float, nil] The average price
70
+ def average_price
71
+ self[:averagePrice] || self[:average_price]
72
+ end
73
+ alias_method :cost_basis_per_share, :average_price
74
+
75
+ # Get the current price
76
+ #
77
+ # @return [Float, nil] The current market price
78
+ def current_price
79
+ if self[:instrument] && self[:instrument][:lastPrice]
80
+ self[:instrument][:lastPrice]
81
+ elsif self[:quote]
82
+ self[:quote][:last] || self[:quote][:mark]
83
+ else
84
+ self[:currentPrice] || self[:current_price] || self[:lastPrice] || self[:last_price]
85
+ end
86
+ end
87
+ alias_method :market_price, :current_price
88
+ alias_method :last_price, :current_price
89
+
90
+ # Get the market value of the position
91
+ #
92
+ # @return [Float] The market value
93
+ def market_value
94
+ value = self[:marketValue] || self[:market_value]
95
+ return value.to_f if value
96
+
97
+ # Calculate if not provided
98
+ if current_price && quantity
99
+ (current_price * quantity.abs).round(2)
100
+ else
101
+ 0.0
102
+ end
103
+ end
104
+
105
+ # Calculate the total cost basis
106
+ #
107
+ # @return [Float] The total cost basis
108
+ def cost_basis
109
+ if average_price && quantity
110
+ (average_price * quantity.abs).round(2)
111
+ else
112
+ 0.0
113
+ end
114
+ end
115
+ alias_method :total_cost, :cost_basis
116
+
117
+ # Calculate unrealized P&L
118
+ #
119
+ # @return [Float] The unrealized profit/loss
120
+ def unrealized_pnl
121
+ market_value - cost_basis
122
+ end
123
+ alias_method :unrealized_profit_loss, :unrealized_pnl
124
+
125
+ # Calculate unrealized P&L percentage
126
+ #
127
+ # @return [Float, nil] The unrealized profit/loss percentage
128
+ def unrealized_pnl_percentage
129
+ return if cost_basis.zero?
130
+
131
+ ((unrealized_pnl / cost_basis) * 100).round(2)
132
+ end
133
+ alias_method :unrealized_profit_loss_percentage, :unrealized_pnl_percentage
134
+
135
+ # Get today's P&L
136
+ #
137
+ # @return [Float, nil] Today's profit/loss
138
+ def day_pnl
139
+ self[:currentDayProfitLoss] ||
140
+ self[:current_day_profit_loss] ||
141
+ self[:dayProfitLoss] ||
142
+ self[:day_profit_loss]
143
+ end
144
+ alias_method :todays_pnl, :day_pnl
145
+ alias_method :current_day_pnl, :day_pnl
146
+
147
+ # Get today's P&L percentage
148
+ #
149
+ # @return [Float, nil] Today's profit/loss percentage
150
+ def day_pnl_percentage
151
+ self[:currentDayProfitLossPercentage] ||
152
+ self[:current_day_profit_loss_percentage] ||
153
+ self[:dayProfitLossPercentage] ||
154
+ self[:day_profit_loss_percentage]
155
+ end
156
+ alias_method :todays_pnl_percentage, :day_pnl_percentage
157
+ alias_method :current_day_pnl_percentage, :day_pnl_percentage
158
+
159
+ # Check if this is a long position
160
+ #
161
+ # @return [Boolean] True if long position
162
+ def long?
163
+ quantity > 0
164
+ end
165
+
166
+ # Check if this is a short position
167
+ #
168
+ # @return [Boolean] True if short position
169
+ def short?
170
+ quantity < 0
171
+ end
172
+
173
+ # Check if this is an equity position
174
+ #
175
+ # @return [Boolean] True if equity
176
+ def equity?
177
+ asset_type == "EQUITY"
178
+ end
179
+
180
+ # Check if this is an option position
181
+ #
182
+ # @return [Boolean] True if option
183
+ def option?
184
+ asset_type == "OPTION"
185
+ end
186
+
187
+ # Check if this is a profitable position
188
+ #
189
+ # @return [Boolean] True if profitable
190
+ def profitable?
191
+ unrealized_pnl > 0
192
+ end
193
+
194
+ # Check if this is a losing position
195
+ #
196
+ # @return [Boolean] True if losing
197
+ def losing?
198
+ unrealized_pnl < 0
199
+ end
200
+
201
+ # Get maintenance requirement for this position
202
+ #
203
+ # @return [Float, nil] The maintenance requirement
204
+ def maintenance_requirement
205
+ self[:maintenanceRequirement] || self[:maintenance_requirement]
206
+ end
207
+
208
+ # Get the instrument details
209
+ #
210
+ # @return [Hash, nil] The instrument details
211
+ def instrument
212
+ self[:instrument]
213
+ end
214
+
215
+ # Get option details if this is an option position
216
+ #
217
+ # @return [Hash, nil] Option details including strike, expiration, etc.
218
+ def option_details
219
+ return unless option? && instrument
220
+
221
+ {
222
+ underlying_symbol: instrument[:underlyingSymbol] || instrument[:underlying_symbol],
223
+ strike_price: instrument[:strikePrice] || instrument[:strike_price],
224
+ expiration_date: instrument[:expirationDate] || instrument[:expiration_date],
225
+ option_type: instrument[:putCall] || instrument[:put_call] || instrument[:optionType] || instrument[:option_type],
226
+ contract_size: instrument[:contractSize] || instrument[:contract_size] || 100,
227
+ }
228
+ end
229
+
230
+ # Get the underlying symbol for options
231
+ #
232
+ # @return [String, nil] The underlying symbol
233
+ def underlying_symbol
234
+ return unless option?
235
+
236
+ option_details[:underlying_symbol] if option_details
237
+ end
238
+
239
+ # Get the strike price for options
240
+ #
241
+ # @return [Float, nil] The strike price
242
+ def strike_price
243
+ return unless option?
244
+
245
+ option_details[:strike_price] if option_details
246
+ end
247
+
248
+ # Get the expiration date for options
249
+ #
250
+ # @return [String, nil] The expiration date
251
+ def expiration_date
252
+ return unless option?
253
+
254
+ option_details[:expiration_date] if option_details
255
+ end
256
+
257
+ # Check if this is a call option
258
+ #
259
+ # @return [Boolean, nil] True if call option
260
+ def call?
261
+ return false unless option?
262
+
263
+ details = option_details
264
+ return false unless details
265
+
266
+ details[:option_type] == "CALL" || details[:option_type] == "call"
267
+ end
268
+
269
+ # Check if this is a put option
270
+ #
271
+ # @return [Boolean, nil] True if put option
272
+ def put?
273
+ return false unless option?
274
+
275
+ details = option_details
276
+ return false unless details
277
+
278
+ details[:option_type] == "PUT" || details[:option_type] == "put"
279
+ end
280
+
281
+ # Calculate the value per contract for options
282
+ #
283
+ # @return [Float, nil] Value per contract
284
+ def value_per_contract
285
+ return unless option? && market_value && quantity != 0
286
+
287
+ contracts = quantity.abs
288
+ market_value / contracts
289
+ end
290
+
291
+ # Calculate cost per contract for options
292
+ #
293
+ # @return [Float, nil] Cost per contract
294
+ def cost_per_contract
295
+ return unless option? && cost_basis != 0 && quantity != 0
296
+
297
+ contracts = quantity.abs
298
+ cost_basis / contracts
299
+ end
300
+
301
+ # Get formatted display string for the position
302
+ #
303
+ # @return [String] Formatted position string
304
+ def to_display_string
305
+ if option?
306
+ details = option_details
307
+ if details
308
+ "#{quantity.to_i} #{details[:underlying_symbol]} #{details[:strike_price]} #{details[:option_type]} #{details[:expiration_date]}"
309
+ else
310
+ "#{quantity.to_i} #{symbol}"
311
+ end
312
+ else
313
+ "#{quantity} shares of #{symbol}"
314
+ end
315
+ end
316
+ end
317
+ end
318
+ end
@@ -0,0 +1,410 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module Schwab
6
+ module Resources
7
+ # Resource wrapper for strategy objects (complex multi-leg orders)
8
+ # Provides strategy-specific helper methods for options strategies
9
+ class Strategy < Base
10
+ # Set up field type coercions for strategy fields
11
+ set_field_type :entered_time, :datetime
12
+ set_field_type :close_time, :datetime
13
+ set_field_type :filled_quantity, :float
14
+ set_field_type :remaining_quantity, :float
15
+ set_field_type :quantity, :float
16
+
17
+ # Get strategy type
18
+ #
19
+ # @return [String] The strategy type
20
+ def strategy_type
21
+ self[:strategyType] || self[:strategy_type] || self[:type]
22
+ end
23
+ alias_method :type, :strategy_type
24
+
25
+ # Get the strategy name/description
26
+ #
27
+ # @return [String] The strategy name
28
+ def name
29
+ self[:strategyName] || self[:strategy_name] || self[:name] || strategy_type
30
+ end
31
+
32
+ # Get strategy status
33
+ #
34
+ # @return [String] The strategy status
35
+ def status
36
+ self[:status] || self[:strategyStatus] || self[:strategy_status]
37
+ end
38
+
39
+ # Get the legs of the strategy
40
+ #
41
+ # @return [Array] Array of strategy legs
42
+ def legs
43
+ self[:legs] || self[:strategyLegs] || self[:strategy_legs] || self[:orderLegCollection] || []
44
+ end
45
+
46
+ # Get number of legs
47
+ #
48
+ # @return [Integer] Number of legs in strategy
49
+ def leg_count
50
+ legs.size
51
+ end
52
+
53
+ # Check if this is a single-leg strategy
54
+ #
55
+ # @return [Boolean] True if single leg
56
+ def single_leg?
57
+ leg_count == 1
58
+ end
59
+
60
+ # Check if this is a multi-leg strategy
61
+ #
62
+ # @return [Boolean] True if multi-leg
63
+ def multi_leg?
64
+ leg_count > 1
65
+ end
66
+
67
+ # Get the underlying symbol
68
+ #
69
+ # @return [String, nil] The underlying symbol
70
+ def underlying_symbol
71
+ # All legs should have the same underlying for a valid strategy
72
+ first_leg = legs.first
73
+ return unless first_leg
74
+
75
+ if first_leg[:instrument]
76
+ first_leg[:instrument][:underlyingSymbol] ||
77
+ first_leg[:instrument][:underlying_symbol] ||
78
+ first_leg[:instrument][:symbol]
79
+ end
80
+ end
81
+
82
+ # Get all strike prices in the strategy
83
+ #
84
+ # @return [Array<Float>] Array of strike prices
85
+ def strike_prices
86
+ legs.map do |leg|
87
+ if leg[:instrument]
88
+ leg[:instrument][:strikePrice] || leg[:instrument][:strike_price]
89
+ end
90
+ end.compact.uniq.sort
91
+ end
92
+
93
+ # Get all expiration dates in the strategy
94
+ #
95
+ # @return [Array<String>] Array of expiration dates
96
+ def expiration_dates
97
+ legs.map do |leg|
98
+ if leg[:instrument]
99
+ leg[:instrument][:expirationDate] || leg[:instrument][:expiration_date]
100
+ end
101
+ end.compact.uniq.sort
102
+ end
103
+
104
+ # Get net credit/debit
105
+ #
106
+ # @return [Float, nil] Net credit (positive) or debit (negative)
107
+ def net_premium
108
+ total = 0.0
109
+ legs.each do |leg|
110
+ price = leg[:price] || 0
111
+ quantity = leg[:quantity] || 0
112
+ instruction = leg[:instruction]
113
+
114
+ # Selling generates credit, buying generates debit
115
+ if ["SELL", "SELL_TO_OPEN", "SELL_TO_CLOSE"].include?(instruction&.upcase)
116
+ total += price * quantity * 100 # Options are in contracts of 100
117
+ else
118
+ total -= price * quantity * 100
119
+ end
120
+ end
121
+ total
122
+ end
123
+
124
+ # Check if this is a credit strategy
125
+ #
126
+ # @return [Boolean] True if net credit
127
+ def credit_strategy?
128
+ net_premium > 0
129
+ end
130
+
131
+ # Check if this is a debit strategy
132
+ #
133
+ # @return [Boolean] True if net debit
134
+ def debit_strategy?
135
+ net_premium < 0
136
+ end
137
+
138
+ # Strategy type identification methods
139
+
140
+ # Check if this is a vertical spread
141
+ #
142
+ # @return [Boolean] True if vertical spread
143
+ def vertical_spread?
144
+ return false unless leg_count == 2
145
+
146
+ expirations = expiration_dates
147
+ strikes = strike_prices
148
+
149
+ # Same expiration, different strikes
150
+ expirations.size == 1 && strikes.size == 2
151
+ end
152
+ alias_method :vertical?, :vertical_spread?
153
+
154
+ # Check if this is a calendar spread
155
+ #
156
+ # @return [Boolean] True if calendar spread
157
+ def calendar_spread?
158
+ return false unless leg_count == 2
159
+
160
+ expirations = expiration_dates
161
+ strikes = strike_prices
162
+
163
+ # Different expirations, same strike
164
+ expirations.size == 2 && strikes.size == 1
165
+ end
166
+ alias_method :calendar?, :calendar_spread?
167
+ alias_method :horizontal?, :calendar_spread?
168
+
169
+ # Check if this is a diagonal spread
170
+ #
171
+ # @return [Boolean] True if diagonal spread
172
+ def diagonal_spread?
173
+ return false unless leg_count == 2
174
+
175
+ expirations = expiration_dates
176
+ strikes = strike_prices
177
+
178
+ # Different expirations and strikes
179
+ expirations.size == 2 && strikes.size == 2
180
+ end
181
+ alias_method :diagonal?, :diagonal_spread?
182
+
183
+ # Check if this is a butterfly spread
184
+ #
185
+ # @return [Boolean] True if butterfly
186
+ def butterfly?
187
+ return false unless leg_count == 4
188
+
189
+ strikes = strike_prices
190
+ # Butterfly has 3 unique strikes
191
+ strikes.size == 3
192
+ end
193
+
194
+ # Check if this is an iron butterfly
195
+ #
196
+ # @return [Boolean] True if iron butterfly
197
+ def iron_butterfly?
198
+ return false unless butterfly?
199
+
200
+ # Iron butterfly uses both puts and calls
201
+ has_puts = legs.any? { |leg| leg_is_put?(leg) }
202
+ has_calls = legs.any? { |leg| leg_is_call?(leg) }
203
+
204
+ has_puts && has_calls
205
+ end
206
+
207
+ # Check if this is a condor
208
+ #
209
+ # @return [Boolean] True if condor
210
+ def condor?
211
+ return false unless leg_count == 4
212
+
213
+ strikes = strike_prices
214
+ # Condor has 4 unique strikes
215
+ strikes.size == 4
216
+ end
217
+
218
+ # Check if this is an iron condor
219
+ #
220
+ # @return [Boolean] True if iron condor
221
+ def iron_condor?
222
+ return false unless condor?
223
+
224
+ # Iron condor uses both puts and calls
225
+ has_puts = legs.any? { |leg| leg_is_put?(leg) }
226
+ has_calls = legs.any? { |leg| leg_is_call?(leg) }
227
+
228
+ has_puts && has_calls
229
+ end
230
+
231
+ # Check if this is a straddle
232
+ #
233
+ # @return [Boolean] True if straddle
234
+ def straddle?
235
+ return false unless leg_count == 2
236
+
237
+ strikes = strike_prices
238
+ expirations = expiration_dates
239
+
240
+ # Same strike and expiration, one put and one call
241
+ if strikes.size == 1 && expirations.size == 1
242
+ has_put = legs.any? { |leg| leg_is_put?(leg) }
243
+ has_call = legs.any? { |leg| leg_is_call?(leg) }
244
+ has_put && has_call
245
+ else
246
+ false
247
+ end
248
+ end
249
+
250
+ # Check if this is a strangle
251
+ #
252
+ # @return [Boolean] True if strangle
253
+ def strangle?
254
+ return false unless leg_count == 2
255
+
256
+ strikes = strike_prices
257
+ expirations = expiration_dates
258
+
259
+ # Different strikes, same expiration, one put and one call
260
+ if strikes.size == 2 && expirations.size == 1
261
+ has_put = legs.any? { |leg| leg_is_put?(leg) }
262
+ has_call = legs.any? { |leg| leg_is_call?(leg) }
263
+ has_put && has_call
264
+ else
265
+ false
266
+ end
267
+ end
268
+
269
+ # Check if this is a collar
270
+ #
271
+ # @return [Boolean] True if collar
272
+ def collar?
273
+ return false unless leg_count == 3 || leg_count == 2
274
+
275
+ # Collar: long stock + long put + short call
276
+ # Or just long put + short call (if stock is held separately)
277
+
278
+ has_long_put = legs.any? { |leg| leg_is_put?(leg) && leg_is_long?(leg) }
279
+ has_short_call = legs.any? { |leg| leg_is_call?(leg) && leg_is_short?(leg) }
280
+
281
+ has_long_put && has_short_call
282
+ end
283
+
284
+ # Check if this is a ratio spread
285
+ #
286
+ # @return [Boolean] True if ratio spread
287
+ def ratio_spread?
288
+ return false unless multi_leg?
289
+
290
+ # Ratio spread has unequal quantities
291
+ quantities = legs.map { |leg| leg[:quantity].to_i.abs }.uniq
292
+ quantities.size > 1
293
+ end
294
+
295
+ # Get max profit for the strategy
296
+ #
297
+ # @return [Float, nil] Maximum profit potential
298
+ def max_profit
299
+ # This would require complex calculations based on strategy type
300
+ # Placeholder for strategy-specific calculations
301
+ nil
302
+ end
303
+
304
+ # Get max loss for the strategy
305
+ #
306
+ # @return [Float, nil] Maximum loss potential
307
+ def max_loss
308
+ # This would require complex calculations based on strategy type
309
+ # Placeholder for strategy-specific calculations
310
+ nil
311
+ end
312
+
313
+ # Get breakeven points
314
+ #
315
+ # @return [Array<Float>] Breakeven points
316
+ def breakeven_points
317
+ # This would require complex calculations based on strategy type
318
+ # Placeholder for strategy-specific calculations
319
+ []
320
+ end
321
+
322
+ # Get strategy description
323
+ #
324
+ # @return [String] Human-readable strategy description
325
+ def description
326
+ if vertical_spread?
327
+ "Vertical Spread"
328
+ elsif calendar_spread?
329
+ "Calendar Spread"
330
+ elsif diagonal_spread?
331
+ "Diagonal Spread"
332
+ elsif iron_butterfly?
333
+ "Iron Butterfly"
334
+ elsif butterfly?
335
+ "Butterfly Spread"
336
+ elsif iron_condor?
337
+ "Iron Condor"
338
+ elsif condor?
339
+ "Condor Spread"
340
+ elsif straddle?
341
+ "Straddle"
342
+ elsif strangle?
343
+ "Strangle"
344
+ elsif collar?
345
+ "Collar"
346
+ elsif ratio_spread?
347
+ "Ratio Spread"
348
+ elsif multi_leg?
349
+ "Multi-leg Strategy"
350
+ else
351
+ "Single Option"
352
+ end
353
+ end
354
+
355
+ # Get formatted display string for the strategy
356
+ #
357
+ # @return [String] Formatted strategy string
358
+ def to_display_string
359
+ parts = []
360
+ parts << description
361
+ parts << underlying_symbol if underlying_symbol
362
+
363
+ if strike_prices.any?
364
+ parts << "Strikes: #{strike_prices.join("/")}"
365
+ end
366
+
367
+ if expiration_dates.any?
368
+ parts << "Exp: #{expiration_dates.first}"
369
+ end
370
+
371
+ premium = net_premium
372
+ if premium != 0
373
+ parts << (premium > 0 ? "Credit: $#{premium.abs}" : "Debit: $#{premium.abs}")
374
+ end
375
+
376
+ parts.compact.join(" - ")
377
+ end
378
+
379
+ private
380
+
381
+ # Check if a leg is a put option
382
+ def leg_is_put?(leg)
383
+ return false unless leg[:instrument]
384
+
385
+ put_call = leg[:instrument][:putCall] || leg[:instrument][:put_call]
386
+ put_call&.upcase == "PUT"
387
+ end
388
+
389
+ # Check if a leg is a call option
390
+ def leg_is_call?(leg)
391
+ return false unless leg[:instrument]
392
+
393
+ put_call = leg[:instrument][:putCall] || leg[:instrument][:put_call]
394
+ put_call&.upcase == "CALL"
395
+ end
396
+
397
+ # Check if a leg is long (buy)
398
+ def leg_is_long?(leg)
399
+ instruction = leg[:instruction]
400
+ ["BUY", "BUY_TO_OPEN", "BUY_TO_CLOSE"].include?(instruction&.upcase)
401
+ end
402
+
403
+ # Check if a leg is short (sell)
404
+ def leg_is_short?(leg)
405
+ instruction = leg[:instruction]
406
+ ["SELL", "SELL_TO_OPEN", "SELL_TO_CLOSE"].include?(instruction&.upcase)
407
+ end
408
+ end
409
+ end
410
+ end