etrade-python 1.0.0__py3-none-any.whl

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 (49) hide show
  1. etrade_python/__init__.py +264 -0
  2. etrade_python/_dates.py +152 -0
  3. etrade_python/_version.py +3 -0
  4. etrade_python/accounts/__init__.py +31 -0
  5. etrade_python/accounts/models.py +204 -0
  6. etrade_python/accounts/service.py +136 -0
  7. etrade_python/alerts/__init__.py +23 -0
  8. etrade_python/alerts/models.py +180 -0
  9. etrade_python/alerts/service.py +128 -0
  10. etrade_python/auth/__init__.py +31 -0
  11. etrade_python/auth/credentials.py +85 -0
  12. etrade_python/auth/oauth.py +268 -0
  13. etrade_python/auth/session.py +154 -0
  14. etrade_python/auth/stores/__init__.py +7 -0
  15. etrade_python/auth/stores/base.py +18 -0
  16. etrade_python/auth/stores/keyring.py +49 -0
  17. etrade_python/auth/stores/memory.py +19 -0
  18. etrade_python/cli/__init__.py +1 -0
  19. etrade_python/cli/app.py +1623 -0
  20. etrade_python/client.py +84 -0
  21. etrade_python/config.py +43 -0
  22. etrade_python/enums.py +19 -0
  23. etrade_python/exceptions.py +68 -0
  24. etrade_python/market/__init__.py +59 -0
  25. etrade_python/market/models.py +811 -0
  26. etrade_python/market/service.py +168 -0
  27. etrade_python/orders/__init__.py +107 -0
  28. etrade_python/orders/builders.py +617 -0
  29. etrade_python/orders/models.py +1009 -0
  30. etrade_python/orders/service.py +259 -0
  31. etrade_python/orders/values.py +67 -0
  32. etrade_python/portfolio/__init__.py +35 -0
  33. etrade_python/portfolio/models.py +429 -0
  34. etrade_python/portfolio/service.py +99 -0
  35. etrade_python/py.typed +0 -0
  36. etrade_python/transactions/__init__.py +27 -0
  37. etrade_python/transactions/models.py +201 -0
  38. etrade_python/transactions/service.py +138 -0
  39. etrade_python/transport/__init__.py +7 -0
  40. etrade_python/transport/auth.py +17 -0
  41. etrade_python/transport/http.py +240 -0
  42. etrade_python/transport/logging.py +38 -0
  43. etrade_python/transport/response.py +243 -0
  44. etrade_python/transport/retry.py +75 -0
  45. etrade_python-1.0.0.dist-info/METADATA +513 -0
  46. etrade_python-1.0.0.dist-info/RECORD +49 -0
  47. etrade_python-1.0.0.dist-info/WHEEL +4 -0
  48. etrade_python-1.0.0.dist-info/entry_points.txt +2 -0
  49. etrade_python-1.0.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,1623 @@
1
+ """Diagnostic CLI for authentication bootstrap and read-only API checks."""
2
+
3
+ import asyncio
4
+ import json
5
+ from collections.abc import Coroutine
6
+ from datetime import date
7
+ from decimal import Decimal, InvalidOperation
8
+ from pathlib import Path
9
+ from typing import Annotated, Any, TypeVar, cast
10
+ from uuid import uuid4
11
+
12
+ import typer
13
+ from pydantic import BaseModel
14
+
15
+ from etrade_python import (
16
+ Account,
17
+ AccountBalanceRequest,
18
+ AccountBalanceResponse,
19
+ AccountListResponse,
20
+ AlertDetailsRequest,
21
+ AlertDetailsResponse,
22
+ AlertsRequest,
23
+ AlertsResponse,
24
+ CancelOrderResponse,
25
+ DeleteAlertsResponse,
26
+ OptionChainRequest,
27
+ OptionChainResponse,
28
+ OptionContract,
29
+ OptionExpiration,
30
+ OptionExpirationsRequest,
31
+ OptionExpirationsResponse,
32
+ OrderBuilder,
33
+ OrdersRequest,
34
+ OrdersResponse,
35
+ PlaceOrderRequest,
36
+ PlaceOrderResponse,
37
+ PortfolioRequest,
38
+ PortfolioResponse,
39
+ PreviewOrderRequest,
40
+ PreviewOrderResponse,
41
+ ProductLookupResponse,
42
+ Quote,
43
+ QuotesRequest,
44
+ QuotesResponse,
45
+ TransactionDetailsRequest,
46
+ TransactionDetailsResponse,
47
+ TransactionsRequest,
48
+ TransactionsResponse,
49
+ __version__,
50
+ )
51
+ from etrade_python.auth import TokenStatus
52
+ from etrade_python.client import ETradeClient
53
+ from etrade_python.config import ETradeSettings
54
+ from etrade_python.exceptions import ETradeError
55
+
56
+ TModel = TypeVar("TModel", bound=BaseModel)
57
+ TChoice = TypeVar("TChoice")
58
+
59
+ app = typer.Typer(no_args_is_help=True, invoke_without_command=True)
60
+ auth_app = typer.Typer(no_args_is_help=True)
61
+ accounts_app = typer.Typer(no_args_is_help=True)
62
+ portfolio_app = typer.Typer(no_args_is_help=True)
63
+ transactions_app = typer.Typer(no_args_is_help=True)
64
+ alerts_app = typer.Typer(no_args_is_help=True)
65
+ market_app = typer.Typer(no_args_is_help=True)
66
+ orders_app = typer.Typer(no_args_is_help=True)
67
+ app.add_typer(auth_app, name="auth")
68
+ app.add_typer(accounts_app, name="accounts")
69
+ app.add_typer(portfolio_app, name="portfolio")
70
+ app.add_typer(transactions_app, name="transactions")
71
+ app.add_typer(alerts_app, name="alerts")
72
+ app.add_typer(market_app, name="market")
73
+ app.add_typer(orders_app, name="orders")
74
+
75
+
76
+ def _run(coro: Coroutine[Any, Any, object]) -> object:
77
+ return asyncio.run(coro)
78
+
79
+
80
+ def _settings() -> ETradeSettings:
81
+ return ETradeSettings()
82
+
83
+
84
+ @app.callback()
85
+ def main(
86
+ version: bool = typer.Option(False, "--version", help="Show package version and exit."),
87
+ ) -> None:
88
+ if version:
89
+ typer.echo(__version__)
90
+ raise typer.Exit()
91
+
92
+
93
+ @auth_app.command("login")
94
+ def login(profile: str = typer.Option("default", "--profile", "-p")) -> None:
95
+ """Start OAuth authorization and save the resulting access token."""
96
+
97
+ async def command() -> None:
98
+ async with ETradeClient(_settings(), profile=profile) as client:
99
+ request_token = await client.oauth.get_request_token()
100
+ authorization = client.oauth.get_authorization_url(request_token)
101
+ typer.echo("Open this URL in a browser and authorize the application:")
102
+ typer.echo(authorization.url)
103
+ verifier = typer.prompt("OAuth verifier", hide_input=True)
104
+ credentials = await client.oauth.exchange_verifier(request_token, verifier)
105
+ await client.session.save(credentials)
106
+ typer.echo(f"Credentials saved for profile '{profile}'.")
107
+
108
+ _handle(command())
109
+
110
+
111
+ @auth_app.command("status")
112
+ def status(profile: str = typer.Option("default", "--profile", "-p")) -> None:
113
+ """Show credential lifecycle status without exposing tokens."""
114
+
115
+ async def command() -> None:
116
+ async with ETradeClient(_settings(), profile=profile) as client:
117
+ credentials = await client.session.load()
118
+ token_status = client.session.status(credentials)
119
+ if token_status is TokenStatus.MISSING:
120
+ typer.echo(f"No credentials stored for profile '{profile}'.")
121
+ else:
122
+ typer.echo(f"Credentials for profile '{profile}' are {token_status.value}.")
123
+
124
+ _handle(command())
125
+
126
+
127
+ @auth_app.command("renew")
128
+ def renew(profile: str = typer.Option("default", "--profile", "-p")) -> None:
129
+ """Explicitly renew same-day credentials with E*TRADE."""
130
+
131
+ async def command() -> None:
132
+ async with ETradeClient(_settings(), profile=profile) as client:
133
+ await client.session.renew()
134
+ typer.echo(f"Credentials renewed for profile '{profile}'.")
135
+
136
+ _handle(command())
137
+
138
+
139
+ @auth_app.command("revoke")
140
+ def revoke(profile: str = typer.Option("default", "--profile", "-p")) -> None:
141
+ """Revoke and delete stored credentials."""
142
+
143
+ async def command() -> None:
144
+ async with ETradeClient(_settings(), profile=profile) as client:
145
+ credentials = await client.session.load()
146
+ if credentials is not None:
147
+ await client.oauth.revoke_access_token(credentials)
148
+ await client.session.delete()
149
+ typer.echo(f"Credentials removed for profile '{profile}'.")
150
+
151
+ _handle(command())
152
+
153
+
154
+ @accounts_app.command("list")
155
+ def list_accounts(
156
+ profile: str = typer.Option("default", "--profile", "-p"),
157
+ json_output: bool = typer.Option(False, "--json", help="Print the full response as JSON."),
158
+ ) -> None:
159
+ """List brokerage accounts for the current profile."""
160
+
161
+ async def command() -> None:
162
+ async with ETradeClient(_settings(), profile=profile) as client:
163
+ response = await client.accounts.list()
164
+ if json_output:
165
+ _echo_json(response)
166
+ return
167
+ _echo_account_list(response)
168
+
169
+ _handle(command())
170
+
171
+
172
+ @accounts_app.command("balance")
173
+ def account_balance(
174
+ account_id_key: str = typer.Argument(..., help="E*TRADE accountIdKey from accounts list."),
175
+ profile: str = typer.Option("default", "--profile", "-p"),
176
+ account_type: str | None = typer.Option(None, "--account-type"),
177
+ inst_type: str = typer.Option("BROKERAGE", "--inst-type"),
178
+ real_time_nav: bool = typer.Option(False, "--real-time-nav"),
179
+ json_output: bool = typer.Option(False, "--json", help="Print the full response as JSON."),
180
+ ) -> None:
181
+ """Show account balance details for an accountIdKey."""
182
+
183
+ async def command() -> None:
184
+ request = AccountBalanceRequest(
185
+ account_type=account_type,
186
+ inst_type=inst_type,
187
+ real_time_nav=real_time_nav,
188
+ )
189
+ async with ETradeClient(_settings(), profile=profile) as client:
190
+ response = await client.accounts.get_balance(account_id_key, request)
191
+ if json_output:
192
+ _echo_json(response)
193
+ return
194
+ _echo_account_balance(response)
195
+
196
+ _handle(command())
197
+
198
+
199
+ @portfolio_app.command("positions")
200
+ def portfolio_positions(
201
+ account_id_key: str = typer.Argument(..., help="E*TRADE accountIdKey from accounts list."),
202
+ profile: str = typer.Option("default", "--profile", "-p"),
203
+ count: int | None = typer.Option(None, "--count"),
204
+ page_number: int | None = typer.Option(None, "--page-number"),
205
+ view: str | None = typer.Option(None, "--view"),
206
+ lots_required: bool | None = typer.Option(None, "--lots-required/--no-lots-required"),
207
+ totals_required: bool | None = typer.Option(None, "--totals-required/--no-totals-required"),
208
+ json_output: bool = typer.Option(False, "--json", help="Print the full response as JSON."),
209
+ ) -> None:
210
+ """Show portfolio positions for an accountIdKey."""
211
+
212
+ async def command() -> None:
213
+ request = PortfolioRequest(
214
+ count=count,
215
+ page_number=page_number,
216
+ view=view,
217
+ lots_required=lots_required,
218
+ totals_required=totals_required,
219
+ )
220
+ async with ETradeClient(_settings(), profile=profile) as client:
221
+ response = await client.portfolio.get_positions(account_id_key, request)
222
+ if json_output:
223
+ _echo_json(response)
224
+ return
225
+ _echo_portfolio(response)
226
+
227
+ _handle(command())
228
+
229
+
230
+ @transactions_app.command("list")
231
+ def list_transactions(
232
+ account_id_key: str = typer.Argument(..., help="E*TRADE accountIdKey from accounts list."),
233
+ profile: str = typer.Option("default", "--profile", "-p"),
234
+ marker: str | None = typer.Option(None, "--marker"),
235
+ count: int | None = typer.Option(None, "--count"),
236
+ start_date: str | None = typer.Option(None, "--start-date"),
237
+ end_date: str | None = typer.Option(None, "--end-date"),
238
+ sort_order: str = typer.Option("DESC", "--sort-order"),
239
+ json_output: bool = typer.Option(False, "--json", help="Print the full response as JSON."),
240
+ ) -> None:
241
+ """List transactions for an accountIdKey."""
242
+
243
+ async def command() -> None:
244
+ request = TransactionsRequest(
245
+ marker=marker,
246
+ count=count,
247
+ start_date=start_date,
248
+ end_date=end_date,
249
+ sort_order=sort_order,
250
+ )
251
+ async with ETradeClient(_settings(), profile=profile) as client:
252
+ response = await client.transactions.list(account_id_key, request)
253
+ if json_output:
254
+ _echo_json(response)
255
+ return
256
+ _echo_transactions(response)
257
+
258
+ _handle(command())
259
+
260
+
261
+ @transactions_app.command("get")
262
+ def get_transaction(
263
+ account_id_key: str = typer.Argument(..., help="E*TRADE accountIdKey from accounts list."),
264
+ transaction_id: str = typer.Argument(..., help="Transaction ID from transactions list."),
265
+ profile: str = typer.Option("default", "--profile", "-p"),
266
+ store_id: str | None = typer.Option(None, "--store-id"),
267
+ json_output: bool = typer.Option(False, "--json", help="Print the full response as JSON."),
268
+ ) -> None:
269
+ """Show transaction details for a transaction ID."""
270
+
271
+ async def command() -> None:
272
+ request = TransactionDetailsRequest(store_id=store_id)
273
+ async with ETradeClient(_settings(), profile=profile) as client:
274
+ response = await client.transactions.get(account_id_key, transaction_id, request)
275
+ if json_output:
276
+ _echo_json(response)
277
+ return
278
+ _echo_transaction_details(response)
279
+
280
+ _handle(command())
281
+
282
+
283
+ @alerts_app.command("list")
284
+ def list_alerts(
285
+ profile: str = typer.Option("default", "--profile", "-p"),
286
+ count: int | None = typer.Option(None, "--count"),
287
+ category: str | None = typer.Option(None, "--category"),
288
+ status: str | None = typer.Option(None, "--status"),
289
+ direction: str | None = typer.Option(None, "--direction"),
290
+ search: str | None = typer.Option(None, "--search"),
291
+ json_output: bool = typer.Option(False, "--json", help="Print the full response as JSON."),
292
+ ) -> None:
293
+ """List user alerts."""
294
+
295
+ async def command() -> None:
296
+ request = AlertsRequest(
297
+ count=count, category=category, status=status, direction=direction, search=search
298
+ )
299
+ async with ETradeClient(_settings(), profile=profile) as client:
300
+ response = await client.alerts.list(request)
301
+ if json_output:
302
+ _echo_json(response)
303
+ return
304
+ _echo_alerts(response)
305
+
306
+ _handle(command())
307
+
308
+
309
+ @alerts_app.command("get")
310
+ def get_alert(
311
+ alert_id: int = typer.Argument(..., help="Alert ID to fetch."),
312
+ profile: str = typer.Option("default", "--profile", "-p"),
313
+ html_tags: bool | None = typer.Option(None, "--html-tags/--no-html-tags"),
314
+ json_output: bool = typer.Option(False, "--json", help="Print the full response as JSON."),
315
+ ) -> None:
316
+ """Show details for one alert."""
317
+
318
+ async def command() -> None:
319
+ request = AlertDetailsRequest(htmlTags=html_tags)
320
+ async with ETradeClient(_settings(), profile=profile) as client:
321
+ response = await client.alerts.get(alert_id, request)
322
+ if json_output:
323
+ _echo_json(response)
324
+ return
325
+ _echo_alert_details(response)
326
+
327
+ _handle(command())
328
+
329
+
330
+ @alerts_app.command("delete")
331
+ def delete_alerts(
332
+ alert_ids: Annotated[list[int], typer.Argument(help="One or more alert IDs to delete.")],
333
+ profile: str = typer.Option("default", "--profile", "-p"),
334
+ confirm_delete: bool = typer.Option(False, "--confirm-delete"),
335
+ json_output: bool = typer.Option(False, "--json", help="Print the full response as JSON."),
336
+ ) -> None:
337
+ """Delete one or more alerts."""
338
+
339
+ if not confirm_delete:
340
+ typer.echo("Refusing to delete alerts without --confirm-delete.")
341
+ raise typer.Exit(code=1)
342
+
343
+ async def command() -> None:
344
+ async with ETradeClient(_settings(), profile=profile) as client:
345
+ response = await client.alerts.delete(alert_ids)
346
+ if json_output:
347
+ _echo_json(response)
348
+ return
349
+ _echo_delete_alerts(response)
350
+
351
+ _handle(command())
352
+
353
+
354
+ @orders_app.command("list")
355
+ def list_orders(
356
+ account_id_key: str = typer.Argument(..., help="E*TRADE accountIdKey from accounts list."),
357
+ profile: str = typer.Option("default", "--profile", "-p"),
358
+ marker: str | None = typer.Option(None, "--marker"),
359
+ count: int | None = typer.Option(None, "--count"),
360
+ status: str | None = typer.Option(None, "--status"),
361
+ from_date: str | None = typer.Option(None, "--from-date"),
362
+ to_date: str | None = typer.Option(None, "--to-date"),
363
+ symbol: str | None = typer.Option(None, "--symbol"),
364
+ security_type: str | None = typer.Option(None, "--security-type"),
365
+ transaction_type: str | None = typer.Option(None, "--transaction-type"),
366
+ market_session: str | None = typer.Option(None, "--market-session"),
367
+ json_output: bool = typer.Option(False, "--json", help="Print the full response as JSON."),
368
+ ) -> None:
369
+ """List orders for an accountIdKey."""
370
+
371
+ async def command() -> None:
372
+ request = OrdersRequest(
373
+ marker=marker,
374
+ count=count,
375
+ status=status,
376
+ fromDate=from_date,
377
+ toDate=to_date,
378
+ symbol=symbol,
379
+ securityType=security_type,
380
+ transactionType=transaction_type,
381
+ marketSession=market_session,
382
+ )
383
+ async with ETradeClient(_settings(), profile=profile) as client:
384
+ response = await client.orders.list(account_id_key, request)
385
+ if json_output:
386
+ _echo_json(response)
387
+ return
388
+ _echo_orders(response)
389
+
390
+ _handle(command())
391
+
392
+
393
+ @orders_app.command("preview")
394
+ def preview_order(
395
+ account_id_key: Annotated[str, typer.Argument(help="E*TRADE accountIdKey from accounts list.")],
396
+ request_json: Annotated[Path, typer.Argument(help="JSON file containing PreviewOrderRequest.")],
397
+ profile: str = typer.Option("default", "--profile", "-p"),
398
+ json_output: bool = typer.Option(False, "--json", help="Print the full response as JSON."),
399
+ ) -> None:
400
+ """Preview an order request from a JSON file."""
401
+
402
+ async def command() -> None:
403
+ request = _load_model_file(request_json, PreviewOrderRequest, "PreviewOrderRequest")
404
+ async with ETradeClient(_settings(), profile=profile) as client:
405
+ response = await client.orders.preview(account_id_key, request)
406
+ if json_output:
407
+ _echo_json(response)
408
+ return
409
+ _echo_preview_order(response)
410
+
411
+ _handle(command())
412
+
413
+
414
+ @orders_app.command("place")
415
+ def place_order(
416
+ account_id_key: Annotated[str, typer.Argument(help="E*TRADE accountIdKey from accounts list.")],
417
+ request_json: Annotated[Path, typer.Argument(help="JSON file containing PlaceOrderRequest.")],
418
+ profile: str = typer.Option("default", "--profile", "-p"),
419
+ confirm_live_order: bool = typer.Option(False, "--confirm-live-order"),
420
+ json_output: bool = typer.Option(False, "--json", help="Print the full response as JSON."),
421
+ ) -> None:
422
+ """Place a previously previewed order request from a JSON file."""
423
+
424
+ _require_live_order_confirmation(confirm_live_order)
425
+
426
+ async def command() -> None:
427
+ request = _load_model_file(request_json, PlaceOrderRequest, "PlaceOrderRequest")
428
+ async with ETradeClient(_settings(), profile=profile) as client:
429
+ response = await client.orders.place(account_id_key, request)
430
+ if json_output:
431
+ _echo_json(response)
432
+ return
433
+ _echo_place_order(response)
434
+
435
+ _handle(command())
436
+
437
+
438
+ @orders_app.command("preview-change")
439
+ def preview_change_order(
440
+ account_id_key: Annotated[str, typer.Argument(help="E*TRADE accountIdKey from accounts list.")],
441
+ order_id: Annotated[int, typer.Argument(help="Order ID to change.")],
442
+ request_json: Annotated[Path, typer.Argument(help="JSON file containing PreviewOrderRequest.")],
443
+ profile: str = typer.Option("default", "--profile", "-p"),
444
+ json_output: bool = typer.Option(False, "--json", help="Print the full response as JSON."),
445
+ ) -> None:
446
+ """Preview a changed order request from a JSON file."""
447
+
448
+ async def command() -> None:
449
+ request = _load_model_file(request_json, PreviewOrderRequest, "PreviewOrderRequest")
450
+ async with ETradeClient(_settings(), profile=profile) as client:
451
+ response = await client.orders.preview_change(account_id_key, order_id, request)
452
+ if json_output:
453
+ _echo_json(response)
454
+ return
455
+ _echo_preview_order(response)
456
+
457
+ _handle(command())
458
+
459
+
460
+ @orders_app.command("place-change")
461
+ def place_change_order(
462
+ account_id_key: Annotated[str, typer.Argument(help="E*TRADE accountIdKey from accounts list.")],
463
+ order_id: Annotated[int, typer.Argument(help="Order ID to change.")],
464
+ request_json: Annotated[Path, typer.Argument(help="JSON file containing PlaceOrderRequest.")],
465
+ profile: str = typer.Option("default", "--profile", "-p"),
466
+ confirm_live_order: bool = typer.Option(False, "--confirm-live-order"),
467
+ json_output: bool = typer.Option(False, "--json", help="Print the full response as JSON."),
468
+ ) -> None:
469
+ """Place a changed order request from a JSON file."""
470
+
471
+ _require_live_order_confirmation(confirm_live_order)
472
+
473
+ async def command() -> None:
474
+ request = _load_model_file(request_json, PlaceOrderRequest, "PlaceOrderRequest")
475
+ async with ETradeClient(_settings(), profile=profile) as client:
476
+ response = await client.orders.place_change(account_id_key, order_id, request)
477
+ if json_output:
478
+ _echo_json(response)
479
+ return
480
+ _echo_place_order(response)
481
+
482
+ _handle(command())
483
+
484
+
485
+ @orders_app.command("cancel")
486
+ def cancel_order(
487
+ account_id_key: str = typer.Argument(..., help="E*TRADE accountIdKey from accounts list."),
488
+ order_id: int = typer.Argument(..., help="Order ID to cancel."),
489
+ profile: str = typer.Option("default", "--profile", "-p"),
490
+ confirm_live_order: bool = typer.Option(False, "--confirm-live-order"),
491
+ json_output: bool = typer.Option(False, "--json", help="Print the full response as JSON."),
492
+ ) -> None:
493
+ """Cancel an order."""
494
+
495
+ _require_live_order_confirmation(confirm_live_order)
496
+
497
+ async def command() -> None:
498
+ async with ETradeClient(_settings(), profile=profile) as client:
499
+ response = await client.orders.cancel(account_id_key, order_id)
500
+ if json_output:
501
+ _echo_json(response)
502
+ return
503
+ _echo_cancel_order(response)
504
+
505
+ _handle(command())
506
+
507
+
508
+ @orders_app.command("demo")
509
+ def orders_demo(
510
+ profile: str = typer.Option("default", "--profile", "-p"),
511
+ ) -> None:
512
+ """Run an interactive order preview/place/change/cancel demo."""
513
+
514
+ async def command() -> None:
515
+ settings = _settings()
516
+ async with ETradeClient(settings, profile=profile) as client:
517
+ accounts = await client.accounts.list()
518
+ account = _prompt_account(accounts)
519
+ account_id_key = account.account_id_key
520
+ typer.echo(f"Profile: {profile}")
521
+ typer.echo(f"Environment: {settings.environment.value}")
522
+ typer.echo(f"Selected accountIdKey: {account_id_key}")
523
+
524
+ scenario = _prompt_order_scenario()
525
+ builder = await _build_demo_order(client, account_id_key, scenario)
526
+ preview_request = builder.build_preview_request()
527
+ typer.echo("Generated preview request:")
528
+ _echo_json(preview_request.request_body())
529
+
530
+ preview = await client.orders.preview(account_id_key, preview_request)
531
+ typer.echo("Preview response:")
532
+ _echo_preview_order(preview)
533
+ preview_ids = _preview_ids(preview)
534
+ if not preview_ids:
535
+ typer.echo("No preview IDs were returned; skipping placement.")
536
+ return
537
+
538
+ if not _confirm_live_action("place this order", settings):
539
+ typer.echo("Order placement skipped.")
540
+ return
541
+
542
+ place_request = builder.build_place_request(preview_ids)
543
+ place = await client.orders.place(account_id_key, place_request)
544
+ typer.echo("Place response:")
545
+ _echo_place_order(place)
546
+ order_id = _first_order_id(place)
547
+ if order_id is None:
548
+ typer.echo("No order ID was returned; skipping change and cancel steps.")
549
+ return
550
+
551
+ if typer.confirm("Preview a limit/net price change for this order?", default=False):
552
+ new_price = _prompt_positive_decimal("New limit/net price")
553
+ _apply_change_price(builder, preview_request, new_price)
554
+ change_client_order_id = _new_demo_client_order_id()
555
+ builder.client_order_id(change_client_order_id)
556
+ typer.echo(f"Change client order ID: {change_client_order_id}")
557
+ change_preview_request = builder.build_change_preview_request(order_id)
558
+ typer.echo("Generated change preview request:")
559
+ _echo_json(change_preview_request.request_body())
560
+ change_preview = await client.orders.preview_change(
561
+ account_id_key, order_id, change_preview_request
562
+ )
563
+ typer.echo("Change preview response:")
564
+ _echo_preview_order(change_preview)
565
+ change_preview_ids = _preview_ids(change_preview)
566
+ if change_preview_ids and _confirm_live_action("place this order change", settings):
567
+ change_place = await client.orders.place_change(
568
+ account_id_key,
569
+ order_id,
570
+ builder.build_change_place_request(change_preview_ids, order_id),
571
+ )
572
+ typer.echo("Change place response:")
573
+ _echo_place_order(change_place)
574
+ changed_order_id = _first_order_id(change_place)
575
+ if changed_order_id is not None:
576
+ order_id = changed_order_id
577
+ typer.echo(f"Current order ID: {order_id}")
578
+ elif not change_preview_ids:
579
+ typer.echo("No change preview IDs were returned; skipping change placement.")
580
+ else:
581
+ typer.echo("Order change placement skipped.")
582
+
583
+ if typer.confirm("Cancel this order?", default=False):
584
+ if _confirm_live_action("cancel this order", settings):
585
+ cancel = await client.orders.cancel(account_id_key, order_id)
586
+ typer.echo("Cancel response:")
587
+ _echo_cancel_order(cancel)
588
+ else:
589
+ typer.echo("Order cancellation skipped.")
590
+
591
+ _handle(command())
592
+
593
+
594
+ @market_app.command("quote")
595
+ def market_quote(
596
+ symbol: str = typer.Argument(..., help="Equity, index, mutual fund, or option symbol."),
597
+ profile: str = typer.Option("default", "--profile", "-p"),
598
+ detail_flag: str | None = typer.Option(None, "--detail-flag"),
599
+ require_earnings_date: bool | None = typer.Option(
600
+ None, "--require-earnings-date/--no-require-earnings-date"
601
+ ),
602
+ skip_mini_options_check: bool | None = typer.Option(
603
+ None, "--skip-mini-options-check/--check-mini-options"
604
+ ),
605
+ json_output: bool = typer.Option(False, "--json", help="Print the full response as JSON."),
606
+ ) -> None:
607
+ """Show a market quote."""
608
+
609
+ async def command() -> None:
610
+ request = QuotesRequest(
611
+ detail_flag=detail_flag,
612
+ require_earnings_date=require_earnings_date,
613
+ skip_mini_options_check=skip_mini_options_check,
614
+ )
615
+ async with ETradeClient(_settings(), profile=profile) as client:
616
+ quote = await client.market.get_quote(symbol, request)
617
+ if json_output:
618
+ _echo_json(quote)
619
+ return
620
+ _echo_quote(quote)
621
+
622
+ _handle(command())
623
+
624
+
625
+ @market_app.command("quotes")
626
+ def market_quotes(
627
+ symbols: Annotated[list[str], typer.Argument(help="One or more market symbols.")],
628
+ profile: str = typer.Option("default", "--profile", "-p"),
629
+ detail_flag: str | None = typer.Option(None, "--detail-flag"),
630
+ override_symbol_count: bool | None = typer.Option(
631
+ None, "--override-symbol-count/--no-override-symbol-count"
632
+ ),
633
+ json_output: bool = typer.Option(False, "--json", help="Print the full response as JSON."),
634
+ ) -> None:
635
+ """Show market quotes for one or more symbols."""
636
+
637
+ async def command() -> None:
638
+ request = QuotesRequest(
639
+ detail_flag=detail_flag,
640
+ override_symbol_count=override_symbol_count,
641
+ )
642
+ async with ETradeClient(_settings(), profile=profile) as client:
643
+ response = await client.market.get_quotes(symbols, request)
644
+ if json_output:
645
+ _echo_json(response)
646
+ return
647
+ _echo_quotes(response)
648
+
649
+ _handle(command())
650
+
651
+
652
+ @market_app.command("lookup")
653
+ def market_lookup(
654
+ search: str = typer.Argument(..., help="Company name search text."),
655
+ profile: str = typer.Option("default", "--profile", "-p"),
656
+ json_output: bool = typer.Option(False, "--json", help="Print the full response as JSON."),
657
+ ) -> None:
658
+ """Look up products by company name."""
659
+
660
+ async def command() -> None:
661
+ async with ETradeClient(_settings(), profile=profile) as client:
662
+ response = await client.market.lookup_product(search)
663
+ if json_output:
664
+ _echo_json(response)
665
+ return
666
+ _echo_product_lookup(response)
667
+
668
+ _handle(command())
669
+
670
+
671
+ @market_app.command("option-expirations")
672
+ def market_option_expirations(
673
+ symbol: str = typer.Argument(..., help="Underlying market symbol."),
674
+ profile: str = typer.Option("default", "--profile", "-p"),
675
+ expiry_type: str | None = typer.Option(None, "--expiry-type"),
676
+ json_output: bool = typer.Option(False, "--json", help="Print the full response as JSON."),
677
+ ) -> None:
678
+ """Show option expiration dates for an underlying symbol."""
679
+
680
+ async def command() -> None:
681
+ request = OptionExpirationsRequest(expiry_type=expiry_type)
682
+ async with ETradeClient(_settings(), profile=profile) as client:
683
+ response = await client.market.get_option_expirations(symbol, request)
684
+ if json_output:
685
+ _echo_json(response)
686
+ return
687
+ _echo_option_expirations(response)
688
+
689
+ _handle(command())
690
+
691
+
692
+ @market_app.command("option-chain")
693
+ def market_option_chain(
694
+ symbol: str = typer.Argument(..., help="Underlying market symbol."),
695
+ profile: str = typer.Option("default", "--profile", "-p"),
696
+ expiry_year: int | None = typer.Option(None, "--expiry-year"),
697
+ expiry_month: int | None = typer.Option(None, "--expiry-month"),
698
+ expiry_day: int | None = typer.Option(None, "--expiry-day"),
699
+ strike_price_near: str | None = typer.Option(None, "--strike-price-near"),
700
+ no_of_strikes: int | None = typer.Option(None, "--no-of-strikes"),
701
+ chain_type: str | None = typer.Option(None, "--chain-type"),
702
+ option_category: str | None = typer.Option(None, "--option-category"),
703
+ price_type: str | None = typer.Option(None, "--price-type"),
704
+ include_weekly: bool | None = typer.Option(None, "--include-weekly/--exclude-weekly"),
705
+ skip_adjusted: bool | None = typer.Option(None, "--skip-adjusted/--include-adjusted"),
706
+ json_output: bool = typer.Option(False, "--json", help="Print the full response as JSON."),
707
+ ) -> None:
708
+ """Show an option chain for an underlying symbol."""
709
+
710
+ async def command() -> None:
711
+ request = OptionChainRequest(
712
+ expiry_year=expiry_year,
713
+ expiry_month=expiry_month,
714
+ expiry_day=expiry_day,
715
+ strike_price_near=Decimal(strike_price_near) if strike_price_near is not None else None,
716
+ no_of_strikes=no_of_strikes,
717
+ chain_type=chain_type,
718
+ option_category=option_category,
719
+ price_type=price_type,
720
+ include_weekly=include_weekly,
721
+ skip_adjusted=skip_adjusted,
722
+ )
723
+ async with ETradeClient(_settings(), profile=profile) as client:
724
+ response = await client.market.get_option_chain(symbol, request)
725
+ if json_output:
726
+ _echo_json(response)
727
+ return
728
+ _echo_option_chain(response)
729
+
730
+ _handle(command())
731
+
732
+
733
+ def _prompt_account(response: AccountListResponse) -> Account:
734
+ accounts = response.accounts
735
+ if not accounts:
736
+ typer.echo("No accounts found.")
737
+ raise typer.Exit(code=1)
738
+ choices = [(_account_label(account), account) for account in accounts]
739
+ return _prompt_menu("Select account", choices)
740
+
741
+
742
+ def _account_label(account: Account) -> str:
743
+ label = account.account_name or account.account_desc or "Account"
744
+ pieces = [label]
745
+ if account.account_type is not None:
746
+ pieces.append(account.account_type)
747
+ pieces.append(f"accountId {_mask_account_id(account.account_id)}")
748
+ pieces.append(f"accountIdKey {account.account_id_key}")
749
+ return " | ".join(pieces)
750
+
751
+
752
+ def _mask_account_id(account_id: str) -> str:
753
+ if len(account_id) <= 4:
754
+ return "*" * len(account_id)
755
+ return "*" * (len(account_id) - 4) + account_id[-4:]
756
+
757
+
758
+ def _prompt_order_scenario() -> str:
759
+ return _prompt_menu(
760
+ "Select order scenario",
761
+ [
762
+ ("Equity limit order", "equity"),
763
+ ("Single-leg option order", "single-option"),
764
+ ("Vertical call spread", "vertical"),
765
+ ("Three-leg call spread + short put", "three-leg"),
766
+ ("Iron condor", "iron-condor"),
767
+ ("Buy-write", "buy-write"),
768
+ ],
769
+ )
770
+
771
+
772
+ async def _build_demo_order(
773
+ client: ETradeClient, account_id_key: str, scenario: str
774
+ ) -> OrderBuilder:
775
+ builder = OrderBuilder.for_account(account_id_key).client_order_id(_prompt_client_order_id())
776
+ if scenario == "equity":
777
+ return builder.equity_limit(
778
+ _prompt_symbol(),
779
+ action=_prompt_menu(
780
+ "Equity action",
781
+ [
782
+ ("Buy", "BUY"),
783
+ ("Sell", "SELL"),
784
+ ("Sell short", "SELL_SHORT"),
785
+ ("Buy to cover", "BUY_TO_COVER"),
786
+ ],
787
+ ),
788
+ quantity=_prompt_positive_decimal("Quantity", "1"),
789
+ limit_price=_prompt_positive_decimal("Limit price"),
790
+ )
791
+
792
+ symbol = _prompt_symbol()
793
+ expiration = await _prompt_option_expiration(client, symbol)
794
+ chain = await _get_option_chain(client, symbol, expiration)
795
+
796
+ if scenario == "single-option":
797
+ option_action = _prompt_menu(
798
+ "Option action",
799
+ [
800
+ ("Long call", "long-call"),
801
+ ("Short call", "short-call"),
802
+ ("Long put", "long-put"),
803
+ ("Short put", "short-put"),
804
+ ],
805
+ )
806
+ option_side = _option_side(option_action)
807
+ strike, contract = _prompt_option_contract(chain, option_side, "Strike price")
808
+ quantity = _prompt_positive_decimal("Contracts", "1")
809
+ suggested = (
810
+ _contract_ask(contract) if option_action.startswith("long") else _contract_bid(contract)
811
+ )
812
+ limit_price = _prompt_limit_with_suggestion("Limit price", suggested)
813
+ builder.order_type("OPTN").with_symbol(symbol).with_expiration(expiration).limit_price(
814
+ limit_price
815
+ )
816
+ if option_action == "long-call":
817
+ return builder.add_long_call(strike, quantity)
818
+ if option_action == "short-call":
819
+ return builder.add_short_call(strike, quantity)
820
+ if option_action == "long-put":
821
+ return builder.add_long_put(strike, quantity)
822
+ return builder.add_short_put(strike, quantity)
823
+
824
+ if scenario == "vertical":
825
+ quantity = _prompt_positive_decimal("Contracts", "1")
826
+ long_strike, long_call = _prompt_option_contract(chain, "CALL", "Long call strike")
827
+ short_strike, short_call = _prompt_option_contract(chain, "CALL", "Short call strike")
828
+ signed_estimate = _subtract(_contract_ask(long_call), _contract_bid(short_call))
829
+ price_type, price = _prompt_net_price(signed_estimate, "NET_DEBIT")
830
+ builder.order_type("SPREADS").with_symbol(symbol).with_expiration(expiration)
831
+ _apply_net_price(builder, price_type, price)
832
+ return builder.add_long_call(long_strike, quantity).add_short_call(short_strike, quantity)
833
+
834
+ if scenario == "three-leg":
835
+ quantity = _prompt_positive_decimal("Contracts", "1")
836
+ long_call_strike, long_call = _prompt_option_contract(chain, "CALL", "Long call strike")
837
+ short_call_strike, short_call = _prompt_option_contract(chain, "CALL", "Short call strike")
838
+ short_put_strike, short_put = _prompt_option_contract(chain, "PUT", "Short put strike")
839
+ signed_estimate = _subtract(
840
+ _subtract(_contract_ask(long_call), _contract_bid(short_call)),
841
+ _contract_bid(short_put),
842
+ )
843
+ price_type, price = _prompt_net_price(signed_estimate, "NET_DEBIT")
844
+ builder.order_type("SPREADS").with_symbol(symbol).with_expiration(expiration)
845
+ _apply_net_price(builder, price_type, price)
846
+ return (
847
+ builder.add_long_call(long_call_strike, quantity)
848
+ .add_short_call(short_call_strike, quantity)
849
+ .add_short_put(short_put_strike, quantity)
850
+ )
851
+
852
+ if scenario == "iron-condor":
853
+ quantity = _prompt_positive_decimal("Contracts", "1")
854
+ short_put_strike, short_put = _prompt_option_contract(chain, "PUT", "Short put strike")
855
+ long_put_strike, long_put = _prompt_option_contract(chain, "PUT", "Long put strike")
856
+ short_call_strike, short_call = _prompt_option_contract(chain, "CALL", "Short call strike")
857
+ long_call_strike, long_call = _prompt_option_contract(chain, "CALL", "Long call strike")
858
+ signed_estimate = _add(
859
+ _subtract(_contract_ask(long_put), _contract_bid(short_put)),
860
+ _subtract(_contract_ask(long_call), _contract_bid(short_call)),
861
+ )
862
+ price_type, price = _prompt_net_price(signed_estimate, "NET_CREDIT")
863
+ builder.order_type("SPREADS").with_symbol(symbol).with_expiration(expiration)
864
+ _apply_net_price(builder, price_type, price)
865
+ return (
866
+ builder.add_short_put(short_put_strike, quantity)
867
+ .add_long_put(long_put_strike, quantity)
868
+ .add_short_call(short_call_strike, quantity)
869
+ .add_long_call(long_call_strike, quantity)
870
+ )
871
+
872
+ if scenario == "buy-write":
873
+ stock_quantity, call_quantity = _prompt_buy_write_quantities()
874
+ call_strike, call = _prompt_option_contract(chain, "CALL", "Call strike")
875
+ quote = await _get_quote(client, symbol)
876
+ suggested = _buy_write_estimate(quote, stock_quantity, call, call_quantity)
877
+ net_debit = _prompt_limit_with_suggestion("Net debit", suggested)
878
+ return builder.buy_write(
879
+ symbol,
880
+ expiration=expiration,
881
+ stock_quantity=stock_quantity,
882
+ call_quantity=call_quantity,
883
+ call_strike=call_strike,
884
+ net_debit=net_debit,
885
+ )
886
+ raise typer.BadParameter("Unknown order scenario")
887
+
888
+
889
+ async def _prompt_option_expiration(client: ETradeClient, symbol: str) -> date:
890
+ expirations = await _get_option_expirations(client, symbol)
891
+ if expirations:
892
+ return _prompt_expiration_choice(expirations)
893
+ fallback = _next_third_friday()
894
+ typer.echo(f"No option expirations found; using third-Friday fallback {fallback}.")
895
+ return _prompt_expiration(fallback)
896
+
897
+
898
+ async def _get_option_expirations(client: ETradeClient, symbol: str) -> list[OptionExpiration]:
899
+ try:
900
+ response = await client.market.get_option_expirations(
901
+ symbol, OptionExpirationsRequest(expiry_type="ALL")
902
+ )
903
+ except ETradeError as exc:
904
+ typer.echo(f"Could not fetch option expirations: {exc}")
905
+ return []
906
+ today = date.today()
907
+ expirations: list[OptionExpiration] = []
908
+ for expiration in response.expiration_dates:
909
+ if expiration.year is None or expiration.month is None or expiration.day is None:
910
+ continue
911
+ try:
912
+ expiration_date = date(expiration.year, expiration.month, expiration.day)
913
+ except ValueError:
914
+ continue
915
+ if expiration_date >= today:
916
+ expirations.append(expiration)
917
+ return sorted(expirations, key=_expiration_sort_key)
918
+
919
+
920
+ def _prompt_expiration_choice(expirations: list[OptionExpiration]) -> date:
921
+ default_index = _default_expiration_index(expirations)
922
+ page_size = 10
923
+ page = default_index // page_size
924
+ total_pages = (len(expirations) + page_size - 1) // page_size
925
+ while True:
926
+ start = page * page_size
927
+ end = min(start + page_size, len(expirations))
928
+ visible_default = default_index if start <= default_index < end else start
929
+ typer.echo(f"Available expirations (Page {page + 1} of {total_pages}):")
930
+ for index in range(start, end):
931
+ expiration = expirations[index]
932
+ expiration_date = _expiration_date(expiration)
933
+ label = expiration_date.isoformat() if expiration_date is not None else "Expiration"
934
+ suffix = f" {expiration.expiry_type}" if expiration.expiry_type else ""
935
+ default_marker = " [default]" if index == visible_default else ""
936
+ typer.echo(f" {index + 1}. {label}{suffix}{default_marker}")
937
+ choices = [f"a numbered expiration from the list ({start + 1}-{end})"]
938
+ if page + 1 < total_pages:
939
+ typer.echo(" n. Next page")
940
+ choices.append("n for Next")
941
+ if page > 0:
942
+ typer.echo(" p. Previous page")
943
+ choices.append("p for Previous")
944
+ selected = typer.prompt("Expiration", default=str(visible_default + 1)).strip().lower()
945
+ if selected == "n" and page + 1 < total_pages:
946
+ page += 1
947
+ continue
948
+ if selected == "p" and page > 0:
949
+ page -= 1
950
+ continue
951
+ try:
952
+ index = int(selected)
953
+ except ValueError:
954
+ typer.echo(f"Enter {', '.join(choices)}.")
955
+ continue
956
+ if start + 1 <= index <= end:
957
+ expiration_date = _expiration_date(expirations[index - 1])
958
+ if expiration_date is not None:
959
+ return expiration_date
960
+ typer.echo(f"Enter {', '.join(choices)}.")
961
+
962
+
963
+ def _default_expiration_index(expirations: list[OptionExpiration]) -> int:
964
+ for index, expiration in enumerate(expirations):
965
+ if (expiration.expiry_type or "").upper() == "MONTHLY":
966
+ return index
967
+ return 0
968
+
969
+
970
+ def _expiration_sort_key(expiration: OptionExpiration) -> tuple[int, int, int]:
971
+ return (expiration.year or 9999, expiration.month or 12, expiration.day or 31)
972
+
973
+
974
+ def _expiration_date(expiration: OptionExpiration) -> date | None:
975
+ if expiration.year is None or expiration.month is None or expiration.day is None:
976
+ return None
977
+ try:
978
+ return date(expiration.year, expiration.month, expiration.day)
979
+ except ValueError:
980
+ return None
981
+
982
+
983
+ def _next_third_friday() -> date:
984
+ today = date.today()
985
+ year = today.year
986
+ month = today.month
987
+ while True:
988
+ candidate = _third_friday(year, month)
989
+ if candidate >= today:
990
+ return candidate
991
+ month += 1
992
+ if month > 12:
993
+ month = 1
994
+ year += 1
995
+
996
+
997
+ def _third_friday(year: int, month: int) -> date:
998
+ first_day = date(year, month, 1)
999
+ days_until_friday = (4 - first_day.weekday()) % 7
1000
+ first_friday = first_day.replace(day=1 + days_until_friday)
1001
+ return first_friday.replace(day=first_friday.day + 14)
1002
+
1003
+
1004
+ async def _get_option_chain(
1005
+ client: ETradeClient, symbol: str, expiration: date
1006
+ ) -> OptionChainResponse | None:
1007
+ try:
1008
+ return await client.market.get_option_chain(
1009
+ symbol,
1010
+ OptionChainRequest(
1011
+ expiry_year=expiration.year,
1012
+ expiry_month=expiration.month,
1013
+ expiry_day=expiration.day,
1014
+ chain_type="CALLPUT",
1015
+ price_type="ALL",
1016
+ ),
1017
+ )
1018
+ except ETradeError as exc:
1019
+ typer.echo(f"Could not fetch option chain: {exc}")
1020
+ return None
1021
+
1022
+
1023
+ async def _get_quote(client: ETradeClient, symbol: str) -> Quote | None:
1024
+ try:
1025
+ return await client.market.get_quote(symbol)
1026
+ except ETradeError as exc:
1027
+ typer.echo(f"Could not fetch quote: {exc}")
1028
+ return None
1029
+
1030
+
1031
+ def _find_option_contract(
1032
+ chain: OptionChainResponse | None, call_put: str, strike: Decimal
1033
+ ) -> OptionContract | None:
1034
+ if chain is None:
1035
+ return None
1036
+ for pair in chain.option_pairs:
1037
+ contract = pair.call if call_put == "CALL" else pair.put
1038
+ if contract is not None and contract.strike_price == strike:
1039
+ return contract
1040
+ typer.echo(f"No {call_put.lower()} quote found for strike {strike}; enter price manually.")
1041
+ return None
1042
+
1043
+
1044
+ def _prompt_option_contract(
1045
+ chain: OptionChainResponse | None, call_put: str, label: str
1046
+ ) -> tuple[Decimal, OptionContract | None]:
1047
+ contracts = _option_contracts(chain, call_put)
1048
+ if not contracts:
1049
+ strike = _prompt_positive_decimal(label)
1050
+ return strike, _find_option_contract(chain, call_put, strike)
1051
+
1052
+ visible_contracts = _visible_option_contracts(contracts, chain.near_price if chain else None)
1053
+ default_index = _default_contract_index(visible_contracts, chain.near_price if chain else None)
1054
+ typer.echo(f"Available {call_put.lower()} strikes:")
1055
+ for index, contract in enumerate(visible_contracts, start=1):
1056
+ strike = _format_decimal(contract.strike_price) or "Strike"
1057
+ default_marker = " [default]" if index - 1 == default_index else ""
1058
+ bid = _format_decimal(contract.bid) or "-"
1059
+ ask = _format_decimal(contract.ask) or "-"
1060
+ typer.echo(f" {index}. {strike} Bid {bid} Ask {ask}{default_marker}")
1061
+
1062
+ while True:
1063
+ selected = typer.prompt(label, default=str(default_index + 1)).strip()
1064
+ try:
1065
+ index = int(selected)
1066
+ except ValueError:
1067
+ typer.echo("Enter a numbered strike.")
1068
+ continue
1069
+ if 1 <= index <= len(visible_contracts):
1070
+ contract = visible_contracts[index - 1]
1071
+ if contract.strike_price is not None:
1072
+ return contract.strike_price, contract
1073
+ typer.echo("Enter a numbered strike from the list.")
1074
+
1075
+
1076
+ def _option_contracts(chain: OptionChainResponse | None, call_put: str) -> list[OptionContract]:
1077
+ if chain is None:
1078
+ return []
1079
+ contracts_by_strike: dict[Decimal, OptionContract] = {}
1080
+ for pair in chain.option_pairs:
1081
+ contract = pair.call if call_put == "CALL" else pair.put
1082
+ if contract is None or contract.strike_price is None:
1083
+ continue
1084
+ contracts_by_strike.setdefault(contract.strike_price, contract)
1085
+ return [contracts_by_strike[strike] for strike in sorted(contracts_by_strike)]
1086
+
1087
+
1088
+ def _visible_option_contracts(
1089
+ contracts: list[OptionContract], near_price: Decimal | None
1090
+ ) -> list[OptionContract]:
1091
+ if len(contracts) <= 11 or near_price is None:
1092
+ return contracts
1093
+
1094
+ at_the_money_index = _default_contract_index(contracts, near_price)
1095
+ start = max(0, at_the_money_index - 5)
1096
+ end = min(len(contracts), at_the_money_index + 6)
1097
+ return contracts[start:end]
1098
+
1099
+
1100
+ def _default_contract_index(contracts: list[OptionContract], near_price: Decimal | None) -> int:
1101
+ if not contracts or near_price is None:
1102
+ return 0
1103
+ return min(
1104
+ range(len(contracts)),
1105
+ key=lambda index: abs((contracts[index].strike_price or near_price) - near_price),
1106
+ )
1107
+
1108
+
1109
+ def _contract_bid(contract: OptionContract | None) -> Decimal | None:
1110
+ return contract.bid if contract is not None else None
1111
+
1112
+
1113
+ def _contract_ask(contract: OptionContract | None) -> Decimal | None:
1114
+ return contract.ask if contract is not None else None
1115
+
1116
+
1117
+ def _option_side(option_action: str) -> str:
1118
+ return "PUT" if option_action.endswith("put") else "CALL"
1119
+
1120
+
1121
+ def _prompt_limit_with_suggestion(label: str, suggested: Decimal | None) -> Decimal:
1122
+ default = _format_decimal(suggested) if suggested is not None and suggested > 0 else None
1123
+ if suggested is not None and suggested > 0:
1124
+ typer.echo(f"Suggested {label.lower()}: {suggested}")
1125
+ return _prompt_positive_decimal(label, default)
1126
+
1127
+
1128
+ def _prompt_net_price(
1129
+ signed_estimate: Decimal | None, fallback_price_type: str
1130
+ ) -> tuple[str, Decimal]:
1131
+ default_price_type = fallback_price_type
1132
+ default_price: Decimal | None = None
1133
+ if signed_estimate is not None:
1134
+ if signed_estimate < 0:
1135
+ default_price_type = "NET_CREDIT"
1136
+ default_price = abs(signed_estimate)
1137
+ typer.echo(f"Estimated net credit: {default_price}")
1138
+ elif signed_estimate > 0:
1139
+ default_price_type = "NET_DEBIT"
1140
+ default_price = signed_estimate
1141
+ typer.echo(f"Estimated net debit: {default_price}")
1142
+ else:
1143
+ typer.echo("Estimated net even: 0")
1144
+ price_type = _prompt_net_price_type(default_price_type)
1145
+ label = "Net credit" if price_type == "NET_CREDIT" else "Net debit"
1146
+ default = _format_decimal(default_price) if default_price is not None else None
1147
+ price = _prompt_positive_decimal(label, default)
1148
+ return price_type, price
1149
+
1150
+
1151
+ def _prompt_net_price_type(default: str) -> str:
1152
+ while True:
1153
+ value = typer.prompt("Price type", default=default).strip().upper()
1154
+ if value in {"NET_DEBIT", "NET_CREDIT"}:
1155
+ return value
1156
+ typer.echo("Price type must be NET_DEBIT or NET_CREDIT.")
1157
+
1158
+
1159
+ def _apply_net_price(builder: OrderBuilder, price_type: str, price: Decimal) -> None:
1160
+ if price_type == "NET_CREDIT":
1161
+ builder.net_credit(price)
1162
+ else:
1163
+ builder.net_debit(price)
1164
+
1165
+
1166
+ def _add(left: Decimal | None, right: Decimal | None) -> Decimal | None:
1167
+ if left is None or right is None:
1168
+ return None
1169
+ return left + right
1170
+
1171
+
1172
+ def _subtract(left: Decimal | None, right: Decimal | None) -> Decimal | None:
1173
+ if left is None or right is None:
1174
+ return None
1175
+ return left - right
1176
+
1177
+
1178
+ def _prompt_buy_write_quantities() -> tuple[Decimal, Decimal]:
1179
+ while True:
1180
+ stock_quantity = _prompt_positive_decimal("Stock quantity", "100")
1181
+ call_quantity = _prompt_positive_decimal("Call contracts", "1")
1182
+ if _is_covered_call_ratio(stock_quantity, call_quantity):
1183
+ return stock_quantity, call_quantity
1184
+ typer.echo("Buy-write quantity must be 100 shares for each short call contract.")
1185
+
1186
+
1187
+ def _is_covered_call_ratio(stock_quantity: Decimal, call_quantity: Decimal) -> bool:
1188
+ return stock_quantity == call_quantity * Decimal("100")
1189
+
1190
+
1191
+ def _buy_write_estimate(
1192
+ quote: Quote | None,
1193
+ stock_quantity: Decimal,
1194
+ call: OptionContract | None,
1195
+ call_quantity: Decimal,
1196
+ ) -> Decimal | None:
1197
+ stock_ask = _quote_ask(quote)
1198
+ call_bid = _contract_bid(call)
1199
+ if stock_ask is None or call_bid is None or stock_quantity <= 0:
1200
+ return None
1201
+ total_debit = stock_ask * stock_quantity - call_bid * call_quantity * Decimal("100")
1202
+ return total_debit / stock_quantity
1203
+
1204
+
1205
+ def _quote_ask(quote: Quote | None) -> Decimal | None:
1206
+ if quote is None:
1207
+ return None
1208
+ details = quote.all or quote.intraday or quote.fundamental or quote.option or quote.week52
1209
+ if details is None:
1210
+ return None
1211
+ return details.ask
1212
+
1213
+
1214
+ def _format_decimal(value: Decimal | None) -> str | None:
1215
+ if value is None:
1216
+ return None
1217
+ return format(value.normalize(), "f")
1218
+
1219
+
1220
+ def _prompt_client_order_id() -> str:
1221
+ default = _new_demo_client_order_id()
1222
+ while True:
1223
+ value = _prompt_nonempty("Client order ID", default=default)
1224
+ if value.isalnum() and len(value) <= 20:
1225
+ return value
1226
+ typer.echo("Client order ID must be 1-20 alphanumeric characters.")
1227
+
1228
+
1229
+ def _new_demo_client_order_id() -> str:
1230
+ return f"epdemo{uuid4().hex[:8]}"
1231
+
1232
+
1233
+ def _prompt_symbol() -> str:
1234
+ return _prompt_nonempty("Symbol").upper()
1235
+
1236
+
1237
+ def _prompt_expiration(default: date | None = None) -> date:
1238
+ return date(
1239
+ _prompt_int("Expiration year", default=default.year if default is not None else None),
1240
+ _prompt_int(
1241
+ "Expiration month",
1242
+ minimum=1,
1243
+ maximum=12,
1244
+ default=default.month if default is not None else None,
1245
+ ),
1246
+ _prompt_int(
1247
+ "Expiration day",
1248
+ minimum=1,
1249
+ maximum=31,
1250
+ default=default.day if default is not None else None,
1251
+ ),
1252
+ )
1253
+
1254
+
1255
+ def _prompt_menu(title: str, choices: list[tuple[str, TChoice]]) -> TChoice:
1256
+ typer.echo(title)
1257
+ for index, (label, _) in enumerate(choices, start=1):
1258
+ typer.echo(f" {index}. {label}")
1259
+ while True:
1260
+ selected = typer.prompt("Choice")
1261
+ try:
1262
+ index = int(selected)
1263
+ except ValueError:
1264
+ typer.echo("Enter a numbered choice.")
1265
+ continue
1266
+ if 1 <= index <= len(choices):
1267
+ return choices[index - 1][1]
1268
+ typer.echo("Enter a numbered choice from the menu.")
1269
+
1270
+
1271
+ def _prompt_nonempty(label: str, default: str | None = None) -> str:
1272
+ while True:
1273
+ value = typer.prompt(label, default=default).strip()
1274
+ if value:
1275
+ return value
1276
+ typer.echo(f"{label} is required.")
1277
+
1278
+
1279
+ def _prompt_int(
1280
+ label: str,
1281
+ minimum: int | None = None,
1282
+ maximum: int | None = None,
1283
+ default: int | None = None,
1284
+ ) -> int:
1285
+ while True:
1286
+ raw = typer.prompt(label, default=str(default) if default is not None else None).strip()
1287
+ try:
1288
+ value = int(raw)
1289
+ except ValueError:
1290
+ typer.echo(f"{label} must be an integer.")
1291
+ continue
1292
+ if minimum is not None and value < minimum:
1293
+ typer.echo(f"{label} must be at least {minimum}.")
1294
+ continue
1295
+ if maximum is not None and value > maximum:
1296
+ typer.echo(f"{label} must be at most {maximum}.")
1297
+ continue
1298
+ return value
1299
+
1300
+
1301
+ def _prompt_positive_decimal(label: str, default: str | None = None) -> Decimal:
1302
+ while True:
1303
+ raw = typer.prompt(label, default=default).strip()
1304
+ try:
1305
+ value = Decimal(raw)
1306
+ except InvalidOperation:
1307
+ typer.echo(f"{label} must be a decimal number.")
1308
+ continue
1309
+ if value <= 0:
1310
+ typer.echo(f"{label} must be greater than zero.")
1311
+ continue
1312
+ return value
1313
+
1314
+
1315
+ def _preview_ids(response: PreviewOrderResponse) -> list[int]:
1316
+ return [item.preview_id for item in response.preview_ids]
1317
+
1318
+
1319
+ def _first_order_id(response: PlaceOrderResponse) -> int | None:
1320
+ if not response.order_ids:
1321
+ return None
1322
+ return response.order_ids[0].order_id
1323
+
1324
+
1325
+ def _apply_change_price(
1326
+ builder: OrderBuilder, preview_request: PreviewOrderRequest, new_price: Decimal
1327
+ ) -> None:
1328
+ price_type = preview_request.orders[0].price_type if preview_request.orders else "LIMIT"
1329
+ if price_type == "NET_CREDIT":
1330
+ builder.net_credit(new_price)
1331
+ elif price_type == "NET_DEBIT":
1332
+ builder.net_debit(new_price)
1333
+ else:
1334
+ builder.limit_price(new_price)
1335
+
1336
+
1337
+ def _confirm_live_action(action: str, settings: ETradeSettings) -> bool:
1338
+ if settings.environment.value == "production":
1339
+ typer.echo("Production environment: this action affects a real brokerage account.")
1340
+ return typer.confirm(f"Confirm that you want to {action}?", default=False)
1341
+
1342
+
1343
+ def _load_model_file(path: Path, model_type: type[TModel], envelope: str) -> TModel:
1344
+ try:
1345
+ payload: Any = json.loads(path.read_text(encoding="utf-8"))
1346
+ except (OSError, json.JSONDecodeError):
1347
+ raise typer.BadParameter("Expected a readable JSON request file") from None
1348
+ if isinstance(payload, dict):
1349
+ payload_mapping = cast(dict[str, Any], payload)
1350
+ if envelope in payload_mapping:
1351
+ payload = payload_mapping[envelope]
1352
+ try:
1353
+ return model_type.model_validate(payload)
1354
+ except ValueError as exc:
1355
+ raise typer.BadParameter("Invalid order request JSON") from exc
1356
+
1357
+
1358
+ def _require_live_order_confirmation(confirmed: bool) -> None:
1359
+ if not confirmed:
1360
+ typer.echo("Order mutation commands require --confirm-live-order.", err=True)
1361
+ raise typer.Exit(code=1)
1362
+
1363
+
1364
+ def _echo_json(value: BaseModel | dict[str, Any]) -> None:
1365
+ if isinstance(value, BaseModel):
1366
+ typer.echo(value.model_dump_json(by_alias=True, exclude_none=True, indent=2))
1367
+ return
1368
+ typer.echo(json.dumps(value, indent=2))
1369
+
1370
+
1371
+ def _echo_account_list(response: AccountListResponse) -> None:
1372
+ if not response.accounts:
1373
+ typer.echo("No accounts found.")
1374
+ return
1375
+ for account in response.accounts:
1376
+ label = account.account_name or account.account_desc or "Account"
1377
+ typer.echo(label)
1378
+ typer.echo(f" Account ID: {account.account_id}")
1379
+ typer.echo(f" Account ID key: {account.account_id_key}")
1380
+ _echo_optional(" Type", account.account_type)
1381
+ _echo_optional(" Status", account.account_status)
1382
+
1383
+
1384
+ def _echo_account_balance(response: AccountBalanceResponse) -> None:
1385
+ typer.echo(f"Account ID: {response.account_id}")
1386
+ _echo_optional("Account type", response.account_type)
1387
+ _echo_optional("Description", response.account_description or response.account_desc)
1388
+ _echo_optional("Day trader status", response.day_trader_status)
1389
+ if response.computed_balance is not None:
1390
+ computed = response.computed_balance
1391
+ _echo_optional_decimal("Net cash", computed.net_cash)
1392
+ _echo_optional_decimal("Cash balance", computed.cash_balance)
1393
+ _echo_optional_decimal("Cash buying power", computed.cash_buying_power)
1394
+ _echo_optional_decimal("Margin buying power", computed.margin_buying_power)
1395
+ _echo_optional_decimal(
1396
+ "Total available for withdrawal", computed.total_available_for_withdrawal
1397
+ )
1398
+ if response.cash is not None:
1399
+ _echo_optional_decimal("Money market balance", response.cash.money_market_balance)
1400
+ if response.margin is not None:
1401
+ _echo_optional_decimal(
1402
+ "Day-trade margin open order reserve",
1403
+ response.margin.dt_margin_open_order_reserve,
1404
+ )
1405
+
1406
+
1407
+ def _echo_portfolio(response: PortfolioResponse) -> None:
1408
+ if response.totals is not None:
1409
+ _echo_optional_decimal("Total market value", response.totals.total_market_value)
1410
+ _echo_optional_decimal("Total gain/loss", response.totals.total_gain_loss)
1411
+ positions = [
1412
+ position
1413
+ for account_portfolio in response.account_portfolios
1414
+ for position in account_portfolio.positions
1415
+ ]
1416
+ if not positions:
1417
+ typer.echo("No positions found.")
1418
+ return
1419
+ for position in positions:
1420
+ symbol = position.product.symbol if position.product is not None else None
1421
+ typer.echo(symbol or position.symbol_description or "Position")
1422
+ _echo_optional(" Position ID", position.position_id)
1423
+ _echo_optional(" Type", position.position_type)
1424
+ _echo_optional_decimal(" Quantity", position.quantity)
1425
+ _echo_optional_decimal(" Market value", position.market_value)
1426
+ _echo_optional_decimal(" Total gain", position.total_gain)
1427
+
1428
+
1429
+ def _echo_transactions(response: TransactionsResponse) -> None:
1430
+ if not response.transactions:
1431
+ typer.echo("No transactions found.")
1432
+ return
1433
+ for transaction in response.transactions:
1434
+ typer.echo(transaction.description or transaction.transaction_id or "Transaction")
1435
+ _echo_optional(" Transaction ID", transaction.transaction_id)
1436
+ _echo_optional(" Date", transaction.transaction_date)
1437
+ _echo_optional_decimal(" Amount", transaction.amount)
1438
+ _echo_optional(" Type", transaction.transaction_type)
1439
+
1440
+
1441
+ def _echo_transaction_details(response: TransactionDetailsResponse) -> None:
1442
+ transaction = response.transaction
1443
+ typer.echo(transaction.description or transaction.transaction_id or "Transaction")
1444
+ _echo_optional("Transaction ID", transaction.transaction_id)
1445
+ _echo_optional("Account ID", transaction.account_id)
1446
+ _echo_optional("Date", transaction.transaction_date)
1447
+ _echo_optional_decimal("Amount", transaction.amount)
1448
+ if transaction.brokerage is not None:
1449
+ _echo_optional("Brokerage type", transaction.brokerage.transaction_type)
1450
+ if transaction.brokerage.product is not None:
1451
+ _echo_optional("Symbol", transaction.brokerage.product.symbol)
1452
+ _echo_optional_decimal("Quantity", transaction.brokerage.quantity)
1453
+ _echo_optional_decimal("Price", transaction.brokerage.price)
1454
+
1455
+
1456
+ def _echo_alerts(response: AlertsResponse) -> None:
1457
+ _echo_optional("Total alerts", response.total_alerts)
1458
+ if not response.alerts:
1459
+ typer.echo("No alerts found.")
1460
+ return
1461
+ for alert in response.alerts:
1462
+ typer.echo(alert.subject or f"Alert {alert.id}")
1463
+ _echo_optional(" Alert ID", alert.id)
1464
+ _echo_optional(" Created", alert.create_time)
1465
+ _echo_optional(" Status", alert.status)
1466
+
1467
+
1468
+ def _echo_alert_details(response: AlertDetailsResponse) -> None:
1469
+ typer.echo(response.subject or f"Alert {response.id}")
1470
+ _echo_optional("Alert ID", response.id)
1471
+ _echo_optional("Created", response.create_time)
1472
+ _echo_optional("Status", response.status)
1473
+ _echo_optional("Symbol", response.symbol)
1474
+ _echo_optional("Message", response.msg_text)
1475
+ _echo_optional("Read time", response.read_time)
1476
+ _echo_optional("Delete time", response.delete_time)
1477
+ _echo_optional("Next", response.next)
1478
+ _echo_optional("Previous", response.prev)
1479
+
1480
+
1481
+ def _echo_delete_alerts(response: DeleteAlertsResponse) -> None:
1482
+ _echo_optional("Result", response.result)
1483
+ if response.failed_alerts is not None and response.failed_alerts.alert_ids:
1484
+ typer.echo(
1485
+ "Failed alert IDs: " + ", ".join(str(item) for item in response.failed_alerts.alert_ids)
1486
+ )
1487
+
1488
+
1489
+ def _echo_orders(response: OrdersResponse) -> None:
1490
+ if not response.orders:
1491
+ typer.echo("No orders found.")
1492
+ return
1493
+ for order in response.orders:
1494
+ typer.echo(f"Order ID: {order.order_id}")
1495
+ _echo_optional(" Type", order.order_type)
1496
+ _echo_optional_decimal(" Total value", order.total_order_value)
1497
+ _echo_optional_decimal(" Commission", order.total_commission)
1498
+ if order.order_details:
1499
+ detail = order.order_details[0]
1500
+ _echo_optional(" Status", detail.status)
1501
+ _echo_optional(" Term", detail.order_term)
1502
+ _echo_optional(" Price type", detail.price_type)
1503
+ if detail.instruments and detail.instruments[0].product is not None:
1504
+ _echo_optional(" Symbol", detail.instruments[0].product.symbol)
1505
+
1506
+
1507
+ def _echo_preview_order(response: PreviewOrderResponse) -> None:
1508
+ typer.echo(response.order_type or "Preview order")
1509
+ _echo_optional_decimal("Total order value", response.total_order_value)
1510
+ if response.preview_ids:
1511
+ typer.echo(
1512
+ "Preview IDs: " + ", ".join(str(item.preview_id) for item in response.preview_ids)
1513
+ )
1514
+ for order in response.orders:
1515
+ _echo_optional("Price type", order.price_type)
1516
+ _echo_optional_decimal("Limit price", order.limit_price)
1517
+ _echo_optional_decimal("Estimated total", order.estimated_total_amount)
1518
+ if order.messages is not None:
1519
+ for message in order.messages.messages:
1520
+ _echo_optional("Message", message.description)
1521
+
1522
+
1523
+ def _echo_place_order(response: PlaceOrderResponse) -> None:
1524
+ typer.echo(response.order_type or "Placed order")
1525
+ if response.order_ids:
1526
+ typer.echo("Order IDs: " + ", ".join(str(item.order_id) for item in response.order_ids))
1527
+ for order in response.orders:
1528
+ _echo_optional("Price type", order.price_type)
1529
+ _echo_optional_decimal("Limit price", order.limit_price)
1530
+ _echo_optional_decimal("Estimated total", order.estimated_total_amount)
1531
+ if order.messages is not None:
1532
+ for message in order.messages.messages:
1533
+ _echo_optional("Message", message.description)
1534
+
1535
+
1536
+ def _echo_cancel_order(response: CancelOrderResponse) -> None:
1537
+ _echo_optional("Account ID", response.account_id)
1538
+ _echo_optional("Order ID", response.order_id)
1539
+ _echo_optional("Cancel time", response.cancel_time)
1540
+ if response.messages is not None:
1541
+ for message in response.messages.messages:
1542
+ _echo_optional("Message", message.description)
1543
+
1544
+
1545
+ def _echo_quote(quote: Quote) -> None:
1546
+ symbol = quote.product.symbol if quote.product is not None else None
1547
+ details = quote.all or quote.intraday or quote.fundamental or quote.option or quote.week52
1548
+ typer.echo(symbol or "Quote")
1549
+ _echo_optional(" Status", quote.quote_status)
1550
+ if details is not None:
1551
+ _echo_optional(" Company", details.company_name or details.symbol_description)
1552
+ _echo_optional_decimal(" Last trade", details.last_trade)
1553
+ _echo_optional_decimal(" Bid", details.bid)
1554
+ _echo_optional_decimal(" Ask", details.ask)
1555
+ _echo_optional_decimal(" Change", details.change_close)
1556
+ _echo_optional(" Volume", details.total_volume)
1557
+
1558
+
1559
+ def _echo_quotes(response: QuotesResponse) -> None:
1560
+ if not response.quotes:
1561
+ typer.echo("No quotes found.")
1562
+ return
1563
+ for quote in response.quotes:
1564
+ _echo_quote(quote)
1565
+
1566
+
1567
+ def _echo_product_lookup(response: ProductLookupResponse) -> None:
1568
+ if not response.products:
1569
+ typer.echo("No products found.")
1570
+ return
1571
+ for product in response.products:
1572
+ typer.echo(product.symbol or "Product")
1573
+ _echo_optional(" Description", product.description)
1574
+ _echo_optional(" Type", product.type)
1575
+
1576
+
1577
+ def _echo_option_expirations(response: OptionExpirationsResponse) -> None:
1578
+ if not response.expiration_dates:
1579
+ typer.echo("No option expirations found.")
1580
+ return
1581
+ for expiration in response.expiration_dates:
1582
+ if expiration.year is None or expiration.month is None or expiration.day is None:
1583
+ typer.echo("Expiration")
1584
+ else:
1585
+ typer.echo(f"{expiration.year:04d}-{expiration.month:02d}-{expiration.day:02d}")
1586
+ _echo_optional(" Type", expiration.expiry_type)
1587
+
1588
+
1589
+ def _echo_option_chain(response: OptionChainResponse) -> None:
1590
+ _echo_optional("Quote type", response.quote_type)
1591
+ _echo_optional_decimal("Near price", response.near_price)
1592
+ if not response.option_pairs:
1593
+ typer.echo("No option pairs found.")
1594
+ return
1595
+ for pair in response.option_pairs:
1596
+ if pair.call is not None:
1597
+ typer.echo(pair.call.display_symbol or pair.call.osi_key or "Call")
1598
+ _echo_optional_decimal(" Strike", pair.call.strike_price)
1599
+ _echo_optional_decimal(" Bid", pair.call.bid)
1600
+ _echo_optional_decimal(" Ask", pair.call.ask)
1601
+ if pair.put is not None:
1602
+ typer.echo(pair.put.display_symbol or pair.put.osi_key or "Put")
1603
+ _echo_optional_decimal(" Strike", pair.put.strike_price)
1604
+ _echo_optional_decimal(" Bid", pair.put.bid)
1605
+ _echo_optional_decimal(" Ask", pair.put.ask)
1606
+
1607
+
1608
+ def _echo_optional(label: str, value: object | None) -> None:
1609
+ if value is not None:
1610
+ typer.echo(f"{label}: {value}")
1611
+
1612
+
1613
+ def _echo_optional_decimal(label: str, value: Decimal | None) -> None:
1614
+ if value is not None:
1615
+ typer.echo(f"{label}: {value}")
1616
+
1617
+
1618
+ def _handle(coro: Coroutine[Any, Any, object]) -> None:
1619
+ try:
1620
+ _run(coro)
1621
+ except ETradeError as exc:
1622
+ typer.echo(str(exc), err=True)
1623
+ raise typer.Exit(code=1) from None