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.
- checksums.yaml +4 -4
- data/.claude/commands/plan.md +13 -0
- data/.claude/commands/release-pr.md +12 -0
- data/CHANGELOG.md +170 -0
- data/README.md +424 -3
- data/ROADMAP.md +17 -17
- data/lib/tastytrade/cli/history_formatter.rb +304 -0
- data/lib/tastytrade/cli/orders.rb +749 -0
- data/lib/tastytrade/cli/positions_formatter.rb +114 -0
- data/lib/tastytrade/cli.rb +701 -12
- data/lib/tastytrade/cli_helpers.rb +111 -14
- data/lib/tastytrade/client.rb +7 -0
- data/lib/tastytrade/file_store.rb +83 -0
- data/lib/tastytrade/instruments/equity.rb +42 -0
- data/lib/tastytrade/models/account.rb +160 -2
- data/lib/tastytrade/models/account_balance.rb +46 -0
- data/lib/tastytrade/models/buying_power_effect.rb +61 -0
- data/lib/tastytrade/models/live_order.rb +272 -0
- data/lib/tastytrade/models/order_response.rb +106 -0
- data/lib/tastytrade/models/order_status.rb +84 -0
- data/lib/tastytrade/models/trading_status.rb +200 -0
- data/lib/tastytrade/models/transaction.rb +151 -0
- data/lib/tastytrade/models.rb +6 -0
- data/lib/tastytrade/order.rb +191 -0
- data/lib/tastytrade/order_validator.rb +355 -0
- data/lib/tastytrade/session.rb +26 -1
- data/lib/tastytrade/session_manager.rb +43 -14
- data/lib/tastytrade/version.rb +1 -1
- data/lib/tastytrade.rb +43 -0
- data/spec/exe/tastytrade_spec.rb +1 -1
- data/spec/spec_helper.rb +72 -0
- data/spec/tastytrade/cli/positions_spec.rb +267 -0
- data/spec/tastytrade/cli_auth_spec.rb +5 -0
- data/spec/tastytrade/cli_env_login_spec.rb +199 -0
- data/spec/tastytrade/cli_helpers_spec.rb +3 -26
- data/spec/tastytrade/cli_orders_spec.rb +168 -0
- data/spec/tastytrade/cli_status_spec.rb +153 -164
- data/spec/tastytrade/file_store_spec.rb +126 -0
- data/spec/tastytrade/models/account_balance_spec.rb +103 -0
- data/spec/tastytrade/models/account_order_history_spec.rb +229 -0
- data/spec/tastytrade/models/account_order_management_spec.rb +271 -0
- data/spec/tastytrade/models/account_place_order_spec.rb +125 -0
- data/spec/tastytrade/models/account_spec.rb +86 -15
- data/spec/tastytrade/models/buying_power_effect_spec.rb +250 -0
- data/spec/tastytrade/models/live_order_json_spec.rb +144 -0
- data/spec/tastytrade/models/live_order_spec.rb +295 -0
- data/spec/tastytrade/models/order_response_spec.rb +96 -0
- data/spec/tastytrade/models/order_status_spec.rb +113 -0
- data/spec/tastytrade/models/trading_status_spec.rb +260 -0
- data/spec/tastytrade/models/transaction_spec.rb +236 -0
- data/spec/tastytrade/order_edge_cases_spec.rb +163 -0
- data/spec/tastytrade/order_spec.rb +201 -0
- data/spec/tastytrade/order_validator_spec.rb +347 -0
- data/spec/tastytrade/session_env_spec.rb +169 -0
- data/spec/tastytrade/session_manager_spec.rb +43 -33
- data/vcr_implementation_plan.md +403 -0
- data/vcr_implementation_research.md +330 -0
- metadata +50 -18
- data/lib/tastytrade/keyring_store.rb +0 -72
- data/spec/tastytrade/keyring_store_spec.rb +0 -168
data/lib/tastytrade/session.rb
CHANGED
@@ -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 "
|
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 &&
|
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
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
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
|
-
|
135
|
+
result = FileStore.set(token_key, token)
|
136
|
+
result
|
127
137
|
end
|
128
138
|
|
129
139
|
def load_token
|
130
|
-
|
140
|
+
FileStore.get(token_key)
|
131
141
|
end
|
132
142
|
|
133
143
|
def save_remember_token(token)
|
134
|
-
|
144
|
+
FileStore.set(remember_token_key, token)
|
135
145
|
end
|
136
146
|
|
137
147
|
def load_remember_token
|
138
|
-
|
148
|
+
FileStore.get(remember_token_key)
|
139
149
|
end
|
140
150
|
|
141
151
|
def save_password(password)
|
142
|
-
|
152
|
+
FileStore.set(password_key, password)
|
143
153
|
end
|
144
154
|
|
145
155
|
def load_password
|
146
|
-
|
156
|
+
FileStore.get(password_key)
|
147
157
|
end
|
148
158
|
|
149
159
|
def save_session_expiration(expiration)
|
150
|
-
|
160
|
+
FileStore.set(session_expiration_key, expiration.iso8601)
|
151
161
|
end
|
152
162
|
|
153
163
|
def load_session_expiration
|
154
|
-
value =
|
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
|
data/lib/tastytrade/version.rb
CHANGED
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"
|
data/spec/exe/tastytrade_spec.rb
CHANGED
@@ -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",
|