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,429 @@
1
+ """Typed portfolio request and response models."""
2
+
3
+ from datetime import date, datetime
4
+ from decimal import Decimal
5
+ from typing import Any, cast
6
+
7
+ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
8
+
9
+ from etrade_python._dates import parse_broker_date, parse_broker_datetime
10
+
11
+
12
+ class BrokerModel(BaseModel):
13
+ """Base response model that discards unmodeled broker fields."""
14
+
15
+ model_config = ConfigDict(extra="ignore", frozen=True, populate_by_name=True)
16
+
17
+
18
+ def _bool_query(value: bool | None) -> str | None:
19
+ if value is None:
20
+ return None
21
+ return "true" if value else "false"
22
+
23
+
24
+ class PortfolioRequest(BaseModel):
25
+ """Query parameters for the portfolio endpoint."""
26
+
27
+ model_config = ConfigDict(frozen=True)
28
+
29
+ count: int | None = Field(default=None, ge=1)
30
+ sort_by: str | None = None
31
+ sort_order: str | None = None
32
+ page_number: int | None = Field(default=None, ge=1)
33
+ market_session: str | None = None
34
+ totals_required: bool | None = None
35
+ lots_required: bool | None = None
36
+ view: str | None = None
37
+
38
+ @field_validator("sort_by", "sort_order", "market_session", "view")
39
+ @classmethod
40
+ def nonempty_string(cls, value: str | None) -> str | None:
41
+ if value is not None and not value.strip():
42
+ raise ValueError("A nonempty value is required")
43
+ return value
44
+
45
+ def query_params(self) -> dict[str, str | int | None]:
46
+ return {
47
+ "count": self.count,
48
+ "sortBy": self.sort_by,
49
+ "sortOrder": self.sort_order,
50
+ "pageNumber": self.page_number,
51
+ "marketSession": self.market_session,
52
+ "totalsRequired": _bool_query(self.totals_required),
53
+ "lotsRequired": _bool_query(self.lots_required),
54
+ "view": self.view,
55
+ }
56
+
57
+
58
+ class ProductId(BrokerModel):
59
+ symbol: str | None = None
60
+ type_code: str | None = Field(default=None, alias="typeCode")
61
+
62
+
63
+ class PortfolioProduct(BrokerModel):
64
+ symbol: str | None = None
65
+ security_type: str | None = Field(default=None, alias="securityType")
66
+ security_sub_type: str | None = Field(default=None, alias="securitySubType")
67
+ call_put: str | None = Field(default=None, alias="callPut")
68
+ expiry_year: int | None = Field(default=None, alias="expiryYear")
69
+ expiry_month: int | None = Field(default=None, alias="expiryMonth")
70
+ expiry_day: int | None = Field(default=None, alias="expiryDay")
71
+ strike_price: Decimal | None = Field(default=None, alias="strikePrice")
72
+ expiry_type: str | None = Field(default=None, alias="expiryType")
73
+ product_id: ProductId | None = Field(default=None, alias="productId")
74
+
75
+ @model_validator(mode="before")
76
+ @classmethod
77
+ def normalize_product_id(cls, value: object) -> object:
78
+ if not isinstance(value, dict):
79
+ return value
80
+ data = dict(cast(dict[str, Any], value))
81
+ if "ProductId" in data and "productId" not in data:
82
+ data["productId"] = data["ProductId"]
83
+ data.pop("ProductId", None)
84
+ return data
85
+
86
+
87
+ class QuickView(BrokerModel):
88
+ last_trade: Decimal | None = Field(default=None, alias="lastTrade")
89
+ last_trade_time: datetime | None = Field(default=None, alias="lastTradeTime")
90
+ change: Decimal | None = None
91
+ change_pct: Decimal | None = Field(default=None, alias="changePct")
92
+ volume: int | None = None
93
+ quote_status: str | None = Field(default=None, alias="quoteStatus")
94
+ seven_day_current_yield: Decimal | None = Field(default=None, alias="sevenDayCurrentYield")
95
+ annual_total_return: Decimal | None = Field(default=None, alias="annualTotalReturn")
96
+ weighted_average_maturity: Decimal | None = Field(default=None, alias="weightedAverageMaturity")
97
+
98
+ @field_validator("last_trade_time", mode="before")
99
+ @classmethod
100
+ def parse_last_trade_time(cls, value: object) -> datetime | None:
101
+ return parse_broker_datetime(value)
102
+
103
+
104
+ class PerformanceView(BrokerModel):
105
+ change: Decimal | None = None
106
+ change_pct: Decimal | None = Field(default=None, alias="changePct")
107
+ last_trade: Decimal | None = Field(default=None, alias="lastTrade")
108
+ days_gain: Decimal | None = Field(default=None, alias="daysGain")
109
+ total_gain: Decimal | None = Field(default=None, alias="totalGain")
110
+ total_gain_pct: Decimal | None = Field(default=None, alias="totalGainPct")
111
+ market_value: Decimal | None = Field(default=None, alias="marketValue")
112
+ quote_status: str | None = Field(default=None, alias="quoteStatus")
113
+ last_trade_time: datetime | None = Field(default=None, alias="lastTradeTime")
114
+
115
+ @field_validator("last_trade_time", mode="before")
116
+ @classmethod
117
+ def parse_last_trade_time(cls, value: object) -> datetime | None:
118
+ return parse_broker_datetime(value)
119
+
120
+
121
+ class FundamentalView(BrokerModel):
122
+ last_trade: Decimal | None = Field(default=None, alias="lastTrade")
123
+ last_trade_time: datetime | None = Field(default=None, alias="lastTradeTime")
124
+ change: Decimal | None = None
125
+ change_pct: Decimal | None = Field(default=None, alias="changePct")
126
+ pe_ratio: Decimal | None = Field(default=None, alias="peRatio")
127
+ eps: Decimal | None = None
128
+ dividend: Decimal | None = None
129
+ div_yield: Decimal | None = Field(default=None, alias="divYield")
130
+ market_cap: Decimal | None = Field(default=None, alias="marketCap")
131
+ week52_range: str | None = Field(default=None, alias="week52Range")
132
+ quote_status: str | None = Field(default=None, alias="quoteStatus")
133
+
134
+ @field_validator("last_trade_time", mode="before")
135
+ @classmethod
136
+ def parse_last_trade_time(cls, value: object) -> datetime | None:
137
+ return parse_broker_datetime(value)
138
+
139
+
140
+ class OptionsWatchView(BrokerModel):
141
+ base_symbol_and_price: str | None = Field(default=None, alias="baseSymbolAndPrice")
142
+ premium: Decimal | None = None
143
+ last_trade: Decimal | None = Field(default=None, alias="lastTrade")
144
+ bid: Decimal | None = None
145
+ ask: Decimal | None = None
146
+ quote_status: str | None = Field(default=None, alias="quoteStatus")
147
+ last_trade_time: datetime | None = Field(default=None, alias="lastTradeTime")
148
+
149
+ @field_validator("last_trade_time", mode="before")
150
+ @classmethod
151
+ def parse_last_trade_time(cls, value: object) -> datetime | None:
152
+ return parse_broker_datetime(value)
153
+
154
+
155
+ class CompleteView(BrokerModel):
156
+ price_adjusted_flag: bool | None = Field(default=None, alias="priceAdjustedFlag")
157
+ price: Decimal | None = None
158
+ adj_price: Decimal | None = Field(default=None, alias="adjPrice")
159
+ change: Decimal | None = None
160
+ change_pct: Decimal | None = Field(default=None, alias="changePct")
161
+ prev_close: Decimal | None = Field(default=None, alias="prevClose")
162
+ adj_prev_close: Decimal | None = Field(default=None, alias="adjPrevClose")
163
+ volume: Decimal | None = None
164
+ last_trade: Decimal | None = Field(default=None, alias="lastTrade")
165
+ last_trade_time: datetime | None = Field(default=None, alias="lastTradeTime")
166
+ symbol_description: str | None = Field(default=None, alias="symbolDescription")
167
+ bid: Decimal | None = None
168
+ ask: Decimal | None = None
169
+ quote_status: str | None = Field(default=None, alias="quoteStatus")
170
+ div_pay_date: date | None = Field(default=None, alias="divPayDate")
171
+ ex_dividend_date: date | None = Field(default=None, alias="exDividendDate")
172
+ adj_last_trade: Decimal | None = Field(default=None, alias="adjLastTrade")
173
+ perform1_month: Decimal | None = Field(default=None, alias="perform1Month")
174
+ perform3_month: Decimal | None = Field(default=None, alias="perform3Month")
175
+ perform6_month: Decimal | None = Field(default=None, alias="perform6Month")
176
+ perform12_month: Decimal | None = Field(default=None, alias="perform12Month")
177
+ prev_day_volume: int | None = Field(default=None, alias="prevDayVolume")
178
+ ten_day_volume: int | None = Field(default=None, alias="tenDayVolume")
179
+ beta: Decimal | None = None
180
+ sv10_days_avg: Decimal | None = Field(default=None, alias="sv10DaysAvg")
181
+ sv20_days_avg: Decimal | None = Field(default=None, alias="sv20DaysAvg")
182
+ sv1_mon_avg: Decimal | None = Field(default=None, alias="sv1MonAvg")
183
+ sv2_mon_avg: Decimal | None = Field(default=None, alias="sv2MonAvg")
184
+ sv3_mon_avg: Decimal | None = Field(default=None, alias="sv3MonAvg")
185
+ sv4_mon_avg: Decimal | None = Field(default=None, alias="sv4MonAvg")
186
+ sv6_mon_avg: Decimal | None = Field(default=None, alias="sv6MonAvg")
187
+ week52_high: Decimal | None = Field(default=None, alias="week52High")
188
+ week52_low: Decimal | None = Field(default=None, alias="week52Low")
189
+ week52_range: str | None = Field(default=None, alias="week52Range")
190
+ market_cap: Decimal | None = Field(default=None, alias="marketCap")
191
+ days_range: str | None = Field(default=None, alias="daysRange")
192
+ delta52_wk_high: Decimal | None = Field(default=None, alias="delta52WkHigh")
193
+ delta52_wk_low: Decimal | None = Field(default=None, alias="delta52WkLow")
194
+ currency: str | None = None
195
+ exchange: str | None = None
196
+ marginable: bool | None = None
197
+ bid_ask_spread: Decimal | None = Field(default=None, alias="bidAskSpread")
198
+ bid_size: int | None = Field(default=None, alias="bidSize")
199
+ ask_size: int | None = Field(default=None, alias="askSize")
200
+ open: Decimal | None = None
201
+ delta: Decimal | None = None
202
+ gamma: Decimal | None = None
203
+ iv_pct: Decimal | None = Field(default=None, alias="ivPct")
204
+ rho: Decimal | None = None
205
+ theta: Decimal | None = None
206
+ vega: Decimal | None = None
207
+ premium: Decimal | None = None
208
+ days_to_expiration: int | None = Field(default=None, alias="daysToExpiration")
209
+ intrinsic_value: Decimal | None = Field(default=None, alias="intrinsicValue")
210
+ open_interest: Decimal | None = Field(default=None, alias="openInterest")
211
+ options_adjusted_flag: bool | None = Field(default=None, alias="optionsAdjustedFlag")
212
+ deliverables_str: str | None = Field(default=None, alias="deliverablesStr")
213
+ option_multiplier: Decimal | None = Field(default=None, alias="optionMultiplier")
214
+ base_symbol_and_price: str | None = Field(default=None, alias="baseSymbolAndPrice")
215
+ est_earnings: Decimal | None = Field(default=None, alias="estEarnings")
216
+ eps: Decimal | None = None
217
+ pe_ratio: Decimal | None = Field(default=None, alias="peRatio")
218
+ annual_dividend: Decimal | None = Field(default=None, alias="annualDividend")
219
+ dividend: Decimal | None = None
220
+ div_yield: Decimal | None = Field(default=None, alias="divYield")
221
+ cusip: str | None = None
222
+
223
+ @field_validator("last_trade_time", mode="before")
224
+ @classmethod
225
+ def parse_last_trade_time(cls, value: object) -> datetime | None:
226
+ return parse_broker_datetime(value)
227
+
228
+ @field_validator("div_pay_date", "ex_dividend_date", mode="before")
229
+ @classmethod
230
+ def parse_dividend_dates(cls, value: object) -> date | None:
231
+ return parse_broker_date(value)
232
+
233
+
234
+ class PositionLot(BrokerModel):
235
+ position_id: int | None = Field(default=None, alias="positionId")
236
+ position_lot_id: int | None = Field(default=None, alias="positionLotId")
237
+ price: Decimal | None = None
238
+ term_code: int | None = Field(default=None, alias="termCode")
239
+ days_gain: Decimal | None = Field(default=None, alias="daysGain")
240
+ days_gain_pct: Decimal | None = Field(default=None, alias="daysGainPct")
241
+ market_value: Decimal | None = Field(default=None, alias="marketValue")
242
+ total_cost: Decimal | None = Field(default=None, alias="totalCost")
243
+ total_cost_for_gain_pct: Decimal | None = Field(default=None, alias="totalCostForGainPct")
244
+ total_gain: Decimal | None = Field(default=None, alias="totalGain")
245
+ lot_source_code: int | None = Field(default=None, alias="lotSourceCode")
246
+ original_qty: Decimal | None = Field(default=None, alias="originalQty")
247
+ remaining_qty: Decimal | None = Field(default=None, alias="remainingQty")
248
+ available_qty: Decimal | None = Field(default=None, alias="availableQty")
249
+ order_no: int | None = Field(default=None, alias="orderNo")
250
+ leg_no: int | None = Field(default=None, alias="legNo")
251
+ acquired_date: date | None = Field(default=None, alias="acquiredDate")
252
+ location_code: int | None = Field(default=None, alias="locationCode")
253
+ exchange_rate: Decimal | None = Field(default=None, alias="exchangeRate")
254
+ settlement_currency: str | None = Field(default=None, alias="settlementCurrency")
255
+ payment_currency: str | None = Field(default=None, alias="paymentCurrency")
256
+ adj_price: Decimal | None = Field(default=None, alias="adjPrice")
257
+ comm_per_share: Decimal | None = Field(default=None, alias="commPerShare")
258
+ fees_per_share: Decimal | None = Field(default=None, alias="feesPerShare")
259
+ premium_adj: Decimal | None = Field(default=None, alias="premiumAdj")
260
+ short_type: int | None = Field(default=None, alias="shortType")
261
+
262
+ @field_validator("acquired_date", mode="before")
263
+ @classmethod
264
+ def parse_acquired_date(cls, value: object) -> date | None:
265
+ return parse_broker_date(value)
266
+
267
+
268
+ def _empty_position_lots() -> list[PositionLot]:
269
+ return []
270
+
271
+
272
+ class Position(BrokerModel):
273
+ position_id: int | None = Field(default=None, alias="positionId")
274
+ account_id: str | None = Field(default=None, alias="accountId")
275
+ product: PortfolioProduct | None = Field(default=None, alias="product")
276
+ osi_key: str | None = Field(default=None, alias="osiKey")
277
+ symbol_description: str | None = Field(default=None, alias="symbolDescription")
278
+ date_acquired: date | None = Field(default=None, alias="dateAcquired")
279
+ price_paid: Decimal | None = Field(default=None, alias="pricePaid")
280
+ price: Decimal | None = None
281
+ commissions: Decimal | None = None
282
+ other_fees: Decimal | None = Field(default=None, alias="otherFees")
283
+ quantity: Decimal | None = None
284
+ position_indicator: str | None = Field(default=None, alias="positionIndicator")
285
+ position_type: str | None = Field(default=None, alias="positionType")
286
+ change: Decimal | None = None
287
+ change_pct: Decimal | None = Field(default=None, alias="changePct")
288
+ days_gain: Decimal | None = Field(default=None, alias="daysGain")
289
+ days_gain_pct: Decimal | None = Field(default=None, alias="daysGainPct")
290
+ market_value: Decimal | None = Field(default=None, alias="marketValue")
291
+ total_cost: Decimal | None = Field(default=None, alias="totalCost")
292
+ total_gain: Decimal | None = Field(default=None, alias="totalGain")
293
+ total_gain_pct: Decimal | None = Field(default=None, alias="totalGainPct")
294
+ pct_of_portfolio: Decimal | None = Field(default=None, alias="pctOfPortfolio")
295
+ cost_per_share: Decimal | None = Field(default=None, alias="costPerShare")
296
+ quote_status: str | None = Field(default=None, alias="quoteStatus")
297
+ date_time_utc: datetime | None = Field(default=None, alias="dateTimeUTC")
298
+ adj_prev_close: Decimal | None = Field(default=None, alias="adjPrevClose")
299
+ performance: PerformanceView | None = None
300
+ fundamental: FundamentalView | None = None
301
+ options_watch: OptionsWatchView | None = Field(default=None, alias="optionsWatch")
302
+ quick: QuickView | None = None
303
+ complete: CompleteView | None = None
304
+ lots_details: str | None = Field(default=None, alias="lotsDetails")
305
+ quote_details: str | None = Field(default=None, alias="quoteDetails")
306
+ position_lots: list[PositionLot] = Field(
307
+ default_factory=_empty_position_lots, alias="positionLot"
308
+ )
309
+ today_commissions: Decimal | None = Field(default=None, alias="todayCommissions")
310
+ today_fees: Decimal | None = Field(default=None, alias="todayFees")
311
+ today_price_paid: Decimal | None = Field(default=None, alias="todayPricePaid")
312
+ today_quantity: Decimal | None = Field(default=None, alias="todayQuantity")
313
+
314
+ @field_validator("date_acquired", mode="before")
315
+ @classmethod
316
+ def parse_date_acquired(cls, value: object) -> date | None:
317
+ return parse_broker_date(value)
318
+
319
+ @field_validator("date_time_utc", mode="before")
320
+ @classmethod
321
+ def parse_date_time_utc(cls, value: object) -> datetime | None:
322
+ return parse_broker_datetime(value)
323
+
324
+ @model_validator(mode="before")
325
+ @classmethod
326
+ def normalize_nested_aliases(cls, value: object) -> object:
327
+ if not isinstance(value, dict):
328
+ return value
329
+ data = dict(cast(dict[str, Any], value))
330
+ for source, target in {
331
+ "Product": "product",
332
+ "Performance": "performance",
333
+ "Fundamental": "fundamental",
334
+ "OptionsWatch": "optionsWatch",
335
+ "Quick": "quick",
336
+ "Complete": "complete",
337
+ "PositionLot": "positionLot",
338
+ "quotestatus": "quoteStatus",
339
+ }.items():
340
+ if source in data and target not in data:
341
+ data[target] = data[source]
342
+ data.pop(source, None)
343
+ return data
344
+
345
+ @field_validator("position_lots", mode="before")
346
+ @classmethod
347
+ def normalize_position_lots(cls, value: object) -> object:
348
+ if value is None:
349
+ return []
350
+ if isinstance(value, dict):
351
+ return [cast(dict[str, Any], value)]
352
+ return value
353
+
354
+
355
+ def _empty_positions() -> list[Position]:
356
+ return []
357
+
358
+
359
+ class AccountPortfolio(BrokerModel):
360
+ account_id: str | None = Field(default=None, alias="accountId")
361
+ next: str | None = None
362
+ total_no_of_pages: int | None = Field(default=None, alias="totalNoOfPages")
363
+ total_pages: int | None = Field(default=None, alias="totalPages")
364
+ next_page_no: str | None = Field(default=None, alias="nextPageNo")
365
+ positions: list[Position] = Field(default_factory=_empty_positions, alias="position")
366
+
367
+ @model_validator(mode="before")
368
+ @classmethod
369
+ def normalize_response(cls, value: object) -> object:
370
+ if not isinstance(value, dict):
371
+ return value
372
+ data = dict(cast(dict[str, Any], value))
373
+ if "Position" in data and "position" not in data:
374
+ data["position"] = data["Position"]
375
+ data.pop("Position", None)
376
+ return data
377
+
378
+ @field_validator("positions", mode="before")
379
+ @classmethod
380
+ def normalize_positions(cls, value: object) -> object:
381
+ if value is None:
382
+ return []
383
+ if isinstance(value, dict):
384
+ return [cast(dict[str, Any], value)]
385
+ return value
386
+
387
+
388
+ class PortfolioTotals(BrokerModel):
389
+ todays_gain_loss: Decimal | None = Field(default=None, alias="todaysGainLoss")
390
+ todays_gain_loss_pct: Decimal | None = Field(default=None, alias="todaysGainLossPct")
391
+ total_market_value: Decimal | None = Field(default=None, alias="totalMarketValue")
392
+ total_gain_loss: Decimal | None = Field(default=None, alias="totalGainLoss")
393
+ total_gain_loss_pct: Decimal | None = Field(default=None, alias="totalGainLossPct")
394
+ total_price_paid: Decimal | None = Field(default=None, alias="totalPricePaid")
395
+ cash_balance: Decimal | None = Field(default=None, alias="cashBalance")
396
+
397
+
398
+ def _empty_account_portfolios() -> list[AccountPortfolio]:
399
+ return []
400
+
401
+
402
+ class PortfolioResponse(BrokerModel):
403
+ totals: PortfolioTotals | None = None
404
+ account_portfolios: list[AccountPortfolio] = Field(
405
+ default_factory=_empty_account_portfolios, alias="accountPortfolio"
406
+ )
407
+
408
+ @model_validator(mode="before")
409
+ @classmethod
410
+ def normalize_response(cls, value: object) -> object:
411
+ if not isinstance(value, dict):
412
+ return value
413
+ data = dict(cast(dict[str, Any], value))
414
+ if "Totals" in data and "totals" not in data:
415
+ data["totals"] = data["Totals"]
416
+ if "AccountPortfolio" in data and "accountPortfolio" not in data:
417
+ data["accountPortfolio"] = data["AccountPortfolio"]
418
+ data.pop("Totals", None)
419
+ data.pop("AccountPortfolio", None)
420
+ return data
421
+
422
+ @field_validator("account_portfolios", mode="before")
423
+ @classmethod
424
+ def normalize_account_portfolios(cls, value: object) -> object:
425
+ if value is None:
426
+ return []
427
+ if isinstance(value, dict):
428
+ return [cast(dict[str, Any], value)]
429
+ return value
@@ -0,0 +1,99 @@
1
+ """Portfolio 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.portfolio.models import PortfolioRequest, PortfolioResponse, Position
10
+ from etrade_python.transport.http import ApiTransport
11
+ from etrade_python.transport.response import (
12
+ JsonValue,
13
+ raise_response_validation_error,
14
+ validate_response_shape,
15
+ )
16
+ from etrade_python.transport.retry import RetrySafety
17
+
18
+
19
+ class PortfolioService:
20
+ """Access E*TRADE portfolio endpoints."""
21
+
22
+ def __init__(self, transport: ApiTransport) -> None:
23
+ self._transport = transport
24
+
25
+ async def get_positions(
26
+ self, account_id_key: str, request: PortfolioRequest | None = None
27
+ ) -> PortfolioResponse:
28
+ if not account_id_key.strip():
29
+ raise ETradeValidationError("account_id_key is required")
30
+
31
+ portfolio_request = request or PortfolioRequest()
32
+ response = await self._transport.request(
33
+ "GET",
34
+ f"/v1/accounts/{account_id_key}/portfolio.json",
35
+ params=portfolio_request.query_params(),
36
+ safety=RetrySafety.SAFE_READ,
37
+ operation="portfolio.positions",
38
+ )
39
+ if response.status_code == 204 and response.data is None:
40
+ return PortfolioResponse(accountPortfolio=[])
41
+ data = _require_mapping(response.data)
42
+ portfolio_data = _unwrap(data, "PortfolioResponse")
43
+ try:
44
+ return validate_response_shape(
45
+ PortfolioResponse.model_validate(portfolio_data),
46
+ "portfolio",
47
+ collection_field="account_portfolios",
48
+ )
49
+ except ValidationError as error:
50
+ raise_response_validation_error("Invalid portfolio response", error)
51
+
52
+ async def iter_positions(
53
+ self, account_id_key: str, request: PortfolioRequest | None = None
54
+ ) -> AsyncIterator[Position]:
55
+ """Iterate positions using the broker's nextPageNo continuation field."""
56
+ base_request = request or PortfolioRequest()
57
+ current_request = base_request
58
+ seen_pages = {base_request.page_number or 1}
59
+ while True:
60
+ page = await self.get_positions(account_id_key, current_request)
61
+ next_page = _next_page_number(page)
62
+ if next_page in seen_pages:
63
+ raise ETradeResponseError("Portfolio pagination did not advance")
64
+ for account_portfolio in page.account_portfolios:
65
+ for position in account_portfolio.positions:
66
+ yield position
67
+ if next_page is None:
68
+ return
69
+ seen_pages.add(next_page)
70
+ current_request = base_request.model_copy(update={"page_number": next_page})
71
+
72
+
73
+ def _require_mapping(data: JsonValue) -> dict[str, Any]:
74
+ if not isinstance(data, dict):
75
+ raise ETradeResponseError("Invalid portfolio response")
76
+ return cast(dict[str, Any], data)
77
+
78
+
79
+ def _unwrap(data: dict[str, Any], envelope: str) -> dict[str, Any]:
80
+ value = data.get(envelope, data)
81
+ if not isinstance(value, dict):
82
+ raise ETradeResponseError("Invalid portfolio response")
83
+ return cast(dict[str, Any], value)
84
+
85
+
86
+ def _next_page_number(response: PortfolioResponse) -> int | None:
87
+ for account_portfolio in response.account_portfolios:
88
+ page_number = account_portfolio.next_page_no
89
+ if page_number:
90
+ try:
91
+ next_page = int(page_number)
92
+ except ValueError:
93
+ raise ETradeResponseError("Invalid portfolio pagination page number") from None
94
+ if next_page < 1:
95
+ raise ETradeResponseError("Invalid portfolio pagination page number")
96
+ return next_page
97
+ if account_portfolio.next:
98
+ raise ETradeResponseError("Portfolio pagination is missing the next page number")
99
+ return None
etrade_python/py.typed ADDED
File without changes
@@ -0,0 +1,27 @@
1
+ """Transactions API service and models."""
2
+
3
+ from etrade_python.transactions.models import (
4
+ ProductId,
5
+ Transaction,
6
+ TransactionBrokerage,
7
+ TransactionCategory,
8
+ TransactionDetailsRequest,
9
+ TransactionDetailsResponse,
10
+ TransactionProduct,
11
+ TransactionsRequest,
12
+ TransactionsResponse,
13
+ )
14
+ from etrade_python.transactions.service import TransactionsService
15
+
16
+ __all__ = [
17
+ "ProductId",
18
+ "Transaction",
19
+ "TransactionBrokerage",
20
+ "TransactionCategory",
21
+ "TransactionDetailsRequest",
22
+ "TransactionDetailsResponse",
23
+ "TransactionProduct",
24
+ "TransactionsRequest",
25
+ "TransactionsResponse",
26
+ "TransactionsService",
27
+ ]