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,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "tastytrade/cli"
5
+ require "tty-table"
6
+
7
+ RSpec.describe "Tastytrade::CLI accounts command" do
8
+ let(:cli) { Tastytrade::CLI.new }
9
+ let(:config) { instance_double(Tastytrade::CLIConfig) }
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(:exit) # Prevent actual exit during tests
27
+ allow(cli).to receive(:current_session).and_return(session)
28
+ allow(cli).to receive(:authenticated?).and_return(true)
29
+ allow(config).to receive(:get).with("current_account_number").and_return(nil)
30
+ allow(config).to receive(:set)
31
+ end
32
+
33
+ describe "#accounts" do
34
+ context "when not authenticated" do
35
+ before do
36
+ allow(cli).to receive(:authenticated?).and_return(false)
37
+ end
38
+
39
+ it "requires authentication" do
40
+ expect(cli).to receive(:require_authentication!)
41
+ # Mock the session.get call that happens after authentication check
42
+ allow(session).to receive(:get).with("/customers/me/accounts/", {}).and_return(
43
+ { "data" => { "items" => [] } }
44
+ )
45
+ cli.accounts
46
+ end
47
+ end
48
+
49
+ context "with no accounts" do
50
+ before do
51
+ allow(Tastytrade::Models::Account).to receive(:get_all).with(session).and_return([])
52
+ end
53
+
54
+ it "displays warning message" do
55
+ expect { cli.accounts }.to output(/No accounts found/).to_stderr
56
+ end
57
+
58
+ it "displays fetching message" do
59
+ expect { cli.accounts }.to output(/Fetching accounts/).to_stdout
60
+ end
61
+ end
62
+
63
+ context "with single account" do
64
+ before do
65
+ allow(Tastytrade::Models::Account).to receive(:get_all).with(session).and_return([account1])
66
+ end
67
+
68
+ it "displays account in table format" do
69
+ expect { cli.accounts }.to output(/5WX12345/).to_stdout
70
+ expect { cli.accounts }.to output(/Main Account/).to_stdout
71
+ expect { cli.accounts }.to output(/Margin/).to_stdout
72
+ end
73
+
74
+ it "auto-selects the account" do
75
+ expect(config).to receive(:set).with("current_account_number", "5WX12345")
76
+ cli.accounts
77
+ end
78
+
79
+ it "shows using account message" do
80
+ expect { cli.accounts }.to output(/Using account: 5WX12345/).to_stdout
81
+ end
82
+
83
+ it "shows total accounts" do
84
+ expect { cli.accounts }.to output(/Total accounts: 1/).to_stdout
85
+ end
86
+ end
87
+
88
+ context "with multiple accounts" do
89
+ before do
90
+ allow(Tastytrade::Models::Account).to receive(:get_all).with(session).and_return([account1, account2])
91
+ end
92
+
93
+ it "displays all accounts in table format" do
94
+ expect { cli.accounts }.to output(/5WX12345.*Main Account.*Margin/).to_stdout
95
+ expect { cli.accounts }.to output(/5WX67890.*Cash/).to_stdout
96
+ end
97
+
98
+ it "handles nil nickname" do
99
+ expect { cli.accounts }.to output(/5WX67890.*-.*Cash/).to_stdout
100
+ end
101
+
102
+ it "prompts to select account" do
103
+ expect { cli.accounts }.to output(/Use 'tastytrade select' to choose an account/).to_stdout
104
+ end
105
+
106
+ it "shows total accounts" do
107
+ expect { cli.accounts }.to output(/Total accounts: 2/).to_stdout
108
+ end
109
+ end
110
+
111
+ context "with current account selected" do
112
+ before do
113
+ allow(config).to receive(:get).with("current_account_number").and_return("5WX12345")
114
+ allow(Tastytrade::Models::Account).to receive(:get_all).with(session).and_return([account1, account2])
115
+ end
116
+
117
+ it "shows indicator for current account" do
118
+ expect { cli.accounts }.to output(/→.*5WX12345/).to_stdout
119
+ end
120
+
121
+ it "doesn't prompt to select account" do
122
+ expect { cli.accounts }.not_to output(/Use 'tastytrade select'/).to_stdout
123
+ end
124
+ end
125
+
126
+ context "with invalid current account" do
127
+ before do
128
+ allow(config).to receive(:get).with("current_account_number").and_return("INVALID")
129
+ allow(Tastytrade::Models::Account).to receive(:get_all).with(session).and_return([account1, account2])
130
+ end
131
+
132
+ it "prompts to select account" do
133
+ expect { cli.accounts }.to output(/Use 'tastytrade select' to choose an account/).to_stdout
134
+ end
135
+ end
136
+
137
+ context "on API error" do
138
+ before do
139
+ allow(Tastytrade::Models::Account).to receive(:get_all).with(session).and_raise(Tastytrade::Error,
140
+ "Network error")
141
+ end
142
+
143
+ it "displays error message" do
144
+ expect(cli).to receive(:exit).with(1)
145
+ expect { cli.accounts }.to output(/Failed to fetch accounts: Network error/).to_stderr
146
+ end
147
+
148
+ it "exits with status 1" do
149
+ expect(cli).to receive(:exit).with(1)
150
+ cli.accounts
151
+ end
152
+ end
153
+
154
+ context "on unexpected error" do
155
+ before do
156
+ allow(Tastytrade::Models::Account).to receive(:get_all).with(session).and_raise(StandardError,
157
+ "Unexpected issue")
158
+ end
159
+
160
+ it "displays generic error message" do
161
+ expect(cli).to receive(:exit).with(1)
162
+ expect { cli.accounts }.to output(/Unexpected error: Unexpected issue/).to_stderr
163
+ end
164
+ end
165
+ end
166
+ end
@@ -0,0 +1,216 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "tastytrade/cli"
5
+
6
+ RSpec.describe "Tastytrade::CLI authentication commands" do
7
+ let(:cli) { Tastytrade::CLI.new }
8
+ let(:session) { instance_double(Tastytrade::Session) }
9
+ let(:user) { instance_double(Tastytrade::Models::User, email: "test@example.com") }
10
+ let(:prompt) { instance_double(TTY::Prompt) }
11
+ let(:config) { instance_double(Tastytrade::CLIConfig) }
12
+ let(:session_manager) { instance_double(Tastytrade::SessionManager) }
13
+
14
+ before do
15
+ allow(cli).to receive(:prompt).and_return(prompt)
16
+ allow(cli).to receive(:config).and_return(config)
17
+ allow(cli).to receive(:exit) # Prevent actual exit during tests
18
+ allow(cli).to receive(:interactive_mode) # Mock interactive mode
19
+ allow(Tastytrade::SessionManager).to receive(:new).and_return(session_manager)
20
+ allow(session_manager).to receive(:save_session).and_return(true)
21
+ end
22
+
23
+ describe "#login" do
24
+ context "with username provided via option" do
25
+ before do
26
+ allow(prompt).to receive(:mask).with("Password:").and_return("secret123")
27
+ allow(Tastytrade::Session).to receive(:new).and_return(session)
28
+ allow(session).to receive(:login).and_return(session)
29
+ allow(session).to receive(:user).and_return(user)
30
+ allow(session).to receive(:session_token).and_return("test_session_token")
31
+ allow(session).to receive(:remember_token).and_return("test_remember_token")
32
+ allow(config).to receive(:set)
33
+ end
34
+
35
+ it "uses provided username and prompts for password" do
36
+ expect(prompt).not_to receive(:ask)
37
+ expect(prompt).to receive(:mask).with("Password:")
38
+
39
+ cli.options = { username: "test@example.com", test: false, remember: false }
40
+ expect { cli.login }.to output(/Successfully logged in/).to_stdout
41
+ end
42
+
43
+ it "creates session with correct parameters" do
44
+ expect(Tastytrade::Session).to receive(:new).with(
45
+ username: "test@example.com",
46
+ password: "secret123",
47
+ remember_me: false,
48
+ is_test: false
49
+ ).and_return(session)
50
+
51
+ cli.options = { username: "test@example.com", test: false, remember: false }
52
+ cli.login
53
+ end
54
+ end
55
+
56
+ context "with interactive username prompt" do
57
+ before do
58
+ allow(prompt).to receive(:ask).with("Username:").and_return("test@example.com")
59
+ allow(prompt).to receive(:mask).with("Password:").and_return("secret123")
60
+ allow(Tastytrade::Session).to receive(:new).and_return(session)
61
+ allow(session).to receive(:login).and_return(session)
62
+ allow(session).to receive(:user).and_return(user)
63
+ allow(session).to receive(:session_token).and_return("test_session_token")
64
+ allow(session).to receive(:remember_token).and_return("test_remember_token")
65
+ allow(config).to receive(:set)
66
+ end
67
+
68
+ it "prompts for both username and password" do
69
+ expect(prompt).to receive(:ask).with("Username:")
70
+ expect(prompt).to receive(:mask).with("Password:")
71
+
72
+ cli.options = { test: false, remember: false }
73
+ cli.login
74
+ end
75
+ end
76
+
77
+ context "with test environment" do
78
+ before do
79
+ allow(prompt).to receive(:ask).with("Username:").and_return("test@example.com")
80
+ allow(prompt).to receive(:mask).with("Password:").and_return("secret123")
81
+ allow(Tastytrade::Session).to receive(:new).and_return(session)
82
+ allow(session).to receive(:login).and_return(session)
83
+ allow(session).to receive(:user).and_return(user)
84
+ allow(session).to receive(:session_token).and_return("test_session_token")
85
+ allow(session).to receive(:remember_token).and_return("test_remember_token")
86
+ allow(config).to receive(:set)
87
+ end
88
+
89
+ it "creates session with test flag" do
90
+ expect(Tastytrade::Session).to receive(:new).with(
91
+ username: "test@example.com",
92
+ password: "secret123",
93
+ remember_me: false,
94
+ is_test: true
95
+ ).and_return(session)
96
+
97
+ cli.options = { test: true, remember: false }
98
+ expect { cli.login }.to output(/sandbox environment/).to_stdout
99
+ end
100
+
101
+ it "saves sandbox environment to config" do
102
+ # The config is set inside SessionManager#save_session
103
+ expect(Tastytrade::SessionManager).to receive(:new).with(
104
+ username: "test@example.com",
105
+ environment: "sandbox"
106
+ ).and_return(session_manager)
107
+
108
+ cli.options = { test: true, remember: false }
109
+ cli.login
110
+ end
111
+ end
112
+
113
+ context "with remember option" do
114
+ before do
115
+ allow(prompt).to receive(:ask).with("Username:").and_return("test@example.com")
116
+ allow(prompt).to receive(:mask).with("Password:").and_return("secret123")
117
+ allow(Tastytrade::Session).to receive(:new).and_return(session)
118
+ allow(session).to receive(:login).and_return(session)
119
+ allow(session).to receive(:user).and_return(user)
120
+ allow(session).to receive(:session_token).and_return("test_session_token")
121
+ allow(session).to receive(:remember_token).and_return("test_remember_token")
122
+ allow(config).to receive(:set)
123
+ end
124
+
125
+ it "creates session with remember flag" do
126
+ expect(Tastytrade::Session).to receive(:new).with(
127
+ username: "test@example.com",
128
+ password: "secret123",
129
+ remember_me: true,
130
+ is_test: false
131
+ ).and_return(session)
132
+
133
+ cli.options = { test: false, remember: true }
134
+ cli.login
135
+ end
136
+ end
137
+
138
+ context "on successful login" do
139
+ before do
140
+ allow(prompt).to receive(:ask).with("Username:").and_return("test@example.com")
141
+ allow(prompt).to receive(:mask).with("Password:").and_return("secret123")
142
+ allow(Tastytrade::Session).to receive(:new).and_return(session)
143
+ allow(session).to receive(:login).and_return(session)
144
+ allow(session).to receive(:user).and_return(user)
145
+ allow(session).to receive(:session_token).and_return("test_session_token")
146
+ allow(session).to receive(:remember_token).and_return("test_remember_token")
147
+ end
148
+
149
+ it "saves username to config" do
150
+ # The config is set inside SessionManager#save_session
151
+ expect(Tastytrade::SessionManager).to receive(:new).with(
152
+ username: "test@example.com",
153
+ environment: "production"
154
+ ).and_return(session_manager)
155
+ expect(session_manager).to receive(:save_session).with(
156
+ session,
157
+ password: "secret123",
158
+ remember: false
159
+ ).and_return(true)
160
+
161
+ cli.options = { test: false, remember: false }
162
+ cli.login
163
+ end
164
+
165
+ it "displays success message with user email" do
166
+ allow(config).to receive(:set)
167
+
168
+ cli.options = { test: false, remember: false }
169
+ expect { cli.login }.to output(/Successfully logged in as test@example.com/).to_stdout
170
+ end
171
+
172
+ it "enters interactive mode after successful login" do
173
+ allow(config).to receive(:set)
174
+ expect(cli).to receive(:interactive_mode)
175
+
176
+ cli.options = { test: false, remember: false }
177
+ cli.login
178
+ end
179
+ end
180
+
181
+ context "on authentication error" do
182
+ before do
183
+ allow(prompt).to receive(:ask).with("Username:").and_return("test@example.com")
184
+ allow(prompt).to receive(:mask).with("Password:").and_return("wrong")
185
+ allow(Tastytrade::Session).to receive(:new).and_return(session)
186
+ allow(session).to receive(:login).and_raise(Tastytrade::Error, "Invalid credentials")
187
+ end
188
+
189
+ it "displays error message" do
190
+ cli.options = { test: false, remember: false }
191
+ expect(cli).to receive(:exit).with(1)
192
+ expect { cli.login }.to output(/Error: Invalid credentials/).to_stderr
193
+ end
194
+
195
+ it "exits with status 1" do
196
+ cli.options = { test: false, remember: false }
197
+ expect(cli).to receive(:exit).with(1)
198
+ cli.login
199
+ end
200
+ end
201
+
202
+ context "on unexpected error" do
203
+ before do
204
+ allow(prompt).to receive(:ask).with("Username:").and_return("test@example.com")
205
+ allow(prompt).to receive(:mask).with("Password:").and_return("secret123")
206
+ allow(Tastytrade::Session).to receive(:new).and_raise(StandardError, "Connection timeout")
207
+ end
208
+
209
+ it "displays generic error message" do
210
+ cli.options = { test: false, remember: false }
211
+ expect(cli).to receive(:exit).with(1)
212
+ expect { cli.login }.to output(/Error: Login failed: Connection timeout/).to_stderr
213
+ end
214
+ end
215
+ end
216
+ end
@@ -0,0 +1,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "tastytrade/cli_config"
5
+ require "tmpdir"
6
+ require "fileutils"
7
+
8
+ RSpec.describe Tastytrade::CLIConfig do
9
+ let(:temp_dir) { Dir.mktmpdir }
10
+ let(:config_dir) { File.join(temp_dir, ".config", "tastytrade") }
11
+ let(:config_file) { File.join(config_dir, "config.yml") }
12
+
13
+ before do
14
+ # Override the config directory for testing
15
+ stub_const("Tastytrade::CLIConfig::CONFIG_DIR", config_dir)
16
+ stub_const("Tastytrade::CLIConfig::CONFIG_FILE", config_file)
17
+ end
18
+
19
+ after do
20
+ FileUtils.rm_rf(temp_dir)
21
+ end
22
+
23
+ describe "#initialize" do
24
+ context "when config file doesn't exist" do
25
+ it "creates config directory" do
26
+ described_class.new
27
+ expect(Dir.exist?(config_dir)).to be true
28
+ end
29
+
30
+ it "loads default configuration" do
31
+ config = described_class.new
32
+ expect(config.data).to eq(described_class::DEFAULT_CONFIG)
33
+ end
34
+ end
35
+
36
+ context "when config file exists" do
37
+ before do
38
+ FileUtils.mkdir_p(config_dir)
39
+ File.write(config_file, { "default_account" => "123456" }.to_yaml)
40
+ end
41
+
42
+ it "loads config from file" do
43
+ config = described_class.new
44
+ expect(config.get("default_account")).to eq("123456")
45
+ end
46
+
47
+ it "merges with default config" do
48
+ config = described_class.new
49
+ expect(config.get("environment")).to eq("production") # from defaults
50
+ expect(config.get("default_account")).to eq("123456") # from file
51
+ end
52
+ end
53
+
54
+ context "when config file is corrupted" do
55
+ before do
56
+ FileUtils.mkdir_p(config_dir)
57
+ File.write(config_file, "invalid yaml: [")
58
+ end
59
+
60
+ it "loads default config and warns" do
61
+ expect { described_class.new }.to output(/Warning: Failed to load config file/).to_stderr
62
+ config = described_class.new
63
+ expect(config.data).to eq(described_class::DEFAULT_CONFIG)
64
+ end
65
+ end
66
+ end
67
+
68
+ describe "#get" do
69
+ let(:config) { described_class.new }
70
+
71
+ it "returns value for existing key" do
72
+ expect(config.get("environment")).to eq("production")
73
+ end
74
+
75
+ it "returns nil for non-existent key" do
76
+ expect(config.get("non_existent")).to be_nil
77
+ end
78
+
79
+ it "accepts symbol keys" do
80
+ expect(config.get(:environment)).to eq("production")
81
+ end
82
+ end
83
+
84
+ describe "#set" do
85
+ let(:config) { described_class.new }
86
+
87
+ it "sets a new value" do
88
+ config.set("test_key", "test_value")
89
+ expect(config.get("test_key")).to eq("test_value")
90
+ end
91
+
92
+ it "updates existing value" do
93
+ config.set("environment", "sandbox")
94
+ expect(config.get("environment")).to eq("sandbox")
95
+ end
96
+
97
+ it "saves to file" do
98
+ config.set("test_key", "test_value")
99
+
100
+ # Load a new config instance to verify persistence
101
+ new_config = described_class.new
102
+ expect(new_config.get("test_key")).to eq("test_value")
103
+ end
104
+
105
+ it "accepts symbol keys" do
106
+ config.set(:test_key, "test_value")
107
+ expect(config.get("test_key")).to eq("test_value")
108
+ end
109
+ end
110
+
111
+ describe "#delete" do
112
+ let(:config) { described_class.new }
113
+
114
+ before do
115
+ config.set("test_key", "test_value")
116
+ end
117
+
118
+ it "removes the key" do
119
+ config.delete("test_key")
120
+ expect(config.get("test_key")).to be_nil
121
+ end
122
+
123
+ it "saves changes to file" do
124
+ config.delete("test_key")
125
+
126
+ new_config = described_class.new
127
+ expect(new_config.get("test_key")).to be_nil
128
+ end
129
+ end
130
+
131
+ describe "#exists?" do
132
+ it "returns false when config doesn't exist" do
133
+ config = described_class.new
134
+ expect(config.exists?).to be false # file not created until save
135
+ end
136
+
137
+ it "returns true when config exists" do
138
+ config = described_class.new
139
+ config.set("test", "value") # This triggers save
140
+ expect(config.exists?).to be true
141
+ end
142
+ end
143
+
144
+ describe "#reset!" do
145
+ let(:config) { described_class.new }
146
+
147
+ before do
148
+ config.set("test_key", "test_value")
149
+ config.set("environment", "sandbox")
150
+ end
151
+
152
+ it "resets to default values" do
153
+ config.reset!
154
+ expect(config.data).to eq(described_class::DEFAULT_CONFIG)
155
+ expect(config.get("test_key")).to be_nil
156
+ expect(config.get("environment")).to eq("production")
157
+ end
158
+
159
+ it "saves defaults to file" do
160
+ config.reset!
161
+
162
+ new_config = described_class.new
163
+ expect(new_config.data).to eq(described_class::DEFAULT_CONFIG)
164
+ end
165
+ end
166
+
167
+ describe "error handling" do
168
+ let(:config) { described_class.new }
169
+
170
+ context "when save fails" do
171
+ before do
172
+ allow(File).to receive(:write).and_raise(StandardError.new("Permission denied"))
173
+ end
174
+
175
+ it "warns but doesn't raise" do
176
+ expect { config.set("test", "value") }.to output(/Warning: Failed to save config file/).to_stderr
177
+ end
178
+ end
179
+ end
180
+ end