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.
- checksums.yaml +7 -0
- data/.claude/commands/release-pr.md +108 -0
- data/.github/ISSUE_TEMPLATE/feature_request.md +29 -0
- data/.github/ISSUE_TEMPLATE/roadmap_task.md +34 -0
- data/.github/dependabot.yml +11 -0
- data/.github/workflows/main.yml +75 -0
- data/.rspec +3 -0
- data/.rubocop.yml +101 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +100 -0
- data/CLAUDE.md +78 -0
- data/CODE_OF_CONDUCT.md +81 -0
- data/CONTRIBUTING.md +89 -0
- data/DISCLAIMER.md +54 -0
- data/LICENSE.txt +24 -0
- data/README.md +235 -0
- data/ROADMAP.md +157 -0
- data/Rakefile +17 -0
- data/SECURITY.md +48 -0
- data/docs/getting_started.md +48 -0
- data/docs/python_sdk_analysis.md +181 -0
- data/exe/tastytrade +8 -0
- data/lib/tastytrade/cli.rb +604 -0
- data/lib/tastytrade/cli_config.rb +79 -0
- data/lib/tastytrade/cli_helpers.rb +178 -0
- data/lib/tastytrade/client.rb +117 -0
- data/lib/tastytrade/keyring_store.rb +72 -0
- data/lib/tastytrade/models/account.rb +129 -0
- data/lib/tastytrade/models/account_balance.rb +75 -0
- data/lib/tastytrade/models/base.rb +47 -0
- data/lib/tastytrade/models/current_position.rb +155 -0
- data/lib/tastytrade/models/user.rb +23 -0
- data/lib/tastytrade/models.rb +7 -0
- data/lib/tastytrade/session.rb +164 -0
- data/lib/tastytrade/session_manager.rb +160 -0
- data/lib/tastytrade/version.rb +5 -0
- data/lib/tastytrade.rb +31 -0
- data/sig/tastytrade.rbs +4 -0
- data/spec/exe/tastytrade_spec.rb +104 -0
- data/spec/spec_helper.rb +26 -0
- data/spec/tastytrade/cli_accounts_spec.rb +166 -0
- data/spec/tastytrade/cli_auth_spec.rb +216 -0
- data/spec/tastytrade/cli_config_spec.rb +180 -0
- data/spec/tastytrade/cli_helpers_spec.rb +248 -0
- data/spec/tastytrade/cli_interactive_spec.rb +54 -0
- data/spec/tastytrade/cli_logout_spec.rb +121 -0
- data/spec/tastytrade/cli_select_spec.rb +174 -0
- data/spec/tastytrade/cli_status_spec.rb +206 -0
- data/spec/tastytrade/client_spec.rb +210 -0
- data/spec/tastytrade/keyring_store_spec.rb +168 -0
- data/spec/tastytrade/models/account_balance_spec.rb +247 -0
- data/spec/tastytrade/models/account_spec.rb +206 -0
- data/spec/tastytrade/models/base_spec.rb +61 -0
- data/spec/tastytrade/models/current_position_spec.rb +444 -0
- data/spec/tastytrade/models/user_spec.rb +58 -0
- data/spec/tastytrade/session_manager_spec.rb +296 -0
- data/spec/tastytrade/session_spec.rb +392 -0
- data/spec/tastytrade_spec.rb +9 -0
- 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
|