Source code for tradeexecutor.state.balance_update

"""Balance update data.

.. note ::

    These are not used by legacy wallet sync model, but only vault based wallets.

"""

import datetime
import enum
from _decimal import Decimal
from dataclasses import dataclass, field
from typing import Optional

from dataclasses_json import dataclass_json
from eth_defi.aave_v3.rates import SECONDS_PER_YEAR, SECONDS_PER_YEAR_INT

from tradeexecutor.state.identifier import AssetIdentifier
from tradingstrategy.types import USDollarAmount, Percent


[docs]class BalanceUpdateCause(enum.Enum): #: Reserve was deposited in the vault deposit = "deposit" #: User redeemed assets redemption = "redemption" #: Position value has change due to accrued interest #: #: #: interest = "interest" #: Accounting correction from on-chain balances to the state (internal ledger) #: correction = "correction"
[docs]class BalanceUpdatePositionType(enum.Enum): reserve = "reserve" open_position = "open_position"
[docs]@dataclass_json @dataclass class BalanceUpdate: """Processed balance update event. Events that are generated on - Deposits - Redemptions - Interest payments. There will be one event per rebase asset per a trading position. See :py:meth:`tradeexecutor.strategy.sync_model.SyncModel.sync_interests`. Events are stored in :py:class:`TradingPosition` and :py:class:`ReservePosition` by their id. Events are referred in :py:class:`tradeexecutor.sync.Treasury`. """ #: Allocated from portfolio #: #: This id is referred in :py:class:`tradeexecutor.state.position.TradingPosition` and :py:class:`tradeexecutor.state.reserve.ReservePosition` balance_update_id: int #: What caused the balance update event to happen cause: BalanceUpdateCause #: What kind of position this event modified position_type: BalanceUpdatePositionType #: Asset that was updated #: #: If this an interest event, this is aToken/vToken asset #: asset: AssetIdentifier #: When the balance event was generated #: #: The block mined timestamp block_mined_at: datetime.datetime #: When balance event was included to the strategy's treasury. #: #: The strategy cycle timestamp. #: #: It might be outside the cycle frequency if treasuries were processed #: in a cron job outside the cycle for slow moving strategies. #: #: For accounting corrections this is set to `None`. #: strategy_cycle_included_at: datetime.datetime | None #: Chain that updated the balance chain_id: int #: What was delta of the asset. #: #: Positive for deposits, negative for redemptions. #: quantity: Decimal #: What was the total of the asset in the position before this event was applied. #: old_balance: Decimal #: How much this deposit/redemption was worth #: #: Used for deposit/redemption inflow/outflow calculation. #: This is the asset value from our internal price keeping at the time of the event. #: usd_value: USDollarAmount #: Wall clock time when this event was created #: created_at: datetime.datetime | None = field(default_factory=datetime.datetime.utcnow) #: What was the event time of the previous update. #: #: This allows us to calculate the effective interest rate #: between the update cycles. #: #: This is the same as :py:attr:`block_mined_at` of the previous event. #: previous_update_at: datetime.datetime | None = None #: Investor address that the balance update is related to #: owner_address: Optional[str] = None #: Transaction that updated the balance #: #: Set None for interested calculation updates tx_hash: Optional[str] = None #: Log that updated the balance #: #: Set None for interest rate updates log_index: Optional[int] = None #: If this update was for open position #: #: Set None for reserve updates position_id: Optional[int] = None #: Human-readable notes regarding this event #: notes: Optional[str] = None #: Block number related to the event. #: #: Not always available. #: block_number: int | None = None def __post_init__(self): # TODO: Not sure what's going on here, # but temporarily allow negative rebases # We might get zero quantity events through # in some cases, though not sure what's the cause # assert self.quantity >= 0, f"Balance update cannot go negative: {self}" if self.previous_update_at: assert self.previous_update_at <= self.block_mined_at, f"Travelling back in time: {self.previous_update_at} - {self.block_mined_at}" if self.block_number is not None: assert type(self.block_number) == int, f"Got wrong type: {type(self.block_number)}" if self.block_mined_at is not None: assert isinstance(self.block_mined_at, datetime.datetime), f"Got wrong type: {self.block_mined_at.__class__}" def __repr__(self): if self.position_id: position_name = f"position #{self.position_id}" else: position_name = "strategy reserves" block_number = self.block_number or 0 return f"Funding event #{self.balance_update_id} {self.cause.name} {self.quantity} tokens for {position_name} at block {block_number:,} from {self.owner_address}" def __eq__(self, other: "BalanceUpdate"): assert isinstance(other, BalanceUpdate), f"Got {other}" match self.cause: case BalanceUpdateCause.deposit: return self.chain_id == other.chain_id and self.tx_hash == other.tx_hash and self.log_index == other.log_index case BalanceUpdateCause.redemption: return self.chain_id == other.chain_id and self.tx_hash == other.tx_hash and self.log_index == other.log_index and self.asset == other.asset case _: raise RuntimeError("Unsupported") def __hash__(self): match self.cause: case BalanceUpdateCause.deposit: return hash((self.chain_id, self.tx_hash, self.log_index)) case BalanceUpdateCause.redemption: return hash((self.chain_id, self.tx_hash, self.log_index, self.asset.address)) case _: raise RuntimeError("Unsupported")
[docs] def is_reserve_update(self) -> bool: """Return whether this event updates reserve balance or open position balance""" return self.position_type == BalanceUpdatePositionType.reserve
[docs] def get_update_period(self) -> datetime.timedelta | None: """How long it was between this event and previous sync event. :return: None if only inital update made """ if not self.previous_update_at: return None return (self.block_mined_at - self.previous_update_at)
[docs] def get_effective_yearly_yield(self, year=datetime.timedelta(seconds=SECONDS_PER_YEAR_INT)) -> Percent | None: """How much we are gaining % yearly. - Based on the this balance update and the previous balance update - Mostly useful for interest rate events - Calculated in tokens (exchange rate immune) :return: 1-based interest. E.g. 1.02 for 2% yearly gained interest. 0.9 for 10% yearly paid interest. Positive if we are gaining interest, negative if we are paying interest. ``None`` if no update period available """ period = self.get_update_period() if not period: return None gain = self.quantity / self.old_balance return float(gain) / (period / year)