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