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.
- etrade_python/__init__.py +264 -0
- etrade_python/_dates.py +152 -0
- etrade_python/_version.py +3 -0
- etrade_python/accounts/__init__.py +31 -0
- etrade_python/accounts/models.py +204 -0
- etrade_python/accounts/service.py +136 -0
- etrade_python/alerts/__init__.py +23 -0
- etrade_python/alerts/models.py +180 -0
- etrade_python/alerts/service.py +128 -0
- etrade_python/auth/__init__.py +31 -0
- etrade_python/auth/credentials.py +85 -0
- etrade_python/auth/oauth.py +268 -0
- etrade_python/auth/session.py +154 -0
- etrade_python/auth/stores/__init__.py +7 -0
- etrade_python/auth/stores/base.py +18 -0
- etrade_python/auth/stores/keyring.py +49 -0
- etrade_python/auth/stores/memory.py +19 -0
- etrade_python/cli/__init__.py +1 -0
- etrade_python/cli/app.py +1623 -0
- etrade_python/client.py +84 -0
- etrade_python/config.py +43 -0
- etrade_python/enums.py +19 -0
- etrade_python/exceptions.py +68 -0
- etrade_python/market/__init__.py +59 -0
- etrade_python/market/models.py +811 -0
- etrade_python/market/service.py +168 -0
- etrade_python/orders/__init__.py +107 -0
- etrade_python/orders/builders.py +617 -0
- etrade_python/orders/models.py +1009 -0
- etrade_python/orders/service.py +259 -0
- etrade_python/orders/values.py +67 -0
- etrade_python/portfolio/__init__.py +35 -0
- etrade_python/portfolio/models.py +429 -0
- etrade_python/portfolio/service.py +99 -0
- etrade_python/py.typed +0 -0
- etrade_python/transactions/__init__.py +27 -0
- etrade_python/transactions/models.py +201 -0
- etrade_python/transactions/service.py +138 -0
- etrade_python/transport/__init__.py +7 -0
- etrade_python/transport/auth.py +17 -0
- etrade_python/transport/http.py +240 -0
- etrade_python/transport/logging.py +38 -0
- etrade_python/transport/response.py +243 -0
- etrade_python/transport/retry.py +75 -0
- etrade_python-1.0.0.dist-info/METADATA +513 -0
- etrade_python-1.0.0.dist-info/RECORD +49 -0
- etrade_python-1.0.0.dist-info/WHEEL +4 -0
- etrade_python-1.0.0.dist-info/entry_points.txt +2 -0
- 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
|
+
]
|