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,259 @@
1
+ """Orders API service."""
2
+
3
+ from collections.abc import AsyncIterator
4
+ from typing import Any, cast
5
+
6
+ from pydantic import ValidationError
7
+
8
+ from etrade_python.exceptions import ETradeResponseError, ETradeValidationError
9
+ from etrade_python.orders.models import (
10
+ CancelOrderRequest,
11
+ CancelOrderResponse,
12
+ Order,
13
+ OrdersRequest,
14
+ OrdersResponse,
15
+ PlaceOrderRequest,
16
+ PlaceOrderResponse,
17
+ PreviewOrderRequest,
18
+ PreviewOrderResponse,
19
+ )
20
+ from etrade_python.transport.http import ApiTransport
21
+ from etrade_python.transport.response import (
22
+ JsonValue,
23
+ raise_response_validation_error,
24
+ validate_response_shape,
25
+ )
26
+ from etrade_python.transport.retry import RetrySafety
27
+
28
+
29
+ class OrdersService:
30
+ """Access E*TRADE order endpoints."""
31
+
32
+ def __init__(self, transport: ApiTransport) -> None:
33
+ self._transport = transport
34
+
35
+ async def list(
36
+ self, account_id_key: str, request: OrdersRequest | None = None
37
+ ) -> OrdersResponse:
38
+ account_id_key = _normalize_required(account_id_key, "account_id_key")
39
+ orders_request = request or OrdersRequest()
40
+ response = await self._transport.request(
41
+ "GET",
42
+ f"/v1/accounts/{account_id_key}/orders.json",
43
+ params=orders_request.query_params(),
44
+ safety=RetrySafety.SAFE_READ,
45
+ operation="orders.list",
46
+ )
47
+ if response.status_code == 204 and response.data is None:
48
+ return OrdersResponse(order=[])
49
+ data = _require_mapping(response.data, "orders")
50
+ orders_data = _unwrap(data, "OrdersResponse", "orders")
51
+ try:
52
+ return validate_response_shape(
53
+ OrdersResponse.model_validate(orders_data), "orders", collection_field="orders"
54
+ )
55
+ except ValidationError as error:
56
+ raise_response_validation_error("Invalid orders response", error)
57
+
58
+ async def list_all(
59
+ self, account_id_key: str, request: OrdersRequest | None = None
60
+ ) -> AsyncIterator[Order]:
61
+ """Iterate orders until the broker returns an empty continuation marker."""
62
+ base_request = request or OrdersRequest()
63
+ current_request = base_request
64
+ seen_markers: set[str] = {base_request.marker} if base_request.marker else set()
65
+ while True:
66
+ page = await self.list(account_id_key, current_request)
67
+ marker = page.marker
68
+ if marker and marker in seen_markers:
69
+ raise ETradeResponseError("Order pagination did not advance")
70
+ for order in page.orders:
71
+ yield order
72
+ if not marker:
73
+ return
74
+ seen_markers.add(marker)
75
+ current_request = base_request.model_copy(update={"marker": marker})
76
+
77
+ async def preview(
78
+ self, account_id_key: str, request: PreviewOrderRequest
79
+ ) -> PreviewOrderResponse:
80
+ account_id_key = _normalize_required(account_id_key, "account_id_key")
81
+ response = await self._transport.request(
82
+ "POST",
83
+ f"/v1/accounts/{account_id_key}/orders/preview.json",
84
+ body=request.request_body(),
85
+ safety=RetrySafety.NEVER,
86
+ operation="orders.preview",
87
+ )
88
+ data = _require_mapping(response.data, "preview order")
89
+ preview_data = _unwrap(data, "PreviewOrderResponse", "preview order")
90
+ try:
91
+ parsed = validate_response_shape(
92
+ PreviewOrderResponse.model_validate(preview_data), "preview order"
93
+ )
94
+ _require_confirmation(parsed, preview_data, "preview order")
95
+ return parsed
96
+ except ValidationError as error:
97
+ raise_response_validation_error("Invalid preview order response", error)
98
+
99
+ async def place(self, account_id_key: str, request: PlaceOrderRequest) -> PlaceOrderResponse:
100
+ account_id_key = _normalize_required(account_id_key, "account_id_key")
101
+ response = await self._transport.request(
102
+ "POST",
103
+ f"/v1/accounts/{account_id_key}/orders/place.json",
104
+ body=request.request_body(),
105
+ safety=RetrySafety.NEVER,
106
+ operation="orders.place",
107
+ )
108
+ data = _require_mapping(response.data, "place order")
109
+ place_data = _unwrap(data, "PlaceOrderResponse", "place order")
110
+ try:
111
+ parsed = validate_response_shape(
112
+ PlaceOrderResponse.model_validate(place_data), "place order"
113
+ )
114
+ _require_confirmation(parsed, place_data, "place order")
115
+ return parsed
116
+ except ValidationError as error:
117
+ raise_response_validation_error("Invalid place order response", error)
118
+
119
+ async def preview_change(
120
+ self, account_id_key: str, order_id: int, request: PreviewOrderRequest
121
+ ) -> PreviewOrderResponse:
122
+ account_id_key = _normalize_required(account_id_key, "account_id_key")
123
+ order_id = _normalize_order_id(order_id)
124
+ response = await self._transport.request(
125
+ "PUT",
126
+ f"/v1/accounts/{account_id_key}/orders/{order_id}/change/preview.json",
127
+ body=request.request_body(),
128
+ safety=RetrySafety.NEVER,
129
+ operation="orders.preview_change",
130
+ )
131
+ data = _require_mapping(response.data, "preview changed order")
132
+ preview_data = _unwrap(data, "PreviewOrderResponse", "preview changed order")
133
+ try:
134
+ parsed = validate_response_shape(
135
+ PreviewOrderResponse.model_validate(preview_data), "preview changed order"
136
+ )
137
+ _require_confirmation(parsed, preview_data, "preview changed order")
138
+ return parsed
139
+ except ValidationError as error:
140
+ raise_response_validation_error("Invalid preview changed order response", error)
141
+
142
+ async def place_change(
143
+ self, account_id_key: str, order_id: int, request: PlaceOrderRequest
144
+ ) -> PlaceOrderResponse:
145
+ account_id_key = _normalize_required(account_id_key, "account_id_key")
146
+ order_id = _normalize_order_id(order_id)
147
+ response = await self._transport.request(
148
+ "PUT",
149
+ f"/v1/accounts/{account_id_key}/orders/{order_id}/change/place.json",
150
+ body=request.request_body(),
151
+ safety=RetrySafety.NEVER,
152
+ operation="orders.place_change",
153
+ )
154
+ data = _require_mapping(response.data, "place changed order")
155
+ place_data = _unwrap(data, "PlaceOrderResponse", "place changed order")
156
+ try:
157
+ parsed = validate_response_shape(
158
+ PlaceOrderResponse.model_validate(place_data), "place changed order"
159
+ )
160
+ _require_confirmation(parsed, place_data, "place changed order")
161
+ return parsed
162
+ except ValidationError as error:
163
+ raise_response_validation_error("Invalid place changed order response", error)
164
+
165
+ async def cancel(self, account_id_key: str, order_id: int) -> CancelOrderResponse:
166
+ account_id_key = _normalize_required(account_id_key, "account_id_key")
167
+ order_id = _normalize_order_id(order_id)
168
+ request = CancelOrderRequest(orderId=order_id)
169
+ response = await self._transport.request(
170
+ "PUT",
171
+ f"/v1/accounts/{account_id_key}/orders/cancel.json",
172
+ body=request.request_body(),
173
+ safety=RetrySafety.NEVER,
174
+ operation="orders.cancel",
175
+ )
176
+ data = _require_mapping(response.data, "cancel order")
177
+ cancel_data = _unwrap(data, "CancelOrderResponse", "cancel order")
178
+ try:
179
+ parsed = validate_response_shape(
180
+ CancelOrderResponse.model_validate(cancel_data), "cancel order"
181
+ )
182
+ _require_confirmation(parsed, cancel_data, "cancel order")
183
+ return parsed
184
+ except ValidationError as error:
185
+ raise_response_validation_error("Invalid cancel order response", error)
186
+
187
+
188
+ def _normalize_required(value: str, label: str) -> str:
189
+ normalized = value.strip()
190
+ if not normalized:
191
+ raise ETradeValidationError(f"{label} is required")
192
+ return normalized
193
+
194
+
195
+ def _require_confirmation(
196
+ response: PreviewOrderResponse | PlaceOrderResponse | CancelOrderResponse,
197
+ data: dict[str, Any],
198
+ response_name: str,
199
+ ) -> None:
200
+ if isinstance(response, PreviewOrderResponse):
201
+ ids = [preview.preview_id for preview in response.preview_ids]
202
+ raw_ids = data.get("PreviewIds", data.get("previewIds", data.get("preview_ids")))
203
+ has_boolean = _contains_boolean_id(raw_ids, "previewId", "PreviewId")
204
+ elif isinstance(response, PlaceOrderResponse):
205
+ ids = [order.order_id for order in response.order_ids]
206
+ if response.order_id is not None:
207
+ ids.append(response.order_id)
208
+ raw_ids = data.get("OrderIds", data.get("orderIds", data.get("order_ids")))
209
+ has_boolean = _contains_boolean_id(raw_ids, "orderId", "OrderId") or isinstance(
210
+ data.get("orderId", data.get("order_id")), bool
211
+ )
212
+ else:
213
+ ids = [] if response.order_id is None else [response.order_id]
214
+ has_boolean = isinstance(data.get("orderId", data.get("order_id")), bool)
215
+ if (
216
+ has_boolean
217
+ or not ids
218
+ or any(type(identifier) is not int or identifier <= 0 for identifier in ids)
219
+ ):
220
+ raise ETradeResponseError(
221
+ f"Invalid {response_name} response: missing or invalid confirmation ID"
222
+ )
223
+
224
+
225
+ def _contains_boolean_id(value: object, field: str, wrapper: str) -> bool:
226
+ """Prevent permissive response integer coercion from treating True as ID 1."""
227
+ if isinstance(value, bool):
228
+ return True
229
+ if isinstance(value, list):
230
+ return any(_contains_boolean_id(item, field, wrapper) for item in cast(list[object], value))
231
+ if isinstance(value, dict):
232
+ data = cast(dict[str, object], value)
233
+ if wrapper in data:
234
+ return _contains_boolean_id(data[wrapper], field, wrapper)
235
+ return _contains_boolean_id(
236
+ data.get(field, data.get("preview_id" if field == "previewId" else "order_id")),
237
+ field,
238
+ wrapper,
239
+ )
240
+ return False
241
+
242
+
243
+ def _normalize_order_id(order_id: int) -> int:
244
+ if type(order_id) is not int or order_id < 1:
245
+ raise ETradeValidationError("order_id must be a positive integer")
246
+ return order_id
247
+
248
+
249
+ def _require_mapping(data: JsonValue, response_name: str) -> dict[str, Any]:
250
+ if not isinstance(data, dict):
251
+ raise ETradeResponseError(f"Invalid {response_name} response")
252
+ return cast(dict[str, Any], data)
253
+
254
+
255
+ def _unwrap(data: dict[str, Any], envelope: str, response_name: str) -> dict[str, Any]:
256
+ value = data.get(envelope, data)
257
+ if not isinstance(value, dict):
258
+ raise ETradeResponseError(f"Invalid {response_name} response")
259
+ return cast(dict[str, Any], value)
@@ -0,0 +1,67 @@
1
+ """Shared supported values for order requests and builders."""
2
+
3
+ from typing import Literal, get_args
4
+
5
+ QuantityType = Literal["QUANTITY", "DOLLAR", "ALL_I_OWN"]
6
+ OrderTerm = Literal[
7
+ "GOOD_UNTIL_CANCEL",
8
+ "GOOD_FOR_DAY",
9
+ "GOOD_TILL_DATE",
10
+ "IMMEDIATE_OR_CANCEL",
11
+ "FILL_OR_KILL",
12
+ ]
13
+ OrderType = Literal[
14
+ "EQ",
15
+ "OPTN",
16
+ "SPREADS",
17
+ "BUY_WRITES",
18
+ "BUTTERFLY",
19
+ "IRON_BUTTERFLY",
20
+ "CONDOR",
21
+ "IRON_CONDOR",
22
+ "MF",
23
+ "MMF",
24
+ ]
25
+ PriceType = Literal[
26
+ "MARKET",
27
+ "LIMIT",
28
+ "STOP",
29
+ "STOP_LIMIT",
30
+ "TRAILING_STOP_CNST_BY_LOWER_TRIGGER",
31
+ "UPPER_TRIGGER_BY_TRAILING_STOP_CNST",
32
+ "TRAILING_STOP_PRCT_BY_LOWER_TRIGGER",
33
+ "UPPER_TRIGGER_BY_TRAILING_STOP_PRCT",
34
+ "TRAILING_STOP_CNST",
35
+ "TRAILING_STOP_PRCT",
36
+ "HIDDEN_STOP",
37
+ "HIDDEN_STOP_BY_LOWER_TRIGGER",
38
+ "UPPER_TRIGGER_BY_HIDDEN_STOP",
39
+ "NET_DEBIT",
40
+ "NET_CREDIT",
41
+ "NET_EVEN",
42
+ "MARKET_ON_OPEN",
43
+ "MARKET_ON_CLOSE",
44
+ "LIMIT_ON_OPEN",
45
+ "LIMIT_ON_CLOSE",
46
+ ]
47
+ MarketSession = Literal["REGULAR", "EXTENDED", "EXTO"]
48
+ OrderAction = Literal[
49
+ "BUY",
50
+ "SELL",
51
+ "BUY_TO_COVER",
52
+ "SELL_SHORT",
53
+ "BUY_OPEN",
54
+ "BUY_CLOSE",
55
+ "SELL_OPEN",
56
+ "SELL_CLOSE",
57
+ "EXCHANGE",
58
+ ]
59
+ SecurityType = Literal["EQ", "OPTN", "MF", "MMF"]
60
+
61
+ VALID_QUANTITY_TYPES = frozenset(get_args(QuantityType))
62
+ VALID_ORDER_TERMS = frozenset(get_args(OrderTerm))
63
+ VALID_ORDER_TYPES = frozenset(get_args(OrderType))
64
+ VALID_PRICE_TYPES = frozenset(get_args(PriceType))
65
+ VALID_MARKET_SESSIONS = frozenset(get_args(MarketSession))
66
+ VALID_ORDER_ACTIONS = frozenset(get_args(OrderAction))
67
+ VALID_SECURITY_TYPES = frozenset(get_args(SecurityType))
@@ -0,0 +1,35 @@
1
+ """Portfolio API service and models."""
2
+
3
+ from etrade_python.portfolio.models import (
4
+ AccountPortfolio,
5
+ CompleteView,
6
+ FundamentalView,
7
+ OptionsWatchView,
8
+ PerformanceView,
9
+ PortfolioProduct,
10
+ PortfolioRequest,
11
+ PortfolioResponse,
12
+ PortfolioTotals,
13
+ Position,
14
+ PositionLot,
15
+ ProductId,
16
+ QuickView,
17
+ )
18
+ from etrade_python.portfolio.service import PortfolioService
19
+
20
+ __all__ = [
21
+ "AccountPortfolio",
22
+ "CompleteView",
23
+ "FundamentalView",
24
+ "OptionsWatchView",
25
+ "PerformanceView",
26
+ "PortfolioProduct",
27
+ "PortfolioRequest",
28
+ "PortfolioResponse",
29
+ "PortfolioService",
30
+ "PortfolioTotals",
31
+ "Position",
32
+ "PositionLot",
33
+ "ProductId",
34
+ "QuickView",
35
+ ]