tastytrade 0.2.0 → 0.3.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.
Files changed (60) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/commands/plan.md +13 -0
  3. data/.claude/commands/release-pr.md +12 -0
  4. data/CHANGELOG.md +170 -0
  5. data/README.md +424 -3
  6. data/ROADMAP.md +17 -17
  7. data/lib/tastytrade/cli/history_formatter.rb +304 -0
  8. data/lib/tastytrade/cli/orders.rb +749 -0
  9. data/lib/tastytrade/cli/positions_formatter.rb +114 -0
  10. data/lib/tastytrade/cli.rb +701 -12
  11. data/lib/tastytrade/cli_helpers.rb +111 -14
  12. data/lib/tastytrade/client.rb +7 -0
  13. data/lib/tastytrade/file_store.rb +83 -0
  14. data/lib/tastytrade/instruments/equity.rb +42 -0
  15. data/lib/tastytrade/models/account.rb +160 -2
  16. data/lib/tastytrade/models/account_balance.rb +46 -0
  17. data/lib/tastytrade/models/buying_power_effect.rb +61 -0
  18. data/lib/tastytrade/models/live_order.rb +272 -0
  19. data/lib/tastytrade/models/order_response.rb +106 -0
  20. data/lib/tastytrade/models/order_status.rb +84 -0
  21. data/lib/tastytrade/models/trading_status.rb +200 -0
  22. data/lib/tastytrade/models/transaction.rb +151 -0
  23. data/lib/tastytrade/models.rb +6 -0
  24. data/lib/tastytrade/order.rb +191 -0
  25. data/lib/tastytrade/order_validator.rb +355 -0
  26. data/lib/tastytrade/session.rb +26 -1
  27. data/lib/tastytrade/session_manager.rb +43 -14
  28. data/lib/tastytrade/version.rb +1 -1
  29. data/lib/tastytrade.rb +43 -0
  30. data/spec/exe/tastytrade_spec.rb +1 -1
  31. data/spec/spec_helper.rb +72 -0
  32. data/spec/tastytrade/cli/positions_spec.rb +267 -0
  33. data/spec/tastytrade/cli_auth_spec.rb +5 -0
  34. data/spec/tastytrade/cli_env_login_spec.rb +199 -0
  35. data/spec/tastytrade/cli_helpers_spec.rb +3 -26
  36. data/spec/tastytrade/cli_orders_spec.rb +168 -0
  37. data/spec/tastytrade/cli_status_spec.rb +153 -164
  38. data/spec/tastytrade/file_store_spec.rb +126 -0
  39. data/spec/tastytrade/models/account_balance_spec.rb +103 -0
  40. data/spec/tastytrade/models/account_order_history_spec.rb +229 -0
  41. data/spec/tastytrade/models/account_order_management_spec.rb +271 -0
  42. data/spec/tastytrade/models/account_place_order_spec.rb +125 -0
  43. data/spec/tastytrade/models/account_spec.rb +86 -15
  44. data/spec/tastytrade/models/buying_power_effect_spec.rb +250 -0
  45. data/spec/tastytrade/models/live_order_json_spec.rb +144 -0
  46. data/spec/tastytrade/models/live_order_spec.rb +295 -0
  47. data/spec/tastytrade/models/order_response_spec.rb +96 -0
  48. data/spec/tastytrade/models/order_status_spec.rb +113 -0
  49. data/spec/tastytrade/models/trading_status_spec.rb +260 -0
  50. data/spec/tastytrade/models/transaction_spec.rb +236 -0
  51. data/spec/tastytrade/order_edge_cases_spec.rb +163 -0
  52. data/spec/tastytrade/order_spec.rb +201 -0
  53. data/spec/tastytrade/order_validator_spec.rb +347 -0
  54. data/spec/tastytrade/session_env_spec.rb +169 -0
  55. data/spec/tastytrade/session_manager_spec.rb +43 -33
  56. data/vcr_implementation_plan.md +403 -0
  57. data/vcr_implementation_research.md +330 -0
  58. metadata +50 -18
  59. data/lib/tastytrade/keyring_store.rb +0 -72
  60. data/spec/tastytrade/keyring_store_spec.rb +0 -168
@@ -106,7 +106,9 @@ module Tastytrade
106
106
 
107
107
  @current_account = Tastytrade::Models::Account.get(current_session, account_number)
108
108
  rescue StandardError => e
109
- warn "Failed to load current account: #{e.message}"
109
+ # Only warn if debug mode is enabled - otherwise silently return nil
110
+ # and let the caller handle the fallback
111
+ warn "Failed to load current account: #{e.message}" if ENV["DEBUG_SESSION"]
110
112
  nil
111
113
  end
112
114
 
@@ -115,6 +117,106 @@ module Tastytrade
115
117
  config.get("current_account_number")
116
118
  end
117
119
 
120
+ # Format buying power status based on usage percentage
121
+ def format_bp_status(usage_percentage)
122
+ return pastel.dim("N/A") unless usage_percentage
123
+
124
+ case usage_percentage.to_f
125
+ when 0..50
126
+ pastel.green("Low")
127
+ when 50..80
128
+ pastel.yellow("Moderate")
129
+ when 80..90
130
+ pastel.bright_yellow("High")
131
+ else
132
+ pastel.red("Critical")
133
+ end
134
+ end
135
+
136
+ # Display trading status information
137
+ def display_trading_status(status)
138
+ puts "\n#{pastel.bold("Trading Status for Account:")} #{status.account_number}"
139
+ puts pastel.dim("─" * 60)
140
+
141
+ # Display restrictions first if any
142
+ restrictions = status.active_restrictions
143
+ if restrictions.any?
144
+ puts "\n#{pastel.bold.red("⚠ Account Restrictions:")}"
145
+ restrictions.each do |restriction|
146
+ case restriction
147
+ when "Margin Call", "Day Trade Equity Maintenance Call"
148
+ puts " #{pastel.red("• #{restriction}")}"
149
+ when "Pattern Day Trader"
150
+ puts " #{pastel.yellow("• #{restriction}")}"
151
+ when "Account Closed", "Account Frozen"
152
+ puts " #{pastel.bright_red("• #{restriction}")}"
153
+ else
154
+ puts " #{pastel.yellow("• #{restriction}")}"
155
+ end
156
+ end
157
+ else
158
+ puts "\n#{pastel.green("✓ No account restrictions")}"
159
+ end
160
+
161
+ # Display trading permissions
162
+ puts "\n#{pastel.bold("Trading Permissions:")}"
163
+ permissions = status.permissions_summary
164
+
165
+ # Options trading
166
+ options_status = permissions[:options]
167
+ options_color = options_status == "Disabled" ? :dim : :green
168
+ puts " Options Trading: #{pastel.send(options_color, options_status)}"
169
+
170
+ # Short calls
171
+ short_calls_status = permissions[:short_calls]
172
+ short_calls_color = short_calls_status == "Enabled" ? :green : :dim
173
+ puts " Short Calls: #{pastel.send(short_calls_color, short_calls_status)}"
174
+
175
+ # Futures trading
176
+ futures_status = permissions[:futures]
177
+ futures_color = case futures_status
178
+ when "Enabled" then :green
179
+ when "Closing Only" then :yellow
180
+ else :dim
181
+ end
182
+ puts " Futures Trading: #{pastel.send(futures_color, futures_status)}"
183
+
184
+ # Cryptocurrency trading
185
+ crypto_status = permissions[:cryptocurrency]
186
+ crypto_color = case crypto_status
187
+ when "Enabled" then :green
188
+ when "Closing Only" then :yellow
189
+ else :dim
190
+ end
191
+ puts " Cryptocurrency: #{pastel.send(crypto_color, crypto_status)}"
192
+
193
+ # Portfolio margin
194
+ pm_status = permissions[:portfolio_margin]
195
+ pm_color = pm_status == "Enabled" ? :green : :dim
196
+ puts " Portfolio Margin: #{pastel.send(pm_color, pm_status)}"
197
+
198
+ # Display account characteristics
199
+ puts "\n#{pastel.bold("Account Characteristics:")}"
200
+ pdt_status = permissions[:pattern_day_trader] == "Yes" ? pastel.yellow("Yes") : pastel.dim("No")
201
+ puts " Pattern Day Trader: #{pdt_status}"
202
+
203
+ if status.day_trade_count && status.day_trade_count > 0
204
+ puts " Day Trade Count: #{pastel.cyan(status.day_trade_count.to_s)}"
205
+ end
206
+
207
+ if status.pdt_reset_on
208
+ puts " PDT Reset Date: #{pastel.yellow(status.pdt_reset_on.strftime("%Y-%m-%d"))}"
209
+ end
210
+
211
+ # Display additional info
212
+ puts "\n#{pastel.bold("Additional Information:")}"
213
+ puts " Fee Schedule: #{pastel.cyan(status.fee_schedule_name)}"
214
+ puts " Margin Type: #{pastel.cyan(status.equities_margin_calculation_type)}"
215
+ puts " Last Updated: #{pastel.dim(status.updated_at.strftime("%Y-%m-%d %H:%M:%S %Z"))}"
216
+
217
+ puts ""
218
+ end
219
+
118
220
  private
119
221
 
120
222
  def load_session
@@ -143,6 +245,12 @@ module Tastytrade
143
245
  session.instance_variable_set(:@session_expiration, Time.parse(session_data[:session_expiration]))
144
246
  end
145
247
 
248
+ # Set user data if available
249
+ if session_data[:user_data]
250
+ user = Tastytrade::Models::User.new(session_data[:user_data])
251
+ session.instance_variable_set(:@user, user)
252
+ end
253
+
146
254
  # Check if session needs refresh
147
255
  if session.expired? && session.remember_token
148
256
  info "Session expired, refreshing automatically..."
@@ -154,19 +262,8 @@ module Tastytrade
154
262
  return nil
155
263
  end
156
264
 
157
- # Final validation
158
- if session.validate
159
- session
160
- else
161
- # Try one more refresh if we have a remember token
162
- if session.remember_token
163
- session.refresh_session
164
- manager.save_session(session) if session.validate
165
- session
166
- else
167
- nil
168
- end
169
- end
265
+ # Return the session - validation happens on actual API calls
266
+ session
170
267
  rescue Tastytrade::SessionExpiredError, Tastytrade::AuthenticationError => e
171
268
  warning "Session invalid: #{e.message}"
172
269
  nil
@@ -108,6 +108,13 @@ module Tastytrade
108
108
  return response.status.to_s if response.body.nil? || response.body.empty?
109
109
 
110
110
  data = parse_json(response.body)
111
+
112
+ # Handle preflight check failures with detailed errors
113
+ if data["code"] == "preflight_check_failure" && data["errors"]
114
+ error_details = data["errors"].map { |e| e["message"] }.join(", ")
115
+ return "#{data["message"]}: #{error_details}"
116
+ end
117
+
111
118
  # Handle both old and new API error formats
112
119
  data["error"] || data["message"] || data["reason"] || response.status.to_s
113
120
  rescue StandardError
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+
6
+ module Tastytrade
7
+ # Secure file-based credential storage
8
+ module FileStore
9
+ class << self
10
+ # Store a credential in a file
11
+ #
12
+ # @param key [String] The credential key
13
+ # @param value [String] The credential value
14
+ # @return [Boolean] Success status
15
+ def set(key, value)
16
+ return false if key.nil? || value.nil?
17
+
18
+ ensure_storage_directory
19
+ File.write(credential_path(key), value.to_s, mode: "w", perm: 0o600)
20
+ true
21
+ rescue StandardError => e
22
+ warn "Failed to store credential: #{e.message}" if ENV["DEBUG_SESSION"]
23
+ false
24
+ end
25
+
26
+ # Retrieve a credential from a file
27
+ #
28
+ # @param key [String] The credential key
29
+ # @return [String, nil] The credential value or nil if not found
30
+ def get(key)
31
+ return nil if key.nil?
32
+
33
+ path = credential_path(key)
34
+ return nil unless File.exist?(path)
35
+
36
+ File.read(path).strip
37
+ rescue StandardError => e
38
+ warn "Failed to retrieve credential: #{e.message}" if ENV["DEBUG_SESSION"]
39
+ nil
40
+ end
41
+
42
+ # Delete a credential file
43
+ #
44
+ # @param key [String] The credential key
45
+ # @return [Boolean] Success status
46
+ def delete(key)
47
+ return false if key.nil?
48
+
49
+ path = credential_path(key)
50
+ return true unless File.exist?(path)
51
+
52
+ File.delete(path)
53
+ true
54
+ rescue StandardError => e
55
+ warn "Failed to delete credential: #{e.message}" if ENV["DEBUG_SESSION"]
56
+ false
57
+ end
58
+
59
+ # Check if file storage is available
60
+ #
61
+ # @return [Boolean] Always true for file storage
62
+ def available?
63
+ true
64
+ end
65
+
66
+ private
67
+
68
+ def storage_directory
69
+ @storage_directory ||= File.expand_path("~/.config/tastytrade/credentials")
70
+ end
71
+
72
+ def ensure_storage_directory
73
+ FileUtils.mkdir_p(storage_directory, mode: 0o700)
74
+ end
75
+
76
+ def credential_path(key)
77
+ # Sanitize key to be filesystem-safe
78
+ safe_key = key.to_s.gsub(/[^a-zA-Z0-9._@-]/, "_")
79
+ File.join(storage_directory, "#{safe_key}.cred")
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tastytrade
4
+ module Instruments
5
+ # Represents an equity instrument
6
+ class Equity
7
+ attr_reader :symbol, :description, :exchange, :cusip, :active
8
+
9
+ def initialize(data = {})
10
+ @symbol = data["symbol"]
11
+ @description = data["description"]
12
+ @exchange = data["exchange"]
13
+ @cusip = data["cusip"]
14
+ @active = data["active"]
15
+ end
16
+
17
+ # Get equity information for a symbol
18
+ #
19
+ # @param session [Tastytrade::Session] Active session
20
+ # @param symbol [String] Equity symbol
21
+ # @return [Equity] Equity instrument
22
+ def self.get(session, symbol)
23
+ response = session.get("/instruments/equities/#{symbol}")
24
+ new(response["data"])
25
+ end
26
+
27
+ # Create an order leg for this equity
28
+ #
29
+ # @param action [String] Order action (from OrderAction module)
30
+ # @param quantity [Integer] Number of shares
31
+ # @return [OrderLeg] Order leg for this equity
32
+ def build_leg(action:, quantity:)
33
+ OrderLeg.new(
34
+ action: action,
35
+ symbol: @symbol,
36
+ quantity: quantity,
37
+ instrument_type: "Equity"
38
+ )
39
+ end
40
+ end
41
+ end
42
+ end
@@ -63,9 +63,147 @@ module Tastytrade
63
63
  # Get trading status
64
64
  #
65
65
  # @param session [Tastytrade::Session] Active session
66
- # @return [Hash] Trading status data
66
+ # @return [Tastytrade::Models::TradingStatus] Trading status object
67
67
  def get_trading_status(session)
68
- session.get("/accounts/#{account_number}/trading-status/")["data"]
68
+ response = session.get("/accounts/#{account_number}/trading-status/")
69
+ TradingStatus.new(response["data"])
70
+ end
71
+
72
+ # Places an order for this account with comprehensive validation.
73
+ # By default, performs full validation including symbol checks, quantity limits,
74
+ # price validation, account permissions, and buying power verification.
75
+ #
76
+ # @param session [Tastytrade::Session] Active session
77
+ # @param order [Tastytrade::Order] Order to place
78
+ # @param dry_run [Boolean] Whether to simulate the order without placing it
79
+ # @param skip_validation [Boolean] Skip pre-submission validation (use with caution)
80
+ # @return [OrderResponse] Response from order placement with order ID and status
81
+ # @raise [OrderValidationError] if validation fails with detailed error messages
82
+ # @raise [InsufficientFundsError] if account lacks buying power
83
+ # @raise [MarketClosedError] if market is closed
84
+ #
85
+ # @example Place an order with validation
86
+ # response = account.place_order(session, order)
87
+ # puts response.order_id
88
+ #
89
+ # @example Dry-run to check buying power
90
+ # response = account.place_order(session, order, dry_run: true)
91
+ # puts response.buying_power_effect
92
+ #
93
+ # @example Skip validation when certain order is valid
94
+ # response = account.place_order(session, order, skip_validation: true)
95
+ def place_order(session, order, dry_run: false, skip_validation: false)
96
+ # Validate the order unless explicitly skipped or it's a dry-run
97
+ unless skip_validation || dry_run
98
+ validator = OrderValidator.new(session, self, order)
99
+ validator.validate!
100
+ end
101
+
102
+ endpoint = "/accounts/#{account_number}/orders"
103
+ endpoint += "/dry-run" if dry_run
104
+
105
+ response = session.post(endpoint, order.to_api_params)
106
+ OrderResponse.new(response["data"])
107
+ end
108
+
109
+ # Get transaction history
110
+ #
111
+ # @param session [Tastytrade::Session] Active session
112
+ # @param options [Hash] Optional filters
113
+ # @option options [Date, String] :start_date Start date for transactions
114
+ # @option options [Date, String] :end_date End date for transactions
115
+ # @option options [String] :symbol Filter by symbol
116
+ # @option options [String] :underlying_symbol Filter by underlying symbol
117
+ # @option options [String] :instrument_type Filter by instrument type
118
+ # @option options [Array<String>] :transaction_types Filter by transaction types
119
+ # @option options [Integer] :per_page Number of results per page (default: 250)
120
+ # @option options [Integer] :page_offset Page offset for pagination
121
+ # @return [Array<Transaction>] Array of transactions
122
+ def get_transactions(session, **options)
123
+ Transaction.get_all(session, account_number, **options)
124
+ end
125
+
126
+ # Get live orders (open and orders from last 24 hours)
127
+ #
128
+ # @param session [Tastytrade::Session] Active session
129
+ # @param status [String, nil] Filter by order status
130
+ # @param underlying_symbol [String, nil] Filter by underlying symbol
131
+ # @param from_time [Time, nil] Start time for order history
132
+ # @param to_time [Time, nil] End time for order history
133
+ # @return [Array<LiveOrder>] Array of live orders
134
+ def get_live_orders(session, status: nil, underlying_symbol: nil, from_time: nil, to_time: nil)
135
+ params = {}
136
+ params["status"] = status if status && OrderStatus.valid?(status)
137
+ params["underlying-symbol"] = underlying_symbol if underlying_symbol
138
+ params["from-time"] = from_time.iso8601 if from_time
139
+ params["to-time"] = to_time.iso8601 if to_time
140
+
141
+ response = session.get("/accounts/#{account_number}/orders/live/", params)
142
+ response["data"]["items"].map { |item| LiveOrder.new(item) }
143
+ end
144
+
145
+ # Get order history for this account (beyond 24 hours)
146
+ #
147
+ # @param session [Tastytrade::Session] Active session
148
+ # @param status [String, nil] Filter by order status
149
+ # @param underlying_symbol [String, nil] Filter by underlying symbol
150
+ # @param from_time [Time, nil] Start time for order history
151
+ # @param to_time [Time, nil] End time for order history
152
+ # @param page_offset [Integer, nil] Pagination offset
153
+ # @param page_limit [Integer, nil] Number of results per page (default 250, max 1000)
154
+ # @return [Array<LiveOrder>] Array of historical orders
155
+ def get_order_history(session, status: nil, underlying_symbol: nil, from_time: nil, to_time: nil,
156
+ page_offset: nil, page_limit: nil)
157
+ params = {}
158
+ params["status"] = status if status && OrderStatus.valid?(status)
159
+ params["underlying-symbol"] = underlying_symbol if underlying_symbol
160
+ params["from-time"] = from_time.iso8601 if from_time
161
+ params["to-time"] = to_time.iso8601 if to_time
162
+ params["page-offset"] = page_offset if page_offset
163
+ params["page-limit"] = page_limit if page_limit
164
+
165
+ response = session.get("/accounts/#{account_number}/orders/", params)
166
+ response["data"]["items"].map { |item| LiveOrder.new(item) }
167
+ end
168
+
169
+ # Get a specific order by ID
170
+ #
171
+ # @param session [Tastytrade::Session] Active session
172
+ # @param order_id [String] Order ID to retrieve
173
+ # @return [LiveOrder] The requested order
174
+ def get_order(session, order_id)
175
+ response = session.get("/accounts/#{account_number}/orders/#{order_id}/")
176
+ LiveOrder.new(response["data"])
177
+ end
178
+
179
+ # Cancel an order
180
+ #
181
+ # @param session [Tastytrade::Session] Active session
182
+ # @param order_id [String] Order ID to cancel
183
+ # @return [void]
184
+ # @raise [OrderNotCancellableError] if order cannot be cancelled
185
+ # @raise [OrderAlreadyFilledError] if order has already been filled
186
+ def cancel_order(session, order_id)
187
+ session.delete("/accounts/#{account_number}/orders/#{order_id}/")
188
+ nil
189
+ rescue Tastytrade::Error => e
190
+ handle_cancel_error(e)
191
+ end
192
+
193
+ # Replace an existing order
194
+ #
195
+ # @param session [Tastytrade::Session] Active session
196
+ # @param order_id [String] Order ID to replace
197
+ # @param new_order [Tastytrade::Order] New order to replace with
198
+ # @return [OrderResponse] Response from order replacement
199
+ # @raise [OrderNotEditableError] if order cannot be edited
200
+ # @raise [InsufficientQuantityError] if trying to replace more than remaining quantity
201
+ def replace_order(session, order_id, new_order)
202
+ response = session.put("/accounts/#{account_number}/orders/#{order_id}/",
203
+ new_order.to_api_params)
204
+ OrderResponse.new(response["data"])
205
+ rescue Tastytrade::Error => e
206
+ handle_replace_error(e)
69
207
  end
70
208
 
71
209
  def closed?
@@ -86,6 +224,26 @@ module Tastytrade
86
224
 
87
225
  private
88
226
 
227
+ def handle_cancel_error(error)
228
+ if error.message.include?("already filled") || error.message.include?("Filled")
229
+ raise OrderAlreadyFilledError, "Order has already been filled and cannot be cancelled"
230
+ elsif error.message.include?("not cancellable") || error.message.include?("Cannot cancel")
231
+ raise OrderNotCancellableError, "Order is not in a cancellable state"
232
+ else
233
+ raise error
234
+ end
235
+ end
236
+
237
+ def handle_replace_error(error)
238
+ if error.message.include?("not editable") || error.message.include?("Cannot edit")
239
+ raise OrderNotEditableError, "Order is not in an editable state"
240
+ elsif error.message.include?("insufficient quantity") || error.message.include?("exceeds remaining")
241
+ raise InsufficientQuantityError, "Cannot replace order with quantity exceeding remaining amount"
242
+ else
243
+ raise error
244
+ end
245
+ end
246
+
89
247
  def parse_attributes
90
248
  parse_basic_attributes
91
249
  parse_status_attributes
@@ -63,6 +63,52 @@ module Tastytrade
63
63
  total_equity_value + total_derivative_value
64
64
  end
65
65
 
66
+ # Calculate derivative buying power usage percentage
67
+ def derivative_buying_power_usage_percentage
68
+ return BigDecimal("0") if derivative_buying_power.zero?
69
+
70
+ used_derivative_buying_power = derivative_buying_power - available_trading_funds
71
+ ((used_derivative_buying_power / derivative_buying_power) * 100).round(2)
72
+ end
73
+
74
+ # Calculate day trading buying power usage percentage
75
+ def day_trading_buying_power_usage_percentage
76
+ return BigDecimal("0") if day_trading_buying_power.zero?
77
+
78
+ used_day_trading_buying_power = day_trading_buying_power - available_trading_funds
79
+ ((used_day_trading_buying_power / day_trading_buying_power) * 100).round(2)
80
+ end
81
+
82
+ # Get the minimum buying power across all types
83
+ def minimum_buying_power
84
+ [equity_buying_power, derivative_buying_power, day_trading_buying_power].min
85
+ end
86
+
87
+ # Check if account has sufficient buying power for a given amount
88
+ def sufficient_buying_power?(amount, buying_power_type: :equity)
89
+ bp = case buying_power_type
90
+ when :equity then equity_buying_power
91
+ when :derivative then derivative_buying_power
92
+ when :day_trading then day_trading_buying_power
93
+ else equity_buying_power
94
+ end
95
+
96
+ bp >= BigDecimal(amount.to_s)
97
+ end
98
+
99
+ # Calculate buying power impact as percentage
100
+ def buying_power_impact_percentage(amount, buying_power_type: :equity)
101
+ bp = case buying_power_type
102
+ when :equity then equity_buying_power
103
+ when :derivative then derivative_buying_power
104
+ when :day_trading then day_trading_buying_power
105
+ else equity_buying_power
106
+ end
107
+
108
+ return BigDecimal("0") if bp.zero?
109
+ ((BigDecimal(amount.to_s) / bp) * 100).round(2)
110
+ end
111
+
66
112
  private
67
113
 
68
114
  # Parse string value to BigDecimal, handling nil and empty strings
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bigdecimal"
4
+
5
+ module Tastytrade
6
+ module Models
7
+ # Represents the buying power effect from a dry-run order or order placement
8
+ class BuyingPowerEffect < Base
9
+ attr_reader :change_in_margin_requirement, :change_in_buying_power,
10
+ :current_buying_power, :new_buying_power,
11
+ :isolated_order_margin_requirement, :is_spread,
12
+ :impact, :effect
13
+
14
+ # Calculate the buying power usage percentage for this order
15
+ def buying_power_usage_percentage
16
+ return BigDecimal("0") if current_buying_power.nil? || current_buying_power.zero?
17
+
18
+ impact_amount = impact || change_in_buying_power&.abs || BigDecimal("0")
19
+ ((impact_amount / current_buying_power) * 100).round(2)
20
+ end
21
+
22
+ # Check if this order would use more than the specified percentage of buying power
23
+ def exceeds_threshold?(threshold_percentage)
24
+ buying_power_usage_percentage > BigDecimal(threshold_percentage.to_s)
25
+ end
26
+
27
+ # Get the absolute value of the buying power change
28
+ def buying_power_change_amount
29
+ change_in_buying_power&.abs || impact&.abs || BigDecimal("0")
30
+ end
31
+
32
+ # Check if this is a debit (reduces buying power)
33
+ def debit?
34
+ effect == "Debit" || (change_in_buying_power && change_in_buying_power < 0)
35
+ end
36
+
37
+ # Check if this is a credit (increases buying power)
38
+ def credit?
39
+ effect == "Credit" || (change_in_buying_power && change_in_buying_power > 0)
40
+ end
41
+
42
+ private
43
+
44
+ def parse_attributes
45
+ @change_in_margin_requirement = parse_decimal(@data["change-in-margin-requirement"])
46
+ @change_in_buying_power = parse_decimal(@data["change-in-buying-power"])
47
+ @current_buying_power = parse_decimal(@data["current-buying-power"])
48
+ @new_buying_power = parse_decimal(@data["new-buying-power"])
49
+ @isolated_order_margin_requirement = parse_decimal(@data["isolated-order-margin-requirement"])
50
+ @is_spread = @data["is-spread"]
51
+ @impact = parse_decimal(@data["impact"])
52
+ @effect = @data["effect"]
53
+ end
54
+
55
+ def parse_decimal(value)
56
+ return nil if value.nil? || value.to_s.empty?
57
+ BigDecimal(value.to_s)
58
+ end
59
+ end
60
+ end
61
+ end