eth-portfolio 1.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


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

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