eth-portfolio 0.5.7__cp312-cp312-win32.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.

Potentially problematic release.


This version of eth-portfolio might be problematic. Click here for more details.

Files changed (83) hide show
  1. eth_portfolio/__init__.py +24 -0
  2. eth_portfolio/_argspec.cp312-win32.pyd +0 -0
  3. eth_portfolio/_argspec.py +43 -0
  4. eth_portfolio/_cache.py +119 -0
  5. eth_portfolio/_config.cp312-win32.pyd +0 -0
  6. eth_portfolio/_config.py +4 -0
  7. eth_portfolio/_db/__init__.py +0 -0
  8. eth_portfolio/_db/decorators.py +147 -0
  9. eth_portfolio/_db/entities.py +311 -0
  10. eth_portfolio/_db/utils.py +616 -0
  11. eth_portfolio/_decimal.py +154 -0
  12. eth_portfolio/_decorators.py +84 -0
  13. eth_portfolio/_exceptions.py +65 -0
  14. eth_portfolio/_ledgers/__init__.py +0 -0
  15. eth_portfolio/_ledgers/address.py +924 -0
  16. eth_portfolio/_ledgers/portfolio.py +328 -0
  17. eth_portfolio/_loaders/__init__.py +33 -0
  18. eth_portfolio/_loaders/_nonce.cp312-win32.pyd +0 -0
  19. eth_portfolio/_loaders/_nonce.py +193 -0
  20. eth_portfolio/_loaders/balances.cp312-win32.pyd +0 -0
  21. eth_portfolio/_loaders/balances.py +95 -0
  22. eth_portfolio/_loaders/token_transfer.py +215 -0
  23. eth_portfolio/_loaders/transaction.py +240 -0
  24. eth_portfolio/_loaders/utils.cp312-win32.pyd +0 -0
  25. eth_portfolio/_loaders/utils.py +67 -0
  26. eth_portfolio/_shitcoins.cp312-win32.pyd +0 -0
  27. eth_portfolio/_shitcoins.py +342 -0
  28. eth_portfolio/_stableish.cp312-win32.pyd +0 -0
  29. eth_portfolio/_stableish.py +42 -0
  30. eth_portfolio/_submodules.py +72 -0
  31. eth_portfolio/_utils.py +215 -0
  32. eth_portfolio/_ydb/__init__.py +0 -0
  33. eth_portfolio/_ydb/token_transfers.py +145 -0
  34. eth_portfolio/address.py +396 -0
  35. eth_portfolio/buckets.py +212 -0
  36. eth_portfolio/constants.cp312-win32.pyd +0 -0
  37. eth_portfolio/constants.py +87 -0
  38. eth_portfolio/portfolio.py +662 -0
  39. eth_portfolio/protocols/__init__.py +64 -0
  40. eth_portfolio/protocols/_base.py +107 -0
  41. eth_portfolio/protocols/convex.py +17 -0
  42. eth_portfolio/protocols/dsr.py +50 -0
  43. eth_portfolio/protocols/lending/README.md +6 -0
  44. eth_portfolio/protocols/lending/__init__.py +50 -0
  45. eth_portfolio/protocols/lending/_base.py +56 -0
  46. eth_portfolio/protocols/lending/compound.py +186 -0
  47. eth_portfolio/protocols/lending/liquity.py +108 -0
  48. eth_portfolio/protocols/lending/maker.py +110 -0
  49. eth_portfolio/protocols/lending/unit.py +44 -0
  50. eth_portfolio/protocols/liquity.py +17 -0
  51. eth_portfolio/py.typed +0 -0
  52. eth_portfolio/structs/__init__.py +43 -0
  53. eth_portfolio/structs/modified.py +69 -0
  54. eth_portfolio/structs/structs.py +626 -0
  55. eth_portfolio/typing/__init__.py +1418 -0
  56. eth_portfolio/typing/balance/single.py +176 -0
  57. eth_portfolio-0.5.7.dist-info/METADATA +26 -0
  58. eth_portfolio-0.5.7.dist-info/RECORD +83 -0
  59. eth_portfolio-0.5.7.dist-info/WHEEL +5 -0
  60. eth_portfolio-0.5.7.dist-info/entry_points.txt +2 -0
  61. eth_portfolio-0.5.7.dist-info/top_level.txt +3 -0
  62. eth_portfolio__mypyc.cp312-win32.pyd +0 -0
  63. eth_portfolio_scripts/__init__.py +17 -0
  64. eth_portfolio_scripts/_args.py +26 -0
  65. eth_portfolio_scripts/_logging.py +14 -0
  66. eth_portfolio_scripts/_portfolio.py +209 -0
  67. eth_portfolio_scripts/_utils.py +106 -0
  68. eth_portfolio_scripts/balances.cp312-win32.pyd +0 -0
  69. eth_portfolio_scripts/balances.py +56 -0
  70. eth_portfolio_scripts/docker/.grafana/dashboards/Portfolio/Balances.json +1962 -0
  71. eth_portfolio_scripts/docker/.grafana/dashboards/dashboards.yaml +10 -0
  72. eth_portfolio_scripts/docker/.grafana/datasources/datasources.yml +11 -0
  73. eth_portfolio_scripts/docker/__init__.cp312-win32.pyd +0 -0
  74. eth_portfolio_scripts/docker/__init__.py +16 -0
  75. eth_portfolio_scripts/docker/check.cp312-win32.pyd +0 -0
  76. eth_portfolio_scripts/docker/check.py +66 -0
  77. eth_portfolio_scripts/docker/docker-compose.yaml +61 -0
  78. eth_portfolio_scripts/docker/docker_compose.cp312-win32.pyd +0 -0
  79. eth_portfolio_scripts/docker/docker_compose.py +97 -0
  80. eth_portfolio_scripts/main.py +118 -0
  81. eth_portfolio_scripts/py.typed +1 -0
  82. eth_portfolio_scripts/victoria/__init__.py +72 -0
  83. eth_portfolio_scripts/victoria/types.py +38 -0
@@ -0,0 +1,1418 @@
1
+ """
2
+ This module defines a set of classes to represent and manipulate various levels of balance structures
3
+ within an Ethereum portfolio. The focus of these classes is on reading, aggregating, and summarizing
4
+ balances, including the value in both tokens and their equivalent in USD.
5
+
6
+ The main classes and their purposes are as follows:
7
+
8
+ - :class:`~eth_portfolio.typing.Balance`: Represents the balance of a single token, including its token amount and equivalent USD value.
9
+ - :class:`~eth_portfolio.typing.TokenBalances`: Manages a collection of :class:`~eth_portfolio.typing.Balance` objects for multiple tokens, providing operations
10
+ such as summing balances across tokens.
11
+ - :class:`~eth_portfolio.typing.RemoteTokenBalances`: Extends :class:`~eth_portfolio.typing.TokenBalances` to manage balances across different protocols, enabling
12
+ aggregation and analysis of balances by protocol.
13
+ - :class:`~eth_portfolio.typing.WalletBalances`: Organizes token balances into categories such as assets, debts, and external balances
14
+ for a single wallet. It combines :class:`~eth_portfolio.typing.TokenBalances` and :class:`~eth_portfolio.typing.RemoteTokenBalances` to provide a complete view
15
+ of a wallet's balances.
16
+ - :class:`~eth_portfolio.typing.PortfolioBalances`: Aggregates :class:`~eth_portfolio.typing.WalletBalances` for multiple wallets, providing operations to sum
17
+ balances across an entire portfolio.
18
+ - :class:`~eth_portfolio.typing.WalletBalancesRaw`: Similar to :class:`~eth_portfolio.typing.WalletBalances`, but with a key structure optimized for accessing
19
+ balances directly by wallet and token.
20
+ - :class:`~eth_portfolio.typing.PortfolioBalancesByCategory`: Provides an inverted view of :class:`~eth_portfolio.typing.PortfolioBalances`, allowing access
21
+ by category first, then by wallet and token.
22
+
23
+ These classes are designed for efficient parsing, manipulation, and summarization of portfolio data,
24
+ without managing or altering any underlying assets.
25
+ """
26
+
27
+ from collections.abc import Callable, Iterable
28
+ from functools import cached_property
29
+ from typing import Any, DefaultDict, Final, Literal, TypedDict, TypeVar, Union, final
30
+
31
+ from checksum_dict import DefaultChecksumDict
32
+ from eth_typing import BlockNumber, HexAddress
33
+ from pandas import DataFrame, concat
34
+ from typing_extensions import ParamSpec, Self
35
+ from y import ERC20, Contract
36
+ from y.datatypes import Address
37
+
38
+ from eth_portfolio._decimal import Decimal
39
+ from eth_portfolio.typing.balance.single import Balance
40
+
41
+ _T = TypeVar("_T")
42
+ _I = TypeVar("_I")
43
+ _P = ParamSpec("_P")
44
+
45
+ Fn = Callable[_P, _T]
46
+
47
+
48
+ ProtocolLabel = str
49
+
50
+ Addresses = Union[Address, Iterable[Address]]
51
+ TokenAddress = TypeVar("TokenAddress", bound=Address)
52
+
53
+
54
+ class _SummableNonNumericMixin:
55
+ """
56
+ Mixin class for non-numeric summable objects.
57
+
58
+ This class provides an interface for objects that can be used with `sum` but are not necessarily numeric.
59
+ """
60
+
61
+ def __add__(self, other: Self) -> Self:
62
+ """
63
+ Abstract method for adding two objects of the same type.
64
+
65
+ Args:
66
+ other: Another object of the same type.
67
+
68
+ Raises:
69
+ NotImplementedError: If no `__add__` method was defined on the subclass.
70
+
71
+ Returns:
72
+ A new object representing the sum.
73
+ """
74
+ raise NotImplementedError
75
+
76
+ def __radd__(self, other: Self | Literal[0]) -> Self:
77
+ """
78
+ Supports the addition operation from the right side to enable use of `sum`.
79
+
80
+ Args:
81
+ other: Another object of the same type or zero.
82
+
83
+ Returns:
84
+ A new object representing the sum.
85
+
86
+ Example:
87
+ >>> class Summable(_SummableNonNumeric):
88
+ ... def __init__(self, value: int):
89
+ ... self.value = value
90
+ ... def __add__(self, other):
91
+ ... return Summable(self.value + other.value)
92
+ ... def __radd__(self, other):
93
+ ... return self.__add__(other)
94
+ >>> a = Summable(10)
95
+ >>> b = Summable(20)
96
+ >>> sum_result = a + b
97
+ >>> sum_result.value
98
+ 30
99
+ """
100
+ return self if other == 0 else self.__add__(other) # type: ignore
101
+
102
+
103
+ _TBSeed = Union[dict[Address, Balance], Iterable[tuple[Address, Balance]]]
104
+
105
+
106
+ @final
107
+ class TokenBalances(DefaultChecksumDict[Balance], _SummableNonNumericMixin): # type: ignore [misc]
108
+ """
109
+ A specialized defaultdict subclass made for holding a mapping of ``token -> balance``.
110
+
111
+ Manages a collection of :class:`~eth_portfolio.typing.Balance` objects for multiple tokens, allowing for operations
112
+ such as summing balances across tokens.
113
+
114
+ The class uses token addresses as keys and :class:`~eth_portfolio.typing.Balance` objects as values. It supports
115
+ arithmetic operations like addition and subtraction of token balances.
116
+
117
+ Token addresses are checksummed automatically when adding items to the dict, and the default value for a token not present is an empty :class:`~eth_portfolio.typing.Balance` object.
118
+
119
+ Args:
120
+ seed: An initial seed of token balances, either as a dictionary or an iterable of tuples.
121
+
122
+ Example:
123
+ >>> token_balances = TokenBalances({'0x123': Balance(Decimal('100'), Decimal('2000'))})
124
+ >>> token_balances['0x123'].balance
125
+ Decimal('100')
126
+ """
127
+
128
+ def __init__(self, seed: _TBSeed | None = None, *, block: BlockNumber | None = None) -> None:
129
+ super().__init__(Balance)
130
+ self.block: Final = block
131
+ if seed is None:
132
+ return
133
+ if isinstance(seed, dict):
134
+ seed = seed.items()
135
+ if not isinstance(seed, Iterable):
136
+ raise TypeError(f"{seed} is not a valid input for TokenBalances")
137
+ for token, balance in seed: # type: ignore [misc]
138
+ try:
139
+ self[token] += balance
140
+ except AttributeError:
141
+ if not isinstance(token, (ERC20, Contract)):
142
+ raise
143
+ self[token.address] += balance
144
+
145
+ def __getitem__(self, key: HexAddress) -> Balance:
146
+ return super().__getitem__(key) if key in self else Balance(token=key, block=self.block)
147
+
148
+ def __setitem__(self, key: HexAddress, value: Balance) -> None:
149
+ """
150
+ Sets the balance for a given token address.
151
+
152
+ Args:
153
+ key: The token address.
154
+ value: The balance to set for the token.
155
+
156
+ Raises:
157
+ TypeError: If the value is not a :class:`~eth_portfolio.typing.Balance` object.
158
+
159
+ Example:
160
+ >>> token_balances = TokenBalances()
161
+ >>> token_balances['0x123'] = Balance(Decimal('100'), Decimal('2000'))
162
+ >>> token_balances['0x123'].balance
163
+ Decimal('100')
164
+ """
165
+ if not isinstance(value, Balance):
166
+ raise TypeError(f"value must be a `Balance` object. You passed {value}") from None
167
+ return super().__setitem__(key, value)
168
+
169
+ @property
170
+ def dataframe(self) -> DataFrame:
171
+ """
172
+ Converts the token balances into a pandas DataFrame.
173
+
174
+ Returns:
175
+ A DataFrame representation of the token balances.
176
+ """
177
+ df = DataFrame(
178
+ {
179
+ token: {"balance": balance.balance, "usd_value": balance.usd_value}
180
+ for token, balance in dict.items(self)
181
+ }
182
+ ).T
183
+ df.reset_index(inplace=True)
184
+ df.rename(columns={"index": "token"}, inplace=True)
185
+ return df
186
+
187
+ def sum_usd(self) -> Decimal:
188
+ """
189
+ Sums the USD values of all token balances.
190
+
191
+ Returns:
192
+ The total USD value of all token balances.
193
+
194
+ Example:
195
+ >>> token_balances = TokenBalances({'0x123': Balance(Decimal('100'), Decimal('2000'))})
196
+ >>> total_usd = token_balances.sum_usd()
197
+ >>> total_usd
198
+ Decimal('2000')
199
+ """
200
+ return Decimal(sum(balance.usd for balance in dict.values(self)))
201
+
202
+ def __bool__(self) -> bool:
203
+ """
204
+ Evaluates the truth value of the :class:`~eth_portfolio.typing.TokenBalances` object.
205
+
206
+ Returns:
207
+ True if any token has a non-zero balance, otherwise False.
208
+
209
+ Example:
210
+ >>> token_balances = TokenBalances()
211
+ >>> bool(token_balances)
212
+ False
213
+ """
214
+ return any(dict.values(self))
215
+
216
+ def __repr__(self) -> str:
217
+ """
218
+ Returns a string representation of the :class:`~eth_portfolio.typing.TokenBalances` object.
219
+
220
+ Returns:
221
+ The string representation of the token balances.
222
+ """
223
+ return f"TokenBalances{dict(self)}"
224
+
225
+ def __add__(self, other: "TokenBalances") -> "TokenBalances":
226
+ """
227
+ Adds another :class:`~eth_portfolio.typing.TokenBalances` object to this one.
228
+
229
+ Args:
230
+ other: Another :class:`~eth_portfolio.typing.TokenBalances` object.
231
+
232
+ Returns:
233
+ A new :class:`~eth_portfolio.typing.TokenBalances` object with the combined balances.
234
+
235
+ Raises:
236
+ TypeError: If the other object is not a :class:`~eth_portfolio.typing.TokenBalances`.
237
+
238
+ Example:
239
+ >>> tb1 = TokenBalances({'0x123': Balance(Decimal('100'), Decimal('2000'))})
240
+ >>> tb2 = TokenBalances({'0x123': Balance(Decimal('50'), Decimal('1000'))})
241
+ >>> combined_tb = tb1 + tb2
242
+ >>> combined_tb['0x123'].balance
243
+ Decimal('150')
244
+ """
245
+ if not isinstance(other, TokenBalances):
246
+ raise TypeError(f"{other} is not a TokenBalances object")
247
+
248
+ block = self.block
249
+ if block != other.block:
250
+ raise ValueError(
251
+ f"These TokenBalances objects are not from the same block ({block} and {other.block})"
252
+ )
253
+
254
+ combined: TokenBalances = TokenBalances(block=block)
255
+ for token, balance in dict.items(self):
256
+ if balance:
257
+ DefaultChecksumDict._setitem_nochecksum(
258
+ combined,
259
+ token,
260
+ Balance(balance.balance, balance.usd_value, token=token, block=block),
261
+ )
262
+ for token, balance in dict.items(other):
263
+ if balance:
264
+ if token in combined:
265
+ DefaultChecksumDict._setitem_nochecksum(
266
+ combined, token, combined._getitem_nochecksum(token) + balance
267
+ )
268
+ else:
269
+ DefaultChecksumDict._setitem_nochecksum(
270
+ combined,
271
+ token,
272
+ Balance(balance.balance, balance.usd_value, token=token, block=block),
273
+ )
274
+ return combined
275
+
276
+ def __iadd__(self, other: "TokenBalances") -> "TokenBalances":
277
+ if not isinstance(other, TokenBalances):
278
+ raise TypeError(f"{other} is not a TokenBalances object")
279
+
280
+ block = self.block
281
+ if block != other.block:
282
+ raise ValueError(
283
+ f"These TokenBalances objects are not from the same block ({block} and {other.block})"
284
+ )
285
+
286
+ for token, balance in dict.items(other):
287
+ if not balance:
288
+ continue
289
+ if token in self:
290
+ DefaultChecksumDict._setitem_nochecksum(
291
+ self,
292
+ token,
293
+ self._getitem_nochecksum(token) + balance,
294
+ )
295
+ else:
296
+ balance = Balance(balance.balance, balance.usd_value, token=token, block=block)
297
+ DefaultChecksumDict._setitem_nochecksum(self, token, balance)
298
+ return self
299
+
300
+ def __sub__(self, other: "TokenBalances") -> "TokenBalances":
301
+ """
302
+ Subtracts another :class:`~eth_portfolio.typing.TokenBalances` object from this one.
303
+
304
+ Args:
305
+ other: Another :class:`~eth_portfolio.typing.TokenBalances` object.
306
+
307
+ Returns:
308
+ A new :class:`~eth_portfolio.typing.TokenBalances` object with the subtracted balances.
309
+
310
+ Raises:
311
+ TypeError: If the other object is not a :class:`~eth_portfolio.typing.TokenBalances`.
312
+
313
+ Example:
314
+ >>> tb1 = TokenBalances({'0x123': Balance(Decimal('100'), Decimal('2000'))})
315
+ >>> tb2 = TokenBalances({'0x123': Balance(Decimal('50'), Decimal('1000'))})
316
+ >>> result_tb = tb1 - tb2
317
+ >>> result_tb['0x123'].balance
318
+ Decimal('50')
319
+ """
320
+ if not isinstance(other, TokenBalances):
321
+ raise TypeError(f"{other} is not a TokenBalances object")
322
+ if self.block != other.block:
323
+ raise ValueError(
324
+ f"These TokenBalances objects are not from the same block ({self.block} and {other.block})"
325
+ )
326
+ # NOTE We need a new object to avoid mutating the inputs
327
+ subtracted: TokenBalances = TokenBalances(self, block=self.block)
328
+ for token, balance in dict.items(other):
329
+ subtracted[token] -= balance
330
+ for token, balance in dict(subtracted).items():
331
+ if not balance:
332
+ del subtracted[token]
333
+ return subtracted
334
+
335
+ __slots__ = ("block",)
336
+
337
+
338
+ _RTBSeed = dict[ProtocolLabel, TokenBalances]
339
+
340
+
341
+ @final
342
+ class RemoteTokenBalances(DefaultDict[ProtocolLabel, TokenBalances], _SummableNonNumericMixin):
343
+ """
344
+ Manages token balances across different protocols, extending the :class:`~eth_portfolio.typing.TokenBalances` functionality
345
+ to multiple protocols.
346
+
347
+ The class uses protocol labels as keys and :class:`~eth_portfolio.typing.TokenBalances` objects as values.
348
+
349
+ Args:
350
+ seed: An initial seed of remote token balances, either as a dictionary
351
+ or an iterable of tuples.
352
+
353
+ Example:
354
+ >>> remote_balances = RemoteTokenBalances({'protocol1': TokenBalances({'0x123': Balance(Decimal('100'), Decimal('2000'))})})
355
+ >>> remote_balances['protocol1']['0x123'].balance
356
+ Decimal('100')
357
+ """
358
+
359
+ __slots__ = ("block",)
360
+
361
+ def __init__(self, seed: _RTBSeed | None = None, *, block: BlockNumber | None = None) -> None:
362
+ super().__init__(lambda: TokenBalances(block=block))
363
+ self.block: Final = block
364
+ if seed is None:
365
+ return
366
+ if isinstance(seed, dict):
367
+ seed = seed.items() # type: ignore [assignment]
368
+ if not isinstance(seed, Iterable):
369
+ raise TypeError(f"{seed} is not a valid input for TokenBalances")
370
+ for remote, token_balances in seed: # type: ignore [misc]
371
+ if self.block != token_balances.block:
372
+ raise ValueError(
373
+ f"These objects are not from the same block ({self.block} and {token_balances.block})"
374
+ )
375
+ self[remote] += token_balances # type: ignore [has-type]
376
+
377
+ def __setitem__(self, protocol: str, value: TokenBalances) -> None:
378
+ """
379
+ Sets the token balances for a given protocol.
380
+
381
+ Args:
382
+ key: The protocol label.
383
+ value: The token balances to set for the protocol.
384
+
385
+ Raises:
386
+ TypeError: If the value is not a :class:`~eth_portfolio.typing.TokenBalances` object.
387
+
388
+ Example:
389
+ >>> remote_balances = RemoteTokenBalances()
390
+ >>> remote_balances['protocol1'] = TokenBalances({'0x123': Balance(Decimal('100'), Decimal('2000'))})
391
+ >>> remote_balances['protocol1']['0x123'].balance
392
+ Decimal('100')
393
+ """
394
+ if not isinstance(value, TokenBalances):
395
+ raise TypeError(f"value must be a `TokenBalances` object. You passed {value}") from None
396
+ return super().__setitem__(protocol, value)
397
+
398
+ @property
399
+ def dataframe(self) -> DataFrame:
400
+ """
401
+ Converts the remote token balances into a pandas DataFrame.
402
+
403
+ Returns:
404
+ A DataFrame representation of the remote token balances.
405
+ """
406
+ dfs: list[DataFrame] = []
407
+ for protocol, balances in dict.items(self):
408
+ df = balances.dataframe
409
+ df["protocol"] = protocol
410
+ dfs.append(df)
411
+ return concat(dfs).reset_index(drop=True) if dfs else DataFrame()
412
+
413
+ def sum_usd(self) -> Decimal:
414
+ """
415
+ Sums the USD values of all token balances across all protocols.
416
+
417
+ Returns:
418
+ The total USD value of all remote token balances.
419
+
420
+ Example:
421
+ >>> remote_balances = RemoteTokenBalances({'protocol1': TokenBalances({'0x123': Balance(Decimal('100'), Decimal('2000'))})})
422
+ >>> total_usd = remote_balances.sum_usd()
423
+ >>> total_usd
424
+ Decimal('2000')
425
+ """
426
+ return Decimal(sum(balance.sum_usd() for balance in dict.values(self)))
427
+
428
+ def __bool__(self) -> bool:
429
+ """
430
+ Evaluates the truth value of the :class:`~eth_portfolio.typing.RemoteTokenBalances` object.
431
+
432
+ Returns:
433
+ True if any protocol has a non-zero balance, otherwise False.
434
+
435
+ Example:
436
+ >>> remote_balances = RemoteTokenBalances()
437
+ >>> bool(remote_balances)
438
+ False
439
+ """
440
+ return any(dict.values(self))
441
+
442
+ def __repr__(self) -> str:
443
+ """
444
+ Returns a string representation of the :class:`~eth_portfolio.typing.RemoteTokenBalances` object.
445
+
446
+ Returns:
447
+ The string representation of the remote token balances.
448
+ """
449
+ return f"RemoteTokenBalances{dict(self)}"
450
+
451
+ def __add__(self, other: "RemoteTokenBalances") -> "RemoteTokenBalances":
452
+ """
453
+ Adds another :class:`~eth_portfolio.typing.RemoteTokenBalances` object to this one.
454
+
455
+ Args:
456
+ other: Another :class:`~eth_portfolio.typing.RemoteTokenBalances` object.
457
+
458
+ Returns:
459
+ A new :class:`~eth_portfolio.typing.RemoteTokenBalances` object with the combined balances.
460
+
461
+ Raises:
462
+ TypeError: If the other object is not a :class:`~eth_portfolio.typing.RemoteTokenBalances`.
463
+
464
+ Example:
465
+ >>> rb1 = RemoteTokenBalances({'protocol1': TokenBalances({'0x123': Balance(Decimal('100'), Decimal('2000'))})})
466
+ >>> rb2 = RemoteTokenBalances({'protocol1': TokenBalances({'0x123': Balance(Decimal('50'), Decimal('1000'))})})
467
+ >>> combined_rb = rb1 + rb2
468
+ >>> combined_rb['protocol1']['0x123'].balance
469
+ Decimal('150')
470
+ """
471
+ if not isinstance(other, RemoteTokenBalances):
472
+ raise TypeError(f"{other} is not a RemoteTokenBalances object")
473
+
474
+ block = self.block
475
+ if block != other.block:
476
+ raise ValueError(
477
+ f"These RemoteTokenBalances objects are not from the same block ({block} and {other.block})"
478
+ )
479
+
480
+ combined: RemoteTokenBalances = RemoteTokenBalances(block=block)
481
+ for protocol, token_balances in dict.items(self):
482
+ if token_balances:
483
+ combined[protocol] += token_balances
484
+ for protocol, token_balances in dict.items(other):
485
+ if token_balances:
486
+ combined[protocol] += token_balances
487
+ return combined
488
+
489
+ def __iadd__(self, other: "RemoteTokenBalances") -> "RemoteTokenBalances":
490
+ if not isinstance(other, RemoteTokenBalances):
491
+ raise TypeError(f"{other} is not a RemoteTokenBalances object")
492
+
493
+ if self.block != other.block:
494
+ raise ValueError(
495
+ f"These RemoteTokenBalances objects are not from the same block ({self.block} and {other.block})"
496
+ )
497
+
498
+ for protocol, token_balances in dict.items(other):
499
+ if token_balances:
500
+ self[protocol] += token_balances
501
+ return self
502
+
503
+ def __sub__(self, other: "RemoteTokenBalances") -> "RemoteTokenBalances":
504
+ """
505
+ Subtracts another :class:`~eth_portfolio.typing.RemoteTokenBalances` object from this one.
506
+
507
+ Args:
508
+ other: Another :class:`~eth_portfolio.typing.RemoteTokenBalances` object.
509
+
510
+ Returns:
511
+ A new :class:`~eth_portfolio.typing.RemoteTokenBalances` object with the subtracted balances.
512
+
513
+ Raises:
514
+ TypeError: If the other object is not a :class:`~eth_portfolio.typing.RemoteTokenBalances`.
515
+
516
+ Example:
517
+ >>> rb1 = RemoteTokenBalances({'protocol1': TokenBalances({'0x123': Balance(Decimal('100'), Decimal('2000'))})})
518
+ >>> rb2 = RemoteTokenBalances({'protocol1': TokenBalances({'0x123': Balance(Decimal('50'), Decimal('1000'))})})
519
+ >>> result_rb = rb1 - rb2
520
+ >>> result_rb['protocol1']['0x123'].balance
521
+ Decimal('50')
522
+ """
523
+ if not isinstance(other, RemoteTokenBalances):
524
+ raise TypeError(f"{other} is not a RemoteTokenBalances object")
525
+ if self.block != other.block:
526
+ raise ValueError(
527
+ f"These RemoteTokenBalances objects are not from the same block ({self.block} and {other.block})"
528
+ )
529
+ # NOTE We need a new object to avoid mutating the inputs
530
+ subtracted: RemoteTokenBalances = RemoteTokenBalances(self, block=self.block)
531
+ for protocol, token_balances in dict.items(other):
532
+ subtracted[protocol] -= token_balances
533
+ for protocol, token_balances in dict(subtracted).items():
534
+ if not token_balances:
535
+ del subtracted[protocol]
536
+ return subtracted
537
+
538
+
539
+ CategoryLabel = Literal["assets", "debt", "external"]
540
+
541
+
542
+ class _WalletBalancesTD(TypedDict):
543
+ assets: TokenBalances
544
+ debt: TokenBalances
545
+ external: RemoteTokenBalances
546
+
547
+
548
+ _WBSeed = Union[_WalletBalancesTD, Iterable[tuple[CategoryLabel, TokenBalances]]]
549
+
550
+
551
+ @final
552
+ class WalletBalances(
553
+ dict[CategoryLabel, Union[TokenBalances, RemoteTokenBalances]], _SummableNonNumericMixin
554
+ ):
555
+ """
556
+ Organizes token balances into categories such as assets, debts, and external balances for a single wallet.
557
+
558
+ The class uses categories as keys (`assets`, `debt`, `external`) and :class:`~eth_portfolio.typing.TokenBalances` or :class:`~eth_portfolio.typing.RemoteTokenBalances`
559
+ objects as values.
560
+
561
+ Args:
562
+ seed: An initial seed of wallet balances, either as a dictionary or an iterable of tuples.
563
+
564
+ Example:
565
+ >>> wallet_balances = WalletBalances({'assets': TokenBalances({'0x123': Balance(Decimal('100'), Decimal('2000'))})})
566
+ >>> wallet_balances['assets']['0x123'].balance
567
+ Decimal('100')
568
+ """
569
+
570
+ def __init__(
571
+ self,
572
+ seed: Union["WalletBalances", _WBSeed] | None = None,
573
+ *,
574
+ block: BlockNumber | None = None,
575
+ ) -> None:
576
+ self.block: Final = block
577
+ self._keys = "assets", "debt", "external"
578
+ self["assets"] = TokenBalances(block=block)
579
+ self["debt"] = RemoteTokenBalances(block=block)
580
+ self["external"] = RemoteTokenBalances(block=block)
581
+
582
+ if seed is None:
583
+ return
584
+ if isinstance(seed, dict):
585
+ seed = seed.items() # type: ignore
586
+ if not isinstance(seed, Iterable):
587
+ raise TypeError(f"{seed} is not a valid input for WalletBalances")
588
+ for key, balances in seed: # type: ignore
589
+ if self.block != balances.block:
590
+ raise ValueError(
591
+ f"These objects are not from the same block ({self.block} and {balances.block})"
592
+ )
593
+ self.__validateitem(key, balances)
594
+ self[key] += balances # type: ignore [operator]
595
+
596
+ @property
597
+ def assets(self) -> TokenBalances:
598
+ """
599
+ Returns the assets held by the wallet.
600
+
601
+ Returns:
602
+ :class:`~eth_portfolio.typing.TokenBalances`: The :class:`~eth_portfolio.typing.TokenBalances` object representing the wallet's assets.
603
+ """
604
+ return self["assets"] # type: ignore
605
+
606
+ @property
607
+ def debt(self) -> RemoteTokenBalances:
608
+ """
609
+ Returns the debts associated with the wallet.
610
+
611
+ Returns:
612
+ :class:`~eth_portfolio.typing.RemoteTokenBalances`: The :class:`~eth_portfolio.typing.RemoteTokenBalances` object representing the wallet's debts.
613
+ """
614
+ return self["debt"]
615
+
616
+ @property
617
+ def external(self) -> RemoteTokenBalances:
618
+ """
619
+ Returns the external balances associated with the wallet.
620
+
621
+ Returns:
622
+ :class:`~eth_portfolio.typing.RemoteTokenBalances`: The :class:`~eth_portfolio.typing.RemoteTokenBalances` object representing the wallet's external balances.
623
+ """
624
+ return self["external"]
625
+
626
+ @property
627
+ def dataframe(self) -> DataFrame:
628
+ """
629
+ Converts the wallet balances into a pandas DataFrame.
630
+
631
+ Returns:
632
+ A DataFrame representation of the wallet balances.
633
+ """
634
+ dfs: list[DataFrame] = []
635
+ for category, category_bals in dict.items(self):
636
+ df = category_bals.dataframe
637
+ df["category"] = category
638
+ dfs.append(df)
639
+ return concat(dfs).reset_index(drop=True) if dfs else DataFrame()
640
+
641
+ def sum_usd(self) -> Decimal:
642
+ """
643
+ Sums the USD values of the wallet's assets, debts, and external balances.
644
+
645
+ Returns:
646
+ The total USD value of the wallet's net assets (assets - debt + external).
647
+
648
+ Example:
649
+ >>> wallet_balances = WalletBalances({'assets': TokenBalances({'0x123': Balance(Decimal('100'), Decimal('2000'))})})
650
+ >>> total_usd = wallet_balances.sum_usd()
651
+ >>> total_usd
652
+ Decimal('2000')
653
+ """
654
+ return self.assets.sum_usd() - self.debt.sum_usd() + self.external.sum_usd()
655
+
656
+ def __bool__(self) -> bool:
657
+ """
658
+ Evaluates the truth value of the :class:`~eth_portfolio.typing.WalletBalances` object.
659
+
660
+ Returns:
661
+ True if any category has a non-zero balance, otherwise False.
662
+
663
+ Example:
664
+ >>> wallet_balances = WalletBalances()
665
+ >>> bool(wallet_balances)
666
+ False
667
+ """
668
+ return any(dict.values(self))
669
+
670
+ def __repr__(self) -> str:
671
+ """
672
+ Returns a string representation of the :class:`~eth_portfolio.typing.WalletBalances` object.
673
+
674
+ Returns:
675
+ The string representation of the wallet balances.
676
+ """
677
+ return f"WalletBalances {dict(self)}"
678
+
679
+ def __add__(self, other: "WalletBalances") -> "WalletBalances":
680
+ """
681
+ Adds another :class:`~eth_portfolio.typing.WalletBalances` object to this one.
682
+
683
+ Args:
684
+ other: Another :class:`~eth_portfolio.typing.WalletBalances` object.
685
+
686
+ Returns:
687
+ A new :class:`~eth_portfolio.typing.WalletBalances` object with the combined balances.
688
+
689
+ Raises:
690
+ TypeError: If the other object is not a :class:`~eth_portfolio.typing.WalletBalances`.
691
+
692
+ Example:
693
+ >>> wb1 = WalletBalances({'assets': TokenBalances({'0x123': Balance(Decimal('100'), Decimal('2000'))})})
694
+ >>> wb2 = WalletBalances({'assets': TokenBalances({'0x123': Balance(Decimal('50'), Decimal('1000'))})})
695
+ >>> combined_wb = wb1 + wb2
696
+ >>> combined_wb['assets']['0x123'].balance
697
+ Decimal('150')
698
+ """
699
+ if not isinstance(other, WalletBalances):
700
+ raise TypeError(f"{other} is not a WalletBalances object")
701
+
702
+ block = self.block
703
+ if block != other.block:
704
+ raise ValueError(
705
+ f"These WalletBalances objects are not from the same block ({block} and {other.block})"
706
+ )
707
+
708
+ combined: WalletBalances = WalletBalances(block=block)
709
+ for category, balances in dict.items(self):
710
+ if balances:
711
+ combined[category] += balances # type: ignore [operator]
712
+ for category, balances in dict.items(other):
713
+ if balances:
714
+ combined[category] += balances # type: ignore [operator]
715
+ return combined
716
+
717
+ def __iadd__(self, other: "WalletBalances") -> "WalletBalances":
718
+ if not isinstance(other, WalletBalances):
719
+ raise TypeError(f"{other} is not a WalletBalances object")
720
+
721
+ if self.block != other.block:
722
+ raise ValueError(
723
+ f"These WalletBalances objects are not from the same block ({self.block} and {other.block})"
724
+ )
725
+
726
+ for category, balances in dict.items(other):
727
+ if balances:
728
+ self[category] += balances # type: ignore [operator]
729
+ return self
730
+
731
+ def __sub__(self, other: "WalletBalances") -> "WalletBalances":
732
+ """
733
+ Subtracts another :class:`~eth_portfolio.typing.WalletBalances` object from this one.
734
+
735
+ Args:
736
+ other: Another :class:`~eth_portfolio.typing.WalletBalances` object.
737
+
738
+ Returns:
739
+ A new :class:`~eth_portfolio.typing.WalletBalances` object with the subtracted balances.
740
+
741
+ Raises:
742
+ TypeError: If the other object is not a :class:`~eth_portfolio.typing.WalletBalances`.
743
+
744
+ Example:
745
+ >>> wb1 = WalletBalances({'assets': TokenBalances({'0x123': Balance(Decimal('100'), Decimal('2000'))})})
746
+ >>> wb2 = WalletBalances({'assets': TokenBalances({'0x123': Balance(Decimal('50'), Decimal('1000'))})})
747
+ >>> result_wb = wb1 - wb2
748
+ >>> result_wb['assets']['0x123'].balance
749
+ Decimal('50')
750
+ """
751
+ if not isinstance(other, WalletBalances):
752
+ raise TypeError(f"{other} is not a WalletBalances object")
753
+ if self.block != other.block:
754
+ raise ValueError(
755
+ f"These WalletBalances objects are not from the same block ({self.block} and {other.block})"
756
+ )
757
+ # We need a new object to avoid mutating the inputs
758
+ subtracted: WalletBalances = WalletBalances(self, block=self.block)
759
+ for category, balances in dict.items(other):
760
+ subtracted[category] -= balances # type: ignore
761
+ for category, balances in dict(subtracted).items():
762
+ if not balances:
763
+ del subtracted[category]
764
+ return subtracted
765
+
766
+ def __getitem__(self, key: CategoryLabel) -> TokenBalances | RemoteTokenBalances:
767
+ """
768
+ Retrieves the balance associated with the given category key.
769
+
770
+ Args:
771
+ key: The category label (`assets`, `debt`, or `external`).
772
+
773
+ Returns:
774
+ The balances associated with the category.
775
+
776
+ Raises:
777
+ KeyError: If the key is not a valid category.
778
+
779
+ Example:
780
+ >>> wallet_balances = WalletBalances({'assets': TokenBalances({'0x123': Balance(Decimal('100'), Decimal('2000'))})})
781
+ >>> assets_balances = wallet_balances['assets']
782
+ >>> assets_balances['0x123'].balance
783
+ Decimal('100')
784
+ """
785
+ self.__validatekey(key)
786
+ return super().__getitem__(key)
787
+
788
+ def __setitem__(self, key: CategoryLabel, value: TokenBalances | RemoteTokenBalances) -> None:
789
+ """
790
+ Sets the balance associated with the given category key.
791
+
792
+ Args:
793
+ key: The category label (`assets`, `debt`, or `external`).
794
+ value: The balance to associate with the category.
795
+
796
+ Raises:
797
+ KeyError: If the key is not a valid category.
798
+ TypeError: If the value is not a valid balance type for the category.
799
+
800
+ Example:
801
+ >>> wallet_balances = WalletBalances()
802
+ >>> wallet_balances['assets'] = TokenBalances({'0x123': Balance(Decimal('100'), Decimal('2000'))})
803
+ >>> wallet_balances['assets']['0x123'].balance
804
+ Decimal('100')
805
+ """
806
+ self.__validateitem(key, value)
807
+ return super().__setitem__(key, value)
808
+
809
+ def __validatekey(self, key: CategoryLabel) -> None:
810
+ """
811
+ Validates that the given key is a valid category.
812
+
813
+ Valid keys: "assets", "debt", "external"
814
+
815
+ Args:
816
+ key: The category label to validate.
817
+
818
+ Raises:
819
+ KeyError: If the key is not a valid category.
820
+ """
821
+ if key not in self._keys:
822
+ raise KeyError(
823
+ f"{key} is not a valid key for WalletBalances. Valid keys are: {self._keys}"
824
+ )
825
+
826
+ def __validateitem(self, key: CategoryLabel, item: Any) -> None:
827
+ """
828
+ Validates that the given item is a valid balance type for the category.
829
+
830
+ Args:
831
+ key: The category label.
832
+ item: The balance item to validate.
833
+
834
+ Raises:
835
+ KeyError: If the key is not a valid category.
836
+ TypeError: If the item is not a valid balance type for the category.
837
+ """
838
+ self.__validatekey(key)
839
+ if key == "assets":
840
+ if not isinstance(item, TokenBalances):
841
+ raise TypeError(
842
+ f'{item} is not a valid value for "{key}". Must be a TokenBalances object.'
843
+ )
844
+ elif key in {"debt", "external"}:
845
+ if not isinstance(item, RemoteTokenBalances):
846
+ raise TypeError(
847
+ f'{item} is not a valid value for "{key}". Must be a RemoteTokenBalances object.'
848
+ )
849
+ else:
850
+ raise NotImplementedError(f"key {key} is not yet implemented.")
851
+
852
+
853
+ _PBSeed = Union[dict[Address, WalletBalances], Iterable[tuple[Address, WalletBalances]]]
854
+
855
+
856
+ @final
857
+ class PortfolioBalances(DefaultChecksumDict[WalletBalances], _SummableNonNumericMixin): # type: ignore [misc]
858
+ """
859
+ Aggregates :class:`~eth_portfolio.typing.WalletBalances` for multiple wallets, providing operations to sum
860
+ balances across an entire portfolio.
861
+
862
+ The class uses wallet addresses as keys and :class:`~eth_portfolio.typing.WalletBalances` objects as values.
863
+
864
+ Args:
865
+ seed: An initial seed of portfolio balances, either as a dictionary or an iterable of tuples.
866
+
867
+ Example:
868
+ >>> portfolio_balances = PortfolioBalances({'0x123': WalletBalances({'assets': TokenBalances({'0x456': Balance(Decimal('100'), Decimal('2000'))})})})
869
+ >>> portfolio_balances['0x123']['assets']['0x456'].balance
870
+ Decimal('100')
871
+ """
872
+
873
+ def __init__(self, seed: _PBSeed | None = None, *, block: BlockNumber | None = None) -> None:
874
+ super().__init__(lambda: WalletBalances(block=block))
875
+ self.block: Final = block
876
+ if seed is None:
877
+ return
878
+ if isinstance(seed, dict):
879
+ seed = seed.items()
880
+ if not isinstance(seed, Iterable):
881
+ raise TypeError(f"{seed} is not a valid input for PortfolioBalances")
882
+ for wallet, balances in seed:
883
+ if self.block != balances.block:
884
+ raise ValueError(
885
+ f"These objects are not from the same block ({self.block} and {balances.block})"
886
+ )
887
+ self[wallet] += balances
888
+
889
+ def __setitem__(self, key: HexAddress, value: WalletBalances) -> None:
890
+ if not isinstance(value, WalletBalances):
891
+ raise TypeError(
892
+ f"value must be a `WalletBalances` object. You passed {value}"
893
+ ) from None
894
+ return super().__setitem__(key, value)
895
+
896
+ @property
897
+ def dataframe(self) -> DataFrame:
898
+ """
899
+ Converts the portfolio balances into a pandas DataFrame.
900
+
901
+ Returns:
902
+ A DataFrame representation of the portfolio balances.
903
+ """
904
+ dfs: list[DataFrame] = []
905
+ for wallet, balances in dict.items(self):
906
+ df = balances.dataframe
907
+ df["wallet"] = wallet
908
+ dfs.append(df)
909
+ return concat(dfs).reset_index(drop=True) if dfs else DataFrame()
910
+
911
+ def sum_usd(self) -> Decimal:
912
+ """
913
+ Sums the USD values of all wallet balances in the portfolio.
914
+
915
+ Returns:
916
+ The total USD value of all wallet balances in the portfolio.
917
+
918
+ Example:
919
+ >>> portfolio_balances = PortfolioBalances({'0x123': WalletBalances({'assets': TokenBalances({'0x123': Balance(Decimal('100'), Decimal('2000'))})})})
920
+ >>> total_usd = portfolio_balances.sum_usd()
921
+ >>> total_usd
922
+ Decimal('2000')
923
+ """
924
+ return sum(balances.sum_usd() for balances in dict.values(self)) # type: ignore
925
+
926
+ @cached_property
927
+ def inverted(self) -> "PortfolioBalancesByCategory":
928
+ """
929
+ Returns an inverted view of the portfolio balances, grouped by category first.
930
+
931
+ Returns:
932
+ :class:`~eth_portfolio.typing.PortfolioBalancesByCategory`: The inverted portfolio balances, grouped by category.
933
+
934
+ Example:
935
+ >>> portfolio_balances = PortfolioBalances({'0x123': WalletBalances({'assets': TokenBalances({'0x123': Balance(Decimal('100'), Decimal('2000'))})})})
936
+ >>> inverted_pb = portfolio_balances.inverted
937
+ >>> inverted_pb['assets']['0x123'].balance
938
+ Decimal('100')
939
+ """
940
+ inverted = PortfolioBalancesByCategory()
941
+ for wallet, wbalances in dict.items(self):
942
+ for label, tbalances in wbalances.items():
943
+ if tbalances:
944
+ inverted[label][wallet] += tbalances # type: ignore [operator]
945
+ return inverted
946
+
947
+ def __bool__(self) -> bool:
948
+ """
949
+ Evaluates the truth value of the :class:`~eth_portfolio.typing.PortfolioBalances` object.
950
+
951
+ Returns:
952
+ True if any wallet has a non-zero balance of any token, otherwise False.
953
+
954
+ Example:
955
+ >>> portfolio_balances = PortfolioBalances()
956
+ >>> bool(portfolio_balances)
957
+ False
958
+ """
959
+ return any(dict.values(self))
960
+
961
+ def __repr__(self) -> str:
962
+ """
963
+ Returns a string representation of the :class:`~eth_portfolio.typing.PortfolioBalances` object.
964
+
965
+ Returns:
966
+ The string representation of the portfolio balances.
967
+ """
968
+ return f"WalletBalances{dict(self)}"
969
+
970
+ def __add__(self, other: "PortfolioBalances") -> "PortfolioBalances":
971
+ """
972
+ Adds another :class:`~eth_portfolio.typing.PortfolioBalances` object to this one.
973
+
974
+ Args:
975
+ other: Another :class:`~eth_portfolio.typing.PortfolioBalances` object.
976
+
977
+ Returns:
978
+ A new :class:`~eth_portfolio.typing.PortfolioBalances` object with the combined balances.
979
+
980
+ Raises:
981
+ TypeError: If the other object is not a :class:`~eth_portfolio.typing.PortfolioBalances`.
982
+
983
+ Example:
984
+ >>> pb1 = PortfolioBalances({'0x123': WalletBalances({'assets': TokenBalances({'0x123': Balance(Decimal('100'), Decimal('2000'))})})})
985
+ >>> pb2 = PortfolioBalances({'0x123': WalletBalances({'assets': TokenBalances({'0x123': Balance(Decimal('50'), Decimal('1000'))})})})
986
+ >>> combined_pb = pb1 + pb2
987
+ >>> combined_pb['0x123']['assets']['0x123'].balance
988
+ Decimal('150')
989
+ """
990
+ if not isinstance(other, PortfolioBalances):
991
+ raise TypeError(f"{other} is not a PortfolioBalances object")
992
+
993
+ block = self.block
994
+ if block != other.block:
995
+ raise ValueError(
996
+ f"These PortfolioBalances objects are not from the same block ({block} and {other.block})"
997
+ )
998
+ # NOTE We need a new object to avoid mutating the inputs
999
+ combined: PortfolioBalances = PortfolioBalances(block=block)
1000
+ for wallet, balance in dict.items(self):
1001
+ if balance:
1002
+ DefaultChecksumDict._setitem_nochecksum(
1003
+ combined, wallet, combined._getitem_nochecksum(wallet) + balance
1004
+ )
1005
+ for wallet, balance in dict.items(other):
1006
+ if balance:
1007
+ DefaultChecksumDict._setitem_nochecksum(
1008
+ combined, wallet, combined._getitem_nochecksum(wallet) + balance
1009
+ )
1010
+ return combined
1011
+
1012
+ def __iadd__(self, other: "PortfolioBalances") -> "PortfolioBalances":
1013
+ if not isinstance(other, PortfolioBalances):
1014
+ raise TypeError(f"{other} is not a PortfolioBalances object")
1015
+
1016
+ if self.block != other.block:
1017
+ raise ValueError(
1018
+ f"These PortfolioBalances objects are not from the same block ({self.block} and {other.block})"
1019
+ )
1020
+
1021
+ for wallet, balance in dict.items(other):
1022
+ if balance:
1023
+ DefaultChecksumDict._setitem_nochecksum(
1024
+ self, wallet, self._getitem_nochecksum(wallet) + balance
1025
+ )
1026
+ return self
1027
+
1028
+ def __sub__(self, other: "PortfolioBalances") -> "PortfolioBalances":
1029
+ """
1030
+ Subtracts another :class:`~eth_portfolio.typing.PortfolioBalances` object from this one.
1031
+
1032
+ Args:
1033
+ other: Another :class:`~eth_portfolio.typing.PortfolioBalances` object.
1034
+
1035
+ Returns:
1036
+ A new :class:`~eth_portfolio.typing.PortfolioBalances` object with the subtracted balances.
1037
+
1038
+ Raises:
1039
+ TypeError: If the other object is not a :class:`~eth_portfolio.typing.PortfolioBalances`.
1040
+
1041
+ Example:
1042
+ >>> pb1 = PortfolioBalances({'0x123': WalletBalances({'assets': TokenBalances({'0x123': Balance(Decimal('100'), Decimal('2000'))})})})
1043
+ >>> pb2 = PortfolioBalances({'0x123': WalletBalances({'assets': TokenBalances({'0x123': Balance(Decimal('50'), Decimal('1000'))})})})
1044
+ >>> result_pb = pb1 - pb2
1045
+ >>> result_pb['0x123']['assets']['0x123'].balance
1046
+ Decimal('50')
1047
+ """
1048
+ if not isinstance(other, PortfolioBalances):
1049
+ raise TypeError(f"{other} is not a PortfolioBalances object")
1050
+ if self.block != other.block:
1051
+ raise ValueError(
1052
+ f"These PortfolioBalances objects are not from the same block ({self.block} and {other.block})"
1053
+ )
1054
+ # We need a new object to avoid mutating the inputs
1055
+ subtracted: PortfolioBalances = PortfolioBalances(self, block=self.block)
1056
+ for protocol, balances in dict.items(other):
1057
+ subtracted[protocol] -= balances
1058
+ for protocol, balances in subtracted.items():
1059
+ if not balances:
1060
+ del subtracted[protocol]
1061
+ return subtracted
1062
+
1063
+ __slots__ = ("block",)
1064
+
1065
+
1066
+ _WTBInput = Union[dict[Address, TokenBalances], Iterable[tuple[Address, TokenBalances]]]
1067
+
1068
+
1069
+ @final
1070
+ class WalletBalancesRaw(DefaultChecksumDict[TokenBalances], _SummableNonNumericMixin): # type: ignore [misc]
1071
+ # Since PortfolioBalances key lookup is: ``wallet -> category -> token -> balance``
1072
+ # We need a new structure for key pattern: ``wallet -> token -> balance``
1073
+
1074
+ # WalletBalancesRaw fills this role.
1075
+ """
1076
+ A structure for key pattern `wallet -> token -> balance`.
1077
+
1078
+ This class is similar to :class:`~eth_portfolio.typing.WalletBalances` but optimized for key lookups by wallet and token directly.
1079
+ It manages :class:`~eth_portfolio.typing.TokenBalances` objects for multiple wallets.
1080
+
1081
+ Args:
1082
+ seed: An initial seed of wallet balances, either as a dictionary or an iterable of tuples.
1083
+
1084
+ Example:
1085
+ >>> raw_balances = WalletBalancesRaw({'0x123': TokenBalances({'0x456': Balance(Decimal('100'), Decimal('2000'))})})
1086
+ >>> raw_balances['0x123']['0x456'].balance
1087
+ Decimal('100')
1088
+ """
1089
+
1090
+ def __init__(self, seed: _WTBInput | None = None, *, block: BlockNumber | None = None) -> None:
1091
+ super().__init__(lambda: TokenBalances(block=block))
1092
+ self.block: Final = block
1093
+ if seed is None:
1094
+ return
1095
+ if isinstance(seed, dict):
1096
+ seed = seed.items()
1097
+ if not isinstance(seed, Iterable):
1098
+ raise TypeError(f"{seed} is not a valid input for WalletBalancesRaw")
1099
+ for wallet, balances in seed:
1100
+ if self.block != balances.block:
1101
+ raise ValueError(
1102
+ f"These objects are not from the same block ({self.block} and {balances.block})"
1103
+ )
1104
+ self[wallet] += balances
1105
+
1106
+ def __bool__(self) -> bool:
1107
+ """
1108
+ Evaluates the truth value of the :class:`~eth_portfolio.typing.WalletBalancesRaw` object.
1109
+
1110
+ Returns:
1111
+ True if any wallet has a non-zero balance, otherwise False.
1112
+
1113
+ Example:
1114
+ >>> raw_balances = WalletBalancesRaw()
1115
+ >>> bool(raw_balances)
1116
+ False
1117
+ """
1118
+ return any(dict.values(self))
1119
+
1120
+ def __repr__(self) -> str:
1121
+ """
1122
+ Returns a string representation of the :class:`~eth_portfolio.typing.WalletBalancesRaw` object.
1123
+
1124
+ Returns:
1125
+ The string representation of the raw wallet balances.
1126
+ """
1127
+ return f"WalletBalances{dict(self)}"
1128
+
1129
+ def __add__(self, other: "WalletBalancesRaw") -> "WalletBalancesRaw":
1130
+ """
1131
+ Adds another :class:`~eth_portfolio.typing.WalletBalancesRaw` object to this one.
1132
+
1133
+ Args:
1134
+ other: Another :class:`~eth_portfolio.typing.WalletBalancesRaw` object.
1135
+
1136
+ Returns:
1137
+ A new :class:`~eth_portfolio.typing.WalletBalancesRaw` object with the combined balances.
1138
+
1139
+ Raises:
1140
+ TypeError: If the other object is not a :class:`~eth_portfolio.typing.WalletBalancesRaw`.
1141
+
1142
+ Example:
1143
+ >>> raw_balances1 = WalletBalancesRaw({'0x123': TokenBalances({'0x123': Balance(Decimal('100'), Decimal('2000'))})})
1144
+ >>> raw_balances2 = WalletBalancesRaw({'0x123': TokenBalances({'0x123': Balance(Decimal('50'), Decimal('1000'))})})
1145
+ >>> combined_raw = raw_balances1 + raw_balances2
1146
+ >>> combined_raw['0x123']['0x123'].balance
1147
+ Decimal('150')
1148
+ """
1149
+ if not isinstance(other, WalletBalancesRaw):
1150
+ raise TypeError(f"{other} is not a WalletBalancesRaw object")
1151
+
1152
+ block = self.block
1153
+ if block != other.block:
1154
+ raise ValueError(
1155
+ f"These WalletBalancesRaw objects are not from the same block ({block} and {other.block})"
1156
+ )
1157
+
1158
+ combined: WalletBalancesRaw = WalletBalancesRaw(block=block)
1159
+ for wallet, balance in dict.items(self):
1160
+ if balance:
1161
+ DefaultChecksumDict._setitem_nochecksum(
1162
+ combined, wallet, combined._getitem_nochecksum(wallet) + balance
1163
+ )
1164
+ for wallet, balance in dict.items(other):
1165
+ if balance:
1166
+ DefaultChecksumDict._setitem_nochecksum(
1167
+ combined, wallet, combined._getitem_nochecksum(wallet) + balance
1168
+ )
1169
+ return combined
1170
+
1171
+ def __iadd__(self, other: "WalletBalancesRaw") -> "WalletBalancesRaw":
1172
+ if not isinstance(other, WalletBalancesRaw):
1173
+ raise TypeError(f"{other} is not a WalletBalancesRaw object")
1174
+
1175
+ if self.block != other.block:
1176
+ raise ValueError(
1177
+ f"These WalletBalancesRaw objects are not from the same block ({self.block} and {other.block})"
1178
+ )
1179
+
1180
+ for wallet, balance in dict.items(other):
1181
+ if balance:
1182
+ DefaultChecksumDict._setitem_nochecksum(
1183
+ self, wallet, self._getitem_nochecksum(wallet) + balance
1184
+ )
1185
+ return self
1186
+
1187
+ def __sub__(self, other: "WalletBalancesRaw") -> "WalletBalancesRaw":
1188
+ """
1189
+ Subtracts another :class:`~eth_portfolio.typing.WalletBalancesRaw` object from this one.
1190
+
1191
+ Args:
1192
+ other: Another :class:`~eth_portfolio.typing.WalletBalancesRaw` object.
1193
+
1194
+ Returns:
1195
+ A new :class:`~eth_portfolio.typing.WalletBalancesRaw` object with the subtracted balances.
1196
+
1197
+ Raises:
1198
+ TypeError: If the other object is not a :class:`~eth_portfolio.typing.WalletBalancesRaw`.
1199
+
1200
+ Example:
1201
+ >>> raw_balances1 = WalletBalancesRaw({'0x123': TokenBalances({'0x123': Balance(Decimal('100'), Decimal('2000'))})})
1202
+ >>> raw_balances2 = WalletBalancesRaw({'0x123': TokenBalances({'0x123': Balance(Decimal('50'), Decimal('1000'))})})
1203
+ >>> result_raw = raw_balances1 - raw_balances2
1204
+ >>> result_raw['0x123']['0x123'].balance
1205
+ Decimal('50')
1206
+ """
1207
+ if not isinstance(other, WalletBalancesRaw):
1208
+ raise TypeError(f"{other} is not a WalletBalancesRaw object")
1209
+ if self.block != other.block:
1210
+ raise ValueError(
1211
+ f"These WalletBalancesRaw objects are not from the same block ({self.block} and {other.block})"
1212
+ )
1213
+ # NOTE We need a new object to avoid mutating the inputs
1214
+ subtracted: WalletBalancesRaw = WalletBalancesRaw(self, block=other.block)
1215
+ for wallet, balances in dict.items(other):
1216
+ if balances:
1217
+ subtracted[wallet] -= balances
1218
+ for wallet, balances in subtracted.items():
1219
+ if not balances:
1220
+ del subtracted[wallet]
1221
+ return subtracted
1222
+
1223
+ __slots__ = ("block",)
1224
+
1225
+
1226
+ _CBInput = Union[
1227
+ dict[CategoryLabel, WalletBalancesRaw], Iterable[tuple[CategoryLabel, WalletBalancesRaw]]
1228
+ ]
1229
+
1230
+
1231
+ @final
1232
+ class PortfolioBalancesByCategory(
1233
+ DefaultDict[CategoryLabel, WalletBalancesRaw], _SummableNonNumericMixin
1234
+ ):
1235
+ """
1236
+ Provides an inverted view of :class:`~eth_portfolio.typing.PortfolioBalances`, allowing access by category first,
1237
+ then by wallet and token.
1238
+
1239
+ The class uses category labels as keys (`assets`, `debt`, `external`) and :class:`~eth_portfolio.typing.WalletBalancesRaw`
1240
+ objects as values.
1241
+
1242
+ Args:
1243
+ seed: An initial seed of portfolio balances by category, either as a dictionary or an iterable of tuples.
1244
+
1245
+ Example:
1246
+ >>> pb_by_category = PortfolioBalancesByCategory({'assets': WalletBalancesRaw({'0x123': TokenBalances({'0x456': Balance(Decimal('100'), Decimal('2000'))})})})
1247
+ >>> pb_by_category['assets']['0x123']['0x456'].balance
1248
+ Decimal('100')
1249
+ """
1250
+
1251
+ def __init__(self, seed: _CBInput | None = None, *, block: BlockNumber | None = None) -> None:
1252
+ super().__init__(lambda: WalletBalancesRaw(block=block))
1253
+ self.block: Final = block
1254
+ if seed is None:
1255
+ return
1256
+ if isinstance(seed, dict):
1257
+ seed = seed.items()
1258
+ if not isinstance(seed, Iterable):
1259
+ raise TypeError(f"{seed} is not a valid input for PortfolioBalancesByCategory")
1260
+ for label, balances in seed: # type: ignore
1261
+ if self.block != balances.block:
1262
+ raise ValueError(
1263
+ f"These objects are not from the same block ({self.block} and {balances.block})"
1264
+ )
1265
+ self[label] += balances
1266
+
1267
+ @property
1268
+ def assets(self) -> WalletBalancesRaw:
1269
+ """
1270
+ Returns the asset balances across all wallets.
1271
+
1272
+ Returns:
1273
+ :class:`~eth_portfolio.typing.WalletBalancesRaw`: The :class:`~eth_portfolio.typing.WalletBalancesRaw` object representing the asset balances.
1274
+ """
1275
+ return self["assets"]
1276
+
1277
+ @property
1278
+ def debt(self) -> WalletBalancesRaw:
1279
+ """
1280
+ Returns the debt balances across all wallets.
1281
+
1282
+ Returns:
1283
+ :class:`~eth_portfolio.typing.WalletBalancesRaw`: The :class:`~eth_portfolio.typing.WalletBalancesRaw` object representing the debt balances.
1284
+ """
1285
+ return self["debt"]
1286
+
1287
+ def invert(self) -> "PortfolioBalances":
1288
+ """
1289
+ Inverts the portfolio balances by category to group by wallet first.
1290
+
1291
+ Returns:
1292
+ :class:`~eth_portfolio.typing.PortfolioBalances`: The inverted portfolio balances, grouped by wallet first.
1293
+
1294
+ Example:
1295
+ >>> pb_by_category = PortfolioBalancesByCategory({'assets': WalletBalancesRaw({'0x123': TokenBalances({'0x123': Balance(Decimal('100'), Decimal('2000'))})})})
1296
+ >>> inverted_pb = pb_by_category.invert()
1297
+ >>> inverted_pb['0x123']['assets']['0x123'].balance
1298
+ Decimal('100')
1299
+ """
1300
+ inverted = PortfolioBalances()
1301
+ for label, wtbalances in dict.items(self):
1302
+ for wallet, tbalances in wtbalances.items():
1303
+ if tbalances:
1304
+ inverted[wallet][label] += tbalances # type: ignore [operator]
1305
+ return inverted
1306
+
1307
+ def __bool__(self) -> bool:
1308
+ """
1309
+ Evaluates the truth value of the :class:`~eth_portfolio.typing.PortfolioBalancesByCategory` object.
1310
+
1311
+ Returns:
1312
+ True if any category has a non-zero balance, otherwise False.
1313
+
1314
+ Example:
1315
+ >>> pb_by_category = PortfolioBalancesByCategory()
1316
+ >>> bool(pb_by_category)
1317
+ False
1318
+ """
1319
+ return any(dict.values(self))
1320
+
1321
+ def __repr__(self) -> str:
1322
+ """
1323
+ Returns a string representation of the :class:`~eth_portfolio.typing.PortfolioBalancesByCategory` object.
1324
+
1325
+ Returns:
1326
+ The string representation of the portfolio balances by category.
1327
+ """
1328
+ return f"PortfolioBalancesByCategory{dict(self)}"
1329
+
1330
+ def __add__(self, other: "PortfolioBalancesByCategory") -> "PortfolioBalancesByCategory":
1331
+ """
1332
+ Adds another :class:`~eth_portfolio.typing.PortfolioBalancesByCategory` object to this one.
1333
+
1334
+ Args:
1335
+ other: Another :class:`~eth_portfolio.typing.PortfolioBalancesByCategory` object.
1336
+
1337
+ Returns:
1338
+ A new :class:`~eth_portfolio.typing.PortfolioBalancesByCategory` object with the combined balances.
1339
+
1340
+ Raises:
1341
+ TypeError: If the other object is not a :class:`~eth_portfolio.typing.PortfolioBalancesByCategory`.
1342
+
1343
+ Example:
1344
+ >>> pb_by_category1 = PortfolioBalancesByCategory({'assets': WalletBalancesRaw({'0x123': TokenBalances({'0x123': Balance(Decimal('100'), Decimal('2000'))})})})
1345
+ >>> pb_by_category2 = PortfolioBalancesByCategory({'assets': WalletBalancesRaw({'0x123': TokenBalances({'0x123': Balance(Decimal('50'), Decimal('1000'))})})})
1346
+ >>> combined_pb_by_category = pb_by_category1 + pb_by_category2
1347
+ >>> combined_pb_by_category['assets']['0x123']['0x123'].balance
1348
+ Decimal('150')
1349
+ """
1350
+ if not isinstance(other, PortfolioBalancesByCategory):
1351
+ raise TypeError(f"{other} is not a PortfolioBalancesByCategory object")
1352
+
1353
+ block = self.block
1354
+ if block != other.block:
1355
+ raise ValueError(
1356
+ f"These PortfolioBalancesByCategory objects are not from the same block ({block} and {other.block})"
1357
+ )
1358
+
1359
+ combined: PortfolioBalancesByCategory = PortfolioBalancesByCategory(block=block)
1360
+ for protocol, balances in dict.items(self):
1361
+ if balances:
1362
+ combined[protocol] += balances
1363
+ for protocol, balances in dict.items(other):
1364
+ if balances:
1365
+ combined[protocol] += balances
1366
+ return combined
1367
+
1368
+ def __iadd__(self, other: "PortfolioBalancesByCategory") -> "PortfolioBalancesByCategory":
1369
+
1370
+ if not isinstance(other, PortfolioBalancesByCategory):
1371
+ raise TypeError(f"{other} is not a PortfolioBalancesByCategory object")
1372
+
1373
+ if self.block != other.block:
1374
+ raise ValueError(
1375
+ f"These PortfolioBalancesByCategory objects are not from the same block ({self.block} and {other.block})"
1376
+ )
1377
+
1378
+ for protocol, balances in dict.items(other):
1379
+ if balances:
1380
+ self[protocol] += balances
1381
+ return self
1382
+
1383
+ def __sub__(self, other: "PortfolioBalancesByCategory") -> "PortfolioBalancesByCategory":
1384
+ """
1385
+ Subtracts another :class:`~eth_portfolio.typing.PortfolioBalancesByCategory` object from this one.
1386
+
1387
+ Args:
1388
+ other: Another :class:`~eth_portfolio.typing.PortfolioBalancesByCategory` object.
1389
+
1390
+ Returns:
1391
+ A new :class:`~eth_portfolio.typing.PortfolioBalancesByCategory` object with the subtracted balances.
1392
+
1393
+ Raises:
1394
+ TypeError: If the other object is not a :class:`~eth_portfolio.typing.PortfolioBalancesByCategory`.
1395
+
1396
+ Example:
1397
+ >>> pb_by_category1 = PortfolioBalancesByCategory({'assets': WalletBalancesRaw({'0x123': TokenBalances({'0x123': Balance(Decimal('100'), Decimal('2000'))})})})
1398
+ >>> pb_by_category2 = PortfolioBalancesByCategory({'assets': WalletBalancesRaw({'0x123': TokenBalances({'0x123': Balance(Decimal('50'), Decimal('1000'))})})})
1399
+ >>> result_pb_by_category = pb_by_category1 - pb_by_category2
1400
+ >>> result_pb_by_category['assets']['0x123']['0x123'].balance
1401
+ Decimal('50')
1402
+ """
1403
+ if not isinstance(other, PortfolioBalancesByCategory):
1404
+ raise TypeError(f"{other} is not a PortfolioBalancesByCategory object")
1405
+ if self.block != other.block:
1406
+ raise ValueError(
1407
+ f"These PortfolioBalancesByCategory objects are not from the same block ({self.block} and {other.block})"
1408
+ )
1409
+ # NOTE We need a new object to avoid mutating the inputs
1410
+ subtracted: PortfolioBalancesByCategory = PortfolioBalancesByCategory(
1411
+ self, block=self.block
1412
+ )
1413
+ for protocol, balances in dict.items(other):
1414
+ subtracted[protocol] -= balances
1415
+ for protocol, balances in subtracted.items():
1416
+ if not balances:
1417
+ del subtracted[protocol]
1418
+ return subtracted