tastytrade 0.2.0 → 0.3.1

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 (57) 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 +180 -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/tastytrade/cli/positions_spec.rb +267 -0
  32. data/spec/tastytrade/cli_auth_spec.rb +5 -0
  33. data/spec/tastytrade/cli_env_login_spec.rb +199 -0
  34. data/spec/tastytrade/cli_helpers_spec.rb +3 -26
  35. data/spec/tastytrade/cli_orders_spec.rb +168 -0
  36. data/spec/tastytrade/cli_status_spec.rb +153 -164
  37. data/spec/tastytrade/file_store_spec.rb +126 -0
  38. data/spec/tastytrade/models/account_balance_spec.rb +103 -0
  39. data/spec/tastytrade/models/account_order_history_spec.rb +229 -0
  40. data/spec/tastytrade/models/account_order_management_spec.rb +271 -0
  41. data/spec/tastytrade/models/account_place_order_spec.rb +125 -0
  42. data/spec/tastytrade/models/account_spec.rb +86 -15
  43. data/spec/tastytrade/models/buying_power_effect_spec.rb +250 -0
  44. data/spec/tastytrade/models/live_order_json_spec.rb +144 -0
  45. data/spec/tastytrade/models/live_order_spec.rb +295 -0
  46. data/spec/tastytrade/models/order_response_spec.rb +96 -0
  47. data/spec/tastytrade/models/order_status_spec.rb +113 -0
  48. data/spec/tastytrade/models/trading_status_spec.rb +260 -0
  49. data/spec/tastytrade/models/transaction_spec.rb +236 -0
  50. data/spec/tastytrade/order_edge_cases_spec.rb +163 -0
  51. data/spec/tastytrade/order_spec.rb +201 -0
  52. data/spec/tastytrade/order_validator_spec.rb +347 -0
  53. data/spec/tastytrade/session_env_spec.rb +169 -0
  54. data/spec/tastytrade/session_manager_spec.rb +43 -33
  55. metadata +34 -18
  56. data/lib/tastytrade/keyring_store.rb +0 -72
  57. data/spec/tastytrade/keyring_store_spec.rb +0 -168
@@ -0,0 +1,272 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bigdecimal"
4
+ require "time"
5
+
6
+ module Tastytrade
7
+ module Models
8
+ # Represents a live order (open or recently closed) from the API
9
+ class LiveOrder < Base
10
+ attr_reader :id, :account_number, :status, :cancellable, :editable,
11
+ :edited, :time_in_force, :order_type, :size, :price,
12
+ :price_effect, :underlying_symbol, :underlying_instrument_type,
13
+ :stop_trigger, :legs, :gtc_date, :created_at, :updated_at,
14
+ :received_at, :routed_at, :filled_at, :cancelled_at,
15
+ :expired_at, :rejected_at, :live_at, :terminal_at,
16
+ :contingent_status, :confirmation_status, :reject_reason,
17
+ :user_tag, :preflight_check_result, :order_rule
18
+
19
+ # Check if order can be cancelled
20
+ def cancellable?
21
+ @cancellable && status == "Live"
22
+ end
23
+
24
+ # Check if order can be replaced/edited
25
+ def editable?
26
+ @editable && status == "Live"
27
+ end
28
+
29
+ # Check if order is in a terminal state
30
+ def terminal?
31
+ %w[Filled Cancelled Rejected Expired].include?(status)
32
+ end
33
+
34
+ # Check if order is working (live in market)
35
+ def working?
36
+ status == "Live"
37
+ end
38
+
39
+ # Check if order has been filled
40
+ def filled?
41
+ status == "Filled"
42
+ end
43
+
44
+ # Check if order has been cancelled
45
+ def cancelled?
46
+ status == "Cancelled"
47
+ end
48
+
49
+ # Get remaining quantity across all legs
50
+ def remaining_quantity
51
+ return 0 unless @legs
52
+ @legs.sum { |leg| leg.remaining_quantity || 0 }
53
+ end
54
+
55
+ # Get filled quantity across all legs
56
+ def filled_quantity
57
+ return 0 unless @legs
58
+ @legs.sum { |leg| leg.filled_quantity || 0 }
59
+ end
60
+
61
+ # Convert to hash for JSON serialization
62
+ def to_h
63
+ {
64
+ id: @id,
65
+ account_number: @account_number,
66
+ status: @status,
67
+ cancellable: @cancellable,
68
+ editable: @editable,
69
+ edited: @edited,
70
+ time_in_force: @time_in_force,
71
+ order_type: @order_type,
72
+ size: @size,
73
+ price: @price&.to_s("F"),
74
+ price_effect: @price_effect,
75
+ underlying_symbol: @underlying_symbol,
76
+ underlying_instrument_type: @underlying_instrument_type,
77
+ stop_trigger: @stop_trigger&.to_s("F"),
78
+ gtc_date: @gtc_date&.to_s,
79
+ created_at: @created_at&.iso8601,
80
+ updated_at: @updated_at&.iso8601,
81
+ received_at: @received_at&.iso8601,
82
+ routed_at: @routed_at&.iso8601,
83
+ filled_at: @filled_at&.iso8601,
84
+ cancelled_at: @cancelled_at&.iso8601,
85
+ expired_at: @expired_at&.iso8601,
86
+ rejected_at: @rejected_at&.iso8601,
87
+ live_at: @live_at&.iso8601,
88
+ terminal_at: @terminal_at&.iso8601,
89
+ contingent_status: @contingent_status,
90
+ confirmation_status: @confirmation_status,
91
+ reject_reason: @reject_reason,
92
+ user_tag: @user_tag,
93
+ preflight_check_result: @preflight_check_result,
94
+ order_rule: @order_rule,
95
+ legs: @legs&.map(&:to_h),
96
+ remaining_quantity: remaining_quantity,
97
+ filled_quantity: filled_quantity
98
+ }.compact
99
+ end
100
+
101
+ private
102
+
103
+ def parse_attributes
104
+ parse_basic_attributes
105
+ parse_order_details
106
+ parse_timestamps
107
+ parse_status_details
108
+ parse_legs
109
+ end
110
+
111
+ def parse_basic_attributes
112
+ @id = @data["id"]
113
+ @account_number = @data["account-number"]
114
+ @status = @data["status"]
115
+ @cancellable = @data["cancellable"]
116
+ @editable = @data["editable"]
117
+ @edited = @data["edited"]
118
+ end
119
+
120
+ def parse_order_details
121
+ @time_in_force = @data["time-in-force"]
122
+ @order_type = @data["order-type"]
123
+ @size = @data["size"]&.to_i
124
+ @price = parse_financial_value(@data["price"])
125
+ @price_effect = @data["price-effect"]
126
+ @underlying_symbol = @data["underlying-symbol"]
127
+ @underlying_instrument_type = @data["underlying-instrument-type"]
128
+ @stop_trigger = parse_financial_value(@data["stop-trigger"])
129
+ @gtc_date = parse_date(@data["gtc-date"])
130
+ end
131
+
132
+ def parse_timestamps
133
+ @created_at = parse_time(@data["created-at"])
134
+ @updated_at = parse_time(@data["updated-at"])
135
+ @received_at = parse_time(@data["received-at"])
136
+ @routed_at = parse_time(@data["routed-at"])
137
+ @filled_at = parse_time(@data["filled-at"])
138
+ @cancelled_at = parse_time(@data["cancelled-at"])
139
+ @expired_at = parse_time(@data["expired-at"])
140
+ @rejected_at = parse_time(@data["rejected-at"])
141
+ @live_at = parse_time(@data["live-at"])
142
+ @terminal_at = parse_time(@data["terminal-at"])
143
+ end
144
+
145
+ def parse_status_details
146
+ @contingent_status = @data["contingent-status"]
147
+ @confirmation_status = @data["confirmation-status"]
148
+ @reject_reason = @data["reject-reason"]
149
+ @user_tag = @data["user-tag"]
150
+ @preflight_check_result = @data["preflight-check-result"]
151
+ @order_rule = @data["order-rule"]
152
+ end
153
+
154
+ def parse_legs
155
+ legs_data = @data["legs"] || []
156
+ @legs = legs_data.map { |leg| LiveOrderLeg.new(leg) }
157
+ end
158
+
159
+ def parse_financial_value(value)
160
+ return nil if value.nil? || value.to_s.empty?
161
+ BigDecimal(value.to_s)
162
+ end
163
+
164
+ def parse_date(value)
165
+ return nil if value.nil? || value.to_s.empty?
166
+ Date.parse(value)
167
+ end
168
+ end
169
+
170
+ # Represents a leg in a live order
171
+ class LiveOrderLeg < Base
172
+ attr_reader :symbol, :instrument_type, :action, :quantity,
173
+ :remaining_quantity, :fills, :fill_quantity, :fill_price,
174
+ :execution_price, :position_effect, :ratio_quantity
175
+
176
+ # Calculate filled quantity
177
+ def filled_quantity
178
+ return 0 if @quantity.nil? || @remaining_quantity.nil?
179
+ @quantity - @remaining_quantity
180
+ end
181
+
182
+ # Check if leg is completely filled
183
+ def filled?
184
+ @remaining_quantity.to_i == 0
185
+ end
186
+
187
+ # Check if leg is partially filled
188
+ def partially_filled?
189
+ !filled? && filled_quantity > 0
190
+ end
191
+
192
+ # Convert to hash for JSON serialization
193
+ def to_h
194
+ {
195
+ symbol: @symbol,
196
+ instrument_type: @instrument_type,
197
+ action: @action,
198
+ quantity: @quantity,
199
+ remaining_quantity: @remaining_quantity,
200
+ filled_quantity: filled_quantity,
201
+ fill_quantity: @fill_quantity,
202
+ fill_price: @fill_price&.to_s("F"),
203
+ execution_price: @execution_price&.to_s("F"),
204
+ position_effect: @position_effect,
205
+ ratio_quantity: @ratio_quantity,
206
+ fills: @fills&.map(&:to_h)
207
+ }.compact
208
+ end
209
+
210
+ private
211
+
212
+ def parse_attributes
213
+ @symbol = @data["symbol"]
214
+ @instrument_type = @data["instrument-type"]
215
+ @action = @data["action"]
216
+ @quantity = @data["quantity"]&.to_i
217
+ @remaining_quantity = @data["remaining-quantity"]&.to_i
218
+ @fills = parse_fills(@data["fills"] || [])
219
+ @fill_quantity = @data["fill-quantity"]&.to_i
220
+ @fill_price = parse_financial_value(@data["fill-price"])
221
+ @execution_price = parse_financial_value(@data["execution-price"])
222
+ @position_effect = @data["position-effect"]
223
+ @ratio_quantity = @data["ratio-quantity"]&.to_i
224
+ end
225
+
226
+ def parse_fills(fills_data)
227
+ fills_data.map { |fill| Fill.new(fill) }
228
+ end
229
+
230
+ def parse_financial_value(value)
231
+ return nil if value.nil? || value.to_s.empty?
232
+ BigDecimal(value.to_s)
233
+ end
234
+ end
235
+
236
+ # Represents a fill execution
237
+ class Fill < Base
238
+ attr_reader :ext_exec_id, :ext_group_fill_id, :fill_id, :quantity,
239
+ :fill_price, :filled_at, :destination_venue
240
+
241
+ # Convert to hash for JSON serialization
242
+ def to_h
243
+ {
244
+ ext_exec_id: @ext_exec_id,
245
+ ext_group_fill_id: @ext_group_fill_id,
246
+ fill_id: @fill_id,
247
+ quantity: @quantity,
248
+ fill_price: @fill_price&.to_s("F"),
249
+ filled_at: @filled_at&.iso8601,
250
+ destination_venue: @destination_venue
251
+ }.compact
252
+ end
253
+
254
+ private
255
+
256
+ def parse_attributes
257
+ @ext_exec_id = @data["ext-exec-id"]
258
+ @ext_group_fill_id = @data["ext-group-fill-id"]
259
+ @fill_id = @data["fill-id"]
260
+ @quantity = @data["quantity"]&.to_i
261
+ @fill_price = parse_financial_value(@data["fill-price"])
262
+ @filled_at = parse_time(@data["filled-at"])
263
+ @destination_venue = @data["destination-venue"]
264
+ end
265
+
266
+ def parse_financial_value(value)
267
+ return nil if value.nil? || value.to_s.empty?
268
+ BigDecimal(value.to_s)
269
+ end
270
+ end
271
+ end
272
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bigdecimal"
4
+
5
+ module Tastytrade
6
+ module Models
7
+ # Represents the response from placing an order
8
+ class OrderResponse < Base
9
+ attr_reader :order_id, :buying_power_effect, :fee_calculations,
10
+ :warnings, :errors, :complex_order_id, :complex_order_tag,
11
+ :status, :account_number, :time_in_force, :order_type,
12
+ :price, :price_effect, :value, :value_effect,
13
+ :stop_trigger, :legs, :cancellable, :editable,
14
+ :edited, :updated_at, :created_at
15
+
16
+ private
17
+
18
+ def parse_attributes
19
+ parse_basic_attributes
20
+ parse_financial_attributes
21
+ parse_order_details
22
+ parse_metadata
23
+ end
24
+
25
+ def parse_basic_attributes
26
+ @order_id = @data["id"]
27
+ @account_number = @data["account-number"]
28
+ @status = @data["status"]
29
+ @cancellable = @data["cancellable"]
30
+ @editable = @data["editable"]
31
+ @edited = @data["edited"]
32
+ end
33
+
34
+ def parse_financial_attributes
35
+ # Handle both simple values and dry-run nested objects
36
+ @buying_power_effect = parse_buying_power_effect(@data["buying-power-effect"])
37
+ @fee_calculations = @data["fee-calculation"] || @data["fee-calculation-details"]
38
+ @price = parse_financial_value(@data["price"])
39
+ @price_effect = @data["price-effect"]
40
+ @value = parse_financial_value(@data["value"])
41
+ @value_effect = @data["value-effect"]
42
+ end
43
+
44
+ def parse_order_details
45
+ @time_in_force = @data["time-in-force"]
46
+ @order_type = @data["order-type"]
47
+ @stop_trigger = parse_financial_value(@data["stop-trigger"])
48
+ @complex_order_id = @data["complex-order-id"]
49
+ @complex_order_tag = @data["complex-order-tag"]
50
+ @legs = parse_legs(@data["legs"] || [])
51
+ end
52
+
53
+ def parse_metadata
54
+ @warnings = @data["warnings"] || []
55
+ @errors = @data["errors"] || []
56
+ @updated_at = parse_time(@data["updated-at"])
57
+ @created_at = parse_time(@data["created-at"])
58
+ end
59
+
60
+ def parse_financial_value(value)
61
+ return nil if value.nil? || value.to_s.empty?
62
+ BigDecimal(value.to_s)
63
+ end
64
+
65
+ def parse_legs(legs_data)
66
+ legs_data.map { |leg| OrderLegResponse.new(leg) }
67
+ end
68
+
69
+ def parse_buying_power_effect(value)
70
+ return nil if value.nil?
71
+
72
+ # Handle dry-run response format with nested object
73
+ if value.is_a?(Hash) && (value["change-in-buying-power"] || value["impact"])
74
+ # Create a full BuyingPowerEffect object for dry-run responses
75
+ BuyingPowerEffect.new(value)
76
+ else
77
+ # Handle regular response format (simple numeric value)
78
+ parse_financial_value(value)
79
+ end
80
+ end
81
+ end
82
+
83
+ # Represents a leg in an order response
84
+ class OrderLegResponse < Base
85
+ attr_reader :action, :symbol, :quantity, :instrument_type,
86
+ :remaining_quantity, :fills, :execution_price
87
+
88
+ private
89
+
90
+ def parse_attributes
91
+ @action = @data["action"]
92
+ @symbol = @data["symbol"]
93
+ @quantity = @data["quantity"]&.to_i
94
+ @instrument_type = @data["instrument-type"]
95
+ @remaining_quantity = @data["remaining-quantity"]&.to_i
96
+ @fills = @data["fills"] || []
97
+ @execution_price = parse_financial_value(@data["execution-price"])
98
+ end
99
+
100
+ def parse_financial_value(value)
101
+ return nil if value.nil? || value.to_s.empty?
102
+ BigDecimal(value.to_s)
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tastytrade
4
+ module Models
5
+ # Order status constants and helpers
6
+ module OrderStatus
7
+ # Submission phase statuses
8
+ RECEIVED = "Received"
9
+ ROUTED = "Routed"
10
+ IN_FLIGHT = "In Flight"
11
+ CONTINGENT = "Contingent"
12
+
13
+ # Working phase statuses
14
+ LIVE = "Live"
15
+ CANCEL_REQUESTED = "Cancel Requested"
16
+ REPLACE_REQUESTED = "Replace Requested"
17
+
18
+ # Terminal phase statuses
19
+ FILLED = "Filled"
20
+ CANCELLED = "Cancelled"
21
+ REJECTED = "Rejected"
22
+ EXPIRED = "Expired"
23
+ REMOVED = "Removed"
24
+
25
+ # Status groupings
26
+ SUBMISSION_STATUSES = [
27
+ RECEIVED,
28
+ ROUTED,
29
+ IN_FLIGHT,
30
+ CONTINGENT
31
+ ].freeze
32
+
33
+ WORKING_STATUSES = [
34
+ LIVE,
35
+ CANCEL_REQUESTED,
36
+ REPLACE_REQUESTED
37
+ ].freeze
38
+
39
+ TERMINAL_STATUSES = [
40
+ FILLED,
41
+ CANCELLED,
42
+ REJECTED,
43
+ EXPIRED,
44
+ REMOVED
45
+ ].freeze
46
+
47
+ ALL_STATUSES = (
48
+ SUBMISSION_STATUSES +
49
+ WORKING_STATUSES +
50
+ TERMINAL_STATUSES
51
+ ).freeze
52
+
53
+ # Check if status is in submission phase
54
+ def self.submission?(status)
55
+ SUBMISSION_STATUSES.include?(status)
56
+ end
57
+
58
+ # Check if status is in working phase
59
+ def self.working?(status)
60
+ WORKING_STATUSES.include?(status)
61
+ end
62
+
63
+ # Check if status is terminal
64
+ def self.terminal?(status)
65
+ TERMINAL_STATUSES.include?(status)
66
+ end
67
+
68
+ # Check if status allows cancellation
69
+ def self.cancellable?(status)
70
+ status == LIVE
71
+ end
72
+
73
+ # Check if status allows replacement
74
+ def self.editable?(status)
75
+ status == LIVE
76
+ end
77
+
78
+ # Validate status value
79
+ def self.valid?(status)
80
+ ALL_STATUSES.include?(status)
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,200 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bigdecimal"
4
+ require "date"
5
+
6
+ module Tastytrade
7
+ module Models
8
+ # Represents the trading status and permissions for an account
9
+ #
10
+ # @attr_reader [String] account_number The account number
11
+ # @attr_reader [String] equities_margin_calculation_type Type of margin calculation for equities
12
+ # @attr_reader [String] fee_schedule_name Fee schedule applied to the account
13
+ # @attr_reader [BigDecimal] futures_margin_rate_multiplier Margin rate multiplier for futures
14
+ # @attr_reader [Boolean] has_intraday_equities_margin Whether intraday equities margin is enabled
15
+ # @attr_reader [Integer] id Trading status record ID
16
+ # @attr_reader [Boolean] is_aggregated_at_clearing Whether account is aggregated at clearing
17
+ # @attr_reader [Boolean] is_closed Whether the account is closed
18
+ # @attr_reader [Boolean] is_closing_only Whether account is restricted to closing trades only
19
+ # @attr_reader [Boolean] is_cryptocurrency_enabled Whether cryptocurrency trading is enabled
20
+ # @attr_reader [Boolean] is_frozen Whether the account is frozen
21
+ # @attr_reader [Boolean] is_full_equity_margin_required Whether full equity margin is required
22
+ # @attr_reader [Boolean] is_futures_closing_only Whether futures are restricted to closing only
23
+ # @attr_reader [Boolean] is_futures_intra_day_enabled Whether intraday futures trading is enabled
24
+ # @attr_reader [Boolean] is_futures_enabled Whether futures trading is enabled
25
+ # @attr_reader [Boolean] is_in_day_trade_equity_maintenance_call Whether account is in day trade equity
26
+ # maintenance call
27
+ # @attr_reader [Boolean] is_in_margin_call Whether account is in margin call
28
+ # @attr_reader [Boolean] is_pattern_day_trader Whether account is flagged as pattern day trader
29
+ # @attr_reader [Boolean] is_small_notional_futures_intra_day_enabled Whether small notional futures
30
+ # intraday is enabled
31
+ # @attr_reader [Boolean] is_roll_the_day_forward_enabled Whether roll the day forward is enabled
32
+ # @attr_reader [Boolean] are_far_otm_net_options_restricted Whether far OTM net options are restricted
33
+ # @attr_reader [String] options_level Options trading permission level
34
+ # @attr_reader [Boolean] short_calls_enabled Whether short calls are enabled
35
+ # @attr_reader [BigDecimal] small_notional_futures_margin_rate_multiplier Margin rate multiplier for
36
+ # small notional futures
37
+ # @attr_reader [Boolean] is_equity_offering_enabled Whether equity offerings are enabled
38
+ # @attr_reader [Boolean] is_equity_offering_closing_only Whether equity offerings are closing only
39
+ # @attr_reader [Time] updated_at When the trading status was last updated
40
+ # @attr_reader [Boolean, nil] is_portfolio_margin_enabled Whether portfolio margin is enabled (optional)
41
+ # @attr_reader [Boolean, nil] is_risk_reducing_only Whether only risk-reducing trades are allowed (optional)
42
+ # @attr_reader [Integer, nil] day_trade_count Current day trade count (optional)
43
+ # @attr_reader [String, nil] autotrade_account_type Type of autotrade account (optional)
44
+ # @attr_reader [String, nil] clearing_account_number Clearing account number (optional)
45
+ # @attr_reader [String, nil] clearing_aggregation_identifier Clearing aggregation identifier (optional)
46
+ # @attr_reader [Boolean, nil] is_cryptocurrency_closing_only Whether crypto is closing only (optional)
47
+ # @attr_reader [Date, nil] pdt_reset_on Date when PDT flag will reset (optional)
48
+ # @attr_reader [Integer, nil] cmta_override CMTA override value (optional)
49
+ # @attr_reader [Time, nil] enhanced_fraud_safeguards_enabled_at When enhanced fraud safeguards were
50
+ # enabled (optional)
51
+ class TradingStatus < Base
52
+ attr_reader :account_number, :equities_margin_calculation_type, :fee_schedule_name,
53
+ :futures_margin_rate_multiplier, :has_intraday_equities_margin, :id,
54
+ :is_aggregated_at_clearing, :is_closed, :is_closing_only,
55
+ :is_cryptocurrency_enabled, :is_frozen, :is_full_equity_margin_required,
56
+ :is_futures_closing_only, :is_futures_intra_day_enabled, :is_futures_enabled,
57
+ :is_in_day_trade_equity_maintenance_call, :is_in_margin_call,
58
+ :is_pattern_day_trader, :is_small_notional_futures_intra_day_enabled,
59
+ :is_roll_the_day_forward_enabled, :are_far_otm_net_options_restricted,
60
+ :options_level, :short_calls_enabled,
61
+ :small_notional_futures_margin_rate_multiplier,
62
+ :is_equity_offering_enabled, :is_equity_offering_closing_only,
63
+ :updated_at, :is_portfolio_margin_enabled, :is_risk_reducing_only,
64
+ :day_trade_count, :autotrade_account_type, :clearing_account_number,
65
+ :clearing_aggregation_identifier, :is_cryptocurrency_closing_only,
66
+ :pdt_reset_on, :cmta_override, :enhanced_fraud_safeguards_enabled_at
67
+
68
+ # Check if account can trade options at any level
69
+ #
70
+ # @return [Boolean] true if options trading is enabled
71
+ def can_trade_options?
72
+ !options_level.nil? && options_level != "No Permissions"
73
+ end
74
+
75
+ # Check if account can trade futures
76
+ #
77
+ # @return [Boolean] true if futures trading is enabled and not closing only
78
+ def can_trade_futures?
79
+ is_futures_enabled && !is_futures_closing_only
80
+ end
81
+
82
+ # Check if account can trade cryptocurrency
83
+ #
84
+ # @return [Boolean] true if crypto trading is enabled and not closing only
85
+ def can_trade_cryptocurrency?
86
+ is_cryptocurrency_enabled && !is_cryptocurrency_closing_only
87
+ end
88
+
89
+ # Check if account has any trading restrictions
90
+ #
91
+ # @return [Boolean] true if account has restrictions
92
+ def restricted?
93
+ is_closed || is_frozen || is_closing_only || is_in_margin_call ||
94
+ is_in_day_trade_equity_maintenance_call || is_risk_reducing_only == true
95
+ end
96
+
97
+ # Get a list of active restrictions
98
+ #
99
+ # @return [Array<String>] list of active restrictions
100
+ def active_restrictions
101
+ restrictions = []
102
+ restrictions << "Account Closed" if is_closed
103
+ restrictions << "Account Frozen" if is_frozen
104
+ restrictions << "Closing Only" if is_closing_only
105
+ restrictions << "Margin Call" if is_in_margin_call
106
+ restrictions << "Day Trade Equity Maintenance Call" if is_in_day_trade_equity_maintenance_call
107
+ restrictions << "Risk Reducing Only" if is_risk_reducing_only
108
+ restrictions << "Pattern Day Trader" if is_pattern_day_trader
109
+ restrictions << "Futures Closing Only" if is_futures_closing_only
110
+ restrictions << "Cryptocurrency Closing Only" if is_cryptocurrency_closing_only
111
+ restrictions << "Equity Offering Closing Only" if is_equity_offering_closing_only
112
+ restrictions << "Far OTM Net Options Restricted" if are_far_otm_net_options_restricted
113
+ restrictions
114
+ end
115
+
116
+ # Get trading permissions summary
117
+ #
118
+ # @return [Hash] summary of trading permissions
119
+ def permissions_summary
120
+ {
121
+ options: can_trade_options? ? options_level : "Disabled",
122
+ futures: can_trade_futures? ? "Enabled" : (is_futures_enabled ? "Closing Only" : "Disabled"),
123
+ cryptocurrency: crypto_status,
124
+ short_calls: short_calls_enabled ? "Enabled" : "Disabled",
125
+ pattern_day_trader: is_pattern_day_trader ? "Yes" : "No",
126
+ portfolio_margin: is_portfolio_margin_enabled ? "Enabled" : "Disabled"
127
+ }
128
+ end
129
+
130
+ def crypto_status
131
+ if can_trade_cryptocurrency?
132
+ "Enabled"
133
+ elsif is_cryptocurrency_enabled
134
+ "Closing Only"
135
+ else
136
+ "Disabled"
137
+ end
138
+ end
139
+
140
+ private
141
+
142
+ def parse_attributes
143
+ @account_number = @data["account-number"]
144
+ @equities_margin_calculation_type = @data["equities-margin-calculation-type"]
145
+ @fee_schedule_name = @data["fee-schedule-name"]
146
+ @futures_margin_rate_multiplier = parse_decimal(@data["futures-margin-rate-multiplier"])
147
+ @has_intraday_equities_margin = @data["has-intraday-equities-margin"]
148
+ @id = @data["id"]
149
+ @is_aggregated_at_clearing = @data["is-aggregated-at-clearing"]
150
+ @is_closed = @data["is-closed"]
151
+ @is_closing_only = @data["is-closing-only"]
152
+ @is_cryptocurrency_enabled = @data["is-cryptocurrency-enabled"]
153
+ @is_frozen = @data["is-frozen"]
154
+ @is_full_equity_margin_required = @data["is-full-equity-margin-required"]
155
+ @is_futures_closing_only = @data["is-futures-closing-only"]
156
+ @is_futures_intra_day_enabled = @data["is-futures-intra-day-enabled"]
157
+ @is_futures_enabled = @data["is-futures-enabled"]
158
+ @is_in_day_trade_equity_maintenance_call = @data["is-in-day-trade-equity-maintenance-call"]
159
+ @is_in_margin_call = @data["is-in-margin-call"]
160
+ @is_pattern_day_trader = @data["is-pattern-day-trader"]
161
+ @is_small_notional_futures_intra_day_enabled = @data["is-small-notional-futures-intra-day-enabled"]
162
+ @is_roll_the_day_forward_enabled = @data["is-roll-the-day-forward-enabled"]
163
+ @are_far_otm_net_options_restricted = @data["are-far-otm-net-options-restricted"]
164
+ @options_level = @data["options-level"]
165
+ @short_calls_enabled = @data["short-calls-enabled"]
166
+ @small_notional_futures_margin_rate_multiplier =
167
+ parse_decimal(@data["small-notional-futures-margin-rate-multiplier"])
168
+ @is_equity_offering_enabled = @data["is-equity-offering-enabled"]
169
+ @is_equity_offering_closing_only = @data["is-equity-offering-closing-only"]
170
+ @updated_at = parse_time(@data["updated-at"])
171
+
172
+ # Optional fields
173
+ @is_portfolio_margin_enabled = @data["is-portfolio-margin-enabled"]
174
+ @is_risk_reducing_only = @data["is-risk-reducing-only"]
175
+ @day_trade_count = @data["day-trade-count"]
176
+ @autotrade_account_type = @data["autotrade-account-type"]
177
+ @clearing_account_number = @data["clearing-account-number"]
178
+ @clearing_aggregation_identifier = @data["clearing-aggregation-identifier"]
179
+ @is_cryptocurrency_closing_only = @data["is-cryptocurrency-closing-only"]
180
+ @pdt_reset_on = parse_date(@data["pdt-reset-on"]) if @data["pdt-reset-on"]
181
+ @cmta_override = @data["cmta-override"]
182
+ @enhanced_fraud_safeguards_enabled_at = parse_time(@data["enhanced-fraud-safeguards-enabled-at"])
183
+ end
184
+
185
+ def parse_date(date_string)
186
+ return nil if date_string.nil?
187
+
188
+ Date.parse(date_string)
189
+ rescue ArgumentError
190
+ nil
191
+ end
192
+
193
+ def parse_decimal(value)
194
+ return nil if value.nil? || value.to_s.empty?
195
+
196
+ BigDecimal(value.to_s)
197
+ end
198
+ end
199
+ end
200
+ end