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.
Files changed (57) 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 +180 -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/tastytrade/cli/positions_spec.rb +267 -0
  32. data/spec/tastytrade/cli_auth_spec.rb +5 -0
  33. data/spec/tastytrade/cli_env_login_spec.rb +199 -0
  34. data/spec/tastytrade/cli_helpers_spec.rb +3 -26
  35. data/spec/tastytrade/cli_orders_spec.rb +168 -0
  36. data/spec/tastytrade/cli_status_spec.rb +153 -164
  37. data/spec/tastytrade/file_store_spec.rb +126 -0
  38. data/spec/tastytrade/models/account_balance_spec.rb +103 -0
  39. data/spec/tastytrade/models/account_order_history_spec.rb +229 -0
  40. data/spec/tastytrade/models/account_order_management_spec.rb +271 -0
  41. data/spec/tastytrade/models/account_place_order_spec.rb +125 -0
  42. data/spec/tastytrade/models/account_spec.rb +86 -15
  43. data/spec/tastytrade/models/buying_power_effect_spec.rb +250 -0
  44. data/spec/tastytrade/models/live_order_json_spec.rb +144 -0
  45. data/spec/tastytrade/models/live_order_spec.rb +295 -0
  46. data/spec/tastytrade/models/order_response_spec.rb +96 -0
  47. data/spec/tastytrade/models/order_status_spec.rb +113 -0
  48. data/spec/tastytrade/models/trading_status_spec.rb +260 -0
  49. data/spec/tastytrade/models/transaction_spec.rb +236 -0
  50. data/spec/tastytrade/order_edge_cases_spec.rb +163 -0
  51. data/spec/tastytrade/order_spec.rb +201 -0
  52. data/spec/tastytrade/order_validator_spec.rb +347 -0
  53. data/spec/tastytrade/session_env_spec.rb +169 -0
  54. data/spec/tastytrade/session_manager_spec.rb +43 -33
  55. metadata +34 -18
  56. data/lib/tastytrade/keyring_store.rb +0 -72
  57. data/spec/tastytrade/keyring_store_spec.rb +0 -168
@@ -6,6 +6,7 @@ require "bigdecimal"
6
6
  require_relative "cli_helpers"
7
7
  require_relative "cli_config"
8
8
  require_relative "session_manager"
9
+ require_relative "cli/orders"
9
10
 
10
11
  module Tastytrade
11
12
  # Main CLI class for Tastytrade gem
@@ -25,20 +26,65 @@ module Tastytrade
25
26
  end
26
27
 
27
28
  desc "login", "Login to Tastytrade"
29
+ long_desc <<-LONGDESC
30
+ Login to your Tastytrade account.#{" "}
31
+
32
+ Credentials can be provided via:
33
+ - Environment variables: TASTYTRADE_USERNAME, TASTYTRADE_PASSWORD (or TT_USERNAME, TT_PASSWORD)
34
+ - Command line option: --username (password will be prompted)
35
+ - Interactive prompts (default)
36
+
37
+ Optional environment variables:
38
+ - TASTYTRADE_ENVIRONMENT=sandbox (or TT_ENVIRONMENT) for test environment
39
+ - TASTYTRADE_REMEMBER=true (or TT_REMEMBER) to save session for auto-refresh
40
+
41
+ Examples:
42
+ $ tastytrade login
43
+ $ tastytrade login --username user@example.com
44
+ $ tastytrade login --no-interactive # Skip interactive mode
45
+ $ TASTYTRADE_USERNAME=user@example.com TASTYTRADE_PASSWORD=pass tastytrade login --no-interactive
46
+ LONGDESC
28
47
  option :username, aliases: "-u", desc: "Username"
29
48
  option :remember, aliases: "-r", type: :boolean, default: false, desc: "Remember credentials"
49
+ option :no_interactive, type: :boolean, default: false, desc: "Skip interactive mode after login"
30
50
  def login
31
- credentials = login_credentials
32
- environment = options[:test] ? "sandbox" : "production"
51
+ # Try environment variables first
52
+ if (session = Session.from_environment)
53
+ environment = session.instance_variable_get(:@is_test) ? "sandbox" : "production"
54
+ info "Using credentials from environment variables..."
55
+ info "Logging in to #{environment} environment..."
33
56
 
57
+ begin
58
+ session.login
59
+ success "Successfully logged in as #{session.user.email}"
60
+
61
+ save_user_session(session, {
62
+ username: session.user.email,
63
+ remember: session.remember_token ? true : false
64
+ }, environment)
65
+
66
+ @current_session = session
67
+ interactive_mode unless options[:no_interactive]
68
+ return
69
+ rescue Tastytrade::Error => e
70
+ error "Environment variable login failed: #{e.message}"
71
+ info "Falling back to interactive login..."
72
+ end
73
+ end
74
+
75
+ # Fall back to interactive login
76
+ environment = options[:test] ? "sandbox" : "production"
77
+ credentials = login_credentials
34
78
  info "Logging in to #{environment} environment..."
35
79
  session = authenticate_user(credentials)
36
80
 
37
- save_user_session(session, credentials, environment)
81
+ # Update credentials with actual email from session
82
+ credentials_with_email = credentials.merge(username: session.user.email)
83
+ save_user_session(session, credentials_with_email, environment)
38
84
 
39
- # Enter interactive mode after successful login
85
+ # Enter interactive mode after successful login (unless --no-interactive)
40
86
  @current_session = session
41
- interactive_mode
87
+ interactive_mode unless options[:no_interactive]
42
88
  rescue Tastytrade::InvalidCredentialsError => e
43
89
  error "Invalid credentials: #{e.message}"
44
90
  exit 1
@@ -99,8 +145,13 @@ module Tastytrade
99
145
  environment: environment
100
146
  )
101
147
 
148
+ # Save the configuration first
149
+ config.set("current_username", credentials[:username])
150
+ config.set("environment", environment)
151
+ config.set("last_login", Time.now.to_s)
152
+
102
153
  if manager.save_session(session, password: credentials[:password], remember: credentials[:remember])
103
- info "Session saved securely" if credentials[:remember]
154
+ info "Session saved securely"
104
155
  else
105
156
  warning "Failed to save session credentials"
106
157
  end
@@ -323,6 +374,240 @@ module Tastytrade
323
374
  exit 1
324
375
  end
325
376
 
377
+ desc "positions", "Display account positions"
378
+ option :account, type: :string, desc: "Account number (uses default if not specified)"
379
+ option :symbol, type: :string, desc: "Filter by symbol"
380
+ option :underlying_symbol, type: :string, desc: "Filter by underlying symbol"
381
+ option :include_closed, type: :boolean, default: false, desc: "Include closed positions"
382
+ # Display account positions with optional filtering
383
+ #
384
+ # @example Display all open positions
385
+ # tastytrade positions
386
+ #
387
+ # @example Display positions for a specific symbol
388
+ # tastytrade positions --symbol AAPL
389
+ #
390
+ # @example Display option positions for an underlying symbol
391
+ # tastytrade positions --underlying-symbol SPY
392
+ #
393
+ # @example Include closed positions
394
+ # tastytrade positions --include-closed
395
+ #
396
+ # @example Display positions for a specific account
397
+ # tastytrade positions --account 5WX12345
398
+ #
399
+ def positions
400
+ require_authentication!
401
+
402
+ # Get the account to use
403
+ account = if options[:account]
404
+ Tastytrade::Models::Account.get(current_session, options[:account])
405
+ else
406
+ current_account || select_account_interactively
407
+ end
408
+
409
+ return unless account
410
+
411
+ info "Fetching positions for account #{account.account_number}..."
412
+
413
+ # Fetch positions with filters
414
+ positions = account.get_positions(
415
+ current_session,
416
+ symbol: options[:symbol],
417
+ underlying_symbol: options[:underlying_symbol],
418
+ include_closed: options[:include_closed]
419
+ )
420
+
421
+ if positions.empty?
422
+ warning "No positions found"
423
+ return
424
+ end
425
+
426
+ # Display positions using formatter
427
+ formatter = Tastytrade::PositionsFormatter.new(pastel: pastel)
428
+ formatter.format_table(positions)
429
+ rescue Tastytrade::Error => e
430
+ error "Failed to fetch positions: #{e.message}"
431
+ exit 1
432
+ rescue StandardError => e
433
+ error "Unexpected error: #{e.message}"
434
+ exit 1
435
+ end
436
+
437
+ desc "trading_status", "Display account trading status and permissions"
438
+ option :account, type: :string, desc: "Account number (uses default if not specified)"
439
+ def trading_status
440
+ require_authentication!
441
+
442
+ account = if options[:account]
443
+ Tastytrade::Models::Account.get(current_session, options[:account])
444
+ else
445
+ current_account || select_account_interactively
446
+ end
447
+
448
+ return unless account
449
+
450
+ trading_status = account.get_trading_status(current_session)
451
+ display_trading_status(trading_status)
452
+ rescue Tastytrade::Error => e
453
+ error "Failed to fetch trading status: #{e.message}"
454
+ exit 1
455
+ rescue StandardError => e
456
+ error "Unexpected error: #{e.message}"
457
+ exit 1
458
+ end
459
+
460
+ desc "history", "Display transaction history"
461
+ option :account, type: :string, desc: "Account number (uses default if not specified)"
462
+ option :start_date, type: :string, desc: "Start date (YYYY-MM-DD)"
463
+ option :end_date, type: :string, desc: "End date (YYYY-MM-DD)"
464
+ option :symbol, type: :string, desc: "Filter by symbol"
465
+ option :type, type: :string, desc: "Filter by transaction type"
466
+ option :group_by, type: :string, desc: "Group transactions by: symbol, type, or date"
467
+ option :limit, type: :numeric, desc: "Limit number of transactions"
468
+ # Display transaction history with optional filtering and grouping
469
+ #
470
+ # @example Display all transactions
471
+ # tastytrade history
472
+ #
473
+ # @example Display transactions for a specific symbol
474
+ # tastytrade history --symbol AAPL
475
+ #
476
+ # @example Display transactions for a date range
477
+ # tastytrade history --start-date 2024-01-01 --end-date 2024-12-31
478
+ #
479
+ # @example Group transactions by symbol
480
+ # tastytrade history --group-by symbol
481
+ #
482
+ # @example Filter by transaction type
483
+ # tastytrade history --type Trade
484
+ #
485
+ def history
486
+ require_authentication!
487
+
488
+ # Get the account to use
489
+ account = if options[:account]
490
+ Tastytrade::Models::Account.get(current_session, options[:account])
491
+ else
492
+ current_account || select_account_interactively
493
+ end
494
+
495
+ return unless account
496
+
497
+ info "Fetching transaction history for account #{account.account_number}..."
498
+
499
+ # Build filter options
500
+ filter_options = {}
501
+ filter_options[:start_date] = Date.parse(options[:start_date]) if options[:start_date]
502
+ filter_options[:end_date] = Date.parse(options[:end_date]) if options[:end_date]
503
+ filter_options[:symbol] = options[:symbol].upcase if options[:symbol]
504
+ filter_options[:transaction_types] = [options[:type]] if options[:type]
505
+ filter_options[:per_page] = options[:limit] if options[:limit]
506
+
507
+ # Fetch transactions
508
+ transactions = account.get_transactions(current_session, **filter_options)
509
+
510
+ if transactions.empty?
511
+ warning "No transactions found"
512
+ return
513
+ end
514
+
515
+ # Display transactions using formatter
516
+ formatter = Tastytrade::HistoryFormatter.new(pastel: pastel)
517
+ group_by = options[:group_by]&.to_sym
518
+ formatter.format_table(transactions, group_by: group_by)
519
+ rescue Date::Error => e
520
+ error "Invalid date format: #{e.message}. Use YYYY-MM-DD format."
521
+ exit 1
522
+ rescue Tastytrade::Error => e
523
+ error "Failed to fetch transaction history: #{e.message}"
524
+ exit 1
525
+ rescue StandardError => e
526
+ error "Unexpected error: #{e.message}"
527
+ exit 1
528
+ end
529
+
530
+ desc "buying_power", "Display buying power status"
531
+ option :account, type: :string, desc: "Account number (uses default if not specified)"
532
+ # Display buying power status and usage
533
+ #
534
+ # @example Display buying power status
535
+ # tastytrade buying_power
536
+ #
537
+ # @example Display buying power for specific account
538
+ # tastytrade buying_power --account 5WX12345
539
+ #
540
+ def buying_power
541
+ require_authentication!
542
+
543
+ # Get the account to use
544
+ account = if options[:account]
545
+ Tastytrade::Models::Account.get(current_session, options[:account])
546
+ else
547
+ current_account || select_account_interactively
548
+ end
549
+
550
+ return unless account
551
+
552
+ info "Fetching buying power status for account #{account.account_number}..."
553
+
554
+ balance = account.get_balances(current_session)
555
+
556
+ # Create buying power status table
557
+ headers = ["Buying Power Type", "Available", "Usage %", "Status"]
558
+ rows = [
559
+ [
560
+ "Equity Buying Power",
561
+ format_currency(balance.equity_buying_power),
562
+ "#{balance.buying_power_usage_percentage.to_s("F")}%",
563
+ format_bp_status(balance.buying_power_usage_percentage)
564
+ ],
565
+ [
566
+ "Derivative Buying Power",
567
+ format_currency(balance.derivative_buying_power),
568
+ "#{balance.derivative_buying_power_usage_percentage.to_s("F")}%",
569
+ format_bp_status(balance.derivative_buying_power_usage_percentage)
570
+ ],
571
+ [
572
+ "Day Trading Buying Power",
573
+ format_currency(balance.day_trading_buying_power),
574
+ "-",
575
+ balance.day_trading_buying_power > 0 ? pastel.green("Available") : pastel.yellow("N/A")
576
+ ]
577
+ ]
578
+
579
+ table = TTY::Table.new(headers, rows)
580
+
581
+ puts
582
+ begin
583
+ puts table.render(:unicode, padding: [0, 1])
584
+ rescue StandardError
585
+ # Fallback for testing or non-TTY environments
586
+ puts headers.join(" | ")
587
+ puts "-" * 80
588
+ rows.each { |row| puts row.join(" | ") }
589
+ end
590
+
591
+ # Display additional metrics
592
+ puts
593
+ puts "Additional Information:"
594
+ puts " Available Trading Funds: #{format_currency(balance.available_trading_funds)}"
595
+ puts " Cash Balance: #{format_currency(balance.cash_balance)}"
596
+ puts " Net Liquidating Value: #{format_currency(balance.net_liquidating_value)}"
597
+
598
+ # Display warnings if needed
599
+ if balance.high_buying_power_usage?
600
+ puts
601
+ warning "High buying power usage detected! Consider reducing positions."
602
+ end
603
+ rescue Tastytrade::Error => e
604
+ error "Failed to fetch buying power status: #{e.message}"
605
+ exit 1
606
+ rescue StandardError => e
607
+ error "Unexpected error: #{e.message}"
608
+ exit 1
609
+ end
610
+
326
611
  desc "status", "Check session status"
327
612
  def status
328
613
  session = current_session
@@ -393,6 +678,164 @@ module Tastytrade
393
678
  interactive_mode
394
679
  end
395
680
 
681
+ # Register the Orders subcommand
682
+ desc "order SUBCOMMAND ...ARGS", "Manage orders"
683
+ subcommand "order", CLI::Orders
684
+
685
+ desc "place SYMBOL QUANTITY", "Place an order for equities"
686
+ option :type, default: "market", desc: "Order type (market or limit)"
687
+ option :price, type: :numeric, desc: "Price for limit orders"
688
+ option :action, default: "buy", desc: "Order action (buy or sell)"
689
+ option :dry_run, type: :boolean, default: false, desc: "Simulate order without placing it"
690
+ option :account, type: :string, desc: "Account number (uses default if not specified)"
691
+ # Place an order for equities
692
+ #
693
+ # @example Place a market buy order
694
+ # tastytrade place AAPL 100
695
+ #
696
+ # @example Place a limit buy order
697
+ # tastytrade place AAPL 100 --type limit --price 150.50
698
+ #
699
+ # @example Place a sell order
700
+ # tastytrade place AAPL 100 --action sell
701
+ #
702
+ # @example Dry run an order
703
+ # tastytrade place AAPL 100 --dry-run
704
+ #
705
+ def place(symbol, quantity)
706
+ require_authentication!
707
+
708
+ # Get the account to use
709
+ account = if options[:account]
710
+ Tastytrade::Models::Account.get(current_session, options[:account])
711
+ else
712
+ current_account || select_account_interactively
713
+ end
714
+
715
+ return unless account
716
+
717
+ # Create the order leg
718
+ action = case options[:action].downcase
719
+ when "buy"
720
+ Tastytrade::OrderAction::BUY_TO_OPEN
721
+ when "sell"
722
+ Tastytrade::OrderAction::SELL_TO_CLOSE
723
+ else
724
+ error "Invalid action: #{options[:action]}. Must be 'buy' or 'sell'"
725
+ exit 1
726
+ end
727
+
728
+ leg = Tastytrade::OrderLeg.new(
729
+ action: action,
730
+ symbol: symbol.upcase,
731
+ quantity: quantity
732
+ )
733
+
734
+ # Create the order
735
+ order_type = case options[:type].downcase
736
+ when "market"
737
+ Tastytrade::OrderType::MARKET
738
+ when "limit"
739
+ Tastytrade::OrderType::LIMIT
740
+ else
741
+ error "Invalid order type: #{options[:type]}. Must be 'market' or 'limit'"
742
+ exit 1
743
+ end
744
+
745
+ begin
746
+ order = Tastytrade::Order.new(
747
+ type: order_type,
748
+ legs: leg,
749
+ price: options[:price]
750
+ )
751
+ rescue ArgumentError => e
752
+ error e.message
753
+ exit 1
754
+ end
755
+
756
+ # Place the order
757
+ order_desc = "#{options[:type]} #{options[:action]} order for #{quantity} shares of #{symbol}"
758
+ info "Placing #{options[:dry_run] ? "simulated " : ""}#{order_desc}..."
759
+
760
+ begin
761
+ # First do a dry run to check buying power impact
762
+ dry_run_response = account.place_order(current_session, order, dry_run: true)
763
+
764
+ # Check if this is a BuyingPowerEffect object
765
+ if dry_run_response.buying_power_effect.is_a?(Tastytrade::Models::BuyingPowerEffect)
766
+ bp_effect = dry_run_response.buying_power_effect
767
+ bp_usage = bp_effect.buying_power_usage_percentage
768
+
769
+ if bp_usage > 80
770
+ warning "This order will use #{bp_usage.to_s("F")}% of your buying power!"
771
+
772
+ # Fetch current balance for more context
773
+ balance = account.get_balances(current_session)
774
+ puts ""
775
+ puts "Current Buying Power Status:"
776
+ puts " Available Trading Funds: #{format_currency(balance.available_trading_funds)}"
777
+ puts " Equity Buying Power: #{format_currency(balance.equity_buying_power)}"
778
+ puts " Current BP Usage: #{balance.buying_power_usage_percentage.to_s("F")}%"
779
+ puts ""
780
+
781
+ unless options[:dry_run]
782
+ unless prompt.yes?("Are you sure you want to proceed with this order?")
783
+ info "Order cancelled"
784
+ return
785
+ end
786
+ end
787
+ end
788
+ end
789
+
790
+ # Place the actual order if not dry run
791
+ if options[:dry_run]
792
+ response = dry_run_response
793
+ success "Dry run successful!"
794
+ else
795
+ response = account.place_order(current_session, order, dry_run: false)
796
+ success "Order placed successfully!"
797
+ end
798
+
799
+ puts ""
800
+ puts "Order Details:"
801
+ if response.order_id && !response.order_id.empty?
802
+ puts " Order ID: #{response.order_id}"
803
+ end
804
+ if response.status && !response.status.empty?
805
+ puts " Status: #{response.status}"
806
+ end
807
+ if response.account_number && !response.account_number.empty?
808
+ puts " Account: #{response.account_number}"
809
+ end
810
+
811
+ # Handle both BigDecimal and BuyingPowerEffect
812
+ if response.buying_power_effect
813
+ if response.buying_power_effect.is_a?(Tastytrade::Models::BuyingPowerEffect)
814
+ bp_effect = response.buying_power_effect
815
+ puts " Buying Power Impact: #{format_currency(bp_effect.buying_power_change_amount)}"
816
+ puts " BP Usage: #{bp_effect.buying_power_usage_percentage.to_s("F")}%"
817
+ else
818
+ puts " Buying Power Effect: #{format_currency(response.buying_power_effect)}"
819
+ end
820
+ end
821
+
822
+ if response.warnings.any?
823
+ puts ""
824
+ warning "Warnings:"
825
+ response.warnings.each do |w|
826
+ if w.is_a?(Hash)
827
+ puts " - #{w["message"] || w["code"]}"
828
+ else
829
+ puts " - #{w}"
830
+ end
831
+ end
832
+ end
833
+ rescue Tastytrade::Error => e
834
+ error "Failed to place order: #{e.message}"
835
+ exit 1
836
+ end
837
+ end
838
+
396
839
  private
397
840
 
398
841
  def interactive_mode
@@ -411,9 +854,11 @@ module Tastytrade
411
854
  when :portfolio
412
855
  info "Portfolio command not yet implemented"
413
856
  when :positions
414
- info "Positions command not yet implemented"
857
+ interactive_positions
858
+ when :history
859
+ interactive_history
415
860
  when :orders
416
- info "Orders command not yet implemented"
861
+ interactive_order
417
862
  when :settings
418
863
  info "Settings command not yet implemented"
419
864
  when :exit
@@ -431,13 +876,14 @@ module Tastytrade
431
876
 
432
877
  result = menu_prompt.select("Main Menu#{account_info}", per_page: 10) do |menu|
433
878
  menu.enum "." # Enable number shortcuts with . delimiter
434
- menu.help "(Use ↑/↓ arrows, vim j/k, numbers 1-8, q or ESC to quit)"
879
+ menu.help "(Use ↑/↓ arrows, vim j/k, numbers 1-9, q or ESC to quit)"
435
880
 
436
881
  menu.choice "Accounts - View all accounts", :accounts
437
882
  menu.choice "Select Account - Choose active account", :select
438
883
  menu.choice "Balance - View account balance", :balance
439
884
  menu.choice "Portfolio - View holdings", :portfolio
440
885
  menu.choice "Positions - View open positions", :positions
886
+ menu.choice "History - View transaction history", :history
441
887
  menu.choice "Orders - View recent orders", :orders
442
888
  menu.choice "Settings - Configure preferences", :settings
443
889
  menu.choice "Exit", :exit
@@ -516,9 +962,7 @@ module Tastytrade
516
962
 
517
963
  case action
518
964
  when :positions
519
- info "Positions view not yet implemented"
520
- prompt.keypress("\nPress any key to continue...")
521
- interactive_balance # Show balance menu again
965
+ interactive_positions
522
966
  when :switch
523
967
  interactive_select
524
968
  interactive_balance if current_account_number # Check for account number instead of making API call
@@ -600,5 +1044,250 @@ module Tastytrade
600
1044
  prompt.select("Select an account:", choices)
601
1045
  end
602
1046
  end
1047
+
1048
+ def interactive_positions
1049
+ account = @current_account || current_account || select_account_interactively
1050
+ return unless account
1051
+
1052
+ info "Fetching positions for account #{account.account_number}..."
1053
+
1054
+ positions = account.get_positions(current_session)
1055
+
1056
+ if positions.empty?
1057
+ warning "No positions found"
1058
+ prompt.keypress("\nPress any key to continue...")
1059
+ return
1060
+ end
1061
+
1062
+ formatter = Tastytrade::PositionsFormatter.new(pastel: pastel)
1063
+ formatter.format_table(positions)
1064
+
1065
+ prompt.keypress("\nPress any key to continue...")
1066
+ rescue Tastytrade::Error => e
1067
+ error "Failed to fetch positions: #{e.message}"
1068
+ prompt.keypress("\nPress any key to continue...")
1069
+ rescue StandardError => e
1070
+ error "Unexpected error: #{e.message}"
1071
+ prompt.keypress("\nPress any key to continue...")
1072
+ end
1073
+
1074
+ def interactive_history
1075
+ account = @current_account || current_account || select_account_interactively
1076
+ return unless account
1077
+
1078
+ # Create vim-enabled prompt for grouping option
1079
+ group_prompt = create_vim_prompt
1080
+ grouping = group_prompt.select("How would you like to view transactions?", per_page: 5) do |menu|
1081
+ menu.enum "."
1082
+ menu.help "(Use ↑/↓ arrows, vim j/k, numbers 1-5, q or ESC to go back)"
1083
+ menu.choice "All transactions (detailed)", nil
1084
+ menu.choice "Group by symbol", :symbol
1085
+ menu.choice "Group by type", :type
1086
+ menu.choice "Group by date", :date
1087
+ menu.choice "Back to main menu", :back
1088
+ end
1089
+
1090
+ return if @exit_requested || grouping == :back
1091
+
1092
+ # Ask for date range
1093
+ filter_by_date = prompt.yes?("Filter by date range?")
1094
+
1095
+ filter_options = {}
1096
+ if filter_by_date
1097
+ begin
1098
+ start_date = prompt.ask("Enter start date (YYYY-MM-DD):") do |q|
1099
+ q.validate(/^\d{4}-\d{2}-\d{2}$/, "Must be in YYYY-MM-DD format")
1100
+ end
1101
+ filter_options[:start_date] = Date.parse(start_date)
1102
+
1103
+ end_date = prompt.ask("Enter end date (YYYY-MM-DD):") do |q|
1104
+ q.validate(/^\d{4}-\d{2}-\d{2}$/, "Must be in YYYY-MM-DD format")
1105
+ end
1106
+ filter_options[:end_date] = Date.parse(end_date)
1107
+ rescue Date::Error => e
1108
+ error "Invalid date: #{e.message}"
1109
+ prompt.keypress("\nPress any key to continue...")
1110
+ return
1111
+ end
1112
+ end
1113
+
1114
+ # Ask for symbol filter
1115
+ filter_by_symbol = prompt.yes?("Filter by symbol?")
1116
+ if filter_by_symbol
1117
+ symbol = prompt.ask("Enter symbol:") { |q| q.modify :up }.upcase
1118
+ filter_options[:symbol] = symbol
1119
+ end
1120
+
1121
+ info "Fetching transaction history for account #{account.account_number}..."
1122
+
1123
+ transactions = account.get_transactions(current_session, **filter_options)
1124
+
1125
+ if transactions.empty?
1126
+ warning "No transactions found"
1127
+ prompt.keypress("\nPress any key to continue...")
1128
+ return
1129
+ end
1130
+
1131
+ formatter = Tastytrade::HistoryFormatter.new(pastel: pastel)
1132
+ formatter.format_table(transactions, group_by: grouping)
1133
+
1134
+ prompt.keypress("\nPress any key to continue...")
1135
+ rescue Tastytrade::Error => e
1136
+ error "Failed to fetch transaction history: #{e.message}"
1137
+ prompt.keypress("\nPress any key to continue...")
1138
+ rescue StandardError => e
1139
+ error "Unexpected error: #{e.message}"
1140
+ prompt.keypress("\nPress any key to continue...")
1141
+ end
1142
+
1143
+ def interactive_order
1144
+ account = @current_account || current_account || select_account_interactively
1145
+ return unless account
1146
+
1147
+ # Get order details
1148
+ symbol = prompt.ask("Enter symbol:") { |q| q.modify :up }.upcase
1149
+ quantity = prompt.ask("Enter quantity:", convert: :int) do |q|
1150
+ q.validate(/^\d+$/, "Must be a positive number")
1151
+ end
1152
+
1153
+ # Create vim-enabled prompt for order type
1154
+ order_type_prompt = create_vim_prompt
1155
+ order_type = order_type_prompt.select("Select order type:", per_page: 2) do |menu|
1156
+ menu.enum "."
1157
+ menu.help "(Use ↑/↓ arrows, vim j/k, numbers 1-2, q or ESC to go back)"
1158
+ menu.choice "Market - Execute at current market price", "Market"
1159
+ menu.choice "Limit - Execute at specified price or better", "Limit"
1160
+ end
1161
+
1162
+ return if @exit_requested
1163
+
1164
+ price = nil
1165
+ if order_type == "Limit"
1166
+ price = prompt.ask("Enter limit price:", convert: :float) do |q|
1167
+ q.validate(/^\d+(\.\d+)?$/, "Must be a valid price")
1168
+ end
1169
+ end
1170
+
1171
+ # Create vim-enabled prompt for action
1172
+ action_prompt = create_vim_prompt
1173
+ action = action_prompt.select("Select action:", per_page: 2) do |menu|
1174
+ menu.enum "."
1175
+ menu.help "(Use ↑/↓ arrows, vim j/k, numbers 1-2, q or ESC to go back)"
1176
+ menu.choice "Buy - Purchase shares", "Buy"
1177
+ menu.choice "Sell - Sell shares", "Sell"
1178
+ end
1179
+
1180
+ return if @exit_requested
1181
+
1182
+ # Show order summary
1183
+ puts "\nOrder Summary:"
1184
+ puts " Symbol: #{symbol}"
1185
+ puts " Quantity: #{quantity}"
1186
+ puts " Type: #{order_type}"
1187
+ puts " Price: #{price ? format_currency(price) : "Market"}" if order_type == "Limit"
1188
+ puts " Action: #{action}"
1189
+ puts " Account: #{account.account_number}"
1190
+
1191
+ dry_run = prompt.yes?("\nRun as simulation (dry run)?")
1192
+
1193
+ if prompt.yes?("\nPlace this order?")
1194
+ # Create the order
1195
+ order_action = action == "Buy" ? Tastytrade::OrderAction::BUY_TO_OPEN : Tastytrade::OrderAction::SELL_TO_CLOSE
1196
+
1197
+ leg = Tastytrade::OrderLeg.new(
1198
+ action: order_action,
1199
+ symbol: symbol,
1200
+ quantity: quantity
1201
+ )
1202
+
1203
+ order_type_constant = order_type == "Market" ? Tastytrade::OrderType::MARKET : Tastytrade::OrderType::LIMIT
1204
+
1205
+ begin
1206
+ order = Tastytrade::Order.new(
1207
+ type: order_type_constant,
1208
+ legs: leg,
1209
+ price: price
1210
+ )
1211
+
1212
+ info "Placing #{dry_run ? "simulated " : ""}order..."
1213
+
1214
+ # First do a dry run to check buying power impact
1215
+ dry_run_response = account.place_order(current_session, order, dry_run: true)
1216
+
1217
+ # Check buying power impact
1218
+ if dry_run_response.buying_power_effect.is_a?(Tastytrade::Models::BuyingPowerEffect)
1219
+ bp_effect = dry_run_response.buying_power_effect
1220
+ bp_usage = bp_effect.buying_power_usage_percentage
1221
+
1222
+ if bp_usage > 80
1223
+ warning "This order will use #{bp_usage.to_s("F")}% of your buying power!"
1224
+
1225
+ # Fetch current balance for more context
1226
+ balance = account.get_balances(current_session)
1227
+ puts ""
1228
+ puts "Current Buying Power Status:"
1229
+ puts " Available Trading Funds: #{format_currency(balance.available_trading_funds)}"
1230
+ puts " Equity Buying Power: #{format_currency(balance.equity_buying_power)}"
1231
+ puts " Current BP Usage: #{balance.buying_power_usage_percentage.to_s("F")}%"
1232
+ puts ""
1233
+
1234
+ unless dry_run
1235
+ unless prompt.yes?("Are you sure you want to proceed with this order?")
1236
+ info "Order cancelled"
1237
+ prompt.keypress("\nPress any key to continue...")
1238
+ return
1239
+ end
1240
+ end
1241
+ end
1242
+ end
1243
+
1244
+ # Place the actual order if not dry run
1245
+ response = if dry_run
1246
+ dry_run_response
1247
+ else
1248
+ account.place_order(current_session, order, dry_run: false)
1249
+ end
1250
+
1251
+ success "#{dry_run ? "Dry run" : "Order placed"} successfully!"
1252
+
1253
+ puts "\nOrder Details:"
1254
+ puts " Order ID: #{response.order_id}" if response.order_id && !response.order_id.empty?
1255
+ puts " Status: #{response.status}" if response.status && !response.status.empty?
1256
+
1257
+ # Handle both BigDecimal and BuyingPowerEffect
1258
+ if response.buying_power_effect
1259
+ if response.buying_power_effect.is_a?(Tastytrade::Models::BuyingPowerEffect)
1260
+ bp_effect = response.buying_power_effect
1261
+ puts " Buying Power Impact: #{format_currency(bp_effect.buying_power_change_amount)}"
1262
+ puts " BP Usage: #{bp_effect.buying_power_usage_percentage.to_s("F")}%"
1263
+ else
1264
+ puts " Buying Power Effect: #{format_currency(response.buying_power_effect)}"
1265
+ end
1266
+ end
1267
+
1268
+ if response.warnings.any?
1269
+ warning "Warnings:"
1270
+ response.warnings.each { |w| puts " - #{w}" }
1271
+ end
1272
+ rescue Tastytrade::Error => e
1273
+ error "Failed to place order: #{e.message}"
1274
+ rescue ArgumentError => e
1275
+ error e.message
1276
+ end
1277
+ else
1278
+ info "Order cancelled"
1279
+ end
1280
+
1281
+ prompt.keypress("\nPress any key to continue...")
1282
+ rescue Interrupt
1283
+ nil
1284
+ rescue => e
1285
+ error "Failed to place order: #{e.message}"
1286
+ prompt.keypress("\nPress any key to continue...")
1287
+ end
603
1288
  end
604
1289
  end
1290
+
1291
+ # Require after CLI class is defined to avoid module/class conflict
1292
+ require_relative "cli/positions_formatter"
1293
+ require_relative "cli/history_formatter"