tastytrade 0.2.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 +7 -0
- data/.claude/commands/release-pr.md +108 -0
- data/.github/ISSUE_TEMPLATE/feature_request.md +29 -0
- data/.github/ISSUE_TEMPLATE/roadmap_task.md +34 -0
- data/.github/dependabot.yml +11 -0
- data/.github/workflows/main.yml +75 -0
- data/.rspec +3 -0
- data/.rubocop.yml +101 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +100 -0
- data/CLAUDE.md +78 -0
- data/CODE_OF_CONDUCT.md +81 -0
- data/CONTRIBUTING.md +89 -0
- data/DISCLAIMER.md +54 -0
- data/LICENSE.txt +24 -0
- data/README.md +235 -0
- data/ROADMAP.md +157 -0
- data/Rakefile +17 -0
- data/SECURITY.md +48 -0
- data/docs/getting_started.md +48 -0
- data/docs/python_sdk_analysis.md +181 -0
- data/exe/tastytrade +8 -0
- data/lib/tastytrade/cli.rb +604 -0
- data/lib/tastytrade/cli_config.rb +79 -0
- data/lib/tastytrade/cli_helpers.rb +178 -0
- data/lib/tastytrade/client.rb +117 -0
- data/lib/tastytrade/keyring_store.rb +72 -0
- data/lib/tastytrade/models/account.rb +129 -0
- data/lib/tastytrade/models/account_balance.rb +75 -0
- data/lib/tastytrade/models/base.rb +47 -0
- data/lib/tastytrade/models/current_position.rb +155 -0
- data/lib/tastytrade/models/user.rb +23 -0
- data/lib/tastytrade/models.rb +7 -0
- data/lib/tastytrade/session.rb +164 -0
- data/lib/tastytrade/session_manager.rb +160 -0
- data/lib/tastytrade/version.rb +5 -0
- data/lib/tastytrade.rb +31 -0
- data/sig/tastytrade.rbs +4 -0
- data/spec/exe/tastytrade_spec.rb +104 -0
- data/spec/spec_helper.rb +26 -0
- data/spec/tastytrade/cli_accounts_spec.rb +166 -0
- data/spec/tastytrade/cli_auth_spec.rb +216 -0
- data/spec/tastytrade/cli_config_spec.rb +180 -0
- data/spec/tastytrade/cli_helpers_spec.rb +248 -0
- data/spec/tastytrade/cli_interactive_spec.rb +54 -0
- data/spec/tastytrade/cli_logout_spec.rb +121 -0
- data/spec/tastytrade/cli_select_spec.rb +174 -0
- data/spec/tastytrade/cli_status_spec.rb +206 -0
- data/spec/tastytrade/client_spec.rb +210 -0
- data/spec/tastytrade/keyring_store_spec.rb +168 -0
- data/spec/tastytrade/models/account_balance_spec.rb +247 -0
- data/spec/tastytrade/models/account_spec.rb +206 -0
- data/spec/tastytrade/models/base_spec.rb +61 -0
- data/spec/tastytrade/models/current_position_spec.rb +444 -0
- data/spec/tastytrade/models/user_spec.rb +58 -0
- data/spec/tastytrade/session_manager_spec.rb +296 -0
- data/spec/tastytrade/session_spec.rb +392 -0
- data/spec/tastytrade_spec.rb +9 -0
- metadata +303 -0
@@ -0,0 +1,604 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "thor"
|
4
|
+
require "tty-table"
|
5
|
+
require "bigdecimal"
|
6
|
+
require_relative "cli_helpers"
|
7
|
+
require_relative "cli_config"
|
8
|
+
require_relative "session_manager"
|
9
|
+
|
10
|
+
module Tastytrade
|
11
|
+
# Main CLI class for Tastytrade gem
|
12
|
+
class CLI < Thor
|
13
|
+
include Tastytrade::CLIHelpers
|
14
|
+
|
15
|
+
package_name "Tastytrade"
|
16
|
+
|
17
|
+
# Map common version flags to version command
|
18
|
+
map %w[--version -v] => :version
|
19
|
+
|
20
|
+
class_option :test, type: :boolean, default: false, desc: "Use sandbox environment"
|
21
|
+
|
22
|
+
desc "version", "Display version information"
|
23
|
+
def version
|
24
|
+
puts "Tastytrade CLI v#{Tastytrade::VERSION}"
|
25
|
+
end
|
26
|
+
|
27
|
+
desc "login", "Login to Tastytrade"
|
28
|
+
option :username, aliases: "-u", desc: "Username"
|
29
|
+
option :remember, aliases: "-r", type: :boolean, default: false, desc: "Remember credentials"
|
30
|
+
def login
|
31
|
+
credentials = login_credentials
|
32
|
+
environment = options[:test] ? "sandbox" : "production"
|
33
|
+
|
34
|
+
info "Logging in to #{environment} environment..."
|
35
|
+
session = authenticate_user(credentials)
|
36
|
+
|
37
|
+
save_user_session(session, credentials, environment)
|
38
|
+
|
39
|
+
# Enter interactive mode after successful login
|
40
|
+
@current_session = session
|
41
|
+
interactive_mode
|
42
|
+
rescue Tastytrade::InvalidCredentialsError => e
|
43
|
+
error "Invalid credentials: #{e.message}"
|
44
|
+
exit 1
|
45
|
+
rescue Tastytrade::SessionExpiredError => e
|
46
|
+
error "Session expired: #{e.message}"
|
47
|
+
info "Please login again"
|
48
|
+
exit 1
|
49
|
+
rescue Tastytrade::NetworkTimeoutError => e
|
50
|
+
error "Network timeout: #{e.message}"
|
51
|
+
info "Check your internet connection and try again"
|
52
|
+
exit 1
|
53
|
+
rescue Tastytrade::Error => e
|
54
|
+
error e.message
|
55
|
+
exit 1
|
56
|
+
rescue StandardError => e
|
57
|
+
error "Login failed: #{e.message}"
|
58
|
+
exit 1
|
59
|
+
end
|
60
|
+
|
61
|
+
private
|
62
|
+
|
63
|
+
def format_time_remaining(seconds)
|
64
|
+
return "unknown time" unless seconds && seconds > 0
|
65
|
+
|
66
|
+
hours = (seconds / 3600).to_i
|
67
|
+
minutes = ((seconds % 3600) / 60).to_i
|
68
|
+
|
69
|
+
if hours > 0
|
70
|
+
"#{hours}h #{minutes}m"
|
71
|
+
else
|
72
|
+
"#{minutes}m"
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
def login_credentials
|
77
|
+
{
|
78
|
+
username: options[:username] || prompt.ask("Username:"),
|
79
|
+
password: prompt.mask("Password:"),
|
80
|
+
remember: options[:remember]
|
81
|
+
}
|
82
|
+
end
|
83
|
+
|
84
|
+
def authenticate_user(credentials)
|
85
|
+
session = Session.new(
|
86
|
+
username: credentials[:username],
|
87
|
+
password: credentials[:password],
|
88
|
+
remember_me: credentials[:remember],
|
89
|
+
is_test: options[:test]
|
90
|
+
)
|
91
|
+
session.login
|
92
|
+
success "Successfully logged in as #{session.user.email}"
|
93
|
+
session
|
94
|
+
end
|
95
|
+
|
96
|
+
def save_user_session(session, credentials, environment)
|
97
|
+
manager = SessionManager.new(
|
98
|
+
username: credentials[:username],
|
99
|
+
environment: environment
|
100
|
+
)
|
101
|
+
|
102
|
+
if manager.save_session(session, password: credentials[:password], remember: credentials[:remember])
|
103
|
+
info "Session saved securely" if credentials[:remember]
|
104
|
+
else
|
105
|
+
warning "Failed to save session credentials"
|
106
|
+
end
|
107
|
+
end
|
108
|
+
|
109
|
+
public
|
110
|
+
|
111
|
+
desc "logout", "Logout from Tastytrade"
|
112
|
+
def logout
|
113
|
+
session_info = current_session_info
|
114
|
+
return warning("No active session found") unless session_info
|
115
|
+
|
116
|
+
clear_user_session(session_info)
|
117
|
+
end
|
118
|
+
|
119
|
+
private
|
120
|
+
|
121
|
+
def current_session_info
|
122
|
+
username = config.get("current_username")
|
123
|
+
return nil unless username
|
124
|
+
|
125
|
+
{
|
126
|
+
username: username,
|
127
|
+
environment: config.get("environment") || "production"
|
128
|
+
}
|
129
|
+
end
|
130
|
+
|
131
|
+
def clear_user_session(session_info)
|
132
|
+
manager = SessionManager.new(
|
133
|
+
username: session_info[:username],
|
134
|
+
environment: session_info[:environment]
|
135
|
+
)
|
136
|
+
|
137
|
+
if manager.clear_session! && clear_config_data?
|
138
|
+
success "Successfully logged out"
|
139
|
+
else
|
140
|
+
error "Failed to logout completely"
|
141
|
+
exit 1
|
142
|
+
end
|
143
|
+
end
|
144
|
+
|
145
|
+
def clear_config_data?
|
146
|
+
config.delete("current_username")
|
147
|
+
config.delete("environment")
|
148
|
+
config.delete("last_login")
|
149
|
+
true
|
150
|
+
end
|
151
|
+
|
152
|
+
public
|
153
|
+
|
154
|
+
desc "accounts", "List all accounts"
|
155
|
+
def accounts
|
156
|
+
require_authentication!
|
157
|
+
info "Fetching accounts..."
|
158
|
+
|
159
|
+
accounts = fetch_accounts
|
160
|
+
return if accounts.nil? || accounts.empty?
|
161
|
+
|
162
|
+
display_accounts_table(accounts)
|
163
|
+
handle_account_selection(accounts)
|
164
|
+
rescue Tastytrade::Error => e
|
165
|
+
error "Failed to fetch accounts: #{e.message}"
|
166
|
+
exit 1
|
167
|
+
rescue StandardError => e
|
168
|
+
error "Unexpected error: #{e.message}"
|
169
|
+
exit 1
|
170
|
+
end
|
171
|
+
|
172
|
+
private
|
173
|
+
|
174
|
+
def fetch_accounts
|
175
|
+
accounts = Tastytrade::Models::Account.get_all(current_session)
|
176
|
+
if accounts.empty?
|
177
|
+
warning "No accounts found"
|
178
|
+
nil
|
179
|
+
else
|
180
|
+
accounts
|
181
|
+
end
|
182
|
+
end
|
183
|
+
|
184
|
+
def display_accounts_table(accounts)
|
185
|
+
current_account_number = config.get("current_account_number")
|
186
|
+
headers = ["", "Account", "Nickname", "Type", "Status"]
|
187
|
+
rows = build_account_rows(accounts, current_account_number)
|
188
|
+
|
189
|
+
render_table(headers, rows)
|
190
|
+
puts "\n#{pastel.dim("Total accounts:")} #{accounts.size}"
|
191
|
+
end
|
192
|
+
|
193
|
+
def build_account_rows(accounts, current_account_number)
|
194
|
+
accounts.map do |account|
|
195
|
+
indicator = account.account_number == current_account_number ? "→" : " "
|
196
|
+
[
|
197
|
+
indicator,
|
198
|
+
account.account_number,
|
199
|
+
account.nickname || "-",
|
200
|
+
account.account_type_name || "Unknown",
|
201
|
+
pastel.green("Active")
|
202
|
+
]
|
203
|
+
end
|
204
|
+
end
|
205
|
+
|
206
|
+
def render_table(headers, rows)
|
207
|
+
table = TTY::Table.new(headers, rows)
|
208
|
+
puts table.render(:unicode, padding: [0, 1])
|
209
|
+
rescue StandardError
|
210
|
+
# Fallback for testing or non-TTY environments
|
211
|
+
puts headers.join(" | ")
|
212
|
+
puts "-" * 50
|
213
|
+
rows.each { |row| puts row.join(" | ") }
|
214
|
+
end
|
215
|
+
|
216
|
+
def handle_account_selection(accounts)
|
217
|
+
current_account_number = config.get("current_account_number")
|
218
|
+
|
219
|
+
if accounts.size == 1
|
220
|
+
config.set("current_account_number", accounts.first.account_number)
|
221
|
+
info "Using account: #{accounts.first.account_number}"
|
222
|
+
elsif !current_account_number || accounts.none? { |a| a.account_number == current_account_number }
|
223
|
+
info "Use 'tastytrade select' to choose an account"
|
224
|
+
end
|
225
|
+
end
|
226
|
+
|
227
|
+
public
|
228
|
+
|
229
|
+
desc "select", "Select an account to use"
|
230
|
+
def select
|
231
|
+
require_authentication!
|
232
|
+
|
233
|
+
accounts = fetch_accounts
|
234
|
+
return if accounts.nil? || accounts.empty?
|
235
|
+
|
236
|
+
handle_single_account(accounts) || prompt_for_account_selection(accounts)
|
237
|
+
rescue Tastytrade::Error => e
|
238
|
+
error "Failed to fetch accounts: #{e.message}"
|
239
|
+
exit 1
|
240
|
+
rescue StandardError => e
|
241
|
+
error "Unexpected error: #{e.message}"
|
242
|
+
exit 1
|
243
|
+
end
|
244
|
+
|
245
|
+
private
|
246
|
+
|
247
|
+
def create_vim_prompt
|
248
|
+
menu_prompt = TTY::Prompt.new
|
249
|
+
@exit_requested = false
|
250
|
+
|
251
|
+
# Add vim-style navigation
|
252
|
+
menu_prompt.on(:keypress) do |event|
|
253
|
+
case event.value
|
254
|
+
when "j"
|
255
|
+
menu_prompt.trigger(:keydown)
|
256
|
+
when "k"
|
257
|
+
menu_prompt.trigger(:keyup)
|
258
|
+
when "q", "\e", "\e[" # q or ESC key
|
259
|
+
@exit_requested = true
|
260
|
+
menu_prompt.trigger(:keyenter) # Select current item to exit the menu
|
261
|
+
end
|
262
|
+
end
|
263
|
+
|
264
|
+
menu_prompt
|
265
|
+
end
|
266
|
+
|
267
|
+
def handle_single_account(accounts)
|
268
|
+
return false unless accounts.size == 1
|
269
|
+
|
270
|
+
account = accounts.first
|
271
|
+
config.set("current_account_number", account.account_number)
|
272
|
+
@current_account = account # Cache the account object
|
273
|
+
success "Using account: #{account.account_number}"
|
274
|
+
true
|
275
|
+
end
|
276
|
+
|
277
|
+
def prompt_for_account_selection(accounts)
|
278
|
+
choices = build_account_choices(accounts)
|
279
|
+
selected = prompt.select("Choose an account:", choices)
|
280
|
+
|
281
|
+
config.set("current_account_number", selected)
|
282
|
+
# Cache the selected account object
|
283
|
+
@current_account = accounts.find { |a| a.account_number == selected }
|
284
|
+
success "Selected account: #{selected}"
|
285
|
+
end
|
286
|
+
|
287
|
+
def build_account_choices(accounts)
|
288
|
+
current_account_number = config.get("current_account_number")
|
289
|
+
|
290
|
+
accounts.map do |account|
|
291
|
+
label = build_account_label(account, current_account_number)
|
292
|
+
{ name: label, value: account.account_number }
|
293
|
+
end
|
294
|
+
end
|
295
|
+
|
296
|
+
def build_account_label(account, current_account_number)
|
297
|
+
label = account.account_number.to_s
|
298
|
+
label += " - #{account.nickname}" if account.nickname
|
299
|
+
label += " (#{account.account_type_name})" if account.account_type_name
|
300
|
+
label += " [current]" if account.account_number == current_account_number
|
301
|
+
label
|
302
|
+
end
|
303
|
+
|
304
|
+
public
|
305
|
+
|
306
|
+
desc "balance", "Display account balance"
|
307
|
+
option :all, type: :boolean, desc: "Show balances for all accounts"
|
308
|
+
def balance
|
309
|
+
require_authentication!
|
310
|
+
|
311
|
+
if options[:all]
|
312
|
+
display_all_account_balances
|
313
|
+
else
|
314
|
+
account = current_account
|
315
|
+
unless account
|
316
|
+
account = select_account_interactively
|
317
|
+
return unless account
|
318
|
+
end
|
319
|
+
display_account_balance(account)
|
320
|
+
end
|
321
|
+
rescue => e
|
322
|
+
error "Failed to fetch balance: #{e.message}"
|
323
|
+
exit 1
|
324
|
+
end
|
325
|
+
|
326
|
+
desc "status", "Check session status"
|
327
|
+
def status
|
328
|
+
session = current_session
|
329
|
+
unless session
|
330
|
+
warning "No active session"
|
331
|
+
info "Run 'tastytrade login' to authenticate"
|
332
|
+
return
|
333
|
+
end
|
334
|
+
|
335
|
+
puts "Session Status:"
|
336
|
+
puts " User: #{session.user.email}"
|
337
|
+
puts " Environment: #{config.get("environment") || "production"}"
|
338
|
+
|
339
|
+
if session.session_expiration
|
340
|
+
if session.expired?
|
341
|
+
puts " Status: #{pastel.red("Expired")}"
|
342
|
+
else
|
343
|
+
time_left = format_time_remaining(session.time_until_expiry)
|
344
|
+
puts " Status: #{pastel.green("Active")}"
|
345
|
+
puts " Expires in: #{time_left}"
|
346
|
+
end
|
347
|
+
else
|
348
|
+
puts " Status: #{pastel.green("Active")}"
|
349
|
+
puts " Expires in: Unknown"
|
350
|
+
end
|
351
|
+
|
352
|
+
puts " Remember token: #{session.remember_token ? pastel.green("Available") : pastel.red("Not available")}"
|
353
|
+
puts " Auto-refresh: #{session.remember_token ? pastel.green("Enabled") : pastel.yellow("Disabled")}"
|
354
|
+
end
|
355
|
+
|
356
|
+
desc "refresh", "Refresh the current session"
|
357
|
+
def refresh
|
358
|
+
session = current_session
|
359
|
+
unless session
|
360
|
+
error "No active session to refresh"
|
361
|
+
exit 1
|
362
|
+
end
|
363
|
+
|
364
|
+
unless session.remember_token
|
365
|
+
error "No remember token available for refresh"
|
366
|
+
info "Login with --remember flag to enable session refresh"
|
367
|
+
exit 1
|
368
|
+
end
|
369
|
+
|
370
|
+
info "Refreshing session..."
|
371
|
+
|
372
|
+
begin
|
373
|
+
session.refresh_session
|
374
|
+
|
375
|
+
# Save refreshed session
|
376
|
+
manager = SessionManager.new(
|
377
|
+
username: session.user.email,
|
378
|
+
environment: config.get("environment") || "production"
|
379
|
+
)
|
380
|
+
manager.save_session(session)
|
381
|
+
|
382
|
+
success "Session refreshed successfully"
|
383
|
+
info "Session expires in #{format_time_remaining(session.time_until_expiry)}" if session.time_until_expiry
|
384
|
+
rescue Tastytrade::TokenRefreshError => e
|
385
|
+
error "Failed to refresh session: #{e.message}"
|
386
|
+
exit 1
|
387
|
+
end
|
388
|
+
end
|
389
|
+
|
390
|
+
desc "interactive", "Enter interactive mode"
|
391
|
+
def interactive
|
392
|
+
require_authentication!
|
393
|
+
interactive_mode
|
394
|
+
end
|
395
|
+
|
396
|
+
private
|
397
|
+
|
398
|
+
def interactive_mode
|
399
|
+
info "Welcome to Tastytrade!"
|
400
|
+
|
401
|
+
loop do
|
402
|
+
choice = show_main_menu
|
403
|
+
|
404
|
+
case choice
|
405
|
+
when :accounts
|
406
|
+
interactive_accounts
|
407
|
+
when :select
|
408
|
+
interactive_select
|
409
|
+
when :balance
|
410
|
+
interactive_balance
|
411
|
+
when :portfolio
|
412
|
+
info "Portfolio command not yet implemented"
|
413
|
+
when :positions
|
414
|
+
info "Positions command not yet implemented"
|
415
|
+
when :orders
|
416
|
+
info "Orders command not yet implemented"
|
417
|
+
when :settings
|
418
|
+
info "Settings command not yet implemented"
|
419
|
+
when :exit
|
420
|
+
break
|
421
|
+
end
|
422
|
+
end
|
423
|
+
|
424
|
+
info "Goodbye!"
|
425
|
+
end
|
426
|
+
|
427
|
+
def show_main_menu
|
428
|
+
account_info = current_account_number ? " (Account: #{current_account_number})" : " (No account selected)"
|
429
|
+
|
430
|
+
menu_prompt = create_vim_prompt
|
431
|
+
|
432
|
+
result = menu_prompt.select("Main Menu#{account_info}", per_page: 10) do |menu|
|
433
|
+
menu.enum "." # Enable number shortcuts with . delimiter
|
434
|
+
menu.help "(Use ↑/↓ arrows, vim j/k, numbers 1-8, q or ESC to quit)"
|
435
|
+
|
436
|
+
menu.choice "Accounts - View all accounts", :accounts
|
437
|
+
menu.choice "Select Account - Choose active account", :select
|
438
|
+
menu.choice "Balance - View account balance", :balance
|
439
|
+
menu.choice "Portfolio - View holdings", :portfolio
|
440
|
+
menu.choice "Positions - View open positions", :positions
|
441
|
+
menu.choice "Orders - View recent orders", :orders
|
442
|
+
menu.choice "Settings - Configure preferences", :settings
|
443
|
+
menu.choice "Exit", :exit
|
444
|
+
end
|
445
|
+
|
446
|
+
# Handle q or ESC key press
|
447
|
+
@exit_requested ? :exit : result
|
448
|
+
rescue Interrupt
|
449
|
+
# Handle Ctrl+C gracefully
|
450
|
+
:exit
|
451
|
+
end
|
452
|
+
|
453
|
+
def interactive_accounts
|
454
|
+
accounts = fetch_accounts
|
455
|
+
return if accounts.nil? || accounts.empty?
|
456
|
+
|
457
|
+
display_accounts_table(accounts)
|
458
|
+
|
459
|
+
prompt.keypress("\nPress any key to continue...")
|
460
|
+
rescue Tastytrade::Error => e
|
461
|
+
error "Failed to fetch accounts: #{e.message}"
|
462
|
+
prompt.keypress("\nPress any key to continue...")
|
463
|
+
end
|
464
|
+
|
465
|
+
def interactive_select
|
466
|
+
accounts = fetch_accounts
|
467
|
+
return if accounts.nil? || accounts.empty?
|
468
|
+
|
469
|
+
if accounts.size == 1
|
470
|
+
handle_single_account(accounts)
|
471
|
+
else
|
472
|
+
prompt_for_account_selection(accounts)
|
473
|
+
end
|
474
|
+
|
475
|
+
prompt.keypress("\nPress any key to continue...")
|
476
|
+
rescue Tastytrade::Error => e
|
477
|
+
error "Failed to fetch accounts: #{e.message}"
|
478
|
+
prompt.keypress("\nPress any key to continue...")
|
479
|
+
end
|
480
|
+
|
481
|
+
def interactive_balance
|
482
|
+
# Try to use cached account first, only fetch if needed
|
483
|
+
account = @current_account
|
484
|
+
|
485
|
+
# If no cached account, check if we have an account number saved
|
486
|
+
if !account && current_account_number
|
487
|
+
begin
|
488
|
+
account = Tastytrade::Models::Account.get(current_session, current_account_number)
|
489
|
+
@current_account = account # Cache it
|
490
|
+
rescue => e
|
491
|
+
# Don't show error here, will try to select account below
|
492
|
+
account = nil
|
493
|
+
end
|
494
|
+
end
|
495
|
+
|
496
|
+
# If still no account, let user select one
|
497
|
+
account ||= select_account_interactively
|
498
|
+
return unless account
|
499
|
+
|
500
|
+
display_account_balance(account)
|
501
|
+
|
502
|
+
menu_prompt = create_vim_prompt
|
503
|
+
|
504
|
+
action = menu_prompt.select("What would you like to do?", per_page: 10) do |menu|
|
505
|
+
menu.enum "." # Enable number shortcuts with . delimiter
|
506
|
+
menu.help "(Use ↑/↓ arrows, vim j/k, numbers 1-4, q or ESC to go back)"
|
507
|
+
|
508
|
+
menu.choice "View positions", :positions
|
509
|
+
menu.choice "Switch account", :switch
|
510
|
+
menu.choice "Refresh", :refresh
|
511
|
+
menu.choice "Back to main menu", :back
|
512
|
+
end
|
513
|
+
|
514
|
+
# Handle q or ESC key press
|
515
|
+
return if @exit_requested || action == :back
|
516
|
+
|
517
|
+
case action
|
518
|
+
when :positions
|
519
|
+
info "Positions view not yet implemented"
|
520
|
+
prompt.keypress("\nPress any key to continue...")
|
521
|
+
interactive_balance # Show balance menu again
|
522
|
+
when :switch
|
523
|
+
interactive_select
|
524
|
+
interactive_balance if current_account_number # Check for account number instead of making API call
|
525
|
+
when :refresh
|
526
|
+
@current_account = nil # Clear cache to force refresh
|
527
|
+
interactive_balance
|
528
|
+
end
|
529
|
+
rescue Interrupt
|
530
|
+
# Handle Ctrl+C gracefully - go back to main menu
|
531
|
+
nil
|
532
|
+
rescue => e
|
533
|
+
error "Failed to fetch balance: #{e.message}"
|
534
|
+
prompt.keypress("\nPress any key to continue...")
|
535
|
+
end
|
536
|
+
|
537
|
+
def display_account_balance(account)
|
538
|
+
balance = account.get_balances(current_session)
|
539
|
+
|
540
|
+
table = TTY::Table.new(
|
541
|
+
title: "#{account.nickname || account.account_number} - Account Balance",
|
542
|
+
header: ["Metric", "Value"],
|
543
|
+
rows: [
|
544
|
+
["Cash Balance", format_currency(balance.cash_balance)],
|
545
|
+
["Net Liquidating Value", format_currency(balance.net_liquidating_value)],
|
546
|
+
["Equity Buying Power", format_currency(balance.equity_buying_power)],
|
547
|
+
["Day Trading BP", format_currency(balance.day_trading_buying_power)],
|
548
|
+
["Available Trading Funds", format_currency(balance.available_trading_funds)],
|
549
|
+
["BP Usage", "#{balance.buying_power_usage_percentage.to_s("F")}%"]
|
550
|
+
]
|
551
|
+
)
|
552
|
+
|
553
|
+
puts
|
554
|
+
begin
|
555
|
+
puts table.render(:unicode, padding: [0, 1])
|
556
|
+
rescue StandardError
|
557
|
+
# Fallback for testing or non-TTY environments
|
558
|
+
puts "#{account.nickname || account.account_number} - Account Balance"
|
559
|
+
puts "-" * 50
|
560
|
+
table.rows.each do |row|
|
561
|
+
puts "#{row[0]}: #{row[1]}"
|
562
|
+
end
|
563
|
+
end
|
564
|
+
|
565
|
+
# Add color-coded BP warning if > 80%
|
566
|
+
if balance.high_buying_power_usage?
|
567
|
+
puts
|
568
|
+
warning "High buying power usage: #{balance.buying_power_usage_percentage.to_s("F")}%"
|
569
|
+
end
|
570
|
+
end
|
571
|
+
|
572
|
+
def display_all_account_balances
|
573
|
+
info "Fetching balances for all accounts..."
|
574
|
+
accounts = Tastytrade::Models::Account.get_all(current_session)
|
575
|
+
total_nlv = BigDecimal("0")
|
576
|
+
|
577
|
+
accounts.each do |account|
|
578
|
+
balance = account.get_balances(current_session)
|
579
|
+
total_nlv += balance.net_liquidating_value
|
580
|
+
display_account_balance(account)
|
581
|
+
puts
|
582
|
+
end
|
583
|
+
|
584
|
+
puts
|
585
|
+
info "Total Net Liquidating Value: #{format_currency(total_nlv)}"
|
586
|
+
end
|
587
|
+
|
588
|
+
def select_account_interactively
|
589
|
+
accounts = fetch_accounts
|
590
|
+
return nil if accounts.nil? || accounts.empty?
|
591
|
+
|
592
|
+
if accounts.size == 1
|
593
|
+
accounts.first
|
594
|
+
else
|
595
|
+
choices = accounts.map do |account|
|
596
|
+
label = build_account_label(account, nil)
|
597
|
+
[label, account]
|
598
|
+
end.to_h
|
599
|
+
|
600
|
+
prompt.select("Select an account:", choices)
|
601
|
+
end
|
602
|
+
end
|
603
|
+
end
|
604
|
+
end
|
@@ -0,0 +1,79 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "yaml"
|
4
|
+
require "fileutils"
|
5
|
+
|
6
|
+
module Tastytrade
|
7
|
+
# Configuration management for Tastytrade CLI
|
8
|
+
class CLIConfig
|
9
|
+
CONFIG_DIR = File.expand_path("~/.config/tastytrade")
|
10
|
+
CONFIG_FILE = File.join(CONFIG_DIR, "config.yml")
|
11
|
+
|
12
|
+
DEFAULT_CONFIG = {
|
13
|
+
"default_account" => nil,
|
14
|
+
"environment" => "production",
|
15
|
+
"auto_refresh" => true
|
16
|
+
}.freeze
|
17
|
+
|
18
|
+
attr_reader :data
|
19
|
+
|
20
|
+
def initialize
|
21
|
+
@data = load_config
|
22
|
+
end
|
23
|
+
|
24
|
+
# Get a configuration value
|
25
|
+
def get(key)
|
26
|
+
@data[key.to_s]
|
27
|
+
end
|
28
|
+
|
29
|
+
# Set a configuration value
|
30
|
+
def set(key, value)
|
31
|
+
@data[key.to_s] = value
|
32
|
+
save_config
|
33
|
+
end
|
34
|
+
|
35
|
+
# Delete a configuration value
|
36
|
+
def delete(key)
|
37
|
+
@data.delete(key.to_s)
|
38
|
+
save_config
|
39
|
+
end
|
40
|
+
|
41
|
+
# Check if config exists
|
42
|
+
def exists?
|
43
|
+
File.exist?(CONFIG_FILE)
|
44
|
+
end
|
45
|
+
|
46
|
+
# Reset to defaults
|
47
|
+
def reset!
|
48
|
+
@data = DEFAULT_CONFIG.dup
|
49
|
+
save_config
|
50
|
+
end
|
51
|
+
|
52
|
+
private
|
53
|
+
|
54
|
+
def load_config
|
55
|
+
ensure_config_dir_exists
|
56
|
+
return DEFAULT_CONFIG.dup unless File.exist?(CONFIG_FILE)
|
57
|
+
|
58
|
+
begin
|
59
|
+
config = YAML.load_file(CONFIG_FILE)
|
60
|
+
# Ensure we have a hash and merge with defaults
|
61
|
+
DEFAULT_CONFIG.merge(config.is_a?(Hash) ? config : {})
|
62
|
+
rescue StandardError => e
|
63
|
+
warn "Warning: Failed to load config file: #{e.message}"
|
64
|
+
DEFAULT_CONFIG.dup
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
def save_config
|
69
|
+
ensure_config_dir_exists
|
70
|
+
File.write(CONFIG_FILE, @data.to_yaml)
|
71
|
+
rescue StandardError => e
|
72
|
+
warn "Warning: Failed to save config file: #{e.message}"
|
73
|
+
end
|
74
|
+
|
75
|
+
def ensure_config_dir_exists
|
76
|
+
FileUtils.mkdir_p(CONFIG_DIR)
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|