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
@@ -3,204 +3,193 @@
3
3
  require "spec_helper"
4
4
  require "tastytrade/cli"
5
5
 
6
- RSpec.describe "Tastytrade::CLI status command" do
7
- def capture_stdout
8
- original_stdout = $stdout
9
- $stdout = StringIO.new
10
- yield
11
- $stdout.string
12
- ensure
13
- $stdout = original_stdout
14
- end
6
+ RSpec.describe "Tastytrade::CLI trading_status command" do
15
7
  let(:cli) { Tastytrade::CLI.new }
16
- let(:config) { instance_double(Tastytrade::CLIConfig) }
17
8
  let(:session) { instance_double(Tastytrade::Session) }
18
- let(:user) { instance_double(Tastytrade::Models::User, email: "test@example.com") }
9
+ let(:account) { instance_double(Tastytrade::Models::Account, account_number: "5WT0001") }
10
+ let(:config) { instance_double(Tastytrade::CLIConfig) }
11
+
12
+ let(:trading_status_data) do
13
+ {
14
+ "account-number" => "5WT0001",
15
+ "equities-margin-calculation-type" => "REG_T",
16
+ "fee-schedule-name" => "standard",
17
+ "futures-margin-rate-multiplier" => "1.0",
18
+ "has-intraday-equities-margin" => false,
19
+ "id" => 123456,
20
+ "is-aggregated-at-clearing" => false,
21
+ "is-closed" => false,
22
+ "is-closing-only" => false,
23
+ "is-cryptocurrency-enabled" => true,
24
+ "is-frozen" => false,
25
+ "is-full-equity-margin-required" => false,
26
+ "is-futures-closing-only" => false,
27
+ "is-futures-intra-day-enabled" => true,
28
+ "is-futures-enabled" => true,
29
+ "is-in-day-trade-equity-maintenance-call" => false,
30
+ "is-in-margin-call" => false,
31
+ "is-pattern-day-trader" => false,
32
+ "is-small-notional-futures-intra-day-enabled" => false,
33
+ "is-roll-the-day-forward-enabled" => true,
34
+ "are-far-otm-net-options-restricted" => false,
35
+ "options-level" => "Level 2",
36
+ "short-calls-enabled" => false,
37
+ "small-notional-futures-margin-rate-multiplier" => "1.0",
38
+ "is-equity-offering-enabled" => true,
39
+ "is-equity-offering-closing-only" => false,
40
+ "updated-at" => "2024-01-15T10:30:00Z",
41
+ "is-portfolio-margin-enabled" => false,
42
+ "day-trade-count" => 0
43
+ }
44
+ end
45
+
46
+ let(:trading_status) { Tastytrade::Models::TradingStatus.new(trading_status_data) }
19
47
 
20
48
  before do
49
+ allow(cli).to receive(:current_session).and_return(session)
50
+ allow(cli).to receive(:current_account).and_return(account)
21
51
  allow(cli).to receive(:config).and_return(config)
22
- allow(config).to receive(:get).with("current_username").and_return("testuser")
23
- allow(config).to receive(:get).with("environment").and_return("production")
52
+ allow(config).to receive(:get).with("current_account_number").and_return("5WT0001")
53
+ allow(account).to receive(:get_trading_status).with(session).and_return(trading_status)
24
54
  end
25
55
 
26
- describe "#status" do
27
- context "when not authenticated" do
56
+ describe "#trading_status" do
57
+ context "when authenticated with default account" do
58
+ it "displays trading status information" do
59
+ expect { cli.trading_status }.to output(/Trading Status for Account:.*5WT0001/).to_stdout
60
+ end
61
+
62
+ it "shows no restrictions when account is unrestricted" do
63
+ expect { cli.trading_status }.to output(/✓ No account restrictions/).to_stdout
64
+ end
65
+
66
+ it "displays trading permissions" do
67
+ output = capture_stdout { cli.trading_status }
68
+ expect(output).to include("Trading Permissions:")
69
+ expect(output).to include("Options Trading:")
70
+ expect(output).to include("Level 2")
71
+ expect(output).to include("Futures Trading:")
72
+ expect(output).to include("Cryptocurrency:")
73
+ end
74
+
75
+ it "displays account characteristics" do
76
+ output = capture_stdout { cli.trading_status }
77
+ expect(output).to include("Account Characteristics:")
78
+ expect(output).to include("Pattern Day Trader:")
79
+ end
80
+
81
+ it "displays additional information" do
82
+ output = capture_stdout { cli.trading_status }
83
+ expect(output).to include("Additional Information:")
84
+ expect(output).to include("Fee Schedule:")
85
+ expect(output).to include("standard")
86
+ expect(output).to include("Margin Type:")
87
+ expect(output).to include("REG_T")
88
+ end
89
+ end
90
+
91
+ context "when account has restrictions" do
92
+ let(:restricted_status_data) do
93
+ trading_status_data.merge(
94
+ "is-in-margin-call" => true,
95
+ "is-pattern-day-trader" => true,
96
+ "is-futures-closing-only" => true
97
+ )
98
+ end
99
+ let(:restricted_status) { Tastytrade::Models::TradingStatus.new(restricted_status_data) }
100
+
28
101
  before do
29
- allow(cli).to receive(:current_session).and_return(nil)
102
+ allow(account).to receive(:get_trading_status).with(session).and_return(restricted_status)
30
103
  end
31
104
 
32
- it "displays warning about no active session" do
33
- expect { cli.status }.to output(/No active session/).to_stderr
105
+ it "displays account restrictions with warnings" do
106
+ output = capture_stdout { cli.trading_status }
107
+ expect(output).to include("⚠ Account Restrictions:")
108
+ expect(output).to include("• Margin Call")
109
+ expect(output).to include("• Pattern Day Trader")
110
+ expect(output).to include("• Futures Closing Only")
34
111
  end
35
112
 
36
- it "suggests login command" do
37
- expect { cli.status }.to output(/Run 'tastytrade login' to authenticate/).to_stdout
113
+ it "shows futures as closing only" do
114
+ output = capture_stdout { cli.trading_status }
115
+ expect(output).to include("Futures Trading:")
116
+ expect(output).to include("Closing Only")
38
117
  end
39
118
  end
40
119
 
41
- context "when authenticated" do
120
+ context "with specific account option" do
121
+ let(:other_account) { instance_double(Tastytrade::Models::Account, account_number: "5WT0002") }
122
+
42
123
  before do
43
- allow(cli).to receive(:current_session).and_return(session)
44
- allow(session).to receive(:user).and_return(user)
45
- end
46
-
47
- context "without session expiration" do
48
- before do
49
- allow(session).to receive(:session_expiration).and_return(nil)
50
- allow(session).to receive(:remember_token).and_return(nil)
51
- end
52
-
53
- it "displays session status" do
54
- output = capture_stdout { cli.status }
55
- expect(output).to include("Session Status:")
56
- expect(output).to include("User: test@example.com")
57
- expect(output).to include("Environment: production")
58
- expect(output).to include("Status: Active")
59
- expect(output).to include("Expires in: Unknown")
60
- expect(output).to include("Remember token: Not available")
61
- expect(output).to include("Auto-refresh: Disabled")
62
- end
63
- end
64
-
65
- context "with non-expired session" do
66
- let(:future_time) { Time.now + 3600 }
67
-
68
- before do
69
- allow(session).to receive(:session_expiration).and_return(future_time)
70
- allow(session).to receive(:expired?).and_return(false)
71
- allow(session).to receive(:time_until_expiry).and_return(3600)
72
- allow(session).to receive(:remember_token).and_return("token123")
73
- end
74
-
75
- it "displays active status with time remaining" do
76
- output = capture_stdout { cli.status }
77
- expect(output).to include("Status: Active")
78
- expect(output).to include("Expires in: 1h 0m")
79
- expect(output).to include("Remember token: Available")
80
- expect(output).to include("Auto-refresh: Enabled")
81
- end
82
- end
83
-
84
- context "with expired session" do
85
- let(:past_time) { Time.now - 3600 }
86
-
87
- before do
88
- allow(session).to receive(:session_expiration).and_return(past_time)
89
- allow(session).to receive(:expired?).and_return(true)
90
- allow(session).to receive(:remember_token).and_return(nil)
91
- end
92
-
93
- it "displays expired status" do
94
- output = capture_stdout { cli.status }
95
- expect(output).to include("Status: Expired")
96
- expect(output).to include("Remember token: Not available")
97
- expect(output).to include("Auto-refresh: Disabled")
98
- end
124
+ allow(Tastytrade::Models::Account).to receive(:get)
125
+ .with(session, "5WT0002")
126
+ .and_return(other_account)
127
+ allow(other_account).to receive(:get_trading_status).with(session).and_return(trading_status)
128
+ cli.options = { account: "5WT0002" }
129
+ end
130
+
131
+ it "uses the specified account" do
132
+ expect(other_account).to receive(:get_trading_status).with(session)
133
+ cli.trading_status
99
134
  end
100
135
  end
101
- end
102
136
 
103
- describe "#refresh" do
104
137
  context "when not authenticated" do
105
138
  before do
106
139
  allow(cli).to receive(:current_session).and_return(nil)
107
140
  end
108
141
 
109
- it "displays error and exits" do
110
- expect { cli.refresh }.to raise_error(SystemExit) do |error|
111
- expect(error.status).to eq(1)
112
- end
113
- expect {
114
- begin
115
- cli.refresh
116
- rescue SystemExit
117
- end
118
- }.to output(/No active session to refresh/).to_stderr
142
+ it "requires authentication" do
143
+ expect { cli.trading_status }.to raise_error(SystemExit)
144
+ .and output(/You must be logged in/).to_stderr
119
145
  end
120
146
  end
121
147
 
122
- context "when authenticated without remember token" do
148
+ context "when API call fails" do
123
149
  before do
124
- allow(cli).to receive(:current_session).and_return(session)
125
- allow(session).to receive(:remember_token).and_return(nil)
126
- end
127
-
128
- it "displays error about missing remember token" do
129
- expect { cli.refresh }.to raise_error(SystemExit) do |error|
130
- expect(error.status).to eq(1)
131
- end
132
- expect {
133
- begin
134
- cli.refresh
135
- rescue SystemExit
136
- end
137
- }.to output(/No remember token available/).to_stderr
138
- end
139
-
140
- it "suggests using --remember flag" do
141
- expect {
142
- begin
143
- cli.refresh
144
- rescue SystemExit
145
- end
146
- }.to output(/Login with --remember flag/).to_stdout
150
+ allow(account).to receive(:get_trading_status)
151
+ .and_raise(Tastytrade::Error, "API error")
152
+ end
153
+
154
+ it "handles errors gracefully" do
155
+ expect { cli.trading_status }.to raise_error(SystemExit)
156
+ .and output(/Failed to fetch trading status/).to_stderr
147
157
  end
148
158
  end
149
159
 
150
- context "when authenticated with remember token" do
151
- let(:manager) { instance_double(Tastytrade::SessionManager) }
160
+ context "with PDT information" do
161
+ let(:pdt_status_data) do
162
+ trading_status_data.merge(
163
+ "is-pattern-day-trader" => true,
164
+ "day-trade-count" => 3,
165
+ "pdt-reset-on" => "2024-02-15"
166
+ )
167
+ end
168
+ let(:pdt_status) { Tastytrade::Models::TradingStatus.new(pdt_status_data) }
152
169
 
153
170
  before do
154
- allow(cli).to receive(:current_session).and_return(session)
155
- allow(session).to receive(:remember_token).and_return("token123")
156
- allow(session).to receive(:user).and_return(user)
157
- allow(Tastytrade::SessionManager).to receive(:new).and_return(manager)
158
- end
159
-
160
- context "on successful refresh" do
161
- before do
162
- allow(session).to receive(:refresh_session).and_return(session)
163
- allow(session).to receive(:time_until_expiry).and_return(3600)
164
- allow(manager).to receive(:save_session).and_return(true)
165
- end
166
-
167
- it "refreshes the session" do
168
- expect(session).to receive(:refresh_session)
169
- cli.refresh
170
- end
171
-
172
- it "saves the refreshed session" do
173
- expect(manager).to receive(:save_session).with(session)
174
- cli.refresh
175
- end
176
-
177
- it "displays success message" do
178
- expect { cli.refresh }.to output(/Session refreshed successfully/).to_stdout
179
- end
180
-
181
- it "displays new expiration time" do
182
- expect { cli.refresh }.to output(/Session expires in 1h 0m/).to_stdout
183
- end
184
- end
185
-
186
- context "on refresh failure" do
187
- before do
188
- allow(session).to receive(:refresh_session)
189
- .and_raise(Tastytrade::TokenRefreshError, "Invalid token")
190
- end
191
-
192
- it "displays error and exits" do
193
- expect { cli.refresh }.to raise_error(SystemExit) do |error|
194
- expect(error.status).to eq(1)
195
- end
196
- expect {
197
- begin
198
- cli.refresh
199
- rescue SystemExit
200
- end
201
- }.to output(/Failed to refresh session: Invalid token/).to_stderr
202
- end
171
+ allow(account).to receive(:get_trading_status).with(session).and_return(pdt_status)
172
+ end
173
+
174
+ it "displays PDT information" do
175
+ output = capture_stdout { cli.trading_status }
176
+ expect(output).to include("Pattern Day Trader:")
177
+ expect(output).to include("Yes")
178
+ expect(output).to include("Day Trade Count:")
179
+ expect(output).to include("3")
180
+ expect(output).to include("PDT Reset Date:")
181
+ expect(output).to include("2024-02-15")
203
182
  end
204
183
  end
205
184
  end
185
+
186
+ # Helper to capture stdout
187
+ def capture_stdout
188
+ original_stdout = $stdout
189
+ $stdout = StringIO.new
190
+ yield
191
+ $stdout.string
192
+ ensure
193
+ $stdout = original_stdout
194
+ end
206
195
  end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "tastytrade/file_store"
5
+ require "tmpdir"
6
+
7
+ RSpec.describe Tastytrade::FileStore do
8
+ let(:test_key) { "test_user_production" }
9
+ let(:test_value) { "test_token_123" }
10
+
11
+ let(:tmpdir) { Dir.mktmpdir }
12
+
13
+ before do
14
+ allow(described_class).to receive(:storage_directory).and_return(tmpdir)
15
+ end
16
+
17
+ after do
18
+ FileUtils.rm_rf(tmpdir) if File.exist?(tmpdir)
19
+ end
20
+
21
+ describe ".set" do
22
+ it "stores a credential successfully" do
23
+ expect(described_class.set(test_key, test_value)).to be true
24
+ end
25
+
26
+ it "creates the storage directory with proper permissions" do
27
+ described_class.set(test_key, test_value)
28
+ storage_dir = described_class.send(:storage_directory)
29
+ expect(File.exist?(storage_dir)).to be true
30
+ expect(File.stat(storage_dir).mode & 0o777).to eq(0o700)
31
+ end
32
+
33
+ it "creates credential files with proper permissions" do
34
+ described_class.set(test_key, test_value)
35
+ cred_path = described_class.send(:credential_path, test_key)
36
+ expect(File.stat(cred_path).mode & 0o777).to eq(0o600)
37
+ end
38
+
39
+ it "returns false for nil key" do
40
+ expect(described_class.set(nil, test_value)).to be false
41
+ end
42
+
43
+ it "returns false for nil value" do
44
+ expect(described_class.set(test_key, nil)).to be false
45
+ end
46
+
47
+ it "handles errors gracefully" do
48
+ allow(File).to receive(:write).and_raise(StandardError, "Write error")
49
+ expect(described_class.set(test_key, test_value)).to be false
50
+ end
51
+
52
+ it "converts symbols to strings" do
53
+ expect(described_class.set(:test_key, :test_value)).to be true
54
+ expect(described_class.get(:test_key)).to eq("test_value")
55
+ end
56
+ end
57
+
58
+ describe ".get" do
59
+ it "retrieves a credential successfully" do
60
+ described_class.set(test_key, test_value)
61
+ expect(described_class.get(test_key)).to eq(test_value)
62
+ end
63
+
64
+ it "returns nil for nil key" do
65
+ expect(described_class.get(nil)).to be nil
66
+ end
67
+
68
+ it "returns nil for non-existent key" do
69
+ expect(described_class.get("non_existent")).to be nil
70
+ end
71
+
72
+ it "handles errors gracefully" do
73
+ described_class.set(test_key, test_value)
74
+ allow(File).to receive(:read).and_raise(StandardError, "Read error")
75
+ expect(described_class.get(test_key)).to be nil
76
+ end
77
+
78
+ it "converts symbols to strings" do
79
+ described_class.set(:test_key, "value")
80
+ expect(described_class.get(:test_key)).to eq("value")
81
+ end
82
+ end
83
+
84
+ describe ".delete" do
85
+ it "deletes a credential successfully" do
86
+ described_class.set(test_key, test_value)
87
+ expect(described_class.delete(test_key)).to be true
88
+ expect(described_class.get(test_key)).to be nil
89
+ end
90
+
91
+ it "returns false for nil key" do
92
+ expect(described_class.delete(nil)).to be false
93
+ end
94
+
95
+ it "returns true for non-existent key" do
96
+ expect(described_class.delete("non_existent")).to be true
97
+ end
98
+
99
+ it "handles errors gracefully" do
100
+ described_class.set(test_key, test_value)
101
+ allow(File).to receive(:delete).and_raise(StandardError, "Delete error")
102
+ expect(described_class.delete(test_key)).to be false
103
+ end
104
+ end
105
+
106
+ describe ".available?" do
107
+ it "always returns true" do
108
+ expect(described_class.available?).to be true
109
+ end
110
+ end
111
+
112
+ describe "credential_path sanitization" do
113
+ it "sanitizes unsafe characters in keys" do
114
+ unsafe_key = "user@example.com/../../etc/passwd"
115
+ safe_path = described_class.send(:credential_path, unsafe_key)
116
+ expect(safe_path).to include("user@example.com_.._.._etc_passwd.cred")
117
+ expect(safe_path).not_to include("/../")
118
+ end
119
+
120
+ it "preserves allowed characters" do
121
+ safe_key = "user@example.com_production-123"
122
+ path = described_class.send(:credential_path, safe_key)
123
+ expect(path).to include("user@example.com_production-123.cred")
124
+ end
125
+ end
126
+ end
@@ -244,4 +244,107 @@ RSpec.describe Tastytrade::Models::AccountBalance do
244
244
  expect(subject.equity_buying_power).to eq(BigDecimal("123456.789"))
245
245
  end
246
246
  end
247
+
248
+ describe "#derivative_buying_power_usage_percentage" do
249
+ it "calculates derivative BP usage correctly" do
250
+ # Used BP = 15000 - 12000 = 3000
251
+ # Percentage = 3000 / 15000 * 100 = 20%
252
+ expect(subject.derivative_buying_power_usage_percentage).to eq(BigDecimal("20.00"))
253
+ end
254
+
255
+ context "with zero derivative buying power" do
256
+ let(:balance_data) do
257
+ {
258
+ "account-number" => "5WX12345",
259
+ "derivative-buying-power" => "0",
260
+ "available-trading-funds" => "0"
261
+ }
262
+ end
263
+
264
+ it "returns zero" do
265
+ expect(subject.derivative_buying_power_usage_percentage).to eq(BigDecimal("0"))
266
+ end
267
+ end
268
+ end
269
+
270
+ describe "#day_trading_buying_power_usage_percentage" do
271
+ it "calculates day trading BP usage correctly" do
272
+ # Used BP = 40000 - 12000 = 28000
273
+ # Percentage = 28000 / 40000 * 100 = 70%
274
+ expect(subject.day_trading_buying_power_usage_percentage).to eq(BigDecimal("70.00"))
275
+ end
276
+ end
277
+
278
+ describe "#minimum_buying_power" do
279
+ it "returns the smallest buying power value" do
280
+ # Min of 20000, 15000, 40000 = 15000
281
+ expect(subject.minimum_buying_power).to eq(BigDecimal("15000.00"))
282
+ end
283
+ end
284
+
285
+ describe "#sufficient_buying_power?" do
286
+ context "with equity buying power" do
287
+ it "returns true when amount is less than available BP" do
288
+ expect(subject.sufficient_buying_power?(10000)).to be true
289
+ expect(subject.sufficient_buying_power?("10000.00")).to be true
290
+ end
291
+
292
+ it "returns false when amount exceeds available BP" do
293
+ expect(subject.sufficient_buying_power?(25000)).to be false
294
+ end
295
+
296
+ it "returns true when amount equals available BP" do
297
+ expect(subject.sufficient_buying_power?(20000)).to be true
298
+ end
299
+ end
300
+
301
+ context "with derivative buying power" do
302
+ it "checks against derivative BP when specified" do
303
+ expect(subject.sufficient_buying_power?(10000, buying_power_type: :derivative)).to be true
304
+ expect(subject.sufficient_buying_power?(20000, buying_power_type: :derivative)).to be false
305
+ end
306
+ end
307
+
308
+ context "with day trading buying power" do
309
+ it "checks against day trading BP when specified" do
310
+ expect(subject.sufficient_buying_power?(35000, buying_power_type: :day_trading)).to be true
311
+ expect(subject.sufficient_buying_power?(45000, buying_power_type: :day_trading)).to be false
312
+ end
313
+ end
314
+ end
315
+
316
+ describe "#buying_power_impact_percentage" do
317
+ context "with equity buying power" do
318
+ it "calculates impact percentage correctly" do
319
+ # 5000 / 20000 * 100 = 25%
320
+ expect(subject.buying_power_impact_percentage(5000)).to eq(BigDecimal("25.00"))
321
+ end
322
+
323
+ it "handles string amounts" do
324
+ expect(subject.buying_power_impact_percentage("5000.00")).to eq(BigDecimal("25.00"))
325
+ end
326
+ end
327
+
328
+ context "with derivative buying power" do
329
+ it "calculates against derivative BP when specified" do
330
+ # 3000 / 15000 * 100 = 20%
331
+ expect(subject.buying_power_impact_percentage(3000, buying_power_type: :derivative))
332
+ .to eq(BigDecimal("20.00"))
333
+ end
334
+ end
335
+
336
+ context "with zero buying power" do
337
+ let(:balance_data) do
338
+ {
339
+ "account-number" => "5WX12345",
340
+ "equity-buying-power" => "0",
341
+ "available-trading-funds" => "0"
342
+ }
343
+ end
344
+
345
+ it "returns zero" do
346
+ expect(subject.buying_power_impact_percentage(1000)).to eq(BigDecimal("0"))
347
+ end
348
+ end
349
+ end
247
350
  end