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,178 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pastel"
4
+ require "tty-prompt"
5
+
6
+ module Tastytrade
7
+ # Common CLI helper methods
8
+ module CLIHelpers
9
+ def self.included(base)
10
+ base.extend(ClassMethods)
11
+ end
12
+
13
+ module ClassMethods
14
+ # Ensure consistent exit behavior
15
+ def exit_on_failure?
16
+ true
17
+ end
18
+ end
19
+
20
+ # Colorization helper
21
+ def pastel
22
+ @pastel ||= Pastel.new
23
+ end
24
+
25
+ # Interactive prompt helper
26
+ def prompt
27
+ @prompt ||= TTY::Prompt.new
28
+ end
29
+
30
+ # Configuration helper
31
+ def config
32
+ @config ||= CLIConfig.new
33
+ end
34
+
35
+ # Print error message in red
36
+ def error(message)
37
+ warn pastel.red("Error: #{message}")
38
+ end
39
+
40
+ # Print warning message in yellow
41
+ def warning(message)
42
+ warn pastel.yellow("Warning: #{message}")
43
+ end
44
+
45
+ # Print success message in green
46
+ def success(message)
47
+ puts pastel.green("✓ #{message}")
48
+ end
49
+
50
+ # Print info message
51
+ def info(message)
52
+ puts pastel.cyan("→ #{message}")
53
+ end
54
+
55
+ # Format currency values
56
+ def format_currency(value)
57
+ return "$0.00" if value.nil? || value.zero?
58
+
59
+ formatted = format("$%.2f", value.abs)
60
+ # Add thousand separators
61
+ formatted.gsub!(/(\d)(?=(\d\d\d)+(?!\d))/, '\\1,')
62
+ value.negative? ? "-#{formatted}" : formatted
63
+ end
64
+
65
+ # Color code value based on positive/negative
66
+ def color_value(value, format_as_currency: true)
67
+ return pastel.dim("$0.00") if value.nil? || value.zero?
68
+
69
+ formatted = format_as_currency ? format_currency(value) : value.to_s
70
+
71
+ if value.positive?
72
+ pastel.green(formatted)
73
+ else
74
+ pastel.red(formatted)
75
+ end
76
+ end
77
+
78
+ # Get current session if authenticated
79
+ def current_session
80
+ @current_session ||= load_session
81
+ rescue StandardError => e
82
+ error("Failed to load session: #{e.message}")
83
+ nil
84
+ end
85
+
86
+ # Check if user is authenticated
87
+ def authenticated?
88
+ !current_session.nil?
89
+ end
90
+
91
+ # Require authentication or exit
92
+ def require_authentication!
93
+ return if authenticated?
94
+
95
+ error("You must be logged in to use this command.")
96
+ info("Run 'tastytrade login' to authenticate.")
97
+ exit 1
98
+ end
99
+
100
+ # Get the currently selected account
101
+ def current_account
102
+ return @current_account if @current_account
103
+
104
+ account_number = config.get("current_account_number")
105
+ return nil unless account_number
106
+
107
+ @current_account = Tastytrade::Models::Account.get(current_session, account_number)
108
+ rescue StandardError => e
109
+ warn "Failed to load current account: #{e.message}"
110
+ nil
111
+ end
112
+
113
+ # Get the currently selected account number
114
+ def current_account_number
115
+ config.get("current_account_number")
116
+ end
117
+
118
+ private
119
+
120
+ def load_session
121
+ # Try to load saved session
122
+ username = config.get("current_username")
123
+ environment = config.get("environment") || "production"
124
+
125
+ return nil unless username
126
+
127
+ manager = SessionManager.new(username: username, environment: environment)
128
+ session_data = manager.load_session
129
+
130
+ return nil unless session_data && session_data[:session_token]
131
+
132
+ # Create session with saved token
133
+ session = Session.new(
134
+ username: session_data[:username],
135
+ password: nil,
136
+ is_test: environment == "sandbox"
137
+ )
138
+
139
+ # Set the tokens and expiration directly
140
+ session.instance_variable_set(:@session_token, session_data[:session_token])
141
+ session.instance_variable_set(:@remember_token, session_data[:remember_token])
142
+ if session_data[:session_expiration]
143
+ session.instance_variable_set(:@session_expiration, Time.parse(session_data[:session_expiration]))
144
+ end
145
+
146
+ # Check if session needs refresh
147
+ if session.expired? && session.remember_token
148
+ info "Session expired, refreshing automatically..."
149
+ session.refresh_session
150
+ manager.save_session(session)
151
+ success "Session refreshed"
152
+ elsif session.expired?
153
+ warning "Session expired and no refresh token available"
154
+ return nil
155
+ end
156
+
157
+ # Final validation
158
+ if session.validate
159
+ session
160
+ else
161
+ # Try one more refresh if we have a remember token
162
+ if session.remember_token
163
+ session.refresh_session
164
+ manager.save_session(session) if session.validate
165
+ session
166
+ else
167
+ nil
168
+ end
169
+ end
170
+ rescue Tastytrade::SessionExpiredError, Tastytrade::AuthenticationError => e
171
+ warning "Session invalid: #{e.message}"
172
+ nil
173
+ rescue StandardError => e
174
+ error "Failed to load session: #{e.message}"
175
+ nil
176
+ end
177
+ end
178
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "faraday/retry"
5
+ require "json"
6
+
7
+ module Tastytrade
8
+ # HTTP client wrapper for Tastytrade API communication
9
+ class Client
10
+ attr_reader :base_url
11
+
12
+ DEFAULT_TIMEOUT = 30
13
+
14
+ def initialize(base_url:, timeout: DEFAULT_TIMEOUT)
15
+ @base_url = base_url
16
+ @timeout = timeout
17
+ end
18
+
19
+ def get(path, params = {}, headers = {})
20
+ response = connection.get(path, params, default_headers.merge(headers))
21
+ handle_response(response)
22
+ rescue Faraday::ConnectionFailed => e
23
+ raise Tastytrade::NetworkTimeoutError, "Request timed out: #{e.message}"
24
+ end
25
+
26
+ def post(path, body = {}, headers = {})
27
+ response = connection.post(path, body.to_json, default_headers.merge(headers))
28
+ handle_response(response)
29
+ rescue Faraday::ConnectionFailed => e
30
+ raise Tastytrade::NetworkTimeoutError, "Request timed out: #{e.message}"
31
+ end
32
+
33
+ def put(path, body = {}, headers = {})
34
+ response = connection.put(path, body.to_json, default_headers.merge(headers))
35
+ handle_response(response)
36
+ rescue Faraday::ConnectionFailed => e
37
+ raise Tastytrade::NetworkTimeoutError, "Request timed out: #{e.message}"
38
+ end
39
+
40
+ def delete(path, headers = {})
41
+ response = connection.delete(path, nil, default_headers.merge(headers))
42
+ handle_response(response)
43
+ rescue Faraday::ConnectionFailed => e
44
+ raise Tastytrade::NetworkTimeoutError, "Request timed out: #{e.message}"
45
+ end
46
+
47
+ private
48
+
49
+ def connection
50
+ @connection ||= Faraday.new(url: base_url) do |faraday|
51
+ faraday.request :retry, max: 2, interval: 0.5,
52
+ retry_statuses: [429, 503, 504],
53
+ methods: %i[get put delete]
54
+ faraday.options.timeout = @timeout
55
+ faraday.options.open_timeout = @timeout
56
+ faraday.adapter Faraday.default_adapter
57
+ end
58
+ end
59
+
60
+ def default_headers
61
+ {
62
+ "Accept" => "application/json",
63
+ "Content-Type" => "application/json"
64
+ }
65
+ end
66
+
67
+ def handle_response(response)
68
+ return handle_success(response) if (200..299).cover?(response.status)
69
+
70
+ handle_error(response)
71
+ end
72
+
73
+ def handle_success(response)
74
+ return nil if response.body.nil? || response.body.empty?
75
+
76
+ # API returns data in a 'data' field for most endpoints
77
+ parse_json(response.body)
78
+ end
79
+
80
+ def handle_error(response)
81
+ error_details = parse_error_message(response)
82
+
83
+ case response.status
84
+ when 401
85
+ raise Tastytrade::InvalidCredentialsError, "Authentication failed: #{error_details}"
86
+ when 403
87
+ raise Tastytrade::SessionExpiredError, "Session expired or invalid: #{error_details}"
88
+ when 404
89
+ raise Tastytrade::Error, "Resource not found: #{error_details}"
90
+ when 429
91
+ raise Tastytrade::Error, "Rate limit exceeded: #{error_details}"
92
+ when 400..499
93
+ raise Tastytrade::Error, "Client error: #{error_details}"
94
+ when 500..599
95
+ raise Tastytrade::Error, "Server error: #{error_details}"
96
+ else
97
+ raise Tastytrade::Error, "Unexpected response: #{error_details}"
98
+ end
99
+ end
100
+
101
+ def parse_json(body)
102
+ JSON.parse(body)
103
+ rescue JSON::ParserError => e
104
+ raise Tastytrade::Error, "Invalid JSON response: #{e.message}"
105
+ end
106
+
107
+ def parse_error_message(response)
108
+ return response.status.to_s if response.body.nil? || response.body.empty?
109
+
110
+ data = parse_json(response.body)
111
+ # Handle both old and new API error formats
112
+ data["error"] || data["message"] || data["reason"] || response.status.to_s
113
+ rescue StandardError
114
+ response.status.to_s
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "keyring"
4
+
5
+ module Tastytrade
6
+ # Secure credential storage using system keyring
7
+ class KeyringStore
8
+ SERVICE_NAME = "tastytrade-ruby"
9
+
10
+ class << self
11
+ # Store a credential securely
12
+ #
13
+ # @param key [String] The credential key
14
+ # @param value [String] The credential value
15
+ # @return [Boolean] Success status
16
+ def set(key, value)
17
+ return false if key.nil? || value.nil?
18
+
19
+ backend.set_password(SERVICE_NAME, key.to_s, value.to_s)
20
+ true
21
+ rescue StandardError => e
22
+ warn "Failed to store credential: #{e.message}"
23
+ false
24
+ end
25
+
26
+ # Retrieve a credential
27
+ #
28
+ # @param key [String] The credential key
29
+ # @return [String, nil] The credential value or nil if not found
30
+ def get(key)
31
+ return nil if key.nil?
32
+
33
+ backend.get_password(SERVICE_NAME, key.to_s)
34
+ rescue StandardError => e
35
+ warn "Failed to retrieve credential: #{e.message}"
36
+ nil
37
+ end
38
+
39
+ # Delete a credential
40
+ #
41
+ # @param key [String] The credential key
42
+ # @return [Boolean] Success status
43
+ def delete(key)
44
+ return false if key.nil?
45
+
46
+ backend.delete_password(SERVICE_NAME, key.to_s)
47
+ true
48
+ rescue StandardError => e
49
+ warn "Failed to delete credential: #{e.message}"
50
+ false
51
+ end
52
+
53
+ # Check if keyring is available
54
+ #
55
+ # @return [Boolean] True if keyring backend is available
56
+ def available?
57
+ !backend.nil?
58
+ rescue StandardError
59
+ false
60
+ end
61
+
62
+ private
63
+
64
+ def backend
65
+ @backend ||= Keyring.new
66
+ rescue StandardError => e
67
+ warn "Keyring not available: #{e.message}"
68
+ nil
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tastytrade
4
+ module Models
5
+ # Represents a Tastytrade account
6
+ class Account < Base
7
+ attr_reader :account_number, :nickname, :account_type_name,
8
+ :opened_at, :is_closed, :day_trader_status,
9
+ :is_futures_approved, :margin_or_cash, :is_foreign,
10
+ :created_at, :external_id, :closed_at, :funding_date,
11
+ :investment_objective, :suitable_options_level,
12
+ :is_test_drive
13
+
14
+ class << self
15
+ # Get all accounts for the authenticated user
16
+ #
17
+ # @param session [Tastytrade::Session] Active session
18
+ # @param include_closed [Boolean] Include closed accounts
19
+ # @return [Array<Account>] List of accounts
20
+ def get_all(session, include_closed: false)
21
+ params = include_closed ? { "include-closed" => true } : {}
22
+ response = session.get("/customers/me/accounts/", params)
23
+ response["data"]["items"].map { |item| new(item["account"]) }
24
+ end
25
+
26
+ # Get a specific account by account number
27
+ #
28
+ # @param session [Tastytrade::Session] Active session
29
+ # @param account_number [String] Account number
30
+ # @return [Account] Account instance
31
+ def get(session, account_number)
32
+ response = session.get("/accounts/#{account_number}/")
33
+ new(response["data"])
34
+ end
35
+ end
36
+
37
+ # Get account balances
38
+ #
39
+ # @param session [Tastytrade::Session] Active session
40
+ # @return [AccountBalance] Account balance object
41
+ def get_balances(session)
42
+ response = session.get("/accounts/#{account_number}/balances/")
43
+ AccountBalance.new(response["data"])
44
+ end
45
+
46
+ # Get current positions
47
+ #
48
+ # @param session [Tastytrade::Session] Active session
49
+ # @param symbol [String, nil] Filter by symbol
50
+ # @param underlying_symbol [String, nil] Filter by underlying symbol
51
+ # @param include_closed [Boolean] Include closed positions
52
+ # @return [Array<CurrentPosition>] Position objects
53
+ def get_positions(session, symbol: nil, underlying_symbol: nil, include_closed: false)
54
+ params = {}
55
+ params["symbol"] = symbol if symbol
56
+ params["underlying-symbol"] = underlying_symbol if underlying_symbol
57
+ params["include-closed"] = include_closed if include_closed
58
+
59
+ response = session.get("/accounts/#{account_number}/positions/", params)
60
+ response["data"]["items"].map { |item| CurrentPosition.new(item) }
61
+ end
62
+
63
+ # Get trading status
64
+ #
65
+ # @param session [Tastytrade::Session] Active session
66
+ # @return [Hash] Trading status data
67
+ def get_trading_status(session)
68
+ session.get("/accounts/#{account_number}/trading-status/")["data"]
69
+ end
70
+
71
+ def closed?
72
+ @is_closed == true
73
+ end
74
+
75
+ def futures_approved?
76
+ @is_futures_approved == true
77
+ end
78
+
79
+ def test_drive?
80
+ @is_test_drive == true
81
+ end
82
+
83
+ def foreign?
84
+ @is_foreign == true
85
+ end
86
+
87
+ private
88
+
89
+ def parse_attributes
90
+ parse_basic_attributes
91
+ parse_status_attributes
92
+ parse_optional_attributes
93
+ end
94
+
95
+ def parse_basic_attributes
96
+ @account_number = @data["account-number"]
97
+ @nickname = @data["nickname"]
98
+ @account_type_name = @data["account-type-name"]
99
+ @opened_at = parse_time(@data["opened-at"])
100
+ @margin_or_cash = @data["margin-or-cash"]
101
+ @created_at = parse_time(@data["created-at"])
102
+ end
103
+
104
+ def parse_status_attributes
105
+ @is_closed = @data["is-closed"]
106
+ @day_trader_status = @data["day-trader-status"]
107
+ @is_futures_approved = @data["is-futures-approved"]
108
+ @is_foreign = @data["is-foreign"]
109
+ @is_test_drive = @data["is-test-drive"]
110
+ end
111
+
112
+ def parse_optional_attributes
113
+ @external_id = @data["external-id"]
114
+ @closed_at = parse_time(@data["closed-at"])
115
+ @funding_date = parse_date(@data["funding-date"])
116
+ @investment_objective = @data["investment-objective"]
117
+ @suitable_options_level = @data["suitable-options-level"]
118
+ end
119
+
120
+ def parse_date(value)
121
+ return nil if value.nil? || value.empty?
122
+
123
+ Date.parse(value)
124
+ rescue ArgumentError
125
+ nil
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bigdecimal"
4
+
5
+ module Tastytrade
6
+ module Models
7
+ # Represents account balance information from the API
8
+ class AccountBalance < Base
9
+ attr_reader :account_number, :cash_balance, :long_equity_value, :short_equity_value,
10
+ :long_derivative_value, :short_derivative_value, :net_liquidating_value,
11
+ :equity_buying_power, :derivative_buying_power, :day_trading_buying_power,
12
+ :available_trading_funds, :margin_equity, :pending_cash,
13
+ :pending_margin_interest, :effective_trading_funds, :updated_at
14
+
15
+ def initialize(data)
16
+ super
17
+ @account_number = data["account-number"]
18
+
19
+ # Convert all monetary values to BigDecimal for precision
20
+ @cash_balance = parse_decimal(data["cash-balance"])
21
+ @long_equity_value = parse_decimal(data["long-equity-value"])
22
+ @short_equity_value = parse_decimal(data["short-equity-value"])
23
+ @long_derivative_value = parse_decimal(data["long-derivative-value"])
24
+ @short_derivative_value = parse_decimal(data["short-derivative-value"])
25
+ @net_liquidating_value = parse_decimal(data["net-liquidating-value"])
26
+ @equity_buying_power = parse_decimal(data["equity-buying-power"])
27
+ @derivative_buying_power = parse_decimal(data["derivative-buying-power"])
28
+ @day_trading_buying_power = parse_decimal(data["day-trading-buying-power"])
29
+ @available_trading_funds = parse_decimal(data["available-trading-funds"])
30
+ @margin_equity = parse_decimal(data["margin-equity"])
31
+ @pending_cash = parse_decimal(data["pending-cash"])
32
+ @pending_margin_interest = parse_decimal(data["pending-margin-interest"])
33
+ @effective_trading_funds = parse_decimal(data["effective-trading-funds"])
34
+
35
+ @updated_at = parse_time(data["updated-at"])
36
+ end
37
+
38
+ # Calculate buying power usage as a percentage
39
+ def buying_power_usage_percentage
40
+ return BigDecimal("0") if equity_buying_power.zero?
41
+
42
+ used_buying_power = equity_buying_power - available_trading_funds
43
+ ((used_buying_power / equity_buying_power) * 100).round(2)
44
+ end
45
+
46
+ # Check if buying power usage is above warning threshold
47
+ def high_buying_power_usage?(threshold = 80)
48
+ buying_power_usage_percentage > threshold
49
+ end
50
+
51
+ # Calculate total equity value (long + short)
52
+ def total_equity_value
53
+ long_equity_value + short_equity_value
54
+ end
55
+
56
+ # Calculate total derivative value (long + short)
57
+ def total_derivative_value
58
+ long_derivative_value + short_derivative_value
59
+ end
60
+
61
+ # Calculate total market value (equity + derivatives)
62
+ def total_market_value
63
+ total_equity_value + total_derivative_value
64
+ end
65
+
66
+ private
67
+
68
+ # Parse string value to BigDecimal, handling nil and empty strings
69
+ def parse_decimal(value)
70
+ return BigDecimal("0") if value.nil? || value.to_s.empty?
71
+ BigDecimal(value.to_s)
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Tastytrade
6
+ module Models
7
+ # Base class for all Tastytrade data models
8
+ class Base
9
+ def initialize(data = {})
10
+ @data = stringify_keys(data)
11
+ parse_attributes
12
+ end
13
+
14
+ attr_reader :data
15
+
16
+ private
17
+
18
+ # Convert snake_case to dash-case for API compatibility
19
+ def to_api_key(key)
20
+ key.to_s.tr("_", "-")
21
+ end
22
+
23
+ # Convert dash-case to snake_case for Ruby
24
+ def to_ruby_key(key)
25
+ key.to_s.tr("-", "_")
26
+ end
27
+
28
+ def stringify_keys(hash)
29
+ hash.transform_keys(&:to_s)
30
+ end
31
+
32
+ # Override in subclasses to define attribute parsing
33
+ def parse_attributes
34
+ # Implemented by subclasses
35
+ end
36
+
37
+ # Helper method to parse datetime strings
38
+ def parse_time(value)
39
+ return nil if value.nil? || value.empty?
40
+
41
+ Time.parse(value)
42
+ rescue ArgumentError
43
+ nil
44
+ end
45
+ end
46
+ end
47
+ end