tastytrade 0.2.0 → 0.3.1
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 +180 -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/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
- metadata +34 -18
- data/lib/tastytrade/keyring_store.rb +0 -72
- data/spec/tastytrade/keyring_store_spec.rb +0 -168
@@ -106,7 +106,9 @@ module Tastytrade
|
|
106
106
|
|
107
107
|
@current_account = Tastytrade::Models::Account.get(current_session, account_number)
|
108
108
|
rescue StandardError => e
|
109
|
-
warn
|
109
|
+
# Only warn if debug mode is enabled - otherwise silently return nil
|
110
|
+
# and let the caller handle the fallback
|
111
|
+
warn "Failed to load current account: #{e.message}" if ENV["DEBUG_SESSION"]
|
110
112
|
nil
|
111
113
|
end
|
112
114
|
|
@@ -115,6 +117,106 @@ module Tastytrade
|
|
115
117
|
config.get("current_account_number")
|
116
118
|
end
|
117
119
|
|
120
|
+
# Format buying power status based on usage percentage
|
121
|
+
def format_bp_status(usage_percentage)
|
122
|
+
return pastel.dim("N/A") unless usage_percentage
|
123
|
+
|
124
|
+
case usage_percentage.to_f
|
125
|
+
when 0..50
|
126
|
+
pastel.green("Low")
|
127
|
+
when 50..80
|
128
|
+
pastel.yellow("Moderate")
|
129
|
+
when 80..90
|
130
|
+
pastel.bright_yellow("High")
|
131
|
+
else
|
132
|
+
pastel.red("Critical")
|
133
|
+
end
|
134
|
+
end
|
135
|
+
|
136
|
+
# Display trading status information
|
137
|
+
def display_trading_status(status)
|
138
|
+
puts "\n#{pastel.bold("Trading Status for Account:")} #{status.account_number}"
|
139
|
+
puts pastel.dim("─" * 60)
|
140
|
+
|
141
|
+
# Display restrictions first if any
|
142
|
+
restrictions = status.active_restrictions
|
143
|
+
if restrictions.any?
|
144
|
+
puts "\n#{pastel.bold.red("⚠ Account Restrictions:")}"
|
145
|
+
restrictions.each do |restriction|
|
146
|
+
case restriction
|
147
|
+
when "Margin Call", "Day Trade Equity Maintenance Call"
|
148
|
+
puts " #{pastel.red("• #{restriction}")}"
|
149
|
+
when "Pattern Day Trader"
|
150
|
+
puts " #{pastel.yellow("• #{restriction}")}"
|
151
|
+
when "Account Closed", "Account Frozen"
|
152
|
+
puts " #{pastel.bright_red("• #{restriction}")}"
|
153
|
+
else
|
154
|
+
puts " #{pastel.yellow("• #{restriction}")}"
|
155
|
+
end
|
156
|
+
end
|
157
|
+
else
|
158
|
+
puts "\n#{pastel.green("✓ No account restrictions")}"
|
159
|
+
end
|
160
|
+
|
161
|
+
# Display trading permissions
|
162
|
+
puts "\n#{pastel.bold("Trading Permissions:")}"
|
163
|
+
permissions = status.permissions_summary
|
164
|
+
|
165
|
+
# Options trading
|
166
|
+
options_status = permissions[:options]
|
167
|
+
options_color = options_status == "Disabled" ? :dim : :green
|
168
|
+
puts " Options Trading: #{pastel.send(options_color, options_status)}"
|
169
|
+
|
170
|
+
# Short calls
|
171
|
+
short_calls_status = permissions[:short_calls]
|
172
|
+
short_calls_color = short_calls_status == "Enabled" ? :green : :dim
|
173
|
+
puts " Short Calls: #{pastel.send(short_calls_color, short_calls_status)}"
|
174
|
+
|
175
|
+
# Futures trading
|
176
|
+
futures_status = permissions[:futures]
|
177
|
+
futures_color = case futures_status
|
178
|
+
when "Enabled" then :green
|
179
|
+
when "Closing Only" then :yellow
|
180
|
+
else :dim
|
181
|
+
end
|
182
|
+
puts " Futures Trading: #{pastel.send(futures_color, futures_status)}"
|
183
|
+
|
184
|
+
# Cryptocurrency trading
|
185
|
+
crypto_status = permissions[:cryptocurrency]
|
186
|
+
crypto_color = case crypto_status
|
187
|
+
when "Enabled" then :green
|
188
|
+
when "Closing Only" then :yellow
|
189
|
+
else :dim
|
190
|
+
end
|
191
|
+
puts " Cryptocurrency: #{pastel.send(crypto_color, crypto_status)}"
|
192
|
+
|
193
|
+
# Portfolio margin
|
194
|
+
pm_status = permissions[:portfolio_margin]
|
195
|
+
pm_color = pm_status == "Enabled" ? :green : :dim
|
196
|
+
puts " Portfolio Margin: #{pastel.send(pm_color, pm_status)}"
|
197
|
+
|
198
|
+
# Display account characteristics
|
199
|
+
puts "\n#{pastel.bold("Account Characteristics:")}"
|
200
|
+
pdt_status = permissions[:pattern_day_trader] == "Yes" ? pastel.yellow("Yes") : pastel.dim("No")
|
201
|
+
puts " Pattern Day Trader: #{pdt_status}"
|
202
|
+
|
203
|
+
if status.day_trade_count && status.day_trade_count > 0
|
204
|
+
puts " Day Trade Count: #{pastel.cyan(status.day_trade_count.to_s)}"
|
205
|
+
end
|
206
|
+
|
207
|
+
if status.pdt_reset_on
|
208
|
+
puts " PDT Reset Date: #{pastel.yellow(status.pdt_reset_on.strftime("%Y-%m-%d"))}"
|
209
|
+
end
|
210
|
+
|
211
|
+
# Display additional info
|
212
|
+
puts "\n#{pastel.bold("Additional Information:")}"
|
213
|
+
puts " Fee Schedule: #{pastel.cyan(status.fee_schedule_name)}"
|
214
|
+
puts " Margin Type: #{pastel.cyan(status.equities_margin_calculation_type)}"
|
215
|
+
puts " Last Updated: #{pastel.dim(status.updated_at.strftime("%Y-%m-%d %H:%M:%S %Z"))}"
|
216
|
+
|
217
|
+
puts ""
|
218
|
+
end
|
219
|
+
|
118
220
|
private
|
119
221
|
|
120
222
|
def load_session
|
@@ -143,6 +245,12 @@ module Tastytrade
|
|
143
245
|
session.instance_variable_set(:@session_expiration, Time.parse(session_data[:session_expiration]))
|
144
246
|
end
|
145
247
|
|
248
|
+
# Set user data if available
|
249
|
+
if session_data[:user_data]
|
250
|
+
user = Tastytrade::Models::User.new(session_data[:user_data])
|
251
|
+
session.instance_variable_set(:@user, user)
|
252
|
+
end
|
253
|
+
|
146
254
|
# Check if session needs refresh
|
147
255
|
if session.expired? && session.remember_token
|
148
256
|
info "Session expired, refreshing automatically..."
|
@@ -154,19 +262,8 @@ module Tastytrade
|
|
154
262
|
return nil
|
155
263
|
end
|
156
264
|
|
157
|
-
#
|
158
|
-
|
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
|
265
|
+
# Return the session - validation happens on actual API calls
|
266
|
+
session
|
170
267
|
rescue Tastytrade::SessionExpiredError, Tastytrade::AuthenticationError => e
|
171
268
|
warning "Session invalid: #{e.message}"
|
172
269
|
nil
|
data/lib/tastytrade/client.rb
CHANGED
@@ -108,6 +108,13 @@ module Tastytrade
|
|
108
108
|
return response.status.to_s if response.body.nil? || response.body.empty?
|
109
109
|
|
110
110
|
data = parse_json(response.body)
|
111
|
+
|
112
|
+
# Handle preflight check failures with detailed errors
|
113
|
+
if data["code"] == "preflight_check_failure" && data["errors"]
|
114
|
+
error_details = data["errors"].map { |e| e["message"] }.join(", ")
|
115
|
+
return "#{data["message"]}: #{error_details}"
|
116
|
+
end
|
117
|
+
|
111
118
|
# Handle both old and new API error formats
|
112
119
|
data["error"] || data["message"] || data["reason"] || response.status.to_s
|
113
120
|
rescue StandardError
|
@@ -0,0 +1,83 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "fileutils"
|
4
|
+
require "json"
|
5
|
+
|
6
|
+
module Tastytrade
|
7
|
+
# Secure file-based credential storage
|
8
|
+
module FileStore
|
9
|
+
class << self
|
10
|
+
# Store a credential in a file
|
11
|
+
#
|
12
|
+
# @param key [String] The credential key
|
13
|
+
# @param value [String] The credential value
|
14
|
+
# @return [Boolean] Success status
|
15
|
+
def set(key, value)
|
16
|
+
return false if key.nil? || value.nil?
|
17
|
+
|
18
|
+
ensure_storage_directory
|
19
|
+
File.write(credential_path(key), value.to_s, mode: "w", perm: 0o600)
|
20
|
+
true
|
21
|
+
rescue StandardError => e
|
22
|
+
warn "Failed to store credential: #{e.message}" if ENV["DEBUG_SESSION"]
|
23
|
+
false
|
24
|
+
end
|
25
|
+
|
26
|
+
# Retrieve a credential from a file
|
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
|
+
path = credential_path(key)
|
34
|
+
return nil unless File.exist?(path)
|
35
|
+
|
36
|
+
File.read(path).strip
|
37
|
+
rescue StandardError => e
|
38
|
+
warn "Failed to retrieve credential: #{e.message}" if ENV["DEBUG_SESSION"]
|
39
|
+
nil
|
40
|
+
end
|
41
|
+
|
42
|
+
# Delete a credential file
|
43
|
+
#
|
44
|
+
# @param key [String] The credential key
|
45
|
+
# @return [Boolean] Success status
|
46
|
+
def delete(key)
|
47
|
+
return false if key.nil?
|
48
|
+
|
49
|
+
path = credential_path(key)
|
50
|
+
return true unless File.exist?(path)
|
51
|
+
|
52
|
+
File.delete(path)
|
53
|
+
true
|
54
|
+
rescue StandardError => e
|
55
|
+
warn "Failed to delete credential: #{e.message}" if ENV["DEBUG_SESSION"]
|
56
|
+
false
|
57
|
+
end
|
58
|
+
|
59
|
+
# Check if file storage is available
|
60
|
+
#
|
61
|
+
# @return [Boolean] Always true for file storage
|
62
|
+
def available?
|
63
|
+
true
|
64
|
+
end
|
65
|
+
|
66
|
+
private
|
67
|
+
|
68
|
+
def storage_directory
|
69
|
+
@storage_directory ||= File.expand_path("~/.config/tastytrade/credentials")
|
70
|
+
end
|
71
|
+
|
72
|
+
def ensure_storage_directory
|
73
|
+
FileUtils.mkdir_p(storage_directory, mode: 0o700)
|
74
|
+
end
|
75
|
+
|
76
|
+
def credential_path(key)
|
77
|
+
# Sanitize key to be filesystem-safe
|
78
|
+
safe_key = key.to_s.gsub(/[^a-zA-Z0-9._@-]/, "_")
|
79
|
+
File.join(storage_directory, "#{safe_key}.cred")
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
@@ -0,0 +1,42 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Tastytrade
|
4
|
+
module Instruments
|
5
|
+
# Represents an equity instrument
|
6
|
+
class Equity
|
7
|
+
attr_reader :symbol, :description, :exchange, :cusip, :active
|
8
|
+
|
9
|
+
def initialize(data = {})
|
10
|
+
@symbol = data["symbol"]
|
11
|
+
@description = data["description"]
|
12
|
+
@exchange = data["exchange"]
|
13
|
+
@cusip = data["cusip"]
|
14
|
+
@active = data["active"]
|
15
|
+
end
|
16
|
+
|
17
|
+
# Get equity information for a symbol
|
18
|
+
#
|
19
|
+
# @param session [Tastytrade::Session] Active session
|
20
|
+
# @param symbol [String] Equity symbol
|
21
|
+
# @return [Equity] Equity instrument
|
22
|
+
def self.get(session, symbol)
|
23
|
+
response = session.get("/instruments/equities/#{symbol}")
|
24
|
+
new(response["data"])
|
25
|
+
end
|
26
|
+
|
27
|
+
# Create an order leg for this equity
|
28
|
+
#
|
29
|
+
# @param action [String] Order action (from OrderAction module)
|
30
|
+
# @param quantity [Integer] Number of shares
|
31
|
+
# @return [OrderLeg] Order leg for this equity
|
32
|
+
def build_leg(action:, quantity:)
|
33
|
+
OrderLeg.new(
|
34
|
+
action: action,
|
35
|
+
symbol: @symbol,
|
36
|
+
quantity: quantity,
|
37
|
+
instrument_type: "Equity"
|
38
|
+
)
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
@@ -63,9 +63,147 @@ module Tastytrade
|
|
63
63
|
# Get trading status
|
64
64
|
#
|
65
65
|
# @param session [Tastytrade::Session] Active session
|
66
|
-
# @return [
|
66
|
+
# @return [Tastytrade::Models::TradingStatus] Trading status object
|
67
67
|
def get_trading_status(session)
|
68
|
-
session.get("/accounts/#{account_number}/trading-status/")
|
68
|
+
response = session.get("/accounts/#{account_number}/trading-status/")
|
69
|
+
TradingStatus.new(response["data"])
|
70
|
+
end
|
71
|
+
|
72
|
+
# Places an order for this account with comprehensive validation.
|
73
|
+
# By default, performs full validation including symbol checks, quantity limits,
|
74
|
+
# price validation, account permissions, and buying power verification.
|
75
|
+
#
|
76
|
+
# @param session [Tastytrade::Session] Active session
|
77
|
+
# @param order [Tastytrade::Order] Order to place
|
78
|
+
# @param dry_run [Boolean] Whether to simulate the order without placing it
|
79
|
+
# @param skip_validation [Boolean] Skip pre-submission validation (use with caution)
|
80
|
+
# @return [OrderResponse] Response from order placement with order ID and status
|
81
|
+
# @raise [OrderValidationError] if validation fails with detailed error messages
|
82
|
+
# @raise [InsufficientFundsError] if account lacks buying power
|
83
|
+
# @raise [MarketClosedError] if market is closed
|
84
|
+
#
|
85
|
+
# @example Place an order with validation
|
86
|
+
# response = account.place_order(session, order)
|
87
|
+
# puts response.order_id
|
88
|
+
#
|
89
|
+
# @example Dry-run to check buying power
|
90
|
+
# response = account.place_order(session, order, dry_run: true)
|
91
|
+
# puts response.buying_power_effect
|
92
|
+
#
|
93
|
+
# @example Skip validation when certain order is valid
|
94
|
+
# response = account.place_order(session, order, skip_validation: true)
|
95
|
+
def place_order(session, order, dry_run: false, skip_validation: false)
|
96
|
+
# Validate the order unless explicitly skipped or it's a dry-run
|
97
|
+
unless skip_validation || dry_run
|
98
|
+
validator = OrderValidator.new(session, self, order)
|
99
|
+
validator.validate!
|
100
|
+
end
|
101
|
+
|
102
|
+
endpoint = "/accounts/#{account_number}/orders"
|
103
|
+
endpoint += "/dry-run" if dry_run
|
104
|
+
|
105
|
+
response = session.post(endpoint, order.to_api_params)
|
106
|
+
OrderResponse.new(response["data"])
|
107
|
+
end
|
108
|
+
|
109
|
+
# Get transaction history
|
110
|
+
#
|
111
|
+
# @param session [Tastytrade::Session] Active session
|
112
|
+
# @param options [Hash] Optional filters
|
113
|
+
# @option options [Date, String] :start_date Start date for transactions
|
114
|
+
# @option options [Date, String] :end_date End date for transactions
|
115
|
+
# @option options [String] :symbol Filter by symbol
|
116
|
+
# @option options [String] :underlying_symbol Filter by underlying symbol
|
117
|
+
# @option options [String] :instrument_type Filter by instrument type
|
118
|
+
# @option options [Array<String>] :transaction_types Filter by transaction types
|
119
|
+
# @option options [Integer] :per_page Number of results per page (default: 250)
|
120
|
+
# @option options [Integer] :page_offset Page offset for pagination
|
121
|
+
# @return [Array<Transaction>] Array of transactions
|
122
|
+
def get_transactions(session, **options)
|
123
|
+
Transaction.get_all(session, account_number, **options)
|
124
|
+
end
|
125
|
+
|
126
|
+
# Get live orders (open and orders from last 24 hours)
|
127
|
+
#
|
128
|
+
# @param session [Tastytrade::Session] Active session
|
129
|
+
# @param status [String, nil] Filter by order status
|
130
|
+
# @param underlying_symbol [String, nil] Filter by underlying symbol
|
131
|
+
# @param from_time [Time, nil] Start time for order history
|
132
|
+
# @param to_time [Time, nil] End time for order history
|
133
|
+
# @return [Array<LiveOrder>] Array of live orders
|
134
|
+
def get_live_orders(session, status: nil, underlying_symbol: nil, from_time: nil, to_time: nil)
|
135
|
+
params = {}
|
136
|
+
params["status"] = status if status && OrderStatus.valid?(status)
|
137
|
+
params["underlying-symbol"] = underlying_symbol if underlying_symbol
|
138
|
+
params["from-time"] = from_time.iso8601 if from_time
|
139
|
+
params["to-time"] = to_time.iso8601 if to_time
|
140
|
+
|
141
|
+
response = session.get("/accounts/#{account_number}/orders/live/", params)
|
142
|
+
response["data"]["items"].map { |item| LiveOrder.new(item) }
|
143
|
+
end
|
144
|
+
|
145
|
+
# Get order history for this account (beyond 24 hours)
|
146
|
+
#
|
147
|
+
# @param session [Tastytrade::Session] Active session
|
148
|
+
# @param status [String, nil] Filter by order status
|
149
|
+
# @param underlying_symbol [String, nil] Filter by underlying symbol
|
150
|
+
# @param from_time [Time, nil] Start time for order history
|
151
|
+
# @param to_time [Time, nil] End time for order history
|
152
|
+
# @param page_offset [Integer, nil] Pagination offset
|
153
|
+
# @param page_limit [Integer, nil] Number of results per page (default 250, max 1000)
|
154
|
+
# @return [Array<LiveOrder>] Array of historical orders
|
155
|
+
def get_order_history(session, status: nil, underlying_symbol: nil, from_time: nil, to_time: nil,
|
156
|
+
page_offset: nil, page_limit: nil)
|
157
|
+
params = {}
|
158
|
+
params["status"] = status if status && OrderStatus.valid?(status)
|
159
|
+
params["underlying-symbol"] = underlying_symbol if underlying_symbol
|
160
|
+
params["from-time"] = from_time.iso8601 if from_time
|
161
|
+
params["to-time"] = to_time.iso8601 if to_time
|
162
|
+
params["page-offset"] = page_offset if page_offset
|
163
|
+
params["page-limit"] = page_limit if page_limit
|
164
|
+
|
165
|
+
response = session.get("/accounts/#{account_number}/orders/", params)
|
166
|
+
response["data"]["items"].map { |item| LiveOrder.new(item) }
|
167
|
+
end
|
168
|
+
|
169
|
+
# Get a specific order by ID
|
170
|
+
#
|
171
|
+
# @param session [Tastytrade::Session] Active session
|
172
|
+
# @param order_id [String] Order ID to retrieve
|
173
|
+
# @return [LiveOrder] The requested order
|
174
|
+
def get_order(session, order_id)
|
175
|
+
response = session.get("/accounts/#{account_number}/orders/#{order_id}/")
|
176
|
+
LiveOrder.new(response["data"])
|
177
|
+
end
|
178
|
+
|
179
|
+
# Cancel an order
|
180
|
+
#
|
181
|
+
# @param session [Tastytrade::Session] Active session
|
182
|
+
# @param order_id [String] Order ID to cancel
|
183
|
+
# @return [void]
|
184
|
+
# @raise [OrderNotCancellableError] if order cannot be cancelled
|
185
|
+
# @raise [OrderAlreadyFilledError] if order has already been filled
|
186
|
+
def cancel_order(session, order_id)
|
187
|
+
session.delete("/accounts/#{account_number}/orders/#{order_id}/")
|
188
|
+
nil
|
189
|
+
rescue Tastytrade::Error => e
|
190
|
+
handle_cancel_error(e)
|
191
|
+
end
|
192
|
+
|
193
|
+
# Replace an existing order
|
194
|
+
#
|
195
|
+
# @param session [Tastytrade::Session] Active session
|
196
|
+
# @param order_id [String] Order ID to replace
|
197
|
+
# @param new_order [Tastytrade::Order] New order to replace with
|
198
|
+
# @return [OrderResponse] Response from order replacement
|
199
|
+
# @raise [OrderNotEditableError] if order cannot be edited
|
200
|
+
# @raise [InsufficientQuantityError] if trying to replace more than remaining quantity
|
201
|
+
def replace_order(session, order_id, new_order)
|
202
|
+
response = session.put("/accounts/#{account_number}/orders/#{order_id}/",
|
203
|
+
new_order.to_api_params)
|
204
|
+
OrderResponse.new(response["data"])
|
205
|
+
rescue Tastytrade::Error => e
|
206
|
+
handle_replace_error(e)
|
69
207
|
end
|
70
208
|
|
71
209
|
def closed?
|
@@ -86,6 +224,26 @@ module Tastytrade
|
|
86
224
|
|
87
225
|
private
|
88
226
|
|
227
|
+
def handle_cancel_error(error)
|
228
|
+
if error.message.include?("already filled") || error.message.include?("Filled")
|
229
|
+
raise OrderAlreadyFilledError, "Order has already been filled and cannot be cancelled"
|
230
|
+
elsif error.message.include?("not cancellable") || error.message.include?("Cannot cancel")
|
231
|
+
raise OrderNotCancellableError, "Order is not in a cancellable state"
|
232
|
+
else
|
233
|
+
raise error
|
234
|
+
end
|
235
|
+
end
|
236
|
+
|
237
|
+
def handle_replace_error(error)
|
238
|
+
if error.message.include?("not editable") || error.message.include?("Cannot edit")
|
239
|
+
raise OrderNotEditableError, "Order is not in an editable state"
|
240
|
+
elsif error.message.include?("insufficient quantity") || error.message.include?("exceeds remaining")
|
241
|
+
raise InsufficientQuantityError, "Cannot replace order with quantity exceeding remaining amount"
|
242
|
+
else
|
243
|
+
raise error
|
244
|
+
end
|
245
|
+
end
|
246
|
+
|
89
247
|
def parse_attributes
|
90
248
|
parse_basic_attributes
|
91
249
|
parse_status_attributes
|
@@ -63,6 +63,52 @@ module Tastytrade
|
|
63
63
|
total_equity_value + total_derivative_value
|
64
64
|
end
|
65
65
|
|
66
|
+
# Calculate derivative buying power usage percentage
|
67
|
+
def derivative_buying_power_usage_percentage
|
68
|
+
return BigDecimal("0") if derivative_buying_power.zero?
|
69
|
+
|
70
|
+
used_derivative_buying_power = derivative_buying_power - available_trading_funds
|
71
|
+
((used_derivative_buying_power / derivative_buying_power) * 100).round(2)
|
72
|
+
end
|
73
|
+
|
74
|
+
# Calculate day trading buying power usage percentage
|
75
|
+
def day_trading_buying_power_usage_percentage
|
76
|
+
return BigDecimal("0") if day_trading_buying_power.zero?
|
77
|
+
|
78
|
+
used_day_trading_buying_power = day_trading_buying_power - available_trading_funds
|
79
|
+
((used_day_trading_buying_power / day_trading_buying_power) * 100).round(2)
|
80
|
+
end
|
81
|
+
|
82
|
+
# Get the minimum buying power across all types
|
83
|
+
def minimum_buying_power
|
84
|
+
[equity_buying_power, derivative_buying_power, day_trading_buying_power].min
|
85
|
+
end
|
86
|
+
|
87
|
+
# Check if account has sufficient buying power for a given amount
|
88
|
+
def sufficient_buying_power?(amount, buying_power_type: :equity)
|
89
|
+
bp = case buying_power_type
|
90
|
+
when :equity then equity_buying_power
|
91
|
+
when :derivative then derivative_buying_power
|
92
|
+
when :day_trading then day_trading_buying_power
|
93
|
+
else equity_buying_power
|
94
|
+
end
|
95
|
+
|
96
|
+
bp >= BigDecimal(amount.to_s)
|
97
|
+
end
|
98
|
+
|
99
|
+
# Calculate buying power impact as percentage
|
100
|
+
def buying_power_impact_percentage(amount, buying_power_type: :equity)
|
101
|
+
bp = case buying_power_type
|
102
|
+
when :equity then equity_buying_power
|
103
|
+
when :derivative then derivative_buying_power
|
104
|
+
when :day_trading then day_trading_buying_power
|
105
|
+
else equity_buying_power
|
106
|
+
end
|
107
|
+
|
108
|
+
return BigDecimal("0") if bp.zero?
|
109
|
+
((BigDecimal(amount.to_s) / bp) * 100).round(2)
|
110
|
+
end
|
111
|
+
|
66
112
|
private
|
67
113
|
|
68
114
|
# Parse string value to BigDecimal, handling nil and empty strings
|
@@ -0,0 +1,61 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "bigdecimal"
|
4
|
+
|
5
|
+
module Tastytrade
|
6
|
+
module Models
|
7
|
+
# Represents the buying power effect from a dry-run order or order placement
|
8
|
+
class BuyingPowerEffect < Base
|
9
|
+
attr_reader :change_in_margin_requirement, :change_in_buying_power,
|
10
|
+
:current_buying_power, :new_buying_power,
|
11
|
+
:isolated_order_margin_requirement, :is_spread,
|
12
|
+
:impact, :effect
|
13
|
+
|
14
|
+
# Calculate the buying power usage percentage for this order
|
15
|
+
def buying_power_usage_percentage
|
16
|
+
return BigDecimal("0") if current_buying_power.nil? || current_buying_power.zero?
|
17
|
+
|
18
|
+
impact_amount = impact || change_in_buying_power&.abs || BigDecimal("0")
|
19
|
+
((impact_amount / current_buying_power) * 100).round(2)
|
20
|
+
end
|
21
|
+
|
22
|
+
# Check if this order would use more than the specified percentage of buying power
|
23
|
+
def exceeds_threshold?(threshold_percentage)
|
24
|
+
buying_power_usage_percentage > BigDecimal(threshold_percentage.to_s)
|
25
|
+
end
|
26
|
+
|
27
|
+
# Get the absolute value of the buying power change
|
28
|
+
def buying_power_change_amount
|
29
|
+
change_in_buying_power&.abs || impact&.abs || BigDecimal("0")
|
30
|
+
end
|
31
|
+
|
32
|
+
# Check if this is a debit (reduces buying power)
|
33
|
+
def debit?
|
34
|
+
effect == "Debit" || (change_in_buying_power && change_in_buying_power < 0)
|
35
|
+
end
|
36
|
+
|
37
|
+
# Check if this is a credit (increases buying power)
|
38
|
+
def credit?
|
39
|
+
effect == "Credit" || (change_in_buying_power && change_in_buying_power > 0)
|
40
|
+
end
|
41
|
+
|
42
|
+
private
|
43
|
+
|
44
|
+
def parse_attributes
|
45
|
+
@change_in_margin_requirement = parse_decimal(@data["change-in-margin-requirement"])
|
46
|
+
@change_in_buying_power = parse_decimal(@data["change-in-buying-power"])
|
47
|
+
@current_buying_power = parse_decimal(@data["current-buying-power"])
|
48
|
+
@new_buying_power = parse_decimal(@data["new-buying-power"])
|
49
|
+
@isolated_order_margin_requirement = parse_decimal(@data["isolated-order-margin-requirement"])
|
50
|
+
@is_spread = @data["is-spread"]
|
51
|
+
@impact = parse_decimal(@data["impact"])
|
52
|
+
@effect = @data["effect"]
|
53
|
+
end
|
54
|
+
|
55
|
+
def parse_decimal(value)
|
56
|
+
return nil if value.nil? || value.to_s.empty?
|
57
|
+
BigDecimal(value.to_s)
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|