tastytrade 0.2.0 → 0.3.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 (60) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/commands/plan.md +13 -0
  3. data/.claude/commands/release-pr.md +12 -0
  4. data/CHANGELOG.md +170 -0
  5. data/README.md +424 -3
  6. data/ROADMAP.md +17 -17
  7. data/lib/tastytrade/cli/history_formatter.rb +304 -0
  8. data/lib/tastytrade/cli/orders.rb +749 -0
  9. data/lib/tastytrade/cli/positions_formatter.rb +114 -0
  10. data/lib/tastytrade/cli.rb +701 -12
  11. data/lib/tastytrade/cli_helpers.rb +111 -14
  12. data/lib/tastytrade/client.rb +7 -0
  13. data/lib/tastytrade/file_store.rb +83 -0
  14. data/lib/tastytrade/instruments/equity.rb +42 -0
  15. data/lib/tastytrade/models/account.rb +160 -2
  16. data/lib/tastytrade/models/account_balance.rb +46 -0
  17. data/lib/tastytrade/models/buying_power_effect.rb +61 -0
  18. data/lib/tastytrade/models/live_order.rb +272 -0
  19. data/lib/tastytrade/models/order_response.rb +106 -0
  20. data/lib/tastytrade/models/order_status.rb +84 -0
  21. data/lib/tastytrade/models/trading_status.rb +200 -0
  22. data/lib/tastytrade/models/transaction.rb +151 -0
  23. data/lib/tastytrade/models.rb +6 -0
  24. data/lib/tastytrade/order.rb +191 -0
  25. data/lib/tastytrade/order_validator.rb +355 -0
  26. data/lib/tastytrade/session.rb +26 -1
  27. data/lib/tastytrade/session_manager.rb +43 -14
  28. data/lib/tastytrade/version.rb +1 -1
  29. data/lib/tastytrade.rb +43 -0
  30. data/spec/exe/tastytrade_spec.rb +1 -1
  31. data/spec/spec_helper.rb +72 -0
  32. data/spec/tastytrade/cli/positions_spec.rb +267 -0
  33. data/spec/tastytrade/cli_auth_spec.rb +5 -0
  34. data/spec/tastytrade/cli_env_login_spec.rb +199 -0
  35. data/spec/tastytrade/cli_helpers_spec.rb +3 -26
  36. data/spec/tastytrade/cli_orders_spec.rb +168 -0
  37. data/spec/tastytrade/cli_status_spec.rb +153 -164
  38. data/spec/tastytrade/file_store_spec.rb +126 -0
  39. data/spec/tastytrade/models/account_balance_spec.rb +103 -0
  40. data/spec/tastytrade/models/account_order_history_spec.rb +229 -0
  41. data/spec/tastytrade/models/account_order_management_spec.rb +271 -0
  42. data/spec/tastytrade/models/account_place_order_spec.rb +125 -0
  43. data/spec/tastytrade/models/account_spec.rb +86 -15
  44. data/spec/tastytrade/models/buying_power_effect_spec.rb +250 -0
  45. data/spec/tastytrade/models/live_order_json_spec.rb +144 -0
  46. data/spec/tastytrade/models/live_order_spec.rb +295 -0
  47. data/spec/tastytrade/models/order_response_spec.rb +96 -0
  48. data/spec/tastytrade/models/order_status_spec.rb +113 -0
  49. data/spec/tastytrade/models/trading_status_spec.rb +260 -0
  50. data/spec/tastytrade/models/transaction_spec.rb +236 -0
  51. data/spec/tastytrade/order_edge_cases_spec.rb +163 -0
  52. data/spec/tastytrade/order_spec.rb +201 -0
  53. data/spec/tastytrade/order_validator_spec.rb +347 -0
  54. data/spec/tastytrade/session_env_spec.rb +169 -0
  55. data/spec/tastytrade/session_manager_spec.rb +43 -33
  56. data/vcr_implementation_plan.md +403 -0
  57. data/vcr_implementation_research.md +330 -0
  58. metadata +50 -18
  59. data/lib/tastytrade/keyring_store.rb +0 -72
  60. data/spec/tastytrade/keyring_store_spec.rb +0 -168
@@ -7,6 +7,26 @@ module Tastytrade
7
7
  class Session
8
8
  attr_reader :user, :session_token, :remember_token, :is_test, :session_expiration
9
9
 
10
+ # Create a session from environment variables
11
+ #
12
+ # @return [Session, nil] Session instance or nil if environment variables not set
13
+ def self.from_environment
14
+ username = ENV["TASTYTRADE_USERNAME"] || ENV["TT_USERNAME"]
15
+ password = ENV["TASTYTRADE_PASSWORD"] || ENV["TT_PASSWORD"]
16
+
17
+ return nil unless username && password
18
+
19
+ remember = ENV["TASTYTRADE_REMEMBER"]&.downcase == "true" || ENV["TT_REMEMBER"]&.downcase == "true"
20
+ is_test = ENV["TASTYTRADE_ENVIRONMENT"]&.downcase == "sandbox" || ENV["TT_ENVIRONMENT"]&.downcase == "sandbox"
21
+
22
+ new(
23
+ username: username,
24
+ password: password,
25
+ remember_me: remember,
26
+ is_test: is_test
27
+ )
28
+ end
29
+
10
30
  # Initialize a new session
11
31
  #
12
32
  # @param username [String] Tastytrade username
@@ -47,9 +67,14 @@ module Tastytrade
47
67
  #
48
68
  # @return [Boolean] True if session is valid
49
69
  def validate
70
+ warn "DEBUG: Validating session, user=#{@user&.email}" if ENV["DEBUG_SESSION"]
50
71
  response = get("/sessions/validate")
72
+ if ENV["DEBUG_SESSION"]
73
+ warn "DEBUG: Validate response email=#{response["data"]["email"]}, user email=#{@user&.email}"
74
+ end
51
75
  response["data"]["email"] == @user.email
52
- rescue Tastytrade::Error
76
+ rescue Tastytrade::Error => e
77
+ warn "DEBUG: Validate error: #{e.message}" if ENV["DEBUG_SESSION"]
53
78
  false
54
79
  end
55
80
 
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "keyring_store"
3
+ require_relative "file_store"
4
+ require_relative "cli_config"
4
5
  require "json"
5
6
 
6
7
  module Tastytrade
@@ -29,9 +30,12 @@ module Tastytrade
29
30
  # Save session expiration if available
30
31
  save_session_expiration(session.session_expiration) if session.session_expiration
31
32
 
33
+ # Save user data
34
+ save_user_data(session.user) if session.user
35
+
32
36
  if remember && session.remember_token
33
37
  save_remember_token(session.remember_token)
34
- save_password(password) if password && KeyringStore.available?
38
+ save_password(password) if password && FileStore.available?
35
39
  end
36
40
 
37
41
  # Save session metadata
@@ -56,6 +60,7 @@ module Tastytrade
56
60
  {
57
61
  session_token: token,
58
62
  remember_token: load_remember_token,
63
+ user_data: load_user_data,
59
64
  session_expiration: load_session_expiration,
60
65
  username: username,
61
66
  environment: environment
@@ -87,10 +92,10 @@ module Tastytrade
87
92
 
88
93
  # Clear all stored session data
89
94
  def clear_session!
90
- KeyringStore.delete(token_key)
91
- KeyringStore.delete(remember_token_key)
92
- KeyringStore.delete(password_key)
93
- KeyringStore.delete(session_expiration_key)
95
+ FileStore.delete(token_key)
96
+ FileStore.delete(remember_token_key)
97
+ FileStore.delete(password_key)
98
+ FileStore.delete(session_expiration_key)
94
99
 
95
100
  config = CLIConfig.new
96
101
  config.delete("current_username")
@@ -122,39 +127,63 @@ module Tastytrade
122
127
  "#{SESSION_KEY_PREFIX}_expiration_#{username}_#{environment}"
123
128
  end
124
129
 
130
+ def user_data_key
131
+ "user_data_#{username}_#{environment}"
132
+ end
133
+
125
134
  def save_token(token)
126
- KeyringStore.set(token_key, token)
135
+ result = FileStore.set(token_key, token)
136
+ result
127
137
  end
128
138
 
129
139
  def load_token
130
- KeyringStore.get(token_key)
140
+ FileStore.get(token_key)
131
141
  end
132
142
 
133
143
  def save_remember_token(token)
134
- KeyringStore.set(remember_token_key, token)
144
+ FileStore.set(remember_token_key, token)
135
145
  end
136
146
 
137
147
  def load_remember_token
138
- KeyringStore.get(remember_token_key)
148
+ FileStore.get(remember_token_key)
139
149
  end
140
150
 
141
151
  def save_password(password)
142
- KeyringStore.set(password_key, password)
152
+ FileStore.set(password_key, password)
143
153
  end
144
154
 
145
155
  def load_password
146
- KeyringStore.get(password_key)
156
+ FileStore.get(password_key)
147
157
  end
148
158
 
149
159
  def save_session_expiration(expiration)
150
- KeyringStore.set(session_expiration_key, expiration.iso8601)
160
+ FileStore.set(session_expiration_key, expiration.iso8601)
151
161
  end
152
162
 
153
163
  def load_session_expiration
154
- value = KeyringStore.get(session_expiration_key)
164
+ value = FileStore.get(session_expiration_key)
155
165
  value ? Time.parse(value).iso8601 : nil
156
166
  rescue StandardError
157
167
  nil
158
168
  end
169
+
170
+ def save_user_data(user)
171
+ return unless user
172
+ # Save minimal user data needed for session validation
173
+ user_data = {
174
+ email: user.email,
175
+ username: user.username,
176
+ external_id: user.external_id
177
+ }
178
+ FileStore.set(user_data_key, JSON.generate(user_data))
179
+ end
180
+
181
+ def load_user_data
182
+ data = FileStore.get(user_data_key)
183
+ return nil unless data
184
+ JSON.parse(data)
185
+ rescue StandardError
186
+ nil
187
+ end
159
188
  end
160
189
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Tastytrade
4
- VERSION = "0.2.0"
4
+ VERSION = "0.3.0"
5
5
  end
data/lib/tastytrade.rb CHANGED
@@ -14,6 +14,9 @@ require_relative "tastytrade/version"
14
14
  require_relative "tastytrade/client"
15
15
  require_relative "tastytrade/models"
16
16
  require_relative "tastytrade/session"
17
+ require_relative "tastytrade/order"
18
+ require_relative "tastytrade/order_validator"
19
+ require_relative "tastytrade/instruments/equity"
17
20
 
18
21
  module Tastytrade
19
22
  class Error < StandardError; end
@@ -25,6 +28,46 @@ module Tastytrade
25
28
  class InvalidCredentialsError < AuthenticationError; end
26
29
  class NetworkTimeoutError < Error; end
27
30
 
31
+ # Order errors
32
+ class OrderError < Error; end
33
+ class InvalidOrderError < OrderError; end
34
+ class InsufficientFundsError < OrderError; end
35
+ class MarketClosedError < OrderError; end
36
+ class OrderNotCancellableError < OrderError; end
37
+ class OrderAlreadyFilledError < OrderError; end
38
+ class OrderNotEditableError < OrderError; end
39
+ class InsufficientQuantityError < OrderError; end
40
+
41
+ # Order validation errors
42
+
43
+ # Base class for order validation errors. Contains an array of specific
44
+ # validation failures that prevented the order from being placed.
45
+ class OrderValidationError < OrderError
46
+ # @return [Array<String>] List of validation errors
47
+ attr_reader :errors
48
+
49
+ # @param errors [String, Array<String>] One or more validation error messages
50
+ def initialize(errors)
51
+ @errors = Array(errors)
52
+ super(@errors.join("; "))
53
+ end
54
+ end
55
+
56
+ # Raised when an order contains an invalid or non-existent symbol
57
+ class InvalidSymbolError < OrderValidationError; end
58
+
59
+ # Raised when an order would exceed available buying power
60
+ class InsufficientBuyingPowerError < OrderValidationError; end
61
+
62
+ # Raised when the account has restrictions preventing the order
63
+ class AccountRestrictedError < OrderValidationError; end
64
+
65
+ # Raised when the order quantity is invalid (zero, negative, or exceeds limits)
66
+ class InvalidQuantityError < OrderValidationError; end
67
+
68
+ # Raised when the order price is invalid (zero, negative, or unreasonable)
69
+ class InvalidPriceError < OrderValidationError; end
70
+
28
71
  # API URLs
29
72
  API_URL = "https://api.tastyworks.com"
30
73
  CERT_URL = "https://api.cert.tastyworks.com"
@@ -74,7 +74,7 @@ RSpec.describe "tastytrade executable" do
74
74
  it "has login command" do
75
75
  result = run_command("help", "login")
76
76
  expect(result[:status].success?).to be true
77
- expect(result[:stdout]).to include("Login to Tastytrade")
77
+ expect(result[:stdout]).to include("Login to your Tastytrade account")
78
78
  end
79
79
 
80
80
  it "has accounts command" do
data/spec/spec_helper.rb CHANGED
@@ -11,6 +11,78 @@ end
11
11
  require "bundler/setup"
12
12
  require "tastytrade"
13
13
  require "webmock/rspec"
14
+ require "vcr"
15
+
16
+ VCR.configure do |config|
17
+ config.cassette_library_dir = "spec/fixtures/vcr_cassettes"
18
+ config.hook_into :webmock
19
+ config.configure_rspec_metadata!
20
+
21
+ # Filter out sensitive data
22
+ config.filter_sensitive_data("<AUTH_TOKEN>") do |interaction|
23
+ if interaction.request.headers["Authorization"]
24
+ interaction.request.headers["Authorization"].first
25
+ end
26
+ end
27
+
28
+ config.filter_sensitive_data("<SESSION_TOKEN>") do |interaction|
29
+ if interaction.response.headers["Content-Type"] &&
30
+ interaction.response.headers["Content-Type"].first.include?("application/json")
31
+ begin
32
+ body = JSON.parse(interaction.response.body)
33
+ body.dig("data", "session-token") || body.dig("data", "session_token")
34
+ rescue JSON::ParserError
35
+ nil
36
+ end
37
+ end
38
+ end
39
+
40
+ config.filter_sensitive_data("<ACCOUNT_NUMBER>") do |interaction|
41
+ # Filter account numbers from URLs
42
+ interaction.request.uri.match(%r{/accounts/([^/]+)})&.captures&.first
43
+ end
44
+
45
+ config.filter_sensitive_data("<USERNAME>") do |interaction|
46
+ if interaction.request.body && interaction.request.body.include?("username")
47
+ begin
48
+ body = JSON.parse(interaction.request.body)
49
+ body["username"]
50
+ rescue JSON::ParserError
51
+ nil
52
+ end
53
+ end
54
+ end
55
+
56
+ config.filter_sensitive_data("<PASSWORD>") do |interaction|
57
+ if interaction.request.body && interaction.request.body.include?("password")
58
+ begin
59
+ body = JSON.parse(interaction.request.body)
60
+ body["password"]
61
+ rescue JSON::ParserError
62
+ nil
63
+ end
64
+ end
65
+ end
66
+
67
+ # Filter personal information from response bodies
68
+ config.filter_sensitive_data("<EMAIL>") do |interaction|
69
+ if interaction.response.body
70
+ begin
71
+ body = JSON.parse(interaction.response.body)
72
+ body.dig("data", "email")
73
+ rescue JSON::ParserError
74
+ nil
75
+ end
76
+ end
77
+ end
78
+
79
+ # Default cassette options
80
+ config.default_cassette_options = {
81
+ record: :new_episodes,
82
+ match_requests_on: [:method, :uri, :body],
83
+ allow_playback_repeats: true
84
+ }
85
+ end
14
86
 
15
87
  RSpec.configure do |config|
16
88
  # Enable flags like --only-failures and --next-failure
@@ -0,0 +1,267 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "tastytrade/cli"
5
+
6
+ RSpec.describe "Tastytrade::CLI positions command" do
7
+ let(:cli) { Tastytrade::CLI.new }
8
+ let(:output) { StringIO.new }
9
+ let(:error_output) { StringIO.new }
10
+ let(:mock_session) { instance_double(Tastytrade::Session, authenticated?: true) }
11
+ let(:mock_account) do
12
+ instance_double(
13
+ Tastytrade::Models::Account,
14
+ account_number: "5WX12345",
15
+ nickname: "Main Account"
16
+ )
17
+ end
18
+
19
+ before do
20
+ # Capture stdout
21
+ allow($stdout).to receive(:puts) { |msg| output.puts(msg) }
22
+
23
+ # Capture stderr more comprehensively
24
+ original_stderr = $stderr
25
+ allow($stderr).to receive(:puts) { |msg| error_output.puts(msg) }
26
+ allow($stderr).to receive(:print) { |msg| error_output.print(msg) }
27
+ allow($stderr).to receive(:write) { |msg| error_output.write(msg) }
28
+
29
+ # Capture Kernel.warn which is used by error/warning helpers
30
+ allow(cli).to receive(:warn) do |msg|
31
+ error_output.puts(msg)
32
+ end
33
+
34
+ # Mock CLI internals
35
+ allow(cli).to receive(:current_session).and_return(mock_session)
36
+ allow(cli).to receive(:current_account).and_return(mock_account)
37
+ allow(cli).to receive(:exit)
38
+ allow(cli).to receive(:options).and_return({})
39
+ end
40
+
41
+ describe "#positions" do
42
+ context "when not authenticated" do
43
+ before do
44
+ allow(cli).to receive(:current_session).and_return(nil)
45
+ end
46
+
47
+ it "requires authentication" do
48
+ allow(cli).to receive(:exit).with(1).and_raise(SystemExit.new(1))
49
+
50
+ expect { cli.positions }.to raise_error(SystemExit)
51
+ expect(error_output.string).to include("Error: You must be logged in to use this command.")
52
+ end
53
+ end
54
+
55
+ context "with no positions" do
56
+ before do
57
+ allow(mock_account).to receive(:get_positions).and_return([])
58
+ end
59
+
60
+ it "displays warning message" do
61
+ cli.positions
62
+ expect(error_output.string).to include("Warning: No positions found")
63
+ end
64
+
65
+ it "displays fetching message" do
66
+ cli.positions
67
+ expect(output.string).to include("Fetching positions for account 5WX12345")
68
+ end
69
+ end
70
+
71
+ context "with positions" do
72
+ let(:position1) do
73
+ instance_double(
74
+ Tastytrade::Models::CurrentPosition,
75
+ symbol: "AAPL",
76
+ quantity: BigDecimal("100"),
77
+ instrument_type: "Equity",
78
+ average_open_price: BigDecimal("150.00"),
79
+ close_price: BigDecimal("155.00"),
80
+ unrealized_pnl: BigDecimal("500.00"),
81
+ unrealized_pnl_percentage: BigDecimal("3.33"),
82
+ option?: false,
83
+ short?: false
84
+ )
85
+ end
86
+
87
+ let(:position2) do
88
+ instance_double(
89
+ Tastytrade::Models::CurrentPosition,
90
+ symbol: "MSFT",
91
+ quantity: BigDecimal("50"),
92
+ instrument_type: "Equity",
93
+ average_open_price: BigDecimal("300.00"),
94
+ close_price: BigDecimal("295.00"),
95
+ unrealized_pnl: BigDecimal("-250.00"),
96
+ unrealized_pnl_percentage: BigDecimal("-1.67"),
97
+ option?: false,
98
+ short?: false
99
+ )
100
+ end
101
+
102
+ before do
103
+ allow(mock_account).to receive(:get_positions).and_return([position1, position2])
104
+ end
105
+
106
+ it "displays positions in table format" do
107
+ cli.positions
108
+ output_str = output.string
109
+ expect(output_str).to include("Symbol")
110
+ expect(output_str).to include("Quantity")
111
+ expect(output_str).to include("Type")
112
+ expect(output_str).to include("Avg Price")
113
+ expect(output_str).to include("Current Price")
114
+ expect(output_str).to include("P/L")
115
+ expect(output_str).to include("P/L %")
116
+ end
117
+
118
+ it "displays position data" do
119
+ cli.positions
120
+ output_str = output.string
121
+ expect(output_str).to include("AAPL")
122
+ expect(output_str).to include("100")
123
+ expect(output_str).to include("Equity")
124
+ expect(output_str).to include("$150.00")
125
+ expect(output_str).to include("$155.00")
126
+ end
127
+
128
+ it "displays summary statistics" do
129
+ cli.positions
130
+ output_str = output.string
131
+ expect(output_str).to include("Summary: 2 positions")
132
+ expect(output_str).to include("Winners: 1, Losers: 1")
133
+ end
134
+ end
135
+
136
+ context "with symbol filter" do
137
+ before do
138
+ allow(cli).to receive(:options).and_return({ symbol: "AAPL" })
139
+ end
140
+
141
+ it "passes symbol filter to get_positions" do
142
+ expect(mock_account).to receive(:get_positions).with(
143
+ mock_session,
144
+ hash_including(symbol: "AAPL")
145
+ ).and_return([])
146
+ cli.positions
147
+ end
148
+ end
149
+
150
+ context "with underlying symbol filter" do
151
+ before do
152
+ allow(cli).to receive(:options).and_return({ underlying_symbol: "SPY" })
153
+ end
154
+
155
+ it "passes underlying symbol filter to get_positions" do
156
+ expect(mock_account).to receive(:get_positions).with(
157
+ mock_session,
158
+ hash_including(underlying_symbol: "SPY")
159
+ ).and_return([])
160
+ cli.positions
161
+ end
162
+ end
163
+
164
+ context "with include_closed option" do
165
+ before do
166
+ allow(cli).to receive(:options).and_return({ include_closed: true })
167
+ end
168
+
169
+ it "passes include_closed flag to get_positions" do
170
+ expect(mock_account).to receive(:get_positions).with(
171
+ mock_session,
172
+ hash_including(include_closed: true)
173
+ ).and_return([])
174
+ cli.positions
175
+ end
176
+ end
177
+
178
+ context "with account option" do
179
+ before do
180
+ allow(cli).to receive(:options).and_return({ account: "5WX67890" })
181
+ allow(Tastytrade::Models::Account).to receive(:get).and_return(mock_account)
182
+ end
183
+
184
+ it "fetches the specified account" do
185
+ expect(Tastytrade::Models::Account).to receive(:get).with(mock_session, "5WX67890")
186
+ allow(mock_account).to receive(:get_positions).and_return([])
187
+ cli.positions
188
+ end
189
+ end
190
+
191
+ context "on API error" do
192
+ before do
193
+ allow(mock_account).to receive(:get_positions).and_raise(
194
+ Tastytrade::Error, "Network error"
195
+ )
196
+ end
197
+
198
+ it "displays error message" do
199
+ allow(cli).to receive(:exit).with(1).and_raise(SystemExit.new(1))
200
+
201
+ expect { cli.positions }.to raise_error(SystemExit)
202
+ expect(error_output.string).to include("Error: Failed to fetch positions: Network error")
203
+ end
204
+
205
+ it "exits with status 1" do
206
+ expect(cli).to receive(:exit).with(1)
207
+ cli.positions rescue SystemExit
208
+ end
209
+ end
210
+
211
+ context "with short position" do
212
+ let(:short_position) do
213
+ instance_double(
214
+ Tastytrade::Models::CurrentPosition,
215
+ symbol: "TSLA",
216
+ quantity: BigDecimal("50"),
217
+ instrument_type: "Equity",
218
+ average_open_price: BigDecimal("200.00"),
219
+ close_price: BigDecimal("195.00"),
220
+ unrealized_pnl: BigDecimal("250.00"),
221
+ unrealized_pnl_percentage: BigDecimal("2.50"),
222
+ option?: false,
223
+ short?: true
224
+ )
225
+ end
226
+
227
+ before do
228
+ allow(mock_account).to receive(:get_positions).and_return([short_position])
229
+ end
230
+
231
+ it "displays negative quantity for short positions" do
232
+ cli.positions
233
+ output_str = output.string
234
+ expect(output_str).to include("-50")
235
+ end
236
+ end
237
+
238
+ context "with option position" do
239
+ let(:option_position) do
240
+ instance_double(
241
+ Tastytrade::Models::CurrentPosition,
242
+ symbol: "AAPL 240119C00150000",
243
+ display_symbol: "AAPL 150C 1/19",
244
+ quantity: BigDecimal("5"),
245
+ instrument_type: "Option",
246
+ average_open_price: BigDecimal("5.50"),
247
+ close_price: BigDecimal("7.25"),
248
+ unrealized_pnl: BigDecimal("875.00"),
249
+ unrealized_pnl_percentage: BigDecimal("31.82"),
250
+ option?: true,
251
+ short?: false
252
+ )
253
+ end
254
+
255
+ before do
256
+ allow(mock_account).to receive(:get_positions).and_return([option_position])
257
+ end
258
+
259
+ it "displays formatted option symbol" do
260
+ cli.positions
261
+ output_str = output.string
262
+ # The table may truncate the symbol, so just check for the key parts
263
+ expect(output_str).to match(/AAPL 150C/)
264
+ end
265
+ end
266
+ end
267
+ end
@@ -18,6 +18,8 @@ RSpec.describe "Tastytrade::CLI authentication commands" do
18
18
  allow(cli).to receive(:interactive_mode) # Mock interactive mode
19
19
  allow(Tastytrade::SessionManager).to receive(:new).and_return(session_manager)
20
20
  allow(session_manager).to receive(:save_session).and_return(true)
21
+ # Stub environment variable login to return nil so tests use interactive login
22
+ allow(Tastytrade::Session).to receive(:from_environment).and_return(nil)
21
23
  end
22
24
 
23
25
  describe "#login" do
@@ -147,6 +149,9 @@ RSpec.describe "Tastytrade::CLI authentication commands" do
147
149
  end
148
150
 
149
151
  it "saves username to config" do
152
+ # Allow config.set calls since save_user_session sets multiple values
153
+ allow(config).to receive(:set)
154
+
150
155
  # The config is set inside SessionManager#save_session
151
156
  expect(Tastytrade::SessionManager).to receive(:new).with(
152
157
  username: "test@example.com",