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
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require "time"
5
+ require "bigdecimal"
6
+
7
+ module Tastytrade
8
+ module Models
9
+ # Represents a transaction in a Tastytrade account
10
+ class Transaction < Base
11
+ attr_reader :id, :account_number, :symbol, :instrument_type, :underlying_symbol,
12
+ :transaction_type, :transaction_sub_type, :description, :action,
13
+ :quantity, :price, :executed_at, :transaction_date, :value,
14
+ :value_effect, :net_value, :net_value_effect, :is_estimated_fee,
15
+ :commission, :clearing_fees, :regulatory_fees, :proprietary_index_option_fees,
16
+ :order_id, :value_date, :reverses_id, :is_verified
17
+
18
+ TRANSACTION_TYPES = %w[
19
+ ACAT Assignment Balance\ Adjustment Cash\ Disbursement Cash\ Merger
20
+ Cash\ Settled\ Assignment Cash\ Settled\ Exercise Credit\ Interest
21
+ Debit\ Interest Deposit Dividend Exercise Expiration Fee Forward\ Split
22
+ Futures\ Settlement Journal\ Entry Mark\ to\ Market Maturity
23
+ Merger\ Acquisition Money\ Movement Name\ Change
24
+ Paid\ Premium\ Lending\ Income Receive\ Deliver Reverse\ Split
25
+ Special\ Dividend Stock\ Dividend Stock\ Loan\ Income Stock\ Merger
26
+ Symbol\ Change Transfer Withdrawal
27
+ ].freeze
28
+
29
+ INSTRUMENT_TYPES = %w[
30
+ Bond Cryptocurrency Equity Equity\ Offering Equity\ Option Future
31
+ Future\ Option Index Unknown Warrant
32
+ ].freeze
33
+
34
+ # Fetch transaction history for an account
35
+ # @param session [Tastytrade::Session] Active session
36
+ # @param account_number [String] Account number
37
+ # @param options [Hash] Optional filters
38
+ # @option options [Date, String] :start_date Start date for transactions
39
+ # @option options [Date, String] :end_date End date for transactions
40
+ # @option options [String] :symbol Filter by symbol
41
+ # @option options [String] :underlying_symbol Filter by underlying symbol
42
+ # @option options [String] :instrument_type Filter by instrument type
43
+ # @option options [Array<String>] :transaction_types Filter by transaction types
44
+ # @option options [Integer] :per_page Number of results per page (default: 250)
45
+ # @option options [Integer] :page_offset Page offset for pagination
46
+ # @return [Array<Transaction>] Array of transactions
47
+ def self.get_all(session, account_number, **options)
48
+ params = build_params(options)
49
+ transactions = []
50
+ page_offset = options[:page_offset] || 0
51
+
52
+ loop do
53
+ current_params = params.dup
54
+ current_params["page-offset"] = page_offset unless page_offset.zero?
55
+ response = session.get("/accounts/#{account_number}/transactions", current_params)
56
+
57
+ items = response.dig("data", "items") || []
58
+ break if items.empty?
59
+
60
+ transactions.concat(items.map { |item| new(item) })
61
+
62
+ # Break if we've reached the requested limit or if pagination is manual
63
+ break if options[:page_offset] || transactions.size >= (options[:per_page] || 250)
64
+
65
+ page_offset += 1
66
+ end
67
+
68
+ transactions
69
+ end
70
+
71
+ private
72
+
73
+ def parse_attributes
74
+ parse_data(@data)
75
+ end
76
+
77
+ def self.build_params(options)
78
+ {}.tap do |params|
79
+ params["start-date"] = format_date(options[:start_date]) if options[:start_date]
80
+ params["end-date"] = format_date(options[:end_date]) if options[:end_date]
81
+ params["symbol"] = options[:symbol] if options[:symbol]
82
+ params["underlying-symbol"] = options[:underlying_symbol] if options[:underlying_symbol]
83
+ params["instrument-type"] = options[:instrument_type] if options[:instrument_type]
84
+ params["type[]"] = Array(options[:transaction_types]) if options[:transaction_types]
85
+ params["per-page"] = options[:per_page] if options[:per_page]
86
+ end
87
+ end
88
+
89
+ def self.format_date(date)
90
+ case date
91
+ when String
92
+ date
93
+ when Date, DateTime, Time
94
+ date.strftime("%Y-%m-%d")
95
+ else
96
+ date.to_s
97
+ end
98
+ end
99
+
100
+ def parse_data(data)
101
+ @id = data["id"]
102
+ @account_number = data["account-number"]
103
+ @symbol = data["symbol"]
104
+ @instrument_type = data["instrument-type"]
105
+ @underlying_symbol = data["underlying-symbol"]
106
+ @transaction_type = data["transaction-type"]
107
+ @transaction_sub_type = data["transaction-sub-type"]
108
+ @description = data["description"]
109
+ @action = data["action"]
110
+ @quantity = parse_decimal(data["quantity"])
111
+ @price = parse_decimal(data["price"])
112
+ @executed_at = parse_datetime(data["executed-at"])
113
+ @transaction_date = parse_date(data["transaction-date"])
114
+ @value = parse_decimal(data["value"])
115
+ @value_effect = data["value-effect"]
116
+ @net_value = parse_decimal(data["net-value"])
117
+ @net_value_effect = data["net-value-effect"]
118
+ @is_estimated_fee = data["is-estimated-fee"]
119
+ @commission = parse_decimal(data["commission"])
120
+ @clearing_fees = parse_decimal(data["clearing-fees"])
121
+ @regulatory_fees = parse_decimal(data["regulatory-fees"])
122
+ @proprietary_index_option_fees = parse_decimal(data["proprietary-index-option-fees"])
123
+ @order_id = data["order-id"]
124
+ @value_date = parse_date(data["value-date"])
125
+ @reverses_id = data["reverses-id"]
126
+ @is_verified = data["is-verified"]
127
+ end
128
+
129
+ def parse_decimal(value)
130
+ return nil if value.nil? || value.to_s.empty?
131
+ BigDecimal(value.to_s)
132
+ rescue ArgumentError
133
+ nil
134
+ end
135
+
136
+ def parse_datetime(value)
137
+ return nil if value.nil? || value.to_s.empty?
138
+ Time.parse(value.to_s)
139
+ rescue ArgumentError
140
+ nil
141
+ end
142
+
143
+ def parse_date(value)
144
+ return nil if value.nil? || value.to_s.empty?
145
+ Date.parse(value.to_s)
146
+ rescue ArgumentError
147
+ nil
148
+ end
149
+ end
150
+ end
151
+ end
@@ -5,3 +5,9 @@ require_relative "models/user"
5
5
  require_relative "models/account"
6
6
  require_relative "models/account_balance"
7
7
  require_relative "models/current_position"
8
+ require_relative "models/order_response"
9
+ require_relative "models/live_order"
10
+ require_relative "models/order_status"
11
+ require_relative "models/transaction"
12
+ require_relative "models/buying_power_effect"
13
+ require_relative "models/trading_status"
@@ -0,0 +1,191 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bigdecimal"
4
+
5
+ module Tastytrade
6
+ # Order action constants
7
+ module OrderAction
8
+ BUY_TO_OPEN = "Buy to Open"
9
+ SELL_TO_CLOSE = "Sell to Close"
10
+ SELL_TO_OPEN = "Sell to Open"
11
+ BUY_TO_CLOSE = "Buy to Close"
12
+ end
13
+
14
+ # Order type constants
15
+ module OrderType
16
+ MARKET = "Market"
17
+ LIMIT = "Limit"
18
+ STOP = "Stop"
19
+ end
20
+
21
+ # Order time in force constants
22
+ module OrderTimeInForce
23
+ DAY = "Day"
24
+ GTC = "GTC"
25
+ end
26
+
27
+ # Price effect constants
28
+ module PriceEffect
29
+ DEBIT = "Debit"
30
+ CREDIT = "Credit"
31
+ end
32
+
33
+ # Represents a single leg of an order
34
+ class OrderLeg
35
+ attr_reader :action, :symbol, :quantity, :instrument_type
36
+
37
+ def initialize(action:, symbol:, quantity:, instrument_type: "Equity")
38
+ validate_action!(action)
39
+
40
+ @action = action
41
+ @symbol = symbol
42
+ @quantity = quantity.to_i
43
+ @instrument_type = instrument_type
44
+ end
45
+
46
+ def to_api_params
47
+ {
48
+ "action" => @action,
49
+ "symbol" => @symbol,
50
+ "quantity" => @quantity,
51
+ "instrument-type" => @instrument_type
52
+ }
53
+ end
54
+
55
+ private
56
+
57
+ def validate_action!(action)
58
+ valid_actions = [
59
+ OrderAction::BUY_TO_OPEN,
60
+ OrderAction::SELL_TO_CLOSE,
61
+ OrderAction::SELL_TO_OPEN,
62
+ OrderAction::BUY_TO_CLOSE
63
+ ]
64
+
65
+ unless valid_actions.include?(action)
66
+ raise ArgumentError, "Invalid action: #{action}. Must be one of: #{valid_actions.join(", ")}"
67
+ end
68
+ end
69
+ end
70
+
71
+ # Represents an order to be placed
72
+ class Order
73
+ attr_reader :type, :time_in_force, :legs, :price
74
+
75
+ def initialize(type:, time_in_force: OrderTimeInForce::DAY, legs:, price: nil)
76
+ validate_type!(type)
77
+ validate_time_in_force!(time_in_force)
78
+ validate_price!(type, price)
79
+
80
+ @type = type
81
+ @time_in_force = time_in_force
82
+ @legs = Array(legs)
83
+ @price = price ? BigDecimal(price.to_s) : nil
84
+ end
85
+
86
+ def market?
87
+ @type == OrderType::MARKET
88
+ end
89
+
90
+ def limit?
91
+ @type == OrderType::LIMIT
92
+ end
93
+
94
+ def stop?
95
+ @type == OrderType::STOP
96
+ end
97
+
98
+ # Validates this order for a specific account using the OrderValidator.
99
+ # Performs comprehensive checks including symbol existence, quantity constraints,
100
+ # price validation, account permissions, and optionally buying power.
101
+ #
102
+ # @param session [Tastytrade::Session] Active session
103
+ # @param account [Tastytrade::Models::Account] Account to validate against
104
+ # @param skip_dry_run [Boolean] Skip dry-run validation for performance
105
+ # @return [Boolean] true if valid
106
+ # @raise [OrderValidationError] if validation fails with detailed error messages
107
+ #
108
+ # @example
109
+ # order.validate!(session, account) # Full validation including dry-run
110
+ # order.validate!(session, account, skip_dry_run: true) # Skip buying power check
111
+ def validate!(session, account, skip_dry_run: false)
112
+ validator = OrderValidator.new(session, account, self)
113
+ validator.validate!(skip_dry_run: skip_dry_run)
114
+ end
115
+
116
+ # Performs a dry-run validation of the order. This submits the order to the API
117
+ # in dry-run mode to check buying power, margin requirements, and get warnings
118
+ # without actually placing the order.
119
+ #
120
+ # @param session [Tastytrade::Session] Active session
121
+ # @param account [Tastytrade::Models::Account] Account to validate against
122
+ # @return [Tastytrade::Models::OrderResponse] Dry-run response with buying power effect
123
+ #
124
+ # @example
125
+ # response = order.dry_run(session, account)
126
+ # puts response.buying_power_effect
127
+ # puts response.warnings
128
+ def dry_run(session, account)
129
+ account.place_order(session, self, dry_run: true)
130
+ end
131
+
132
+ def to_api_params
133
+ params = {
134
+ "order-type" => @type,
135
+ "time-in-force" => @time_in_force,
136
+ "legs" => @legs.map(&:to_api_params)
137
+ }
138
+
139
+ # Add price for limit orders
140
+ # API expects string representation without negative sign
141
+ if limit? && @price
142
+ params["price"] = @price.to_s("F")
143
+ params["price-effect"] = determine_price_effect
144
+ end
145
+
146
+ params
147
+ end
148
+
149
+ private
150
+
151
+ def determine_price_effect
152
+ # Determine price effect based on the first leg's action
153
+ # Buy actions result in debit, sell actions result in credit
154
+ first_leg = @legs.first
155
+ return PriceEffect::DEBIT unless first_leg
156
+
157
+ case first_leg.action
158
+ when OrderAction::BUY_TO_OPEN, OrderAction::BUY_TO_CLOSE
159
+ PriceEffect::DEBIT
160
+ when OrderAction::SELL_TO_OPEN, OrderAction::SELL_TO_CLOSE
161
+ PriceEffect::CREDIT
162
+ else
163
+ PriceEffect::DEBIT # Default to debit
164
+ end
165
+ end
166
+
167
+ def validate_type!(type)
168
+ valid_types = [OrderType::MARKET, OrderType::LIMIT, OrderType::STOP]
169
+ unless valid_types.include?(type)
170
+ raise ArgumentError, "Invalid order type: #{type}. Must be one of: #{valid_types.join(", ")}"
171
+ end
172
+ end
173
+
174
+ def validate_time_in_force!(time_in_force)
175
+ valid_tifs = [OrderTimeInForce::DAY, OrderTimeInForce::GTC]
176
+ unless valid_tifs.include?(time_in_force)
177
+ raise ArgumentError, "Invalid time in force: #{time_in_force}. Must be one of: #{valid_tifs.join(", ")}"
178
+ end
179
+ end
180
+
181
+ def validate_price!(type, price)
182
+ if type == OrderType::LIMIT && price.nil?
183
+ raise ArgumentError, "Price is required for limit orders"
184
+ end
185
+
186
+ if price && price.to_f <= 0
187
+ raise ArgumentError, "Price must be greater than 0"
188
+ end
189
+ end
190
+ end
191
+ end
@@ -0,0 +1,355 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bigdecimal"
4
+ require "time"
5
+
6
+ module Tastytrade
7
+ # Validates orders before submission to ensure they meet all requirements.
8
+ # Performs comprehensive checks including symbol validation, quantity constraints,
9
+ # price validation, account permissions, buying power, and market hours.
10
+ #
11
+ # @example Basic usage
12
+ # validator = OrderValidator.new(session, account, order)
13
+ # validator.validate! # Raises OrderValidationError if invalid
14
+ #
15
+ # @example With dry-run validation
16
+ # validator = OrderValidator.new(session, account, order)
17
+ # response = validator.dry_run_validate!
18
+ # puts validator.warnings if validator.warnings.any?
19
+ class OrderValidator
20
+ # @return [Array<String>] List of validation errors
21
+ attr_reader :errors
22
+
23
+ # @return [Array<String>] List of validation warnings
24
+ attr_reader :warnings
25
+
26
+ # Common tick sizes for different price ranges
27
+ TICK_SIZES = {
28
+ penny: BigDecimal("0.01"),
29
+ nickel: BigDecimal("0.05"),
30
+ dime: BigDecimal("0.10")
31
+ }.freeze
32
+
33
+ # Minimum quantity constraints
34
+ MIN_QUANTITY = 1
35
+ MAX_QUANTITY = 999_999
36
+
37
+ # Creates a new OrderValidator instance
38
+ #
39
+ # @param session [Tastytrade::Session] Active trading session
40
+ # @param account [Tastytrade::Models::Account] Account to validate against
41
+ # @param order [Tastytrade::Order] Order to validate
42
+ def initialize(session, account, order)
43
+ @session = session
44
+ @account = account
45
+ @order = order
46
+ @errors = []
47
+ @warnings = []
48
+ @trading_status = nil
49
+ @dry_run_response = nil
50
+ end
51
+
52
+ # Performs comprehensive order validation including structure, symbols,
53
+ # quantities, prices, account permissions, market hours, and optionally
54
+ # buying power via dry-run.
55
+ #
56
+ # @param skip_dry_run [Boolean] Skip the dry-run validation (for performance)
57
+ # @return [Boolean] true if validation passes
58
+ # @raise [OrderValidationError] if validation fails with detailed error messages
59
+ def validate!(skip_dry_run: false)
60
+ # Reset errors and warnings
61
+ @errors = []
62
+ @warnings = []
63
+
64
+ # Run all validations
65
+ validate_order_structure!
66
+ validate_symbols!
67
+ validate_quantities!
68
+ validate_prices!
69
+ validate_account_permissions!
70
+ validate_market_hours!
71
+ validate_buying_power! unless skip_dry_run
72
+
73
+ # Raise error if any validation failed
74
+ raise OrderValidationError, @errors if @errors.any?
75
+
76
+ true
77
+ end
78
+
79
+ # Performs pre-flight validation via dry-run API call. This checks buying power,
80
+ # margin requirements, and API-level validation rules without placing the order.
81
+ #
82
+ # @return [Tastytrade::Models::OrderResponse, nil] Dry-run response if successful, nil if failed
83
+ def dry_run_validate!
84
+ @dry_run_response = @account.place_order(@session, @order, dry_run: true)
85
+
86
+ # Check for API-level errors
87
+ if @dry_run_response.errors.any?
88
+ @errors.concat(@dry_run_response.errors.map { |e| format_api_error(e) })
89
+ end
90
+
91
+ # Check for warnings
92
+ if @dry_run_response.warnings.any?
93
+ @warnings.concat(@dry_run_response.warnings)
94
+ end
95
+
96
+ # Check buying power effect
97
+ if @dry_run_response.buying_power_effect
98
+ validate_buying_power_effect!(@dry_run_response.buying_power_effect)
99
+ end
100
+
101
+ @dry_run_response
102
+ rescue StandardError => e
103
+ @errors << "Dry-run validation failed: #{e.message}"
104
+ nil
105
+ end
106
+
107
+ private
108
+
109
+ # Validate basic order structure
110
+ def validate_order_structure!
111
+ # Check for at least one leg
112
+ if @order.legs.nil? || @order.legs.empty?
113
+ @errors << "Order must have at least one leg"
114
+ end
115
+
116
+ # Validate order type and price consistency
117
+ if @order.limit? && @order.price.nil?
118
+ @errors << "Limit orders require a price"
119
+ end
120
+
121
+ # Validate time in force
122
+ if @order.time_in_force.nil?
123
+ @errors << "Time in force is required"
124
+ end
125
+ end
126
+
127
+ # Validate symbols exist and are tradeable
128
+ def validate_symbols!
129
+ return if @order.legs.nil?
130
+
131
+ @order.legs.each do |leg|
132
+ validate_symbol!(leg.symbol, leg.instrument_type)
133
+ end
134
+ end
135
+
136
+ # Validate a single symbol
137
+ def validate_symbol!(symbol, instrument_type)
138
+ return if symbol.nil? || symbol.empty?
139
+
140
+ case instrument_type
141
+ when "Equity"
142
+ validate_equity_symbol!(symbol)
143
+ when "Option"
144
+ # TODO: Implement option symbol validation
145
+ @warnings << "Option symbol validation not yet implemented for #{symbol}"
146
+ when "Future"
147
+ # TODO: Implement futures symbol validation
148
+ @warnings << "Futures symbol validation not yet implemented for #{symbol}"
149
+ else
150
+ @errors << "Unknown instrument type: #{instrument_type}"
151
+ end
152
+ end
153
+
154
+ # Validate equity symbol exists
155
+ def validate_equity_symbol!(symbol)
156
+ # Try to fetch the equity to validate it exists
157
+ Instruments::Equity.get(@session, symbol)
158
+ rescue StandardError => e
159
+ @errors << "Invalid equity symbol '#{symbol}': #{e.message}"
160
+ end
161
+
162
+ # Validate order quantities
163
+ def validate_quantities!
164
+ return if @order.legs.nil?
165
+
166
+ @order.legs.each do |leg|
167
+ validate_quantity!(leg.quantity, leg.symbol)
168
+ end
169
+ end
170
+
171
+ # Validate a single quantity
172
+ def validate_quantity!(quantity, symbol)
173
+ # Check minimum quantity
174
+ if quantity.nil? || quantity < MIN_QUANTITY
175
+ @errors << "Quantity for #{symbol} must be at least #{MIN_QUANTITY}"
176
+ end
177
+
178
+ # Check maximum quantity
179
+ if quantity && quantity > MAX_QUANTITY
180
+ @errors << "Quantity for #{symbol} exceeds maximum of #{MAX_QUANTITY}"
181
+ end
182
+
183
+ # Check for whole shares (no fractional shares for now)
184
+ if quantity && quantity != quantity.to_i
185
+ @errors << "Fractional shares not supported for #{symbol}"
186
+ end
187
+ end
188
+
189
+ # Validate order prices
190
+ def validate_prices!
191
+ return unless @order.limit?
192
+
193
+ validate_price!(@order.price)
194
+ end
195
+
196
+ # Validate a single price
197
+ def validate_price!(price)
198
+ return if price.nil?
199
+
200
+ # Check for positive price
201
+ if price <= 0
202
+ @errors << "Price must be greater than 0"
203
+ end
204
+
205
+ # Check for reasonable price (not too high)
206
+ if price > BigDecimal("999999")
207
+ @errors << "Price exceeds reasonable limits"
208
+ end
209
+
210
+ # Round to appropriate tick size
211
+ rounded_price = round_to_tick_size(price)
212
+ if rounded_price != price
213
+ @warnings << "Price #{price} will be rounded to #{rounded_price}"
214
+ end
215
+ end
216
+
217
+ # Round price to appropriate tick size
218
+ def round_to_tick_size(price)
219
+ return price if price.nil?
220
+
221
+ # Simple tick size rules (can be enhanced based on instrument/exchange)
222
+ tick = if price < BigDecimal("1")
223
+ TICK_SIZES[:penny]
224
+ elsif price < BigDecimal("10")
225
+ TICK_SIZES[:penny]
226
+ else
227
+ TICK_SIZES[:penny]
228
+ end
229
+
230
+ (price / tick).round * tick
231
+ end
232
+
233
+ # Validate account permissions for the order
234
+ def validate_account_permissions!
235
+ @trading_status ||= @account.get_trading_status(@session)
236
+
237
+ # Check if account is restricted
238
+ if @trading_status.restricted?
239
+ restrictions = @trading_status.active_restrictions
240
+ @errors << "Account has active restrictions: #{restrictions.join(", ")}"
241
+ end
242
+
243
+ # Check specific permissions based on order type
244
+ @order.legs.each do |leg|
245
+ validate_leg_permissions!(leg)
246
+ end
247
+ end
248
+
249
+ # Validate permissions for a specific leg
250
+ def validate_leg_permissions!(leg)
251
+ @trading_status ||= @account.get_trading_status(@session)
252
+
253
+ case leg.instrument_type
254
+ when "Option"
255
+ unless @trading_status.can_trade_options?
256
+ @errors << "Account does not have options trading permissions"
257
+ end
258
+ when "Future"
259
+ unless @trading_status.can_trade_futures?
260
+ @errors << "Account does not have futures trading permissions"
261
+ end
262
+ when "Cryptocurrency"
263
+ unless @trading_status.can_trade_cryptocurrency?
264
+ @errors << "Account does not have cryptocurrency trading permissions"
265
+ end
266
+ end
267
+
268
+ # Check for closing-only restrictions
269
+ if opening_order?(leg.action) && @trading_status.is_closing_only
270
+ @errors << "Account is restricted to closing orders only"
271
+ end
272
+ end
273
+
274
+ # Check if this is an opening order
275
+ def opening_order?(action)
276
+ [OrderAction::BUY_TO_OPEN, OrderAction::SELL_TO_OPEN].include?(action)
277
+ end
278
+
279
+ # Validate market hours
280
+ def validate_market_hours!
281
+ # Get current time
282
+ now = Time.now
283
+
284
+ # Check if it's a market order during extended hours
285
+ if @order.market? && !regular_market_hours?(now)
286
+ @warnings << "Market orders may not be accepted outside regular trading hours"
287
+ end
288
+
289
+ # Basic weekday/weekend check
290
+ if weekend?(now)
291
+ @warnings << "Markets are closed on weekends"
292
+ end
293
+ end
294
+
295
+ # Check if current time is during regular market hours (9:30 AM - 4:00 PM ET)
296
+ def regular_market_hours?(time)
297
+ # Convert to Eastern Time (simplified - should use proper timezone library)
298
+ hour = time.hour
299
+ minute = time.min
300
+
301
+ # Rough check for market hours (9:30 AM - 4:00 PM ET)
302
+ # Note: This is simplified and doesn't account for timezones properly
303
+ return false if hour < 9 || hour >= 16
304
+ return false if hour == 9 && minute < 30
305
+
306
+ true
307
+ end
308
+
309
+ # Check if it's a weekend
310
+ def weekend?(time)
311
+ time.saturday? || time.sunday?
312
+ end
313
+
314
+ # Validate buying power via dry-run
315
+ def validate_buying_power!
316
+ # If we haven't run dry-run yet, do it now
317
+ @dry_run_response ||= dry_run_validate!
318
+ return if @dry_run_response.nil?
319
+
320
+ effect = @dry_run_response.buying_power_effect
321
+ return if effect.nil?
322
+
323
+ validate_buying_power_effect!(effect)
324
+ end
325
+
326
+ # Validate buying power effect from dry-run
327
+ def validate_buying_power_effect!(effect)
328
+ # Check if order would result in negative buying power
329
+ if effect.new_buying_power && effect.new_buying_power < 0
330
+ @errors << "Insufficient buying power. Order requires #{effect.buying_power_change_amount}, " \
331
+ "but only #{effect.current_buying_power} available"
332
+ end
333
+
334
+ # Warn if using significant portion of buying power
335
+ if effect.buying_power_usage_percentage > BigDecimal("50")
336
+ percentage = effect.buying_power_usage_percentage.to_f.round(1)
337
+ @warnings << "Order will use #{percentage}% of available buying power"
338
+ end
339
+
340
+ # Check for margin requirements
341
+ if effect.change_in_margin_requirement && effect.change_in_margin_requirement.abs > effect.current_buying_power
342
+ @errors << "Margin requirement of #{effect.change_in_margin_requirement.abs} exceeds available buying power"
343
+ end
344
+ end
345
+
346
+ # Format API error for display
347
+ def format_api_error(error)
348
+ if error.is_a?(Hash)
349
+ "#{error["domain"]}: #{error["reason"]}"
350
+ else
351
+ error.to_s
352
+ end
353
+ end
354
+ end
355
+ end