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,260 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe Tastytrade::Models::TradingStatus do
6
+ let(:trading_status_data) do
7
+ {
8
+ "account-number" => "5WT0001",
9
+ "equities-margin-calculation-type" => "REG_T",
10
+ "fee-schedule-name" => "standard",
11
+ "futures-margin-rate-multiplier" => "1.0",
12
+ "has-intraday-equities-margin" => false,
13
+ "id" => 123456,
14
+ "is-aggregated-at-clearing" => false,
15
+ "is-closed" => false,
16
+ "is-closing-only" => false,
17
+ "is-cryptocurrency-enabled" => true,
18
+ "is-frozen" => false,
19
+ "is-full-equity-margin-required" => false,
20
+ "is-futures-closing-only" => false,
21
+ "is-futures-intra-day-enabled" => true,
22
+ "is-futures-enabled" => true,
23
+ "is-in-day-trade-equity-maintenance-call" => false,
24
+ "is-in-margin-call" => false,
25
+ "is-pattern-day-trader" => false,
26
+ "is-small-notional-futures-intra-day-enabled" => false,
27
+ "is-roll-the-day-forward-enabled" => true,
28
+ "are-far-otm-net-options-restricted" => false,
29
+ "options-level" => "Level 2",
30
+ "short-calls-enabled" => false,
31
+ "small-notional-futures-margin-rate-multiplier" => "1.0",
32
+ "is-equity-offering-enabled" => true,
33
+ "is-equity-offering-closing-only" => false,
34
+ "updated-at" => "2024-01-15T10:30:00Z",
35
+ "is-portfolio-margin-enabled" => false,
36
+ "is-risk-reducing-only" => nil,
37
+ "day-trade-count" => 0,
38
+ "autotrade-account-type" => nil,
39
+ "clearing-account-number" => nil,
40
+ "clearing-aggregation-identifier" => nil,
41
+ "is-cryptocurrency-closing-only" => false,
42
+ "pdt-reset-on" => nil,
43
+ "cmta-override" => nil,
44
+ "enhanced-fraud-safeguards-enabled-at" => nil
45
+ }
46
+ end
47
+
48
+ subject(:trading_status) { described_class.new(trading_status_data) }
49
+
50
+ describe "#initialize" do
51
+ it "parses all required fields correctly" do
52
+ expect(trading_status.account_number).to eq("5WT0001")
53
+ expect(trading_status.equities_margin_calculation_type).to eq("REG_T")
54
+ expect(trading_status.fee_schedule_name).to eq("standard")
55
+ expect(trading_status.futures_margin_rate_multiplier).to eq(BigDecimal("1.0"))
56
+ expect(trading_status.has_intraday_equities_margin).to eq(false)
57
+ expect(trading_status.id).to eq(123456)
58
+ expect(trading_status.is_aggregated_at_clearing).to eq(false)
59
+ expect(trading_status.is_closed).to eq(false)
60
+ expect(trading_status.is_closing_only).to eq(false)
61
+ expect(trading_status.is_cryptocurrency_enabled).to eq(true)
62
+ expect(trading_status.is_frozen).to eq(false)
63
+ expect(trading_status.is_full_equity_margin_required).to eq(false)
64
+ expect(trading_status.is_futures_closing_only).to eq(false)
65
+ expect(trading_status.is_futures_intra_day_enabled).to eq(true)
66
+ expect(trading_status.is_futures_enabled).to eq(true)
67
+ expect(trading_status.is_in_day_trade_equity_maintenance_call).to eq(false)
68
+ expect(trading_status.is_in_margin_call).to eq(false)
69
+ expect(trading_status.is_pattern_day_trader).to eq(false)
70
+ expect(trading_status.options_level).to eq("Level 2")
71
+ expect(trading_status.short_calls_enabled).to eq(false)
72
+ expect(trading_status.updated_at).to be_a(Time)
73
+ end
74
+
75
+ it "parses optional fields correctly" do
76
+ expect(trading_status.is_portfolio_margin_enabled).to eq(false)
77
+ expect(trading_status.is_risk_reducing_only).to be_nil
78
+ expect(trading_status.day_trade_count).to eq(0)
79
+ expect(trading_status.is_cryptocurrency_closing_only).to eq(false)
80
+ end
81
+
82
+ context "with PDT reset date" do
83
+ let(:trading_status_data_with_pdt) do
84
+ trading_status_data.merge("pdt-reset-on" => "2024-02-15")
85
+ end
86
+
87
+ subject(:trading_status_with_pdt) { described_class.new(trading_status_data_with_pdt) }
88
+
89
+ it "parses the PDT reset date correctly" do
90
+ expect(trading_status_with_pdt.pdt_reset_on).to be_a(Date)
91
+ expect(trading_status_with_pdt.pdt_reset_on.to_s).to eq("2024-02-15")
92
+ end
93
+ end
94
+ end
95
+
96
+ describe "#can_trade_options?" do
97
+ context "when options are enabled" do
98
+ it "returns true" do
99
+ expect(trading_status.can_trade_options?).to eq(true)
100
+ end
101
+ end
102
+
103
+ context "when options level is 'No Permissions'" do
104
+ before { trading_status_data["options-level"] = "No Permissions" }
105
+
106
+ it "returns false" do
107
+ expect(trading_status.can_trade_options?).to eq(false)
108
+ end
109
+ end
110
+
111
+ context "when options level is nil" do
112
+ before { trading_status_data["options-level"] = nil }
113
+
114
+ it "returns false" do
115
+ expect(trading_status.can_trade_options?).to eq(false)
116
+ end
117
+ end
118
+ end
119
+
120
+ describe "#can_trade_futures?" do
121
+ context "when futures are enabled and not closing only" do
122
+ it "returns true" do
123
+ expect(trading_status.can_trade_futures?).to eq(true)
124
+ end
125
+ end
126
+
127
+ context "when futures are disabled" do
128
+ before { trading_status_data["is-futures-enabled"] = false }
129
+
130
+ it "returns false" do
131
+ expect(trading_status.can_trade_futures?).to eq(false)
132
+ end
133
+ end
134
+
135
+ context "when futures are closing only" do
136
+ before { trading_status_data["is-futures-closing-only"] = true }
137
+
138
+ it "returns false" do
139
+ expect(trading_status.can_trade_futures?).to eq(false)
140
+ end
141
+ end
142
+ end
143
+
144
+ describe "#can_trade_cryptocurrency?" do
145
+ context "when crypto is enabled and not closing only" do
146
+ it "returns true" do
147
+ expect(trading_status.can_trade_cryptocurrency?).to eq(true)
148
+ end
149
+ end
150
+
151
+ context "when crypto is disabled" do
152
+ before { trading_status_data["is-cryptocurrency-enabled"] = false }
153
+
154
+ it "returns false" do
155
+ expect(trading_status.can_trade_cryptocurrency?).to eq(false)
156
+ end
157
+ end
158
+
159
+ context "when crypto is closing only" do
160
+ before { trading_status_data["is-cryptocurrency-closing-only"] = true }
161
+
162
+ it "returns false" do
163
+ expect(trading_status.can_trade_cryptocurrency?).to eq(false)
164
+ end
165
+ end
166
+ end
167
+
168
+ describe "#restricted?" do
169
+ context "when account has no restrictions" do
170
+ it "returns false" do
171
+ expect(trading_status.restricted?).to eq(false)
172
+ end
173
+ end
174
+
175
+ context "when account is closed" do
176
+ before { trading_status_data["is-closed"] = true }
177
+
178
+ it "returns true" do
179
+ expect(trading_status.restricted?).to eq(true)
180
+ end
181
+ end
182
+
183
+ context "when account is frozen" do
184
+ before { trading_status_data["is-frozen"] = true }
185
+
186
+ it "returns true" do
187
+ expect(trading_status.restricted?).to eq(true)
188
+ end
189
+ end
190
+
191
+ context "when account is in margin call" do
192
+ before { trading_status_data["is-in-margin-call"] = true }
193
+
194
+ it "returns true" do
195
+ expect(trading_status.restricted?).to eq(true)
196
+ end
197
+ end
198
+
199
+ context "when account is risk reducing only" do
200
+ before { trading_status_data["is-risk-reducing-only"] = true }
201
+
202
+ it "returns true" do
203
+ expect(trading_status.restricted?).to eq(true)
204
+ end
205
+ end
206
+ end
207
+
208
+ describe "#active_restrictions" do
209
+ context "when account has no restrictions" do
210
+ it "returns an empty array" do
211
+ expect(trading_status.active_restrictions).to eq([])
212
+ end
213
+ end
214
+
215
+ context "when account has multiple restrictions" do
216
+ before do
217
+ trading_status_data["is-in-margin-call"] = true
218
+ trading_status_data["is-pattern-day-trader"] = true
219
+ trading_status_data["is-futures-closing-only"] = true
220
+ end
221
+
222
+ it "returns all active restrictions" do
223
+ restrictions = trading_status.active_restrictions
224
+ expect(restrictions).to include("Margin Call")
225
+ expect(restrictions).to include("Pattern Day Trader")
226
+ expect(restrictions).to include("Futures Closing Only")
227
+ end
228
+ end
229
+ end
230
+
231
+ describe "#permissions_summary" do
232
+ it "returns a summary of all trading permissions" do
233
+ summary = trading_status.permissions_summary
234
+ expect(summary[:options]).to eq("Level 2")
235
+ expect(summary[:futures]).to eq("Enabled")
236
+ expect(summary[:cryptocurrency]).to eq("Enabled")
237
+ expect(summary[:short_calls]).to eq("Disabled")
238
+ expect(summary[:pattern_day_trader]).to eq("No")
239
+ expect(summary[:portfolio_margin]).to eq("Disabled")
240
+ end
241
+
242
+ context "when futures are closing only" do
243
+ before { trading_status_data["is-futures-closing-only"] = true }
244
+
245
+ it "shows futures as closing only" do
246
+ summary = trading_status.permissions_summary
247
+ expect(summary[:futures]).to eq("Closing Only")
248
+ end
249
+ end
250
+
251
+ context "when pattern day trader is flagged" do
252
+ before { trading_status_data["is-pattern-day-trader"] = true }
253
+
254
+ it "shows PDT as Yes" do
255
+ summary = trading_status.permissions_summary
256
+ expect(summary[:pattern_day_trader]).to eq("Yes")
257
+ end
258
+ end
259
+ end
260
+ end
@@ -0,0 +1,236 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe Tastytrade::Models::Transaction do
6
+ let(:session) { instance_double(Tastytrade::Session) }
7
+ let(:account_number) { "123456" }
8
+
9
+ let(:transaction_data) do
10
+ {
11
+ "id" => 252640963,
12
+ "account-number" => "5WT0001",
13
+ "symbol" => "AAPL",
14
+ "instrument-type" => "Equity",
15
+ "underlying-symbol" => "AAPL",
16
+ "transaction-type" => "Trade",
17
+ "transaction-sub-type" => "Buy",
18
+ "description" => "Bought 100 AAPL @ 150.00",
19
+ "action" => "Buy to Open",
20
+ "quantity" => "100",
21
+ "price" => "150.00",
22
+ "executed-at" => "2023-07-28T21:00:00.000+00:00",
23
+ "transaction-date" => "2023-07-28",
24
+ "value" => "-15000.00",
25
+ "value-effect" => "Debit",
26
+ "net-value" => "-15007.00",
27
+ "net-value-effect" => "Debit",
28
+ "is-estimated-fee" => false,
29
+ "commission" => "5.00",
30
+ "clearing-fees" => "1.00",
31
+ "regulatory-fees" => "0.50",
32
+ "proprietary-index-option-fees" => "0.50",
33
+ "order-id" => "12345",
34
+ "value-date" => "2023-07-30",
35
+ "reverses-id" => nil,
36
+ "is-verified" => true
37
+ }
38
+ end
39
+
40
+ describe ".get_all" do
41
+ let(:response_data) do
42
+ {
43
+ "data" => {
44
+ "items" => [transaction_data]
45
+ }
46
+ }
47
+ end
48
+
49
+ it "fetches transactions without filters" do
50
+ # First call returns data
51
+ allow(session).to receive(:get)
52
+ .with("/accounts/#{account_number}/transactions", {})
53
+ .and_return(response_data)
54
+ # Second call returns empty to stop pagination
55
+ allow(session).to receive(:get)
56
+ .with("/accounts/#{account_number}/transactions", { "page-offset" => 1 })
57
+ .and_return({ "data" => { "items" => [] } })
58
+
59
+ transactions = described_class.get_all(session, account_number)
60
+
61
+ expect(transactions).to be_an(Array)
62
+ expect(transactions.length).to eq(1)
63
+ expect(transactions.first).to be_a(described_class)
64
+ end
65
+
66
+ it "applies date filters" do
67
+ start_date = Date.new(2023, 7, 1)
68
+ end_date = Date.new(2023, 7, 31)
69
+
70
+ expected_params = {
71
+ "start-date" => "2023-07-01",
72
+ "end-date" => "2023-07-31"
73
+ }
74
+
75
+ allow(session).to receive(:get)
76
+ .with("/accounts/#{account_number}/transactions", expected_params)
77
+ .and_return(response_data)
78
+ allow(session).to receive(:get)
79
+ .with("/accounts/#{account_number}/transactions", expected_params.merge("page-offset" => 1))
80
+ .and_return({ "data" => { "items" => [] } })
81
+
82
+ transactions = described_class.get_all(session, account_number,
83
+ start_date: start_date,
84
+ end_date: end_date)
85
+
86
+ expect(transactions.length).to eq(1)
87
+ end
88
+
89
+ it "applies symbol and instrument type filters" do
90
+ expected_params = {
91
+ "symbol" => "AAPL",
92
+ "instrument-type" => "Equity"
93
+ }
94
+
95
+ allow(session).to receive(:get)
96
+ .with("/accounts/#{account_number}/transactions", expected_params)
97
+ .and_return(response_data)
98
+ allow(session).to receive(:get)
99
+ .with("/accounts/#{account_number}/transactions", expected_params.merge("page-offset" => 1))
100
+ .and_return({ "data" => { "items" => [] } })
101
+
102
+ transactions = described_class.get_all(session, account_number,
103
+ symbol: "AAPL",
104
+ instrument_type: "Equity")
105
+
106
+ expect(transactions.length).to eq(1)
107
+ end
108
+
109
+ it "handles pagination automatically" do
110
+ page1_response = {
111
+ "data" => {
112
+ "items" => [transaction_data]
113
+ }
114
+ }
115
+
116
+ page2_response = {
117
+ "data" => {
118
+ "items" => [transaction_data.merge("id" => 252640964)]
119
+ }
120
+ }
121
+
122
+ page3_response = {
123
+ "data" => {
124
+ "items" => []
125
+ }
126
+ }
127
+
128
+ allow(session).to receive(:get)
129
+ .with("/accounts/#{account_number}/transactions", {})
130
+ .and_return(page1_response)
131
+
132
+ allow(session).to receive(:get)
133
+ .with("/accounts/#{account_number}/transactions", { "page-offset" => 1 })
134
+ .and_return(page2_response)
135
+
136
+ allow(session).to receive(:get)
137
+ .with("/accounts/#{account_number}/transactions", { "page-offset" => 2 })
138
+ .and_return(page3_response)
139
+
140
+ transactions = described_class.get_all(session, account_number)
141
+
142
+ expect(transactions.length).to eq(2)
143
+ end
144
+
145
+ it "respects manual pagination settings" do
146
+ expected_params = {
147
+ "per-page" => 50,
148
+ "page-offset" => 2
149
+ }
150
+
151
+ allow(session).to receive(:get)
152
+ .with("/accounts/#{account_number}/transactions", expected_params)
153
+ .and_return(response_data)
154
+
155
+ transactions = described_class.get_all(session, account_number,
156
+ per_page: 50,
157
+ page_offset: 2)
158
+
159
+ expect(transactions.length).to eq(1)
160
+ end
161
+ end
162
+
163
+ describe "attribute parsing" do
164
+ let(:transaction) { described_class.new(transaction_data) }
165
+
166
+ it "parses basic attributes correctly" do
167
+ expect(transaction.id).to eq(252640963)
168
+ expect(transaction.account_number).to eq("5WT0001")
169
+ expect(transaction.symbol).to eq("AAPL")
170
+ expect(transaction.instrument_type).to eq("Equity")
171
+ expect(transaction.underlying_symbol).to eq("AAPL")
172
+ end
173
+
174
+ it "parses transaction type attributes" do
175
+ expect(transaction.transaction_type).to eq("Trade")
176
+ expect(transaction.transaction_sub_type).to eq("Buy")
177
+ expect(transaction.description).to eq("Bought 100 AAPL @ 150.00")
178
+ expect(transaction.action).to eq("Buy to Open")
179
+ end
180
+
181
+ it "parses decimal values correctly" do
182
+ expect(transaction.quantity).to be_a(BigDecimal)
183
+ expect(transaction.quantity).to eq(BigDecimal("100"))
184
+ expect(transaction.price).to eq(BigDecimal("150.00"))
185
+ expect(transaction.value).to eq(BigDecimal("-15000.00"))
186
+ expect(transaction.net_value).to eq(BigDecimal("-15007.00"))
187
+ end
188
+
189
+ it "parses fee attributes" do
190
+ expect(transaction.commission).to eq(BigDecimal("5.00"))
191
+ expect(transaction.clearing_fees).to eq(BigDecimal("1.00"))
192
+ expect(transaction.regulatory_fees).to eq(BigDecimal("0.50"))
193
+ expect(transaction.proprietary_index_option_fees).to eq(BigDecimal("0.50"))
194
+ end
195
+
196
+ it "parses date and time attributes" do
197
+ expect(transaction.executed_at).to be_a(Time)
198
+ expect(transaction.executed_at.year).to eq(2023)
199
+ expect(transaction.transaction_date).to be_a(Date)
200
+ expect(transaction.transaction_date.day).to eq(28)
201
+ end
202
+
203
+ it "parses boolean and other attributes" do
204
+ expect(transaction.is_estimated_fee).to eq(false)
205
+ expect(transaction.is_verified).to eq(true)
206
+ expect(transaction.order_id).to eq("12345")
207
+ expect(transaction.reverses_id).to be_nil
208
+ end
209
+
210
+ it "handles nil decimal values" do
211
+ data_with_nil = transaction_data.merge("commission" => nil)
212
+ transaction = described_class.new(data_with_nil)
213
+ expect(transaction.commission).to be_nil
214
+ end
215
+
216
+ it "handles empty string decimal values" do
217
+ data_with_empty = transaction_data.merge("clearing-fees" => "")
218
+ transaction = described_class.new(data_with_empty)
219
+ expect(transaction.clearing_fees).to be_nil
220
+ end
221
+ end
222
+
223
+ describe "constants" do
224
+ it "defines transaction types" do
225
+ expect(described_class::TRANSACTION_TYPES).to include("Dividend")
226
+ expect(described_class::TRANSACTION_TYPES).to include("Fee")
227
+ expect(described_class::TRANSACTION_TYPES).to include("Deposit")
228
+ end
229
+
230
+ it "defines instrument types" do
231
+ expect(described_class::INSTRUMENT_TYPES).to include("Equity")
232
+ expect(described_class::INSTRUMENT_TYPES).to include("Equity Option")
233
+ expect(described_class::INSTRUMENT_TYPES).to include("Future")
234
+ end
235
+ end
236
+ end
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe "Order edge cases and additional coverage" do
4
+ describe Tastytrade::Order do
5
+ describe "edge cases" do
6
+ it "accepts multiple legs" do
7
+ leg1 = Tastytrade::OrderLeg.new(
8
+ action: Tastytrade::OrderAction::BUY_TO_OPEN,
9
+ symbol: "AAPL",
10
+ quantity: 100
11
+ )
12
+
13
+ leg2 = Tastytrade::OrderLeg.new(
14
+ action: Tastytrade::OrderAction::BUY_TO_OPEN,
15
+ symbol: "MSFT",
16
+ quantity: 50
17
+ )
18
+
19
+ order = Tastytrade::Order.new(
20
+ type: Tastytrade::OrderType::MARKET,
21
+ legs: [leg1, leg2]
22
+ )
23
+
24
+ expect(order.legs).to eq([leg1, leg2])
25
+ params = order.to_api_params
26
+ expect(params["legs"]).to have_attributes(size: 2)
27
+ end
28
+
29
+ it "handles GTC time in force" do
30
+ leg = Tastytrade::OrderLeg.new(
31
+ action: Tastytrade::OrderAction::BUY_TO_OPEN,
32
+ symbol: "AAPL",
33
+ quantity: 100
34
+ )
35
+
36
+ order = Tastytrade::Order.new(
37
+ type: Tastytrade::OrderType::LIMIT,
38
+ time_in_force: Tastytrade::OrderTimeInForce::GTC,
39
+ legs: leg,
40
+ price: 150
41
+ )
42
+
43
+ expect(order.time_in_force).to eq("GTC")
44
+ expect(order.to_api_params["time-in-force"]).to eq("GTC")
45
+ end
46
+
47
+ it "handles sell to open orders" do
48
+ leg = Tastytrade::OrderLeg.new(
49
+ action: Tastytrade::OrderAction::SELL_TO_OPEN,
50
+ symbol: "AAPL",
51
+ quantity: 100
52
+ )
53
+
54
+ order = Tastytrade::Order.new(
55
+ type: Tastytrade::OrderType::LIMIT,
56
+ legs: leg,
57
+ price: 150
58
+ )
59
+
60
+ # Sell orders should have Credit price effect
61
+ expect(order.to_api_params["price-effect"]).to eq("Credit")
62
+ end
63
+
64
+ it "handles buy to close orders" do
65
+ leg = Tastytrade::OrderLeg.new(
66
+ action: Tastytrade::OrderAction::BUY_TO_CLOSE,
67
+ symbol: "AAPL",
68
+ quantity: 100
69
+ )
70
+
71
+ order = Tastytrade::Order.new(
72
+ type: Tastytrade::OrderType::LIMIT,
73
+ legs: leg,
74
+ price: 150
75
+ )
76
+
77
+ # Buy orders should have Debit price effect
78
+ expect(order.to_api_params["price-effect"]).to eq("Debit")
79
+ end
80
+ end
81
+ end
82
+
83
+ describe Tastytrade::OrderLeg do
84
+ describe "edge cases" do
85
+ it "converts large quantities correctly" do
86
+ leg = Tastytrade::OrderLeg.new(
87
+ action: Tastytrade::OrderAction::BUY_TO_OPEN,
88
+ symbol: "SPY",
89
+ quantity: 10_000
90
+ )
91
+
92
+ expect(leg.quantity).to eq(10_000)
93
+ expect(leg.to_api_params["quantity"]).to eq(10_000)
94
+ end
95
+
96
+ it "handles string quantities" do
97
+ leg = Tastytrade::OrderLeg.new(
98
+ action: Tastytrade::OrderAction::BUY_TO_OPEN,
99
+ symbol: "AAPL",
100
+ quantity: "100"
101
+ )
102
+
103
+ expect(leg.quantity).to eq(100)
104
+ end
105
+ end
106
+ end
107
+
108
+ describe Tastytrade::Models::OrderResponse do
109
+ describe "edge cases" do
110
+ it "handles empty warnings array" do
111
+ response = Tastytrade::Models::OrderResponse.new(
112
+ "warnings" => [],
113
+ "errors" => []
114
+ )
115
+
116
+ expect(response.warnings).to eq([])
117
+ expect(response.errors).to eq([])
118
+ end
119
+
120
+ it "handles missing legs data" do
121
+ response = Tastytrade::Models::OrderResponse.new({})
122
+
123
+ expect(response.legs).to eq([])
124
+ end
125
+
126
+ it "handles complex fee calculation structure" do
127
+ response = Tastytrade::Models::OrderResponse.new(
128
+ "fee-calculation" => {
129
+ "total-fees" => "1.50",
130
+ "commission" => "0.65",
131
+ "regulatory-fees" => "0.01",
132
+ "clearing-fees" => "0.84"
133
+ }
134
+ )
135
+
136
+ expect(response.fee_calculations).to be_a(Hash)
137
+ expect(response.fee_calculations["total-fees"]).to eq("1.50")
138
+ end
139
+ end
140
+ end
141
+
142
+ describe Tastytrade::Instruments::Equity do
143
+ describe "#build_leg" do
144
+ let(:equity) { Tastytrade::Instruments::Equity.new("symbol" => "AAPL") }
145
+
146
+ it "builds legs with all action types" do
147
+ [
148
+ Tastytrade::OrderAction::BUY_TO_OPEN,
149
+ Tastytrade::OrderAction::SELL_TO_CLOSE,
150
+ Tastytrade::OrderAction::SELL_TO_OPEN,
151
+ Tastytrade::OrderAction::BUY_TO_CLOSE
152
+ ].each do |action|
153
+ leg = equity.build_leg(action: action, quantity: 100)
154
+
155
+ expect(leg.action).to eq(action)
156
+ expect(leg.symbol).to eq("AAPL")
157
+ expect(leg.quantity).to eq(100)
158
+ expect(leg.instrument_type).to eq("Equity")
159
+ end
160
+ end
161
+ end
162
+ end
163
+ end