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
@@ -0,0 +1,749 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "thor"
|
4
|
+
require "tty-table"
|
5
|
+
require "tty-prompt"
|
6
|
+
require "time"
|
7
|
+
|
8
|
+
module Tastytrade
|
9
|
+
class CLI < Thor
|
10
|
+
# Thor subcommand for order management
|
11
|
+
class Orders < Thor
|
12
|
+
include Tastytrade::CLIHelpers
|
13
|
+
|
14
|
+
desc "list", "List live orders (open and orders from last 24 hours)"
|
15
|
+
option :status, type: :string, desc: "Filter by status (Live, Filled, Cancelled, etc.)"
|
16
|
+
option :symbol, type: :string, desc: "Filter by underlying symbol"
|
17
|
+
option :all, type: :boolean, default: false, desc: "Show orders for all accounts"
|
18
|
+
option :format, type: :string, desc: "Output format (table, json)", default: "table"
|
19
|
+
def list
|
20
|
+
require_authentication!
|
21
|
+
|
22
|
+
accounts = if options[:all]
|
23
|
+
Tastytrade::Models::Account.get_all(current_session)
|
24
|
+
else
|
25
|
+
[current_account || select_account_interactively]
|
26
|
+
end
|
27
|
+
|
28
|
+
return unless accounts.all?
|
29
|
+
|
30
|
+
all_orders = []
|
31
|
+
accounts.each do |account|
|
32
|
+
next if account.closed?
|
33
|
+
|
34
|
+
info "Fetching orders for account #{account.account_number}..." if options[:all]
|
35
|
+
orders = account.get_live_orders(
|
36
|
+
current_session,
|
37
|
+
status: options[:status],
|
38
|
+
underlying_symbol: options[:symbol]
|
39
|
+
)
|
40
|
+
all_orders.concat(orders.map { |order| [account, order] })
|
41
|
+
end
|
42
|
+
|
43
|
+
if all_orders.empty?
|
44
|
+
info "No orders found"
|
45
|
+
return
|
46
|
+
end
|
47
|
+
|
48
|
+
# Sort by created_at desc (most recent first)
|
49
|
+
all_orders.sort! { |a, b| (b[1].created_at || Time.now) <=> (a[1].created_at || Time.now) }
|
50
|
+
|
51
|
+
if options[:format] == "json"
|
52
|
+
# Output as JSON
|
53
|
+
output = all_orders.map do |account, order|
|
54
|
+
order_hash = order.to_h
|
55
|
+
order_hash[:account_number] = account.account_number if options[:all]
|
56
|
+
order_hash
|
57
|
+
end
|
58
|
+
puts JSON.pretty_generate(output)
|
59
|
+
else
|
60
|
+
# Fetch market data for unique symbols
|
61
|
+
unique_symbols = all_orders.map { |_, order| order.underlying_symbol }.uniq.compact
|
62
|
+
market_data = fetch_market_data(unique_symbols) if unique_symbols.any?
|
63
|
+
|
64
|
+
display_orders(all_orders, market_data, show_account: options[:all])
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
desc "cancel ORDER_ID", "Cancel an order"
|
69
|
+
option :account, type: :string, desc: "Account number (uses default if not specified)"
|
70
|
+
def cancel(order_id)
|
71
|
+
require_authentication!
|
72
|
+
|
73
|
+
account = if options[:account]
|
74
|
+
Tastytrade::Models::Account.get(current_session, options[:account])
|
75
|
+
else
|
76
|
+
current_account || select_account_interactively
|
77
|
+
end
|
78
|
+
|
79
|
+
return unless account
|
80
|
+
|
81
|
+
# First, fetch the order to display it
|
82
|
+
orders = account.get_live_orders(current_session)
|
83
|
+
order = orders.find { |o| o.id == order_id }
|
84
|
+
|
85
|
+
unless order
|
86
|
+
error "Order #{order_id} not found"
|
87
|
+
exit 1
|
88
|
+
end
|
89
|
+
|
90
|
+
unless order.cancellable?
|
91
|
+
error "Order #{order_id} is not cancellable (status: #{order.status})"
|
92
|
+
exit 1
|
93
|
+
end
|
94
|
+
|
95
|
+
# Display order details
|
96
|
+
puts ""
|
97
|
+
puts "Order to cancel:"
|
98
|
+
puts " Order ID: #{order.id}"
|
99
|
+
puts " Symbol: #{order.underlying_symbol}"
|
100
|
+
puts " Type: #{order.order_type}"
|
101
|
+
puts " Status: #{order.status}"
|
102
|
+
puts " Price: #{format_currency(order.price)}" if order.price
|
103
|
+
|
104
|
+
if order.legs.any?
|
105
|
+
leg = order.legs.first
|
106
|
+
puts " Action: #{leg.action} #{leg.quantity} shares"
|
107
|
+
if leg.partially_filled?
|
108
|
+
puts " Filled: #{leg.filled_quantity} of #{leg.quantity} shares"
|
109
|
+
end
|
110
|
+
end
|
111
|
+
|
112
|
+
puts ""
|
113
|
+
unless prompt.yes?("Are you sure you want to cancel this order?")
|
114
|
+
info "Cancellation aborted"
|
115
|
+
return
|
116
|
+
end
|
117
|
+
|
118
|
+
info "Cancelling order #{order_id}..."
|
119
|
+
|
120
|
+
begin
|
121
|
+
account.cancel_order(current_session, order_id)
|
122
|
+
success "Order #{order_id} cancelled successfully"
|
123
|
+
rescue Tastytrade::OrderAlreadyFilledError => e
|
124
|
+
error "Cannot cancel: #{e.message}"
|
125
|
+
exit 1
|
126
|
+
rescue Tastytrade::OrderNotCancellableError => e
|
127
|
+
error "Cannot cancel: #{e.message}"
|
128
|
+
exit 1
|
129
|
+
rescue Tastytrade::Error => e
|
130
|
+
error "Failed to cancel order: #{e.message}"
|
131
|
+
exit 1
|
132
|
+
end
|
133
|
+
end
|
134
|
+
|
135
|
+
desc "history", "List order history (orders older than 24 hours)"
|
136
|
+
option :status, type: :string, desc: "Filter by status (Filled, Cancelled, Expired, etc.)"
|
137
|
+
option :symbol, type: :string, desc: "Filter by underlying symbol"
|
138
|
+
option :from, type: :string, desc: "From date (YYYY-MM-DD)"
|
139
|
+
option :to, type: :string, desc: "To date (YYYY-MM-DD)"
|
140
|
+
option :account, type: :string, desc: "Account number (uses default if not specified)"
|
141
|
+
option :format, type: :string, desc: "Output format (table, json)", default: "table"
|
142
|
+
option :limit, type: :numeric, desc: "Maximum number of orders to retrieve", default: 100
|
143
|
+
def history
|
144
|
+
require_authentication!
|
145
|
+
|
146
|
+
account = if options[:account]
|
147
|
+
Tastytrade::Models::Account.get(current_session, options[:account])
|
148
|
+
else
|
149
|
+
current_account || select_account_interactively
|
150
|
+
end
|
151
|
+
|
152
|
+
return unless account
|
153
|
+
|
154
|
+
# Parse date filters
|
155
|
+
from_time = Time.parse(options[:from]) if options[:from]
|
156
|
+
to_time = Time.parse(options[:to]) if options[:to]
|
157
|
+
# Set to end of day if only date was provided
|
158
|
+
to_time = to_time + (24 * 60 * 60) - 1 if to_time && to_time.hour == 0 && to_time.min == 0
|
159
|
+
|
160
|
+
info "Fetching order history for account #{account.account_number}..."
|
161
|
+
|
162
|
+
orders = account.get_order_history(
|
163
|
+
current_session,
|
164
|
+
status: options[:status],
|
165
|
+
underlying_symbol: options[:symbol],
|
166
|
+
from_time: from_time,
|
167
|
+
to_time: to_time,
|
168
|
+
page_limit: options[:limit]
|
169
|
+
)
|
170
|
+
|
171
|
+
if orders.empty?
|
172
|
+
info "No historical orders found"
|
173
|
+
return
|
174
|
+
end
|
175
|
+
|
176
|
+
if options[:format] == "json"
|
177
|
+
puts JSON.pretty_generate(orders.map(&:to_h))
|
178
|
+
else
|
179
|
+
# Sort by created_at desc (most recent first)
|
180
|
+
orders.sort! { |a, b| (b.created_at || Time.now) <=> (a.created_at || Time.now) }
|
181
|
+
display_orders(orders.map { |order| [account, order] }, nil, show_account: false)
|
182
|
+
end
|
183
|
+
end
|
184
|
+
|
185
|
+
desc "get ORDER_ID", "Get details for a specific order"
|
186
|
+
option :account, type: :string, desc: "Account number (uses default if not specified)"
|
187
|
+
option :format, type: :string, desc: "Output format (table, json)", default: "table"
|
188
|
+
def get(order_id)
|
189
|
+
require_authentication!
|
190
|
+
|
191
|
+
account = if options[:account]
|
192
|
+
Tastytrade::Models::Account.get(current_session, options[:account])
|
193
|
+
else
|
194
|
+
current_account || select_account_interactively
|
195
|
+
end
|
196
|
+
|
197
|
+
return unless account
|
198
|
+
|
199
|
+
info "Fetching order #{order_id}..."
|
200
|
+
|
201
|
+
begin
|
202
|
+
order = account.get_order(current_session, order_id)
|
203
|
+
|
204
|
+
if options[:format] == "json"
|
205
|
+
puts JSON.pretty_generate(order.to_h)
|
206
|
+
else
|
207
|
+
display_order_details(order)
|
208
|
+
end
|
209
|
+
rescue Tastytrade::Error => e
|
210
|
+
error "Failed to fetch order: #{e.message}"
|
211
|
+
exit 1
|
212
|
+
end
|
213
|
+
end
|
214
|
+
|
215
|
+
desc "place", "Place a new order"
|
216
|
+
option :account, type: :string, desc: "Account number (uses default if not specified)"
|
217
|
+
option :symbol, type: :string, required: true, desc: "Symbol to trade (e.g., AAPL, SPY)"
|
218
|
+
option :action, type: :string, required: true, desc: "Order action (buy_to_open, sell_to_close, etc.)"
|
219
|
+
option :quantity, type: :numeric, required: true, desc: "Number of shares"
|
220
|
+
option :type, type: :string, default: "limit", desc: "Order type (market, limit)"
|
221
|
+
option :price, type: :numeric, desc: "Limit price (required for limit orders)"
|
222
|
+
option :time_in_force, type: :string, default: "day", desc: "Order duration (day, gtc)"
|
223
|
+
option :dry_run, type: :boolean, default: false, desc: "Perform validation only without placing the order"
|
224
|
+
option :skip_confirmation, type: :boolean, default: false, desc: "Skip confirmation prompt"
|
225
|
+
def place
|
226
|
+
require_authentication!
|
227
|
+
|
228
|
+
account = if options[:account]
|
229
|
+
Tastytrade::Models::Account.get(current_session, options[:account])
|
230
|
+
else
|
231
|
+
current_account || select_account_interactively
|
232
|
+
end
|
233
|
+
|
234
|
+
return unless account
|
235
|
+
|
236
|
+
# Map user-friendly action names to API constants
|
237
|
+
action_map = {
|
238
|
+
"buy_to_open" => Tastytrade::OrderAction::BUY_TO_OPEN,
|
239
|
+
"bto" => Tastytrade::OrderAction::BUY_TO_OPEN,
|
240
|
+
"sell_to_close" => Tastytrade::OrderAction::SELL_TO_CLOSE,
|
241
|
+
"stc" => Tastytrade::OrderAction::SELL_TO_CLOSE,
|
242
|
+
"sell_to_open" => Tastytrade::OrderAction::SELL_TO_OPEN,
|
243
|
+
"sto" => Tastytrade::OrderAction::SELL_TO_OPEN,
|
244
|
+
"buy_to_close" => Tastytrade::OrderAction::BUY_TO_CLOSE,
|
245
|
+
"btc" => Tastytrade::OrderAction::BUY_TO_CLOSE
|
246
|
+
}
|
247
|
+
|
248
|
+
action = action_map[options[:action].downcase]
|
249
|
+
unless action
|
250
|
+
error "Invalid action. Must be one of: #{action_map.keys.join(", ")}"
|
251
|
+
exit 1
|
252
|
+
end
|
253
|
+
|
254
|
+
# Map order type
|
255
|
+
order_type = case options[:type].downcase
|
256
|
+
when "market", "mkt"
|
257
|
+
Tastytrade::OrderType::MARKET
|
258
|
+
when "limit", "lmt"
|
259
|
+
Tastytrade::OrderType::LIMIT
|
260
|
+
when "stop", "stp"
|
261
|
+
Tastytrade::OrderType::STOP
|
262
|
+
else
|
263
|
+
error "Invalid order type. Must be: market, limit, or stop"
|
264
|
+
exit 1
|
265
|
+
end
|
266
|
+
|
267
|
+
# Validate price for limit orders
|
268
|
+
if order_type == Tastytrade::OrderType::LIMIT && options[:price].nil?
|
269
|
+
error "Price is required for limit orders"
|
270
|
+
exit 1
|
271
|
+
end
|
272
|
+
|
273
|
+
# Map time in force
|
274
|
+
time_in_force = case options[:time_in_force].downcase
|
275
|
+
when "day", "d"
|
276
|
+
Tastytrade::OrderTimeInForce::DAY
|
277
|
+
when "gtc", "g", "good_till_cancelled"
|
278
|
+
Tastytrade::OrderTimeInForce::GTC
|
279
|
+
else
|
280
|
+
error "Invalid time in force. Must be: day or gtc"
|
281
|
+
exit 1
|
282
|
+
end
|
283
|
+
|
284
|
+
# Create the order
|
285
|
+
leg = Tastytrade::OrderLeg.new(
|
286
|
+
action: action,
|
287
|
+
symbol: options[:symbol].upcase,
|
288
|
+
quantity: options[:quantity].to_i
|
289
|
+
)
|
290
|
+
|
291
|
+
order = Tastytrade::Order.new(
|
292
|
+
type: order_type,
|
293
|
+
time_in_force: time_in_force,
|
294
|
+
legs: leg,
|
295
|
+
price: options[:price] ? BigDecimal(options[:price].to_s) : nil
|
296
|
+
)
|
297
|
+
|
298
|
+
# Display order summary
|
299
|
+
puts ""
|
300
|
+
puts "Order Summary:"
|
301
|
+
puts " Account: #{account.account_number}"
|
302
|
+
puts " Symbol: #{options[:symbol].upcase}"
|
303
|
+
puts " Action: #{action}"
|
304
|
+
puts " Quantity: #{options[:quantity]}"
|
305
|
+
puts " Type: #{order_type}"
|
306
|
+
puts " Time in Force: #{time_in_force}"
|
307
|
+
puts " Price: #{options[:price] ? format_currency(options[:price]) : "Market"}"
|
308
|
+
puts ""
|
309
|
+
|
310
|
+
# Perform dry-run validation first
|
311
|
+
info "Validating order..."
|
312
|
+
begin
|
313
|
+
validator = Tastytrade::OrderValidator.new(current_session, account, order)
|
314
|
+
|
315
|
+
# Always do a dry-run to get buying power effect
|
316
|
+
dry_run_response = validator.dry_run_validate!
|
317
|
+
|
318
|
+
if dry_run_response && dry_run_response.buying_power_effect
|
319
|
+
effect = dry_run_response.buying_power_effect
|
320
|
+
puts "Buying Power Impact:"
|
321
|
+
puts " Current BP: #{format_currency(effect.current_buying_power)}"
|
322
|
+
puts " Order Impact: #{format_currency(effect.buying_power_change_amount)}"
|
323
|
+
puts " New BP: #{format_currency(effect.new_buying_power)}"
|
324
|
+
puts " BP Usage: #{effect.buying_power_usage_percentage}%"
|
325
|
+
puts ""
|
326
|
+
end
|
327
|
+
|
328
|
+
# Display any warnings
|
329
|
+
if validator.warnings.any?
|
330
|
+
puts "Warnings:"
|
331
|
+
validator.warnings.each { |w| warning " - #{w}" }
|
332
|
+
puts ""
|
333
|
+
end
|
334
|
+
|
335
|
+
# Check for validation errors
|
336
|
+
if validator.errors.any?
|
337
|
+
error "Validation failed:"
|
338
|
+
validator.errors.each { |e| error " - #{e}" }
|
339
|
+
exit 1
|
340
|
+
end
|
341
|
+
|
342
|
+
rescue Tastytrade::OrderValidationError => e
|
343
|
+
error "Order validation failed:"
|
344
|
+
e.errors.each { |err| error " - #{err}" }
|
345
|
+
exit 1
|
346
|
+
rescue StandardError => e
|
347
|
+
error "Validation error: #{e.message}"
|
348
|
+
exit 1
|
349
|
+
end
|
350
|
+
|
351
|
+
# If dry-run only, stop here
|
352
|
+
if options[:dry_run]
|
353
|
+
success "Dry-run validation passed! Order is valid but was not placed."
|
354
|
+
return
|
355
|
+
end
|
356
|
+
|
357
|
+
# Confirmation prompt
|
358
|
+
unless options[:skip_confirmation]
|
359
|
+
prompt = TTY::Prompt.new
|
360
|
+
unless prompt.yes?("Place this order?")
|
361
|
+
info "Order cancelled by user"
|
362
|
+
return
|
363
|
+
end
|
364
|
+
end
|
365
|
+
|
366
|
+
# Place the order
|
367
|
+
info "Placing order..."
|
368
|
+
begin
|
369
|
+
response = account.place_order(current_session, order, skip_validation: true)
|
370
|
+
|
371
|
+
success "Order placed successfully!"
|
372
|
+
puts ""
|
373
|
+
puts "Order Details:"
|
374
|
+
puts " Order ID: #{response.order_id}"
|
375
|
+
puts " Status: #{response.status}"
|
376
|
+
|
377
|
+
if response.buying_power_effect
|
378
|
+
puts " Buying Power Effect: #{format_currency(response.buying_power_effect)}"
|
379
|
+
end
|
380
|
+
|
381
|
+
rescue Tastytrade::OrderValidationError => e
|
382
|
+
error "Order validation failed:"
|
383
|
+
e.errors.each { |err| error " - #{err}" }
|
384
|
+
exit 1
|
385
|
+
rescue Tastytrade::InsufficientFundsError => e
|
386
|
+
error "Insufficient funds: #{e.message}"
|
387
|
+
exit 1
|
388
|
+
rescue Tastytrade::MarketClosedError => e
|
389
|
+
error "Market closed: #{e.message}"
|
390
|
+
exit 1
|
391
|
+
rescue Tastytrade::Error => e
|
392
|
+
error "Failed to place order: #{e.message}"
|
393
|
+
exit 1
|
394
|
+
end
|
395
|
+
end
|
396
|
+
|
397
|
+
desc "replace ORDER_ID", "Replace/modify an existing order"
|
398
|
+
option :account, type: :string, desc: "Account number (uses default if not specified)"
|
399
|
+
option :price, type: :numeric, desc: "New price for the order"
|
400
|
+
option :quantity, type: :numeric, desc: "New quantity (cannot exceed remaining)"
|
401
|
+
def replace(order_id)
|
402
|
+
require_authentication!
|
403
|
+
|
404
|
+
account = if options[:account]
|
405
|
+
Tastytrade::Models::Account.get(current_session, options[:account])
|
406
|
+
else
|
407
|
+
current_account || select_account_interactively
|
408
|
+
end
|
409
|
+
|
410
|
+
return unless account
|
411
|
+
|
412
|
+
# Fetch the order to modify
|
413
|
+
orders = account.get_live_orders(current_session)
|
414
|
+
order = orders.find { |o| o.id == order_id }
|
415
|
+
|
416
|
+
unless order
|
417
|
+
error "Order #{order_id} not found"
|
418
|
+
exit 1
|
419
|
+
end
|
420
|
+
|
421
|
+
unless order.editable?
|
422
|
+
error "Order #{order_id} is not editable (status: #{order.status})"
|
423
|
+
exit 1
|
424
|
+
end
|
425
|
+
|
426
|
+
# Display current order details
|
427
|
+
puts ""
|
428
|
+
puts "Current order:"
|
429
|
+
puts " Order ID: #{order.id}"
|
430
|
+
puts " Symbol: #{order.underlying_symbol}"
|
431
|
+
puts " Type: #{order.order_type}"
|
432
|
+
puts " Status: #{order.status}"
|
433
|
+
puts " Current Price: #{format_currency(order.price)}" if order.price
|
434
|
+
|
435
|
+
leg = order.legs.first if order.legs.any?
|
436
|
+
if leg
|
437
|
+
puts " Action: #{leg.action} #{leg.quantity} shares"
|
438
|
+
puts " Remaining: #{leg.remaining_quantity} shares"
|
439
|
+
if leg.partially_filled?
|
440
|
+
puts " Filled: #{leg.filled_quantity} shares"
|
441
|
+
end
|
442
|
+
end
|
443
|
+
|
444
|
+
# Interactive prompts for new values if not provided
|
445
|
+
new_price = if options[:price]
|
446
|
+
BigDecimal(options[:price].to_s)
|
447
|
+
elsif order.order_type == "Limit"
|
448
|
+
puts ""
|
449
|
+
current_price_str = order.price ? order.price.to_s("F") : "N/A"
|
450
|
+
price_input = prompt.ask("New price (current: #{current_price_str}):",
|
451
|
+
default: current_price_str,
|
452
|
+
convert: :float)
|
453
|
+
BigDecimal(price_input.to_s) if price_input
|
454
|
+
else
|
455
|
+
order.price
|
456
|
+
end
|
457
|
+
|
458
|
+
new_quantity = if options[:quantity]
|
459
|
+
options[:quantity].to_i
|
460
|
+
elsif leg
|
461
|
+
puts ""
|
462
|
+
max_qty = leg.remaining_quantity
|
463
|
+
quantity_input = prompt.ask("New quantity (current: #{max_qty}, max: #{max_qty}):",
|
464
|
+
default: max_qty,
|
465
|
+
convert: :int) do |q|
|
466
|
+
q.in("1-#{leg.remaining_quantity}")
|
467
|
+
q.messages[:range?] = "Quantity must be between 1 and #{leg.remaining_quantity}"
|
468
|
+
end
|
469
|
+
quantity_input
|
470
|
+
else
|
471
|
+
nil
|
472
|
+
end
|
473
|
+
|
474
|
+
# Show summary of changes
|
475
|
+
puts ""
|
476
|
+
puts "Order modifications:"
|
477
|
+
if new_price && order.price != new_price
|
478
|
+
puts " Price: #{format_currency(order.price)} → #{format_currency(new_price)}"
|
479
|
+
end
|
480
|
+
if new_quantity && leg && leg.remaining_quantity != new_quantity
|
481
|
+
puts " Quantity: #{leg.remaining_quantity} → #{new_quantity}"
|
482
|
+
end
|
483
|
+
|
484
|
+
puts ""
|
485
|
+
unless prompt.yes?("Proceed with these changes?")
|
486
|
+
info "Replacement cancelled"
|
487
|
+
return
|
488
|
+
end
|
489
|
+
|
490
|
+
# Create new order with modifications
|
491
|
+
begin
|
492
|
+
# Recreate the order with new parameters
|
493
|
+
action = if leg
|
494
|
+
case leg.action.downcase
|
495
|
+
when "buy", "buy to open"
|
496
|
+
Tastytrade::OrderAction::BUY_TO_OPEN
|
497
|
+
when "sell", "sell to close"
|
498
|
+
Tastytrade::OrderAction::SELL_TO_CLOSE
|
499
|
+
else
|
500
|
+
leg.action
|
501
|
+
end
|
502
|
+
end
|
503
|
+
|
504
|
+
new_leg = Tastytrade::OrderLeg.new(
|
505
|
+
action: action,
|
506
|
+
symbol: leg.symbol,
|
507
|
+
quantity: new_quantity || leg.remaining_quantity
|
508
|
+
)
|
509
|
+
|
510
|
+
order_type = case order.order_type.downcase
|
511
|
+
when "market"
|
512
|
+
Tastytrade::OrderType::MARKET
|
513
|
+
when "limit"
|
514
|
+
Tastytrade::OrderType::LIMIT
|
515
|
+
else
|
516
|
+
order.order_type
|
517
|
+
end
|
518
|
+
|
519
|
+
new_order = Tastytrade::Order.new(
|
520
|
+
type: order_type,
|
521
|
+
legs: new_leg,
|
522
|
+
price: new_price
|
523
|
+
)
|
524
|
+
|
525
|
+
info "Replacing order #{order_id}..."
|
526
|
+
response = account.replace_order(current_session, order_id, new_order)
|
527
|
+
|
528
|
+
success "Order replaced successfully!"
|
529
|
+
puts ""
|
530
|
+
puts "New Order Details:"
|
531
|
+
puts " Order ID: #{response.order_id}"
|
532
|
+
puts " Status: #{response.status}"
|
533
|
+
puts " Price: #{format_currency(new_price)}" if new_price
|
534
|
+
|
535
|
+
rescue Tastytrade::OrderNotEditableError => e
|
536
|
+
error "Cannot replace: #{e.message}"
|
537
|
+
exit 1
|
538
|
+
rescue Tastytrade::InsufficientQuantityError => e
|
539
|
+
error "Cannot replace: #{e.message}"
|
540
|
+
exit 1
|
541
|
+
rescue Tastytrade::Error => e
|
542
|
+
error "Failed to replace order: #{e.message}"
|
543
|
+
exit 1
|
544
|
+
end
|
545
|
+
end
|
546
|
+
|
547
|
+
private
|
548
|
+
|
549
|
+
def display_order_details(order)
|
550
|
+
puts ""
|
551
|
+
puts "Order Details:"
|
552
|
+
puts " Order ID: #{order.id}"
|
553
|
+
puts " Account: #{order.account_number}" if order.account_number
|
554
|
+
puts " Symbol: #{order.underlying_symbol}"
|
555
|
+
puts " Type: #{order.order_type}"
|
556
|
+
puts " Status: #{colorize_status(order.status)}"
|
557
|
+
puts " Time in Force: #{order.time_in_force}"
|
558
|
+
puts " Price: #{format_currency(order.price)}" if order.price
|
559
|
+
puts " Stop Price: #{format_currency(order.stop_trigger)}" if order.stop_trigger
|
560
|
+
puts ""
|
561
|
+
|
562
|
+
if order.legs.any?
|
563
|
+
puts "Legs:"
|
564
|
+
order.legs.each_with_index do |leg, i|
|
565
|
+
puts " Leg #{i + 1}:"
|
566
|
+
puts " Symbol: #{leg.symbol}"
|
567
|
+
puts " Action: #{leg.action}"
|
568
|
+
puts " Quantity: #{leg.quantity}"
|
569
|
+
puts " Remaining: #{leg.remaining_quantity}"
|
570
|
+
puts " Filled: #{leg.filled_quantity}"
|
571
|
+
|
572
|
+
if leg.fills.any?
|
573
|
+
puts " Fills:"
|
574
|
+
leg.fills.each do |fill|
|
575
|
+
puts " #{fill.quantity} @ #{format_currency(fill.fill_price)} at #{format_time(fill.filled_at)}"
|
576
|
+
end
|
577
|
+
end
|
578
|
+
end
|
579
|
+
end
|
580
|
+
|
581
|
+
puts ""
|
582
|
+
puts "Timestamps:"
|
583
|
+
puts " Created: #{format_timestamp(order.created_at)}" if order.created_at
|
584
|
+
puts " Updated: #{format_timestamp(order.updated_at)}" if order.updated_at
|
585
|
+
puts " Filled: #{format_timestamp(order.filled_at)}" if order.filled_at
|
586
|
+
puts " Cancelled: #{format_timestamp(order.cancelled_at)}" if order.cancelled_at
|
587
|
+
puts " Expired: #{format_timestamp(order.expired_at)}" if order.expired_at
|
588
|
+
|
589
|
+
puts ""
|
590
|
+
puts "Status Flags:"
|
591
|
+
puts " Cancellable: #{order.cancellable? ? "Yes" : "No"}"
|
592
|
+
puts " Editable: #{order.editable? ? "Yes" : "No"}"
|
593
|
+
puts " Terminal: #{order.terminal? ? "Yes" : "No"}"
|
594
|
+
puts " Working: #{order.working? ? "Yes" : "No"}"
|
595
|
+
end
|
596
|
+
|
597
|
+
def display_orders(orders_with_accounts, market_data, show_account: false)
|
598
|
+
headers = ["Order ID", "Symbol", "Action", "Qty", "Filled", "Type", "TIF", "Price", "Status", "Time"]
|
599
|
+
headers.unshift("Account") if show_account
|
600
|
+
|
601
|
+
rows = orders_with_accounts.map do |account, order|
|
602
|
+
leg = order.legs.first if order.legs.any?
|
603
|
+
|
604
|
+
row = [
|
605
|
+
order.id,
|
606
|
+
order.underlying_symbol || "N/A",
|
607
|
+
leg ? leg.action : "N/A",
|
608
|
+
leg ? leg.quantity.to_s : "N/A",
|
609
|
+
leg ? "#{leg.filled_quantity}/#{leg.quantity}" : "N/A",
|
610
|
+
order.order_type || "N/A",
|
611
|
+
order.time_in_force || "N/A",
|
612
|
+
order.price ? format_currency(order.price) : "N/A",
|
613
|
+
colorize_status(order.status),
|
614
|
+
format_time(order.created_at)
|
615
|
+
]
|
616
|
+
|
617
|
+
row.unshift(account.account_number) if show_account
|
618
|
+
row
|
619
|
+
end
|
620
|
+
|
621
|
+
table = TTY::Table.new(headers, rows)
|
622
|
+
puts table.render(:unicode, padding: [0, 1], alignments: [:left])
|
623
|
+
|
624
|
+
# Show market data if available
|
625
|
+
if market_data && !market_data.empty?
|
626
|
+
puts ""
|
627
|
+
puts "Current Market Prices:"
|
628
|
+
market_data.each do |symbol, data|
|
629
|
+
if data
|
630
|
+
bid = format_currency(data[:bid])
|
631
|
+
ask = format_currency(data[:ask])
|
632
|
+
last = format_currency(data[:last])
|
633
|
+
puts " #{symbol}: Bid: #{bid} | Ask: #{ask} | Last: #{last}"
|
634
|
+
end
|
635
|
+
end
|
636
|
+
end
|
637
|
+
end
|
638
|
+
|
639
|
+
def fetch_market_data(symbols)
|
640
|
+
return {} if symbols.empty?
|
641
|
+
|
642
|
+
market_data = {}
|
643
|
+
symbols.each do |symbol|
|
644
|
+
begin
|
645
|
+
equity = Tastytrade::Instruments::Equity.get_equity(current_session, symbol)
|
646
|
+
if equity
|
647
|
+
# Fetch quote data for the equity
|
648
|
+
# This is a placeholder - actual implementation would fetch real-time quotes
|
649
|
+
market_data[symbol] = {
|
650
|
+
bid: nil, # Would fetch from market data API
|
651
|
+
ask: nil, # Would fetch from market data API
|
652
|
+
last: nil # Would fetch from market data API
|
653
|
+
}
|
654
|
+
end
|
655
|
+
rescue Tastytrade::Error
|
656
|
+
# Silently skip if we can't fetch market data
|
657
|
+
end
|
658
|
+
end
|
659
|
+
market_data
|
660
|
+
end
|
661
|
+
|
662
|
+
def colorize_status(status)
|
663
|
+
case status
|
664
|
+
when "Live"
|
665
|
+
pastel.green(status)
|
666
|
+
when "Filled"
|
667
|
+
pastel.blue(status)
|
668
|
+
when "Cancelled", "Rejected", "Expired"
|
669
|
+
pastel.red(status)
|
670
|
+
when "Received", "Routed"
|
671
|
+
pastel.yellow(status)
|
672
|
+
else
|
673
|
+
status
|
674
|
+
end
|
675
|
+
end
|
676
|
+
|
677
|
+
def format_time(time)
|
678
|
+
return "N/A" unless time
|
679
|
+
time.strftime("%m/%d %H:%M")
|
680
|
+
end
|
681
|
+
|
682
|
+
def format_timestamp(time)
|
683
|
+
return "N/A" unless time
|
684
|
+
time.strftime("%Y-%m-%d %H:%M:%S")
|
685
|
+
end
|
686
|
+
|
687
|
+
def format_currency(amount)
|
688
|
+
return "N/A" unless amount
|
689
|
+
"$#{amount.to_s("F")}"
|
690
|
+
end
|
691
|
+
|
692
|
+
def current_session
|
693
|
+
@current_session ||= begin
|
694
|
+
# Try to get session from parent CLI instance
|
695
|
+
if defined?(parent_options) && parent_options
|
696
|
+
parent_options[:current_session]
|
697
|
+
else
|
698
|
+
SessionManager.load_session
|
699
|
+
end
|
700
|
+
end
|
701
|
+
end
|
702
|
+
|
703
|
+
def current_account
|
704
|
+
@current_account ||= begin
|
705
|
+
if current_session
|
706
|
+
accounts = Tastytrade::Models::Account.get_all(current_session)
|
707
|
+
accounts.reject(&:closed?).first
|
708
|
+
end
|
709
|
+
end
|
710
|
+
end
|
711
|
+
|
712
|
+
def select_account_interactively
|
713
|
+
accounts = Tastytrade::Models::Account.get_all(current_session)
|
714
|
+
active_accounts = accounts.reject(&:closed?)
|
715
|
+
|
716
|
+
if active_accounts.empty?
|
717
|
+
error "No active accounts found"
|
718
|
+
nil
|
719
|
+
elsif active_accounts.size == 1
|
720
|
+
active_accounts.first
|
721
|
+
else
|
722
|
+
choices = active_accounts.map do |acc|
|
723
|
+
{
|
724
|
+
name: "#{acc.account_number} - #{acc.nickname || acc.account_type_name}",
|
725
|
+
value: acc
|
726
|
+
}
|
727
|
+
end
|
728
|
+
prompt.select("Select an account:", choices)
|
729
|
+
end
|
730
|
+
end
|
731
|
+
|
732
|
+
def require_authentication!
|
733
|
+
unless current_session
|
734
|
+
error "You must be logged in to use this command"
|
735
|
+
error "Run: tastytrade login"
|
736
|
+
exit 1
|
737
|
+
end
|
738
|
+
end
|
739
|
+
|
740
|
+
def prompt
|
741
|
+
@prompt ||= TTY::Prompt.new
|
742
|
+
end
|
743
|
+
|
744
|
+
def pastel
|
745
|
+
@pastel ||= Pastel.new
|
746
|
+
end
|
747
|
+
end
|
748
|
+
end
|
749
|
+
end
|