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,229 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe "Tastytrade::Models::Account#get_order_history" do
6
+ let(:session) { instance_double(Tastytrade::Session) }
7
+ let(:account_number) { "5WZ38925" }
8
+ let(:account) { Tastytrade::Models::Account.new("account-number" => account_number) }
9
+
10
+ let(:order_history_response) do
11
+ {
12
+ "data" => {
13
+ "items" => [
14
+ {
15
+ "id" => "12345",
16
+ "account-number" => account_number,
17
+ "status" => "Filled",
18
+ "cancellable" => false,
19
+ "editable" => false,
20
+ "time-in-force" => "Day",
21
+ "order-type" => "Limit",
22
+ "underlying-symbol" => "AAPL",
23
+ "price" => "150.00",
24
+ "created-at" => "2024-01-01T10:00:00Z",
25
+ "filled-at" => "2024-01-01T10:05:00Z",
26
+ "legs" => [
27
+ {
28
+ "symbol" => "AAPL",
29
+ "instrument-type" => "Equity",
30
+ "action" => "Buy to Open",
31
+ "quantity" => 100,
32
+ "remaining-quantity" => 0
33
+ }
34
+ ]
35
+ },
36
+ {
37
+ "id" => "12346",
38
+ "account-number" => account_number,
39
+ "status" => "Cancelled",
40
+ "cancellable" => false,
41
+ "editable" => false,
42
+ "time-in-force" => "Day",
43
+ "order-type" => "Market",
44
+ "underlying-symbol" => "MSFT",
45
+ "created-at" => "2024-01-02T14:30:00Z",
46
+ "cancelled-at" => "2024-01-02T14:35:00Z",
47
+ "legs" => [
48
+ {
49
+ "symbol" => "MSFT",
50
+ "instrument-type" => "Equity",
51
+ "action" => "Sell to Close",
52
+ "quantity" => 50,
53
+ "remaining-quantity" => 50
54
+ }
55
+ ]
56
+ }
57
+ ]
58
+ }
59
+ }
60
+ end
61
+
62
+ context "without filters" do
63
+ it "retrieves all historical orders" do
64
+ expect(session).to receive(:get)
65
+ .with("/accounts/#{account_number}/orders/", {})
66
+ .and_return(order_history_response)
67
+
68
+ orders = account.get_order_history(session)
69
+
70
+ expect(orders).to be_an(Array)
71
+ expect(orders.size).to eq(2)
72
+ expect(orders.first).to be_a(Tastytrade::Models::LiveOrder)
73
+ expect(orders.first.id).to eq("12345")
74
+ expect(orders.first.status).to eq("Filled")
75
+ expect(orders.last.id).to eq("12346")
76
+ expect(orders.last.status).to eq("Cancelled")
77
+ end
78
+ end
79
+
80
+ context "with status filter" do
81
+ it "includes status in request params" do
82
+ expect(session).to receive(:get)
83
+ .with("/accounts/#{account_number}/orders/", { "status" => "Filled" })
84
+ .and_return(order_history_response)
85
+
86
+ account.get_order_history(session, status: "Filled")
87
+ end
88
+
89
+ it "ignores invalid status values" do
90
+ expect(session).to receive(:get)
91
+ .with("/accounts/#{account_number}/orders/", {})
92
+ .and_return(order_history_response)
93
+
94
+ account.get_order_history(session, status: "InvalidStatus")
95
+ end
96
+ end
97
+
98
+ context "with underlying symbol filter" do
99
+ it "includes underlying symbol in request params" do
100
+ expect(session).to receive(:get)
101
+ .with("/accounts/#{account_number}/orders/", { "underlying-symbol" => "AAPL" })
102
+ .and_return(order_history_response)
103
+
104
+ account.get_order_history(session, underlying_symbol: "AAPL")
105
+ end
106
+ end
107
+
108
+ context "with time filters" do
109
+ it "includes time range in request params" do
110
+ from_time = Time.parse("2024-01-01T00:00:00Z")
111
+ to_time = Time.parse("2024-01-31T23:59:59Z")
112
+
113
+ expect(session).to receive(:get)
114
+ .with("/accounts/#{account_number}/orders/", {
115
+ "from-time" => from_time.iso8601,
116
+ "to-time" => to_time.iso8601
117
+ })
118
+ .and_return(order_history_response)
119
+
120
+ account.get_order_history(session, from_time: from_time, to_time: to_time)
121
+ end
122
+ end
123
+
124
+ context "with pagination" do
125
+ it "includes pagination parameters" do
126
+ expect(session).to receive(:get)
127
+ .with("/accounts/#{account_number}/orders/", {
128
+ "page-offset" => 100,
129
+ "page-limit" => 50
130
+ })
131
+ .and_return(order_history_response)
132
+
133
+ account.get_order_history(session, page_offset: 100, page_limit: 50)
134
+ end
135
+ end
136
+
137
+ context "with multiple filters" do
138
+ it "combines all filters in request" do
139
+ from_time = Time.parse("2024-01-01T00:00:00Z")
140
+ to_time = Time.parse("2024-01-31T23:59:59Z")
141
+
142
+ expect(session).to receive(:get)
143
+ .with("/accounts/#{account_number}/orders/", {
144
+ "status" => "Filled",
145
+ "underlying-symbol" => "AAPL",
146
+ "from-time" => from_time.iso8601,
147
+ "to-time" => to_time.iso8601,
148
+ "page-limit" => 100
149
+ })
150
+ .and_return(order_history_response)
151
+
152
+ account.get_order_history(
153
+ session,
154
+ status: "Filled",
155
+ underlying_symbol: "AAPL",
156
+ from_time: from_time,
157
+ to_time: to_time,
158
+ page_limit: 100
159
+ )
160
+ end
161
+ end
162
+ end
163
+
164
+ RSpec.describe "Tastytrade::Models::Account#get_order" do
165
+ let(:session) { instance_double(Tastytrade::Session) }
166
+ let(:account_number) { "5WZ38925" }
167
+ let(:account) { Tastytrade::Models::Account.new("account-number" => account_number) }
168
+ let(:order_id) { "12345" }
169
+
170
+ let(:order_response) do
171
+ {
172
+ "data" => {
173
+ "id" => order_id,
174
+ "account-number" => account_number,
175
+ "status" => "Live",
176
+ "cancellable" => true,
177
+ "editable" => true,
178
+ "time-in-force" => "GTC",
179
+ "order-type" => "Limit",
180
+ "underlying-symbol" => "AAPL",
181
+ "price" => "150.00",
182
+ "created-at" => "2024-01-01T10:00:00Z",
183
+ "legs" => [
184
+ {
185
+ "symbol" => "AAPL",
186
+ "instrument-type" => "Equity",
187
+ "action" => "Buy to Open",
188
+ "quantity" => 100,
189
+ "remaining-quantity" => 100
190
+ }
191
+ ]
192
+ }
193
+ }
194
+ end
195
+
196
+ it "retrieves a specific order by ID" do
197
+ expect(session).to receive(:get)
198
+ .with("/accounts/#{account_number}/orders/#{order_id}/")
199
+ .and_return(order_response)
200
+
201
+ order = account.get_order(session, order_id)
202
+
203
+ expect(order).to be_a(Tastytrade::Models::LiveOrder)
204
+ expect(order.id).to eq(order_id)
205
+ expect(order.status).to eq("Live")
206
+ expect(order.underlying_symbol).to eq("AAPL")
207
+ expect(order.cancellable?).to be true
208
+ expect(order.editable?).to be true
209
+ end
210
+
211
+ context "when order doesn't exist" do
212
+ it "raises an error" do
213
+ error_response = {
214
+ "error" => {
215
+ "code" => "order_not_found",
216
+ "message" => "Order not found"
217
+ }
218
+ }
219
+
220
+ expect(session).to receive(:get)
221
+ .with("/accounts/#{account_number}/orders/#{order_id}/")
222
+ .and_raise(Tastytrade::Error.new("Order not found"))
223
+
224
+ expect {
225
+ account.get_order(session, order_id)
226
+ }.to raise_error(Tastytrade::Error, "Order not found")
227
+ end
228
+ end
229
+ end
@@ -0,0 +1,271 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe Tastytrade::Models::Account, "#get_live_orders" do
6
+ let(:session) { instance_double(Tastytrade::Session) }
7
+ let(:account) { described_class.new({ "account-number" => "5WV12345" }) }
8
+
9
+ let(:live_orders_response) do
10
+ {
11
+ "data" => {
12
+ "items" => [
13
+ {
14
+ "id" => "12345",
15
+ "account-number" => "5WV12345",
16
+ "status" => "Live",
17
+ "cancellable" => true,
18
+ "editable" => true,
19
+ "time-in-force" => "Day",
20
+ "order-type" => "Limit",
21
+ "price" => "150.50",
22
+ "underlying-symbol" => "AAPL",
23
+ "legs" => [
24
+ {
25
+ "symbol" => "AAPL",
26
+ "action" => "Buy",
27
+ "quantity" => 100,
28
+ "remaining-quantity" => 100
29
+ }
30
+ ]
31
+ },
32
+ {
33
+ "id" => "12346",
34
+ "account-number" => "5WV12345",
35
+ "status" => "Filled",
36
+ "cancellable" => false,
37
+ "editable" => false,
38
+ "time-in-force" => "Day",
39
+ "order-type" => "Market",
40
+ "underlying-symbol" => "TSLA",
41
+ "filled-at" => "2024-01-15T09:35:00.000Z",
42
+ "legs" => [
43
+ {
44
+ "symbol" => "TSLA",
45
+ "action" => "Sell",
46
+ "quantity" => 50,
47
+ "remaining-quantity" => 0
48
+ }
49
+ ]
50
+ }
51
+ ]
52
+ }
53
+ }
54
+ end
55
+
56
+ describe "without filters" do
57
+ it "retrieves all live orders" do
58
+ allow(session).to receive(:get)
59
+ .with("/accounts/5WV12345/orders/live/", {})
60
+ .and_return(live_orders_response)
61
+
62
+ orders = account.get_live_orders(session)
63
+
64
+ expect(orders).to be_an(Array)
65
+ expect(orders.size).to eq(2)
66
+ expect(orders.first).to be_a(Tastytrade::Models::LiveOrder)
67
+ expect(orders.first.id).to eq("12345")
68
+ expect(orders.first.status).to eq("Live")
69
+ expect(orders.last.status).to eq("Filled")
70
+ end
71
+ end
72
+
73
+ describe "with status filter" do
74
+ it "includes status in request params" do
75
+ expect(session).to receive(:get)
76
+ .with("/accounts/5WV12345/orders/live/", { "status" => "Live" })
77
+ .and_return({ "data" => { "items" => [] } })
78
+
79
+ account.get_live_orders(session, status: "Live")
80
+ end
81
+
82
+ it "ignores invalid status values" do
83
+ expect(session).to receive(:get)
84
+ .with("/accounts/5WV12345/orders/live/", {})
85
+ .and_return({ "data" => { "items" => [] } })
86
+
87
+ account.get_live_orders(session, status: "InvalidStatus")
88
+ end
89
+ end
90
+
91
+ describe "with underlying symbol filter" do
92
+ it "includes underlying symbol in request params" do
93
+ expect(session).to receive(:get)
94
+ .with("/accounts/5WV12345/orders/live/", { "underlying-symbol" => "AAPL" })
95
+ .and_return({ "data" => { "items" => [] } })
96
+
97
+ account.get_live_orders(session, underlying_symbol: "AAPL")
98
+ end
99
+ end
100
+
101
+ describe "with time filters" do
102
+ let(:from_time) { Time.parse("2024-01-15T09:00:00Z") }
103
+ let(:to_time) { Time.parse("2024-01-15T17:00:00Z") }
104
+
105
+ it "includes time range in request params" do
106
+ expect(session).to receive(:get)
107
+ .with("/accounts/5WV12345/orders/live/",
108
+ {
109
+ "from-time" => from_time.iso8601,
110
+ "to-time" => to_time.iso8601
111
+ })
112
+ .and_return({ "data" => { "items" => [] } })
113
+
114
+ account.get_live_orders(session, from_time: from_time, to_time: to_time)
115
+ end
116
+ end
117
+ end
118
+
119
+ RSpec.describe Tastytrade::Models::Account, "#cancel_order" do
120
+ let(:session) { instance_double(Tastytrade::Session) }
121
+ let(:account) { described_class.new({ "account-number" => "5WV12345" }) }
122
+ let(:order_id) { "12345" }
123
+
124
+ describe "successful cancellation" do
125
+ it "sends DELETE request and returns nil" do
126
+ expect(session).to receive(:delete)
127
+ .with("/accounts/5WV12345/orders/12345/")
128
+ .and_return(nil)
129
+
130
+ result = account.cancel_order(session, order_id)
131
+ expect(result).to be_nil
132
+ end
133
+ end
134
+
135
+ describe "error handling" do
136
+ context "when order is already filled" do
137
+ it "raises OrderAlreadyFilledError" do
138
+ error = Tastytrade::Error.new("Order already filled")
139
+ expect(session).to receive(:delete)
140
+ .with("/accounts/5WV12345/orders/12345/")
141
+ .and_raise(error)
142
+
143
+ expect do
144
+ account.cancel_order(session, order_id)
145
+ end.to raise_error(Tastytrade::OrderAlreadyFilledError, /already been filled/)
146
+ end
147
+ end
148
+
149
+ context "when order is not cancellable" do
150
+ it "raises OrderNotCancellableError" do
151
+ error = Tastytrade::Error.new("Cannot cancel order in current state")
152
+ expect(session).to receive(:delete)
153
+ .with("/accounts/5WV12345/orders/12345/")
154
+ .and_raise(error)
155
+
156
+ expect do
157
+ account.cancel_order(session, order_id)
158
+ end.to raise_error(Tastytrade::OrderNotCancellableError, /not in a cancellable state/)
159
+ end
160
+ end
161
+
162
+ context "when other error occurs" do
163
+ it "re-raises the original error" do
164
+ error = Tastytrade::Error.new("Network error")
165
+ expect(session).to receive(:delete)
166
+ .with("/accounts/5WV12345/orders/12345/")
167
+ .and_raise(error)
168
+
169
+ expect do
170
+ account.cancel_order(session, order_id)
171
+ end.to raise_error(Tastytrade::Error, "Network error")
172
+ end
173
+ end
174
+ end
175
+ end
176
+
177
+ RSpec.describe Tastytrade::Models::Account, "#replace_order" do
178
+ let(:session) { instance_double(Tastytrade::Session) }
179
+ let(:account) { described_class.new({ "account-number" => "5WV12345" }) }
180
+ let(:order_id) { "12345" }
181
+ let(:new_order) { instance_double(Tastytrade::Order) }
182
+ let(:order_params) do
183
+ {
184
+ "time-in-force" => "Day",
185
+ "order-type" => "Limit",
186
+ "price" => "155.00",
187
+ "legs" => [
188
+ {
189
+ "action" => "Buy",
190
+ "symbol" => "AAPL",
191
+ "quantity" => 50
192
+ }
193
+ ]
194
+ }
195
+ end
196
+
197
+ let(:replace_response) do
198
+ {
199
+ "data" => {
200
+ "id" => "12347",
201
+ "account-number" => "5WV12345",
202
+ "status" => "Received",
203
+ "cancellable" => true,
204
+ "editable" => true,
205
+ "time-in-force" => "Day",
206
+ "order-type" => "Limit",
207
+ "price" => "155.00"
208
+ }
209
+ }
210
+ end
211
+
212
+ describe "successful replacement" do
213
+ it "sends PUT request and returns OrderResponse" do
214
+ expect(new_order).to receive(:to_api_params).and_return(order_params)
215
+ expect(session).to receive(:put)
216
+ .with("/accounts/5WV12345/orders/12345/", order_params)
217
+ .and_return(replace_response)
218
+
219
+ result = account.replace_order(session, order_id, new_order)
220
+
221
+ expect(result).to be_a(Tastytrade::Models::OrderResponse)
222
+ expect(result.order_id).to eq("12347")
223
+ expect(result.status).to eq("Received")
224
+ end
225
+ end
226
+
227
+ describe "error handling" do
228
+ before do
229
+ allow(new_order).to receive(:to_api_params).and_return(order_params)
230
+ end
231
+
232
+ context "when order is not editable" do
233
+ it "raises OrderNotEditableError" do
234
+ error = Tastytrade::Error.new("Order not editable in current state")
235
+ expect(session).to receive(:put)
236
+ .with("/accounts/5WV12345/orders/12345/", order_params)
237
+ .and_raise(error)
238
+
239
+ expect do
240
+ account.replace_order(session, order_id, new_order)
241
+ end.to raise_error(Tastytrade::OrderNotEditableError, /not in an editable state/)
242
+ end
243
+ end
244
+
245
+ context "when quantity exceeds remaining" do
246
+ it "raises InsufficientQuantityError" do
247
+ error = Tastytrade::Error.new("Quantity exceeds remaining amount")
248
+ expect(session).to receive(:put)
249
+ .with("/accounts/5WV12345/orders/12345/", order_params)
250
+ .and_raise(error)
251
+
252
+ expect do
253
+ account.replace_order(session, order_id, new_order)
254
+ end.to raise_error(Tastytrade::InsufficientQuantityError, /exceeding remaining amount/)
255
+ end
256
+ end
257
+
258
+ context "when other error occurs" do
259
+ it "re-raises the original error" do
260
+ error = Tastytrade::Error.new("Server error")
261
+ expect(session).to receive(:put)
262
+ .with("/accounts/5WV12345/orders/12345/", order_params)
263
+ .and_raise(error)
264
+
265
+ expect do
266
+ account.replace_order(session, order_id, new_order)
267
+ end.to raise_error(Tastytrade::Error, "Server error")
268
+ end
269
+ end
270
+ end
271
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe "Tastytrade::Models::Account#place_order" do
4
+ let(:session) { instance_double(Tastytrade::Session) }
5
+ let(:account) { Tastytrade::Models::Account.new("account-number" => "5WX12345") }
6
+
7
+ let(:order_leg) do
8
+ Tastytrade::OrderLeg.new(
9
+ action: Tastytrade::OrderAction::BUY_TO_OPEN,
10
+ symbol: "AAPL",
11
+ quantity: 100
12
+ )
13
+ end
14
+
15
+ let(:market_order) do
16
+ Tastytrade::Order.new(
17
+ type: Tastytrade::OrderType::MARKET,
18
+ legs: order_leg
19
+ )
20
+ end
21
+
22
+ let(:limit_order) do
23
+ Tastytrade::Order.new(
24
+ type: Tastytrade::OrderType::LIMIT,
25
+ legs: order_leg,
26
+ price: 150.50
27
+ )
28
+ end
29
+
30
+ let(:successful_response) do
31
+ {
32
+ "data" => {
33
+ "id" => "123456",
34
+ "account-number" => "5WX12345",
35
+ "status" => "Routed",
36
+ "buying-power-effect" => "-15050.00"
37
+ }
38
+ }
39
+ end
40
+
41
+ let(:dry_run_response) do
42
+ {
43
+ "data" => {
44
+ "buying-power-effect" => {
45
+ "impact" => "1.50",
46
+ "change-in-buying-power" => "1.50"
47
+ },
48
+ "warnings" => [
49
+ { "code" => "market_closed", "message" => "Market is closed" }
50
+ ]
51
+ }
52
+ }
53
+ end
54
+
55
+ describe "successful order placement" do
56
+ before do
57
+ allow(session).to receive(:post).and_return(successful_response)
58
+ end
59
+
60
+ it "places a market order" do
61
+ response = account.place_order(session, market_order, skip_validation: true)
62
+
63
+ expect(session).to have_received(:post).with(
64
+ "/accounts/5WX12345/orders",
65
+ market_order.to_api_params
66
+ )
67
+
68
+ expect(response).to be_a(Tastytrade::Models::OrderResponse)
69
+ expect(response.order_id).to eq("123456")
70
+ expect(response.status).to eq("Routed")
71
+ end
72
+
73
+ it "places a limit order with correct parameters" do
74
+ response = account.place_order(session, limit_order, skip_validation: true)
75
+
76
+ expect(session).to have_received(:post).with(
77
+ "/accounts/5WX12345/orders",
78
+ hash_including(
79
+ "order-type" => "Limit",
80
+ "price" => "150.5",
81
+ "price-effect" => "Debit"
82
+ )
83
+ )
84
+
85
+ expect(response).to be_a(Tastytrade::Models::OrderResponse)
86
+ end
87
+
88
+ it "handles dry run orders" do
89
+ allow(session).to receive(:post).and_return(dry_run_response)
90
+
91
+ response = account.place_order(session, market_order, dry_run: true)
92
+
93
+ expect(session).to have_received(:post).with(
94
+ "/accounts/5WX12345/orders/dry-run",
95
+ anything
96
+ )
97
+
98
+ expect(response.buying_power_effect).to be_a(Tastytrade::Models::BuyingPowerEffect)
99
+ expect(response.buying_power_effect.impact).to eq(BigDecimal("1.50"))
100
+ expect(response.warnings).not_to be_empty
101
+ end
102
+ end
103
+
104
+ describe "error handling" do
105
+ it "handles API errors" do
106
+ allow(session).to receive(:post).and_raise(
107
+ Tastytrade::Error, "Invalid symbol"
108
+ )
109
+
110
+ expect {
111
+ account.place_order(session, market_order, skip_validation: true)
112
+ }.to raise_error(Tastytrade::Error, "Invalid symbol")
113
+ end
114
+
115
+ it "handles network timeouts" do
116
+ allow(session).to receive(:post).and_raise(
117
+ Tastytrade::NetworkTimeoutError, "Request timed out"
118
+ )
119
+
120
+ expect {
121
+ account.place_order(session, market_order, skip_validation: true)
122
+ }.to raise_error(Tastytrade::NetworkTimeoutError)
123
+ end
124
+ end
125
+ end