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,617 @@
1
+ """Fluent helpers for constructing E*TRADE order requests."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping, Sequence
6
+ from datetime import date
7
+ from decimal import Decimal
8
+ from typing import Any, Literal, TypeAlias, cast
9
+
10
+ from etrade_python.orders.models import (
11
+ Disclosure,
12
+ OrderInstrumentRequest,
13
+ PlaceOrderRequest,
14
+ PreviewId,
15
+ PreviewOrderRequest,
16
+ )
17
+ from etrade_python.orders.values import (
18
+ VALID_MARKET_SESSIONS,
19
+ VALID_ORDER_ACTIONS,
20
+ VALID_ORDER_TERMS,
21
+ VALID_ORDER_TYPES,
22
+ VALID_PRICE_TYPES,
23
+ VALID_QUANTITY_TYPES,
24
+ VALID_SECURITY_TYPES,
25
+ MarketSession,
26
+ OrderAction,
27
+ OrderTerm,
28
+ OrderType,
29
+ PriceType,
30
+ QuantityType,
31
+ SecurityType,
32
+ )
33
+
34
+ __all__ = [
35
+ "QuantityType",
36
+ "OrderTerm",
37
+ "OrderType",
38
+ "PriceType",
39
+ "MarketSession",
40
+ "OrderAction",
41
+ "SecurityType",
42
+ "VALID_QUANTITY_TYPES",
43
+ "VALID_ORDER_TERMS",
44
+ "VALID_ORDER_TYPES",
45
+ "VALID_PRICE_TYPES",
46
+ "VALID_MARKET_SESSIONS",
47
+ "VALID_ORDER_ACTIONS",
48
+ "VALID_SECURITY_TYPES",
49
+ "OrderBuilder",
50
+ "NumberLike",
51
+ "PreviewIdInput",
52
+ "InstrumentInput",
53
+ "DisclosureInput",
54
+ ]
55
+
56
+
57
+ NumberLike: TypeAlias = Decimal | int | str | float
58
+ PreviewIdInput: TypeAlias = int | dict[str, int] | PreviewId
59
+ InstrumentInput: TypeAlias = OrderInstrumentRequest | Mapping[str, Any]
60
+ DisclosureInput: TypeAlias = Disclosure | Mapping[str, Any]
61
+
62
+
63
+ class OrderBuilder:
64
+ """Build typed E*TRADE preview and place order requests."""
65
+
66
+ def __init__(self, account_id_key: str) -> None:
67
+ self._account_id_key = _nonempty(account_id_key, "account_id_key")
68
+ self._order_id: int | None = None
69
+ self._order_type: OrderType | None = None
70
+ self._client_order_id: str | None = None
71
+ self._symbol: str | None = None
72
+ self._expiration: date | None = None
73
+ self._quantity_type: QuantityType | None = None
74
+ self._order_term: OrderTerm = "GOOD_FOR_DAY"
75
+ self._price_type: PriceType | None = None
76
+ self._limit_price: Decimal | None = None
77
+ self._stop_price: Decimal | str | None = None
78
+ self._stop_limit_price: Decimal | None = None
79
+ self._market_session: MarketSession = "REGULAR"
80
+ self._all_or_none: bool | str | None = "false"
81
+ self._disclosure: Disclosure | None = None
82
+ self._detail_fields: dict[str, Any] = {}
83
+ self._instruments: list[OrderInstrumentRequest] = []
84
+
85
+ @classmethod
86
+ def for_account(cls, account_id_key: str) -> OrderBuilder:
87
+ return cls(account_id_key)
88
+
89
+ @property
90
+ def account_id_key(self) -> str:
91
+ return self._account_id_key
92
+
93
+ @property
94
+ def order_id_value(self) -> int | None:
95
+ return self._order_id
96
+
97
+ @property
98
+ def order_id_for_change_value(self) -> int | None:
99
+ return self._order_id
100
+
101
+ def client_order_id(self, client_order_id: str) -> OrderBuilder:
102
+ self._client_order_id = _nonempty(client_order_id, "client_order_id")
103
+ return self
104
+
105
+ def order_id(self, order_id: int) -> OrderBuilder:
106
+ if type(order_id) is not int or order_id < 1:
107
+ raise ValueError("order_id must be a positive integer")
108
+ self._order_id = order_id
109
+ return self
110
+
111
+ def order_id_for_change(self, order_id: int) -> OrderBuilder:
112
+ return self.order_id(order_id)
113
+
114
+ def order_type(self, order_type: OrderType | str) -> OrderBuilder:
115
+ self._order_type = cast(OrderType, _valid_enum(order_type, VALID_ORDER_TYPES, "order_type"))
116
+ return self
117
+
118
+ def with_symbol(self, symbol: str) -> OrderBuilder:
119
+ self._symbol = _upper(symbol, "symbol")
120
+ return self
121
+
122
+ def with_expiry(self, year: int, month: int, day: int) -> OrderBuilder:
123
+ try:
124
+ self._expiration = date(year, month, day)
125
+ except ValueError as exc:
126
+ raise ValueError("expiry date must be a valid calendar date") from exc
127
+ return self
128
+
129
+ def with_expiration(self, expiration: date) -> OrderBuilder:
130
+ self._expiration = expiration
131
+ return self
132
+
133
+ def quantity_type(self, quantity_type: QuantityType | str) -> OrderBuilder:
134
+ self._quantity_type = cast(
135
+ QuantityType, _valid_enum(quantity_type, VALID_QUANTITY_TYPES, "quantity_type")
136
+ )
137
+ return self
138
+
139
+ def term(self, order_term: OrderTerm | str) -> OrderBuilder:
140
+ self._order_term = cast(OrderTerm, _valid_enum(order_term, VALID_ORDER_TERMS, "order_term"))
141
+ return self
142
+
143
+ def gfd(self) -> OrderBuilder:
144
+ return self.term("GOOD_FOR_DAY")
145
+
146
+ def gtc(self) -> OrderBuilder:
147
+ return self.term("GOOD_UNTIL_CANCEL")
148
+
149
+ def price_type(self, price_type: PriceType | str) -> OrderBuilder:
150
+ self._price_type = cast(PriceType, _valid_enum(price_type, VALID_PRICE_TYPES, "price_type"))
151
+ return self
152
+
153
+ def market(self) -> OrderBuilder:
154
+ self._price_type = "MARKET"
155
+ self._limit_price = None
156
+ self._stop_price = None
157
+ self._stop_limit_price = None
158
+ return self
159
+
160
+ def limit(self, limit_price: NumberLike) -> OrderBuilder:
161
+ return self.limit_price(limit_price)
162
+
163
+ def limit_price(self, limit_price: NumberLike) -> OrderBuilder:
164
+ self._price_type = "LIMIT"
165
+ self._limit_price = _positive_decimal(limit_price, "limit_price")
166
+ return self
167
+
168
+ def net_debit(self, limit_price: NumberLike) -> OrderBuilder:
169
+ self._price_type = "NET_DEBIT"
170
+ self._limit_price = _positive_decimal(limit_price, "limit_price")
171
+ return self
172
+
173
+ def net_credit(self, limit_price: NumberLike) -> OrderBuilder:
174
+ self._price_type = "NET_CREDIT"
175
+ self._limit_price = _positive_decimal(limit_price, "limit_price")
176
+ return self
177
+
178
+ def stop(self, stop_price: NumberLike) -> OrderBuilder:
179
+ return self.stop_price(stop_price)
180
+
181
+ def stop_price(self, stop_price: NumberLike) -> OrderBuilder:
182
+ self._price_type = "STOP"
183
+ self._stop_price = _positive_decimal(stop_price, "stop_price")
184
+ self._limit_price = None
185
+ return self
186
+
187
+ def stop_limit(self, stop_price: NumberLike, limit_price: NumberLike) -> OrderBuilder:
188
+ self._price_type = "STOP_LIMIT"
189
+ self._stop_price = _positive_decimal(stop_price, "stop_price")
190
+ self._stop_limit_price = _positive_decimal(limit_price, "stop_limit_price")
191
+ self._limit_price = self._stop_limit_price
192
+ return self
193
+
194
+ def stop_limit_price(self, stop_limit_price: NumberLike) -> OrderBuilder:
195
+ self._price_type = "STOP_LIMIT"
196
+ self._stop_limit_price = _positive_decimal(stop_limit_price, "stop_limit_price")
197
+ self._limit_price = self._stop_limit_price
198
+ return self
199
+
200
+ def market_session(self, market_session: MarketSession | str) -> OrderBuilder:
201
+ self._market_session = cast(
202
+ MarketSession, _valid_enum(market_session, VALID_MARKET_SESSIONS, "market_session")
203
+ )
204
+ return self
205
+
206
+ def all_or_none(self, all_or_none: bool) -> OrderBuilder:
207
+ self._all_or_none = all_or_none
208
+ return self
209
+
210
+ def disclosure(self, disclosure: DisclosureInput) -> OrderBuilder:
211
+ if isinstance(disclosure, Disclosure):
212
+ self._disclosure = disclosure
213
+ else:
214
+ self._disclosure = Disclosure.model_validate(dict(disclosure))
215
+ return self
216
+
217
+ def with_detail(self, fields: Mapping[str, Any]) -> OrderBuilder:
218
+ for key, value in fields.items():
219
+ if key not in {"instrument", "Instrument"}:
220
+ self._detail_fields[key] = value
221
+ return self
222
+
223
+ def add_instrument(self, instrument: InstrumentInput) -> OrderBuilder:
224
+ if isinstance(instrument, OrderInstrumentRequest):
225
+ model = instrument
226
+ else:
227
+ data = dict(instrument)
228
+ if data.get("quantityType") is None and data.get("quantity_type") is None:
229
+ data["quantityType"] = self._quantity_type
230
+ elif data.get("quantityType") is not None:
231
+ data["quantityType"] = _valid_enum(
232
+ str(data["quantityType"]), VALID_QUANTITY_TYPES, "quantity_type"
233
+ )
234
+ elif data.get("quantity_type") is not None:
235
+ data["quantity_type"] = _valid_enum(
236
+ str(data["quantity_type"]), VALID_QUANTITY_TYPES, "quantity_type"
237
+ )
238
+ model = OrderInstrumentRequest.model_validate(data)
239
+ self._instruments.append(model)
240
+ return self
241
+
242
+ def add_equity(
243
+ self,
244
+ order_action: OrderAction | str,
245
+ quantity: NumberLike = Decimal("1"),
246
+ *,
247
+ symbol: str | None = None,
248
+ overrides: Mapping[str, Any] | None = None,
249
+ ) -> OrderBuilder:
250
+ overrides = dict(overrides or {})
251
+ action = cast(OrderAction, _valid_enum(order_action, VALID_ORDER_ACTIONS, "order_action"))
252
+ symbol_value = _symbol_from_override(overrides, symbol, self._symbol, "equity")
253
+ security_type = cast(
254
+ SecurityType,
255
+ _valid_enum(
256
+ str(overrides.get("securityType", "EQ")), VALID_SECURITY_TYPES, "security_type"
257
+ ),
258
+ )
259
+ data: dict[str, Any] = {
260
+ "orderAction": action,
261
+ "quantityType": overrides.get("quantityType", self._quantity_type),
262
+ "quantity": _positive_decimal(quantity, "quantity"),
263
+ "orderedQuantity": _optional_decimal(
264
+ overrides.get("orderedQuantity"), "ordered_quantity"
265
+ ),
266
+ "Product": {"symbol": symbol_value, "securityType": security_type},
267
+ }
268
+ return self.add_instrument(_drop_none(data))
269
+
270
+ def add_long_call(
271
+ self,
272
+ strike_price: NumberLike,
273
+ quantity: NumberLike = Decimal("1"),
274
+ overrides: Mapping[str, Any] | None = None,
275
+ ) -> OrderBuilder:
276
+ return self._add_option_leg("CALL", "BUY_OPEN", strike_price, quantity, overrides)
277
+
278
+ def add_short_call(
279
+ self,
280
+ strike_price: NumberLike,
281
+ quantity: NumberLike = Decimal("1"),
282
+ overrides: Mapping[str, Any] | None = None,
283
+ ) -> OrderBuilder:
284
+ return self._add_option_leg("CALL", "SELL_OPEN", strike_price, quantity, overrides)
285
+
286
+ def add_long_put(
287
+ self,
288
+ strike_price: NumberLike,
289
+ quantity: NumberLike = Decimal("1"),
290
+ overrides: Mapping[str, Any] | None = None,
291
+ ) -> OrderBuilder:
292
+ return self._add_option_leg("PUT", "BUY_OPEN", strike_price, quantity, overrides)
293
+
294
+ def add_short_put(
295
+ self,
296
+ strike_price: NumberLike,
297
+ quantity: NumberLike = Decimal("1"),
298
+ overrides: Mapping[str, Any] | None = None,
299
+ ) -> OrderBuilder:
300
+ return self._add_option_leg("PUT", "SELL_OPEN", strike_price, quantity, overrides)
301
+
302
+ def equity_limit(
303
+ self,
304
+ symbol: str,
305
+ *,
306
+ action: OrderAction | str,
307
+ quantity: NumberLike,
308
+ limit_price: NumberLike,
309
+ ) -> OrderBuilder:
310
+ return (
311
+ self.order_type("EQ")
312
+ .with_symbol(symbol)
313
+ .limit_price(limit_price)
314
+ .add_equity(action, quantity)
315
+ )
316
+
317
+ def long_call_limit(
318
+ self,
319
+ symbol: str,
320
+ *,
321
+ expiration: date,
322
+ strike_price: NumberLike,
323
+ quantity: NumberLike,
324
+ limit_price: NumberLike,
325
+ ) -> OrderBuilder:
326
+ return (
327
+ self.order_type("OPTN")
328
+ .with_symbol(symbol)
329
+ .with_expiration(expiration)
330
+ .limit_price(limit_price)
331
+ .add_long_call(strike_price, quantity)
332
+ )
333
+
334
+ def option_vertical_call(
335
+ self,
336
+ symbol: str,
337
+ *,
338
+ expiration: date,
339
+ quantity: NumberLike,
340
+ long_strike: NumberLike,
341
+ short_strike: NumberLike,
342
+ net_debit: NumberLike,
343
+ ) -> OrderBuilder:
344
+ return (
345
+ self.order_type("SPREADS")
346
+ .with_symbol(symbol)
347
+ .with_expiration(expiration)
348
+ .net_debit(net_debit)
349
+ .add_long_call(long_strike, quantity)
350
+ .add_short_call(short_strike, quantity)
351
+ )
352
+
353
+ def three_leg_call_spread_short_put(
354
+ self,
355
+ symbol: str,
356
+ *,
357
+ expiration: date,
358
+ quantity: NumberLike,
359
+ long_call_strike: NumberLike,
360
+ short_call_strike: NumberLike,
361
+ short_put_strike: NumberLike,
362
+ net_debit: NumberLike,
363
+ ) -> OrderBuilder:
364
+ return (
365
+ self.order_type("SPREADS")
366
+ .with_symbol(symbol)
367
+ .with_expiration(expiration)
368
+ .net_debit(net_debit)
369
+ .add_long_call(long_call_strike, quantity)
370
+ .add_short_call(short_call_strike, quantity)
371
+ .add_short_put(short_put_strike, quantity)
372
+ )
373
+
374
+ def iron_condor(
375
+ self,
376
+ symbol: str,
377
+ *,
378
+ expiration: date,
379
+ quantity: NumberLike,
380
+ short_put_strike: NumberLike,
381
+ long_put_strike: NumberLike,
382
+ short_call_strike: NumberLike,
383
+ long_call_strike: NumberLike,
384
+ net_credit: NumberLike,
385
+ ) -> OrderBuilder:
386
+ return (
387
+ self.order_type("SPREADS")
388
+ .with_symbol(symbol)
389
+ .with_expiration(expiration)
390
+ .net_credit(net_credit)
391
+ .add_short_put(short_put_strike, quantity)
392
+ .add_long_put(long_put_strike, quantity)
393
+ .add_short_call(short_call_strike, quantity)
394
+ .add_long_call(long_call_strike, quantity)
395
+ )
396
+
397
+ def buy_write(
398
+ self,
399
+ symbol: str,
400
+ *,
401
+ expiration: date,
402
+ stock_quantity: NumberLike,
403
+ call_quantity: NumberLike,
404
+ call_strike: NumberLike,
405
+ net_debit: NumberLike,
406
+ ) -> OrderBuilder:
407
+ return (
408
+ self.order_type("BUY_WRITES")
409
+ .with_symbol(symbol)
410
+ .net_debit(net_debit)
411
+ .add_equity("BUY", stock_quantity)
412
+ .with_expiration(expiration)
413
+ .add_short_call(call_strike, call_quantity)
414
+ )
415
+
416
+ def build_preview_request(self) -> PreviewOrderRequest:
417
+ self._assert_ready_for_preview()
418
+ return PreviewOrderRequest.model_validate(
419
+ {
420
+ "orderType": self._order_type,
421
+ "clientOrderId": self._client_order_id,
422
+ "Order": [self._build_order_detail_payload()],
423
+ }
424
+ )
425
+
426
+ def build_change_preview_request(self, order_id: int | None = None) -> PreviewOrderRequest:
427
+ if order_id is not None:
428
+ self.order_id(order_id)
429
+ if self._order_id is None:
430
+ raise ValueError("order_id is required to preview an order change")
431
+ return self.build_preview_request()
432
+
433
+ def build_place_request(self, preview_ids: Sequence[PreviewIdInput]) -> PlaceOrderRequest:
434
+ self._assert_ready_for_preview()
435
+ normalized_ids = _normalize_preview_ids(preview_ids)
436
+ return PlaceOrderRequest.model_validate(
437
+ {
438
+ "orderType": self._order_type,
439
+ "clientOrderId": self._client_order_id,
440
+ "PreviewIds": normalized_ids,
441
+ "Order": [self._build_order_detail_payload()],
442
+ }
443
+ )
444
+
445
+ def build_change_place_request(
446
+ self, preview_ids: Sequence[PreviewIdInput], order_id: int | None = None
447
+ ) -> PlaceOrderRequest:
448
+ if order_id is not None:
449
+ self.order_id(order_id)
450
+ if self._order_id is None:
451
+ raise ValueError("order_id is required to place an order change")
452
+ return self.build_place_request(preview_ids)
453
+
454
+ def _add_option_leg(
455
+ self,
456
+ call_put: Literal["CALL", "PUT"],
457
+ order_action: OrderAction,
458
+ strike_price: NumberLike,
459
+ quantity: NumberLike,
460
+ overrides: Mapping[str, Any] | None,
461
+ ) -> OrderBuilder:
462
+ overrides = dict(overrides or {})
463
+ symbol = _symbol_from_override(overrides, None, self._symbol, "option")
464
+ expiry = _expiry_from_override(overrides, self._expiration)
465
+ security_type = cast(
466
+ SecurityType,
467
+ _valid_enum(
468
+ str(overrides.get("securityType", "OPTN")), VALID_SECURITY_TYPES, "security_type"
469
+ ),
470
+ )
471
+ data: dict[str, Any] = {
472
+ "orderAction": order_action,
473
+ "quantityType": overrides.get("quantityType", self._quantity_type),
474
+ "quantity": _positive_decimal(quantity, "quantity"),
475
+ "orderedQuantity": _optional_decimal(
476
+ overrides.get("orderedQuantity", quantity), "ordered_quantity"
477
+ ),
478
+ "Product": {
479
+ "symbol": symbol,
480
+ "securityType": security_type,
481
+ "callPut": call_put,
482
+ "expiryYear": expiry.year,
483
+ "expiryMonth": expiry.month,
484
+ "expiryDay": expiry.day,
485
+ "strikePrice": _positive_decimal(strike_price, "strike_price"),
486
+ },
487
+ }
488
+ return self.add_instrument(_drop_none(data))
489
+
490
+ def _build_order_detail_payload(self) -> dict[str, Any]:
491
+ payload: dict[str, Any] = {
492
+ "allOrNone": self._all_or_none,
493
+ "priceType": self._price_type,
494
+ "orderTerm": self._order_term,
495
+ "marketSession": self._market_session,
496
+ "stopPrice": self._stop_price,
497
+ "limitPrice": self._limit_price,
498
+ "stopLimitPrice": self._stop_limit_price,
499
+ "disclosure": self._disclosure,
500
+ }
501
+ payload.update(self._detail_fields)
502
+ payload.pop("instrument", None)
503
+ payload.pop("Instrument", None)
504
+ payload["Instrument"] = list(self._instruments)
505
+ if payload.get("stopPrice") == "":
506
+ payload["stopPrice"] = None
507
+ return _drop_none(payload)
508
+
509
+ def _assert_ready_for_preview(self) -> None:
510
+ if self._client_order_id is None:
511
+ raise ValueError("client_order_id is required")
512
+ if self._order_type is None:
513
+ raise ValueError("order_type is required")
514
+ if self._price_type is None:
515
+ raise ValueError("price_type is required")
516
+ if not self._instruments:
517
+ raise ValueError("At least one instrument is required")
518
+
519
+ def _require_symbol(self) -> str:
520
+ if self._symbol is None:
521
+ raise ValueError("symbol is required")
522
+ return self._symbol
523
+
524
+
525
+ def _normalize_preview_ids(preview_ids: Sequence[PreviewIdInput]) -> list[PreviewId]:
526
+ if not preview_ids:
527
+ raise ValueError("At least one preview ID is required")
528
+ normalized: list[PreviewId] = []
529
+ for preview_id in preview_ids:
530
+ if isinstance(preview_id, PreviewId):
531
+ PreviewId.model_validate({"previewId": preview_id.preview_id})
532
+ normalized.append(preview_id)
533
+ continue
534
+ if isinstance(preview_id, bool):
535
+ raise ValueError("preview IDs must be positive integers")
536
+ if isinstance(preview_id, int):
537
+ if preview_id < 1:
538
+ raise ValueError("preview IDs must be positive integers")
539
+ normalized.append(PreviewId(previewId=preview_id))
540
+ continue
541
+ if not isinstance(cast(object, preview_id), Mapping):
542
+ raise ValueError("preview IDs must be positive integers")
543
+ value = preview_id.get("previewId")
544
+ if isinstance(value, bool) or not isinstance(value, int) or value < 1:
545
+ raise ValueError("preview IDs must be positive integers")
546
+ normalized.append(PreviewId(previewId=value))
547
+ return normalized
548
+
549
+
550
+ def _symbol_from_override(
551
+ overrides: Mapping[str, Any], symbol: str | None, default_symbol: str | None, leg_type: str
552
+ ) -> str:
553
+ raw_symbol = overrides.get("symbol", symbol if symbol is not None else default_symbol)
554
+ if raw_symbol is None:
555
+ raise ValueError(f"symbol is required for {leg_type} legs")
556
+ return _upper(str(raw_symbol), "symbol")
557
+
558
+
559
+ def _expiry_from_override(overrides: Mapping[str, Any], default_expiration: date | None) -> date:
560
+ if all(key in overrides for key in ("expiryYear", "expiryMonth", "expiryDay")):
561
+ try:
562
+ return date(
563
+ int(overrides["expiryYear"]),
564
+ int(overrides["expiryMonth"]),
565
+ int(overrides["expiryDay"]),
566
+ )
567
+ except (TypeError, ValueError) as exc:
568
+ raise ValueError("expiry date must be a valid calendar date") from exc
569
+ if default_expiration is None:
570
+ raise ValueError("expiration is required for option legs")
571
+ return default_expiration
572
+
573
+
574
+ def _valid_enum(value: str, allowed: frozenset[str], label: str) -> str:
575
+ normalized = _upper(value, label)
576
+ if normalized not in allowed:
577
+ allowed_values = ", ".join(sorted(allowed))
578
+ raise ValueError(f"{label} must be one of: {allowed_values}")
579
+ return normalized
580
+
581
+
582
+ def _decimal(value: NumberLike, label: str) -> Decimal:
583
+ try:
584
+ decimal = Decimal(str(value))
585
+ except Exception as exc: # noqa: BLE001 - convert arbitrary construction failures
586
+ raise ValueError(f"{label} must be a decimal value") from exc
587
+ if not decimal.is_finite():
588
+ raise ValueError(f"{label} must be finite")
589
+ return decimal
590
+
591
+
592
+ def _optional_decimal(value: object, label: str) -> Decimal | None:
593
+ if value is None:
594
+ return None
595
+ return _decimal(cast(NumberLike, value), label)
596
+
597
+
598
+ def _positive_decimal(value: NumberLike, label: str) -> Decimal:
599
+ decimal = _decimal(value, label)
600
+ if decimal <= 0:
601
+ raise ValueError(f"{label} must be positive")
602
+ return decimal
603
+
604
+
605
+ def _nonempty(value: str, label: str) -> str:
606
+ normalized = value.strip()
607
+ if not normalized:
608
+ raise ValueError(f"{label} is required")
609
+ return normalized
610
+
611
+
612
+ def _upper(value: str, label: str) -> str:
613
+ return _nonempty(value, label).upper()
614
+
615
+
616
+ def _drop_none(data: Mapping[str, Any]) -> dict[str, Any]:
617
+ return {key: value for key, value in data.items() if value is not None}