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,247 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "bigdecimal"
5
+
6
+ RSpec.describe Tastytrade::Models::AccountBalance do
7
+ let(:balance_data) do
8
+ {
9
+ "account-number" => "5WX12345",
10
+ "cash-balance" => "10000.50",
11
+ "long-equity-value" => "25000.75",
12
+ "short-equity-value" => "5000.25",
13
+ "long-derivative-value" => "3000.00",
14
+ "short-derivative-value" => "1500.00",
15
+ "net-liquidating-value" => "42001.00",
16
+ "equity-buying-power" => "20000.00",
17
+ "derivative-buying-power" => "15000.00",
18
+ "day-trading-buying-power" => "40000.00",
19
+ "available-trading-funds" => "12000.00",
20
+ "margin-equity" => "42001.00",
21
+ "pending-cash" => "500.00",
22
+ "pending-margin-interest" => "25.50",
23
+ "effective-trading-funds" => "11974.50",
24
+ "updated-at" => "2024-01-15T10:30:00Z"
25
+ }
26
+ end
27
+
28
+ subject { described_class.new(balance_data) }
29
+
30
+ describe "#initialize" do
31
+ it "parses account number" do
32
+ expect(subject.account_number).to eq("5WX12345")
33
+ end
34
+
35
+ it "converts monetary values to BigDecimal" do
36
+ expect(subject.cash_balance).to be_a(BigDecimal)
37
+ expect(subject.cash_balance).to eq(BigDecimal("10000.50"))
38
+ end
39
+
40
+ it "parses all balance fields correctly" do
41
+ expect(subject.long_equity_value).to eq(BigDecimal("25000.75"))
42
+ expect(subject.short_equity_value).to eq(BigDecimal("5000.25"))
43
+ expect(subject.long_derivative_value).to eq(BigDecimal("3000.00"))
44
+ expect(subject.short_derivative_value).to eq(BigDecimal("1500.00"))
45
+ expect(subject.net_liquidating_value).to eq(BigDecimal("42001.00"))
46
+ expect(subject.equity_buying_power).to eq(BigDecimal("20000.00"))
47
+ expect(subject.derivative_buying_power).to eq(BigDecimal("15000.00"))
48
+ expect(subject.day_trading_buying_power).to eq(BigDecimal("40000.00"))
49
+ expect(subject.available_trading_funds).to eq(BigDecimal("12000.00"))
50
+ expect(subject.margin_equity).to eq(BigDecimal("42001.00"))
51
+ expect(subject.pending_cash).to eq(BigDecimal("500.00"))
52
+ expect(subject.pending_margin_interest).to eq(BigDecimal("25.50"))
53
+ expect(subject.effective_trading_funds).to eq(BigDecimal("11974.50"))
54
+ end
55
+
56
+ it "parses updated_at as Time" do
57
+ expect(subject.updated_at).to be_a(Time)
58
+ expect(subject.updated_at.iso8601).to eq("2024-01-15T10:30:00Z")
59
+ end
60
+
61
+ context "with nil values" do
62
+ let(:balance_data) do
63
+ {
64
+ "account-number" => "5WX12345",
65
+ "cash-balance" => nil,
66
+ "long-equity-value" => "",
67
+ "net-liquidating-value" => "1000.00",
68
+ "equity-buying-power" => "1000.00",
69
+ "available-trading-funds" => "1000.00"
70
+ }
71
+ end
72
+
73
+ it "converts nil and empty strings to zero" do
74
+ expect(subject.cash_balance).to eq(BigDecimal("0"))
75
+ expect(subject.long_equity_value).to eq(BigDecimal("0"))
76
+ end
77
+ end
78
+
79
+ context "with string numbers" do
80
+ let(:balance_data) do
81
+ {
82
+ "account-number" => "5WX12345",
83
+ "cash-balance" => "1234.567890",
84
+ "net-liquidating-value" => "1234.567890",
85
+ "equity-buying-power" => "1000.00",
86
+ "available-trading-funds" => "1000.00"
87
+ }
88
+ end
89
+
90
+ it "maintains precision with BigDecimal" do
91
+ expect(subject.cash_balance.to_s("F")).to eq("1234.56789")
92
+ expect(subject.net_liquidating_value.to_s("F")).to eq("1234.56789")
93
+ end
94
+ end
95
+ end
96
+
97
+ describe "#buying_power_usage_percentage" do
98
+ context "with normal usage" do
99
+ it "calculates the percentage correctly" do
100
+ # Used BP = 20000 - 12000 = 8000
101
+ # Percentage = 8000 / 20000 * 100 = 40%
102
+ expect(subject.buying_power_usage_percentage).to eq(BigDecimal("40.00"))
103
+ end
104
+ end
105
+
106
+ context "with zero equity buying power" do
107
+ let(:balance_data) do
108
+ {
109
+ "account-number" => "5WX12345",
110
+ "equity-buying-power" => "0",
111
+ "available-trading-funds" => "0"
112
+ }
113
+ end
114
+
115
+ it "returns zero" do
116
+ expect(subject.buying_power_usage_percentage).to eq(BigDecimal("0"))
117
+ end
118
+ end
119
+
120
+ context "with full usage" do
121
+ let(:balance_data) do
122
+ {
123
+ "account-number" => "5WX12345",
124
+ "equity-buying-power" => "10000.00",
125
+ "available-trading-funds" => "0.00"
126
+ }
127
+ end
128
+
129
+ it "returns 100%" do
130
+ expect(subject.buying_power_usage_percentage).to eq(BigDecimal("100.00"))
131
+ end
132
+ end
133
+
134
+ context "with high precision" do
135
+ let(:balance_data) do
136
+ {
137
+ "account-number" => "5WX12345",
138
+ "equity-buying-power" => "10000.00",
139
+ "available-trading-funds" => "3333.33"
140
+ }
141
+ end
142
+
143
+ it "rounds to 2 decimal places" do
144
+ # Used BP = 10000 - 3333.33 = 6666.67
145
+ # Percentage = 6666.67 / 10000 * 100 = 66.6667
146
+ expect(subject.buying_power_usage_percentage).to eq(BigDecimal("66.67"))
147
+ end
148
+ end
149
+ end
150
+
151
+ describe "#high_buying_power_usage?" do
152
+ context "with default threshold (80%)" do
153
+ it "returns false when usage is below 80%" do
154
+ expect(subject.high_buying_power_usage?).to be false
155
+ end
156
+
157
+ context "with high usage" do
158
+ let(:balance_data) do
159
+ {
160
+ "account-number" => "5WX12345",
161
+ "equity-buying-power" => "10000.00",
162
+ "available-trading-funds" => "1500.00"
163
+ }
164
+ end
165
+
166
+ it "returns true when usage is above 80%" do
167
+ # Usage = 85%
168
+ expect(subject.high_buying_power_usage?).to be true
169
+ end
170
+ end
171
+ end
172
+
173
+ context "with custom threshold" do
174
+ it "uses the provided threshold" do
175
+ expect(subject.high_buying_power_usage?(30)).to be true
176
+ expect(subject.high_buying_power_usage?(50)).to be false
177
+ end
178
+ end
179
+ end
180
+
181
+ describe "#total_equity_value" do
182
+ it "sums long and short equity values" do
183
+ # 25000.75 + 5000.25 = 30001.00
184
+ expect(subject.total_equity_value).to eq(BigDecimal("30001.00"))
185
+ end
186
+ end
187
+
188
+ describe "#total_derivative_value" do
189
+ it "sums long and short derivative values" do
190
+ # 3000.00 + 1500.00 = 4500.00
191
+ expect(subject.total_derivative_value).to eq(BigDecimal("4500.00"))
192
+ end
193
+ end
194
+
195
+ describe "#total_market_value" do
196
+ it "sums all market values" do
197
+ # 30001.00 + 4500.00 = 34501.00
198
+ expect(subject.total_market_value).to eq(BigDecimal("34501.00"))
199
+ end
200
+ end
201
+
202
+ describe "attribute readers" do
203
+ it "provides access to all balance fields" do
204
+ expect(subject).to respond_to(:account_number)
205
+ expect(subject).to respond_to(:cash_balance)
206
+ expect(subject).to respond_to(:long_equity_value)
207
+ expect(subject).to respond_to(:short_equity_value)
208
+ expect(subject).to respond_to(:long_derivative_value)
209
+ expect(subject).to respond_to(:short_derivative_value)
210
+ expect(subject).to respond_to(:net_liquidating_value)
211
+ expect(subject).to respond_to(:equity_buying_power)
212
+ expect(subject).to respond_to(:derivative_buying_power)
213
+ expect(subject).to respond_to(:day_trading_buying_power)
214
+ expect(subject).to respond_to(:available_trading_funds)
215
+ expect(subject).to respond_to(:margin_equity)
216
+ expect(subject).to respond_to(:pending_cash)
217
+ expect(subject).to respond_to(:pending_margin_interest)
218
+ expect(subject).to respond_to(:effective_trading_funds)
219
+ expect(subject).to respond_to(:updated_at)
220
+ end
221
+ end
222
+
223
+ describe "BigDecimal precision" do
224
+ let(:balance_data) do
225
+ {
226
+ "account-number" => "5WX12345",
227
+ "cash-balance" => "0.01",
228
+ "net-liquidating-value" => "999999.99",
229
+ "equity-buying-power" => "123456.789",
230
+ "available-trading-funds" => "0.001"
231
+ }
232
+ end
233
+
234
+ it "handles small values correctly" do
235
+ expect(subject.cash_balance).to eq(BigDecimal("0.01"))
236
+ expect(subject.available_trading_funds).to eq(BigDecimal("0.001"))
237
+ end
238
+
239
+ it "handles large values correctly" do
240
+ expect(subject.net_liquidating_value).to eq(BigDecimal("999999.99"))
241
+ end
242
+
243
+ it "maintains precision beyond 2 decimal places" do
244
+ expect(subject.equity_buying_power).to eq(BigDecimal("123456.789"))
245
+ end
246
+ end
247
+ end
@@ -0,0 +1,206 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "bigdecimal"
5
+
6
+ RSpec.describe Tastytrade::Models::Account do
7
+ let(:account_data) do
8
+ {
9
+ "account-number" => "123456",
10
+ "nickname" => "My Account",
11
+ "account-type-name" => "Individual",
12
+ "opened-at" => "2025-01-01T10:00:00Z",
13
+ "is-closed" => false,
14
+ "day-trader-status" => false,
15
+ "is-futures-approved" => true,
16
+ "margin-or-cash" => "Margin",
17
+ "is-foreign" => false,
18
+ "created-at" => "2025-01-01T10:00:00Z",
19
+ "is-test-drive" => false
20
+ }
21
+ end
22
+
23
+ let(:session) { instance_double(Tastytrade::Session) }
24
+ subject(:account) { described_class.new(account_data) }
25
+
26
+ describe "attributes" do
27
+ it "parses account number" do
28
+ expect(account.account_number).to eq("123456")
29
+ end
30
+
31
+ it "parses nickname" do
32
+ expect(account.nickname).to eq("My Account")
33
+ end
34
+
35
+ it "parses account type" do
36
+ expect(account.account_type_name).to eq("Individual")
37
+ end
38
+
39
+ it "parses opened_at as Time" do
40
+ expect(account.opened_at).to be_a(Time)
41
+ expect(account.opened_at.year).to eq(2025)
42
+ end
43
+
44
+ it "parses boolean fields" do
45
+ expect(account.is_closed).to be false
46
+ expect(account.day_trader_status).to be false
47
+ expect(account.is_futures_approved).to be true
48
+ end
49
+
50
+ it "parses optional fields" do
51
+ expect(account.external_id).to be_nil
52
+ expect(account.closed_at).to be_nil
53
+ end
54
+ end
55
+
56
+ describe ".get_all" do
57
+ let(:response) do
58
+ {
59
+ "data" => {
60
+ "items" => [
61
+ {
62
+ "account" => account_data,
63
+ "authority-level" => "owner"
64
+ },
65
+ {
66
+ "account" => account_data.merge("account-number" => "789012"),
67
+ "authority-level" => "owner"
68
+ }
69
+ ]
70
+ }
71
+ }
72
+ end
73
+
74
+ it "returns array of Account objects" do
75
+ allow(session).to receive(:get).with("/customers/me/accounts/", {}).and_return(response)
76
+
77
+ accounts = described_class.get_all(session)
78
+
79
+ expect(accounts).to be_an(Array)
80
+ expect(accounts.size).to eq(2)
81
+ expect(accounts.first).to be_a(described_class)
82
+ expect(accounts.first.account_number).to eq("123456")
83
+ expect(accounts.last.account_number).to eq("789012")
84
+ end
85
+
86
+ it "includes closed accounts when specified" do
87
+ allow(session).to receive(:get).with("/customers/me/accounts/", { "include-closed" => true })
88
+ .and_return(response)
89
+
90
+ described_class.get_all(session, include_closed: true)
91
+ end
92
+ end
93
+
94
+ describe ".get" do
95
+ let(:response) do
96
+ { "data" => account_data }
97
+ end
98
+
99
+ it "returns single Account object" do
100
+ allow(session).to receive(:get).with("/accounts/123456/").and_return(response)
101
+
102
+ account = described_class.get(session, "123456")
103
+
104
+ expect(account).to be_a(described_class)
105
+ expect(account.account_number).to eq("123456")
106
+ end
107
+ end
108
+
109
+ describe "#get_balances" do
110
+ let(:balance_data) do
111
+ {
112
+ "data" => {
113
+ "account-number" => "123456",
114
+ "cash-balance" => "10000.00",
115
+ "net-liquidating-value" => "15000.00"
116
+ }
117
+ }
118
+ end
119
+
120
+ it "returns balance data" do
121
+ allow(session).to receive(:get).with("/accounts/123456/balances/").and_return(balance_data)
122
+
123
+ balance = account.get_balances(session)
124
+
125
+ expect(balance).to be_a(Tastytrade::Models::AccountBalance)
126
+ expect(balance.account_number).to eq("123456")
127
+ expect(balance.cash_balance).to eq(BigDecimal("10000.00"))
128
+ expect(balance.net_liquidating_value).to eq(BigDecimal("15000.00"))
129
+ end
130
+ end
131
+
132
+ describe "#get_positions" do
133
+ let(:positions_data) do
134
+ {
135
+ "data" => {
136
+ "items" => [
137
+ { "symbol" => "AAPL", "quantity" => "100" },
138
+ { "symbol" => "MSFT", "quantity" => "50" }
139
+ ]
140
+ }
141
+ }
142
+ end
143
+
144
+ it "returns array of positions" do
145
+ allow(session).to receive(:get).with("/accounts/123456/positions/", {}).and_return(positions_data)
146
+
147
+ positions = account.get_positions(session)
148
+
149
+ expect(positions).to be_an(Array)
150
+ expect(positions.size).to eq(2)
151
+ expect(positions.first).to be_a(Tastytrade::Models::CurrentPosition)
152
+ expect(positions.first.symbol).to eq("AAPL")
153
+ expect(positions.first.quantity).to eq(BigDecimal("100"))
154
+ end
155
+ end
156
+
157
+ describe "#get_trading_status" do
158
+ let(:status_data) do
159
+ {
160
+ "data" => {
161
+ "account-number" => "123456",
162
+ "is-pattern-day-trader" => false
163
+ }
164
+ }
165
+ end
166
+
167
+ it "returns trading status data" do
168
+ allow(session).to receive(:get).with("/accounts/123456/trading-status/").and_return(status_data)
169
+
170
+ status = account.get_trading_status(session)
171
+
172
+ expect(status).to eq(status_data["data"])
173
+ end
174
+ end
175
+
176
+ describe "boolean helper methods" do
177
+ describe "#closed?" do
178
+ it "returns true when is_closed is true" do
179
+ account = described_class.new(account_data.merge("is-closed" => true))
180
+ expect(account.closed?).to be true
181
+ end
182
+
183
+ it "returns false when is_closed is false" do
184
+ expect(account.closed?).to be false
185
+ end
186
+ end
187
+
188
+ describe "#futures_approved?" do
189
+ it "returns true when is_futures_approved is true" do
190
+ expect(account.futures_approved?).to be true
191
+ end
192
+ end
193
+
194
+ describe "#test_drive?" do
195
+ it "returns false when is_test_drive is false" do
196
+ expect(account.test_drive?).to be false
197
+ end
198
+ end
199
+
200
+ describe "#foreign?" do
201
+ it "returns false when is_foreign is false" do
202
+ expect(account.foreign?).to be false
203
+ end
204
+ end
205
+ end
206
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe Tastytrade::Models::Base do
6
+ let(:test_class) do
7
+ Class.new(described_class) do
8
+ attr_reader :test_attr, :parsed_time
9
+
10
+ private
11
+
12
+ def parse_attributes
13
+ @test_attr = @data["test-attr"]
14
+ @parsed_time = parse_time(@data["time-field"])
15
+ end
16
+ end
17
+ end
18
+
19
+ describe "#initialize" do
20
+ it "accepts hash with string keys" do
21
+ instance = test_class.new("test-attr" => "value")
22
+ expect(instance.test_attr).to eq("value")
23
+ end
24
+
25
+ it "accepts hash with symbol keys" do
26
+ instance = test_class.new(test_attr: "value")
27
+ expect(instance.data["test_attr"]).to eq("value")
28
+ end
29
+
30
+ it "stores raw data" do
31
+ data = { "test-attr" => "value", "other" => "data" }
32
+ instance = test_class.new(data)
33
+ expect(instance.data).to eq(data.transform_keys(&:to_s))
34
+ end
35
+ end
36
+
37
+ describe "#parse_time" do
38
+ let(:instance) { test_class.new({}) }
39
+
40
+ it "parses valid ISO 8601 datetime" do
41
+ instance = test_class.new("time-field" => "2025-07-30T10:30:00Z")
42
+ expect(instance.parsed_time).to be_a(Time)
43
+ expect(instance.parsed_time.year).to eq(2025)
44
+ end
45
+
46
+ it "returns nil for nil value" do
47
+ instance = test_class.new("time-field" => nil)
48
+ expect(instance.parsed_time).to be_nil
49
+ end
50
+
51
+ it "returns nil for empty string" do
52
+ instance = test_class.new("time-field" => "")
53
+ expect(instance.parsed_time).to be_nil
54
+ end
55
+
56
+ it "returns nil for invalid datetime" do
57
+ instance = test_class.new("time-field" => "not a date")
58
+ expect(instance.parsed_time).to be_nil
59
+ end
60
+ end
61
+ end