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.
- checksums.yaml +4 -4
- data/.claude/commands/plan.md +13 -0
- data/.claude/commands/release-pr.md +12 -0
- data/CHANGELOG.md +170 -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/spec_helper.rb +72 -0
- 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
- data/vcr_implementation_plan.md +403 -0
- data/vcr_implementation_research.md +330 -0
- metadata +50 -18
- data/lib/tastytrade/keyring_store.rb +0 -72
- data/spec/tastytrade/keyring_store_spec.rb +0 -168
data/lib/tastytrade/cli.rb
CHANGED
@@ -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
|
-
|
32
|
-
|
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
|
-
|
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"
|
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
|
-
|
857
|
+
interactive_positions
|
858
|
+
when :history
|
859
|
+
interactive_history
|
415
860
|
when :orders
|
416
|
-
|
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-
|
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
|
-
|
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"
|