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,444 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "bigdecimal"
5
+
6
+ RSpec.describe Tastytrade::Models::CurrentPosition do
7
+ let(:position_data) do
8
+ {
9
+ "account-number" => "5WX12345",
10
+ "symbol" => "AAPL",
11
+ "instrument-type" => "Equity",
12
+ "underlying-symbol" => "AAPL",
13
+ "quantity" => "100",
14
+ "quantity-direction" => "Long",
15
+ "close-price" => "150.00",
16
+ "average-open-price" => "145.00",
17
+ "average-yearly-market-close-price" => "140.00",
18
+ "average-daily-market-close-price" => "149.00",
19
+ "multiplier" => 1,
20
+ "cost-effect" => "Credit",
21
+ "is-suppressed" => false,
22
+ "is-frozen" => false,
23
+ "realized-day-gain" => "200.00",
24
+ "realized-today" => "100.00",
25
+ "created-at" => "2024-01-10T09:00:00Z",
26
+ "updated-at" => "2024-01-15T15:30:00Z",
27
+ "mark" => "152.00",
28
+ "mark-price" => "152.00",
29
+ "restricted-quantity" => "0"
30
+ }
31
+ end
32
+
33
+ subject { described_class.new(position_data) }
34
+
35
+ describe "#initialize" do
36
+ it "parses basic position attributes" do
37
+ expect(subject.account_number).to eq("5WX12345")
38
+ expect(subject.symbol).to eq("AAPL")
39
+ expect(subject.instrument_type).to eq("Equity")
40
+ expect(subject.underlying_symbol).to eq("AAPL")
41
+ end
42
+
43
+ it "converts quantity values to BigDecimal" do
44
+ expect(subject.quantity).to be_a(BigDecimal)
45
+ expect(subject.quantity).to eq(BigDecimal("100"))
46
+ expect(subject.restricted_quantity).to eq(BigDecimal("0"))
47
+ end
48
+
49
+ it "parses quantity direction" do
50
+ expect(subject.quantity_direction).to eq("Long")
51
+ end
52
+
53
+ it "converts all price values to BigDecimal" do
54
+ expect(subject.close_price).to eq(BigDecimal("150.00"))
55
+ expect(subject.average_open_price).to eq(BigDecimal("145.00"))
56
+ expect(subject.average_yearly_market_close_price).to eq(BigDecimal("140.00"))
57
+ expect(subject.average_daily_market_close_price).to eq(BigDecimal("149.00"))
58
+ expect(subject.mark).to eq(BigDecimal("152.00"))
59
+ expect(subject.mark_price).to eq(BigDecimal("152.00"))
60
+ end
61
+
62
+ it "parses multiplier as integer" do
63
+ expect(subject.multiplier).to eq(1)
64
+ end
65
+
66
+ it "parses boolean fields" do
67
+ expect(subject.is_suppressed).to be false
68
+ expect(subject.is_frozen).to be false
69
+ end
70
+
71
+ it "converts realized gains to BigDecimal" do
72
+ expect(subject.realized_day_gain).to eq(BigDecimal("200.00"))
73
+ expect(subject.realized_today).to eq(BigDecimal("100.00"))
74
+ end
75
+
76
+ it "parses timestamps" do
77
+ expect(subject.created_at).to be_a(Time)
78
+ expect(subject.updated_at).to be_a(Time)
79
+ expect(subject.created_at.iso8601).to eq("2024-01-10T09:00:00Z")
80
+ expect(subject.updated_at.iso8601).to eq("2024-01-15T15:30:00Z")
81
+ end
82
+
83
+ context "with option position" do
84
+ let(:position_data) do
85
+ {
86
+ "account-number" => "5WX12345",
87
+ "symbol" => "AAPL 240119C150",
88
+ "instrument-type" => "Equity Option",
89
+ "underlying-symbol" => "AAPL",
90
+ "quantity" => "10",
91
+ "quantity-direction" => "Long",
92
+ "close-price" => "5.00",
93
+ "average-open-price" => "4.50",
94
+ "multiplier" => 100,
95
+ "mark-price" => "5.50",
96
+ "expires-at" => "2024-01-19T21:00:00Z",
97
+ "root-symbol" => "AAPL",
98
+ "option-expiration-type" => "Regular",
99
+ "strike-price" => "150.00",
100
+ "option-type" => "Call",
101
+ "contract-size" => 100,
102
+ "exercise-style" => "American"
103
+ }
104
+ end
105
+
106
+ it "parses option-specific fields" do
107
+ expect(subject.expires_at).to be_a(Time)
108
+ expect(subject.expires_at.iso8601).to eq("2024-01-19T21:00:00Z")
109
+ expect(subject.root_symbol).to eq("AAPL")
110
+ expect(subject.option_expiration_type).to eq("Regular")
111
+ expect(subject.strike_price).to eq(BigDecimal("150.00"))
112
+ expect(subject.option_type).to eq("Call")
113
+ expect(subject.contract_size).to eq(100)
114
+ expect(subject.exercise_style).to eq("American")
115
+ expect(subject.multiplier).to eq(100)
116
+ end
117
+ end
118
+
119
+ context "with nil values" do
120
+ let(:position_data) do
121
+ {
122
+ "account-number" => "5WX12345",
123
+ "symbol" => "AAPL",
124
+ "quantity" => nil,
125
+ "close-price" => "",
126
+ "multiplier" => nil
127
+ }
128
+ end
129
+
130
+ it "handles nil and empty values gracefully" do
131
+ expect(subject.quantity).to eq(BigDecimal("0"))
132
+ expect(subject.close_price).to eq(BigDecimal("0"))
133
+ expect(subject.multiplier).to eq(1)
134
+ end
135
+ end
136
+ end
137
+
138
+ describe "#long?" do
139
+ it "returns true for long positions" do
140
+ expect(subject.long?).to be true
141
+ end
142
+
143
+ context "with short position" do
144
+ before { position_data["quantity-direction"] = "Short" }
145
+ it "returns false" do
146
+ expect(subject.long?).to be false
147
+ end
148
+ end
149
+ end
150
+
151
+ describe "#short?" do
152
+ it "returns false for long positions" do
153
+ expect(subject.short?).to be false
154
+ end
155
+
156
+ context "with short position" do
157
+ before { position_data["quantity-direction"] = "Short" }
158
+ it "returns true" do
159
+ expect(subject.short?).to be true
160
+ end
161
+ end
162
+ end
163
+
164
+ describe "#closed?" do
165
+ it "returns false for open positions" do
166
+ expect(subject.closed?).to be false
167
+ end
168
+
169
+ context "with zero quantity direction" do
170
+ before { position_data["quantity-direction"] = "Zero" }
171
+ it "returns true" do
172
+ expect(subject.closed?).to be true
173
+ end
174
+ end
175
+
176
+ context "with zero quantity" do
177
+ before { position_data["quantity"] = "0" }
178
+ it "returns true" do
179
+ expect(subject.closed?).to be true
180
+ end
181
+ end
182
+ end
183
+
184
+ describe "#equity?" do
185
+ it "returns true for equity positions" do
186
+ expect(subject.equity?).to be true
187
+ end
188
+
189
+ context "with option position" do
190
+ before { position_data["instrument-type"] = "Equity Option" }
191
+ it "returns false" do
192
+ expect(subject.equity?).to be false
193
+ end
194
+ end
195
+ end
196
+
197
+ describe "#option?" do
198
+ it "returns false for equity positions" do
199
+ expect(subject.option?).to be false
200
+ end
201
+
202
+ context "with option position" do
203
+ before { position_data["instrument-type"] = "Equity Option" }
204
+ it "returns true" do
205
+ expect(subject.option?).to be true
206
+ end
207
+ end
208
+ end
209
+
210
+ describe "#futures?" do
211
+ it "returns false for equity positions" do
212
+ expect(subject.futures?).to be false
213
+ end
214
+
215
+ context "with futures position" do
216
+ before { position_data["instrument-type"] = "Future" }
217
+ it "returns true" do
218
+ expect(subject.futures?).to be true
219
+ end
220
+ end
221
+ end
222
+
223
+ describe "#futures_option?" do
224
+ it "returns false for equity positions" do
225
+ expect(subject.futures_option?).to be false
226
+ end
227
+
228
+ context "with futures option position" do
229
+ before { position_data["instrument-type"] = "Future Option" }
230
+ it "returns true" do
231
+ expect(subject.futures_option?).to be true
232
+ end
233
+ end
234
+ end
235
+
236
+ describe "#position_value" do
237
+ it "calculates position value correctly for long positions" do
238
+ # 100 shares * $152 * 1 = $15,200
239
+ expect(subject.position_value).to eq(BigDecimal("15200"))
240
+ end
241
+
242
+ context "with short position" do
243
+ before do
244
+ position_data["quantity-direction"] = "Short"
245
+ position_data["quantity"] = "-100"
246
+ end
247
+
248
+ it "uses absolute quantity" do
249
+ expect(subject.position_value).to eq(BigDecimal("15200"))
250
+ end
251
+ end
252
+
253
+ context "with options" do
254
+ before do
255
+ position_data["quantity"] = "10"
256
+ position_data["mark-price"] = "5.00"
257
+ position_data["multiplier"] = 100
258
+ end
259
+
260
+ it "includes multiplier" do
261
+ # 10 contracts * $5 * 100 = $5,000
262
+ expect(subject.position_value).to eq(BigDecimal("5000"))
263
+ end
264
+ end
265
+
266
+ context "when position is closed" do
267
+ before { position_data["quantity-direction"] = "Zero" }
268
+
269
+ it "returns zero" do
270
+ expect(subject.position_value).to eq(BigDecimal("0"))
271
+ end
272
+ end
273
+
274
+ context "when mark price is nil" do
275
+ before { position_data["mark-price"] = nil }
276
+
277
+ it "falls back to close price" do
278
+ # 100 shares * $150 * 1 = $15,000
279
+ expect(subject.position_value).to eq(BigDecimal("15000"))
280
+ end
281
+ end
282
+ end
283
+
284
+ describe "#unrealized_pnl" do
285
+ it "calculates profit for long positions correctly" do
286
+ # (152 - 145) * 100 * 1 = $700
287
+ expect(subject.unrealized_pnl).to eq(BigDecimal("700"))
288
+ end
289
+
290
+ context "with short position" do
291
+ before do
292
+ position_data["quantity-direction"] = "Short"
293
+ position_data["quantity"] = "-100"
294
+ position_data["average-open-price"] = "155.00"
295
+ position_data["mark-price"] = "152.00"
296
+ end
297
+
298
+ it "calculates profit correctly" do
299
+ # (155 - 152) * 100 * 1 = $300
300
+ expect(subject.unrealized_pnl).to eq(BigDecimal("300"))
301
+ end
302
+ end
303
+
304
+ context "with losing long position" do
305
+ before do
306
+ position_data["average-open-price"] = "160.00"
307
+ position_data["mark-price"] = "152.00"
308
+ end
309
+
310
+ it "calculates loss correctly" do
311
+ # (152 - 160) * 100 * 1 = -$800
312
+ expect(subject.unrealized_pnl).to eq(BigDecimal("-800"))
313
+ end
314
+ end
315
+
316
+ context "when position is closed" do
317
+ before { position_data["quantity-direction"] = "Zero" }
318
+
319
+ it "returns zero" do
320
+ expect(subject.unrealized_pnl).to eq(BigDecimal("0"))
321
+ end
322
+ end
323
+
324
+ context "when average open price is zero" do
325
+ before { position_data["average-open-price"] = "0" }
326
+
327
+ it "returns zero" do
328
+ expect(subject.unrealized_pnl).to eq(BigDecimal("0"))
329
+ end
330
+ end
331
+ end
332
+
333
+ describe "#unrealized_pnl_percentage" do
334
+ it "calculates percentage correctly" do
335
+ # PnL = $700, Cost = 145 * 100 = $14,500
336
+ # 700 / 14500 * 100 = 4.83%
337
+ expect(subject.unrealized_pnl_percentage).to eq(BigDecimal("4.83"))
338
+ end
339
+
340
+ context "with losing position" do
341
+ before do
342
+ position_data["average-open-price"] = "160.00"
343
+ position_data["mark-price"] = "152.00"
344
+ end
345
+
346
+ it "calculates negative percentage" do
347
+ # PnL = -$800, Cost = 160 * 100 = $16,000
348
+ # -800 / 16000 * 100 = -5.00%
349
+ expect(subject.unrealized_pnl_percentage).to eq(BigDecimal("-5.00"))
350
+ end
351
+ end
352
+
353
+ context "when position is closed" do
354
+ before { position_data["quantity-direction"] = "Zero" }
355
+
356
+ it "returns zero" do
357
+ expect(subject.unrealized_pnl_percentage).to eq(BigDecimal("0"))
358
+ end
359
+ end
360
+
361
+ context "when cost basis is zero" do
362
+ before { position_data["average-open-price"] = "0" }
363
+
364
+ it "returns zero" do
365
+ expect(subject.unrealized_pnl_percentage).to eq(BigDecimal("0"))
366
+ end
367
+ end
368
+ end
369
+
370
+ describe "#total_pnl" do
371
+ it "sums realized and unrealized P&L" do
372
+ # Realized today: $100, Unrealized: $700
373
+ expect(subject.total_pnl).to eq(BigDecimal("800"))
374
+ end
375
+ end
376
+
377
+ describe "#display_symbol" do
378
+ context "with equity position" do
379
+ it "returns the symbol as-is" do
380
+ expect(subject.display_symbol).to eq("AAPL")
381
+ end
382
+ end
383
+
384
+ context "with option position" do
385
+ let(:position_data) do
386
+ {
387
+ "symbol" => "AAPL 240119C150",
388
+ "instrument-type" => "Equity Option",
389
+ "expires-at" => "2024-01-19T21:00:00Z",
390
+ "root-symbol" => "AAPL",
391
+ "strike-price" => "150.00",
392
+ "option-type" => "Call"
393
+ }
394
+ end
395
+
396
+ it "formats option symbol nicely" do
397
+ expect(subject.display_symbol).to eq("AAPL 01/19/24 C 150.0")
398
+ end
399
+
400
+ context "with put option" do
401
+ before { position_data["option-type"] = "Put" }
402
+
403
+ it "uses P for puts" do
404
+ expect(subject.display_symbol).to eq("AAPL 01/19/24 P 150.0")
405
+ end
406
+ end
407
+
408
+ context "with missing option data" do
409
+ before do
410
+ position_data["expires-at"] = nil
411
+ position_data["strike-price"] = nil
412
+ position_data["option-type"] = nil
413
+ end
414
+
415
+ it "returns original symbol" do
416
+ expect(subject.display_symbol).to eq("AAPL 240119C150")
417
+ end
418
+ end
419
+ end
420
+ end
421
+
422
+ describe "precision handling" do
423
+ context "with fractional shares" do
424
+ before { position_data["quantity"] = "0.5" }
425
+
426
+ it "handles fractional quantities" do
427
+ expect(subject.quantity).to eq(BigDecimal("0.5"))
428
+ expect(subject.position_value).to eq(BigDecimal("76")) # 0.5 * 152
429
+ end
430
+ end
431
+
432
+ context "with very small prices" do
433
+ before do
434
+ position_data["mark-price"] = "0.0001"
435
+ position_data["average-open-price"] = "0.0002"
436
+ end
437
+
438
+ it "maintains precision" do
439
+ expect(subject.mark_price).to eq(BigDecimal("0.0001"))
440
+ expect(subject.average_open_price).to eq(BigDecimal("0.0002"))
441
+ end
442
+ end
443
+ end
444
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe Tastytrade::Models::User do
6
+ let(:user_data) do
7
+ {
8
+ "email" => "test@example.com",
9
+ "username" => "testuser",
10
+ "external-id" => "ext-123",
11
+ "is-professional" => false
12
+ }
13
+ end
14
+
15
+ subject(:user) { described_class.new(user_data) }
16
+
17
+ describe "#email" do
18
+ it "returns the email" do
19
+ expect(user.email).to eq("test@example.com")
20
+ end
21
+ end
22
+
23
+ describe "#username" do
24
+ it "returns the username" do
25
+ expect(user.username).to eq("testuser")
26
+ end
27
+ end
28
+
29
+ describe "#external_id" do
30
+ it "returns the external ID" do
31
+ expect(user.external_id).to eq("ext-123")
32
+ end
33
+ end
34
+
35
+ describe "#professional?" do
36
+ context "when is-professional is true" do
37
+ let(:user_data) { super().merge("is-professional" => true) }
38
+
39
+ it "returns true" do
40
+ expect(user.professional?).to be true
41
+ end
42
+ end
43
+
44
+ context "when is-professional is false" do
45
+ it "returns false" do
46
+ expect(user.professional?).to be false
47
+ end
48
+ end
49
+
50
+ context "when is-professional is nil" do
51
+ let(:user_data) { super().merge("is-professional" => nil) }
52
+
53
+ it "returns false" do
54
+ expect(user.professional?).to be false
55
+ end
56
+ end
57
+ end
58
+ end