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,248 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "tastytrade/cli_helpers"
5
+ require "tastytrade/cli_config"
6
+
7
+ RSpec.describe Tastytrade::CLIHelpers do
8
+ # Create a test class that includes the module
9
+ let(:test_class) do
10
+ Class.new do
11
+ include Tastytrade::CLIHelpers
12
+ end
13
+ end
14
+
15
+ let(:instance) { test_class.new }
16
+
17
+ describe "output helpers" do
18
+ describe "#error" do
19
+ it "outputs red error message to stderr" do
20
+ expect { instance.error("Something went wrong") }
21
+ .to output(/Error: Something went wrong/).to_stderr
22
+ end
23
+ end
24
+
25
+ describe "#warning" do
26
+ it "outputs yellow warning message to stderr" do
27
+ expect { instance.warning("Be careful") }
28
+ .to output(/Warning: Be careful/).to_stderr
29
+ end
30
+ end
31
+
32
+ describe "#success" do
33
+ it "outputs green success message with checkmark" do
34
+ expect { instance.success("Operation completed") }
35
+ .to output(/✓ Operation completed/).to_stdout
36
+ end
37
+ end
38
+
39
+ describe "#info" do
40
+ it "outputs cyan info message with arrow" do
41
+ expect { instance.info("Processing...") }
42
+ .to output(/→ Processing.../).to_stdout
43
+ end
44
+ end
45
+ end
46
+
47
+ describe "#format_currency" do
48
+ it "formats nil as $0.00" do
49
+ expect(instance.format_currency(nil)).to eq("$0.00")
50
+ end
51
+
52
+ it "formats zero as $0.00" do
53
+ expect(instance.format_currency(0)).to eq("$0.00")
54
+ end
55
+
56
+ it "formats positive values" do
57
+ expect(instance.format_currency(1234.56)).to eq("$1,234.56")
58
+ end
59
+
60
+ it "formats negative values" do
61
+ expect(instance.format_currency(-1234.56)).to eq("-$1,234.56")
62
+ end
63
+
64
+ it "formats small values" do
65
+ expect(instance.format_currency(0.99)).to eq("$0.99")
66
+ end
67
+
68
+ it "formats large values with commas" do
69
+ expect(instance.format_currency(1_234_567.89)).to eq("$1,234,567.89")
70
+ end
71
+ end
72
+
73
+ describe "#color_value" do
74
+ it "colors positive values green" do
75
+ result = instance.color_value(100)
76
+ expect(result).to include("$100.00")
77
+ # Pastel adds ANSI codes, so we check for presence of the value
78
+ expect(instance.pastel.strip(result)).to eq("$100.00")
79
+ end
80
+
81
+ it "colors negative values red" do
82
+ result = instance.color_value(-100)
83
+ expect(result).to include("$100.00")
84
+ expect(instance.pastel.strip(result)).to eq("-$100.00")
85
+ end
86
+
87
+ it "colors zero as dim" do
88
+ result = instance.color_value(0)
89
+ expect(instance.pastel.strip(result)).to eq("$0.00")
90
+ end
91
+
92
+ it "can format without currency" do
93
+ result = instance.color_value(42, format_as_currency: false)
94
+ expect(instance.pastel.strip(result)).to eq("42")
95
+ end
96
+ end
97
+
98
+ describe "#pastel" do
99
+ it "returns a Pastel instance" do
100
+ expect(instance.pastel).to be_a(Pastel::Delegator)
101
+ end
102
+
103
+ it "memoizes the instance" do
104
+ pastel1 = instance.pastel
105
+ pastel2 = instance.pastel
106
+ expect(pastel1).to be(pastel2)
107
+ end
108
+ end
109
+
110
+ describe "#prompt" do
111
+ it "returns a TTY::Prompt instance" do
112
+ expect(instance.prompt).to be_a(TTY::Prompt)
113
+ end
114
+
115
+ it "memoizes the instance" do
116
+ prompt1 = instance.prompt
117
+ prompt2 = instance.prompt
118
+ expect(prompt1).to be(prompt2)
119
+ end
120
+ end
121
+
122
+ describe "#config" do
123
+ it "returns a Config instance" do
124
+ expect(instance.config).to be_a(Tastytrade::CLIConfig)
125
+ end
126
+
127
+ it "memoizes the instance" do
128
+ config1 = instance.config
129
+ config2 = instance.config
130
+ expect(config1).to be(config2)
131
+ end
132
+ end
133
+
134
+ describe "authentication helpers" do
135
+ describe "#authenticated?" do
136
+ context "when no session exists" do
137
+ it "returns false" do
138
+ expect(instance.authenticated?).to be false
139
+ end
140
+ end
141
+ end
142
+
143
+ describe "#require_authentication!" do
144
+ context "when not authenticated" do
145
+ it "exits with error message" do
146
+ expect(instance).to receive(:exit).with(1)
147
+ expect { instance.require_authentication! }
148
+ .to output(/You must be logged in/).to_stderr
149
+ end
150
+
151
+ it "suggests login command" do
152
+ expect(instance).to receive(:exit).with(1)
153
+ expect { instance.require_authentication! }
154
+ .to output(/Run 'tastytrade login'/).to_stdout
155
+ end
156
+ end
157
+ end
158
+ end
159
+
160
+ describe "class methods" do
161
+ it "sets exit_on_failure? to true" do
162
+ expect(test_class.exit_on_failure?).to be true
163
+ end
164
+ end
165
+
166
+ describe "#current_account" do
167
+ let(:session) { instance_double(Tastytrade::Session) }
168
+ let(:config) { instance_double(Tastytrade::CLIConfig) }
169
+ let(:account) do
170
+ instance_double(Tastytrade::Models::Account,
171
+ account_number: "5WX12345",
172
+ nickname: "Test Account")
173
+ end
174
+
175
+ before do
176
+ allow(instance).to receive(:current_session).and_return(session)
177
+ allow(instance).to receive(:config).and_return(config)
178
+ end
179
+
180
+ context "when account number is saved" do
181
+ before do
182
+ allow(config).to receive(:get).with("current_account_number").and_return("5WX12345")
183
+ allow(Tastytrade::Models::Account).to receive(:get)
184
+ .with(session, "5WX12345").and_return(account)
185
+ end
186
+
187
+ it "returns the account" do
188
+ expect(instance.current_account).to eq(account)
189
+ end
190
+
191
+ it "caches the account" do
192
+ expect(Tastytrade::Models::Account).to receive(:get).once
193
+ 2.times { instance.current_account }
194
+ end
195
+ end
196
+
197
+ context "when no account number is saved" do
198
+ before do
199
+ allow(config).to receive(:get).with("current_account_number").and_return(nil)
200
+ end
201
+
202
+ it "returns nil" do
203
+ expect(instance.current_account).to be_nil
204
+ end
205
+ end
206
+
207
+ context "when account fetch fails" do
208
+ before do
209
+ allow(config).to receive(:get).with("current_account_number").and_return("5WX12345")
210
+ allow(Tastytrade::Models::Account).to receive(:get)
211
+ .and_raise(StandardError, "Network error")
212
+ end
213
+
214
+ it "returns nil" do
215
+ expect { instance.current_account }.to output(/Failed to load current account/).to_stderr
216
+ expect(instance.current_account).to be_nil
217
+ end
218
+ end
219
+ end
220
+
221
+ describe "#current_account_number" do
222
+ let(:config) { instance_double(Tastytrade::CLIConfig) }
223
+
224
+ before do
225
+ allow(instance).to receive(:config).and_return(config)
226
+ end
227
+
228
+ context "when account number is saved" do
229
+ before do
230
+ allow(config).to receive(:get).with("current_account_number").and_return("5WX12345")
231
+ end
232
+
233
+ it "returns the account number" do
234
+ expect(instance.current_account_number).to eq("5WX12345")
235
+ end
236
+ end
237
+
238
+ context "when no account number is saved" do
239
+ before do
240
+ allow(config).to receive(:get).with("current_account_number").and_return(nil)
241
+ end
242
+
243
+ it "returns nil" do
244
+ expect(instance.current_account_number).to be_nil
245
+ end
246
+ end
247
+ end
248
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "tastytrade/cli"
5
+
6
+ RSpec.describe "Tastytrade::CLI interactive command" do
7
+ let(:cli) { Tastytrade::CLI.new }
8
+ let(:session) { instance_double(Tastytrade::Session) }
9
+ let(:prompt) { instance_double(TTY::Prompt) }
10
+ let(:config) { instance_double(Tastytrade::CLIConfig) }
11
+
12
+ before do
13
+ allow(cli).to receive(:prompt).and_return(prompt)
14
+ allow(cli).to receive(:config).and_return(config)
15
+ allow(cli).to receive(:exit)
16
+ allow(config).to receive(:get).with("current_account_number").and_return(nil)
17
+ end
18
+
19
+ describe "#interactive" do
20
+ context "when authenticated" do
21
+ before do
22
+ allow(cli).to receive(:current_session).and_return(session)
23
+ allow(cli).to receive(:interactive_mode)
24
+ end
25
+
26
+ it "enters interactive mode" do
27
+ expect(cli).to receive(:interactive_mode)
28
+ cli.interactive
29
+ end
30
+ end
31
+
32
+ context "when not authenticated" do
33
+ before do
34
+ allow(cli).to receive(:current_session).and_return(nil)
35
+ allow(cli).to receive(:exit).with(1).and_raise(SystemExit)
36
+ end
37
+
38
+ it "requires authentication" do
39
+ expect { cli.interactive }.to raise_error(SystemExit)
40
+ .and output(/You must be logged in/).to_stderr
41
+ end
42
+
43
+ it "suggests login command" do
44
+ expect { cli.interactive }.to raise_error(SystemExit)
45
+ .and output(/Run 'tastytrade login'/).to_stdout
46
+ end
47
+
48
+ it "does not enter interactive mode" do
49
+ expect(cli).not_to receive(:interactive_mode)
50
+ expect { cli.interactive }.to raise_error(SystemExit)
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "tastytrade/cli"
5
+
6
+ RSpec.describe "Tastytrade::CLI logout command" do
7
+ let(:cli) { Tastytrade::CLI.new }
8
+ let(:config) { instance_double(Tastytrade::CLIConfig) }
9
+ let(:session_manager) { instance_double(Tastytrade::SessionManager) }
10
+
11
+ before do
12
+ allow(cli).to receive(:config).and_return(config)
13
+ allow(cli).to receive(:exit) # Prevent actual exit during tests
14
+ end
15
+
16
+ describe "#logout" do
17
+ context "with active session" do
18
+ before do
19
+ allow(config).to receive(:get).with("current_username").and_return("test@example.com")
20
+ allow(config).to receive(:get).with("environment").and_return("production")
21
+ allow(Tastytrade::SessionManager).to receive(:new).with(
22
+ username: "test@example.com",
23
+ environment: "production"
24
+ ).and_return(session_manager)
25
+ end
26
+
27
+ context "when logout succeeds" do
28
+ before do
29
+ allow(session_manager).to receive(:clear_session!).and_return(true)
30
+ allow(config).to receive(:delete)
31
+ end
32
+
33
+ it "clears session credentials" do
34
+ expect(session_manager).to receive(:clear_session!)
35
+ cli.logout
36
+ end
37
+
38
+ it "removes config entries" do
39
+ expect(config).to receive(:delete).with("current_username")
40
+ expect(config).to receive(:delete).with("environment")
41
+ expect(config).to receive(:delete).with("last_login")
42
+ cli.logout
43
+ end
44
+
45
+ it "displays success message" do
46
+ expect { cli.logout }.to output(/Successfully logged out/).to_stdout
47
+ end
48
+ end
49
+
50
+ context "when logout fails" do
51
+ before do
52
+ allow(session_manager).to receive(:clear_session!).and_return(false)
53
+ end
54
+
55
+ it "displays error message" do
56
+ expect(cli).to receive(:exit).with(1)
57
+ expect { cli.logout }.to output(/Failed to logout completely/).to_stderr
58
+ end
59
+
60
+ it "exits with status 1" do
61
+ expect(cli).to receive(:exit).with(1)
62
+ cli.logout
63
+ end
64
+ end
65
+ end
66
+
67
+ context "with sandbox environment" do
68
+ before do
69
+ allow(config).to receive(:get).with("current_username").and_return("test@example.com")
70
+ allow(config).to receive(:get).with("environment").and_return("sandbox")
71
+ allow(session_manager).to receive(:clear_session!).and_return(true)
72
+ allow(config).to receive(:delete)
73
+ end
74
+
75
+ it "creates session manager with sandbox environment" do
76
+ expect(Tastytrade::SessionManager).to receive(:new).with(
77
+ username: "test@example.com",
78
+ environment: "sandbox"
79
+ ).and_return(session_manager)
80
+ cli.logout
81
+ end
82
+ end
83
+
84
+ context "without active session" do
85
+ before do
86
+ allow(config).to receive(:get).with("current_username").and_return(nil)
87
+ end
88
+
89
+ it "displays warning message" do
90
+ expect { cli.logout }.to output(/No active session found/).to_stderr
91
+ end
92
+
93
+ it "doesn't attempt to clear session" do
94
+ expect(Tastytrade::SessionManager).not_to receive(:new)
95
+ cli.logout
96
+ end
97
+
98
+ it "doesn't exit with error" do
99
+ expect(cli).not_to receive(:exit)
100
+ cli.logout
101
+ end
102
+ end
103
+
104
+ context "with missing environment" do
105
+ before do
106
+ allow(config).to receive(:get).with("current_username").and_return("test@example.com")
107
+ allow(config).to receive(:get).with("environment").and_return(nil)
108
+ allow(session_manager).to receive(:clear_session!).and_return(true)
109
+ allow(config).to receive(:delete)
110
+ end
111
+
112
+ it "defaults to production environment" do
113
+ expect(Tastytrade::SessionManager).to receive(:new).with(
114
+ username: "test@example.com",
115
+ environment: "production"
116
+ ).and_return(session_manager)
117
+ cli.logout
118
+ end
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "tastytrade/cli"
5
+
6
+ RSpec.describe "Tastytrade::CLI select command" do
7
+ let(:cli) { Tastytrade::CLI.new }
8
+ let(:config) { instance_double(Tastytrade::CLIConfig) }
9
+ let(:prompt) { instance_double(TTY::Prompt) }
10
+ let(:session) { instance_double(Tastytrade::Session) }
11
+ let(:account1) do
12
+ instance_double(Tastytrade::Models::Account,
13
+ account_number: "5WX12345",
14
+ nickname: "Main Account",
15
+ account_type_name: "Margin")
16
+ end
17
+ let(:account2) do
18
+ instance_double(Tastytrade::Models::Account,
19
+ account_number: "5WX67890",
20
+ nickname: nil,
21
+ account_type_name: "Cash")
22
+ end
23
+
24
+ before do
25
+ allow(cli).to receive(:config).and_return(config)
26
+ allow(cli).to receive(:prompt).and_return(prompt)
27
+ allow(cli).to receive(:exit) # Prevent actual exit during tests
28
+ allow(cli).to receive(:current_session).and_return(session)
29
+ allow(cli).to receive(:authenticated?).and_return(true)
30
+ allow(config).to receive(:get).with("current_account_number").and_return(nil)
31
+ allow(config).to receive(:set)
32
+ end
33
+
34
+ describe "#select" do
35
+ context "when not authenticated" do
36
+ before do
37
+ allow(cli).to receive(:authenticated?).and_return(false)
38
+ end
39
+
40
+ it "requires authentication" do
41
+ expect(cli).to receive(:require_authentication!)
42
+ allow(Tastytrade::Models::Account).to receive(:get_all).with(session).and_return([])
43
+ cli.select
44
+ end
45
+ end
46
+
47
+ context "with no accounts" do
48
+ before do
49
+ allow(Tastytrade::Models::Account).to receive(:get_all).with(session).and_return([])
50
+ end
51
+
52
+ it "returns early with warning" do
53
+ expect { cli.select }.to output(/No accounts found/).to_stderr
54
+ end
55
+
56
+ it "doesn't prompt for selection" do
57
+ expect(prompt).not_to receive(:select)
58
+ cli.select
59
+ end
60
+ end
61
+
62
+ context "with single account" do
63
+ before do
64
+ allow(Tastytrade::Models::Account).to receive(:get_all).with(session).and_return([account1])
65
+ end
66
+
67
+ it "auto-selects the account" do
68
+ expect(config).to receive(:set).with("current_account_number", "5WX12345")
69
+ cli.select
70
+ end
71
+
72
+ it "displays success message" do
73
+ expect { cli.select }.to output(/Using account: 5WX12345/).to_stdout
74
+ end
75
+
76
+ it "doesn't prompt for selection" do
77
+ expect(prompt).not_to receive(:select)
78
+ cli.select
79
+ end
80
+ end
81
+
82
+ context "with multiple accounts" do
83
+ before do
84
+ allow(Tastytrade::Models::Account).to receive(:get_all).with(session).and_return([account1, account2])
85
+ end
86
+
87
+ it "prompts for account selection" do
88
+ expect(prompt).to receive(:select).with(
89
+ "Choose an account:",
90
+ [
91
+ { name: "5WX12345 - Main Account (Margin)", value: "5WX12345" },
92
+ { name: "5WX67890 (Cash)", value: "5WX67890" }
93
+ ]
94
+ ).and_return("5WX12345")
95
+
96
+ cli.select
97
+ end
98
+
99
+ it "saves selected account" do
100
+ allow(prompt).to receive(:select).and_return("5WX67890")
101
+
102
+ expect(config).to receive(:set).with("current_account_number", "5WX67890")
103
+ cli.select
104
+ end
105
+
106
+ it "displays success message" do
107
+ allow(prompt).to receive(:select).and_return("5WX12345")
108
+
109
+ expect { cli.select }.to output(/Selected account: 5WX12345/).to_stdout
110
+ end
111
+
112
+ context "with current account selected" do
113
+ before do
114
+ allow(config).to receive(:get).with("current_account_number").and_return("5WX12345")
115
+ end
116
+
117
+ it "marks current account in choices" do
118
+ expect(prompt).to receive(:select).with(
119
+ "Choose an account:",
120
+ [
121
+ { name: "5WX12345 - Main Account (Margin) [current]", value: "5WX12345" },
122
+ { name: "5WX67890 (Cash)", value: "5WX67890" }
123
+ ]
124
+ ).and_return("5WX12345")
125
+
126
+ cli.select
127
+ end
128
+ end
129
+
130
+ context "with account without nickname" do
131
+ it "handles nil nickname properly" do
132
+ expect(prompt).to receive(:select).with(
133
+ "Choose an account:",
134
+ [
135
+ { name: "5WX12345 - Main Account (Margin)", value: "5WX12345" },
136
+ { name: "5WX67890 (Cash)", value: "5WX67890" }
137
+ ]
138
+ ).and_return("5WX67890")
139
+
140
+ cli.select
141
+ end
142
+ end
143
+ end
144
+
145
+ context "on API error" do
146
+ before do
147
+ allow(Tastytrade::Models::Account).to receive(:get_all).with(session)
148
+ .and_raise(Tastytrade::Error, "Network error")
149
+ end
150
+
151
+ it "displays error message" do
152
+ expect(cli).to receive(:exit).with(1)
153
+ expect { cli.select }.to output(/Failed to fetch accounts: Network error/).to_stderr
154
+ end
155
+
156
+ it "exits with status 1" do
157
+ expect(cli).to receive(:exit).with(1)
158
+ cli.select
159
+ end
160
+ end
161
+
162
+ context "on unexpected error" do
163
+ before do
164
+ allow(Tastytrade::Models::Account).to receive(:get_all).with(session)
165
+ .and_raise(StandardError, "Unexpected issue")
166
+ end
167
+
168
+ it "displays generic error message" do
169
+ expect(cli).to receive(:exit).with(1)
170
+ expect { cli.select }.to output(/Unexpected error: Unexpected issue/).to_stderr
171
+ end
172
+ end
173
+ end
174
+ end