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,1009 @@
1
+ """Typed order request and response models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from datetime import date, datetime
7
+ from decimal import Decimal
8
+ from typing import Any, cast
9
+
10
+ from pydantic import BaseModel, ConfigDict, Field, ValidationInfo, field_validator, model_validator
11
+
12
+ from etrade_python._dates import parse_broker_datetime
13
+ from etrade_python.orders.values import (
14
+ VALID_MARKET_SESSIONS,
15
+ VALID_ORDER_ACTIONS,
16
+ VALID_ORDER_TERMS,
17
+ VALID_ORDER_TYPES,
18
+ VALID_PRICE_TYPES,
19
+ VALID_QUANTITY_TYPES,
20
+ VALID_SECURITY_TYPES,
21
+ )
22
+
23
+ _CLIENT_ORDER_ID_RE = re.compile(r"^[A-Za-z0-9]{1,20}$")
24
+
25
+
26
+ def _supported_value(value: str, allowed: frozenset[str], label: str) -> str:
27
+ normalized = value.strip().upper()
28
+ if normalized not in allowed:
29
+ raise ValueError(f"{label} must be one of: {', '.join(sorted(allowed))}")
30
+ return normalized
31
+
32
+
33
+ class BrokerModel(BaseModel):
34
+ """Base response model that discards unmodeled broker fields."""
35
+
36
+ model_config = ConfigDict(extra="ignore", frozen=True, populate_by_name=True)
37
+
38
+
39
+ def _strip_broker_metadata(value: object) -> object:
40
+ if isinstance(value, dict):
41
+ return {
42
+ key: _strip_broker_metadata(item)
43
+ for key, item in cast(dict[str, object], value).items()
44
+ if key != "broker_metadata"
45
+ }
46
+ if isinstance(value, list):
47
+ return [_strip_broker_metadata(item) for item in cast(list[object], value)]
48
+ return value
49
+
50
+
51
+ def _normalize_aliases(value: object, aliases: dict[str, str]) -> object:
52
+ if not isinstance(value, dict):
53
+ return value
54
+ data = dict(cast(dict[str, Any], value))
55
+ for source, target in aliases.items():
56
+ if source in data and target not in data:
57
+ data[target] = data[source]
58
+ data.pop(source, None)
59
+ return data
60
+
61
+
62
+ def _normalize_response_ids(value: object, key: str, wrapper: str) -> object:
63
+ if isinstance(value, BaseModel):
64
+ value = value.model_dump(by_alias=True)
65
+ if isinstance(value, dict):
66
+ data = cast(dict[str, Any], value)
67
+ if wrapper in data:
68
+ value = data[wrapper]
69
+ elif isinstance(data.get(key), (dict, list)):
70
+ value = data[key]
71
+ if value is None or value == {}:
72
+ return []
73
+ if isinstance(value, int):
74
+ return [{key: value}]
75
+ if isinstance(value, dict):
76
+ return [cast(dict[str, Any], value)]
77
+ if isinstance(value, list):
78
+ items: list[object] = []
79
+ for item in cast(list[object], value):
80
+ if isinstance(item, int):
81
+ items.append({key: item})
82
+ elif isinstance(item, BaseModel):
83
+ items.append(item.model_dump(by_alias=True))
84
+ else:
85
+ items.append(item)
86
+ return items
87
+ return value
88
+
89
+
90
+ class OrderProductId(BrokerModel):
91
+ symbol: str | None = None
92
+ type_code: str | None = Field(default=None, alias="typeCode")
93
+
94
+
95
+ class OrderProduct(BrokerModel):
96
+ symbol: str | None = None
97
+ security_type: str | None = Field(default=None, alias="securityType")
98
+ security_sub_type: str | None = Field(default=None, alias="securitySubType")
99
+ call_put: str | None = Field(default=None, alias="callPut")
100
+ expiry_year: int | None = Field(default=None, alias="expiryYear")
101
+ expiry_month: int | None = Field(default=None, alias="expiryMonth")
102
+ expiry_day: int | None = Field(default=None, alias="expiryDay")
103
+ strike_price: Decimal | None = Field(default=None, alias="strikePrice")
104
+ expiry_type: str | None = Field(default=None, alias="expiryType")
105
+
106
+ @field_validator("symbol", "security_type", "security_sub_type", "call_put", "expiry_type")
107
+ @classmethod
108
+ def nonempty_strings(cls, value: str | None) -> str | None:
109
+ if value is not None and not value.strip():
110
+ raise ValueError("A nonempty value is required")
111
+ return value
112
+
113
+
114
+ class OrderResponseProduct(OrderProduct):
115
+ """Product details returned by the broker, retaining request-model compatibility."""
116
+
117
+ product_id: OrderProductId | None = Field(default=None, alias="productId")
118
+
119
+ @model_validator(mode="before")
120
+ @classmethod
121
+ def normalize_product_id(cls, value: object) -> object:
122
+ return _normalize_aliases(value, {"ProductId": "productId"})
123
+
124
+
125
+ class OrderLot(BrokerModel):
126
+ id: int | None = None
127
+ size: Decimal | None = None
128
+
129
+
130
+ def _empty_order_lots() -> list[OrderLot]:
131
+ return []
132
+
133
+
134
+ class OrderLots(BrokerModel):
135
+ lots: list[OrderLot] = Field(default_factory=_empty_order_lots, alias="lot")
136
+
137
+ @model_validator(mode="before")
138
+ @classmethod
139
+ def normalize_response(cls, value: object) -> object:
140
+ if not isinstance(value, dict):
141
+ return value
142
+ data = dict(cast(dict[str, Any], value))
143
+ for source in ("Lot", "lots"):
144
+ if source in data and "lot" not in data:
145
+ data["lot"] = data[source]
146
+ data.pop(source, None)
147
+ return data
148
+
149
+ @field_validator("lots", mode="before")
150
+ @classmethod
151
+ def normalize_lots(cls, value: object) -> object:
152
+ if value is None:
153
+ return []
154
+ if isinstance(value, dict):
155
+ return [cast(dict[str, Any], value)]
156
+ return value
157
+
158
+
159
+ class MFQuantity(BrokerModel):
160
+ cash: Decimal | None = None
161
+ margin: Decimal | None = None
162
+ cusip: str | None = None
163
+
164
+
165
+ class OrderInstrumentRequest(BaseModel):
166
+ """Instrument request entry inside an E*TRADE order body."""
167
+
168
+ model_config = ConfigDict(extra="allow", frozen=True, populate_by_name=True)
169
+
170
+ product: OrderProduct = Field(alias="Product")
171
+ order_action: str = Field(alias="orderAction")
172
+ quantity_type: str | None = Field(default="QUANTITY", alias="quantityType")
173
+ quantity: Decimal | None = None
174
+ ordered_quantity: Decimal | None = Field(default=None, alias="orderedQuantity")
175
+ lots: OrderLots | None = None
176
+ mf_quantity: MFQuantity | None = Field(default=None, alias="mfQuantity")
177
+ osi_key: str | None = Field(default=None, alias="osiKey")
178
+ mf_transaction: str | None = Field(default=None, alias="mfTransaction")
179
+ reserve_order: bool | None = Field(default=None, alias="reserveOrder")
180
+ reserve_quantity: Decimal | None = Field(default=None, alias="reserveQuantity")
181
+
182
+ @field_validator("order_action", "quantity_type", "osi_key", "mf_transaction")
183
+ @classmethod
184
+ def normalize_optional_strings(cls, value: str | None) -> str | None:
185
+ if value is None:
186
+ return None
187
+ normalized = value.strip().upper()
188
+ if not normalized:
189
+ raise ValueError("A nonempty value is required")
190
+ return normalized
191
+
192
+ @field_validator("quantity", "ordered_quantity", "reserve_quantity")
193
+ @classmethod
194
+ def positive_quantities(cls, value: Decimal | None) -> Decimal | None:
195
+ if value is not None and (not value.is_finite() or value <= 0):
196
+ raise ValueError("Quantity values must be finite and positive")
197
+ return value
198
+
199
+ @field_validator("order_action", "quantity_type")
200
+ @classmethod
201
+ def validate_supported_values(cls, value: str | None, info: ValidationInfo) -> str | None:
202
+ if value is None:
203
+ return None
204
+ allowed = VALID_ORDER_ACTIONS if info.field_name == "order_action" else VALID_QUANTITY_TYPES
205
+ return _supported_value(value, allowed, info.field_name or "value")
206
+
207
+ @field_validator("product")
208
+ @classmethod
209
+ def validate_product(cls, value: OrderProduct) -> OrderProduct:
210
+ if value.symbol is None or not value.symbol.strip():
211
+ raise ValueError("symbol is required")
212
+ if value.security_type is None:
213
+ raise ValueError("security_type is required")
214
+ security_type = _supported_value(value.security_type, VALID_SECURITY_TYPES, "security_type")
215
+ updates: dict[str, Any] = {
216
+ "symbol": value.symbol.strip().upper(),
217
+ "security_type": security_type,
218
+ }
219
+ if security_type == "OPTN":
220
+ if value.call_put is None:
221
+ raise ValueError("call_put is required for option instruments")
222
+ updates["call_put"] = _supported_value(
223
+ value.call_put, frozenset({"CALL", "PUT"}), "call_put"
224
+ )
225
+ if value.expiry_year is None or value.expiry_month is None or value.expiry_day is None:
226
+ raise ValueError("expiration is required for option instruments")
227
+ try:
228
+ date(value.expiry_year, value.expiry_month, value.expiry_day)
229
+ except ValueError:
230
+ raise ValueError("expiration must be a valid calendar date") from None
231
+ if (
232
+ value.strike_price is None
233
+ or not value.strike_price.is_finite()
234
+ or value.strike_price <= 0
235
+ ):
236
+ raise ValueError("strike_price must be finite and positive for option instruments")
237
+ return value.model_copy(update=updates)
238
+
239
+
240
+ class OrderInstrument(BrokerModel):
241
+ product: OrderResponseProduct | None = Field(default=None, alias="Product")
242
+ symbol_description: str | None = Field(default=None, alias="symbolDescription")
243
+ order_action: str | None = Field(default=None, alias="orderAction")
244
+ quantity_type: str | None = Field(default=None, alias="quantityType")
245
+ quantity: Decimal | None = None
246
+ cancel_quantity: Decimal | None = Field(default=None, alias="cancelQuantity")
247
+ ordered_quantity: Decimal | None = Field(default=None, alias="orderedQuantity")
248
+ filled_quantity: Decimal | None = Field(default=None, alias="filledQuantity")
249
+ average_execution_price: Decimal | None = Field(default=None, alias="averageExecutionPrice")
250
+ estimated_commission: Decimal | None = Field(default=None, alias="estimatedCommission")
251
+ estimated_fees: Decimal | None = Field(default=None, alias="estimatedFees")
252
+ bid: Decimal | None = None
253
+ ask: Decimal | None = None
254
+ last_price: Decimal | None = Field(default=None, alias="lastprice")
255
+ currency: str | None = None
256
+ lots: OrderLots | None = None
257
+ mf_quantity: MFQuantity | None = Field(default=None, alias="mfQuantity")
258
+ osi_key: str | None = Field(default=None, alias="osiKey")
259
+ reserve_order: bool | None = Field(default=None, alias="reserveOrder")
260
+ reserve_quantity: Decimal | None = Field(default=None, alias="reserveQuantity")
261
+ mf_transaction: str | None = Field(default=None, alias="mfTransaction")
262
+
263
+ @field_validator("product", mode="before")
264
+ @classmethod
265
+ def normalize_product_model(cls, value: object) -> object:
266
+ if isinstance(value, OrderProduct) and not isinstance(value, OrderResponseProduct):
267
+ return value.model_dump(by_alias=True)
268
+ return value
269
+
270
+ @model_validator(mode="before")
271
+ @classmethod
272
+ def normalize_response(cls, value: object) -> object:
273
+ if not isinstance(value, dict):
274
+ return value
275
+ data = dict(cast(dict[str, Any], value))
276
+ aliases = {"product": "Product", "Lots": "lots", "MFQuantity": "mfQuantity"}
277
+ for source, target in aliases.items():
278
+ if source in data and target not in data:
279
+ data[target] = data[source]
280
+ data.pop(source, None)
281
+ return data
282
+
283
+
284
+ class Message(BrokerModel):
285
+ description: str | None = None
286
+ code: int | None = None
287
+ type: str | None = None
288
+
289
+
290
+ def _empty_messages() -> list[Message]:
291
+ return []
292
+
293
+
294
+ class Messages(BrokerModel):
295
+ messages: list[Message] = Field(default_factory=_empty_messages, alias="message")
296
+
297
+ @model_validator(mode="before")
298
+ @classmethod
299
+ def normalize_response(cls, value: object) -> object:
300
+ if not isinstance(value, dict):
301
+ return value
302
+ data = dict(cast(dict[str, Any], value))
303
+ for source in ("Message", "messages"):
304
+ if source in data and "message" not in data:
305
+ data["message"] = data[source]
306
+ data.pop(source, None)
307
+ return data
308
+
309
+ @field_validator("messages", mode="before")
310
+ @classmethod
311
+ def normalize_messages(cls, value: object) -> object:
312
+ if value is None:
313
+ return []
314
+ if isinstance(value, dict):
315
+ return [cast(dict[str, Any], value)]
316
+ return value
317
+
318
+
319
+ class OrderDetailRequest(BaseModel):
320
+ """Order detail request entry inside preview/place requests."""
321
+
322
+ model_config = ConfigDict(extra="allow", frozen=True, populate_by_name=True)
323
+
324
+ all_or_none: bool | str | None = Field(default=None, alias="allOrNone")
325
+ price_type: str = Field(alias="priceType")
326
+ order_term: str = Field(alias="orderTerm")
327
+ market_session: str | None = Field(default="REGULAR", alias="marketSession")
328
+ stop_price: Decimal | str | None = Field(default=None, alias="stopPrice")
329
+ limit_price: Decimal | None = Field(default=None, alias="limitPrice")
330
+ stop_limit_price: Decimal | None = Field(default=None, alias="stopLimitPrice")
331
+ offset_type: str | None = Field(default=None, alias="offsetType")
332
+ offset_value: Decimal | None = Field(default=None, alias="offsetValue")
333
+ routing_destination: str | None = Field(default=None, alias="routingDestination")
334
+ disclosure: Disclosure | None = Field(default=None, alias="disclosure")
335
+ instruments: list[OrderInstrumentRequest] = Field(alias="Instrument")
336
+
337
+ @field_validator(
338
+ "price_type", "order_term", "market_session", "offset_type", "routing_destination"
339
+ )
340
+ @classmethod
341
+ def normalize_optional_strings(cls, value: str | None) -> str | None:
342
+ if value is None:
343
+ return None
344
+ normalized = value.strip().upper()
345
+ if not normalized:
346
+ raise ValueError("A nonempty value is required")
347
+ return normalized
348
+
349
+ @field_validator("price_type", "order_term", "market_session")
350
+ @classmethod
351
+ def validate_supported_values(cls, value: str | None, info: ValidationInfo) -> str | None:
352
+ if value is None:
353
+ return None
354
+ allowed = {
355
+ "price_type": VALID_PRICE_TYPES,
356
+ "order_term": VALID_ORDER_TERMS,
357
+ "market_session": VALID_MARKET_SESSIONS,
358
+ }
359
+ return _supported_value(value, allowed[info.field_name or ""], info.field_name or "value")
360
+
361
+ @field_validator("stop_price", mode="before")
362
+ @classmethod
363
+ def parse_stop_price(cls, value: object) -> object:
364
+ if value is None or value == "":
365
+ return value
366
+ try:
367
+ return Decimal(str(value))
368
+ except Exception:
369
+ raise ValueError("stop_price must be a decimal value") from None
370
+
371
+ @field_validator("limit_price", "stop_limit_price", "stop_price")
372
+ @classmethod
373
+ def finite_prices(cls, value: Decimal | str | None) -> Decimal | str | None:
374
+ if isinstance(value, Decimal) and not value.is_finite():
375
+ raise ValueError("Prices must be finite")
376
+ return value
377
+
378
+ @field_validator("instruments", mode="before")
379
+ @classmethod
380
+ def normalize_instruments(cls, value: object) -> object:
381
+ if isinstance(value, dict):
382
+ return [cast(dict[str, Any], value)]
383
+ return value
384
+
385
+ @model_validator(mode="after")
386
+ def validate_price_shape(self) -> OrderDetailRequest:
387
+ if not self.instruments:
388
+ raise ValueError("At least one instrument is required")
389
+ if self.price_type in {
390
+ "LIMIT",
391
+ "NET_DEBIT",
392
+ "NET_CREDIT",
393
+ "LIMIT_ON_OPEN",
394
+ "LIMIT_ON_CLOSE",
395
+ }:
396
+ if self.limit_price is None:
397
+ raise ValueError("limit_price is required for limit price types")
398
+ if self.limit_price <= 0:
399
+ raise ValueError("limit_price must be positive")
400
+ if self.price_type in {"STOP", "STOP_LIMIT"} and self.stop_price in (None, ""):
401
+ raise ValueError("stop_price is required for stop price types")
402
+ if self.price_type in {"STOP", "STOP_LIMIT"}:
403
+ if not isinstance(self.stop_price, Decimal) or self.stop_price <= 0:
404
+ raise ValueError("stop_price must be positive")
405
+ if self.price_type == "STOP_LIMIT":
406
+ if self.limit_price is None and self.stop_limit_price is None:
407
+ raise ValueError("limit_price or stop_limit_price is required for STOP_LIMIT")
408
+ if any(
409
+ price is not None and price <= 0
410
+ for price in (self.limit_price, self.stop_limit_price)
411
+ ):
412
+ raise ValueError("STOP_LIMIT limit prices must be positive")
413
+ if self.price_type == "MARKET" and self.limit_price is not None:
414
+ raise ValueError("limit_price is not valid for MARKET orders")
415
+ return self
416
+
417
+
418
+ def _empty_order_instruments() -> list[OrderInstrument]:
419
+ return []
420
+
421
+
422
+ class OrderDetail(BrokerModel):
423
+ order_number: int | None = Field(default=None, alias="orderNumber")
424
+ account_id: str | None = Field(default=None, alias="accountId")
425
+ preview_time: datetime | None = Field(default=None, alias="previewTime")
426
+ placed_time: datetime | None = Field(default=None, alias="placedTime")
427
+ executed_time: datetime | None = Field(default=None, alias="executedTime")
428
+ order_value: Decimal | None = Field(default=None, alias="orderValue")
429
+ status: str | None = None
430
+ order_type: str | None = Field(default=None, alias="orderType")
431
+ order_term: str | None = Field(default=None, alias="orderTerm")
432
+ price_type: str | None = Field(default=None, alias="priceType")
433
+ price_value: str | None = Field(default=None, alias="priceValue")
434
+ limit_price: Decimal | None = Field(default=None, alias="limitPrice")
435
+ stop_price: Decimal | None = Field(default=None, alias="stopPrice")
436
+ stop_limit_price: Decimal | None = Field(default=None, alias="stopLimitPrice")
437
+ offset_type: str | None = Field(default=None, alias="offsetType")
438
+ offset_value: Decimal | None = Field(default=None, alias="offsetValue")
439
+ market_session: str | None = Field(default=None, alias="marketSession")
440
+ all_or_none: bool | str | None = Field(default=None, alias="allOrNone")
441
+ messages: Messages | None = None
442
+ estimated_commission: Decimal | None = Field(default=None, alias="estimatedCommission")
443
+ estimated_total_amount: Decimal | None = Field(default=None, alias="estimatedTotalAmount")
444
+ net_price: Decimal | None = Field(default=None, alias="netPrice")
445
+ net_bid: Decimal | None = Field(default=None, alias="netBid")
446
+ net_ask: Decimal | None = Field(default=None, alias="netAsk")
447
+ ratio: str | None = None
448
+ eg_qual: str | None = Field(default=None, alias="egQual")
449
+ instruments: list[OrderInstrument] = Field(
450
+ default_factory=_empty_order_instruments, alias="Instrument"
451
+ )
452
+ routing_destination: str | None = Field(default=None, alias="routingDestination")
453
+ bracketed_limit_price: Decimal | None = Field(default=None, alias="bracketedLimitPrice")
454
+ initial_stop_price: Decimal | None = Field(default=None, alias="initialStopPrice")
455
+ trail_price: Decimal | None = Field(default=None, alias="trailPrice")
456
+ trigger_price: Decimal | None = Field(default=None, alias="triggerPrice")
457
+ condition_price: Decimal | None = Field(default=None, alias="conditionPrice")
458
+ condition_symbol: str | None = Field(default=None, alias="conditionSymbol")
459
+ condition_type: str | None = Field(default=None, alias="conditionType")
460
+ condition_follow_price: str | None = Field(default=None, alias="conditionFollowPrice")
461
+ condition_security_type: str | None = Field(default=None, alias="conditionSecurityType")
462
+ replaced_by_order_id: int | None = Field(default=None, alias="replacedByOrderId")
463
+ replaces_order_id: int | None = Field(default=None, alias="replacesOrderId")
464
+ preview_id: int | None = Field(default=None, alias="previewId")
465
+ investment_amount: Decimal | None = Field(default=None, alias="investmentAmount")
466
+ position_quantity: str | None = Field(default=None, alias="positionQuantity")
467
+ aip_flag: bool | None = Field(default=None, alias="aipFlag")
468
+ re_invest_option: str | None = Field(default=None, alias="reInvestOption")
469
+ estimated_fees: Decimal | None = Field(default=None, alias="estimatedFees")
470
+ gcd: int | None = None
471
+ mf_price_type: str | None = Field(default=None, alias="mfpriceType")
472
+
473
+ @field_validator("preview_time", "placed_time", "executed_time", mode="before")
474
+ @classmethod
475
+ def parse_times(cls, value: object) -> datetime | None:
476
+ return parse_broker_datetime(value)
477
+
478
+ @model_validator(mode="before")
479
+ @classmethod
480
+ def normalize_response(cls, value: object) -> object:
481
+ if not isinstance(value, dict):
482
+ return value
483
+ data = dict(cast(dict[str, Any], value))
484
+ aliases = {"instrument": "Instrument", "messages": "messages", "Messages": "messages"}
485
+ for source, target in aliases.items():
486
+ if source in data and target not in data:
487
+ data[target] = data[source]
488
+ if source != target:
489
+ data.pop(source, None)
490
+ if isinstance(data.get("Instrument"), dict):
491
+ nested = cast(dict[str, Any], data["Instrument"])
492
+ data["Instrument"] = nested.get("Instrument", nested)
493
+ if isinstance(data["Instrument"], dict):
494
+ data["Instrument"] = [data["Instrument"]]
495
+ return data
496
+
497
+ @field_validator("instruments", mode="before")
498
+ @classmethod
499
+ def normalize_instruments(cls, value: object) -> object:
500
+ if value is None:
501
+ return []
502
+ if isinstance(value, dict):
503
+ nested = cast(dict[str, Any], value)
504
+ if "Instrument" in nested:
505
+ return nested["Instrument"]
506
+ return [nested]
507
+ return value
508
+
509
+
510
+ def _empty_event_instruments() -> list[OrderInstrument]:
511
+ return []
512
+
513
+
514
+ class OrderEvent(BrokerModel):
515
+ name: str | None = None
516
+ date_time: datetime | None = Field(default=None, alias="dateTime")
517
+ order_number: int | None = Field(default=None, alias="orderNumber")
518
+ instruments: list[OrderInstrument] = Field(
519
+ default_factory=_empty_event_instruments, alias="instrument"
520
+ )
521
+
522
+ @model_validator(mode="before")
523
+ @classmethod
524
+ def normalize_response(cls, value: object) -> object:
525
+ if not isinstance(value, dict):
526
+ return value
527
+ data = dict(cast(dict[str, Any], value))
528
+ if isinstance(data.get("instrument"), dict):
529
+ nested = cast(dict[str, Any], data["instrument"])
530
+ data["instrument"] = nested.get("Instrument", nested)
531
+ if isinstance(data["instrument"], dict):
532
+ data["instrument"] = [data["instrument"]]
533
+ return data
534
+
535
+ @field_validator("date_time", mode="before")
536
+ @classmethod
537
+ def parse_date_time(cls, value: object) -> datetime | None:
538
+ return parse_broker_datetime(value)
539
+
540
+ @field_validator("instruments", mode="before")
541
+ @classmethod
542
+ def normalize_instruments(cls, value: object) -> object:
543
+ if value is None:
544
+ return []
545
+ if isinstance(value, dict):
546
+ nested = cast(dict[str, Any], value)
547
+ if "Instrument" in nested:
548
+ return nested["Instrument"]
549
+ return [nested]
550
+ return value
551
+
552
+
553
+ def _empty_order_events() -> list[OrderEvent]:
554
+ return []
555
+
556
+
557
+ class Events(BrokerModel):
558
+ events: list[OrderEvent] = Field(default_factory=_empty_order_events, alias="event")
559
+
560
+ @field_validator("events", mode="before")
561
+ @classmethod
562
+ def normalize_events(cls, value: object) -> object:
563
+ if value is None:
564
+ return []
565
+ if isinstance(value, dict):
566
+ return [cast(dict[str, Any], value)]
567
+ return value
568
+
569
+
570
+ def _empty_order_details() -> list[OrderDetail]:
571
+ return []
572
+
573
+
574
+ class Order(BrokerModel):
575
+ order_id: int | None = Field(default=None, alias="orderId")
576
+ details: str | None = None
577
+ order_type: str | None = Field(default=None, alias="orderType")
578
+ total_order_value: Decimal | None = Field(default=None, alias="totalOrderValue")
579
+ total_commission: Decimal | None = Field(default=None, alias="totalCommission")
580
+ order_details: list[OrderDetail] = Field(
581
+ default_factory=_empty_order_details, alias="orderDetail"
582
+ )
583
+ events: Events | None = None
584
+
585
+ @model_validator(mode="before")
586
+ @classmethod
587
+ def normalize_response(cls, value: object) -> object:
588
+ if not isinstance(value, dict):
589
+ return value
590
+ data = dict(cast(dict[str, Any], value))
591
+ for source in ("OrderDetail", "orderDetails"):
592
+ if source in data and "orderDetail" not in data:
593
+ data["orderDetail"] = data[source]
594
+ data.pop(source, None)
595
+ if "events" in data and isinstance(data["events"], dict) and "event" not in data["events"]:
596
+ data["events"] = {"event": data["events"]}
597
+ return data
598
+
599
+ @field_validator("order_details", mode="before")
600
+ @classmethod
601
+ def normalize_details(cls, value: object) -> object:
602
+ if value is None:
603
+ return []
604
+ if isinstance(value, dict):
605
+ return [cast(dict[str, Any], value)]
606
+ return value
607
+
608
+
609
+ class PreviewId(BaseModel):
610
+ model_config = ConfigDict(frozen=True, populate_by_name=True)
611
+
612
+ preview_id: int = Field(alias="previewId", strict=True, gt=0)
613
+
614
+
615
+ class OrderId(BaseModel):
616
+ model_config = ConfigDict(frozen=True, populate_by_name=True)
617
+
618
+ order_id: int = Field(alias="orderId", strict=True, gt=0)
619
+
620
+
621
+ class PreviewResponseId(PreviewId, BrokerModel):
622
+ """Preview ID and broker margin designation; placement requests use PreviewId."""
623
+
624
+ preview_id: int = Field(alias="previewId")
625
+ cash_margin: str | None = Field(default=None, alias="cashMargin")
626
+
627
+
628
+ class OrderResponseId(OrderId, BrokerModel):
629
+ """Order ID and margin designation returned by the broker."""
630
+
631
+ order_id: int = Field(alias="orderId")
632
+ cash_margin: str | None = Field(default=None, alias="cashMargin")
633
+
634
+
635
+ class OrderBuyPowerEffect(BrokerModel):
636
+ current_bp: Decimal | None = Field(default=None, alias="currentBp")
637
+ current_oor: Decimal | None = Field(default=None, alias="currentOor")
638
+ current_net_bp: Decimal | None = Field(default=None, alias="currentNetBp")
639
+ current_order_impact: Decimal | None = Field(default=None, alias="currentOrderImpact")
640
+ net_bp: Decimal | None = Field(default=None, alias="netBp")
641
+
642
+
643
+ class CashBuyingPowerDetails(BrokerModel):
644
+ settled: OrderBuyPowerEffect | None = None
645
+ settled_unsettled: OrderBuyPowerEffect | None = Field(default=None, alias="settledUnsettled")
646
+
647
+ @model_validator(mode="before")
648
+ @classmethod
649
+ def normalize_response(cls, value: object) -> object:
650
+ return _normalize_aliases(
651
+ value, {"Settled": "settled", "SettledUnsettled": "settledUnsettled"}
652
+ )
653
+
654
+
655
+ class MarginBuyingPowerDetails(BrokerModel):
656
+ non_marginable: OrderBuyPowerEffect | None = Field(default=None, alias="nonMarginable")
657
+ marginable: OrderBuyPowerEffect | None = None
658
+
659
+ @model_validator(mode="before")
660
+ @classmethod
661
+ def normalize_response(cls, value: object) -> object:
662
+ return _normalize_aliases(
663
+ value, {"NonMarginable": "nonMarginable", "Marginable": "marginable"}
664
+ )
665
+
666
+
667
+ class DtBuyingPowerDetails(MarginBuyingPowerDetails):
668
+ """Day-trading buying-power effects for marginable and non-marginable securities."""
669
+
670
+
671
+ class PortfolioMargin(BrokerModel):
672
+ house_excess_equity_new: Decimal | None = Field(default=None, alias="houseExcessEquityNew")
673
+ pm_eligible: bool | None = Field(default=None, alias="pmEligible")
674
+ house_excess_equity_curr: Decimal | None = Field(default=None, alias="houseExcessEquityCurr")
675
+ house_excess_equity_change: Decimal | None = Field(
676
+ default=None, alias="houseExcessEquityChange"
677
+ )
678
+
679
+
680
+ class Disclosure(BrokerModel):
681
+ eh_disclosure_flag: bool | None = Field(default=None, alias="ehDisclosureFlag")
682
+ ah_disclosure_flag: bool | None = Field(default=None, alias="ahDisclosureFlag")
683
+ conditional_disclosure_flag: bool | None = Field(
684
+ default=None, alias="conditionalDisclosureFlag"
685
+ )
686
+ ao_disclosure_flag: bool | None = Field(default=None, alias="aoDisclosureFlag")
687
+ mf_fl_consent: bool | None = Field(default=None, alias="mfFLConsent")
688
+ mf_eo_consent: bool | None = Field(default=None, alias="mfEOConsent")
689
+
690
+
691
+ class OrdersRequest(BaseModel):
692
+ """Query parameters for listing orders."""
693
+
694
+ model_config = ConfigDict(frozen=True, populate_by_name=True)
695
+
696
+ marker: str | None = None
697
+ count: int | None = Field(default=None, ge=1, le=100)
698
+ status: str | None = None
699
+ from_date: str | None = Field(default=None, alias="fromDate")
700
+ to_date: str | None = Field(default=None, alias="toDate")
701
+ symbol: str | None = None
702
+ security_type: str | None = Field(default=None, alias="securityType")
703
+ transaction_type: str | None = Field(default=None, alias="transactionType")
704
+ market_session: str | None = Field(default=None, alias="marketSession")
705
+
706
+ @field_validator(
707
+ "marker",
708
+ "status",
709
+ "from_date",
710
+ "to_date",
711
+ "symbol",
712
+ "security_type",
713
+ "transaction_type",
714
+ "market_session",
715
+ )
716
+ @classmethod
717
+ def normalize_strings(cls, value: str | None) -> str | None:
718
+ if value is None:
719
+ return None
720
+ normalized = value.strip()
721
+ if not normalized:
722
+ raise ValueError("A nonempty value is required")
723
+ return normalized
724
+
725
+ def query_params(self) -> dict[str, str | int | None]:
726
+ return {
727
+ "marker": self.marker,
728
+ "count": self.count,
729
+ "status": self.status,
730
+ "fromDate": self.from_date,
731
+ "toDate": self.to_date,
732
+ "symbol": self.symbol,
733
+ "securityType": self.security_type,
734
+ "transactionType": self.transaction_type,
735
+ "marketSession": self.market_session,
736
+ }
737
+
738
+
739
+ class PreviewOrderRequest(BaseModel):
740
+ """Request body for previewing a new or changed order."""
741
+
742
+ model_config = ConfigDict(frozen=True, populate_by_name=True)
743
+
744
+ order_type: str = Field(alias="orderType")
745
+ client_order_id: str = Field(alias="clientOrderId")
746
+ orders: list[OrderDetailRequest] = Field(alias="Order")
747
+
748
+ @field_validator("order_type")
749
+ @classmethod
750
+ def normalize_order_type(cls, value: str) -> str:
751
+ return _supported_value(value, VALID_ORDER_TYPES, "order_type")
752
+
753
+ @field_validator("client_order_id")
754
+ @classmethod
755
+ def valid_client_order_id(cls, value: str) -> str:
756
+ normalized = value.strip()
757
+ if not _CLIENT_ORDER_ID_RE.fullmatch(normalized):
758
+ raise ValueError("client_order_id must be 1-20 alphanumeric characters")
759
+ return normalized
760
+
761
+ @field_validator("orders", mode="before")
762
+ @classmethod
763
+ def normalize_orders(cls, value: object) -> object:
764
+ if isinstance(value, dict):
765
+ return [cast(dict[str, Any], value)]
766
+ return value
767
+
768
+ @model_validator(mode="after")
769
+ def validate_orders(self) -> PreviewOrderRequest:
770
+ if not self.orders:
771
+ raise ValueError("At least one order is required")
772
+ return self
773
+
774
+ def request_body(self) -> dict[str, Any]:
775
+ payload = self.model_dump(by_alias=True, exclude_none=True, mode="json")
776
+ return {"PreviewOrderRequest": cast(dict[str, Any], _strip_broker_metadata(payload))}
777
+
778
+
779
+ class PlaceOrderRequest(PreviewOrderRequest):
780
+ """Request body for placing a previewed new or changed order."""
781
+
782
+ preview_ids: list[PreviewId] = Field(alias="PreviewIds")
783
+
784
+ @field_validator("preview_ids", mode="before")
785
+ @classmethod
786
+ def normalize_preview_ids(cls, value: object) -> object:
787
+ if value is None:
788
+ return value
789
+ if isinstance(value, int):
790
+ return [{"previewId": value}]
791
+ if isinstance(value, dict):
792
+ return [cast(dict[str, Any], value)]
793
+ if isinstance(value, list):
794
+ items = cast(list[object], value)
795
+ return [{"previewId": item} if isinstance(item, int) else item for item in items]
796
+ return value
797
+
798
+ @model_validator(mode="after")
799
+ def validate_preview_ids(self) -> PlaceOrderRequest:
800
+ if not self.preview_ids:
801
+ raise ValueError("At least one preview ID is required")
802
+ for preview in self.preview_ids:
803
+ PreviewId.model_validate({"previewId": preview.preview_id})
804
+ return self
805
+
806
+ def request_body(self) -> dict[str, Any]:
807
+ payload = self.model_dump(by_alias=True, exclude_none=True, mode="json")
808
+ return {"PlaceOrderRequest": cast(dict[str, Any], _strip_broker_metadata(payload))}
809
+
810
+
811
+ class CancelOrderRequest(BaseModel):
812
+ model_config = ConfigDict(frozen=True, populate_by_name=True)
813
+
814
+ order_id: int = Field(alias="orderId", strict=True, ge=1)
815
+
816
+ def request_body(self) -> dict[str, Any]:
817
+ return {"CancelOrderRequest": self.model_dump(by_alias=True, mode="json")}
818
+
819
+
820
+ def _empty_orders() -> list[Order]:
821
+ return []
822
+
823
+
824
+ class OrdersResponse(BrokerModel):
825
+ orders: list[Order] = Field(default_factory=_empty_orders, alias="order")
826
+ marker: str | None = None
827
+ next: str | None = None
828
+ messages: Messages | None = None
829
+
830
+ @model_validator(mode="before")
831
+ @classmethod
832
+ def normalize_response(cls, value: object) -> object:
833
+ if not isinstance(value, dict):
834
+ return value
835
+ data = dict(cast(dict[str, Any], value))
836
+ for source in ("Order", "orders"):
837
+ if source in data and "order" not in data:
838
+ data["order"] = data[source]
839
+ data.pop(source, None)
840
+ for source in ("Messages",):
841
+ if source in data and "messages" not in data:
842
+ data["messages"] = data[source]
843
+ data.pop(source, None)
844
+ return data
845
+
846
+ @field_validator("orders", mode="before")
847
+ @classmethod
848
+ def normalize_orders(cls, value: object) -> object:
849
+ if value is None:
850
+ return []
851
+ if isinstance(value, dict):
852
+ return [cast(dict[str, Any], value)]
853
+ return value
854
+
855
+
856
+ def _empty_response_order_details() -> list[OrderDetail]:
857
+ return []
858
+
859
+
860
+ def _empty_preview_ids() -> list[PreviewResponseId]:
861
+ return []
862
+
863
+
864
+ class PreviewOrderResponse(BrokerModel):
865
+ order_type: str | None = Field(default=None, alias="orderType")
866
+ total_order_value: Decimal | None = Field(default=None, alias="totalOrderValue")
867
+ preview_time: datetime | None = Field(default=None, alias="previewTime")
868
+ dst_flag: bool | None = Field(default=None, alias="dstFlag")
869
+ account_id: str | None = Field(default=None, alias="accountId")
870
+ option_level_cd: int | None = Field(default=None, alias="optionLevelCd")
871
+ margin_level_cd: str | None = Field(default=None, alias="marginLevelCd")
872
+ orders: list[OrderDetail] = Field(default_factory=_empty_response_order_details, alias="Order")
873
+ preview_ids: list[PreviewResponseId] = Field(
874
+ default_factory=_empty_preview_ids, alias="PreviewIds"
875
+ )
876
+ disclosure: Disclosure | None = Field(default=None, alias="Disclosure")
877
+ settled: OrderBuyPowerEffect | None = None
878
+ settled_unsettled: OrderBuyPowerEffect | None = Field(default=None, alias="settledUnsettled")
879
+ message_list: Messages | None = Field(default=None, alias="messageList")
880
+ total_commission: Decimal | None = Field(default=None, alias="totalCommission")
881
+ portfolio_margin: PortfolioMargin | None = Field(default=None, alias="portfolioMargin")
882
+ is_employee: bool | None = Field(default=None, alias="isEmployee")
883
+ commission_message: str | None = Field(default=None, alias="commissionMessage")
884
+ client_order_id: str | None = Field(default=None, alias="clientOrderId")
885
+ margin_bp_details: MarginBuyingPowerDetails | None = Field(
886
+ default=None, alias="marginBpDetails"
887
+ )
888
+ cash_bp_details: CashBuyingPowerDetails | None = Field(default=None, alias="cashBpDetails")
889
+ dt_bp_details: DtBuyingPowerDetails | None = Field(default=None, alias="dtBpDetails")
890
+
891
+ @model_validator(mode="before")
892
+ @classmethod
893
+ def normalize_response(cls, value: object) -> object:
894
+ return _normalize_aliases(
895
+ value,
896
+ {
897
+ "order": "Order",
898
+ "previewIds": "PreviewIds",
899
+ "disclosure": "Disclosure",
900
+ "MessageList": "messageList",
901
+ "Messages": "messageList",
902
+ "messages": "messageList",
903
+ "PortfolioMargin": "portfolioMargin",
904
+ "MarginBpDetails": "marginBpDetails",
905
+ "CashBpDetails": "cashBpDetails",
906
+ "DtBpDetails": "dtBpDetails",
907
+ },
908
+ )
909
+
910
+ @field_validator("preview_time", mode="before")
911
+ @classmethod
912
+ def parse_preview_time(cls, value: object) -> datetime | None:
913
+ return parse_broker_datetime(value)
914
+
915
+ @field_validator("preview_ids", mode="before")
916
+ @classmethod
917
+ def normalize_preview_ids(cls, value: object) -> object:
918
+ return _normalize_response_ids(value, "previewId", "PreviewId")
919
+
920
+ @field_validator("orders", mode="before")
921
+ @classmethod
922
+ def normalize_lists(cls, value: object) -> object:
923
+ if value is None:
924
+ return []
925
+ if isinstance(value, dict):
926
+ return [cast(dict[str, Any], value)]
927
+ return value
928
+
929
+
930
+ def _empty_order_ids() -> list[OrderResponseId]:
931
+ return []
932
+
933
+
934
+ class PlaceOrderResponse(BrokerModel):
935
+ order_type: str | None = Field(default=None, alias="orderType")
936
+ placed_time: datetime | None = Field(default=None, alias="placedTime")
937
+ dst_flag: bool | None = Field(default=None, alias="dstFlag")
938
+ account_id: str | None = Field(default=None, alias="accountId")
939
+ option_level_cd: int | None = Field(default=None, alias="optionLevelCd")
940
+ margin_level_cd: str | None = Field(default=None, alias="marginLevelCd")
941
+ orders: list[OrderDetail] = Field(default_factory=_empty_response_order_details, alias="Order")
942
+ order_ids: list[OrderResponseId] = Field(default_factory=_empty_order_ids, alias="OrderIds")
943
+ message_list: Messages | None = Field(default=None, alias="messageList")
944
+ total_order_value: Decimal | None = Field(default=None, alias="totalOrderValue")
945
+ total_commission: Decimal | None = Field(default=None, alias="totalCommission")
946
+ order_id: int | None = Field(default=None, alias="orderId")
947
+ is_employee: bool | None = Field(default=None, alias="isEmployee")
948
+ commission_msg: str | None = Field(default=None, alias="commissionMsg")
949
+ portfolio_margin: PortfolioMargin | None = Field(default=None, alias="portfolioMargin")
950
+ disclosure: Disclosure | None = None
951
+ client_order_id: str | None = Field(default=None, alias="clientOrderId")
952
+
953
+ @model_validator(mode="before")
954
+ @classmethod
955
+ def normalize_response(cls, value: object) -> object:
956
+ return _normalize_aliases(
957
+ value,
958
+ {
959
+ "order": "Order",
960
+ "orderIds": "OrderIds",
961
+ "Disclosure": "disclosure",
962
+ "MessageList": "messageList",
963
+ "Messages": "messageList",
964
+ "messages": "messageList",
965
+ "PortfolioMargin": "portfolioMargin",
966
+ },
967
+ )
968
+
969
+ @field_validator("placed_time", mode="before")
970
+ @classmethod
971
+ def parse_placed_time(cls, value: object) -> datetime | None:
972
+ return parse_broker_datetime(value)
973
+
974
+ @field_validator("order_ids", mode="before")
975
+ @classmethod
976
+ def normalize_order_ids(cls, value: object) -> object:
977
+ return _normalize_response_ids(value, "orderId", "OrderId")
978
+
979
+ @field_validator("orders", mode="before")
980
+ @classmethod
981
+ def normalize_lists(cls, value: object) -> object:
982
+ if value is None:
983
+ return []
984
+ if isinstance(value, dict):
985
+ return [cast(dict[str, Any], value)]
986
+ return value
987
+
988
+
989
+ class CancelOrderResponse(BrokerModel):
990
+ account_id: str | None = Field(default=None, alias="accountId")
991
+ order_id: int | None = Field(default=None, alias="orderId")
992
+ cancel_time: datetime | None = Field(default=None, alias="cancelTime")
993
+ messages: Messages | None = None
994
+
995
+ @field_validator("cancel_time", mode="before")
996
+ @classmethod
997
+ def parse_cancel_time(cls, value: object) -> datetime | None:
998
+ return parse_broker_datetime(value)
999
+
1000
+ @model_validator(mode="before")
1001
+ @classmethod
1002
+ def normalize_response(cls, value: object) -> object:
1003
+ if not isinstance(value, dict):
1004
+ return value
1005
+ data = dict(cast(dict[str, Any], value))
1006
+ if "Messages" in data and "messages" not in data:
1007
+ data["messages"] = data["Messages"]
1008
+ data.pop("Messages", None)
1009
+ return data