tastytrade 0.2.0

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 (59) hide show
  1. checksums.yaml +7 -0
  2. data/.claude/commands/release-pr.md +108 -0
  3. data/.github/ISSUE_TEMPLATE/feature_request.md +29 -0
  4. data/.github/ISSUE_TEMPLATE/roadmap_task.md +34 -0
  5. data/.github/dependabot.yml +11 -0
  6. data/.github/workflows/main.yml +75 -0
  7. data/.rspec +3 -0
  8. data/.rubocop.yml +101 -0
  9. data/.ruby-version +1 -0
  10. data/CHANGELOG.md +100 -0
  11. data/CLAUDE.md +78 -0
  12. data/CODE_OF_CONDUCT.md +81 -0
  13. data/CONTRIBUTING.md +89 -0
  14. data/DISCLAIMER.md +54 -0
  15. data/LICENSE.txt +24 -0
  16. data/README.md +235 -0
  17. data/ROADMAP.md +157 -0
  18. data/Rakefile +17 -0
  19. data/SECURITY.md +48 -0
  20. data/docs/getting_started.md +48 -0
  21. data/docs/python_sdk_analysis.md +181 -0
  22. data/exe/tastytrade +8 -0
  23. data/lib/tastytrade/cli.rb +604 -0
  24. data/lib/tastytrade/cli_config.rb +79 -0
  25. data/lib/tastytrade/cli_helpers.rb +178 -0
  26. data/lib/tastytrade/client.rb +117 -0
  27. data/lib/tastytrade/keyring_store.rb +72 -0
  28. data/lib/tastytrade/models/account.rb +129 -0
  29. data/lib/tastytrade/models/account_balance.rb +75 -0
  30. data/lib/tastytrade/models/base.rb +47 -0
  31. data/lib/tastytrade/models/current_position.rb +155 -0
  32. data/lib/tastytrade/models/user.rb +23 -0
  33. data/lib/tastytrade/models.rb +7 -0
  34. data/lib/tastytrade/session.rb +164 -0
  35. data/lib/tastytrade/session_manager.rb +160 -0
  36. data/lib/tastytrade/version.rb +5 -0
  37. data/lib/tastytrade.rb +31 -0
  38. data/sig/tastytrade.rbs +4 -0
  39. data/spec/exe/tastytrade_spec.rb +104 -0
  40. data/spec/spec_helper.rb +26 -0
  41. data/spec/tastytrade/cli_accounts_spec.rb +166 -0
  42. data/spec/tastytrade/cli_auth_spec.rb +216 -0
  43. data/spec/tastytrade/cli_config_spec.rb +180 -0
  44. data/spec/tastytrade/cli_helpers_spec.rb +248 -0
  45. data/spec/tastytrade/cli_interactive_spec.rb +54 -0
  46. data/spec/tastytrade/cli_logout_spec.rb +121 -0
  47. data/spec/tastytrade/cli_select_spec.rb +174 -0
  48. data/spec/tastytrade/cli_status_spec.rb +206 -0
  49. data/spec/tastytrade/client_spec.rb +210 -0
  50. data/spec/tastytrade/keyring_store_spec.rb +168 -0
  51. data/spec/tastytrade/models/account_balance_spec.rb +247 -0
  52. data/spec/tastytrade/models/account_spec.rb +206 -0
  53. data/spec/tastytrade/models/base_spec.rb +61 -0
  54. data/spec/tastytrade/models/current_position_spec.rb +444 -0
  55. data/spec/tastytrade/models/user_spec.rb +58 -0
  56. data/spec/tastytrade/session_manager_spec.rb +296 -0
  57. data/spec/tastytrade/session_spec.rb +392 -0
  58. data/spec/tastytrade_spec.rb +9 -0
  59. metadata +303 -0
@@ -0,0 +1,206 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "tastytrade/cli"
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
15
+ let(:cli) { Tastytrade::CLI.new }
16
+ let(:config) { instance_double(Tastytrade::CLIConfig) }
17
+ let(:session) { instance_double(Tastytrade::Session) }
18
+ let(:user) { instance_double(Tastytrade::Models::User, email: "test@example.com") }
19
+
20
+ before do
21
+ 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")
24
+ end
25
+
26
+ describe "#status" do
27
+ context "when not authenticated" do
28
+ before do
29
+ allow(cli).to receive(:current_session).and_return(nil)
30
+ end
31
+
32
+ it "displays warning about no active session" do
33
+ expect { cli.status }.to output(/No active session/).to_stderr
34
+ end
35
+
36
+ it "suggests login command" do
37
+ expect { cli.status }.to output(/Run 'tastytrade login' to authenticate/).to_stdout
38
+ end
39
+ end
40
+
41
+ context "when authenticated" do
42
+ 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
99
+ end
100
+ end
101
+ end
102
+
103
+ describe "#refresh" do
104
+ context "when not authenticated" do
105
+ before do
106
+ allow(cli).to receive(:current_session).and_return(nil)
107
+ end
108
+
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
119
+ end
120
+ end
121
+
122
+ context "when authenticated without remember token" do
123
+ 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
147
+ end
148
+ end
149
+
150
+ context "when authenticated with remember token" do
151
+ let(:manager) { instance_double(Tastytrade::SessionManager) }
152
+
153
+ 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
203
+ end
204
+ end
205
+ end
206
+ end
@@ -0,0 +1,210 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe Tastytrade::Client do
6
+ let(:base_url) { "https://api.example.com" }
7
+ let(:client) { described_class.new(base_url: base_url) }
8
+
9
+ describe "#initialize" do
10
+ it "sets the base URL" do
11
+ expect(client.base_url).to eq(base_url)
12
+ end
13
+ end
14
+
15
+ describe "HTTP methods" do
16
+ let(:path) { "/test" }
17
+ let(:response_body) { '{"key": "value"}' }
18
+ let(:parsed_response) { { "key" => "value" } }
19
+
20
+ describe "#get" do
21
+ it "makes a GET request and returns parsed JSON" do
22
+ stub_request(:get, "#{base_url}#{path}")
23
+ .to_return(status: 200, body: response_body, headers: { "Content-Type" => "application/json" })
24
+
25
+ result = client.get(path)
26
+ expect(result).to eq(parsed_response)
27
+ end
28
+
29
+ it "includes query parameters" do
30
+ params = { foo: "bar" }
31
+ stub_request(:get, "#{base_url}#{path}")
32
+ .with(query: params)
33
+ .to_return(status: 200, body: response_body)
34
+
35
+ client.get(path, params)
36
+ end
37
+ end
38
+
39
+ describe "#post" do
40
+ it "makes a POST request with JSON body" do
41
+ body = { data: "test" }
42
+ stub_request(:post, "#{base_url}#{path}")
43
+ .with(body: body.to_json,
44
+ headers: { "Content-Type" => "application/json" })
45
+ .to_return(status: 201, body: response_body)
46
+
47
+ result = client.post(path, body)
48
+ expect(result).to eq(parsed_response)
49
+ end
50
+ end
51
+
52
+ describe "#put" do
53
+ it "makes a PUT request with JSON body" do
54
+ body = { data: "updated" }
55
+ stub_request(:put, "#{base_url}#{path}")
56
+ .with(body: body.to_json,
57
+ headers: { "Content-Type" => "application/json" })
58
+ .to_return(status: 200, body: response_body)
59
+
60
+ result = client.put(path, body)
61
+ expect(result).to eq(parsed_response)
62
+ end
63
+ end
64
+
65
+ describe "#delete" do
66
+ it "makes a DELETE request" do
67
+ stub_request(:delete, "#{base_url}#{path}")
68
+ .to_return(status: 204, body: "")
69
+
70
+ result = client.delete(path)
71
+ expect(result).to be_nil
72
+ end
73
+ end
74
+ end
75
+
76
+ describe "error handling" do
77
+ let(:path) { "/test" }
78
+
79
+ it "raises InvalidCredentialsError for 401 response" do
80
+ stub_request(:get, "#{base_url}#{path}")
81
+ .to_return(status: 401, body: '{"error": "Unauthorized"}')
82
+
83
+ expect { client.get(path) }.to raise_error(Tastytrade::InvalidCredentialsError, /Authentication failed/)
84
+ end
85
+
86
+ it "raises SessionExpiredError for 403 response" do
87
+ stub_request(:get, "#{base_url}#{path}")
88
+ .to_return(status: 403, body: '{"error": "Session expired"}')
89
+
90
+ expect { client.get(path) }.to raise_error(Tastytrade::SessionExpiredError, /Session expired or invalid/)
91
+ end
92
+
93
+ it "raises error for 404 response" do
94
+ stub_request(:get, "#{base_url}#{path}")
95
+ .to_return(status: 404, body: '{"error": "Not found"}')
96
+
97
+ expect { client.get(path) }.to raise_error(Tastytrade::Error, /Resource not found/)
98
+ end
99
+
100
+ it "raises error for 500 response" do
101
+ stub_request(:get, "#{base_url}#{path}")
102
+ .to_return(status: 500, body: '{"error": "Internal server error"}')
103
+
104
+ expect { client.get(path) }.to raise_error(Tastytrade::Error, /Server error/)
105
+ end
106
+
107
+ it "handles invalid JSON response" do
108
+ stub_request(:get, "#{base_url}#{path}")
109
+ .to_return(status: 200, body: "invalid json")
110
+
111
+ expect { client.get(path) }.to raise_error(Tastytrade::Error, /Invalid JSON response/)
112
+ end
113
+
114
+ it "handles empty response body" do
115
+ stub_request(:get, "#{base_url}#{path}")
116
+ .to_return(status: 200, body: "")
117
+
118
+ result = client.get(path)
119
+ expect(result).to be_nil
120
+ end
121
+
122
+ context "with different error message formats" do
123
+ it "handles 'error' field" do
124
+ stub_request(:get, "#{base_url}#{path}")
125
+ .to_return(status: 400, body: '{"error": "Bad request"}')
126
+
127
+ expect { client.get(path) }.to raise_error(Tastytrade::Error, /Bad request/)
128
+ end
129
+
130
+ it "handles 'message' field" do
131
+ stub_request(:get, "#{base_url}#{path}")
132
+ .to_return(status: 400, body: '{"message": "Invalid input"}')
133
+
134
+ expect { client.get(path) }.to raise_error(Tastytrade::Error, /Invalid input/)
135
+ end
136
+
137
+ it "handles 'reason' field" do
138
+ stub_request(:get, "#{base_url}#{path}")
139
+ .to_return(status: 400, body: '{"reason": "Missing parameter"}')
140
+
141
+ expect { client.get(path) }.to raise_error(Tastytrade::Error, /Missing parameter/)
142
+ end
143
+ end
144
+ end
145
+
146
+ describe "retry behavior" do
147
+ let(:path) { "/test" }
148
+
149
+ it "retries on 503 errors" do
150
+ stub_request(:get, "#{base_url}#{path}")
151
+ .to_return(status: 503, body: "")
152
+ .then.to_return(status: 200, body: '{"success": true}')
153
+
154
+ result = client.get(path)
155
+ expect(result).to eq({ "success" => true })
156
+ end
157
+
158
+ it "does not retry on POST requests" do
159
+ # POST is not in the retry methods list by default
160
+ stub_request(:post, "#{base_url}#{path}")
161
+ .to_return(status: 503, body: "")
162
+ .times(1)
163
+
164
+ expect { client.post(path) }.to raise_error(Tastytrade::Error, /Server error/)
165
+ end
166
+ end
167
+
168
+ describe "timeout handling" do
169
+ let(:path) { "/test" }
170
+
171
+ it "raises NetworkTimeoutError on GET timeout" do
172
+ stub_request(:get, "#{base_url}#{path}")
173
+ .to_timeout
174
+
175
+ expect { client.get(path) }.to raise_error(Tastytrade::NetworkTimeoutError, /Request timed out/)
176
+ end
177
+
178
+ it "raises NetworkTimeoutError on POST timeout" do
179
+ stub_request(:post, "#{base_url}#{path}")
180
+ .to_timeout
181
+
182
+ expect { client.post(path) }.to raise_error(Tastytrade::NetworkTimeoutError, /Request timed out/)
183
+ end
184
+
185
+ it "raises NetworkTimeoutError on PUT timeout" do
186
+ stub_request(:put, "#{base_url}#{path}")
187
+ .to_timeout
188
+
189
+ expect { client.put(path) }.to raise_error(Tastytrade::NetworkTimeoutError, /Request timed out/)
190
+ end
191
+
192
+ it "raises NetworkTimeoutError on DELETE timeout" do
193
+ stub_request(:delete, "#{base_url}#{path}")
194
+ .to_timeout
195
+
196
+ expect { client.delete(path) }.to raise_error(Tastytrade::NetworkTimeoutError, /Request timed out/)
197
+ end
198
+ end
199
+
200
+ describe "timeout configuration" do
201
+ it "accepts custom timeout" do
202
+ custom_client = described_class.new(base_url: base_url, timeout: 60)
203
+ expect(custom_client.instance_variable_get(:@timeout)).to eq(60)
204
+ end
205
+
206
+ it "uses default timeout when not specified" do
207
+ expect(client.instance_variable_get(:@timeout)).to eq(Tastytrade::Client::DEFAULT_TIMEOUT)
208
+ end
209
+ end
210
+ end
@@ -0,0 +1,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "tastytrade/keyring_store"
5
+
6
+ RSpec.describe Tastytrade::KeyringStore do
7
+ let(:mock_backend) { instance_double(Keyring) }
8
+
9
+ before do
10
+ # Reset the backend before each test
11
+ described_class.instance_variable_set(:@backend, nil)
12
+ allow(Keyring).to receive(:new).and_return(mock_backend)
13
+ # Suppress warnings during tests
14
+ allow(described_class).to receive(:warn)
15
+ end
16
+
17
+ describe ".set" do
18
+ it "stores a credential successfully" do
19
+ expect(mock_backend).to receive(:set_password)
20
+ .with("tastytrade-ruby", "test_key", "test_value")
21
+
22
+ expect(described_class.set("test_key", "test_value")).to be true
23
+ end
24
+
25
+ it "returns false for nil key" do
26
+ expect(mock_backend).not_to receive(:set_password)
27
+ expect(described_class.set(nil, "value")).to be false
28
+ end
29
+
30
+ it "returns false for nil value" do
31
+ expect(mock_backend).not_to receive(:set_password)
32
+ expect(described_class.set("key", nil)).to be false
33
+ end
34
+
35
+ it "handles errors gracefully" do
36
+ expect(mock_backend).to receive(:set_password)
37
+ .and_raise(StandardError, "Keyring error")
38
+
39
+ expect(described_class.set("key", "value")).to be false
40
+ end
41
+
42
+ it "converts symbols to strings" do
43
+ expect(mock_backend).to receive(:set_password)
44
+ .with("tastytrade-ruby", "symbol_key", "value")
45
+
46
+ described_class.set(:symbol_key, "value")
47
+ end
48
+ end
49
+
50
+ describe ".get" do
51
+ it "retrieves a credential successfully" do
52
+ expect(mock_backend).to receive(:get_password)
53
+ .with("tastytrade-ruby", "test_key")
54
+ .and_return("test_value")
55
+
56
+ expect(described_class.get("test_key")).to eq("test_value")
57
+ end
58
+
59
+ it "returns nil for nil key" do
60
+ expect(mock_backend).not_to receive(:get_password)
61
+ expect(described_class.get(nil)).to be_nil
62
+ end
63
+
64
+ it "handles errors gracefully" do
65
+ expect(mock_backend).to receive(:get_password)
66
+ .and_raise(StandardError, "Keyring error")
67
+
68
+ expect(described_class.get("key")).to be_nil
69
+ end
70
+
71
+ it "converts symbols to strings" do
72
+ expect(mock_backend).to receive(:get_password)
73
+ .with("tastytrade-ruby", "symbol_key")
74
+ .and_return("value")
75
+
76
+ expect(described_class.get(:symbol_key)).to eq("value")
77
+ end
78
+ end
79
+
80
+ describe ".delete" do
81
+ it "deletes a credential successfully" do
82
+ expect(mock_backend).to receive(:delete_password)
83
+ .with("tastytrade-ruby", "test_key")
84
+
85
+ expect(described_class.delete("test_key")).to be true
86
+ end
87
+
88
+ it "returns false for nil key" do
89
+ expect(mock_backend).not_to receive(:delete_password)
90
+ expect(described_class.delete(nil)).to be false
91
+ end
92
+
93
+ it "handles errors gracefully" do
94
+ expect(mock_backend).to receive(:delete_password)
95
+ .and_raise(StandardError, "Keyring error")
96
+
97
+ expect(described_class.delete("key")).to be false
98
+ end
99
+ end
100
+
101
+ describe ".available?" do
102
+ context "when keyring is available" do
103
+ it "returns true" do
104
+ expect(described_class.available?).to be true
105
+ end
106
+ end
107
+
108
+ context "when keyring is not available" do
109
+ before do
110
+ allow(Keyring).to receive(:new).and_raise(StandardError, "No backend")
111
+ end
112
+
113
+ it "returns false" do
114
+ expect(described_class.available?).to be false
115
+ end
116
+ end
117
+
118
+ context "when backend is nil" do
119
+ before do
120
+ allow(Keyring).to receive(:new).and_return(nil)
121
+ end
122
+
123
+ it "returns false" do
124
+ expect(described_class.available?).to be false
125
+ end
126
+ end
127
+ end
128
+
129
+ describe "error messages" do
130
+ context "when warnings are not suppressed" do
131
+ before do
132
+ allow(described_class).to receive(:warn).and_call_original
133
+ end
134
+
135
+ it "warns on set failure" do
136
+ expect(mock_backend).to receive(:set_password)
137
+ .and_raise(StandardError, "Set error")
138
+
139
+ expect { described_class.set("key", "value") }
140
+ .to output(/Failed to store credential: Set error/).to_stderr
141
+ end
142
+
143
+ it "warns on get failure" do
144
+ expect(mock_backend).to receive(:get_password)
145
+ .and_raise(StandardError, "Get error")
146
+
147
+ expect { described_class.get("key") }
148
+ .to output(/Failed to retrieve credential: Get error/).to_stderr
149
+ end
150
+
151
+ it "warns on delete failure" do
152
+ expect(mock_backend).to receive(:delete_password)
153
+ .and_raise(StandardError, "Delete error")
154
+
155
+ expect { described_class.delete("key") }
156
+ .to output(/Failed to delete credential: Delete error/).to_stderr
157
+ end
158
+
159
+ it "warns when keyring is not available" do
160
+ allow(Keyring).to receive(:new).and_raise(StandardError, "No backend")
161
+
162
+ # Force backend initialization
163
+ expect { described_class.send(:backend) }
164
+ .to output(/Keyring not available: No backend/).to_stderr
165
+ end
166
+ end
167
+ end
168
+ end