diff --git a/liquidation/__main__.py b/liquidation/__main__.py index ee2a2f4..eb2061a 100644 --- a/liquidation/__main__.py +++ b/liquidation/__main__.py @@ -52,6 +52,7 @@ def main(): strategy = FullLiquidationPercentMAEStrategy(DMAE, reseller.validate_position) logger.info("Found %d liquidatable accounts", len(accounts)) + liquidated: list[LiquidatingAccountContext] = [] for account in accounts: logger.info("%s - processing Liquidation", account.account_id) account.populate() @@ -59,7 +60,11 @@ def main(): "%s - number of positions %d", account.account_id, len(account.positions) ) - liquidation_started, dmae, dmmu = account.start_liquidation(strategy) + liquidation_started, request_failed, dmae, dmmu = account.start_liquidation(strategy) + if request_failed: + logger.info("%s - cannot initiate liquidation, skipping...", account.account_id) + continue + if liquidation_started: logger.info("%s - requested liquidation", account.account_id) else: @@ -95,9 +100,28 @@ def main(): account.account_id, format_int(mae_offered, account.collateral_decimals), ) - bids = FullLiquidationPercentMAEStrategy( - DMAE, reseller.validate_position - ).construct_bids(account.positions) + skip, blocks_to_wait = account.wait_time_for(dmae, dmmu, mae_offered, account.auction_data.mmu_now) + if skip: + logger.info("%s - Delta MAE is too big", account.account_id) + continue + bid_possible, bids = strategy.construct_bids( + format_int( + account.auction_data.mae_at_initiation, + account.collateral_decimals + ), + account.positions + ) + if (not bid_possible) or (len(bids) == 0): + logger.info("%s - no valid bid constructed, skipping...", account.account_id) + continue + logger.info( + "%s - waiting for %s blocks before liquidation", + account.account_id, + blocks_to_wait, + ) + ## wait an extra block to ensure we are clear + wait_for_blocks(w3, blocks_to_wait + 1) + mae_check_failed, mae_over_mmu_exceeded = clearing.mae_check_on_bid( w3.eth.default_account, account.account_id, @@ -112,32 +136,29 @@ def main(): mae_over_mmu_exceeded, ) continue - blocks_to_wait = account.wait_time_for(dmae, dmmu) - logger.info( - "%s - waiting for %s before liquidation", - account.account_id, - blocks_to_wait, - ) - ## wait an extra block to ensure we are clear - wait_for_blocks(w3, blocks_to_wait + 1) - account.bid_liquidation(strategy) + if account.bid_liquidation(strategy): + liquidated.append(account) - if len(accounts) > 0: - content = f"Liquidation processed for {len(accounts)} margin accounts" + if len(liquidated) > 0: + content = f"Liquidation processed for {len(liquidated)} margin accounts" notify_data = [ NotificationItem( title=f"Account {account.account_id} liquidated", values={ "Account ID": account.account_id, "Collateral Asset": account.collateral_asset, - "MMU (before)": Decimal(account.auction_data.mae_at_initiation) - / Decimal(10**account.collateral_decimals), - "MAE (before)": Decimal(account.auction_data.mae_at_initiation) - / Decimal(10**account.collateral_decimals), + "MMU (before)": format_int( + account.auction_data.mmu_at_initiation, + account.collateral_decimals + ), + "MAE (before)": format_int( + account.auction_data.mae_at_initiation, + account.collateral_decimals + ), "Positions": str(len(account.positions)), }, ) - for account in accounts + for account in liquidated ] notifier.notify( "Margin Accounts Liquidated", @@ -145,7 +166,7 @@ def main(): notify_data, ) - logger.info("Liquidation bids submitted for %d margin accounts", len(accounts)) + logger.info("Liquidation bids submitted for %d margin accounts", len(liquidated)) # Here we check all margin accounts for positions, just in case some accounts were not liquidated # in previous runs reseller.populate() diff --git a/liquidation/bid.py b/liquidation/bid.py index 6328d85..b85b7e5 100644 --- a/liquidation/bid.py +++ b/liquidation/bid.py @@ -1,5 +1,6 @@ from abc import ABC, abstractmethod from decimal import Decimal +import math from typing import Callable import afp.bindings @@ -18,14 +19,16 @@ class BidStrategy(ABC): """ @abstractmethod - def construct_bids(self, positions: list[Position]) -> list[afp.bindings.BidData]: + def construct_bids(self, mae_initial: Decimal, positions: list[Position]) -> (bool, list[afp.bindings.BidData]): """ Construct bids for the liquidating account based on its positions. Args: + mae_initial (Decimal): Initial MAE of the account without the collateral token precision positions (list[Position]): List of positions to construct bids for. Returns: + bool: True if bid construction is possible, False otherwise list[afp.bindings.BidData]: List of bid data objects. """ pass @@ -46,14 +49,16 @@ def __init__(self, position_validator: Callable[[HexBytes], bool]): """ self.position_validator = position_validator - def construct_bids(self, positions: list[Position]) -> list[afp.bindings.BidData]: + def construct_bids(self, mae_initial: Decimal, positions: list[Position]) -> (bool, list[afp.bindings.BidData]): """ Construct bids for every position held by the liquidating account at the mark price. Args: + mae_initial (Decimal): Initial MAE of the account without the collateral token precision positions (list[Position]): List of positions to construct bids for. Returns: + bool: True if bid construction is possible, False otherwise list[afp.bindings.BidData]: List of bid data objects. """ bids = [] @@ -61,8 +66,8 @@ def construct_bids(self, positions: list[Position]) -> list[afp.bindings.BidData if not self.position_validator(position.position_id): continue mark_price = position.mark_price - quantity = -position.quantity - side = afp.bindings.Side.BID if quantity < 0 else afp.bindings.Side.ASK + quantity = position.quantity + side = afp.bindings.Side.BID if quantity > 0 else afp.bindings.Side.ASK bids.append( afp.bindings.BidData( product_id=position.position_id, @@ -71,7 +76,7 @@ def construct_bids(self, positions: list[Position]) -> list[afp.bindings.BidData side=side, ) ) - return bids + return True, bids class FullLiquidationPercentMAEStrategy(BidStrategy): @@ -82,6 +87,8 @@ class FullLiquidationPercentMAEStrategy(BidStrategy): reducing the MAE of the liquidating account by percent_mae. """ + percent_mae: Decimal + def __init__( self, percent_mae: Decimal, position_validator: Callable[[HexBytes], bool] ): @@ -93,40 +100,106 @@ def __init__( self.percent_mae = percent_mae self.position_validator = position_validator - def construct_bids(self, positions: list[Position]) -> list[afp.bindings.BidData]: + def construct_bids(self, mae_initial: Decimal, positions: list[Position]) -> (bool, list[afp.bindings.BidData]): """ Construct bids for full liquidation based on a percentage of the mark price. Args: + mae_initial (Decimal): Initial MAE of the account without the collateral token precision positions (list[Position]): List of positions to construct bids for. Returns: + bool: True if bid construction is possible, False otherwise list[afp.bindings.BidData]: List of bid data objects. """ bids = [] + skip_bids = [] + sum_notional_long = Decimal(0) + sum_notional_short = Decimal(0) for position in positions: if not self.position_validator(position.position_id): + skip_bids.append(True) + continue + if position.mark_price == 0: + # just a safety check for further calculation + raise RuntimeError("Got zero mark price for position ", position.position_id) + + if position.quantity > 0: + sum_notional_long += position.notional_at_mark() + else: + sum_notional_short += position.notional_at_mark() + + skip_bids.append(False) + + # we calculate a constant difference percentage between mark price and bid price for all positions + # so that we take `mae_initial * percent_mae` amount of MAE from the liquidating account + # + # the equation we solve here is the following: + # `percent_dmark_long * sum_notional_long + percent_dmark_short * sum_notional_short >= dmae` + # we solve this assuming `percent_dmark_long = percent_dmark_short = percent_dmark` + sum_notional = sum_notional_long + sum_notional_short + dmae = mae_initial * self.percent_mae + percent_dmark = dmae / sum_notional + + if percent_dmark > Decimal(1): + # for long positions, bid price cannot differ more than 100% from mark price + # we can use different percentage for long and short positions to compensate this + # we will take 100% for long, which means the bid price will be zero + for index, position in enumerate(positions): + if skip_bids[index]: + continue + + if position.quantity < 0: + continue + + bids.append( + afp.bindings.BidData( + product_id=position.position_id, + quantity=abs(position.quantity), + price=0, + side=afp.bindings.Side.BID, + ) + ) + skip_bids[index] = True + + # for short, we will calculate the price difference percentage from here + # as we assumed `percent_dmark_long = 1`, we get `percent_dmark_short * sum_notional_short >= dmae - sum_notional_long` + # so we update `sum_notional` and `dmae` accordingly + sum_notional = sum_notional_short + dmae -= sum_notional_long + percent_dmark = dmae / sum_notional + + for index, position in enumerate(positions): + if skip_bids[index]: continue - mark_price = Decimal(position.mark_price) / Decimal(10**position.tick_size) - tick_size = position.tick_size quantity = position.quantity + mark_price = position.mark_price side = afp.bindings.Side.BID if quantity > 0 else afp.bindings.Side.ASK bid_price = ( - mark_price - * (1 - self.percent_mae / (abs(quantity) * position.point_value)) - if quantity > 0 - else mark_price - * (1 + self.percent_mae / (abs(quantity) * position.point_value)) + math.floor( + Decimal(mark_price) * (1 - percent_dmark) + ) if quantity > 0 + else math.ceil( + Decimal(mark_price) * (1 + percent_dmark) + ) ) bids.append( afp.bindings.BidData( product_id=position.position_id, quantity=abs(quantity), - price=parse_decimal(bid_price, tick_size), + price=bid_price, side=side, ) ) - return bids + + # update `dmae` and `sum_notional` and calculate `percent_dmark` again + dmae -= position.dmae(bid_price) + sum_notional -= position.notional_at_mark() + if dmae < Decimal(0): + percent_dmark = Decimal(0) + elif sum_notional > Decimal(0): + percent_dmark = dmae / sum_notional + return True, bids class OrderedPercentMAEStrategy(BidStrategy): @@ -135,6 +208,6 @@ class OrderedPercentMAEStrategy(BidStrategy): def __init__(self, percent_mae: Decimal): self.percent_mae = percent_mae - def construct_bids(self, positions: list[Position]) -> list[afp.bindings.BidData]: + def construct_bids(self, mae_initial: Decimal, positions: list[Position]) -> (bool, list[afp.bindings.BidData]): # ToDo: bid the largest positions raise NotImplementedError("OrderedPercentMAEStrategy not implemented yet.") diff --git a/liquidation/model.py b/liquidation/model.py index 0408fcd..3a6717a 100644 --- a/liquidation/model.py +++ b/liquidation/model.py @@ -35,3 +35,15 @@ def __init__( self.tick_size = tick_size self.point_value = point_value + def notional_at_mark(self) -> Decimal: + return Decimal(self.mark_price * abs(self.quantity)) * self.point_value / Decimal(10**self.tick_size) + + def notional_at_price(self, price: int) -> Decimal: + return Decimal(price * abs(self.quantity)) * self.point_value / Decimal(10**self.tick_size) + + # Returns the amount of MAE (without the collateral token precision) decreased with this `bid_price` + def dmae(self, bid_price: int) -> Decimal: + return ( + self.notional_at_mark() - self.notional_at_price(bid_price) if self.quantity > 0 + else self.notional_at_price(bid_price) - self.notional_at_mark() + ) diff --git a/liquidation/service.py b/liquidation/service.py index c553a52..0db7d27 100644 --- a/liquidation/service.py +++ b/liquidation/service.py @@ -13,6 +13,7 @@ from .bid import BidStrategy from .model import Position, Step, TransactionStep +from utils import format_int logger = logging.getLogger(__name__) @@ -117,33 +118,52 @@ def is_transaction_submitted(self, step: Step) -> bool: """ return any(submitted.step == step for submitted in self.transaction_steps) - def start_liquidation(self, strategy: BidStrategy) -> (bool, Decimal, Decimal): + def start_liquidation(self, strategy: BidStrategy) -> (bool, bool, Decimal, Decimal): """ Initiate liquidation for the account if not already in progress. Returns: bool: True if liquidation was started, False otherwise. + bool: True if liquidation initiation failed, False otherwise. Decimal: Mae delta after bid Decimal: Mmu delta after bid """ - dmae, dmmu = self._check_bids(strategy.construct_bids(self.positions)) + mae_at_initiation: Decimal = 0 if self.is_liquidating: - return False, dmae, dmmu + mae_at_initiation = format_int(self.auction_data.mae_at_initiation, self.collateral_decimals) + else: + mae_at_initiation = format_int( + afp.bindings.MarginAccount(self.w3, self.margin_account).mae(self.account_id), + self.collateral_decimals + ) + + bid_possible, bids = strategy.construct_bids(mae_at_initiation, self.positions) + if not bid_possible: + return False, True, 0, 0 + + check_passed, dmae, dmmu = self._check_bids(bids) + if not check_passed: + return False, True, dmae, dmmu + if self.is_liquidating: + return False, False, dmae, dmmu if self.is_transaction_submitted(Step.REQUEST_LIQUIDATION): logger.info("%s - liquidation already requested", self.account_id) - return False, dmae, dmmu + return False, False, dmae, dmmu clearing = afp.bindings.ClearingDiamond(self.w3) fn = clearing.request_liquidation(self.account_id, self.collateral_asset) tx_hash = fn.transact() + receipt = self.w3.eth.wait_for_transaction_receipt(tx_hash) + # if the transaction is not successful, we skip this account + if receipt['status'] != 1: + return False, True, dmae, dmmu self.transaction_steps.append( TransactionStep(Step.REQUEST_LIQUIDATION, tx_hash) ) - self.w3.eth.wait_for_transaction_receipt(tx_hash) self.is_liquidating = True self.auction_data = clearing.auction_data( self.account_id, self.collateral_asset ) - return True, dmae, dmmu + return True, False, dmae, dmmu def bid_liquidation(self, strategy: BidStrategy) -> bool: """ @@ -161,13 +181,22 @@ def bid_liquidation(self, strategy: BidStrategy) -> bool: logger.info("%s - liquidation already in progress", self.account_id) return False clearing = afp.bindings.ClearingDiamond(self.w3) - bids = strategy.construct_bids(self.positions) - if len(bids) == 0: + bid_possible, bids = strategy.construct_bids( + format_int( + self.auction_data.mae_at_initiation, + self.collateral_decimals + ), + self.positions + ) + if (not bid_possible) or (len(bids) == 0): logger.info( "%s - no valid bids constructed, skipping liquidation", self.account_id ) return False - self._check_bids(bids) + check_passed, _, _ = self._check_bids(bids) + if not check_passed: + return False + fn = clearing.bid_auction(self.account_id, self.collateral_asset, bids) tx_hash = fn.transact() logger.info( @@ -177,7 +206,12 @@ def bid_liquidation(self, strategy: BidStrategy) -> bool: ) self.transaction_steps.append(TransactionStep(Step.BID_AUCTION, tx_hash)) receipt = self.w3.eth.wait_for_transaction_receipt(tx_hash) - logging.info( + + if receipt['status'] != 1: + logger.info("%s - liquidation tx %s reverted", self.account_id, tx_hash.hex()) + return False + + logger.info( "%s - liquidation tx mined in block %d", self.account_id, receipt["blockNumber"], @@ -185,7 +219,7 @@ def bid_liquidation(self, strategy: BidStrategy) -> bool: logger.info("%s - liquidation processed", self.account_id) return True - def _check_bids(self, bids: list[afp.bindings.BidData]) -> (Decimal, Decimal): + def _check_bids(self, bids: list[afp.bindings.BidData]) -> (bool, Decimal, Decimal): margin_account = afp.bindings.MarginAccount(self.w3, self.margin_account) mae_before, mmu_before = ( margin_account.mae(self.account_id), @@ -212,6 +246,8 @@ def _check_bids(self, bids: list[afp.bindings.BidData]) -> (Decimal, Decimal): Decimal(mae_before - mae_after) / Decimal(10**self.collateral_decimals), Decimal(mmu_before - mmu_after) / Decimal(10**self.collateral_decimals), ) + if mae_after < 0: + return False, dmae, dmmu logger.info( "%s - MAE after bid: %s, MMU after bid: %s", self.account_id, @@ -222,36 +258,41 @@ def _check_bids(self, bids: list[afp.bindings.BidData]) -> (Decimal, Decimal): raise RuntimeError("MAE did not decrease after bid") if dmmu < Decimal(0): raise RuntimeError("MMU did not decrease after bid") - return dmae, dmmu + return True, dmae, dmmu - def wait_time_for(self, dmae: Decimal, dmmu: Decimal) -> int: + def wait_time_for(self, dmae: Decimal, dmmu: Decimal, max_mae_offered: int, mmu_now: int) -> (bool, int): """ Calculate the remaining wait time for the auction based on delta MAE and delta MMU. Args: dmae (Decimal): Delta MAE value. dmmu (Decimal): Delta MMU value. + max_mae_offered (int): Max MAE offered + mmu_now (int): MMU available Returns: + bool: True if delta MAE is too big to wait int: Number of blocks left to wait for the auction. """ + if dmae * Decimal(mmu_now) <= dmmu * Decimal(max_mae_offered): + return False, 0 + if self.auction_data is None: raise RuntimeError("Call populate() before calculating wait time.") - current_block = self.w3.eth.get_block("latest")["number"] - if current_block - self.auction_data.start_block > self.auction_duration: - # Auction duration has already passed - return 0 tau = Decimal(self.auction_duration) mmu_0 = Decimal(self.auction_data.mmu_at_initiation) mae_0 = Decimal(self.auction_data.mae_at_initiation) t = (dmae * tau * mmu_0) / (dmmu * mae_0) blocks_to_wait = math.ceil(t) + if blocks_to_wait > tau: + return True, 0 # this assumes 1 block per second + current_block = self.w3.eth.get_block("latest")["number"] blocks_left = blocks_to_wait - (current_block - self.auction_data.start_block) if blocks_left < 0: - return 0 - return blocks_left + return False, 0 + return False, blocks_left class LiquidationService: diff --git a/pyproject.toml b/pyproject.toml index bba5b75..8df1876 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "afp-agents" version = "0.1.0" -requires-python = ">=3.13" +requires-python = "==3.13" dependencies = [ "afp-sdk==0.4.0", "gql[all]>=3.5.3", diff --git a/uv.lock b/uv.lock index afaeb73..7af7d84 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,6 @@ version = 1 -revision = 2 -requires-python = ">=3.13" +revision = 3 +requires-python = "==3.13" [[package]] name = "abnf"