tastytrade 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/commands/plan.md +13 -0
  3. data/.claude/commands/release-pr.md +12 -0
  4. data/CHANGELOG.md +170 -0
  5. data/README.md +424 -3
  6. data/ROADMAP.md +17 -17
  7. data/lib/tastytrade/cli/history_formatter.rb +304 -0
  8. data/lib/tastytrade/cli/orders.rb +749 -0
  9. data/lib/tastytrade/cli/positions_formatter.rb +114 -0
  10. data/lib/tastytrade/cli.rb +701 -12
  11. data/lib/tastytrade/cli_helpers.rb +111 -14
  12. data/lib/tastytrade/client.rb +7 -0
  13. data/lib/tastytrade/file_store.rb +83 -0
  14. data/lib/tastytrade/instruments/equity.rb +42 -0
  15. data/lib/tastytrade/models/account.rb +160 -2
  16. data/lib/tastytrade/models/account_balance.rb +46 -0
  17. data/lib/tastytrade/models/buying_power_effect.rb +61 -0
  18. data/lib/tastytrade/models/live_order.rb +272 -0
  19. data/lib/tastytrade/models/order_response.rb +106 -0
  20. data/lib/tastytrade/models/order_status.rb +84 -0
  21. data/lib/tastytrade/models/trading_status.rb +200 -0
  22. data/lib/tastytrade/models/transaction.rb +151 -0
  23. data/lib/tastytrade/models.rb +6 -0
  24. data/lib/tastytrade/order.rb +191 -0
  25. data/lib/tastytrade/order_validator.rb +355 -0
  26. data/lib/tastytrade/session.rb +26 -1
  27. data/lib/tastytrade/session_manager.rb +43 -14
  28. data/lib/tastytrade/version.rb +1 -1
  29. data/lib/tastytrade.rb +43 -0
  30. data/spec/exe/tastytrade_spec.rb +1 -1
  31. data/spec/spec_helper.rb +72 -0
  32. data/spec/tastytrade/cli/positions_spec.rb +267 -0
  33. data/spec/tastytrade/cli_auth_spec.rb +5 -0
  34. data/spec/tastytrade/cli_env_login_spec.rb +199 -0
  35. data/spec/tastytrade/cli_helpers_spec.rb +3 -26
  36. data/spec/tastytrade/cli_orders_spec.rb +168 -0
  37. data/spec/tastytrade/cli_status_spec.rb +153 -164
  38. data/spec/tastytrade/file_store_spec.rb +126 -0
  39. data/spec/tastytrade/models/account_balance_spec.rb +103 -0
  40. data/spec/tastytrade/models/account_order_history_spec.rb +229 -0
  41. data/spec/tastytrade/models/account_order_management_spec.rb +271 -0
  42. data/spec/tastytrade/models/account_place_order_spec.rb +125 -0
  43. data/spec/tastytrade/models/account_spec.rb +86 -15
  44. data/spec/tastytrade/models/buying_power_effect_spec.rb +250 -0
  45. data/spec/tastytrade/models/live_order_json_spec.rb +144 -0
  46. data/spec/tastytrade/models/live_order_spec.rb +295 -0
  47. data/spec/tastytrade/models/order_response_spec.rb +96 -0
  48. data/spec/tastytrade/models/order_status_spec.rb +113 -0
  49. data/spec/tastytrade/models/trading_status_spec.rb +260 -0
  50. data/spec/tastytrade/models/transaction_spec.rb +236 -0
  51. data/spec/tastytrade/order_edge_cases_spec.rb +163 -0
  52. data/spec/tastytrade/order_spec.rb +201 -0
  53. data/spec/tastytrade/order_validator_spec.rb +347 -0
  54. data/spec/tastytrade/session_env_spec.rb +169 -0
  55. data/spec/tastytrade/session_manager_spec.rb +43 -33
  56. data/vcr_implementation_plan.md +403 -0
  57. data/vcr_implementation_research.md +330 -0
  58. metadata +50 -18
  59. data/lib/tastytrade/keyring_store.rb +0 -72
  60. 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