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.
- checksums.yaml +4 -4
- data/.claude/commands/plan.md +13 -0
- data/.claude/commands/release-pr.md +12 -0
- data/CHANGELOG.md +180 -0
- data/README.md +424 -3
- data/ROADMAP.md +17 -17
- data/lib/tastytrade/cli/history_formatter.rb +304 -0
- data/lib/tastytrade/cli/orders.rb +749 -0
- data/lib/tastytrade/cli/positions_formatter.rb +114 -0
- data/lib/tastytrade/cli.rb +701 -12
- data/lib/tastytrade/cli_helpers.rb +111 -14
- data/lib/tastytrade/client.rb +7 -0
- data/lib/tastytrade/file_store.rb +83 -0
- data/lib/tastytrade/instruments/equity.rb +42 -0
- data/lib/tastytrade/models/account.rb +160 -2
- data/lib/tastytrade/models/account_balance.rb +46 -0
- data/lib/tastytrade/models/buying_power_effect.rb +61 -0
- data/lib/tastytrade/models/live_order.rb +272 -0
- data/lib/tastytrade/models/order_response.rb +106 -0
- data/lib/tastytrade/models/order_status.rb +84 -0
- data/lib/tastytrade/models/trading_status.rb +200 -0
- data/lib/tastytrade/models/transaction.rb +151 -0
- data/lib/tastytrade/models.rb +6 -0
- data/lib/tastytrade/order.rb +191 -0
- data/lib/tastytrade/order_validator.rb +355 -0
- data/lib/tastytrade/session.rb +26 -1
- data/lib/tastytrade/session_manager.rb +43 -14
- data/lib/tastytrade/version.rb +1 -1
- data/lib/tastytrade.rb +43 -0
- data/spec/exe/tastytrade_spec.rb +1 -1
- data/spec/tastytrade/cli/positions_spec.rb +267 -0
- data/spec/tastytrade/cli_auth_spec.rb +5 -0
- data/spec/tastytrade/cli_env_login_spec.rb +199 -0
- data/spec/tastytrade/cli_helpers_spec.rb +3 -26
- data/spec/tastytrade/cli_orders_spec.rb +168 -0
- data/spec/tastytrade/cli_status_spec.rb +153 -164
- data/spec/tastytrade/file_store_spec.rb +126 -0
- data/spec/tastytrade/models/account_balance_spec.rb +103 -0
- data/spec/tastytrade/models/account_order_history_spec.rb +229 -0
- data/spec/tastytrade/models/account_order_management_spec.rb +271 -0
- data/spec/tastytrade/models/account_place_order_spec.rb +125 -0
- data/spec/tastytrade/models/account_spec.rb +86 -15
- data/spec/tastytrade/models/buying_power_effect_spec.rb +250 -0
- data/spec/tastytrade/models/live_order_json_spec.rb +144 -0
- data/spec/tastytrade/models/live_order_spec.rb +295 -0
- data/spec/tastytrade/models/order_response_spec.rb +96 -0
- data/spec/tastytrade/models/order_status_spec.rb +113 -0
- data/spec/tastytrade/models/trading_status_spec.rb +260 -0
- data/spec/tastytrade/models/transaction_spec.rb +236 -0
- data/spec/tastytrade/order_edge_cases_spec.rb +163 -0
- data/spec/tastytrade/order_spec.rb +201 -0
- data/spec/tastytrade/order_validator_spec.rb +347 -0
- data/spec/tastytrade/session_env_spec.rb +169 -0
- data/spec/tastytrade/session_manager_spec.rb +43 -33
- metadata +34 -18
- data/lib/tastytrade/keyring_store.rb +0 -72
- 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
|
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(:
|
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("
|
23
|
-
allow(
|
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 "#
|
27
|
-
context "when
|
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(
|
102
|
+
allow(account).to receive(:get_trading_status).with(session).and_return(restricted_status)
|
30
103
|
end
|
31
104
|
|
32
|
-
it "displays
|
33
|
-
|
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 "
|
37
|
-
|
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 "
|
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(
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
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 "
|
110
|
-
expect { cli.
|
111
|
-
|
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
|
148
|
+
context "when API call fails" do
|
123
149
|
before do
|
124
|
-
allow(
|
125
|
-
|
126
|
-
end
|
127
|
-
|
128
|
-
it "
|
129
|
-
expect { cli.
|
130
|
-
|
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 "
|
151
|
-
let(:
|
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(
|
155
|
-
|
156
|
-
|
157
|
-
|
158
|
-
|
159
|
-
|
160
|
-
|
161
|
-
|
162
|
-
|
163
|
-
|
164
|
-
|
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
|